diff --git a/config/knip.config.ts b/config/knip.config.ts index 5ce800eacc0..d4d0d586572 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -254,6 +254,9 @@ const rootToolingAndWorkspaceDependencies = [ // scripts/ui.js anchors these lookups at ui/package.json before invoking the UI workspace. "@vitest/browser-playwright", "dompurify", + // Root typecheck/test projects compile @openclaw/net-policy source directly. + // Keep its exact dependency available without externalizing it from packaged builds. + "ipaddr.js", "jscpd", "lit", "oxlint", diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 13164b0a326..eb97433f1eb 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -490,8 +490,6 @@ src/agents/model-auth-availability.ts src/agents/model-auth.profiles.test.ts src/agents/model-auth.test.ts src/agents/model-auth.ts -src/agents/model-catalog.test.ts -src/agents/model-catalog.ts src/agents/model-fallback.test.ts src/agents/model-fallback.ts src/agents/model-selection-shared.ts @@ -953,7 +951,6 @@ src/logging/logger.ts src/logging/redact.test.ts src/logging/redact.ts src/media-understanding/apply.test.ts -src/media-understanding/image.test.ts src/media-understanding/runner.entries.ts src/media-understanding/runner.ts src/media-understanding/runner.vision-skip.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index caf37717bfe..e1936e2fe94 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -17,8 +17,8 @@ ca9810b66aff3c8b60278b80d634c9c280322034964c05cb727d84252080aabf module/agent-h ec22d7a039fb58d0b8343ad149322960d3d8ca58b3f4c70f2fa8a099f8186d0c module/agent-harness-task-runtime 5f63bf587bf3547d59d0dc5d0dc2fee54745aa6edaab4aa3ae700dba03443edb module/agent-harness-tool-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload -56a1fabbb9edb9bc5de8e777f3cfad5924bfc76cc7d22be50ee53aa2a6947765 module/agent-runtime -fe45da8617366aec94d419f2e0ad5aea0f9f90ceb006bac9d0589cba2b11f078 module/agent-sessions +2c60564ae141dc249234e409994498f4143943d36c7860fa5759a63e4375e377 module/agent-runtime +d5191546cfd0b2dde39f6d9f5077ab1539aa359a90cf9a2ab13c5c19b7c948b9 module/agent-sessions dd9282f1eeadf44db2887599b52d80db7f5fb99c6d9eac720dbf1b77065f2145 module/allow-from 55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit 7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime @@ -205,14 +205,14 @@ b41a69c03c3a671a120963bebec65f379261220b72a4636242223d8bb15b830c module/plugin- 109f2b3b0984552e620adfb33c2d5dea6f70b4b440677e77d4fd89679e286683 module/plugin-state-runtime 1a5be86c0c661469b3ba07bb1c3c0db4d893cbe3042040c298a412bfffc10b5b module/poll-runtime c305e43d9cc43cd699759f36212527a1fc1f1c8d26cfa96425b0dae4a98e91b2 module/process-runtime -d1e5fc198e340e2cf547090454008a53533b61bb2709a49edbef79f5644a5505 module/provider-auth +f9717249bb66c5fff4fce145c260c5b8ac1d6063a03de6025afe19f90efd1ab6 module/provider-auth a5d3fbf94ef6b3186861c9db8510e749e6caf95a16f87a439c395fd8466ef45d module/provider-auth-api-key fbe62044a65a728bc6a89fabd80a2ecbc4f8199b4ca6ab15282035db0f76b0b3 module/provider-auth-login b20eeddd6af4ad509dc0802591180843873a9c6b337edab142a63869d270ebc1 module/provider-auth-login-flow-runtime b32f041136217c510e559e5fcf7ed4361cf51ebfad0fa15799d69fe539780feb module/provider-auth-result 3381bf9416f0fad6509b43cdf24cf32547f9fa18632ddb5755e3dd7212bd65a3 module/provider-auth-runtime 7a0adc27e12f90f5ab95f36d75fb208029ffc83c29af167ea3fa992273561023 module/provider-catalog-live-runtime -2b689b3714421f408a1d4eae36837bd557aa52744e7d323e6f72004f8efa0af0 module/provider-catalog-runtime +71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime ee5184ab251bcbe49a4cb9c845eee31975733bce1dc2300854cff14e99cf6db7 module/provider-catalog-shared ca5d408937bc0ff92021673320aeba6ae6fce705711b251f15797887b8f34d3a module/provider-entry 52085bfaa917f4fd262508a93af923e5e834901b5f0f017c93b02a0798073344 module/provider-env-vars diff --git a/docs/agent-runtime-architecture.md b/docs/agent-runtime-architecture.md index 7fd7cf2e4bf..c1459e952b3 100644 --- a/docs/agent-runtime-architecture.md +++ b/docs/agent-runtime-architecture.md @@ -49,6 +49,12 @@ Resource types not listed in a manifest fall back to discovery of conventional ` - `auto` selects a registered plugin harness that supports the effective provider route, otherwise the built-in OpenClaw runtime. A provider or model prefix alone never selects a harness. - OpenAI may select `codex` implicitly only for an exact official HTTPS Platform Responses or ChatGPT Responses route with no authored request override. Completions adapters, custom endpoints, and routes with authored request behavior stay on `openclaw`; plaintext official HTTP endpoints are rejected. See [OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). +## Model Runtime Generations + +Gateway startup and config, plugin, or auth publication build one prepared model runtime generation per configured agent. Each generation owns the discovered auth template, model registry, and projected model catalog as one atomic snapshot. Agent runs fork mutable auth and registry stores from that snapshot; browse, status, cron, doctor, TUI, PDF, and image paths read the published catalog instead of repeating filesystem discovery. + +Standalone embedded runtimes publish the same snapshot shape at their activation boundary. A failed or stale generation is never served alongside a newer partial generation; the lifecycle owner must publish a complete replacement first. + ## Related - [OpenClaw agent runtime workflow](/openclaw-agent-runtime) diff --git a/docs/docs_map.md b/docs/docs_map.md index c58a3a8beb7..040568906e2 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -17,6 +17,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Boundaries - H2: Manifests - H2: Runtime Selection + - H2: Model Runtime Generations - H2: Related ## announcements/bluebubbles-imessage.md diff --git a/extensions/discord/src/monitor/native-command-model-picker-ui.ts b/extensions/discord/src/monitor/native-command-model-picker-ui.ts index a561ffa13ab..ea8fd7da86a 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-ui.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-ui.ts @@ -185,7 +185,12 @@ export async function resolveDiscordNativeChoiceContext(params: { cfg: OpenClawConfig; accountId: string; threadBindings: ThreadBindingManager; -}): Promise<{ provider?: string; model?: string; agentRuntime?: string } | null> { +}): Promise<{ + provider?: string; + model?: string; + agentRuntime?: string; + agentId: string; +} | null> { try { const resolved = await resolveDiscordModelPickerRouteState({ interaction: params.interaction, @@ -217,6 +222,7 @@ export async function resolveDiscordNativeChoiceContext(params: { return { provider, model, + agentId: route.agentId, agentRuntime: resolveEffectiveAgentRuntime({ cfg: params.cfg, provider, diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts index 1f7c2384849..56963389d7f 100644 --- a/extensions/discord/src/monitor/native-command.options.test.ts +++ b/extensions/discord/src/monitor/native-command.options.test.ts @@ -35,7 +35,9 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => { }); vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ - loadModelCatalog: loadModelCatalogMock, + getPreparedModelCatalogSnapshot: loadModelCatalogMock, + resolveAgentDir: (_cfg: OpenClawConfig, agentId: string) => `/tmp/agents/${agentId}/agent`, + resolveAgentWorkspaceDir: (_cfg: OpenClawConfig, agentId: string) => `/tmp/workspaces/${agentId}`, resolveHumanDelayConfig: () => undefined, })); @@ -231,7 +233,7 @@ describe("createDiscordNativeCommand option wiring", () => { beforeEach(() => { clearRuntimeConfigSnapshot(); - loadModelCatalogMock.mockReset().mockResolvedValue([]); + loadModelCatalogMock.mockReset().mockReturnValue({ entries: [], routeVariants: [] }); logVerboseMock.mockReset(); loggerWarnMock.mockReset(); }); @@ -284,7 +286,6 @@ describe("createDiscordNativeCommand option wiring", () => { focusedValue: "", }); - expect(loadModelCatalogMock).toHaveBeenCalledWith({ cacheOnly: true }); expect(loadModelCatalogMock).toHaveBeenCalledWith({ config: cfg }); }); @@ -318,6 +319,7 @@ describe("createDiscordNativeCommand option wiring", () => { resolveChoiceContext: async () => ({ provider: "openai", model: "gpt-5.6-luna", + agentId: "agent-a", agentRuntime, }), }); @@ -336,6 +338,11 @@ describe("createDiscordNativeCommand option wiring", () => { const codexRespond = await runAutocomplete(autocomplete, params); expect(codexRespond).toHaveBeenCalledWith([{ name: "max", value: "max" }]); + expect(loadModelCatalogMock).toHaveBeenCalledWith({ + config: {}, + agentId: "agent-a", + agentDir: "/tmp/agents/agent-a/agent", + }); agentRuntime = "openclaw"; const openclawRespond = await runAutocomplete(autocomplete, params); diff --git a/extensions/discord/src/monitor/native-command.options.ts b/extensions/discord/src/monitor/native-command.options.ts index 4c53922c6b0..df5d894ac70 100644 --- a/extensions/discord/src/monitor/native-command.options.ts +++ b/extensions/discord/src/monitor/native-command.options.ts @@ -1,6 +1,9 @@ // Discord plugin module implements native command.options behavior. import { ApplicationCommandOptionType } from "discord-api-types/v10"; -import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime"; +import { + getPreparedModelCatalogSnapshot, + resolveAgentDir, +} from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveCommandArgChoices, @@ -62,9 +65,12 @@ export function buildDiscordCommandOptions(params: { cfg: OpenClawConfig; resolveConfig?: () => OpenClawConfig; authorizeChoiceContext?: (interaction: AutocompleteInteraction) => Promise; - resolveChoiceContext?: ( - interaction: AutocompleteInteraction, - ) => Promise<{ provider?: string; model?: string; agentRuntime?: string } | null>; + resolveChoiceContext?: (interaction: AutocompleteInteraction) => Promise<{ + provider?: string; + model?: string; + agentRuntime?: string; + agentId?: string; + } | null>; }): CommandOptions | undefined { const { command, cfg, resolveConfig, authorizeChoiceContext, resolveChoiceContext } = params; const commandLabel = resolveDiscordCommandLogLabel(command); @@ -119,10 +125,18 @@ export function buildDiscordCommandOptions(params: { ? await resolveChoiceContext(interaction) : null; const currentCfg = resolveConfig?.() ?? cfg; - // Autocomplete cannot defer beyond Discord's three-second deadline. - // Cache-only catalog reads never start discovery or filesystem work. const choiceCatalog = - command.key === "think" ? await loadModelCatalog({ cacheOnly: true }) : undefined; + command.key === "think" + ? getPreparedModelCatalogSnapshot({ + config: currentCfg, + ...(context?.agentId + ? { + agentId: context.agentId, + agentDir: resolveAgentDir(currentCfg, context.agentId), + } + : {}), + })?.entries + : undefined; const choices = resolveCommandArgChoices({ command, arg, @@ -140,11 +154,6 @@ export function buildDiscordCommandOptions(params: { await interaction.respond( filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })), ); - if (command.key === "think" && !choiceCatalog?.length) { - // The interaction is acknowledged now, so a failed startup warmup can retry - // discovery without risking Discord's response deadline. - void loadModelCatalog({ config: currentCfg }); - } } : undefined; const choices = diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index 819d12a11ba..218cc8543ba 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -99,7 +99,11 @@ vi.mock("openclaw/plugin-sdk/conversation-binding-runtime", async () => { }); vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ + getPreparedModelCatalogSnapshot: vi.fn(() => ({ entries: [], routeVariants: [] })), + loadPreparedModelCatalog: vi.fn(async () => []), normalizeProviderId: (value: string) => value.trim().toLowerCase(), + resolveAgentDir: (_cfg: OpenClawConfig, agentId: string) => `/tmp/agents/${agentId}/agent`, + resolveAgentWorkspaceDir: (_cfg: OpenClawConfig, agentId: string) => `/tmp/workspaces/${agentId}`, resolveDefaultModelForAgent: (params: { cfg: OpenClawConfig }) => { const configuredModel = params.cfg.agents?.defaults?.model; const primary = @@ -313,6 +317,7 @@ describe("discord native /think autocomplete", () => { expect(context).toEqual({ provider: "openai", model: "gpt-5.4", + agentId: "main", agentRuntime: "codex", }); @@ -374,6 +379,7 @@ describe("discord native /think autocomplete", () => { expect(context).toEqual({ provider: "openai", model: "gpt-5.6-luna", + agentId: "main", agentRuntime: expectedRuntime, }); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 146a1a63872..d31290cfd9d 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -1,6 +1,6 @@ // Discord plugin module implements native command behavior. import { ApplicationCommandOptionType } from "discord-api-types/v10"; -import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime"; +import { loadPreparedModelCatalog, resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; @@ -488,7 +488,16 @@ async function dispatchDiscordCommandInteraction(params: { // Native /think must not wait on provider discovery; persisted rows retain its metadata. const menuModelCatalog = command.key === "think" && menuNeedsModelContext - ? await loadModelCatalog({ config: cfg, readOnly: true }) + ? await loadPreparedModelCatalog({ + config: cfg, + ...(menuModelContext?.agentId + ? { + agentId: menuModelContext.agentId, + agentDir: resolveAgentDir(cfg, menuModelContext.agentId), + } + : {}), + readOnly: true, + }) : undefined; const menu = resolveCommandArgMenu({ command, diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index f0cfd687ac5..0f856e10276 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -1,4 +1,3 @@ -import { loadModelCatalog } from "openclaw/plugin-sdk/agent-runtime"; // Discord provider module implements model/runtime integration. import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-contracts"; @@ -294,11 +293,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { let earlyGatewayEmitter = gatewaySupervisor?.emitter; let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined; try { - if (nativeEnabled && commandSpecs.some((command) => command.name === "think")) { - // Autocomplete cannot defer. Warm opportunistically before interactions begin, - // but never let provider discovery block Discord startup. - void loadModelCatalog({ config: cfg }); - } const { commands, components, modals } = createDiscordProviderInteractionSurface({ cfg, discordConfig: discordCfg, diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index 696ae34f62c..a0930428e42 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -17,7 +17,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { ); return { ...actual, - loadModelCatalog: vi.fn(async () => []), + loadPreparedModelCatalog: vi.fn(async () => []), }; }); diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 6adf182abe7..5c46178a9a1 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -1,7 +1,11 @@ // Slack plugin module implements slash behavior. import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; -import { loadModelCatalog, resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { + loadPreparedModelCatalog, + resolveAgentDir, + resolveDefaultModelForAgent, +} from "openclaw/plugin-sdk/agent-runtime"; import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound"; import { formatCommandArgMenuTitle, @@ -619,7 +623,16 @@ export async function registerSlackMonitorSlashCommands(params: { // Native /think must not wait on provider discovery; persisted rows retain its metadata. const menuModelCatalog = commandDefinition.key === "think" && menuNeedsModelContext - ? await loadModelCatalog({ config: cfg, readOnly: true }) + ? await loadPreparedModelCatalog({ + config: cfg, + ...(menuRoute + ? { + agentId: menuRoute.agentId, + agentDir: resolveAgentDir(cfg, menuRoute.agentId), + } + : {}), + readOnly: true, + }) : undefined; const menu = resolveCommandArgMenu({ command: commandDefinition, diff --git a/extensions/telegram/src/bot-message-dispatch.agent.runtime.ts b/extensions/telegram/src/bot-message-dispatch.agent.runtime.ts index 7146b020e9b..e4e5d42cbc3 100644 --- a/extensions/telegram/src/bot-message-dispatch.agent.runtime.ts +++ b/extensions/telegram/src/bot-message-dispatch.agent.runtime.ts @@ -1,7 +1,7 @@ // Telegram plugin module implements bot message dispatch.agent behavior. export { findModelInCatalog, - loadModelCatalog, + loadPreparedModelCatalog, modelSupportsVision, resolveAgentDir, resolveDefaultModelForAgent, diff --git a/extensions/telegram/src/bot-message-dispatch.test-harness.ts b/extensions/telegram/src/bot-message-dispatch.test-harness.ts index 47c3f222109..0820c9e6a69 100644 --- a/extensions/telegram/src/bot-message-dispatch.test-harness.ts +++ b/extensions/telegram/src/bot-message-dispatch.test-harness.ts @@ -104,6 +104,7 @@ const loadModelCatalogHoisted = vi.hoisted(() => vi.fn(async () => ({}))); const findModelInCatalogHoisted = vi.hoisted(() => vi.fn(() => null)); const modelSupportsVisionHoisted = vi.hoisted(() => vi.fn(() => false)); const resolveAgentDirHoisted = vi.hoisted(() => vi.fn(() => "/tmp/agent")); +const resolveAgentWorkspaceDirHoisted = vi.hoisted(() => vi.fn(() => "/tmp/workspace")); const resolveDefaultModelForAgentHoisted = vi.hoisted(() => vi.fn(() => ({ provider: "openai", model: "gpt-test" })), ); @@ -270,9 +271,10 @@ vi.mock("./bot-message-dispatch.runtime.js", () => ({ vi.mock("./bot-message-dispatch.agent.runtime.js", () => ({ findModelInCatalog: findModelInCatalogHoisted, - loadModelCatalog: loadModelCatalogHoisted, + loadPreparedModelCatalog: loadModelCatalogHoisted, modelSupportsVision: modelSupportsVisionHoisted, resolveAgentDir: resolveAgentDirHoisted, + resolveAgentWorkspaceDir: resolveAgentWorkspaceDirHoisted, resolveDefaultModelForAgent: resolveDefaultModelForAgentHoisted, })); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 5d7f477bbdc..eae5c642258 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -18,7 +18,7 @@ import { createTelegramDispatchStatus } from "./bot-message-dispatch-status.js"; import { runTelegramDispatchTurn } from "./bot-message-dispatch-turn.js"; import { findModelInCatalog, - loadModelCatalog, + loadPreparedModelCatalog, modelSupportsVision, resolveAgentDir, resolveDefaultModelForAgent, @@ -51,7 +51,12 @@ async function resolveStickerVisionSupport( agentId: string, ) { try { - const catalog = await loadModelCatalog({ config: cfg }); + const catalog = await loadPreparedModelCatalog({ + config: cfg, + agentId, + agentDir: resolveAgentDir(cfg, agentId), + readOnly: true, + }); const defaultModel = resolveDefaultModelForAgent({ cfg, agentId }); const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model); return entry ? modelSupportsVision(entry) : false; diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 35b721cb855..19c7579b0d0 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -199,7 +199,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async () => { ); return { ...actual, - loadModelCatalog: agentRuntimeMocks.loadModelCatalog, + loadPreparedModelCatalog: agentRuntimeMocks.loadModelCatalog, resolveDefaultModelForAgent: agentRuntimeMocks.resolveDefaultModelForAgent, }; }); @@ -1070,10 +1070,16 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(createTelegramPrivateCommandContext()); - expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith({ - config: cfg, - readOnly: true, - }); + expect(agentRuntimeMocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + agentDir: expect.any(String), + readOnly: true, + }), + ); + expect(agentRuntimeMocks.loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty( + "workspaceDir", + ); expectSendMessageCall({ sendMessage, chatId: 100, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 4ef232bd70b..9d56c7da1b9 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto"; import type { Bot, Context } from "grammy"; import { - loadModelCatalog, + loadPreparedModelCatalog, resolveAgentConfig, + resolveAgentDir, resolveDefaultModelForAgent, resolveThinkingDefaultWithRuntimeCatalog, } from "openclaw/plugin-sdk/agent-runtime"; @@ -436,7 +437,7 @@ async function resolveTelegramThinkMenuCurrentLevel(params: { model?: string; agentRuntime?: string; thinkingLevel?: string; - catalog: Awaited>; + catalog: Awaited>; }): Promise { const explicit = normalizeOptionalString(params.thinkingLevel); if (explicit) { @@ -457,7 +458,7 @@ async function resolveTelegramThinkMenuCurrentLevel(params: { provider: params.provider ?? defaultModel.provider, model: params.model ?? defaultModel.model, agentRuntime: params.agentRuntime, - loadModelCatalog: async () => params.catalog, + loadRuntimeCatalog: async () => params.catalog, }); } @@ -1493,7 +1494,12 @@ export const registerTelegramNativeCommands = ({ // Native /think must not wait on provider discovery; persisted rows retain its metadata. const menuModelCatalog = commandDefinition?.key === "think" && menuNeedsModelContext - ? await loadModelCatalog({ config: runtimeCfg, readOnly: true }) + ? await loadPreparedModelCatalog({ + config: runtimeCfg, + agentId: route.agentId, + agentDir: resolveAgentDir(runtimeCfg, route.agentId), + readOnly: true, + }) : undefined; const menu = commandDefinition ? resolveCommandArgMenu({ diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index 96bc4bb3a80..dce3e1f4783 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -305,6 +305,7 @@ vi.mock("./bot.agent.runtime.js", () => ({ vi.mock("./bot-handlers.agent.runtime.js", () => ({ resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), resolveDefaultAgentId: vi.fn(() => "default"), resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", @@ -314,9 +315,10 @@ vi.mock("./bot-handlers.agent.runtime.js", () => ({ vi.mock("./bot-message-dispatch.agent.runtime.js", () => ({ findModelInCatalog: vi.fn(() => undefined), - loadModelCatalog: vi.fn(async () => []), + loadPreparedModelCatalog: vi.fn(async () => []), modelSupportsVision: vi.fn(() => false), resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-test", diff --git a/extensions/telegram/src/sticker-cache.describe.test.ts b/extensions/telegram/src/sticker-cache.describe.test.ts index b51968be64b..f31a1892001 100644 --- a/extensions/telegram/src/sticker-cache.describe.test.ts +++ b/extensions/telegram/src/sticker-cache.describe.test.ts @@ -1,4 +1,4 @@ -// Telegram tests cover sticker cacheescribe plugin behavior. +// Telegram tests cover sticker cache describe plugin behavior. import { beforeEach, describe, expect, it, vi } from "vitest"; import { describeStickerImage } from "./sticker-cache.js"; @@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => { })), resolveAutoMediaKeyProviders: vi.fn(() => ["minimax-cn", "minimax"]), resolveDefaultMediaModel: vi.fn(() => "MiniMax-VL-01"), + resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), resolveDefaultModelForAgent: vi.fn(() => ({ provider: "minimax-cn", model: "MiniMax-M2.7", @@ -37,9 +39,11 @@ const mocks = vi.hoisted(() => { vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ findModelInCatalog: mocks.findModelInCatalog, - loadModelCatalog: mocks.loadModelCatalog, + loadPreparedModelCatalog: mocks.loadModelCatalog, modelSupportsVision: mocks.modelSupportsVision, resolveApiKeyForProvider: mocks.resolveApiKeyForProvider, + resolveAgentDir: mocks.resolveAgentDir, + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, resolveDefaultModelForAgent: mocks.resolveDefaultModelForAgent, })); diff --git a/extensions/telegram/src/sticker-cache.ts b/extensions/telegram/src/sticker-cache.ts index d8401966fa3..efa49713b34 100644 --- a/extensions/telegram/src/sticker-cache.ts +++ b/extensions/telegram/src/sticker-cache.ts @@ -3,10 +3,10 @@ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/agent-runtime"; import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime"; import { findModelInCatalog, - loadModelCatalog, + loadPreparedModelCatalog, modelSupportsVision, } from "openclaw/plugin-sdk/agent-runtime"; -import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveAgentDir, resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveAutoImageModel } from "openclaw/plugin-sdk/media-runtime"; import { @@ -57,7 +57,18 @@ export async function describeStickerImage(params: DescribeStickerParams): Promi let activeModel = undefined as { provider: string; model: string } | undefined; let catalog: ModelCatalogEntry[] = []; try { - catalog = await loadModelCatalog({ config: cfg }); + catalog = await loadPreparedModelCatalog({ + config: cfg, + ...(agentId + ? { + agentId, + agentDir: agentDir ?? resolveAgentDir(cfg, agentId), + } + : agentDir + ? { agentDir } + : {}), + readOnly: true, + }); const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model); const supportsVision = modelSupportsVision(entry); if (supportsVision) { diff --git a/extensions/telegram/src/sticker-vision.runtime.ts b/extensions/telegram/src/sticker-vision.runtime.ts index f2c4d576bfe..4c0b28b438e 100644 --- a/extensions/telegram/src/sticker-vision.runtime.ts +++ b/extensions/telegram/src/sticker-vision.runtime.ts @@ -1,8 +1,9 @@ // Telegram plugin module implements sticker vision behavior. import { findModelInCatalog, - loadModelCatalog, + loadPreparedModelCatalog, modelSupportsVision, + resolveAgentDir, resolveDefaultModelForAgent, } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; @@ -11,7 +12,16 @@ export async function resolveStickerVisionSupportRuntime(params: { cfg: OpenClawConfig; agentId?: string; }): Promise { - const catalog = await loadModelCatalog({ config: params.cfg }); + const catalog = await loadPreparedModelCatalog({ + config: params.cfg, + ...(params.agentId + ? { + agentId: params.agentId, + agentDir: resolveAgentDir(params.cfg, params.agentId), + } + : {}), + readOnly: true, + }); const defaultModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId, diff --git a/package.json b/package.json index 252dc8122a9..41f0d0ad6e4 100644 --- a/package.json +++ b/package.json @@ -2128,6 +2128,7 @@ "acorn": "8.17.0", "esbuild": "0.28.1", "fast-glob": "3.3.3", + "ipaddr.js": "2.4.0", "jscpd": "4.2.4", "jsdom": "29.1.1", "lit": "3.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28906008ff2..07ae6aeb755 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,6 +296,9 @@ importers: fast-glob: specifier: 3.3.3 version: 3.3.3 + ipaddr.js: + specifier: 2.4.0 + version: 2.4.0 jscpd: specifier: 4.2.4 version: 4.2.4 diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 240b8b8eb78..1b6f3dfa9ab 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -289,7 +289,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +9: outbound echo identity type and record/query helpers across // channel-outbound and its two compatibility barrels. // Net +1: public session catalog locator types after the protocol cleanup harvest. - 8190, + // +2: lifecycle-owned prepared model catalog sync and async readers. + 8192, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -337,7 +338,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +2: upstream-link registry write/delete for harness-owned session forks. // +1: config-backed main-session resolver for Gateway-hosted plugin services. // +6: outbound echo record/query helpers across channel-outbound and mirrors. - 4555, + // +2: lifecycle-owned prepared model catalog sync and async readers. + 4557, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( @@ -361,7 +363,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: AgentThinkingLevel mirrored by deprecated config-types. // +1: SwarmConfig mirrored by deprecated config-types. // +2: outbound echo helpers inherited by deprecated channel barrels. - 3016, + // +1: lifecycle-owned prepared model catalog contract mirrored by agent-runtime compat. + 3017, env, ), publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/agent-auth-discovery.ts b/src/agents/agent-auth-discovery.ts index 8f73d5a9f92..9a89afab2a4 100644 --- a/src/agents/agent-auth-discovery.ts +++ b/src/agents/agent-auth-discovery.ts @@ -21,6 +21,7 @@ import { /** Options for discovering credentials without prompting for secret material. */ export type DiscoverAuthStorageOptions = { externalCli?: ExternalCliAuthDiscovery; + inheritedAuthDir?: string; readOnly?: boolean; skipExternalAuthProfiles?: boolean; skipCredentials?: boolean; @@ -36,16 +37,21 @@ export function resolveAgentCredentialsForDiscovery( allowKeychainPrompt: false, ...(options?.config ? { config: options.config } : {}), ...(options?.externalCli ? { externalCli: options.externalCli } : {}), + ...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}), }; const store = options?.skipExternalAuthProfiles === true ? options.readOnly === true - ? loadAuthProfileStoreWithoutExternalProfiles(agentDir) + ? loadAuthProfileStoreWithoutExternalProfiles( + agentDir, + options.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : undefined, + ) : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { allowKeychainPrompt: false, + ...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}), }) : options?.readOnly === true - ? options.externalCli || options.config + ? options.externalCli || options.config || options.inheritedAuthDir ? loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, ...storeOptions }) : loadAuthProfileStoreForSecretsRuntime(agentDir) : ensureAuthProfileStore(agentDir, storeOptions); @@ -87,5 +93,3 @@ export function resolveAgentCredentialsForDiscovery( } return credentials; } - -export { addEnvBackedAgentCredentials } from "./agent-auth-discovery-core.js"; diff --git a/src/agents/agent-model-discovery.auth.test.ts b/src/agents/agent-model-discovery.auth.test.ts index 62042482517..dd5c9cfcd4e 100644 --- a/src/agents/agent-model-discovery.auth.test.ts +++ b/src/agents/agent-model-discovery.auth.test.ts @@ -302,6 +302,53 @@ describe("discoverAuthStorage", () => { }); }); + it("uses the lifecycle owner's explicit inherited auth directory", async () => { + await withAgentDir(async (inheritedAuthDir) => { + await withAgentDir(async (agentDir) => { + writeAuthProfilesSqlite(inheritedAuthDir, { + version: 1, + profiles: { + "inherited-provider:default": { + type: "api_key", + provider: "inherited-provider", + key: "inherited-key", + }, + "shared-provider:inherited": { + type: "api_key", + provider: "shared-provider", + key: "inherited-shared-key", + }, + }, + }); + writeAuthProfilesSqlite(agentDir, { + version: 1, + profiles: { + "shared-provider:local": { + type: "api_key", + provider: "shared-provider", + key: "local-shared-key", + }, + }, + }); + + const storage = discoverAuthStorage(agentDir, { + inheritedAuthDir, + skipExternalAuthProfiles: true, + env: {}, + }); + + expect(storage.get("inherited-provider")).toEqual({ + type: "api_key", + key: "inherited-key", + }); + expect(storage.get("shared-provider")).toEqual({ + type: "api_key", + key: "local-shared-key", + }); + }); + }); + }); + it("includes env-backed provider auth when no auth profile exists", () => { const previousMistral = process.env.MISTRAL_API_KEY; const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; diff --git a/src/agents/agent-model-discovery.ts b/src/agents/agent-model-discovery.ts index 5f49368eb15..0df997d801a 100644 --- a/src/agents/agent-model-discovery.ts +++ b/src/agents/agent-model-discovery.ts @@ -176,9 +176,3 @@ export function discoverModels( options, ); } - -export { - addEnvBackedAgentCredentials, - resolveAgentCredentialsForDiscovery, - type DiscoverAuthStorageOptions, -} from "./agent-auth-discovery.js"; diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 9a7fbfeeeb1..76fc57c9563 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -77,6 +77,7 @@ import { import type { ModelAuthMode } from "./model-auth.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; import { createOpenClawTools, filterToolsByClientCaps } from "./openclaw-tools.js"; +import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.js"; import type { SandboxContext } from "./sandbox.js"; import { SANDBOX_AGENT_WORKSPACE_MOUNT } from "./sandbox/constants.js"; import { resolveReadOnlyWorkspaceSkillMounts } from "./sandbox/workspace-mounts.js"; @@ -325,6 +326,7 @@ type OpenClawCodingToolsOptions = { /** Relative workspace path that memory-triggered writes may append to. */ memoryFlushWritePath?: string; agentDir?: string; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; /** Task working directory for coding tools. Defaults to workspaceDir. */ cwd?: string; workspaceDir?: string; @@ -964,6 +966,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) agentGroupSpace: options?.groupSpace ?? null, agentMemberRoleIds: options?.memberRoleIds, agentDir: options?.agentDir, + preparedModelRuntime: options?.preparedModelRuntime, sandboxRoot, sandboxContainerWorkdir: sandbox?.containerWorkdir, sandboxFsBridge, diff --git a/src/agents/auth-profiles/runtime-snapshots.test.ts b/src/agents/auth-profiles/runtime-snapshots.test.ts index b193faadd6f..1883fc7b2a3 100644 --- a/src/agents/auth-profiles/runtime-snapshots.test.ts +++ b/src/agents/auth-profiles/runtime-snapshots.test.ts @@ -11,6 +11,7 @@ import { getRuntimeAuthProfileStoreSnapshot, getRuntimeAuthProfileStoreCredentialsRevision, noteRuntimeAuthProfileStorePersistedMutation, + registerRuntimeAuthProfileStoreMutationListener, replaceRuntimeAuthProfileStoreSnapshots, setRuntimeAuthProfileStoreSnapshot, } from "./runtime-snapshots.js"; @@ -58,6 +59,89 @@ function expectOpenAICodexSnapshotCredential( } describe("runtime auth profile snapshots", () => { + it("notifies listeners only when credential ownership changes", () => { + const agentDir = "/tmp/openclaw-auth-runtime-listener"; + const listener = vi.fn(); + const unregister = registerRuntimeAuthProfileStoreMutationListener(listener); + try { + const store = createStore("listener"); + setRuntimeAuthProfileStoreSnapshot(store, agentDir); + setRuntimeAuthProfileStoreSnapshot( + { + ...store, + usageStats: { "openai:default": { lastUsed: 2 } }, + }, + agentDir, + ); + clearRuntimeAuthProfileStoreSnapshot(agentDir); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenNthCalledWith(1, { + agentDir, + affectsInheritedStores: false, + }); + expect(listener).toHaveBeenNthCalledWith(2, { + agentDir, + affectsInheritedStores: false, + }); + } finally { + unregister(); + clearRuntimeAuthProfileStoreSnapshots(); + } + }); + + it("notifies when provider credential order changes", () => { + const agentDir = "/tmp/openclaw-auth-runtime-order"; + const store = createStore("order"); + setRuntimeAuthProfileStoreSnapshot(store, agentDir); + const listener = vi.fn(); + const unregister = registerRuntimeAuthProfileStoreMutationListener(listener); + try { + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir, + store: { + ...store, + order: { openai: [] }, + }, + }, + ]); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith({ + affectsInheritedStores: true, + }); + } finally { + unregister(); + clearRuntimeAuthProfileStoreSnapshots(); + } + }); + + it("notifies when an empty runtime snapshot starts or stops shadowing persisted auth", () => { + const agentDir = "/tmp/openclaw-auth-runtime-empty-owner"; + const listener = vi.fn(); + const unregister = registerRuntimeAuthProfileStoreMutationListener(listener); + const emptyStore: AuthProfileStore = { version: 1, profiles: {} }; + try { + setRuntimeAuthProfileStoreSnapshot(emptyStore, agentDir); + setRuntimeAuthProfileStoreSnapshot(emptyStore, agentDir); + clearRuntimeAuthProfileStoreSnapshot(agentDir); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenNthCalledWith(1, { + agentDir, + affectsInheritedStores: false, + }); + expect(listener).toHaveBeenNthCalledWith(2, { + agentDir, + affectsInheritedStores: false, + }); + } finally { + unregister(); + clearRuntimeAuthProfileStoreSnapshots(); + } + }); + it("advances credential revision without coupling to usage bookkeeping", () => { const initialRevision = getRuntimeAuthProfileStoreCredentialsRevision(); const store = createStore("set"); diff --git a/src/agents/auth-profiles/runtime-snapshots.ts b/src/agents/auth-profiles/runtime-snapshots.ts index b8d55a3864a..fb50c268c60 100644 --- a/src/agents/auth-profiles/runtime-snapshots.ts +++ b/src/agents/auth-profiles/runtime-snapshots.ts @@ -9,6 +9,11 @@ import { resolveAuthStorePath } from "./path-resolve.js"; import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js"; const runtimeAuthStoreSnapshots = new Map(); +type RuntimeAuthProfileStoreMutationListener = (event: { + agentDir?: string; + affectsInheritedStores: boolean; +}) => void; +const runtimeAuthStoreMutationListeners = new Set(); let runtimeAuthStoreCredentialsRevision = 0; let runtimeAuthStoreSnapshotsRevision = 0; // Per-store generations isolate rollback ownership; the global counter remains @@ -110,6 +115,50 @@ function credentialState( .toSorted(([left], [right]) => left.localeCompare(right)); } +function ownerState( + store: RuntimeAuthProfileStore | undefined, +): + | Pick< + RuntimeAuthProfileStore, + | "order" + | "profiles" + | "runtimePersistedProfileIds" + | "runtimeExternalProfileIds" + | "runtimeExternalProfileIdsAuthoritative" + | "runtimeLocalProfileIds" + | "runtimeInheritsMainState" + > + | undefined { + if (!store) { + return undefined; + } + return { + order: store.order, + profiles: store.profiles, + runtimePersistedProfileIds: store.runtimePersistedProfileIds, + runtimeExternalProfileIds: store.runtimeExternalProfileIds, + runtimeExternalProfileIdsAuthoritative: store.runtimeExternalProfileIdsAuthoritative, + runtimeLocalProfileIds: store.runtimeLocalProfileIds, + runtimeInheritsMainState: store.runtimeInheritsMainState, + }; +} + +function replaceChangesOwner( + entries: Array<{ agentDir?: string; store: RuntimeAuthProfileStore }>, +): boolean { + const next = new Map( + entries.map((entry) => [resolveRuntimeStoreKey(entry.agentDir), entry.store] as const), + ); + const currentState = Array.from( + runtimeAuthStoreSnapshots, + ([key, store]) => [key, ownerState(store)] as const, + ).toSorted(([left], [right]) => left.localeCompare(right)); + const nextState = Array.from(next, ([key, store]) => [key, ownerState(store)] as const).toSorted( + ([left], [right]) => left.localeCompare(right), + ); + return !isDeepStrictEqual(currentState, nextState); +} + function replaceChangesCredentials( entries: Array<{ agentDir?: string; store: RuntimeAuthProfileStore }>, ): boolean { @@ -121,15 +170,17 @@ function replaceChangesCredentials( function recordChangedSnapshotRevisions( entries: Array<{ agentDir?: string; store: RuntimeAuthProfileStore }>, -): void { +): boolean { const next = new Map( entries.map((entry) => [resolveRuntimeStoreKey(entry.agentDir), entry.store] as const), ); const keys = new Set([...runtimeAuthStoreSnapshots.keys(), ...next.keys()]); + let changed = false; for (const key of keys) { if (isDeepStrictEqual(runtimeAuthStoreSnapshots.get(key), next.get(key))) { continue; } + changed = true; runtimeAuthStoreSnapshotsRevision += 1; if (next.has(key)) { runtimeAuthStoreSnapshotRevisions.set(key, runtimeAuthStoreSnapshotsRevision); @@ -137,6 +188,7 @@ function recordChangedSnapshotRevisions( runtimeAuthStoreSnapshotRevisions.delete(key); } } + return changed; } // Runtime snapshots are keyed by the resolved auth store path so default-agent @@ -145,6 +197,24 @@ function resolveRuntimeStoreKey(agentDir?: string): string { return resolveAuthStorePath(agentDir); } +function notifyRuntimeAuthStoreMutation(agentDir?: string): void { + const event = { + ...(agentDir ? { agentDir } : {}), + affectsInheritedStores: agentDir === undefined, + }; + for (const listener of runtimeAuthStoreMutationListeners) { + listener(event); + } +} + +/** Observes credential snapshot changes at their lifecycle publication edge. */ +export function registerRuntimeAuthProfileStoreMutationListener( + listener: RuntimeAuthProfileStoreMutationListener, +): () => void { + runtimeAuthStoreMutationListeners.add(listener); + return () => runtimeAuthStoreMutationListeners.delete(listener); +} + /** Reads a cloned runtime auth profile store snapshot for an agent dir. */ export function getRuntimeAuthProfileStoreSnapshot( agentDir?: string, @@ -186,7 +256,9 @@ export function hasAnyRuntimeAuthProfileStoreSource(agentDir?: string): boolean export function replaceRuntimeAuthProfileStoreSnapshots( entries: Array<{ agentDir?: string; store: RuntimeAuthProfileStore }>, ): void { - if (replaceChangesCredentials(entries)) { + const credentialsChanged = replaceChangesCredentials(entries); + const ownerChanged = replaceChangesOwner(entries); + if (credentialsChanged) { runtimeAuthStoreCredentialsRevision += 1; } recordChangedSnapshotRevisions(entries); @@ -197,18 +269,26 @@ export function replaceRuntimeAuthProfileStoreSnapshots( cloneAuthProfileStore(entry.store), ); } + if (ownerChanged) { + notifyRuntimeAuthStoreMutation(); + } } /** Clears all runtime auth profile snapshots. */ export function clearRuntimeAuthProfileStoreSnapshots(): void { - if (credentialState(runtimeAuthStoreSnapshots).length > 0) { + const snapshotsChanged = runtimeAuthStoreSnapshots.size > 0; + const credentialsChanged = credentialState(runtimeAuthStoreSnapshots).length > 0; + if (credentialsChanged) { runtimeAuthStoreCredentialsRevision += 1; } - if (runtimeAuthStoreSnapshots.size > 0) { + if (snapshotsChanged) { runtimeAuthStoreSnapshotsRevision += 1; } runtimeAuthStoreSnapshots.clear(); runtimeAuthStoreSnapshotRevisions.clear(); + if (snapshotsChanged) { + notifyRuntimeAuthStoreMutation(); + } } /** Clears one runtime auth-profile snapshot without disturbing other active agents. */ @@ -224,6 +304,7 @@ export function clearRuntimeAuthProfileStoreSnapshot(agentDir?: string): boolean runtimeAuthStoreSnapshotsRevision += 1; runtimeAuthStoreSnapshots.delete(key); runtimeAuthStoreSnapshotRevisions.delete(key); + notifyRuntimeAuthStoreMutation(agentDir); return true; } @@ -233,14 +314,26 @@ export function setRuntimeAuthProfileStoreSnapshot( agentDir?: string, ): void { const key = resolveRuntimeStoreKey(agentDir); - if (!isDeepStrictEqual(runtimeAuthStoreSnapshots.get(key)?.profiles ?? {}, store.profiles)) { + const credentialsChanged = !isDeepStrictEqual( + credentialState( + runtimeAuthStoreSnapshots.has(key) ? [[key, runtimeAuthStoreSnapshots.get(key)!]] : [], + ), + credentialState([[key, store]]), + ); + if (credentialsChanged) { runtimeAuthStoreCredentialsRevision += 1; } - if (!isDeepStrictEqual(runtimeAuthStoreSnapshots.get(key), store)) { + const previousStore = runtimeAuthStoreSnapshots.get(key); + const ownerChanged = !isDeepStrictEqual(ownerState(previousStore), ownerState(store)); + const snapshotChanged = !isDeepStrictEqual(previousStore, store); + if (snapshotChanged) { runtimeAuthStoreSnapshotsRevision += 1; runtimeAuthStoreSnapshotRevisions.set(key, runtimeAuthStoreSnapshotsRevision); } runtimeAuthStoreSnapshots.set(key, cloneAuthProfileStore(store)); + if (ownerChanged) { + notifyRuntimeAuthStoreMutation(agentDir); + } } /** @@ -297,6 +390,9 @@ export function noteRuntimeAuthProfileStorePersistedMutation( if (deletedDerivedSnapshot) { runtimeAuthStoreSnapshotsRevision += 1; } + if (mutation.credentialsChanged || mutation.profileSetChanged) { + notifyRuntimeAuthStoreMutation(agentDir); + } } export type RuntimeAuthProfileStoreMutationToken = { diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index d9e53bf3cf1..d05e15b0fce 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -58,6 +58,7 @@ type LoadAuthProfileStoreOptions = { config?: OpenClawConfig; database?: OpenClawAgentDatabase; externalCli?: ExternalCliAuthDiscovery; + inheritedAuthDir?: string; readOnly?: boolean; syncExternalCli?: boolean; externalCliProviderIds?: Iterable; @@ -241,11 +242,11 @@ function shouldUseMainOwnerForLocalOAuthCredential(params: { function resolveRuntimeAuthProfileStore( agentDir?: string, - options?: Pick, + options?: Pick, ): AuthProfileStore | null { - const mainKey = resolveAuthStorePath(undefined); + const mainKey = resolveAuthStorePath(options?.inheritedAuthDir); const requestedKey = resolveAuthStorePath(agentDir); - const mainStore = getRuntimeAuthProfileStoreSnapshotImpl(undefined); + const mainStore = getRuntimeAuthProfileStoreSnapshotImpl(options?.inheritedAuthDir); const requestedStore = getRuntimeAuthProfileStoreSnapshotImpl(agentDir); if (!agentDir || requestedKey === mainKey) { @@ -261,7 +262,7 @@ function resolveRuntimeAuthProfileStore( }); } if (requestedStore) { - const persistedMainStore = loadAuthProfileStoreForAgent(undefined, { + const persistedMainStore = loadAuthProfileStoreForAgent(options?.inheritedAuthDir, { readOnly: true, syncExternalCli: false, ...resolvePersistedLoadOptions(options), @@ -576,7 +577,7 @@ function buildLocalAuthProfileStoreForSave(params: { function buildAuthProfileStoreWithoutExternalProfiles(params: { store: AuthProfileStore; agentDir?: string; - options?: Pick; + options?: Pick; }): AuthProfileStore { const runtimeExternalProfileIds = new Set(params.store.runtimeExternalProfileIds ?? []); const localStore = cloneAuthProfileStore(params.store); @@ -953,7 +954,7 @@ export function loadAuthProfileStoreForRuntime( ): AuthProfileStore { const store = loadAuthProfileStoreForAgent(agentDir, options); const authPath = resolveAuthStorePath(agentDir); - const mainAuthPath = resolveAuthStorePath(); + const mainAuthPath = resolveAuthStorePath(options?.inheritedAuthDir); const externalCli = resolveExternalCliOverlayOptions(options); if (!agentDir || authPath === mainAuthPath) { return setRuntimeLocalProfileMetadata( @@ -965,7 +966,7 @@ export function loadAuthProfileStoreForRuntime( ); } - const mainStore = loadAuthProfileStoreForAgent(undefined, options); + const mainStore = loadAuthProfileStoreForAgent(options?.inheritedAuthDir, options); const mergedStore = mergeAuthProfileStores(mainStore, store, { preserveBaseRuntimeExternalProfiles: true, }); @@ -984,7 +985,11 @@ export function loadAuthProfileStoreForSecretsRuntime( agentDir?: string, options?: Pick< LoadAuthProfileStoreOptions, - "config" | "externalCli" | "externalCliProviderIds" | "externalCliProfileIds" + | "config" + | "externalCli" + | "externalCliProviderIds" + | "externalCliProfileIds" + | "inheritedAuthDir" >, ): AuthProfileStore { return loadAuthProfileStoreForRuntime(agentDir, { @@ -997,15 +1002,16 @@ export function loadAuthProfileStoreForSecretsRuntime( /** Load auth profiles with runtime external profiles removed from the result. */ export function loadAuthProfileStoreWithoutExternalProfiles( agentDir?: string, - loadOptions?: Pick, + loadOptions?: Pick, ): AuthProfileStore { const options: LoadAuthProfileStoreOptions = { readOnly: true, allowKeychainPrompt: loadOptions?.allowKeychainPrompt ?? false, + ...(loadOptions?.inheritedAuthDir ? { inheritedAuthDir: loadOptions.inheritedAuthDir } : {}), }; const store = loadAuthProfileStoreForAgent(agentDir, options); const authPath = resolveAuthStorePath(agentDir); - const mainAuthPath = resolveAuthStorePath(); + const mainAuthPath = resolveAuthStorePath(options.inheritedAuthDir); if (!agentDir || authPath === mainAuthPath) { return setRuntimeLocalProfileMetadata( stripRuntimeExternalProfileMetadata(store), @@ -1013,7 +1019,7 @@ export function loadAuthProfileStoreWithoutExternalProfiles( ); } - const mainStore = loadAuthProfileStoreForAgent(undefined, options); + const mainStore = loadAuthProfileStoreForAgent(options.inheritedAuthDir, options); const mergedStore = mergeAuthProfileStores(mainStore, store, { preserveBaseRuntimeExternalProfiles: true, }); @@ -1033,6 +1039,7 @@ export function ensureAuthProfileStore( externalCli?: ExternalCliAuthDiscovery; externalCliProviderIds?: Iterable; externalCliProfileIds?: Iterable; + inheritedAuthDir?: string; readOnly?: boolean; syncExternalCli?: boolean; }, @@ -1060,6 +1067,7 @@ export function ensureAuthProfileStoreWithoutExternalProfiles( agentDir?: string, options?: { allowKeychainPrompt?: boolean; + inheritedAuthDir?: string; readOnly?: boolean; syncExternalCli?: boolean; }, @@ -1077,12 +1085,15 @@ export function ensureAuthProfileStoreWithoutExternalProfiles( } const store = loadAuthProfileStoreForAgent(agentDir, effectiveOptions); const authPath = resolveAuthStorePath(agentDir); - const mainAuthPath = resolveAuthStorePath(); + const mainAuthPath = resolveAuthStorePath(effectiveOptions.inheritedAuthDir); if (!agentDir || authPath === mainAuthPath) { return stripRuntimeExternalProfileMetadata(store); } - const mainStore = loadAuthProfileStoreForAgent(undefined, effectiveOptions); + const mainStore = loadAuthProfileStoreForAgent( + effectiveOptions.inheritedAuthDir, + effectiveOptions, + ); return stripRuntimeExternalProfileMetadata( mergeAuthProfileStores(mainStore, store, { preserveBaseRuntimeExternalProfiles: true, diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index f1ebbe627b4..9deac179a9f 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -87,6 +87,40 @@ vi.mock("./sessions/model-registry-runtime.js", () => ({ getModelRegistryRuntime: (...args: unknown[]) => getModelRegistryRuntimeMock(...args), })); +vi.mock("./prepared-model-runtime.js", () => ({ + preparedModelRuntimeConfigsMatch: (left: unknown, right: unknown) => left === right, + loadPreparedModelRuntimeSnapshot: async (params: { + agentId?: string; + agentDir: string; + config: unknown; + inheritedAuthDir?: string; + workspaceDir?: string; + }) => { + const workspaceOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}; + await ensureOpenClawModelsJsonMock(params.config, params.agentDir, workspaceOptions); + const authStorage = discoverAuthStorageMock(params.agentDir, { + config: params.config, + ...(params.inheritedAuthDir ? { inheritedAuthDir: params.inheritedAuthDir } : {}), + ...workspaceOptions, + }); + const modelRegistry = discoverModelsMock(authStorage, params.agentDir, { + config: params.config, + ...workspaceOptions, + }); + return { + agentId: params.agentId, + agentDir: params.agentDir, + config: params.config, + workspaceDir: params.workspaceDir, + createStores: () => ({ authStorage, modelRegistry }), + }; + }, +})); + +vi.mock("./model-discovery-context.js", () => ({ + resolveModelPluginMetadataSnapshot: () => undefined, +})); + vi.mock("./embedded-agent-runner/model.js", () => ({ resolveModelAsync: (...args: unknown[]) => resolveModelAsyncMock(...args), resolveModelWithRegistry: (...args: unknown[]) => resolveModelWithRegistryMock(...args), @@ -174,6 +208,7 @@ vi.mock("./agent-scope.js", () => ({ resolveSessionAgentIds: (...args: unknown[]) => resolveSessionAgentIdsMock(...args), resolveSessionAgentId: (...args: unknown[]) => resolveSessionAgentIdMock(...args), resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args), + resolveDefaultAgentDir: () => "/tmp/agent", })); vi.mock("../plugins/provider-runtime.js", () => ({ diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 8fd9646997c..fc8b17f2c3d 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -20,8 +20,11 @@ import type { } from "../llm/types.js"; import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js"; import { isModelSelectionLocked } from "../sessions/model-overrides.js"; -import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; -import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentDir, + resolveSessionAgentId, +} from "./agent-scope.js"; import { resolveExternalCliAuthOverlayScopeFromSelection } from "./auth-profiles/external-cli-auth-selection.js"; import { resolveSessionAuthProfileOverride } from "./auth-profiles/session-override.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; @@ -59,11 +62,16 @@ import { isCliRuntimeAliasForProvider, resolveCliRuntimeExecutionProvider, } from "./model-runtime-aliases.js"; -import { ensureOpenClawModelsJson } from "./models-config.js"; import { isOpenAIProvider, listOpenAIAuthProfileProvidersForAgentRuntime, } from "./openai-routing.js"; +import { + loadPreparedModelRuntimeSnapshot, + preparedModelRuntimeConfigsMatch, + type PreparedModelRuntimeSnapshot, + type PreparedModelRuntimeStores, +} from "./prepared-model-runtime.js"; import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js"; import { protectPreparedProviderRuntimeAuth, @@ -78,6 +86,7 @@ import { scopeAuthProfileStoreToPreparedPlan, } from "./runtime-plan/resolve-auth.js"; import type { AgentRuntimeAuthPlan } from "./runtime-plan/types.js"; +import { resolveSessionModelRef } from "./session-model-ref.js"; import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; import { getModelRegistryRuntime } from "./sessions/model-registry-runtime.js"; @@ -398,8 +407,8 @@ type BtwRuntimeModelMaterialization = { modelId: string; agentDir: string; workspaceDir?: string; - authStorage: ReturnType; - modelRegistry: ReturnType; + authStorage: PreparedModelRuntimeStores["authStorage"]; + modelRegistry: PreparedModelRuntimeStores["modelRegistry"]; }; async function materializeBtwRuntimeModel( @@ -477,30 +486,26 @@ async function resolveRuntimeModel(params: { isNewSession: boolean; harnessId?: string; harnessAuthBootstrap?: AgentHarness["authBootstrap"]; + preparedModelRuntime: PreparedModelRuntimeSnapshot; }): Promise<{ model: Model; authProfileId?: string; authProfileIdSource?: "auto" | "user"; authProfileStore: AuthProfileStore; runtimeAuthPreparation: BtwRuntimeAuthPreparation; - authStorage: ReturnType; - modelRegistry: ReturnType; + authStorage: PreparedModelRuntimeStores["authStorage"]; + modelRegistry: PreparedModelRuntimeStores["modelRegistry"]; }> { - const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; - await ensureOpenClawModelsJson(params.cfg, params.agentDir, modelsOptions); - const authStorage = discoverAuthStorage(params.agentDir, { - config: params.cfg, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - }); - const modelRegistry = discoverModels(authStorage, params.agentDir, { - config: params.cfg, - ...modelsOptions, - }); + const preparedModelRuntime = params.preparedModelRuntime; + const cfg = preparedModelRuntime.config; + const agentDir = preparedModelRuntime.agentDir; + const workspaceDir = preparedModelRuntime.workspaceDir; + const { authStorage, modelRegistry } = preparedModelRuntime.createStores(); let model = resolveModelWithRegistry({ provider: params.provider, modelId: params.model, modelRegistry, - cfg: params.cfg, + cfg, }); if (!model) { throw new Error(`Unknown model: ${params.provider}/${params.model}`); @@ -512,13 +517,13 @@ async function resolveRuntimeModel(params: { provider: runtimeProvider, harnessRuntime: params.harnessId, agentHarnessId: params.harnessId, - config: params.cfg, + config: cfg, }); const authProfileId = await resolveSessionAuthProfileOverride({ - cfg: params.cfg, + cfg, provider: runtimeProvider, acceptedProviderIds, - agentDir: params.agentDir, + agentDir, sessionEntry: params.sessionEntry, sessionStore: params.sessionStore, sessionKey: params.sessionKey, @@ -527,12 +532,12 @@ async function resolveRuntimeModel(params: { }); const authProfileIdSource = resolveReturnedAuthProfileSource(params.sessionEntry, authProfileId); const authProfileStoreSelection = resolveBtwAuthProfileStore({ - cfg: params.cfg, + cfg, provider: runtimeProvider, modelId: runtimeModelId, agentId: params.agentId, - agentDir: params.agentDir, - workspaceDir: params.workspaceDir, + agentDir, + workspaceDir, authProfileId, authProfileIdSource, }); @@ -545,9 +550,9 @@ async function resolveRuntimeModel(params: { modelId: runtimeModelId, modelApi: model.api, modelBaseUrl: model.baseUrl, - config: params.cfg, + config: cfg, env: process.env, - workspaceDir: params.workspaceDir, + workspaceDir, authProfileStore: authProfileStoreSelection.store, sessionAuthProfileId: effectiveAuthProfileId, sessionAuthProfileSource: authProfileIdSource, @@ -556,11 +561,11 @@ async function resolveRuntimeModel(params: { harnessAuthBootstrap: params.harnessAuthBootstrap, }); model = await materializeBtwRuntimeModel({ - cfg: params.cfg, + cfg, provider: runtimeProvider, modelId: runtimeModelId, - agentDir: params.agentDir, - workspaceDir: params.workspaceDir, + agentDir, + workspaceDir, authStorage, modelRegistry, plan: runtimeAuthPreparation.plan, @@ -682,8 +687,9 @@ async function runCliBtwSideQuestion(params: { /** Answers a side question using sanitized session context and no tool execution. */ export async function runBtwSideQuestion( - params: RunBtwSideQuestionParams, + paramsInput: RunBtwSideQuestionParams, ): Promise { + let params = paramsInput; const sessionId = params.sessionEntry.sessionId?.trim(); if (!sessionId) { throw new Error("No active session context."); @@ -699,11 +705,37 @@ export async function runBtwSideQuestion( throw new Error("No active session transcript."); } - const sessionAgentId = resolveSessionAgentId({ + const requestedAgentId = resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg, }); - const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId); + const requestedWorkspaceDir = resolveAgentWorkspaceDir(params.cfg, requestedAgentId); + const preparedModelRuntime = await loadPreparedModelRuntimeSnapshot({ + config: params.cfg, + agentId: requestedAgentId, + agentDir: params.agentDir, + inheritedAuthDir: resolveDefaultAgentDir(params.cfg), + workspaceDir: requestedWorkspaceDir, + }); + const sessionAgentId = + preparedModelRuntime.agentId ?? + resolveSessionAgentId({ sessionKey: params.sessionKey, config: preparedModelRuntime.config }); + const workspaceDir = + preparedModelRuntime.workspaceDir ?? + resolveAgentWorkspaceDir(preparedModelRuntime.config, sessionAgentId); + const preparedModelRef = preparedModelRuntimeConfigsMatch(preparedModelRuntime.config, params.cfg) + ? { provider: params.provider, model: params.model } + : resolveSessionModelRef(preparedModelRuntime.config, params.sessionEntry, sessionAgentId); + // BTW policy, model selection, directories, auth, and catalog must come from one generation. + // A reload may have committed while the command waited for its transcript/session lookup. + // Rebind every later policy/auth/dispatch read to the generation returned above. + params = { + ...params, + cfg: preparedModelRuntime.config, + agentDir: preparedModelRuntime.agentDir, + provider: preparedModelRef.provider, + model: preparedModelRef.model, + }; const preparedHarnesses = new Map(); const prepareHarness = async ( provider: string, @@ -781,6 +813,7 @@ export async function runBtwSideQuestion( isNewSession: params.isNewSession, harnessId: harness.id, harnessAuthBootstrap: harness.authBootstrap, + preparedModelRuntime, }); } return runtimeSelection; diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index e2719a9a488..6624debd317 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -20,8 +20,13 @@ const contextTestState = vi.hoisted(() => { staticCatalogModels: [] as DiscoveredModel[], runtimeConfigSnapshot: null as OpenClawConfig | null, runtimeConfigSourceSnapshot: null as OpenClawConfig | null, - loadModelCatalog: vi.fn(async () => state.discoveredModels), - loadStaticCatalog: vi.fn(async () => state.staticCatalogModels), + loadModelCatalogOwnerSnapshot: vi.fn(async (_params: unknown) => ({ + modelCatalog: { + entries: state.discoveredModels, + routeVariants: [], + staticEntries: state.staticCatalogModels, + }, + })), }; return state; }); @@ -37,12 +42,8 @@ vi.mock("../config/runtime-source-projection.js", () => ({ : config, })); -vi.mock("./model-catalog.runtime.js", () => ({ - loadModelCatalog: contextTestState.loadModelCatalog, -})); - -vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({ - loadBundledProviderStaticCatalogContextModels: contextTestState.loadStaticCatalog, +vi.mock("./prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogOwnerSnapshot: contextTestState.loadModelCatalogOwnerSnapshot, })); function mockContextDeps(params: { @@ -131,11 +132,14 @@ describe("lookupContextTokens", () => { contextTestState.staticCatalogModels = []; contextTestState.runtimeConfigSnapshot = null; contextTestState.runtimeConfigSourceSnapshot = null; - contextTestState.loadModelCatalog.mockClear(); - contextTestState.loadStaticCatalog.mockClear(); - contextTestState.loadStaticCatalog.mockImplementation( - async () => contextTestState.staticCatalogModels, - ); + contextTestState.loadModelCatalogOwnerSnapshot.mockClear(); + contextTestState.loadModelCatalogOwnerSnapshot.mockImplementation(async () => ({ + modelCatalog: { + entries: contextTestState.discoveredModels, + routeVariants: [], + staticEntries: contextTestState.staticCatalogModels, + }, + })); contextModule.resetContextWindowCacheForTest(); }); @@ -338,11 +342,17 @@ describe("lookupContextTokens", () => { lookupContextTokens("anthropic/claude-opus-4.7-20260219"); await flushAsyncWarmup(); - expect(contextTestState.loadModelCatalog).toHaveBeenCalledOnce(); - expect(contextTestState.loadModelCatalog).toHaveBeenCalledWith({ - config, - readOnly: true, - }); + expect(contextTestState.loadModelCatalogOwnerSnapshot).toHaveBeenCalledOnce(); + expect(contextTestState.loadModelCatalogOwnerSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + config, + agentDir: expect.any(String), + readOnly: true, + }), + ); + expect(contextTestState.loadModelCatalogOwnerSnapshot.mock.calls[0]?.[0]).not.toHaveProperty( + "workspaceDir", + ); expect(lookupContextTokens("anthropic/claude-opus-4.7-20260219")).toBe( ANTHROPIC_CONTEXT_1M_TOKENS, ); @@ -361,10 +371,9 @@ describe("lookupContextTokens", () => { const { ensureContextWindowCacheLoaded, lookupContextTokens } = await importContextModule(); await ensureContextWindowCacheLoaded(config); - expect(contextTestState.loadModelCatalog).toHaveBeenCalledWith({ - config, - readOnly: true, - }); + expect(contextTestState.loadModelCatalogOwnerSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ config, readOnly: true }), + ); expect( lookupContextTokens("anthropic/claude-opus-4.7-20260219", { allowAsyncLoad: false }), ).toBe(ANTHROPIC_CONTEXT_1M_TOKENS); @@ -397,8 +406,8 @@ describe("lookupContextTokens", () => { it("status waits for pending context warmup but releases on timeout", async () => { vi.useFakeTimers(); try { - contextTestState.loadModelCatalog.mockImplementationOnce( - () => new Promise(() => {}), + contextTestState.loadModelCatalogOwnerSnapshot.mockImplementationOnce( + () => new Promise(() => {}), ); const { ensureContextWindowCacheLoaded, waitForContextWindowCacheLoad } = @@ -432,7 +441,7 @@ describe("lookupContextTokens", () => { expect(lookupContextTokens("gemini-3.1-pro-preview")).toBe(1_048_576); }); - it("keeps persisted context metadata when provider static warmup fails", async () => { + it("keeps discovered context metadata when no static rows exist", async () => { mockDiscoveryDeps([ { id: "claude-sonnet", @@ -440,8 +449,6 @@ describe("lookupContextTokens", () => { contextWindow: 654_321, }, ]); - contextTestState.loadStaticCatalog.mockRejectedValueOnce(new Error("catalog unavailable")); - const { lookupContextTokens } = await importContextModule(); lookupContextTokens("claude-sonnet"); await flushAsyncWarmup(); diff --git a/src/agents/context.ts b/src/agents/context.ts index 4784eb29d7b..4a557bb768f 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -6,6 +6,7 @@ import { getRuntimeConfig } from "../config/config.js"; import { projectConfigOntoRuntimeSourceSnapshot } from "../config/runtime-source-projection.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js"; +import { resolveAgentDir, resolveDefaultAgentId } from "./agent-scope.js"; import { lookupCachedContextTokens, lookupCachedContextWindow, @@ -48,9 +49,7 @@ const CONFIG_LOAD_RETRY_POLICY: BackoffPolicy = { factor: 2, jitter: 0, }; -const loadModelCatalogRuntime = () => import("./model-catalog.runtime.js"); -const loadStaticModelCatalogRuntime = () => - import("./embedded-agent-runner/model.static-catalog.js"); +const loadPreparedModelCatalogRuntime = () => import("./prepared-model-catalog.js"); export function applyDiscoveredContextWindows(params: { cache: Map; @@ -209,26 +208,33 @@ export function ensureContextWindowCacheLoaded(cfgOverride?: OpenClawConfig): Pr return; } try { - // Read-only catalog loading overlays current config and manifest rows - // onto persisted discovery without rewriting models.json. - const [{ loadModelCatalog }, { loadBundledProviderStaticCatalogContextModels }] = - await Promise.all([loadModelCatalogRuntime(), loadStaticModelCatalogRuntime()]); - const [modelsResult, providerStaticModelsResult] = await Promise.allSettled([ - loadModelCatalog({ config: cfg, readOnly: true }), - loadBundledProviderStaticCatalogContextModels({ cfg }), - ]); + const { loadPreparedModelCatalogOwnerSnapshot } = await loadPreparedModelCatalogRuntime(); + const defaultAgentId = resolveDefaultAgentId(cfg); + const catalogResult = await loadPreparedModelCatalogOwnerSnapshot({ + config: cfg, + agentId: defaultAgentId, + agentDir: resolveAgentDir(cfg, defaultAgentId), + readOnly: true, + }).then( + (value) => ({ status: "fulfilled" as const, value }), + (reason: unknown) => ({ status: "rejected" as const, reason }), + ); if (CONTEXT_WINDOW_RUNTIME_STATE.generation !== generation) { return; } - const models = modelsResult.status === "fulfilled" ? modelsResult.value : []; + const models = + catalogResult.status === "fulfilled" ? catalogResult.value.modelCatalog.entries : []; const providerStaticModels = - providerStaticModelsResult.status === "fulfilled" ? providerStaticModelsResult.value : []; + catalogResult.status === "fulfilled" + ? (catalogResult.value.modelCatalog.staticEntries ?? []) + : []; applyDiscoveredContextWindows({ cache: stagedTokenCache, models: [...models, ...providerStaticModels], }); } catch { - // If model discovery fails, continue with config overrides only. + // Static and discovered rows belong to one atomic generation. If its owner fails, keep + // config overrides only instead of mixing in independently rediscovered static metadata. } if (CONTEXT_WINDOW_RUNTIME_STATE.generation !== generation) { diff --git a/src/agents/embedded-agent-runner.e2e.test.ts b/src/agents/embedded-agent-runner.e2e.test.ts index 316330a9969..28f338ba9e4 100644 --- a/src/agents/embedded-agent-runner.e2e.test.ts +++ b/src/agents/embedded-agent-runner.e2e.test.ts @@ -366,6 +366,28 @@ function firstRunEmbeddedAttemptParams(): { sessionKey?: string } { } describe("runEmbeddedAgent", () => { + it("reuses one standalone snapshot across configless runs", async () => { + mockSuccessfulEmbeddedAttempt(); + mockSuccessfulEmbeddedAttempt(); + + for (const suffix of ["first", "second"]) { + await runEmbeddedAgent({ + sessionId: `configless-${suffix}`, + sessionFile: nextSessionFile(), + workspaceDir, + prompt: "hello", + provider: "openrouter", + model: "openrouter/auto", + timeoutMs: 5_000, + agentDir, + runId: nextRunId(`configless-${suffix}`), + enqueue: immediateEnqueue, + }); + } + + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledTimes(1); + }); + it("uses the configured default model when the caller omits provider and model", async () => { const sessionFile = nextSessionFile(); const cfg = { @@ -521,7 +543,7 @@ describe("runEmbeddedAgent", () => { ).toEqual(expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-4-6" })); }); - it("skips models.json generation when dynamic model resolution succeeds", async () => { + it("publishes the standalone model snapshot before dynamic model resolution", async () => { const sessionFile = nextSessionFile(); const cfg = createEmbeddedAgentRunnerOpenAiConfig([]); runEmbeddedAttemptMock.mockResolvedValueOnce( @@ -555,7 +577,7 @@ describe("runEmbeddedAgent", () => { expect( (resolveModelCall?.[4] as { skipAgentDiscovery?: boolean } | undefined)?.skipAgentDiscovery, ).toBe(true); - expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledTimes(1); }); it("resolves explicit OpenAI OpenClaw runs through Codex when auth order starts with Codex OAuth", async () => { @@ -700,7 +722,7 @@ describe("runEmbeddedAgent", () => { expect.objectContaining({ skipAgentDiscovery: true }), ); expect(resolveModelAsyncMock).toHaveBeenCalledTimes(1); - expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledTimes(1); expect( (firstRunEmbeddedAttemptParams() as { model?: { provider?: string } }).model?.provider, ).toBe("openai"); @@ -774,7 +796,7 @@ describe("runEmbeddedAgent", () => { preferBundledStaticCatalogTransport: true, }), ); - expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledTimes(1); expect( (firstRunEmbeddedAttemptParams() as { model?: { provider?: string } }).model?.provider, ).toBe("openai"); @@ -804,7 +826,7 @@ describe("runEmbeddedAgent", () => { }); expect(resolveModelAsyncMock).not.toHaveBeenCalled(); - expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledTimes(1); const attempt = firstRunEmbeddedAttemptParams() as Record; expect(attempt).toMatchObject({ agentHarnessId: "codex", diff --git a/src/agents/embedded-agent-runner/compact.abort-signal.test.ts b/src/agents/embedded-agent-runner/compact.abort-signal.test.ts index 7df6cf99faa..63d65d9858e 100644 --- a/src/agents/embedded-agent-runner/compact.abort-signal.test.ts +++ b/src/agents/embedded-agent-runner/compact.abort-signal.test.ts @@ -16,6 +16,26 @@ vi.mock("../model-fallback.js", () => ({ vi.mock("./compact.queued.js", () => ({ compactEmbeddedAgentSession: vi.fn() })); +vi.mock("../prepared-model-runtime.js", () => ({ + acquireAgentRunPreparedModelRuntime: vi.fn( + async (input: { + config: OpenClawConfig; + agentId?: string; + agentDir: string; + workspaceDir?: string; + }) => ({ + snapshot: { + config: input.config, + agentId: input.agentId, + agentDir: input.agentDir, + workspaceDir: input.workspaceDir, + createStores: () => ({}), + }, + release: vi.fn(), + }), + ), +})); + import { runWithModelFallback } from "../model-fallback.js"; import { compactEmbeddedAgentSessionDirect } from "./compact.js"; diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index bf0d8e9fea7..a4472003a62 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -735,6 +735,26 @@ export async function loadCompactHooksHarness(): Promise<{ ensureOpenClawModelsJson: vi.fn(async () => {}), })); + vi.doMock("../prepared-model-runtime.js", () => ({ + activateStandalonePreparedModelRuntime: vi.fn(async () => {}), + acquireAgentRunPreparedModelRuntime: vi.fn(async (input: Record) => ({ + snapshot: { + agentId: input.agentId, + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + createStores: () => ({ authStorage: {}, modelRegistry: {} }), + }, + release: vi.fn(), + })), + prepareModelRuntimeSnapshot: vi.fn(async () => ({ + createStores: () => ({ authStorage: {}, modelRegistry: {} }), + })), + loadPreparedModelRuntimeSnapshot: vi.fn(async () => ({ + createStores: () => ({ authStorage: {}, modelRegistry: {} }), + })), + })); + vi.doMock("../model-auth.js", () => ({ applyAuthHeaderOverride: vi.fn((model: unknown) => model), applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), @@ -957,6 +977,7 @@ export async function loadCompactHooksHarness(): Promise<{ listAgentEntries: vi.fn(() => []), resolveAgentConfig: vi.fn(() => undefined), resolveAgentDir: vi.fn((_cfg: unknown, agentId: string) => `/tmp/agents/${agentId}/agent`), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp"), resolveDefaultAgentDir: vi.fn(() => "/tmp/agents/main/agent"), resolveDefaultAgentId: vi.fn(() => "main"), resolveAgentIdFromSessionKey: vi.fn( diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 033b060ac8f..8ce763bcb99 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -703,10 +703,9 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { workspaceDir: "/tmp/workspace", }); - expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ - config: undefined, - workspaceDir: "/tmp/workspace", - }); + expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith( + expect.objectContaining({ config: {}, workspaceDir: "/tmp/workspace" }), + ); }); it("forwards gateway subagent binding opt-in during compaction bootstrap", async () => { @@ -726,11 +725,13 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { allowGatewaySubagentBinding: true, }); - expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ - config: undefined, - workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, - }); + expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith( + expect.objectContaining({ + config: {}, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }), + ); }); it("uses sandboxSessionKey only for compaction sandbox resolution", async () => { @@ -742,11 +743,13 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { workspaceDir: "/tmp/workspace", }); - expect(resolveSandboxContextMock).toHaveBeenCalledWith({ - config: undefined, - sessionKey: "agent:main:telegram:default:direct:12345", - workspaceDir: "/tmp/workspace", - }); + expect(resolveSandboxContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: {}, + sessionKey: "agent:main:telegram:default:direct:12345", + workspaceDir: "/tmp/workspace", + }), + ); }); it("uses subagent prompt surface and guidance for compacted subagent prompt rebuilds", async () => { @@ -1515,7 +1518,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { expect(result.ok).toBe(true); expect(mockCallArg(resolveModelMock)).toBe("openai"); expect(mockCallArg(resolveModelMock, 0, 1)).toBe("gpt-5.5"); - expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toEqual({ + expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toMatchObject({ authProfileMode: "api_key", }); }); diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 7c164fd75ba..704fc5048a6 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -64,6 +64,8 @@ import { createPreparedEmbeddedAgentSettingsManager } from "../agent-project-set import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentDir, resolveRunModelFallbacksOverride, resolveSessionAgentIds, } from "../agent-scope.js"; @@ -118,8 +120,11 @@ import { runWithModelFallback, } from "../model-fallback.js"; import { supportsModelTools } from "../model-tool-support.js"; -import { ensureOpenClawModelsJson } from "../models-config.js"; import { isOpenAIProvider } from "../openai-routing.js"; +import { + acquireAgentRunPreparedModelRuntime, + type PreparedModelRuntimeSnapshot, +} from "../prepared-model-runtime.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../prompt-surface.js"; import { applyPreparedRuntimeAuthToModel } from "../provider-request-config.js"; import { @@ -241,6 +246,10 @@ const compactionCheckpointStore = createFileBackedCompactionCheckpointStore(); type CompactEmbeddedAgentSessionParamsWithSessionFile = CompactEmbeddedAgentSessionRuntimeParams & { sessionFile: string; }; +type PreparedCompactEmbeddedAgentSessionParams = + CompactEmbeddedAgentSessionParamsWithSessionFile & { + preparedModelRuntime: PreparedModelRuntimeSnapshot; + }; function hasRealConversationContent( msg: AgentMessage, @@ -443,42 +452,73 @@ export async function compactEmbeddedAgentSessionDirect( }; } const runSessionTarget = await resolveAgentRunSessionTarget(paramsBase); - const params: CompactEmbeddedAgentSessionParamsWithSessionFile = { + const requestedParams: CompactEmbeddedAgentSessionParamsWithSessionFile = { ...paramsBase, agentId: paramsBase.agentId ?? runSessionTarget.agentId, sessionId: runSessionTarget.sessionId, sessionKey: paramsBase.sessionKey ?? runSessionTarget.sessionKey, sessionFile: runSessionTarget.sessionFile, }; - if (hasExplicitCompactionModel(params) || !hasCompactionModelFallbackCandidates(params)) { - return await compactEmbeddedAgentSessionDirectOnce(params); - } - const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ - config: params.config, - provider: params.provider, - modelId: params.model, - authProfileId: params.authProfileId, - modelSelectionLocked: params.modelSelectionLocked, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, + const requestedAgentIds = resolveSessionAgentIds({ + sessionKey: requestedParams.sessionKey, + config: requestedParams.config, + agentId: requestedParams.agentId, + }); + const requestedAgentDir = + requestedParams.agentDir ?? + resolveAgentDir(requestedParams.config ?? {}, requestedAgentIds.sessionAgentId); + const requestedWorkspaceDir = resolveUserPath(requestedParams.workspaceDir); + const canonicalWorkspaceDir = resolveUserPath( + resolveAgentWorkspaceDir(requestedParams.config ?? {}, requestedAgentIds.sessionAgentId), + ); + const preparedModelRuntimeLease = await acquireAgentRunPreparedModelRuntime({ + config: requestedParams.config ?? {}, + agentId: requestedAgentIds.sessionAgentId, + agentDir: requestedAgentDir, + inheritedAuthDir: resolveDefaultAgentDir(requestedParams.config ?? {}), + workspaceDir: requestedWorkspaceDir, + preserveWorkspaceDirOnRefresh: requestedWorkspaceDir !== canonicalWorkspaceDir, }); - const primaryProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER; - const primaryModel = resolvedCompactionTarget.model ?? DEFAULT_MODEL; - const requestedPrimaryProvider = params.provider?.trim() || DEFAULT_PROVIDER; - const fallbacksOverride = resolveCompactionFallbacksOverride(params); - const resolvedPrimaryCandidate = resolveModelCandidateChain({ - cfg: params.config, - provider: primaryProvider, - model: primaryModel, - fallbacksOverride, - })[0]; - const fallbackAgentId = resolveSessionAgentIds({ - sessionKey: params.sandboxSessionKey ?? params.sessionKey, - config: params.config, - agentId: params.agentId, - }).sessionAgentId; - const fallbackSessionKey = params.sandboxSessionKey ?? params.sessionKey ?? params.sessionId; try { + const preparedModelRuntime = preparedModelRuntimeLease.snapshot; + // Fallback policy and every attempt consume the same generation as model/auth discovery. + // A reload may have committed while session targeting was resolved above. + const params: PreparedCompactEmbeddedAgentSessionParams = { + ...requestedParams, + config: preparedModelRuntime.config, + agentId: preparedModelRuntime.agentId ?? requestedAgentIds.sessionAgentId, + agentDir: preparedModelRuntime.agentDir, + workspaceDir: preparedModelRuntime.workspaceDir ?? requestedWorkspaceDir, + preparedModelRuntime, + }; + if (hasExplicitCompactionModel(params) || !hasCompactionModelFallbackCandidates(params)) { + return await compactEmbeddedAgentSessionDirectOnce(params); + } + const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ + config: params.config, + provider: params.provider, + modelId: params.model, + authProfileId: params.authProfileId, + modelSelectionLocked: params.modelSelectionLocked, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const primaryProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER; + const primaryModel = resolvedCompactionTarget.model ?? DEFAULT_MODEL; + const requestedPrimaryProvider = params.provider?.trim() || DEFAULT_PROVIDER; + const fallbacksOverride = resolveCompactionFallbacksOverride(params); + const resolvedPrimaryCandidate = resolveModelCandidateChain({ + cfg: params.config, + provider: primaryProvider, + model: primaryModel, + fallbacksOverride, + })[0]; + const fallbackAgentId = resolveSessionAgentIds({ + sessionKey: params.sandboxSessionKey ?? params.sessionKey, + config: params.config, + agentId: params.agentId, + }).sessionAgentId; + const fallbackSessionKey = params.sandboxSessionKey ?? params.sessionKey ?? params.sessionId; const fallbackResult = await runWithModelFallback({ cfg: params.config, provider: primaryProvider, @@ -528,11 +568,13 @@ export async function compactEmbeddedAgentSessionDirect( return fallbackResult.result; } catch (err) { return fallbackFailureToCompactionResult(err); + } finally { + preparedModelRuntimeLease.release(); } } async function compactEmbeddedAgentSessionDirectOnce( - params: CompactEmbeddedAgentSessionParamsWithSessionFile, + params: PreparedCompactEmbeddedAgentSessionParams, ): Promise { const startedAt = Date.now(); const diagId = params.diagId?.trim() || createCompactionDiagId(); @@ -675,15 +717,19 @@ async function compactEmbeddedAgentSessionDirectOnce( : undefined, }; }; - await ensureOpenClawModelsJson(params.config, agentDir, { - workspaceDir: resolvedWorkspace, - }); + const preparedModelRuntime = params.preparedModelRuntime; + const preparedStores = preparedModelRuntime.createStores(); const { model, error, authStorage, modelRegistry } = await resolveModelAsync( runtimeProvider, modelId, agentDir, params.config, - initialModelAuth, + { + ...initialModelAuth, + authStorage: preparedStores.authStorage, + modelRegistry: preparedStores.modelRegistry, + workspaceDir: resolvedWorkspace, + }, ); if (!model) { const reason = error ?? `Unknown model: ${runtimeProvider}/${modelId}`; diff --git a/src/agents/embedded-agent-runner/model-discovery-cache.test-support.ts b/src/agents/embedded-agent-runner/model-discovery-cache.test-support.ts deleted file mode 100644 index e144fc37d67..00000000000 --- a/src/agents/embedded-agent-runner/model-discovery-cache.test-support.ts +++ /dev/null @@ -1,15 +0,0 @@ -import "./model-discovery-cache.js"; - -type ModelDiscoveryCacheTestApi = { - resetModelDiscoveryCacheForTest(): void; -}; - -function getTestApi(): ModelDiscoveryCacheTestApi { - return (globalThis as Record)[ - Symbol.for("openclaw.modelDiscoveryCacheTestApi") - ] as ModelDiscoveryCacheTestApi; -} - -export function resetModelDiscoveryCacheForTest(): void { - getTestApi().resetModelDiscoveryCacheForTest(); -} diff --git a/src/agents/embedded-agent-runner/model-discovery-cache.ts b/src/agents/embedded-agent-runner/model-discovery-cache.ts deleted file mode 100644 index a7c53f83320..00000000000 --- a/src/agents/embedded-agent-runner/model-discovery-cache.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Discovers cached model/provider state from configured agent stores. - */ -import { statSync } from "node:fs"; -import path from "node:path"; -import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; -import { - resolveRuntimeExternalAuthProviderRefs, - resolveRuntimeSyntheticAuthProviderRefs, -} from "../../plugins/synthetic-auth.runtime.js"; -import { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js"; -import { resolveDefaultAgentDir } from "../agent-scope.js"; -import { hasAnyRuntimeAuthProfileStoreSource } from "../auth-profiles/runtime-snapshots.js"; -import { resolveModelPluginMetadataSnapshot } from "../model-discovery-context.js"; -import { listPluginModelCatalogFiles } from "../plugin-model-catalog.js"; -import type { AuthStorage, ModelRegistry } from "../sessions/index.js"; - -/** - * Caches auth/model discovery for embedded-agent turns that reuse a stable agent directory. - * - * Runtime auth profile stores and live plugin auth sources bypass this cache because their - * source of truth can change without file metadata updates in the agent directory. - */ -type DiscoveryStores = { - authStorage: AuthStorage; - modelRegistry: ModelRegistry; -}; - -type DiscoverCachedAgentStoresOptions = { - agentDir: string; - config?: OpenClawConfig; - inheritedAuthDir?: string; - workspaceDir?: string; -}; - -type CacheEntry = DiscoveryStores & { - fingerprint: string; - lastUsedAt: number; -}; - -const MAX_DISCOVERY_STORE_CACHE_ENTRIES = 64; -const DISCOVERY_STORE_CACHE = new Map(); - -/** Returns the small file metadata tuple used to invalidate cached discovery snapshots. */ -function fileFingerprint(pathname: string): { mtimeMs: number; size: number } | null { - try { - const stat = statSync(pathname); - return Number.isFinite(stat.mtimeMs) ? { mtimeMs: stat.mtimeMs, size: stat.size } : null; - } catch { - return null; - } -} - -function normalizeCacheDir(dirname: string | undefined): string | undefined { - return dirname ? path.resolve(dirname) : undefined; -} - -function authFingerprint(agentDir: string): object { - return { - authProfilesSqlite: fileFingerprint(path.join(agentDir, "openclaw-agent.sqlite")), - authProfilesSqliteWal: fileFingerprint(path.join(agentDir, "openclaw-agent.sqlite-wal")), - }; -} - -function pluginModelCatalogFingerprint( - agentDir: string, -): Array<[string, ReturnType]> { - return listPluginModelCatalogFiles(agentDir).map((catalogFile) => [ - catalogFile.relativePath, - fileFingerprint(catalogFile.path), - ]); -} - -function discoveryFingerprint( - params: DiscoverCachedAgentStoresOptions & { - pluginMetadataSnapshot?: PluginMetadataSnapshot; - }, -): string { - // Only include inherited auth when it points at a distinct store. The common same-dir case must - // not double-count WAL/file state or it would churn cache keys without changing discovery output. - const inheritedAuthDir = - params.inheritedAuthDir && params.inheritedAuthDir !== params.agentDir - ? params.inheritedAuthDir - : undefined; - return JSON.stringify({ - agentDir: params.agentDir, - inheritedAuthDir, - localAuth: authFingerprint(params.agentDir), - inheritedAuth: inheritedAuthDir ? authFingerprint(inheritedAuthDir) : undefined, - modelsJson: fileFingerprint(path.join(params.agentDir, "models.json")), - // Discovery normalization can project provider/model route facts from config. - // Tie the registry snapshot to that same runtime config generation. - runtimeConfig: params.config ? resolveRuntimeConfigCacheKey(params.config) : undefined, - pluginMetadata: pluginMetadataFingerprint(params.pluginMetadataSnapshot), - pluginModelCatalogs: pluginModelCatalogFingerprint(params.agentDir), - }); -} - -function hasRuntimePluginAuthSources(): boolean { - return ( - resolveRuntimeSyntheticAuthProviderRefs().length > 0 || - resolveRuntimeExternalAuthProviderRefs().length > 0 - ); -} - -function pruneDiscoveryStoreCache(): void { - if (DISCOVERY_STORE_CACHE.size <= MAX_DISCOVERY_STORE_CACHE_ENTRIES) { - return; - } - const overflow = DISCOVERY_STORE_CACHE.size - MAX_DISCOVERY_STORE_CACHE_ENTRIES; - const oldestKeys = [...DISCOVERY_STORE_CACHE.entries()] - .toSorted((left, right) => left[1].lastUsedAt - right[1].lastUsedAt) - .slice(0, overflow) - .map(([key]) => key); - for (const key of oldestKeys) { - DISCOVERY_STORE_CACHE.delete(key); - } -} - -function resolvePluginMetadataSnapshotForDiscovery( - options: DiscoverCachedAgentStoresOptions, -): PluginMetadataSnapshot | undefined { - return resolveModelPluginMetadataSnapshot({ - ...(options.config ? { config: options.config } : {}), - ...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), - useRuntimeConfig: options.config === undefined, - }) as PluginMetadataSnapshot | undefined; -} - -function pluginMetadataFingerprint(snapshot: PluginMetadataSnapshot | undefined): object { - return { - configFingerprint: snapshot?.configFingerprint, - policyHash: snapshot?.policyHash, - workspaceDir: snapshot?.workspaceDir, - }; -} - -function discoverFreshAgentStores( - agentDir: string, - options: Pick, - pluginMetadataSnapshot: PluginMetadataSnapshot | undefined, -): DiscoveryStores { - const authStorage = discoverAuthStorage(agentDir, { - ...(options.config ? { config: options.config } : {}), - ...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), - }); - const modelRegistry = discoverModels(authStorage, agentDir, { - ...(options.config ? { config: options.config } : {}), - ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), - ...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), - }); - return { authStorage, modelRegistry }; -} - -/** Discovers auth/model stores, reusing file-backed snapshots until their inputs change. */ -export function discoverCachedAgentStores( - options: DiscoverCachedAgentStoresOptions, -): DiscoveryStores { - const agentDir = normalizeCacheDir(options.agentDir) ?? options.agentDir; - const inheritedAuthDir = normalizeCacheDir( - options.inheritedAuthDir ?? resolveDefaultAgentDir({}), - ); - if (hasAnyRuntimeAuthProfileStoreSource(agentDir) || hasRuntimePluginAuthSources()) { - // Runtime profile sources are process-owned state, not file-backed metadata. Fresh discovery - // preserves provider/auth changes made during the same long-lived gateway process. - return discoverFreshAgentStores( - agentDir, - options, - resolvePluginMetadataSnapshotForDiscovery(options), - ); - } - const pluginMetadataSnapshot = resolvePluginMetadataSnapshotForDiscovery(options); - - const cacheKey = JSON.stringify({ agentDir, inheritedAuthDir }); - const fingerprint = discoveryFingerprint({ - agentDir, - config: options.config, - inheritedAuthDir, - pluginMetadataSnapshot, - }); - const cached = DISCOVERY_STORE_CACHE.get(cacheKey); - if (cached?.fingerprint === fingerprint) { - cached.lastUsedAt = Date.now(); - return { - authStorage: cached.authStorage, - modelRegistry: cached.modelRegistry, - }; - } - - const stores = discoverFreshAgentStores(agentDir, options, pluginMetadataSnapshot); - DISCOVERY_STORE_CACHE.set(cacheKey, { - authStorage: stores.authStorage, - fingerprint, - lastUsedAt: Date.now(), - modelRegistry: stores.modelRegistry, - }); - pruneDiscoveryStoreCache(); - return stores; -} - -/** Clears the process-local discovery cache between tests that mutate model/auth fixtures. */ -function resetModelDiscoveryCacheForTest(): void { - DISCOVERY_STORE_CACHE.clear(); -} - -if (process.env.VITEST || process.env.NODE_ENV === "test") { - (globalThis as Record)[Symbol.for("openclaw.modelDiscoveryCacheTestApi")] = - { resetModelDiscoveryCacheForTest }; -} diff --git a/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts b/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts index b8db69273ad..74be31cb562 100644 --- a/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts +++ b/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts @@ -55,13 +55,38 @@ vi.mock("../model-suppression.js", () => ({ }, })); +vi.mock("../prepared-model-runtime.js", async () => { + const discovery = await import("../agent-model-discovery.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 } : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + if (!("fork" in modelRegistry)) { + Object.assign(modelRegistry, { fork: () => modelRegistry }); + } + return { authStorage, modelRegistry }; + }, + }); + return { + getPreparedModelRuntimeSnapshot: createSnapshot, + loadPreparedModelRuntimeSnapshot: async (input: Parameters[0]) => + createSnapshot(input), + }; +}); + vi.mock("../agent-model-discovery.js", () => ({ discoverAuthStorage: vi.fn(() => ({ mocked: true })), discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), })); import type { OpenClawConfig } from "../../config/config.js"; -import { resetModelDiscoveryCacheForTest } from "./model-discovery-cache.test-support.js"; import { expectResolvedForwardCompatFallbackResult, expectUnknownModelErrorResult, @@ -76,7 +101,6 @@ import { } from "./model.test-harness.js"; beforeEach(() => { - resetModelDiscoveryCacheForTest(); resetMockDiscoverModels(discoverModels); }); diff --git a/src/agents/embedded-agent-runner/model.manifest-alias.ts b/src/agents/embedded-agent-runner/model.manifest-alias.ts index 98daf510d6d..488a094358b 100644 --- a/src/agents/embedded-agent-runner/model.manifest-alias.ts +++ b/src/agents/embedded-agent-runner/model.manifest-alias.ts @@ -114,9 +114,7 @@ type ManifestModelCatalogAliasPlugin = Pick< | "modelCatalog" >; -export type ManifestModelCatalogProviderTransport = Readonly< - Pick ->; +type ManifestModelCatalogProviderTransport = Readonly>; export type ManifestModelCatalogProviderAliasMetadata = { readonly ambiguous?: true; @@ -339,25 +337,3 @@ export function resolveManifestModelCatalogProviderAliasMetadata(params: { } } } - -/** Resolves a provider alias from plugin model-catalog metadata when the alias is unambiguous. */ -export function canonicalizeManifestModelCatalogProviderAlias(params: { - provider: string; - modelId?: string; - cfg?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): string { - return resolveManifestModelCatalogProviderAliasMetadata(params).provider; -} - -/** Resolves transport defaults owned by a retained manifest provider alias. */ -export function resolveManifestModelCatalogProviderTransport(params: { - provider: string; - modelId?: string; - cfg?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): ManifestModelCatalogProviderTransport | undefined { - return resolveManifestModelCatalogProviderAliasMetadata(params).transport; -} diff --git a/src/agents/embedded-agent-runner/model.startup-retry.test.ts b/src/agents/embedded-agent-runner/model.startup-retry.test.ts index 646a4ac503f..e76c7f151a8 100644 --- a/src/agents/embedded-agent-runner/model.startup-retry.test.ts +++ b/src/agents/embedded-agent-runner/model.startup-retry.test.ts @@ -34,6 +34,21 @@ vi.mock("../agent-model-discovery.js", () => ({ discoverModels: discoverModelsMock, })); +vi.mock("../prepared-model-runtime.js", () => ({ + getPreparedModelRuntimeSnapshot: () => undefined, + loadPreparedModelRuntimeSnapshot: async ({ agentDir }: { agentDir: string }) => { + const authStorage = discoverAuthStorageMock(agentDir); + return { + agentDir, + config: {}, + createStores: () => ({ + authStorage, + modelRegistry: discoverModelsMock(authStorage, agentDir), + }), + }; + }, +})); + vi.mock("../../plugins/provider-runtime.js", () => ({ applyProviderResolvedTransportWithPlugin: () => undefined, buildProviderUnknownModelHintWithPlugin: () => undefined, diff --git a/src/agents/embedded-agent-runner/model.static-catalog.test.ts b/src/agents/embedded-agent-runner/model.static-catalog.test.ts index 70c0e45ebea..cbfdc1fce22 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.test.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.test.ts @@ -49,9 +49,7 @@ vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => ({ import { getModelProviderRequestTransport } from "../provider-request-config.js"; import { - canonicalizeManifestModelCatalogProviderAlias, createBundledProviderStaticCatalogContextResolver, - createBundledProviderStaticCatalogModelResolver, createBundledStaticCatalogModelResolver, loadBundledProviderStaticCatalogContextModels, resolveBundledProviderStaticCatalogModel, @@ -59,6 +57,10 @@ import { resolveManifestModelCatalogProviderAliasMetadata, } from "./model.static-catalog.js"; +const canonicalizeManifestModelCatalogProviderAlias = ( + params: Parameters[0], +) => resolveManifestModelCatalogProviderAliasMetadata(params).provider; + function setManifestPlugins(plugins: unknown[]) { // Static catalog resolution reads scan metadata first, then loads the manifest // from disk; the mock preserves that two-step contract. @@ -882,10 +884,10 @@ describe("resolveBundledProviderStaticCatalogModel", () => { }, }); - const resolveModel = createBundledProviderStaticCatalogModelResolver(); + const resolveModel = createBundledProviderStaticCatalogContextResolver(); await expect( resolveModel({ provider: "google", modelId: "gemini-3.1-pro-preview" }), - ).resolves.toMatchObject({ contextWindow: 1_048_576 }); + ).resolves.toEqual({ contextWindow: 1_048_576 }); await expect( resolveModel({ provider: "google", modelId: "missing-model" }), ).resolves.toBeUndefined(); @@ -940,9 +942,8 @@ describe("resolveBundledProviderStaticCatalogModel", () => { providerMocks.resolveRuntimePluginDiscoveryProviders.mockClear(); providerMocks.runProviderStaticCatalog.mockClear(); - const resolveModel = createBundledProviderStaticCatalogModelResolver(); await expect( - resolveModel({ + resolveBundledProviderStaticCatalogModel({ provider: "google-gemini-cli", modelId: "google/gemini-3.1-pro-preview", }), diff --git a/src/agents/embedded-agent-runner/model.static-catalog.ts b/src/agents/embedded-agent-runner/model.static-catalog.ts index c9ff497063e..79d5e70b6fe 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.ts @@ -26,15 +26,8 @@ import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildInlineProviderModels } from "./model.inline-provider.js"; import { staticModelIdMatches } from "./model.static-id.js"; -export { - canonicalizeManifestModelCatalogProviderAlias, - resolveManifestModelCatalogProviderAliasMetadata, - resolveManifestModelCatalogProviderTransport, -} from "./model.manifest-alias.js"; -export type { - ManifestModelCatalogProviderAliasMetadata, - ManifestModelCatalogProviderTransport, -} from "./model.manifest-alias.js"; +export { resolveManifestModelCatalogProviderAliasMetadata } from "./model.manifest-alias.js"; +export type { ManifestModelCatalogProviderAliasMetadata } from "./model.manifest-alias.js"; /** * Resolves bundled plugin static model-catalog rows into runtime model records. @@ -457,7 +450,7 @@ function createScopedBundledProviderStaticCatalogModelResolver( * Prepares bundled provider static-catalog lookup. * Each provider hook runs at most once for the resolver lifetime. */ -export function createBundledProviderStaticCatalogModelResolver( +function createBundledProviderStaticCatalogModelResolver( params: BundledProviderStaticCatalogResolverParams = {}, ): (lookup: BundledStaticCatalogLookup) => Promise { const resolveModel = createScopedBundledProviderStaticCatalogModelResolver(params); diff --git a/src/agents/embedded-agent-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts index 05d71a00509..690754f25cd 100644 --- a/src/agents/embedded-agent-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -12,7 +12,6 @@ import { saveAuthProfileStore, } from "../auth-profiles.js"; import { PLUGIN_MODEL_CATALOG_GENERATED_BY } from "../plugin-model-catalog.js"; -import { resetModelDiscoveryCacheForTest } from "./model-discovery-cache.test-support.js"; import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js"; const PLUGIN_MODEL_CATALOG_FILE = "catalog.json"; @@ -33,6 +32,11 @@ const resolveManifestModelCatalogProviderAliasMetadataMock = vi.hoisted(() => ); const resolveRuntimeSyntheticAuthProviderRefsMock = vi.hoisted(() => vi.fn((): string[] => [])); const resolveRuntimeExternalAuthProviderRefsMock = vi.hoisted(() => vi.fn((): string[] => [])); +const preparedSnapshotState = vi.hoisted(() => ({ + enabled: true, + getInputs: [] as Array>, + snapshots: new Map(), +})); vi.mock("../model-suppression.js", () => { // Mirrors the canonical manifest-driven suppression in @@ -142,6 +146,49 @@ vi.mock("../model-suppression.js", () => { }; }); +vi.mock("../prepared-model-runtime.js", async () => { + const discovery = await import("../agent-model-discovery.js"); + const discoveryContext = await import("../model-discovery-context.js"); + const createSnapshot = (input: { + agentId?: string; + agentDir: string; + config?: OpenClawConfig; + workspaceDir?: string; + }) => { + const workspaceDir = discoveryContext.resolveModelWorkspaceDir( + input.config, + input.workspaceDir, + ); + const key = `${input.agentId ?? ""}\u0000${input.agentDir}\u0000${workspaceDir ?? ""}`; + const current = preparedSnapshotState.snapshots.get(key); + if (current) { + return current; + } + const authStorage = discovery.discoverAuthStorage(input.agentDir); + const modelRegistry = discovery.discoverModels(authStorage, input.agentDir, { + ...(input.config ? { config: input.config } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + }); + if (!("fork" in modelRegistry)) { + Object.assign(modelRegistry, { fork: () => modelRegistry }); + } + const snapshot = { + ...(workspaceDir ? { workspaceDir } : {}), + createStores: () => ({ authStorage, modelRegistry }), + }; + preparedSnapshotState.snapshots.set(key, snapshot); + return snapshot; + }; + return { + getPreparedModelRuntimeSnapshot: (input: Parameters[0]) => { + preparedSnapshotState.getInputs.push(input); + return preparedSnapshotState.enabled ? createSnapshot(input) : undefined; + }, + loadPreparedModelRuntimeSnapshot: async (input: Parameters[0]) => + createSnapshot(input), + }; +}); + vi.mock("../agent-model-discovery.js", () => ({ discoverAuthStorage: vi.fn(() => ({ mocked: true })), discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), @@ -196,8 +243,10 @@ import { } from "./model.test-harness.js"; beforeEach(() => { + preparedSnapshotState.enabled = true; + preparedSnapshotState.getInputs.length = 0; + preparedSnapshotState.snapshots.clear(); clearRuntimeAuthProfileStoreSnapshots(); - resetModelDiscoveryCacheForTest(); resetMockDiscoverModels(discoverModels); vi.mocked(discoverModels).mockClear(); vi.mocked(discoverAuthStorage).mockClear(); @@ -227,6 +276,22 @@ beforeEach(() => { }); }); +it("rejects synchronous resolution before lifecycle publication", () => { + preparedSnapshotState.enabled = false; + + expect(() => + resolveModel( + "openai", + "gpt-5.5", + "/tmp/unpublished-agent", + {}, + { + runtimeHooks: createRuntimeHooks(), + }, + ), + ).toThrow("prepared model runtime is not published for synchronous model resolution"); +}); + function createRuntimeHooks() { // Runtime hooks emulate provider plugin model discovery, transport // normalization, and OpenRouter capability loading without plugin imports. @@ -362,7 +427,57 @@ describe("resolveModel", () => { expect(discoverModels).toHaveBeenCalledTimes(1); }); - it("invalidates agent discovery stores when provider route config changes", async () => { + it("looks up the lifecycle owner before applying a derived workspace", async () => { + mockDiscoveredModel(discoverModels, { + provider: "openai", + modelId: "gpt-5.5", + templateModel: { + provider: "openai", + ...makeModel("gpt-5.5"), + }, + }); + const cfg = { + agents: { defaults: { workspace: "/tmp/config-derived-workspace" } }, + } as OpenClawConfig; + + const result = await resolveModelAsync("openai", "gpt-5.5", "/tmp/agent", cfg, { + agentId: "main", + runtimeHooks: createRuntimeHooks(), + }); + + expectResolvedModel(result); + expect(preparedSnapshotState.getInputs[0]).toEqual( + expect.objectContaining({ agentId: "main", agentDir: "/tmp/agent" }), + ); + expect(preparedSnapshotState.getInputs[0]).not.toHaveProperty("workspaceDir"); + }); + + it("keeps prepared discovery generations separate for agents sharing directories", async () => { + mockDiscoveredModel(discoverModels, { + provider: "openai", + modelId: "gpt-5.5", + templateModel: { + provider: "openai", + ...makeModel("gpt-5.5"), + }, + }); + + const first = await resolveModelAsync("openai", "gpt-5.5", "/tmp/agent", undefined, { + agentId: "agent-a", + runtimeHooks: createRuntimeHooks(), + }); + const second = await resolveModelAsync("openai", "gpt-5.5", "/tmp/agent", undefined, { + agentId: "agent-b", + runtimeHooks: createRuntimeHooks(), + }); + + expectResolvedModel(first); + expectResolvedModel(second); + expect(discoverAuthStorage).toHaveBeenCalledTimes(2); + expect(discoverModels).toHaveBeenCalledTimes(2); + }); + + it("keeps lifecycle discovery stable when request route config changes", async () => { mockDiscoveredModel(discoverModels, { provider: "openai", modelId: "gpt-5.5", @@ -401,11 +516,11 @@ describe("resolveModel", () => { expectResolvedModel(first); expectResolvedModel(second); - expect(discoverAuthStorage).toHaveBeenCalledTimes(2); - expect(discoverModels).toHaveBeenCalledTimes(2); + expect(discoverAuthStorage).toHaveBeenCalledTimes(1); + expect(discoverModels).toHaveBeenCalledTimes(1); }); - it("invalidates agent discovery stores when generated plugin catalogs change", async () => { + it("does not poll generated plugin catalogs between lifecycle generations", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-cache-plugin-")); const agentDir = path.join(rootDir, "agent"); fs.mkdirSync(agentDir, { recursive: true }); @@ -436,10 +551,10 @@ describe("resolveModel", () => { expectResolvedModel(first); expectResolvedModel(second); - expect(discoverModels).toHaveBeenCalledTimes(2); + expect(discoverModels).toHaveBeenCalledTimes(1); }); - it("invalidates agent discovery stores when inherited default auth changes", async () => { + it("reuses inherited auth from one lifecycle generation", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-cache-")); const agentDir = path.join(rootDir, "agent"); const defaultAgentDir = path.join(rootDir, "default-agent"); @@ -479,11 +594,11 @@ describe("resolveModel", () => { expectResolvedModel(first); expectResolvedModel(second); - expect(discoverAuthStorage).toHaveBeenCalledTimes(2); - expect(discoverModels).toHaveBeenCalledTimes(2); + expect(discoverAuthStorage).toHaveBeenCalledTimes(1); + expect(discoverModels).toHaveBeenCalledTimes(1); }); - it("uses the resolved default agent workspace for cached model discovery", () => { + it("uses the resolved default agent workspace for prepared model discovery", () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-workspace-")); const agentDir = path.join(rootDir, "agent"); const workspaceDir = path.join(rootDir, "workspace"); @@ -558,7 +673,7 @@ describe("resolveModel", () => { }, ); - it("invalidates agent discovery stores when implicit main auth changes without config", async () => { + it("does not poll implicit main auth during request resolution", async () => { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-cache-state-")); const agentDir = path.join(rootDir, "agents", "worker", "agent"); const mainAgentDir = path.join(rootDir, "agents", "main", "agent"); @@ -592,15 +707,15 @@ describe("resolveModel", () => { expectResolvedModel(first); expectResolvedModel(second); - expect(discoverAuthStorage).toHaveBeenCalledTimes(2); - expect(discoverModels).toHaveBeenCalledTimes(2); + expect(discoverAuthStorage).toHaveBeenCalledTimes(1); + expect(discoverModels).toHaveBeenCalledTimes(1); }); } finally { fs.rmSync(rootDir, { recursive: true, force: true }); } }); - it("does not cache agent discovery stores while runtime auth snapshots are active", async () => { + it("keeps runtime auth snapshots inside the lifecycle generation", async () => { replaceRuntimeAuthProfileStoreSnapshots([ { store: { @@ -629,11 +744,11 @@ describe("resolveModel", () => { expectResolvedModel(first); expectResolvedModel(second); - expect(discoverAuthStorage).toHaveBeenCalledTimes(2); - expect(discoverModels).toHaveBeenCalledTimes(2); + expect(discoverAuthStorage).toHaveBeenCalledTimes(1); + expect(discoverModels).toHaveBeenCalledTimes(1); }); - it("does not cache agent discovery stores while plugin auth overlays are active", async () => { + it("keeps plugin auth overlays inside the lifecycle generation", async () => { resolveRuntimeSyntheticAuthProviderRefsMock.mockReturnValue(["runtime-provider"]); resolveRuntimeExternalAuthProviderRefsMock.mockReturnValue(["external-provider"]); mockDiscoveredModel(discoverModels, { @@ -654,8 +769,8 @@ describe("resolveModel", () => { expectResolvedModel(first); expectResolvedModel(second); - expect(discoverAuthStorage).toHaveBeenCalledTimes(2); - expect(discoverModels).toHaveBeenCalledTimes(2); + expect(discoverAuthStorage).toHaveBeenCalledTimes(1); + expect(discoverModels).toHaveBeenCalledTimes(1); }); it("skips OpenClaw auth and model discovery during dynamic model resolution", async () => { diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index f596faf871e..87e2a90f58d 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -17,7 +17,6 @@ import { normalizeProviderResolvedModelWithPlugin, shouldPreferProviderRuntimeResolvedModel, } from "../../plugins/provider-runtime.js"; -import { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js"; import { resolveDefaultAgentDir } from "../agent-scope.js"; import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../auth-profiles.js"; import type { AuthProfileCredential } from "../auth-profiles/types.js"; @@ -35,6 +34,10 @@ import { canonicalizeOpenAIModelId, listOpenAIAuthProfileProvidersForAgentRuntime, } from "../openai-routing.js"; +import { + getPreparedModelRuntimeSnapshot, + loadPreparedModelRuntimeSnapshot, +} from "../prepared-model-runtime.js"; import { attachModelProviderLocalService } from "../provider-local-service.js"; import { attachModelProviderRequestTransport, @@ -47,7 +50,6 @@ import { type AuthStorage, type ModelRegistry, } from "../sessions/index.js"; -import { discoverCachedAgentStores } from "./model-discovery-cache.js"; import { mergeModelCompat, mergeModelMediaInput, @@ -162,20 +164,29 @@ function resolveRuntimeHooks(params?: { return DEFAULT_PROVIDER_RUNTIME_HOOKS; } -function discoverCachedAgentStoresForAgent( +function resolvePreparedAgentSnapshot( resolvedAgentDir: string, cfg: OpenClawConfig | undefined, - workspaceDir: string | undefined, -): { - authStorage: AuthStorage; - modelRegistry: ModelRegistry; -} { - return discoverCachedAgentStores({ + explicitWorkspaceDir: string | undefined, + derivedWorkspaceDir: string | undefined, + agentId: string | undefined, +): ReturnType { + const base = { + ...(agentId ? { agentId } : {}), agentDir: resolvedAgentDir, - ...(cfg ? { config: cfg } : {}), + config: cfg ?? {}, inheritedAuthDir: resolveDefaultAgentDir(cfg ?? {}), - ...(workspaceDir ? { workspaceDir } : {}), + }; + const published = getPreparedModelRuntimeSnapshot({ + ...base, + ...(explicitWorkspaceDir ? { workspaceDir: explicitWorkspaceDir } : {}), }); + if (published || explicitWorkspaceDir || !derivedWorkspaceDir) { + return published; + } + // Standalone runs publish an exact workspace owner. Gateway owners may instead carry an + // authoritative launch workspace, which the workspace-free lookup above resolves by agent. + return getPreparedModelRuntimeSnapshot({ ...base, workspaceDir: derivedWorkspaceDir }); } function canonicalizeLegacyResolvedModel(params: { provider: string; model: Model }): Model { @@ -1593,6 +1604,7 @@ export function resolveModel( options?: { authStorage?: AuthStorage; modelRegistry?: ModelRegistry; + agentId?: string; runtimeHooks?: ProviderRuntimeHooks; skipProviderRuntimeHooks?: boolean; workspaceDir?: string; @@ -1606,27 +1618,35 @@ export function resolveModel( authStorage: AuthStorage; modelRegistry: ModelRegistry; } { - const workspaceDir = resolveModelWorkspaceDir(cfg, options?.workspaceDir); - const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir }); const resolvedAgentDir = agentDir ?? resolveDefaultAgentDir(cfg ?? {}); - const cachedStores = - !options?.authStorage && !options?.modelRegistry - ? discoverCachedAgentStoresForAgent(resolvedAgentDir, cfg, workspaceDir) + const derivedWorkspaceDir = resolveModelWorkspaceDir(cfg, options?.workspaceDir); + const preparedSnapshot = + !options?.authStorage || !options?.modelRegistry + ? resolvePreparedAgentSnapshot( + resolvedAgentDir, + cfg, + options?.workspaceDir, + derivedWorkspaceDir, + options?.agentId, + ) : undefined; - const authStorage = - options?.authStorage ?? - cachedStores?.authStorage ?? - discoverAuthStorage(resolvedAgentDir, { - ...(cfg ? { config: cfg } : {}), - ...(workspaceDir ? { workspaceDir } : {}), - }); + if ((!options?.authStorage || !options?.modelRegistry) && !preparedSnapshot) { + // Synchronous callers must enter through a lifecycle that already published discovery. + // Falling back to an empty registry turns a stale/pending generation into a false model miss. + throw new Error( + `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 ?? - cachedStores?.modelRegistry ?? - discoverModels(authStorage, resolvedAgentDir, { - ...(cfg ? { config: cfg } : {}), - ...(workspaceDir ? { workspaceDir } : {}), - }); + (options?.authStorage + ? preparedStores!.modelRegistry.fork(authStorage) + : preparedStores!.modelRegistry); const runtimeHooks = resolveRuntimeHooks(options); const model = resolveModelWithPreparedRegistry({ provider: normalizedRef.provider, @@ -1667,6 +1687,7 @@ export async function resolveModelAsync( options?: { authStorage?: AuthStorage; modelRegistry?: ModelRegistry; + agentId?: string; allowBundledStaticCatalogFallback?: boolean; preferBundledStaticCatalogTransport?: boolean; retryTransientProviderRuntimeMiss?: boolean; @@ -1685,33 +1706,45 @@ export async function resolveModelAsync( authStorage: AuthStorage; modelRegistry: ModelRegistry; }> { - const workspaceDir = resolveModelWorkspaceDir(cfg, options?.workspaceDir); - const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir }); const resolvedAgentDir = agentDir ?? resolveDefaultAgentDir(cfg ?? {}); + const derivedWorkspaceDir = resolveModelWorkspaceDir(cfg, options?.workspaceDir); const emptyDiscoveryStores = options?.skipAgentDiscovery && (!options.authStorage || !options.modelRegistry) ? createEmptyAgentDiscoveryStores() : undefined; - const cachedStores = - !emptyDiscoveryStores && !options?.authStorage && !options?.modelRegistry - ? discoverCachedAgentStoresForAgent(resolvedAgentDir, cfg, workspaceDir) + const publishedSnapshot = + !emptyDiscoveryStores && (!options?.authStorage || !options?.modelRegistry) + ? resolvePreparedAgentSnapshot( + resolvedAgentDir, + cfg, + options?.workspaceDir, + derivedWorkspaceDir, + options?.agentId, + ) : undefined; - const authStorage = - options?.authStorage ?? - emptyDiscoveryStores?.authStorage ?? - cachedStores?.authStorage ?? - discoverAuthStorage(resolvedAgentDir, { - ...(cfg ? { config: cfg } : {}), - ...(workspaceDir ? { workspaceDir } : {}), - }); + const preparedSnapshot = + publishedSnapshot ?? + (!emptyDiscoveryStores && (!options?.authStorage || !options?.modelRegistry) + ? await loadPreparedModelRuntimeSnapshot({ + ...(options?.agentId ? { agentId: options.agentId } : {}), + agentDir: resolvedAgentDir, + config: cfg ?? {}, + inheritedAuthDir: resolveDefaultAgentDir(cfg ?? {}), + ...(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 ?? - emptyDiscoveryStores?.modelRegistry ?? - cachedStores?.modelRegistry ?? - discoverModels(authStorage, resolvedAgentDir, { - ...(cfg ? { config: cfg } : {}), - ...(workspaceDir ? { workspaceDir } : {}), - }); + (options?.authStorage + ? fallbackStores.modelRegistry.fork(authStorage) + : fallbackStores.modelRegistry); const runtimeHooks = resolveRuntimeHooks(options); if (normalizedRef.manifestAlias.ambiguous) { return { diff --git a/src/agents/embedded-agent-runner/run-loop.ts b/src/agents/embedded-agent-runner/run-loop.ts index e2b2928ff5e..9dcbb06d1fa 100644 --- a/src/agents/embedded-agent-runner/run-loop.ts +++ b/src/agents/embedded-agent-runner/run-loop.ts @@ -87,6 +87,7 @@ export async function runPreparedEmbeddedLoop( markStartupStage: (stage) => startupStages.mark(stage), notifyExecutionPhase, fallbackConfigured, + preparedModelRuntime: input.preparedModelRuntime, }); provider = preparedRuntime.provider; modelId = preparedRuntime.modelId; diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index e74c7640556..8dceb9699b0 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -4,6 +4,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import { getRuntimeConfigSnapshot } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { revokeMessageActionTurnCapability } from "../../gateway/message-action-turn-capability.js"; import { captureAgentRunLifecycleGeneration, @@ -21,7 +22,12 @@ import { import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; -import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agent-scope.js"; +import { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentDir, +} from "../agent-scope.js"; +import { acquireAgentRunPreparedModelRuntime } from "../prepared-model-runtime.js"; import { applyAgentRunSessionTargetIdentity, resolveAgentRunSessionTarget, @@ -50,12 +56,15 @@ import type { } from "./run/internal-params.js"; import { createEmbeddedRunLaneController } from "./run/lane-controller.js"; import type { RunEmbeddedAgentParams } from "./run/params.js"; +import { bindRunToPreparedModelRuntime } from "./run/prepared-runtime-context.js"; import { createEmbeddedRunProgressController } from "./run/progress-controller.js"; import { createRecoveryMessageActionTurnCapability } from "./run/recovery-message-action-capability.js"; import { resolveInitialEmbeddedRunModel } from "./run/runtime-resolution.js"; import { assertAgentHarnessRunAdmission, backfillSessionKey } from "./run/session-bootstrap.js"; import type { EmbeddedAgentRunResult } from "./types.js"; +const EMPTY_EMBEDDED_AGENT_CONFIG: OpenClawConfig = Object.freeze({}); + export function runEmbeddedAgent( paramsInput: RunEmbeddedAgentParams, ): Promise { @@ -177,133 +186,165 @@ async function runEmbeddedAgentInternal( } const started = Date.now(); const startupStages = createEmbeddedRunStageTracker(); - const progressController = createEmbeddedRunProgressController({ - attempt: params, - noteLaneTaskProgress, - startedAtMs: started, - }); - const { notifyExecutionPhase } = progressController; - const emitStartupStageSummary = createEmbeddedRunStageSummaryEmitter({ - label: "startup stages", - log, - runId: params.runId, - sessionId: params.sessionId, - tracker: startupStages, - }); - params.onExecutionStarted?.({ lifecycleGeneration }); - notifyExecutionPhase("runner_entered"); - const workspaceResolution = resolveRunWorkspaceDir({ + const requestedWorkspaceResolution = resolveRunWorkspaceDir({ workspaceDir: params.workspaceDir, sessionKey: params.sessionKey, agentId: params.agentId, config: params.config, }); - const resolvedWorkspace = workspaceResolution.workspaceDir; - const canonicalWorkspace = resolveUserPath( - resolveAgentWorkspaceDir(params.config ?? {}, workspaceResolution.agentId), - ); - const isCanonicalWorkspace = canonicalWorkspace === resolvedWorkspace; - const redactedSessionId = redactRunIdentifier(params.sessionId); - const redactedSessionKey = redactRunIdentifier(params.sessionKey); - const redactedWorkspace = redactRunIdentifier(resolvedWorkspace); - if (workspaceResolution.usedFallback) { - log.warn( - `[workspace-fallback] caller=runEmbeddedAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`, - ); - } - startupStages.mark("workspace"); - notifyExecutionPhase("workspace"); - ensureRuntimePluginsLoaded({ - config: params.config, - workspaceDir: resolvedWorkspace, - allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, - }); - startupStages.mark("runtime-plugins"); - notifyExecutionPhase("runtime_plugins"); - - const { provider, modelId } = resolveInitialEmbeddedRunModel({ - config: params.config, - agentId: workspaceResolution.agentId, - provider: params.provider, - model: params.model, - }); - const agentDir = - params.agentDir ?? resolveAgentDir(params.config ?? {}, workspaceResolution.agentId); - const normalizedSessionKey = params.sessionKey?.trim(); - const fallbackConfigured = hasEmbeddedRunConfiguredModelFallbacks({ - cfg: params.config, - agentId: params.agentId, - sessionKey: normalizedSessionKey, - modelFallbacksOverride: params.modelFallbacksOverride, - }); - const resolvedSessionKey = - normalizedSessionKey ?? params.sessionTarget?.sessionKey ?? params.sessionId; - const hookRunner = getGlobalHookRunner(); - const hookCtx = { - runId: params.runId, - jobId: params.jobId, - agentId: workspaceResolution.agentId, - sessionKey: resolvedSessionKey, - sessionId: params.sessionId, - workspaceDir: resolvedWorkspace, - modelProviderId: provider, - modelId, - trigger: params.trigger, - ...buildAgentHookContextChannelFields(params), - ...buildAgentHookContextIdentityFields({ - trigger: params.trigger, - senderId: params.senderId, - chatId: params.chatId, - channelContext: params.channelContext, - }), + const config = params.config ?? EMPTY_EMBEDDED_AGENT_CONFIG; + const requestedAgentDir = + params.agentDir ?? resolveAgentDir(config, requestedWorkspaceResolution.agentId); + const preparedInput = { + config, + agentId: requestedWorkspaceResolution.agentId, + agentDir: requestedAgentDir, + inheritedAuthDir: resolveDefaultAgentDir(config), + workspaceDir: requestedWorkspaceResolution.workspaceDir, + preserveWorkspaceDirOnRefresh: !requestedWorkspaceResolution.isCanonicalWorkspace, }; - const hookResult = await runBeforeAgentReplyForTurn({ - runId: params.runId, - trigger: params.trigger, - event: { cleanedBody: params.prompt }, - context: hookCtx, - onDispatch: () => notifyExecutionPhase("before_agent_reply", { provider, model: modelId }), - onDeclined: () => notifyExecutionPhase("runtime_plugins", { provider, model: modelId }), - }); - if (hookResult?.handled) { - return { - payloads: buildHandledBeforeAgentReplyPayloads(hookResult.reply), - meta: { - durationMs: Date.now() - started, - agentMeta: { - sessionId: params.sessionId, - provider, - model: modelId, - }, - finalAssistantVisibleText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, - finalAssistantRawText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, - }, - }; - } + // Every admitted run leases its exact generation. Non-gateway hosts release dynamic + // workspaces after the run so one-off workspace paths cannot accumulate persistent owners. + const preparedModelRuntimeLease = await acquireAgentRunPreparedModelRuntime(preparedInput); + const preparedModelRuntime = preparedModelRuntimeLease.snapshot; + try { + // A reload may complete while admission waits. The committed generation owns config, + // directories, model selection, hooks, fallbacks, and every later run projection. + const rebound = bindRunToPreparedModelRuntime({ + runParams: params, + requestedWorkspaceResolution, + preparedModelRuntime, + }); + params = rebound.runParams; + const workspaceResolution = rebound.workspaceResolution; + const preparedAgentId = workspaceResolution.agentId; + const resolvedWorkspace = workspaceResolution.workspaceDir; + const agentDir = preparedModelRuntime.agentDir; + const progressController = createEmbeddedRunProgressController({ + attempt: params, + noteLaneTaskProgress, + startedAtMs: started, + }); + const { notifyExecutionPhase } = progressController; + const emitStartupStageSummary = createEmbeddedRunStageSummaryEmitter({ + label: "startup stages", + log, + runId: params.runId, + sessionId: params.sessionId, + tracker: startupStages, + }); + params.onExecutionStarted?.({ lifecycleGeneration }); + notifyExecutionPhase("runner_entered"); + const canonicalWorkspace = resolveUserPath( + resolveAgentWorkspaceDir(preparedModelRuntime.config, preparedAgentId), + ); + const isCanonicalWorkspace = canonicalWorkspace === resolvedWorkspace; + const redactedSessionId = redactRunIdentifier(params.sessionId); + const redactedSessionKey = redactRunIdentifier(params.sessionKey); + const redactedWorkspace = redactRunIdentifier(resolvedWorkspace); + if (requestedWorkspaceResolution.usedFallback) { + log.warn( + `[workspace-fallback] caller=runEmbeddedAgent reason=${requestedWorkspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${preparedAgentId} workspace=${redactedWorkspace}`, + ); + } + startupStages.mark("workspace"); + notifyExecutionPhase("workspace"); + ensureRuntimePluginsLoaded({ + config: preparedModelRuntime.config, + workspaceDir: resolvedWorkspace, + ...(params.allowGatewaySubagentBinding !== undefined + ? { allowGatewaySubagentBinding: params.allowGatewaySubagentBinding } + : {}), + }); + startupStages.mark("runtime-plugins"); + notifyExecutionPhase("runtime_plugins"); - return executePreparedEmbeddedRun({ - runParams: params, - provider, - modelId, - agentDir, - workspaceResolution, - workspaceDir: resolvedWorkspace, - isCanonicalWorkspace, - globalLane, - hookRunner, - hookContext: hookCtx, - fallbackConfigured, - isProbeSession, - resolvedSessionKey, - resolvedToolResultFormat, - startedAtMs: started, - startupStages, - emitStartupStageSummary, - progressController, - laneController, - lifecycleGeneration, - suspendForFailure, - }); + const { provider, modelId } = resolveInitialEmbeddedRunModel({ + config: params.config, + agentId: workspaceResolution.agentId, + provider: params.provider, + model: params.model, + }); + const normalizedSessionKey = params.sessionKey?.trim(); + const fallbackConfigured = hasEmbeddedRunConfiguredModelFallbacks({ + cfg: params.config, + agentId: params.agentId, + sessionKey: normalizedSessionKey, + modelFallbacksOverride: params.modelFallbacksOverride, + }); + const resolvedSessionKey = + normalizedSessionKey ?? params.sessionTarget?.sessionKey ?? params.sessionId; + const hookRunner = getGlobalHookRunner(); + const hookCtx = { + runId: params.runId, + jobId: params.jobId, + agentId: workspaceResolution.agentId, + sessionKey: resolvedSessionKey, + sessionId: params.sessionId, + workspaceDir: resolvedWorkspace, + modelProviderId: provider, + modelId, + trigger: params.trigger, + ...buildAgentHookContextChannelFields(params), + ...buildAgentHookContextIdentityFields({ + trigger: params.trigger, + senderId: params.senderId, + chatId: params.chatId, + channelContext: params.channelContext, + }), + }; + const hookResult = await runBeforeAgentReplyForTurn({ + runId: params.runId, + trigger: params.trigger, + event: { cleanedBody: params.prompt }, + context: hookCtx, + onDispatch: () => + notifyExecutionPhase("before_agent_reply", { provider, model: modelId }), + onDeclined: () => notifyExecutionPhase("runtime_plugins", { provider, model: modelId }), + }); + if (hookResult?.handled) { + return { + payloads: buildHandledBeforeAgentReplyPayloads(hookResult.reply), + meta: { + durationMs: Date.now() - started, + agentMeta: { + sessionId: params.sessionId, + provider, + model: modelId, + }, + finalAssistantVisibleText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, + finalAssistantRawText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, + }, + }; + } + + return await executePreparedEmbeddedRun({ + runParams: params, + provider, + modelId, + agentDir, + workspaceResolution, + workspaceDir: resolvedWorkspace, + isCanonicalWorkspace, + globalLane, + hookRunner, + hookContext: hookCtx, + fallbackConfigured, + isProbeSession, + resolvedSessionKey, + resolvedToolResultFormat, + startedAtMs: started, + startupStages, + emitStartupStageSummary, + progressController, + laneController, + lifecycleGeneration, + suspendForFailure, + preparedModelRuntime, + }); + } finally { + preparedModelRuntimeLease.release(); + } }); }).finally(() => { revokeMessageActionTurnCapability(recoveryMessageActionTurnCapability); diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index 1c6ad23554a..c7ffce6aeda 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -806,11 +806,12 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ }); vi.doMock("../workspace-run.js", () => ({ - resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ + resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string; agentId?: string }) => ({ workspaceDir: params.workspaceDir, usedFallback: false, + isCanonicalWorkspace: false, fallbackReason: undefined, - agentId: "main", + agentId: params.agentId ?? "main", })), redactRunIdentifier: vi.fn((value?: string) => value ?? ""), })); @@ -876,6 +877,23 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ ensureOpenClawModelsJson: vi.fn(async () => {}), })); + vi.doMock("../prepared-model-runtime.js", () => ({ + activateStandalonePreparedModelRuntime: vi.fn(async () => {}), + acquireAgentRunPreparedModelRuntime: vi.fn(async (input: Record) => ({ + snapshot: { + agentId: input.agentId, + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + createStores: () => ({ authStorage: {}, modelRegistry: {} }), + }, + release: vi.fn(), + })), + prepareModelRuntimeSnapshot: vi.fn(async () => ({ + createStores: () => ({ authStorage: {}, modelRegistry: {} }), + })), + })); + vi.doMock("../context-window-guard.js", () => ({ CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000, evaluateContextWindowGuard: mockedEvaluateContextWindowGuard, diff --git a/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts b/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts index b4dd81e687a..114a7adb367 100644 --- a/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts +++ b/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts @@ -176,6 +176,7 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: { workspaceDir, isCanonicalWorkspace, agentDir, + preparedModelRuntime: runInput.preparedModelRuntime, contextEngine: nativeModelOwned ? undefined : contextEngine, contextTokenBudget: runtime.contextTokenBudget, contextWindowInfo: runtime.contextWindowInfo, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index c2d14080f7a..9b7375337b2 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -245,6 +245,7 @@ export function prepareEmbeddedAttemptToolBase(params: { oneShotCliRun: attempt.oneShotCliRun, toolSearchCatalogRef, agentDir: params.agentDir, + preparedModelRuntime: attempt.preparedModelRuntime, cwd: params.effectiveCwd, workspaceDir: params.effectiveWorkspace, spawnWorkspaceDir, diff --git a/src/agents/embedded-agent-runner/run/execution-context.ts b/src/agents/embedded-agent-runner/run/execution-context.ts index 191ed015576..1b2ea579f27 100644 --- a/src/agents/embedded-agent-runner/run/execution-context.ts +++ b/src/agents/embedded-agent-runner/run/execution-context.ts @@ -1,4 +1,5 @@ import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; +import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js"; import type { SessionSuspensionParams } from "../../session-suspension.js"; import { resolveRunWorkspaceDir } from "../../workspace-run.js"; import { createEmbeddedRunStageTracker } from "./attempt-stage-timing.js"; @@ -30,4 +31,5 @@ export type PreparedEmbeddedRunInput = { laneController: ReturnType; lifecycleGeneration: NonNullable; suspendForFailure: (params: Omit) => void; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; }; diff --git a/src/agents/embedded-agent-runner/run/model-setup.ts b/src/agents/embedded-agent-runner/run/model-setup.ts index 9b3a0485b8a..e1f5794ee29 100644 --- a/src/agents/embedded-agent-runner/run/model-setup.ts +++ b/src/agents/embedded-agent-runner/run/model-setup.ts @@ -1,8 +1,12 @@ +import { resolveDefaultAgentDir } from "../../agent-scope.js"; import { FailoverError } from "../../failover-error.js"; import { ensureSelectedAgentHarnessPlugin } from "../../harness/runtime-plugin.js"; import { selectAgentHarness } from "../../harness/selection.js"; -import { ensureOpenClawModelsJson } from "../../models-config.js"; import { resolveSelectedOpenAIRuntimeProvider } from "../../openai-routing.js"; +import { + prepareModelRuntimeSnapshot, + type PreparedModelRuntimeSnapshot, +} from "../../prepared-model-runtime.js"; import { createEmptyAgentDiscoveryStores, resolveModelAsync } from "../model.js"; import type { RunEmbeddedAgentParams } from "./params.js"; import { resolveRequestStreamTransportOverrides } from "./runtime-resolution.js"; @@ -23,6 +27,7 @@ export async function resolveEmbeddedRunModelSetup(params: { hookRunner: Parameters[0]["hookRunner"]; hookContext: Parameters[0]["hookContext"]; onHooksResolved: () => void; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; }) { const runParams = params.runParams; const hookSelection = await resolveHookModelSelection({ @@ -137,9 +142,16 @@ export async function resolveEmbeddedRunModelSetup(params: { modelResolution = firstModelResolution; } if (!modelResolution) { - await ensureOpenClawModelsJson(runParams.config, params.agentDir, { - workspaceDir: params.workspaceDir, - }); + const config = runParams.config ?? {}; + const preparedModelRuntime = + params.preparedModelRuntime ?? + (await prepareModelRuntimeSnapshot({ + config, + agentDir: params.agentDir, + inheritedAuthDir: resolveDefaultAgentDir(config), + workspaceDir: params.workspaceDir, + })); + const preparedStores = preparedModelRuntime.createStores(); for (const candidateProvider of modelResolutionProviders) { const candidateResolution = await resolveModelAsync( candidateProvider, @@ -147,6 +159,8 @@ export async function resolveEmbeddedRunModelSetup(params: { params.agentDir, runParams.config, { + authStorage: preparedStores.authStorage, + modelRegistry: preparedStores.modelRegistry, workspaceDir: params.workspaceDir, authProfileId: runParams.authProfileId, allowBundledStaticCatalogFallback: true, diff --git a/src/agents/embedded-agent-runner/run/prepared-runtime-context.test.ts b/src/agents/embedded-agent-runner/run/prepared-runtime-context.test.ts new file mode 100644 index 00000000000..10a85f82d82 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/prepared-runtime-context.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js"; +import type { RunEmbeddedAgentParamsWithSessionFile } from "./internal-params.js"; +import { bindRunToPreparedModelRuntime } from "./prepared-runtime-context.js"; + +describe("bindRunToPreparedModelRuntime", () => { + it("replaces queued config and directories with one committed generation", () => { + const requestedConfig = { logging: { level: "info" as const } }; + const committedConfig = { logging: { level: "debug" as const } }; + const runParams = { + runId: "run-1", + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + prompt: "hello", + config: requestedConfig, + agentId: "requested-agent", + agentDir: "/tmp/requested-agent", + workspaceDir: "/tmp/requested-workspace", + } as RunEmbeddedAgentParamsWithSessionFile; + const preparedModelRuntime = { + agentId: "committed-agent", + agentDir: "/tmp/committed-agent", + workspaceDir: "/tmp/committed-workspace", + config: committedConfig, + } as PreparedModelRuntimeSnapshot; + + const result = bindRunToPreparedModelRuntime({ + runParams, + requestedWorkspaceResolution: { + agentId: "requested-agent", + agentIdSource: "explicit", + workspaceDir: "/tmp/requested-workspace", + usedFallback: true, + isCanonicalWorkspace: true, + fallbackReason: "missing", + }, + preparedModelRuntime, + }); + + expect(result.runParams).toEqual( + expect.objectContaining({ + agentId: "committed-agent", + agentDir: "/tmp/committed-agent", + config: committedConfig, + workspaceDir: "/tmp/committed-workspace", + }), + ); + expect(result.workspaceResolution).toEqual({ + agentId: "committed-agent", + agentIdSource: "explicit", + workspaceDir: "/tmp/committed-workspace", + usedFallback: true, + isCanonicalWorkspace: true, + fallbackReason: "missing", + }); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/prepared-runtime-context.ts b/src/agents/embedded-agent-runner/run/prepared-runtime-context.ts new file mode 100644 index 00000000000..dc2ffb5f3bf --- /dev/null +++ b/src/agents/embedded-agent-runner/run/prepared-runtime-context.ts @@ -0,0 +1,32 @@ +import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js"; +import type { ResolveRunWorkspaceResult } from "../../workspace-run.js"; +import type { RunEmbeddedAgentParamsWithSessionFile } from "./internal-params.js"; + +/** Rebinds every config-derived run projection to one committed prepared generation. */ +export function bindRunToPreparedModelRuntime(params: { + runParams: RunEmbeddedAgentParamsWithSessionFile; + requestedWorkspaceResolution: ResolveRunWorkspaceResult; + preparedModelRuntime: PreparedModelRuntimeSnapshot; +}): { + runParams: RunEmbeddedAgentParamsWithSessionFile; + workspaceResolution: ResolveRunWorkspaceResult; +} { + const preparedAgentId = + params.preparedModelRuntime.agentId ?? params.requestedWorkspaceResolution.agentId; + const workspaceResolution = { + ...params.requestedWorkspaceResolution, + agentId: preparedAgentId, + workspaceDir: + params.preparedModelRuntime.workspaceDir ?? params.requestedWorkspaceResolution.workspaceDir, + }; + return { + runParams: { + ...params.runParams, + agentId: preparedAgentId, + agentDir: params.preparedModelRuntime.agentDir, + config: params.preparedModelRuntime.config, + workspaceDir: workspaceResolution.workspaceDir, + }, + workspaceResolution, + }; +} diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index ebf1454aba2..2a4cad49029 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -32,6 +32,7 @@ type AttemptRuntime = { workspaceDir: string; isCanonicalWorkspace: boolean; agentDir: string; + preparedModelRuntime?: EmbeddedRunAttemptParams["preparedModelRuntime"]; contextEngine?: EmbeddedRunAttemptParams["contextEngine"]; contextTokenBudget?: number; contextWindowInfo?: EmbeddedRunAttemptParams["contextWindowInfo"]; @@ -204,6 +205,7 @@ export async function dispatchEmbeddedRunAttempt(input: { workspaceDir: runtime.workspaceDir, cwd: params.cwd, agentDir: runtime.agentDir, + preparedModelRuntime: runtime.preparedModelRuntime, config: params.config, allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, ...(runtime.contextEngine diff --git a/src/agents/embedded-agent-runner/run/runtime-preparation.ts b/src/agents/embedded-agent-runner/run/runtime-preparation.ts index 7c814a1cef9..b3a4e2a5e34 100644 --- a/src/agents/embedded-agent-runner/run/runtime-preparation.ts +++ b/src/agents/embedded-agent-runner/run/runtime-preparation.ts @@ -2,6 +2,7 @@ import type { ThinkLevel } from "../../../auto-reply/thinking.js"; import type { AuthProfileStore } from "../../auth-profiles.js"; import { isProfileInCooldown } from "../../auth-profiles.js"; import type { ResolvedProviderAuth } from "../../model-auth.js"; +import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js"; import { hasPreparedAuthAttemptModelMetadata, resolveCredentialScopedAuthAttemptModelDecision, @@ -50,6 +51,7 @@ export async function prepareEmbeddedRunRuntime(input: { context?: Omit>[0], "phase">, ) => void; fallbackConfigured: boolean; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; }) { const params = input.runParams; let provider = input.provider; @@ -64,6 +66,7 @@ export async function prepareEmbeddedRunRuntime(input: { hookRunner: input.hookRunner, hookContext: input.hookContext, onHooksResolved: () => input.markStartupStage("hooks"), + preparedModelRuntime: input.preparedModelRuntime, }); provider = modelSetup.provider; modelId = modelSetup.modelId; diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index 98c4ae3a676..cf73d06960d 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -22,6 +22,7 @@ import type { } from "../../embedded-agent-messaging.types.js"; import type { AgentHarnessRuntimeArtifactBinding } from "../../harness/runtime-artifact.types.js"; import type { McpAppChannelView } from "../../mcp-ui-resource.js"; +import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js"; import type { AgentRunTimeoutPhase } from "../../run-timeout-attribution.js"; import type { AgentRuntimePlan } from "../../runtime-plan/types.js"; import type { AgentMessage } from "../../runtime/index.js"; @@ -92,6 +93,7 @@ export type EmbeddedRunAttemptTrajectoryRecorder = { }; export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & { + preparedModelRuntime?: PreparedModelRuntimeSnapshot; /** Active file-backed artifact target resolved by the run/session target seam. */ sessionFile: string; initialReplayState?: EmbeddedRunReplayState; diff --git a/src/agents/embedded-agent-runner/usage-reporting.test.ts b/src/agents/embedded-agent-runner/usage-reporting.test.ts index 520153b5e55..e213d4dff45 100644 --- a/src/agents/embedded-agent-runner/usage-reporting.test.ts +++ b/src/agents/embedded-agent-runner/usage-reporting.test.ts @@ -71,7 +71,7 @@ describe("runEmbeddedAgent usage reporting", () => { }); expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ - config: undefined, + config: {}, workspaceDir: "/tmp/workspace", }); }); @@ -95,7 +95,7 @@ describe("runEmbeddedAgent usage reporting", () => { }); expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ - config: undefined, + config: {}, workspaceDir: "/tmp/workspace", allowGatewaySubagentBinding: true, }); diff --git a/src/agents/model-catalog-browse.test.ts b/src/agents/model-catalog-browse.test.ts index b16e0f15875..f1f1784dd71 100644 --- a/src/agents/model-catalog-browse.test.ts +++ b/src/agents/model-catalog-browse.test.ts @@ -7,7 +7,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; import { buildProviderConfigModelCatalogForBrowse, - loadModelCatalogSnapshotForBrowse, + loadPreparedModelCatalogSnapshotForBrowse, } from "./model-catalog-browse.js"; import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; @@ -35,7 +35,7 @@ function config(params: { providerWildcard?: boolean } = {}): OpenClawConfig { } as OpenClawConfig; } -describe("loadModelCatalogSnapshotForBrowse", () => { +describe("loadPreparedModelCatalogSnapshotForBrowse", () => { beforeEach(() => { vi.useRealTimers(); }); @@ -51,9 +51,9 @@ describe("loadModelCatalogSnapshotForBrowse", () => { readOnly ? readOnlyCatalog : fullCatalog, ); - await expect(loadModelCatalogSnapshotForBrowse({ cfg: config(), loadCatalog })).resolves.toBe( - readOnlyCatalog, - ); + await expect( + loadPreparedModelCatalogSnapshotForBrowse({ cfg: config(), loadCatalog }), + ).resolves.toBe(readOnlyCatalog); expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true }); }); @@ -64,7 +64,7 @@ describe("loadModelCatalogSnapshotForBrowse", () => { ); await expect( - loadModelCatalogSnapshotForBrowse({ cfg: config(), view: "all", loadCatalog }), + loadPreparedModelCatalogSnapshotForBrowse({ cfg: config(), view: "all", loadCatalog }), ).resolves.toBe(fullCatalog); expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false }); @@ -76,7 +76,10 @@ describe("loadModelCatalogSnapshotForBrowse", () => { ); await expect( - loadModelCatalogSnapshotForBrowse({ cfg: config({ providerWildcard: true }), loadCatalog }), + loadPreparedModelCatalogSnapshotForBrowse({ + cfg: config({ providerWildcard: true }), + loadCatalog, + }), ).resolves.toBe(readOnlyCatalog); expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true }); @@ -88,7 +91,7 @@ describe("loadModelCatalogSnapshotForBrowse", () => { ); await expect( - loadModelCatalogSnapshotForBrowse({ + loadPreparedModelCatalogSnapshotForBrowse({ cfg: config({ providerWildcard: true }), view: "configured", loadCatalog, @@ -107,7 +110,7 @@ describe("loadModelCatalogSnapshotForBrowse", () => { ); await expect( - loadModelCatalogSnapshotForBrowse({ cfg, view: "provider-config", loadCatalog }), + loadPreparedModelCatalogSnapshotForBrowse({ cfg, view: "provider-config", loadCatalog }), ).resolves.toBe(readOnlyCatalog); expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true }); @@ -145,7 +148,7 @@ describe("loadModelCatalogSnapshotForBrowse", () => { const onTimeout = vi.fn(); const loadCatalog = vi.fn(() => new Promise(() => {})); - const resultPromise = loadModelCatalogSnapshotForBrowse({ + const resultPromise = loadPreparedModelCatalogSnapshotForBrowse({ cfg: config({ providerWildcard: true }), loadCatalog, timeoutMs: 5, @@ -163,7 +166,7 @@ describe("loadModelCatalogSnapshotForBrowse", () => { const clearTimeout = vi.spyOn(globalThis, "clearTimeout"); const loadCatalog = vi.fn(async () => readOnlyCatalog); - const resultPromise = loadModelCatalogSnapshotForBrowse({ + const resultPromise = loadPreparedModelCatalogSnapshotForBrowse({ cfg: config(), loadCatalog, timeoutMs: Number.NaN, @@ -184,7 +187,7 @@ describe("loadModelCatalogSnapshotForBrowse", () => { const clearTimeout = vi.spyOn(globalThis, "clearTimeout"); const loadCatalog = vi.fn(async () => readOnlyCatalog); - const resultPromise = loadModelCatalogSnapshotForBrowse({ + const resultPromise = loadPreparedModelCatalogSnapshotForBrowse({ cfg: config(), loadCatalog, timeoutMs: Number.MAX_SAFE_INTEGER, diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index 4308159b700..2271e81dec7 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -34,7 +34,7 @@ export function buildProviderConfigModelCatalogForBrowse(params: { ); } -/** True when a browse view cannot be answered from read-only cached catalog entries. */ +/** True when a browse view requires the full published catalog generation. */ export function modelCatalogBrowseRequiresFullDiscovery(params: { cfg: OpenClawConfig; view?: ModelCatalogBrowseView; @@ -93,7 +93,7 @@ async function loadCatalogForBrowse(params: { } /** Loads an explicit logical/physical catalog snapshot for route-aware browse surfaces. */ -export function loadModelCatalogSnapshotForBrowse(params: { +export function loadPreparedModelCatalogSnapshotForBrowse(params: { cfg: OpenClawConfig; view?: ModelCatalogBrowseView; loadCatalog: (params: { readOnly: boolean }) => Promise; diff --git a/src/agents/model-catalog-state-cache.test.ts b/src/agents/model-catalog-state-cache.test.ts deleted file mode 100644 index e40fe3e57b6..00000000000 --- a/src/agents/model-catalog-state-cache.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; -import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; -import { - buildAgentModelCatalogCacheKey, - readCachedAgentModelCatalog, - readCachedAgentModelCatalogSnapshot, - writeCachedAgentModelCatalog, -} from "./model-catalog-state-cache.js"; - -let envSnapshot: ReturnType; -let stateDir: string; - -function configuredModel(id: string) { - return { - id, - name: id, - reasoning: false, - input: ["text"] as Array<"text">, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128_000, - maxTokens: 8192, - }; -} - -describe("model catalog state cache", () => { - beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); - stateDir = mkdtempSync(join(tmpdir(), "openclaw-model-catalog-state-")); - setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - }); - - afterEach(() => { - closeOpenClawStateDatabaseForTest(); - envSnapshot.restore(); - rmSync(stateDir, { recursive: true, force: true }); - }); - - it("writes and reads agent catalog rows from shared state", () => { - const entries = [{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }]; - - writeCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "catalog-key", - entries, - nowMs: 1_000, - }); - - expect( - readCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "catalog-key", - nowMs: 1_000, - }), - ).toEqual(entries); - }); - - it("round-trips physical route variants atomically", () => { - const entries = [{ provider: "openai", id: "gpt-5.4-nano", name: "Platform" }]; - const routeVariants = [ - { ...entries[0], api: "openai-responses" }, - { ...entries[0], name: "ChatGPT", api: "openai-chatgpt-responses" }, - ]; - writeCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "variant-key", - entries, - routeVariants, - nowMs: 1_000, - }); - - expect( - readCachedAgentModelCatalogSnapshot({ - agentDir: "/agent/main", - catalogKey: "variant-key", - nowMs: 1_000, - }), - ).toEqual({ entries, routeVariants }); - }); - - it("treats legacy entry-only cache rows as a provenance miss", () => { - writeCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "legacy-key", - entries: [{ provider: "openai", id: "gpt-5.4-nano", name: "Collapsed" }], - nowMs: 1_000, - }); - - expect( - readCachedAgentModelCatalogSnapshot({ - agentDir: "/agent/main", - catalogKey: "legacy-key", - nowMs: 1_000, - }), - ).toBeUndefined(); - }); - - it("rejects stale or mismatched agent catalog rows", () => { - writeCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "catalog-key", - entries: [{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }], - nowMs: 1_000, - }); - - expect( - readCachedAgentModelCatalog({ - agentDir: "/agent/other", - catalogKey: "catalog-key", - nowMs: 1_000, - }), - ).toBeUndefined(); - expect( - readCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "catalog-key", - nowMs: 31 * 60 * 1_000, - }), - ).toBeUndefined(); - }); - - it("prunes expired agent catalog rows on write", () => { - const expiredEntries = [{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }]; - writeCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "expired-key", - entries: expiredEntries, - nowMs: 1_000, - }); - - writeCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "fresh-key", - entries: [{ provider: "openai", id: "gpt-5.6", name: "GPT-5.6" }], - nowMs: 31 * 60 * 1_000, - }); - - expect( - readCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "expired-key", - nowMs: 1_000, - }), - ).toBeUndefined(); - expect( - readCachedAgentModelCatalog({ - agentDir: "/agent/main", - catalogKey: "fresh-key", - nowMs: 31 * 60 * 1_000, - }), - ).toEqual([{ provider: "openai", id: "gpt-5.6", name: "GPT-5.6" }]); - }); - - it("builds stable keys that change with relevant catalog inputs", () => { - const base = buildAgentModelCatalogCacheKey({ - agentDir: "/agent/main", - workspaceDir: "/workspace", - config: { - models: { - providers: { - openai: { baseUrl: "https://api.openai.com/v1", models: [configuredModel("gpt-5.5")] }, - }, - }, - }, - metadataSnapshot: { - policyHash: "policy", - configFingerprint: "config", - index: { policyHash: "policy", plugins: [] }, - plugins: [], - } as never, - }); - const same = buildAgentModelCatalogCacheKey({ - agentDir: "/agent/main", - workspaceDir: "/workspace", - config: { - models: { - providers: { - openai: { baseUrl: "https://api.openai.com/v1", models: [configuredModel("gpt-5.5")] }, - }, - }, - }, - metadataSnapshot: { - policyHash: "policy", - configFingerprint: "config", - index: { policyHash: "policy", plugins: [] }, - plugins: [], - } as never, - }); - const changed = buildAgentModelCatalogCacheKey({ - agentDir: "/agent/main", - workspaceDir: "/workspace", - config: { - models: { - providers: { - openai: { baseUrl: "https://api.openai.com/v1", models: [configuredModel("gpt-5.6")] }, - }, - }, - }, - metadataSnapshot: { - policyHash: "policy", - configFingerprint: "config", - index: { policyHash: "policy", plugins: [] }, - plugins: [], - } as never, - }); - - expect(base).toBe(same); - expect(base).not.toBe(changed); - }); -}); diff --git a/src/agents/model-catalog-state-cache.ts b/src/agents/model-catalog-state-cache.ts deleted file mode 100644 index dca671bb3b8..00000000000 --- a/src/agents/model-catalog-state-cache.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { createHash } from "node:crypto"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - executeSqliteQuerySync, - executeSqliteQueryTakeFirstSync, - getNodeSqliteKysely, -} from "../infra/kysely-sync.js"; -import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; -import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; -import { - openOpenClawStateDatabase, - runOpenClawStateWriteTransaction, -} from "../state/openclaw-state-db.js"; -const AGENT_MODEL_CATALOG_CACHE_VERSION = 1; -const AGENT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 60 * 1000; - -type AgentModelCatalogDatabase = Pick; - -type CachedAgentModelCatalogPayload = { - version: typeof AGENT_MODEL_CATALOG_CACHE_VERSION; - entries: readonly unknown[]; - routeVariants?: readonly unknown[]; -}; - -type CachedAgentModelCatalogSnapshot = { - entries: unknown[]; - routeVariants: unknown[]; -}; - -type AgentModelCatalogCacheKeyInput = { - agentDir: string; - cacheScope?: unknown; - config: OpenClawConfig; - metadataSnapshot?: PluginMetadataSnapshot; - workspaceDir?: string; -}; - -type ReadCachedAgentModelCatalogParams = { - agentDir: string; - catalogKey: string; - nowMs?: number; -}; - -type WriteCachedAgentModelCatalogParams = { - agentDir: string; - catalogKey: string; - entries: readonly unknown[]; - routeVariants?: readonly unknown[]; - nowMs?: number; -}; - -function stableJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((entry) => stableJson(entry)).join(",")}]`; - } - if (value && typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record) - .toSorted() - .filter((key) => record[key] !== undefined && typeof record[key] !== "function") - .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) - .join(",")}}`; - } - return JSON.stringify(value); -} - -function metadataSnapshotCacheShape(snapshot: PluginMetadataSnapshot | undefined): unknown { - if (!snapshot) { - return undefined; - } - return { - configFingerprint: snapshot.configFingerprint, - policyHash: snapshot.policyHash, - indexPolicyHash: snapshot.index?.policyHash, - indexPlugins: snapshot.index?.plugins?.map((plugin) => ({ - enabled: plugin.enabled, - id: plugin.pluginId, - origin: plugin.origin, - })), - modelCatalogPlugins: snapshot.plugins.map((plugin) => ({ - id: plugin.id, - modelCatalog: plugin.modelCatalog, - origin: plugin.origin, - version: plugin.version, - })), - }; -} - -export function buildAgentModelCatalogCacheKey(input: AgentModelCatalogCacheKeyInput): string { - const payload = stableJson({ - version: AGENT_MODEL_CATALOG_CACHE_VERSION, - agentDir: input.agentDir, - cacheScope: input.cacheScope, - workspaceDir: input.workspaceDir, - config: input.config, - metadataSnapshot: metadataSnapshotCacheShape(input.metadataSnapshot), - }); - return `agent-model-catalog:v${AGENT_MODEL_CATALOG_CACHE_VERSION}:${createHash("sha256") - .update(payload) - .digest("hex")}`; -} - -function parseCachedAgentModelCatalog(rawJson: string): CachedAgentModelCatalogPayload | undefined { - const parsed = JSON.parse(rawJson) as CachedAgentModelCatalogPayload; - if (parsed?.version !== AGENT_MODEL_CATALOG_CACHE_VERSION || !Array.isArray(parsed.entries)) { - return undefined; - } - return parsed; -} - -function readCachedAgentModelCatalogPayload( - params: ReadCachedAgentModelCatalogParams, -): CachedAgentModelCatalogPayload | undefined { - try { - const database = openOpenClawStateDatabase(); - const db = getNodeSqliteKysely(database.db); - const row = executeSqliteQueryTakeFirstSync( - database.db, - db - .selectFrom("agent_model_catalogs") - .select(["raw_json", "updated_at"]) - .where("catalog_key", "=", params.catalogKey) - .where("agent_dir", "=", params.agentDir), - ); - if (!row || (params.nowMs ?? Date.now()) - row.updated_at > AGENT_MODEL_CATALOG_CACHE_TTL_MS) { - return undefined; - } - return parseCachedAgentModelCatalog(row.raw_json); - } catch { - return undefined; - } -} - -export function readCachedAgentModelCatalog( - params: ReadCachedAgentModelCatalogParams, -): unknown[] | undefined { - return readCachedAgentModelCatalogPayload(params)?.entries as unknown[] | undefined; -} - -/** Reads only provenance-complete snapshots; legacy entry-only rows refresh. */ -export function readCachedAgentModelCatalogSnapshot( - params: ReadCachedAgentModelCatalogParams, -): CachedAgentModelCatalogSnapshot | undefined { - const payload = readCachedAgentModelCatalogPayload(params); - return payload && Array.isArray(payload.routeVariants) - ? { entries: [...payload.entries], routeVariants: [...payload.routeVariants] } - : undefined; -} - -export function writeCachedAgentModelCatalog(params: WriteCachedAgentModelCatalogParams): void { - if (params.entries.length === 0) { - return; - } - try { - const updatedAt = params.nowMs ?? Date.now(); - const rawJson = JSON.stringify({ - version: AGENT_MODEL_CATALOG_CACHE_VERSION, - entries: params.entries, - ...(params.routeVariants ? { routeVariants: params.routeVariants } : {}), - } satisfies CachedAgentModelCatalogPayload); - runOpenClawStateWriteTransaction((database) => { - const db = getNodeSqliteKysely(database.db); - executeSqliteQuerySync( - database.db, - db - .deleteFrom("agent_model_catalogs") - .where("updated_at", "<", updatedAt - AGENT_MODEL_CATALOG_CACHE_TTL_MS), - ); - executeSqliteQuerySync( - database.db, - db - .insertInto("agent_model_catalogs") - .values({ - catalog_key: params.catalogKey, - agent_dir: params.agentDir, - raw_json: rawJson, - updated_at: updatedAt, - }) - .onConflict((conflict) => - conflict.column("catalog_key").doUpdateSet({ - agent_dir: params.agentDir, - raw_json: rawJson, - updated_at: updatedAt, - }), - ), - ); - }); - } catch { - // Fall back to runtime discovery if local state storage is unavailable. - } -} diff --git a/src/agents/model-catalog.runtime.ts b/src/agents/model-catalog.runtime.ts index ab93a076a13..7a1997acfed 100644 --- a/src/agents/model-catalog.runtime.ts +++ b/src/agents/model-catalog.runtime.ts @@ -1,6 +1,6 @@ -/** Runtime barrel for model catalog loading helpers. */ +/** Runtime barrel for lifecycle-owned model catalog helpers. */ +export { loadManifestModelCatalog } from "./model-catalog.js"; export { - loadManifestModelCatalog, - loadModelCatalog, - loadModelCatalogSnapshot, -} from "./model-catalog.js"; + loadPreparedModelCatalog, + loadPreparedModelCatalogSnapshot, +} from "./prepared-model-catalog.js"; diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 4c70e6e9f25..35c224f82cc 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -1,2228 +1,219 @@ -// Covers model catalog loading, plugin manifests, normalization, and suppression. -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { resetLogger, setLoggerOverride } from "../logging/logger.js"; -import { PLUGIN_MODEL_CATALOG_GENERATED_BY } from "./plugin-model-catalog.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import { resolveOAuthApiKeyMarker } from "./model-auth-markers.js"; +import { + buildPreparedModelCatalogSnapshot, + findModelCatalogEntry, + modelSupportsDocument, + modelSupportsVision, +} from "./model-catalog.js"; +import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import type { ModelRegistry } from "./sessions/index.js"; -type AgentModelDiscoveryModule = typeof import("./agent-model-discovery.js"); +type AugmentModelCatalogWithProviderPlugins = + typeof import("../plugins/provider-runtime.js").augmentModelCatalogWithProviderPlugins; -let setModelCatalogImportForTest: typeof import("./model-catalog.js").setModelCatalogImportForTest; -let findModelCatalogEntry: typeof import("./model-catalog.js").findModelCatalogEntry; -let findModelInCatalog: typeof import("./model-catalog.js").findModelInCatalog; -let loadManifestModelCatalog: typeof import("./model-catalog.js").loadManifestModelCatalog; -let loadModelCatalog: typeof import("./model-catalog.js").loadModelCatalog; -let loadModelCatalogSnapshot: typeof import("./model-catalog.js").loadModelCatalogSnapshot; -let modelSupportsInput: typeof import("./model-catalog.js").modelSupportsInput; -let resetModelCatalogCache: typeof import("./model-catalog.js").resetModelCatalogCache; -let resetModelCatalogCacheForTest: typeof import("./model-catalog.js").resetModelCatalogCacheForTest; -let augmentCatalogMock: ReturnType; -let prepareOpenClawModelsJsonSourceMock: ReturnType; -let currentPluginMetadataSnapshotMock: ReturnType unknown>>; -let loadPluginMetadataSnapshotMock: ReturnType unknown>>; -let readFileMock: ReturnType Promise>>; -let buildAgentModelCatalogCacheKeyMock: ReturnType; -let buildModelsJsonSourceFingerprintMock: ReturnType; -let readCachedAgentModelCatalogMock: ReturnType< - typeof vi.fn<(params: { agentDir: string; catalogKey: string }) => unknown[] | undefined> ->; -let readCachedAgentModelCatalogSnapshotMock: ReturnType; -let writeCachedAgentModelCatalogMock: ReturnType; - -vi.mock("./model-suppression.runtime.js", () => ({ - shouldSuppressBuiltInModel: (params: { provider?: string; id?: string; baseUrl?: string }) => - isSuppressedModel(params.provider, params.id, params.baseUrl), - buildShouldSuppressBuiltInModel: - () => (params: { provider?: string; id?: string; baseUrl?: string }) => - isSuppressedModel(params.provider, params.id, params.baseUrl), +const mocks = vi.hoisted(() => ({ + augmentModelCatalogWithProviderPlugins: vi.fn( + async () => [], + ), })); -function isDirectOpenAiBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return true; - } - try { - return new URL(trimmed).hostname.toLowerCase().replace(/\.+$/, "") === "api.openai.com"; - } catch { - return false; - } +vi.mock("../plugins/provider-runtime.runtime.js", () => ({ + augmentModelCatalogWithProviderPlugins: ( + ...args: Parameters + ) => mocks.augmentModelCatalogWithProviderPlugins(...args), +})); + +const metadataSnapshot = { plugins: [] } as unknown as PluginMetadataSnapshot; + +function registry(entries: ModelCatalogEntry[]): ModelRegistry { + return { getAll: () => entries } as unknown as ModelRegistry; } -function isSuppressedModel(provider?: string, id?: string, baseUrl?: string): boolean { - const modelId = id?.trim().toLowerCase(); - if (!modelId) { - return false; - } - if (modelId !== "gpt-5.3-codex-spark") { - return false; - } - if (provider === "azure-openai-responses") { - return true; - } - return provider === "openai" && isDirectOpenAiBaseUrl(baseUrl); -} - -function mockCatalogImportFailThenRecover() { - // Simulates a transient discovery import failure so cache/error handling can - // prove the catalog loader recovers on the next attempt. - let call = 0; - setModelCatalogImportForTest(async () => { - call += 1; - if (call === 1) { - throw new Error("boom"); - } - return { - discoverAuthStorage: () => ({}), - AuthStorage: function AuthStorage() {}, - discoverModels: () => ({ - getAll() { - return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]; - }, - }), - ModelRegistry: class { - getAll() { - return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]; - } - }, - } as unknown as AgentModelDiscoveryModule; - }); - return () => call; -} - -function mockAgentDiscoveryModels(models: unknown[]) { - setModelCatalogImportForTest( - async () => - ({ - discoverAuthStorage: () => ({}), - AuthStorage: function AuthStorage() {}, - discoverModels: () => ({ - getAll() { - return models; - }, - }), - ModelRegistry: class { - getAll() { - return models; - } - }, - }) as unknown as AgentModelDiscoveryModule, - ); -} - -function mockSingleOpenAiCatalogModel() { - mockAgentDiscoveryModels([{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]); -} - -function emptyPluginMetadataSnapshot() { - return { - policyHash: "test-policy", - configFingerprint: "test-config", - index: { - policyHash: "test-policy", - plugins: [], - }, - owners: { - channels: new Map(), - channelConfigs: new Map(), - providers: new Map(), - modelCatalogProviders: new Map(), - cliBackends: new Map(), - setupProviders: new Map(), - commandAliases: new Map(), - contracts: new Map(), - }, - plugins: [], - }; -} - -function modelIdNormalizationSnapshot() { - return { - ...emptyPluginMetadataSnapshot(), - configFingerprint: "model-id-normalizers", - plugins: [ - { - id: "external-normalizer", - modelIdNormalization: { - providers: { - custom: { - aliases: { - latest: "modern-model", - }, - stripPrefixes: ["legacy/"], - prefixWhenBare: "vendor", - }, - }, - }, - }, - ], - }; -} - -function manifestModelCatalogSnapshot(model: { - id: string; - name?: string; - input?: Array<"text" | "image">; - reasoning?: boolean; - contextWindow?: number; +async function build(params: { + config?: OpenClawConfig; + entries?: ModelCatalogEntry[]; + readOnly?: boolean; }) { - // Minimal plugin metadata snapshot containing a manifest-owned external - // provider model catalog. - return { - policyHash: "policy", - index: { - policyHash: "policy", - plugins: [ + return await buildPreparedModelCatalogSnapshot({ + agentDir: "/tmp/model-catalog-test", + authCredentials: {}, + config: params.config ?? { plugins: { enabled: false } }, + metadataSnapshot, + modelRegistry: registry(params.entries ?? []), + readOnly: params.readOnly ?? true, + }); +} + +describe("prepared model catalog builder", () => { + beforeEach(() => { + mocks.augmentModelCatalogWithProviderPlugins.mockReset(); + mocks.augmentModelCatalogWithProviderPlugins.mockResolvedValue([]); + }); + + it("projects and sorts one lifecycle registry generation", async () => { + const snapshot = await build({ + entries: [ + { id: "z", name: "Zulu", provider: "beta", input: ["text"] }, { - pluginId: "external-provider", - enabled: true, - origin: "global", + id: "a", + name: "Alpha", + provider: "alpha", + contextWindow: 64_000, + input: ["text", "image"], }, ], - }, - plugins: [ - { - id: "external-provider", - origin: "global", - modelCatalog: { + }); + + expect(snapshot.entries.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([ + "alpha/a", + "beta/z", + ]); + expect(snapshot.routeVariants).toEqual(snapshot.entries); + }); + + it("overlays configured metadata onto discovered rows", async () => { + const config: OpenClawConfig = { + plugins: { enabled: false }, + models: { + providers: { + custom: { + baseUrl: "https://example.test/v1", + api: "openai-completions", + models: [ + { + id: "demo", + name: "Configured Demo", + contextWindow: 32_000, + maxTokens: 4_096, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }, + }, + }; + const snapshot = await build({ + config, + entries: [ + { + id: "demo", + name: "Discovered Demo", + provider: "custom", + input: ["text"], + }, + ], + }); + + expect( + findModelCatalogEntry(snapshot.entries, { provider: "custom", modelId: "demo" }), + ).toMatchObject({ + name: "Discovered Demo", + api: "openai-completions", + contextWindow: 32_000, + reasoning: true, + input: ["text", "image"], + }); + expect(snapshot.routeVariants).toHaveLength(2); + }); + + it("keeps configured models absent from registry discovery", async () => { + const snapshot = await build({ + config: { + plugins: { enabled: false }, + models: { providers: { - external: { + custom: { + baseUrl: "https://example.test/v1", + api: "openai-completions", models: [ { - name: model.id, - ...model, + id: "configured-only", + name: "Configured Only", + contextWindow: 8_192, + maxTokens: 1_024, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }, ], }, }, }, }, - ], - }; -} - -function configuredModel(id: string) { - return { - id, - name: id, - reasoning: false, - input: ["text"] as Array<"text">, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128_000, - maxTokens: 8192, - }; -} - -type ModelCatalogEntry = Awaited< - ReturnType ->[number]; - -function findCatalogEntry( - entries: readonly ModelCatalogEntry[], - provider: string, - id: string, -): ModelCatalogEntry | undefined { - return entries.find((entry) => entry.provider === provider && entry.id === id); -} - -function requireCatalogEntry( - entries: readonly ModelCatalogEntry[], - provider: string, - id: string, -): ModelCatalogEntry { - // Most catalog tests need a narrowed entry before checking capabilities or - // normalized ids; fail loudly when the fixture model disappears. - const entry = findCatalogEntry(entries, provider, id); - if (!entry) { - throw new Error(`expected catalog entry ${provider}/${id}`); - } - return entry; -} - -function expectNoCatalogEntry(entries: readonly ModelCatalogEntry[], provider: string, id: string) { - expect(findCatalogEntry(entries, provider, id)).toBeUndefined(); -} - -function requireMockCallParam( - mock: ReturnType, - label: string, -): Record { - const first = mock.mock.calls[0]?.[0]; - if (!first || typeof first !== "object" || Array.isArray(first)) { - throw new Error(`expected ${label}`); - } - return first as Record; -} - -describe("loadModelCatalog", () => { - beforeAll(async () => { - vi.resetModules(); - readFileMock = vi.fn<(pathname: string) => Promise>(); - vi.doMock("node:fs/promises", async (importOriginal) => ({ - ...(await importOriginal()), - readFile: readFileMock, - })); - prepareOpenClawModelsJsonSourceMock = vi.fn().mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "source-fingerprint", - workspaceDir: "/tmp/openclaw-workspace", - wrote: false, }); - buildModelsJsonSourceFingerprintMock = vi.fn().mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "source-fingerprint", - workspaceDir: "/tmp/openclaw-workspace", - }); - vi.doMock("./models-config.js", () => ({ - buildModelsJsonSourceFingerprint: buildModelsJsonSourceFingerprintMock, - prepareOpenClawModelsJsonSource: prepareOpenClawModelsJsonSourceMock, - })); - buildAgentModelCatalogCacheKeyMock = vi.fn( - (input: { cacheScope?: { sourceFingerprint?: string } }) => - `test-cache-key:${input.cacheScope?.sourceFingerprint ?? "none"}`, - ); - readCachedAgentModelCatalogMock = vi.fn< - (params: { agentDir: string; catalogKey: string }) => unknown[] | undefined - >(() => undefined); - readCachedAgentModelCatalogSnapshotMock = vi.fn((params) => { - const entries = readCachedAgentModelCatalogMock(params); - return entries ? { entries, routeVariants: entries } : undefined; - }); - writeCachedAgentModelCatalogMock = vi.fn(); - vi.doMock("./model-catalog-state-cache.js", () => ({ - buildAgentModelCatalogCacheKey: buildAgentModelCatalogCacheKeyMock, - readCachedAgentModelCatalog: readCachedAgentModelCatalogMock, - readCachedAgentModelCatalogSnapshot: readCachedAgentModelCatalogSnapshotMock, - writeCachedAgentModelCatalog: writeCachedAgentModelCatalogMock, - })); - vi.doMock("./agent-scope.js", () => ({ - resolveAgentWorkspaceDir: (cfg: OpenClawConfig, agentId: string) => { - const entry = cfg.agents?.list?.find((entryEntry) => entryEntry.id === agentId); - return entry?.workspace ?? cfg.agents?.defaults?.workspace ?? "/tmp/openclaw-workspace"; + + expect(snapshot.entries.map((entry) => entry.id)).toEqual(["configured-only"]); + }); + + it("rejects the whole generation when catalog projection fails after a valid row", async () => { + const projectionError = new Error("catalog projection failed"); + const brokenEntry = { + id: "broken", + get provider() { + throw projectionError; }, - resolveDefaultAgentDir: () => "/tmp/openclaw", - resolveDefaultAgentId: (cfg: OpenClawConfig) => - cfg.agents?.list?.find((entry) => entry.default)?.id ?? cfg.agents?.list?.[0]?.id ?? "main", - })); - vi.doMock("../plugins/provider-runtime.runtime.js", () => ({ - augmentModelCatalogWithProviderPlugins: vi.fn().mockResolvedValue([]), - })); - currentPluginMetadataSnapshotMock = vi.fn(() => emptyPluginMetadataSnapshot()); - loadPluginMetadataSnapshotMock = vi.fn(() => emptyPluginMetadataSnapshot()); - vi.doMock("../plugins/current-plugin-metadata-snapshot.js", () => ({ - getCurrentPluginMetadataSnapshot: currentPluginMetadataSnapshotMock, - })); - vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({ - loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, - resolvePluginMetadataSnapshot: (...args: unknown[]) => - currentPluginMetadataSnapshotMock(...args) ?? loadPluginMetadataSnapshotMock(...args), - })); - vi.doMock("../plugins/manifest-contract-eligibility.js", () => ({ - isManifestPluginAvailableForControlPlane: ({ - plugin, - snapshot, - }: { - plugin: { id: string; origin?: string }; - snapshot: { - index?: { plugins?: Array<{ pluginId?: string; id?: string; enabled?: boolean }> }; - }; - }) => - plugin.origin === "bundled" || - Boolean( - snapshot.index?.plugins?.some( - (entry) => (entry.pluginId ?? entry.id) === plugin.id && entry.enabled !== false, - ), - ), - loadManifestMetadataSnapshot: (...args: unknown[]) => - currentPluginMetadataSnapshotMock(...args) ?? loadPluginMetadataSnapshotMock(...args), - })); + } as unknown as ModelCatalogEntry; - ({ - setModelCatalogImportForTest, - findModelCatalogEntry, - findModelInCatalog, - loadManifestModelCatalog, - loadModelCatalog, - loadModelCatalogSnapshot, - modelSupportsInput, - resetModelCatalogCache, - resetModelCatalogCacheForTest, - } = await import("./model-catalog.js")); - const providerRuntime = await import("../plugins/provider-runtime.runtime.js"); - augmentCatalogMock = vi.mocked(providerRuntime.augmentModelCatalogWithProviderPlugins); + await expect( + buildPreparedModelCatalogSnapshot({ + agentDir: "/tmp/model-catalog-test", + authCredentials: {}, + config: { plugins: { enabled: false } }, + metadataSnapshot, + modelRegistry: registry([{ id: "valid", name: "Valid", provider: "test" }, brokenEntry]), + readOnly: true, + }), + ).rejects.toBe(projectionError); }); - beforeEach(() => { - resetModelCatalogCacheForTest(); - readFileMock.mockReset(); - readFileMock.mockRejectedValue( - Object.assign(new Error("models.json missing"), { code: "ENOENT" }), - ); - prepareOpenClawModelsJsonSourceMock.mockReset(); - prepareOpenClawModelsJsonSourceMock.mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "source-fingerprint", - workspaceDir: "/tmp/openclaw-workspace", - wrote: false, + it("uses the lifecycle auth snapshot for provider catalog augmentation", async () => { + let resolvedKey: string | undefined; + let resolvedOAuth: string | undefined; + mocks.augmentModelCatalogWithProviderPlugins.mockImplementationOnce(async ({ context }) => { + if (!context.resolveProviderApiKey) { + throw new Error("expected lifecycle auth resolver"); + } + resolvedKey = context.resolveProviderApiKey("inherited").apiKey; + resolvedOAuth = context.resolveProviderApiKey("subscription").apiKey; + return []; }); - augmentCatalogMock.mockClear(); - currentPluginMetadataSnapshotMock.mockReset(); - currentPluginMetadataSnapshotMock.mockReturnValue(undefined); - loadPluginMetadataSnapshotMock.mockReset(); - loadPluginMetadataSnapshotMock.mockReturnValue(emptyPluginMetadataSnapshot()); - buildModelsJsonSourceFingerprintMock.mockClear(); - buildModelsJsonSourceFingerprintMock.mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "source-fingerprint", - workspaceDir: "/tmp/openclaw-workspace", - }); - buildAgentModelCatalogCacheKeyMock.mockClear(); - readCachedAgentModelCatalogMock.mockReset(); - readCachedAgentModelCatalogMock.mockReturnValue(undefined); - readCachedAgentModelCatalogSnapshotMock.mockReset(); - readCachedAgentModelCatalogSnapshotMock.mockImplementation((params) => { - const entries = readCachedAgentModelCatalogMock(params); - return entries ? { entries, routeVariants: entries } : undefined; - }); - writeCachedAgentModelCatalogMock.mockClear(); - }); - afterEach(() => { - setModelCatalogImportForTest(); - resetModelCatalogCacheForTest(); - vi.restoreAllMocks(); - }); - - afterAll(() => { - vi.doUnmock("node:fs/promises"); - vi.doUnmock("./models-config.js"); - vi.doUnmock("./model-catalog-state-cache.js"); - vi.doUnmock("./agent-scope.js"); - vi.doUnmock("../plugins/provider-runtime.runtime.js"); - vi.doUnmock("../plugins/current-plugin-metadata-snapshot.js"); - vi.doUnmock("../plugins/plugin-metadata-snapshot.js"); - vi.doUnmock("../plugins/manifest-contract-eligibility.js"); - }); - - it("retries after import failure without poisoning the cache", async () => { - setLoggerOverride({ level: "silent", consoleLevel: "warn" }); - try { - const getCallCount = mockCatalogImportFailThenRecover(); - - const cfg = {} as OpenClawConfig; - const first = await loadModelCatalog({ config: cfg }); - expect(first).toStrictEqual([]); - - const second = await loadModelCatalog({ config: cfg }); - expect(second).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]); - expect(getCallCount()).toBe(2); - } finally { - setLoggerOverride(null); - resetLogger(); - } - }); - - it("uses the resolved default agent workspace for registry discovery", async () => { - const discoverModels = vi.fn(() => ({ - getAll() { - return []; + await buildPreparedModelCatalogSnapshot({ + agentDir: "/tmp/model-catalog-test", + authCredentials: { + inherited: { type: "api_key", key: "test-api-key" }, + subscription: { + type: "oauth", + access: "test-access", + refresh: "test-refresh", + expires: Date.now() + 60_000, + }, }, - })); - setModelCatalogImportForTest( - async () => - ({ - discoverAuthStorage: () => ({}), - AuthStorage: function AuthStorage() {}, - discoverModels, - ModelRegistry: class { - getAll() { - return []; - } - }, - }) as unknown as AgentModelDiscoveryModule, - ); - const config = { - agents: { - list: [{ id: "workspace-agent", default: true, workspace: "/tmp/workspace-agent" }], - }, - } as OpenClawConfig; - - await loadModelCatalog({ config }); - - expect(discoverModels).toHaveBeenCalledWith( - expect.anything(), - "/tmp/openclaw", - expect.objectContaining({ config, workspaceDir: "/tmp/workspace-agent" }), - ); - }); - - it("uses the state cached catalog before runtime discovery", async () => { - const cached = [{ id: "cached-fast", name: "Cached Fast", provider: "openai" }]; - readCachedAgentModelCatalogMock.mockReturnValueOnce(cached); - const importAgentDiscoveryModule = vi.fn(async () => { - throw new Error("provider discovery should not load"); - }); - setModelCatalogImportForTest( - importAgentDiscoveryModule as unknown as () => Promise, - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - expect(result).toEqual(cached); - expect(readCachedAgentModelCatalogMock).toHaveBeenCalledWith({ - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:source-fingerprint", - }); - expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); - expect(importAgentDiscoveryModule).not.toHaveBeenCalled(); - expect(writeCachedAgentModelCatalogMock).not.toHaveBeenCalled(); - }); - - it("includes injected metadata snapshots in the state cache key", async () => { - const cached = [{ id: "cached-fast", name: "Cached Fast", provider: "openai" }]; - const metadataSnapshot = emptyPluginMetadataSnapshot(); - readCachedAgentModelCatalogMock.mockReturnValueOnce(cached); - - const result = await loadModelCatalog({ - config: {} as OpenClawConfig, - metadataSnapshot: metadataSnapshot as never, + config: { plugins: { enabled: false } }, + metadataSnapshot, + modelRegistry: registry([]), }); - expect(result).toEqual(cached); - expect(buildAgentModelCatalogCacheKeyMock).toHaveBeenCalledWith( + expect(resolvedKey).toBe("test-api-key"); + expect(resolvedOAuth).toBe(resolveOAuthApiKeyMarker("subscription")); + expect(mocks.augmentModelCatalogWithProviderPlugins).toHaveBeenCalledWith( expect.objectContaining({ metadataSnapshot }), ); - expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); - expect(writeCachedAgentModelCatalogMock).not.toHaveBeenCalled(); }); - it("bypasses the state cached catalog when a refresh is requested", async () => { - readCachedAgentModelCatalogMock.mockReturnValue([ - { id: "cached-stale", name: "Cached Stale", provider: "openai" }, - ]); - mockAgentDiscoveryModels([{ id: "fresh-fast", name: "Fresh Fast", provider: "openai" }]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, useCache: false }); - - expect(result).toEqual([{ id: "fresh-fast", name: "Fresh Fast", provider: "openai" }]); - expect(readCachedAgentModelCatalogMock).not.toHaveBeenCalled(); - expect(writeCachedAgentModelCatalogMock).toHaveBeenCalledWith({ - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:source-fingerprint", - entries: result, - routeVariants: result, - }); - }); - - it("writes runtime discovery results to the state catalog cache", async () => { - mockAgentDiscoveryModels([{ id: "runtime-fast", name: "Runtime Fast", provider: "openai" }]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - expect(result).toEqual([{ id: "runtime-fast", name: "Runtime Fast", provider: "openai" }]); - expect(writeCachedAgentModelCatalogMock).toHaveBeenCalledWith({ - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:source-fingerprint", - entries: result, - routeVariants: result, - }); - }); - - it("exposes only a fully loaded process catalog snapshot", async () => { - mockAgentDiscoveryModels([ - { id: "runtime-reasoner", name: "Runtime Reasoner", provider: "ollama", reasoning: true }, - ]); - await expect(loadModelCatalog({ cacheOnly: true })).resolves.toEqual([]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - await expect(loadModelCatalog({ cacheOnly: true })).resolves.toBe(result); - resetModelCatalogCache(); - await expect(loadModelCatalog({ cacheOnly: true })).resolves.toEqual([]); - resetModelCatalogCacheForTest(); - await expect(loadModelCatalog({ cacheOnly: true })).resolves.toEqual([]); - }); - - it("does not publish a catalog load from an invalidated generation", async () => { - let releaseStaleFingerprint: - | ((value: { agentDir: string; fingerprint: string; workspaceDir: string }) => void) - | undefined; - const staleFingerprint = new Promise<{ - agentDir: string; - fingerprint: string; - workspaceDir: string; - }>((resolve) => { - releaseStaleFingerprint = resolve; - }); - buildModelsJsonSourceFingerprintMock.mockReturnValueOnce(staleFingerprint).mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "fresh-fingerprint", - workspaceDir: "/tmp/openclaw-workspace", - }); - const freshCatalog = [{ id: "fresh", name: "Fresh", provider: "ollama", reasoning: true }]; - const staleCatalog = [{ id: "stale", name: "Stale", provider: "ollama", reasoning: false }]; - readCachedAgentModelCatalogMock - .mockReturnValueOnce(freshCatalog) - .mockReturnValueOnce(staleCatalog); - - const staleLoad = loadModelCatalog({ config: {} as OpenClawConfig }); - resetModelCatalogCache(); - await expect(loadModelCatalog({ config: {} as OpenClawConfig })).resolves.toBe(freshCatalog); - await expect(loadModelCatalog({ cacheOnly: true })).resolves.toBe(freshCatalog); - - releaseStaleFingerprint?.({ - agentDir: "/tmp/openclaw", - fingerprint: "stale-fingerprint", - workspaceDir: "/tmp/openclaw-workspace", - }); - await expect(staleLoad).resolves.toBe(staleCatalog); - await expect(loadModelCatalog({ cacheOnly: true })).resolves.toBe(freshCatalog); - }); - - it("preserves runtime model params in the internal catalog", async () => { - mockAgentDiscoveryModels([ - { - id: "company-fable", - name: "Company Fable", - provider: "amazon-bedrock", - params: { canonicalModelId: "claude-fable-5" }, - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - expect(result).toEqual([ - { - id: "company-fable", - name: "Company Fable", - provider: "amazon-bedrock", - params: { canonicalModelId: "claude-fable-5" }, - }, - ]); - }); - - it("writes runtime discovery results under the refreshed models.json fingerprint", async () => { - buildModelsJsonSourceFingerprintMock.mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "pre-refresh-source", - workspaceDir: "/tmp/openclaw-workspace", - }); - prepareOpenClawModelsJsonSourceMock.mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "post-refresh-source", - workspaceDir: "/tmp/openclaw-workspace", - wrote: true, - }); - mockAgentDiscoveryModels([{ id: "runtime-fast", name: "Runtime Fast", provider: "openai" }]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - expect(result).toEqual([{ id: "runtime-fast", name: "Runtime Fast", provider: "openai" }]); - expect(readCachedAgentModelCatalogMock).toHaveBeenNthCalledWith(1, { - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:pre-refresh-source", - }); - expect(readCachedAgentModelCatalogMock).toHaveBeenNthCalledWith(2, { - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:post-refresh-source", - }); - expect(writeCachedAgentModelCatalogMock).toHaveBeenCalledWith({ - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:post-refresh-source", - entries: result, - routeVariants: result, - }); - }); - - it("uses a refreshed state cached catalog before runtime discovery", async () => { - const cached = [{ id: "cached-fast", name: "Cached Fast", provider: "openai" }]; - buildModelsJsonSourceFingerprintMock.mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "pre-refresh-source", - workspaceDir: "/tmp/openclaw-workspace", - }); - prepareOpenClawModelsJsonSourceMock.mockResolvedValue({ - agentDir: "/tmp/openclaw", - fingerprint: "post-refresh-source", - workspaceDir: "/tmp/openclaw-workspace", - wrote: true, - }); - readCachedAgentModelCatalogMock.mockImplementation(({ catalogKey }: { catalogKey: string }) => - catalogKey.endsWith("post-refresh-source") ? cached : undefined, - ); - const importAgentDiscoveryModule = vi.fn(async () => { - throw new Error("provider discovery should not load"); - }); - setModelCatalogImportForTest( - importAgentDiscoveryModule as unknown as () => Promise, - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - expect(result).toEqual(cached); - expect(readCachedAgentModelCatalogMock).toHaveBeenNthCalledWith(1, { - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:pre-refresh-source", - }); - expect(readCachedAgentModelCatalogMock).toHaveBeenNthCalledWith(2, { - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:post-refresh-source", - }); - expect(importAgentDiscoveryModule).not.toHaveBeenCalled(); - expect(writeCachedAgentModelCatalogMock).not.toHaveBeenCalled(); - }); - - it("misses the state cached catalog when source freshness changes", async () => { - buildModelsJsonSourceFingerprintMock - .mockResolvedValueOnce({ - agentDir: "/tmp/openclaw", - fingerprint: "old-source", - workspaceDir: "/tmp/openclaw-workspace", - }) - .mockResolvedValueOnce({ - agentDir: "/tmp/openclaw", - fingerprint: "new-source", - workspaceDir: "/tmp/openclaw-workspace", - }); - readCachedAgentModelCatalogMock.mockImplementation(({ catalogKey }: { catalogKey: string }) => - catalogKey.endsWith("old-source") - ? [{ id: "cached-stale", name: "Cached Stale", provider: "openai" }] - : undefined, - ); - mockAgentDiscoveryModels([{ id: "fresh-fast", name: "Fresh Fast", provider: "openai" }]); - - await expect(loadModelCatalog({ config: {} as OpenClawConfig })).resolves.toEqual([ - { id: "cached-stale", name: "Cached Stale", provider: "openai" }, - ]); - resetModelCatalogCacheForTest(); - mockAgentDiscoveryModels([{ id: "fresh-fast", name: "Fresh Fast", provider: "openai" }]); - await expect(loadModelCatalog({ config: {} as OpenClawConfig })).resolves.toEqual([ - { id: "fresh-fast", name: "Fresh Fast", provider: "openai" }, - ]); - - expect(readCachedAgentModelCatalogMock).toHaveBeenNthCalledWith(1, { - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:old-source", - }); - expect(readCachedAgentModelCatalogMock).toHaveBeenNthCalledWith(2, { - agentDir: "/tmp/openclaw", - catalogKey: "test-cache-key:new-source", - }); - }); - - it("reloads dynamic registry entries after clearing the cache", async () => { - const models = [{ id: "existing", name: "Existing", provider: "ollama" }]; - mockAgentDiscoveryModels(models); - - const first = await loadModelCatalog({ config: {} as OpenClawConfig }); - expect(first).toStrictEqual([ - { - id: "existing", - name: "Existing", - provider: "ollama", - contextWindow: undefined, - reasoning: undefined, - input: undefined, - compat: undefined, - }, - ]); - - models.push({ id: "glm-5.1:cloud", name: "GLM 5.1 Cloud", provider: "ollama" }); - resetModelCatalogCacheForTest(); - mockAgentDiscoveryModels(models); - - const second = await loadModelCatalog({ config: {} as OpenClawConfig }); - expect(second).toStrictEqual([ - { - id: "existing", - name: "Existing", - provider: "ollama", - contextWindow: undefined, - reasoning: undefined, - input: undefined, - compat: undefined, - }, - { - id: "glm-5.1:cloud", - name: "GLM 5.1 Cloud", - provider: "ollama", - contextWindow: undefined, - reasoning: undefined, - input: undefined, - compat: undefined, - }, - ]); - }); - - it("returns partial results on discovery errors", async () => { - setLoggerOverride({ level: "silent", consoleLevel: "warn" }); - try { - setModelCatalogImportForTest( - async () => - ({ - discoverAuthStorage: () => ({}), - AuthStorage: function AuthStorage() {}, - discoverModels: () => ({ - getAll() { - return [ - { id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }, - { - get id() { - throw new Error("boom"); - }, - provider: "openai", - name: "bad", - }, - ]; - }, - }), - ModelRegistry: class { - getAll() { - return [ - { id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }, - { - get id() { - throw new Error("boom"); - }, - provider: "openai", - name: "bad", - }, - ]; - } - }, - }) as unknown as AgentModelDiscoveryModule, - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]); - await expect(loadModelCatalog({ cacheOnly: true })).resolves.toEqual([]); - } finally { - setLoggerOverride(null); - resetLogger(); - } - }); - - it("does not prepare models.json or import provider discovery when loading fallback catalog in read-only mode", async () => { - const importAgentDiscoveryModule = vi.fn(async () => { - throw new Error("provider discovery should not load"); - }); - setModelCatalogImportForTest( - importAgentDiscoveryModule as unknown as () => Promise, - ); - currentPluginMetadataSnapshotMock.mockReturnValueOnce(undefined); - loadPluginMetadataSnapshotMock.mockImplementationOnce(() => { - throw new Error("metadata scan should not run"); - }); - - const result = await loadModelCatalog({ - config: { - models: { - providers: { - openai: { - baseUrl: "https://openai.example.com/v1", - models: [ - { - id: "gpt-test", - name: "GPT Test", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8192, - }, - ], - }, - }, - }, - } as OpenClawConfig, - readOnly: true, - }); - - const entry = requireCatalogEntry(result, "openai", "gpt-test"); - expect(entry.name).toBe("GPT Test"); - expect(entry.baseUrl).toBe("https://openai.example.com/v1"); - expect(readCachedAgentModelCatalogMock).not.toHaveBeenCalled(); - expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); - expect(importAgentDiscoveryModule).not.toHaveBeenCalled(); - expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); - }); - - it("filters suppressed built-ins from persisted read-only catalog rows", async () => { - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - openai: { - models: [ - { - id: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - reasoning: true, - contextWindow: 128000, - input: ["text"], - }, - { - id: "gpt-5.4", - name: "GPT-5.4", - reasoning: true, - contextWindow: 272000, - input: ["text", "image"], - }, - ], - }, - }, - }), - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); - - expect(result).toEqual([ - { - provider: "openai", - id: "gpt-5.4", - name: "GPT-5.4", - reasoning: true, - contextWindow: 272000, - input: ["text", "image"], - compat: undefined, - }, - ]); - expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); - expect(augmentCatalogMock).not.toHaveBeenCalled(); - }); - - it("loads generated plugin catalog rows in read-only mode", async () => { - const catalogPath = "/tmp/openclaw/plugins/read-only-shard/catalog.json"; - mkdirSync("/tmp/openclaw/plugins/read-only-shard", { recursive: true }); - writeFileSync(catalogPath, "{}"); - try { - readFileMock.mockImplementation(async (pathname: string) => { - if (pathname.endsWith("models.json")) { - return JSON.stringify({ providers: {} }); - } - if (pathname === catalogPath) { - return JSON.stringify({ - generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, - providers: { - zai: { - models: [ - { - id: "glm-5.1", - name: "GLM 5.1", - reasoning: true, - contextWindow: 131072, - input: ["text"], - }, - ], - }, - }, - }); - } - throw Object.assign(new Error("not found"), { code: "ENOENT" }); - }); - loadPluginMetadataSnapshotMock.mockReturnValueOnce({ - ...emptyPluginMetadataSnapshot(), - index: { - policyHash: "test-policy", - plugins: [{ pluginId: "read-only-shard", enabled: true }], - }, - normalizePluginId: (id: string) => id, - owners: { - providers: new Map([["zai", ["read-only-shard"]]]), - modelCatalogProviders: new Map([["zai", ["read-only-shard"]]]), - setupProviders: new Map(), - }, - }); - - const result = await loadModelCatalog({ - config: { - agents: { - list: [{ id: "workspace-agent", default: true, workspace: "/tmp/read-only-workspace" }], - }, - } as OpenClawConfig, - readOnly: true, - }); - - expect(requireCatalogEntry(result, "zai", "glm-5.1")).toMatchObject({ - provider: "zai", - id: "glm-5.1", - name: "GLM 5.1", - reasoning: true, - contextWindow: 131072, - }); - expect( - loadPluginMetadataSnapshotMock.mock.calls.some(([call]) => { - return ( - typeof call === "object" && - call !== null && - (call as { workspaceDir?: string }).workspaceDir === "/tmp/read-only-workspace" - ); - }), - ).toBe(true); - } finally { - rmSync("/tmp/openclaw/plugins/read-only-shard", { recursive: true, force: true }); - } - }); - - it("preserves sidecar and manifest physical routes in the read-only catalog", async () => { - const catalogPath = "/tmp/openclaw/plugins/openai/catalog.json"; - mkdirSync("/tmp/openclaw/plugins/openai", { recursive: true }); - writeFileSync(catalogPath, "{}"); - const metadataSnapshot = { - ...emptyPluginMetadataSnapshot(), - index: { - policyHash: "test-policy", - plugins: [{ pluginId: "openai", enabled: true, origin: "bundled" }], - }, - normalizePluginId: (id: string) => id, - owners: { - providers: new Map([["openai", ["openai"]]]), - modelCatalogProviders: new Map([["openai", ["openai"]]]), - setupProviders: new Map(), - }, - plugins: [ - { - id: "openai", - origin: "bundled", - providers: ["openai"], - modelCatalog: { - providers: { - openai: { - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - models: [ - { - id: "gpt-5.4-nano", - name: "Platform Nano", - input: ["text", "image"], - contextWindow: 1_000_000, - }, - ], - }, - }, - }, - }, - ], + it("reports media capabilities from the prepared row", () => { + const entry: ModelCatalogEntry = { + id: "media", + name: "Media", + provider: "test", + input: ["text", "image", "document"], }; - try { - readFileMock.mockImplementation(async (pathname: string) => { - if (pathname.endsWith("models.json")) { - return JSON.stringify({ providers: {} }); - } - if (pathname === catalogPath) { - return JSON.stringify({ - generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, - providers: { - openai: { - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - models: [ - { - id: "gpt-5.4-nano", - name: "ChatGPT Nano", - input: ["text"], - contextWindow: 400_000, - }, - ], - }, - }, - }); - } - throw Object.assign(new Error("not found"), { code: "ENOENT" }); - }); - currentPluginMetadataSnapshotMock.mockReturnValue(metadataSnapshot); - - const snapshot = await loadModelCatalogSnapshot({ - config: {} as OpenClawConfig, - readOnly: true, - metadataSnapshot: metadataSnapshot as never, - }); - const catalog = snapshot.entries; - - expect(catalog).toHaveLength(1); - expect(catalog[0]).toMatchObject({ name: "Platform Nano", api: "openai-responses" }); - expect(snapshot.routeVariants).toEqual([ - expect.objectContaining({ - name: "ChatGPT Nano", - api: "openai-chatgpt-responses", - contextWindow: 400_000, - }), - expect.objectContaining({ - name: "Platform Nano", - api: "openai-responses", - contextWindow: 1_000_000, - }), - ]); - } finally { - rmSync("/tmp/openclaw/plugins/openai", { recursive: true, force: true }); - } - }); - - it("falls back to manifest catalog rows when persisted read-only catalog has no model rows", async () => { - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - openai: { - modelOverrides: { - "gpt-4.1": { - contextWindow: 128000, - }, - }, - }, - }, - }), - ); - currentPluginMetadataSnapshotMock.mockReturnValueOnce({ - policyHash: "policy", - index: { - policyHash: "policy", - plugins: [ - { - pluginId: "external-provider", - enabled: true, - origin: "global", - }, - ], - }, - plugins: [ - { - id: "external-provider", - origin: "global", - modelCatalog: { - providers: { - external: { - models: [{ id: "external-fast", name: "External Fast" }], - }, - }, - }, - }, - ], - }); - const importAgentDiscoveryModule = vi.fn(async () => { - throw new Error("provider discovery should not load"); - }); - setModelCatalogImportForTest( - importAgentDiscoveryModule as unknown as () => Promise, - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); - - expect(result).toEqual([ - { - provider: "external", - id: "external-fast", - name: "External Fast", - input: ["text"], - reasoning: false, - }, - ]); - expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); - expect(importAgentDiscoveryModule).not.toHaveBeenCalled(); - }); - - it("preserves registry defaults for minimal persisted read-only catalog rows", async () => { - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - custom: { - models: [{ id: "local-tiny" }], - }, - }, - }), - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); - - expect(result).toEqual([ - { - provider: "custom", - id: "local-tiny", - name: "local-tiny", - reasoning: false, - contextWindow: 128000, - input: ["text"], - compat: undefined, - }, - ]); - expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); - expect(augmentCatalogMock).not.toHaveBeenCalled(); - }); - - it("inherits provider API and canonical Fable reasoning in persisted rows", async () => { - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - "microsoft-foundry": { - api: "anthropic-messages", - models: [ - { - id: "company-fable", - reasoning: false, - params: { canonicalModelId: "claude-fable-5" }, - }, - ], - }, - }, - }), - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); - const entry = requireCatalogEntry(result, "microsoft-foundry", "company-fable"); - - expect(entry.api).toBe("anthropic-messages"); - expect(entry.reasoning).toBe(true); - }); - - it("refreshes stale persisted read-only rows with manifest catalog metadata", async () => { - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - xai: { - models: [ - { - id: "grok-4.3", - name: "Grok 4.3", - reasoning: false, - contextWindow: 200_000, - input: ["text"], - }, - ], - }, - }, - }), - ); - currentPluginMetadataSnapshotMock.mockReturnValue({ - ...emptyPluginMetadataSnapshot(), - plugins: [ - { - id: "xai", - origin: "bundled", - providers: ["xai"], - modelCatalog: { - providers: { - xai: { - models: [ - { - id: "grok-4.3", - name: "Grok 4.3", - reasoning: true, - contextWindow: 1_000_000, - input: ["text", "image"], - }, - ], - }, - }, - }, - }, - ], - }); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); - - const entry = requireCatalogEntry(result, "xai", "grok-4.3"); - expect(result.filter((entryValue) => entryValue.provider === "xai")).toHaveLength(1); - expect(entry.contextWindow).toBe(1_000_000); - expect(entry.input).toEqual(["text", "image"]); - expect(entry.reasoning).toBe(true); - expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); - expect(augmentCatalogMock).not.toHaveBeenCalled(); - }); - - it("normalizes persisted read-only catalog rows with manifest model id policies", async () => { - currentPluginMetadataSnapshotMock.mockReturnValue(modelIdNormalizationSnapshot()); - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - custom: { - models: [ - { id: "latest", name: "Latest Alias" }, - { id: "legacy/trimmed" }, - { id: "vendor/already-prefixed" }, - ], - }, - }, - }), - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); - - expect(requireCatalogEntry(result, "custom", "vendor/modern-model").name).toBe("Latest Alias"); - expect(requireCatalogEntry(result, "custom", "vendor/trimmed").name).toBe("vendor/trimmed"); - expect(requireCatalogEntry(result, "custom", "vendor/already-prefixed").name).toBe( - "vendor/already-prefixed", - ); - expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); - }); - - it("reuses injected metadata for persisted read-only catalog normalization", async () => { - currentPluginMetadataSnapshotMock.mockReturnValue(undefined); - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - custom: { - models: [{ id: "latest", name: "Latest Alias" }], - }, - }, - }), - ); - - const result = await loadModelCatalog({ - config: {} as OpenClawConfig, - readOnly: true, - metadataSnapshot: modelIdNormalizationSnapshot() as unknown as NonNullable< - Parameters[0] - >["metadataSnapshot"], - }); - - expect(requireCatalogEntry(result, "custom", "vendor/modern-model").name).toBe("Latest Alias"); - expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); - }); - - it("reuses injected metadata when read-only catalog falls back to manifest rows", async () => { - currentPluginMetadataSnapshotMock.mockReturnValue(undefined); - const metadataSnapshot = { - ...emptyPluginMetadataSnapshot(), - index: { - policyHash: "policy", - plugins: [ - { - pluginId: "external-provider", - enabled: true, - origin: "global", - }, - ], - }, - plugins: [ - { - id: "external-provider", - origin: "global", - modelCatalog: { - providers: { - external: { - models: [{ id: "external-fast", name: "External Fast" }], - }, - }, - }, - }, - ], - }; - - const result = await loadModelCatalog({ - config: {} as OpenClawConfig, - readOnly: true, - metadataSnapshot: metadataSnapshot as unknown as NonNullable< - Parameters[0] - >["metadataSnapshot"], - }); - - expect(result).toEqual([ - { - provider: "external", - id: "external-fast", - name: "External Fast", - input: ["text"], - reasoning: false, - }, - ]); - expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); - }); - - it("loads manifest model id policies once for persisted read-only catalog rows", async () => { - currentPluginMetadataSnapshotMock.mockReturnValue(undefined); - loadPluginMetadataSnapshotMock.mockReturnValue(modelIdNormalizationSnapshot()); - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - custom: { - models: [{ id: "model-a" }, { id: "model-b" }, { id: "model-c" }, { id: "model-d" }], - }, - }, - }), - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); - - expect(requireCatalogEntry(result, "custom", "vendor/model-a").id).toBe("vendor/model-a"); - expect(requireCatalogEntry(result, "custom", "vendor/model-d").id).toBe("vendor/model-d"); - expect(loadPluginMetadataSnapshotMock).toHaveBeenCalledTimes(1); - }); - - it("loads manifest model id policies once for configured read-only catalog rows", async () => { - currentPluginMetadataSnapshotMock.mockReturnValue(undefined); - loadPluginMetadataSnapshotMock.mockReturnValue(modelIdNormalizationSnapshot()); - - const result = await loadModelCatalog({ - readOnly: true, - config: { - models: { - providers: { - custom: { - api: "openai-completions", - baseUrl: "https://custom.example/v1", - models: [ - configuredModel("model-a"), - configuredModel("model-b"), - configuredModel("model-c"), - configuredModel("model-d"), - ], - }, - }, - }, - } as OpenClawConfig, - }); - - expect(requireCatalogEntry(result, "custom", "vendor/model-a").id).toBe("vendor/model-a"); - expect(requireCatalogEntry(result, "custom", "vendor/model-d").id).toBe("vendor/model-d"); - expect(loadPluginMetadataSnapshotMock).toHaveBeenCalledTimes(1); - }); - - it("loads manifest model id policies once for discovered catalog rows", async () => { - currentPluginMetadataSnapshotMock.mockReturnValue(undefined); - loadPluginMetadataSnapshotMock.mockReturnValue(modelIdNormalizationSnapshot()); - mockAgentDiscoveryModels([ - { provider: "custom", id: "model-a", name: "Model A" }, - { provider: "custom", id: "model-b", name: "Model B" }, - { provider: "custom", id: "model-c", name: "Model C" }, - { provider: "custom", id: "model-d", name: "Model D" }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, useCache: false }); - - expect(requireCatalogEntry(result, "custom", "vendor/model-a").name).toBe("Model A"); - expect(requireCatalogEntry(result, "custom", "vendor/model-d").name).toBe("Model D"); - expect(loadPluginMetadataSnapshotMock).toHaveBeenCalledTimes(1); - }); - - it("preserves provider context defaults for persisted read-only catalog rows", async () => { - readFileMock.mockResolvedValueOnce( - JSON.stringify({ - providers: { - custom: { - contextWindow: 262144, - models: [ - { id: "inherits-provider-context" }, - { id: "overrides-context", contextWindow: 65536 }, - ], - }, - }, - }), - ); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig, readOnly: true }); - - expect(result).toEqual([ - { - provider: "custom", - id: "inherits-provider-context", - name: "inherits-provider-context", - reasoning: false, - contextWindow: 262144, - input: ["text"], - compat: undefined, - }, - { - provider: "custom", - id: "overrides-context", - name: "overrides-context", - reasoning: false, - contextWindow: 65536, - input: ["text"], - compat: undefined, - }, - ]); - expect(prepareOpenClawModelsJsonSourceMock).not.toHaveBeenCalled(); - expect(augmentCatalogMock).not.toHaveBeenCalled(); - }); - - it("does not synthesize stale openai/gpt-5.3-codex-spark entries from gpt-5.4", async () => { - mockAgentDiscoveryModels([ - { - id: "gpt-5.4", - provider: "openai", - name: "GPT-5.3 Codex", - reasoning: true, - contextWindow: 200000, - input: ["text"], - }, - { - id: "gpt-5.2-codex", - provider: "openai", - name: "GPT-5.2 Codex", - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - expectNoCatalogEntry(result, "openai", "gpt-5.3-codex-spark"); - const entry = requireCatalogEntry(result, "openai", "gpt-5.4"); - expect(entry.name).toBe("GPT-5.3 Codex"); - }); - - it("filters stale gpt-5.3-codex-spark built-ins from the catalog", async () => { - mockAgentDiscoveryModels([ - { - id: "gpt-5.3-codex-spark", - provider: "openai", - name: "GPT-5.3 Codex Spark", - reasoning: true, - contextWindow: 128000, - input: ["text", "image"], - }, - { - id: "gpt-5.3-codex-spark", - provider: "azure-openai-responses", - name: "GPT-5.3 Codex Spark", - reasoning: true, - contextWindow: 128000, - input: ["text", "image"], - }, - { - id: "gpt-5.3-codex-spark", - provider: "openai", - name: "GPT-5.3 Codex Spark", - reasoning: true, - contextWindow: 128000, - input: ["text"], - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - expectNoCatalogEntry(result, "openai", "gpt-5.3-codex-spark"); - expectNoCatalogEntry(result, "azure-openai-responses", "gpt-5.3-codex-spark"); - expectNoCatalogEntry(result, "openai", "gpt-5.3-codex-spark"); - }); - - it("keeps custom endpoint gpt-5.3-codex-spark rows in the catalog", async () => { - mockAgentDiscoveryModels([ - { - id: "gpt-5.3-codex-spark", - provider: "openai", - baseUrl: "https://api.openai.com/v1", - name: "GPT-5.3 Codex Spark", - contextWindow: 128000, - input: ["text"], - }, - { - id: "gpt-5.3-codex-spark", - provider: "openai", - baseUrl: "https://proxy.example.com/v1", - name: "GPT-5.3 Codex Spark Proxy", - contextWindow: 128000, - input: ["text"], - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - const entry = requireCatalogEntry(result, "openai", "gpt-5.3-codex-spark"); - expect(entry.name).toBe("GPT-5.3 Codex Spark Proxy"); - expect(entry.baseUrl).toBe("https://proxy.example.com/v1"); - }); - - it("keeps available openai 5.1/5.2/5.3 built-ins in the catalog", async () => { - mockAgentDiscoveryModels([ - { - id: "gpt-5.1-codex-mini", - provider: "openai", - name: "GPT-5.1 Codex Mini", - reasoning: true, - contextWindow: 400000, - input: ["text", "image"], - }, - { - id: "gpt-5.2-codex", - provider: "openai", - name: "GPT-5.2 Codex", - reasoning: true, - contextWindow: 400000, - input: ["text", "image"], - }, - { - id: "gpt-5.3-codex", - provider: "openai", - name: "GPT-5.3 Codex", - reasoning: true, - contextWindow: 400000, - input: ["text", "image"], - }, - { - id: "gpt-5.5", - provider: "openai", - name: "GPT-5.5", - reasoning: true, - contextWindow: 400000, - input: ["text", "image"], - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - expect(requireCatalogEntry(result, "openai", "gpt-5.1-codex-mini").name).toBe( - "GPT-5.1 Codex Mini", - ); - expect(requireCatalogEntry(result, "openai", "gpt-5.2-codex").name).toBe("GPT-5.2 Codex"); - expect(requireCatalogEntry(result, "openai", "gpt-5.3-codex").name).toBe("GPT-5.3 Codex"); - expect(requireCatalogEntry(result, "openai", "gpt-5.5").name).toBe("GPT-5.5"); - }); - - it("keeps OpenAI forward-compat entries on the unified provider", async () => { - mockAgentDiscoveryModels([ - { - id: "gpt-5.2", - provider: "openai", - name: "GPT-5.2", - reasoning: true, - contextWindow: 1_050_000, - input: ["text", "image"], - }, - { - id: "gpt-5.2-pro", - provider: "openai", - name: "GPT-5.2 Pro", - reasoning: true, - contextWindow: 1_050_000, - input: ["text", "image"], - }, - { - id: "gpt-5-mini", - provider: "openai", - name: "GPT-5 mini", - reasoning: true, - contextWindow: 400_000, - input: ["text", "image"], - }, - { - id: "gpt-5-nano", - provider: "openai", - name: "GPT-5 nano", - reasoning: true, - contextWindow: 400_000, - input: ["text", "image"], - }, - { - id: "gpt-5.4", - provider: "openai", - name: "GPT-5.3 Codex", - reasoning: true, - contextWindow: 272000, - input: ["text", "image"], - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - const entry = requireCatalogEntry(result, "openai", "gpt-5.4"); - expect(entry.name).toBe("GPT-5.3 Codex"); - expect( - result.some( - (entryResult) => entryResult.provider === "openai" && entryResult.id === "gpt-5.4-mini", - ), - ).toBe(false); - }); - - it("merges provider-owned supplemental catalog entries", async () => { - mockSingleOpenAiCatalogModel(); - augmentCatalogMock.mockResolvedValueOnce([ - { - provider: "kilocode", - id: "google/gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", - input: ["text", "image"], - reasoning: true, - contextWindow: 1048576, - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - const entry = requireCatalogEntry(result, "kilocode", "google/gemini-3.1-pro-preview"); - expect(entry.name).toBe("Gemini 3 Pro Preview"); - }); - - it("does not carry capabilities across a supplemental route change", async () => { - mockAgentDiscoveryModels([ - { - provider: "openai", - id: "gpt-5.5", - name: "Platform GPT-5.5", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - contextWindow: 1_000_000, - reasoning: true, - input: ["text", "image"], - params: { platformOnly: true }, - compat: { supportsTemperature: false }, - }, - ]); - augmentCatalogMock.mockResolvedValueOnce([ - { - provider: "openai", - id: "gpt-5.5", - name: "ChatGPT GPT-5.5", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - expect(requireCatalogEntry(result, "openai", "gpt-5.5")).toEqual({ - provider: "openai", - id: "gpt-5.5", - name: "ChatGPT GPT-5.5", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - compat: undefined, - }); - }); - - it("loads manifest catalog rows from the current metadata snapshot without provider runtime", () => { - const snapshot = manifestModelCatalogSnapshot({ - id: "external-fast", - name: "External Fast", - input: ["text", "image"], - reasoning: true, - contextWindow: 32000, - }); - currentPluginMetadataSnapshotMock.mockReturnValue(snapshot); - - const result = loadManifestModelCatalog({ config: {} as OpenClawConfig }); - - expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); - expect(augmentCatalogMock).not.toHaveBeenCalled(); - expect(result).toEqual([ - { - provider: "external", - id: "external-fast", - name: "External Fast", - input: ["text", "image"], - reasoning: true, - contextWindow: 32000, - }, - ]); - }); - - it("reuses planned manifest catalog rows for the same config and metadata snapshot", () => { - const config = {} as OpenClawConfig; - const snapshot = manifestModelCatalogSnapshot({ id: "external-fast" }); - currentPluginMetadataSnapshotMock.mockReturnValue(snapshot); - - const first = loadManifestModelCatalog({ config }); - const second = loadManifestModelCatalog({ config }); - - expect(second).toBe(first); - expect(first).toEqual([ - { - provider: "external", - id: "external-fast", - name: "external-fast", - input: ["text"], - reasoning: false, - }, - ]); - expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); - }); - - it("refreshes manifest catalog rows when the metadata snapshot changes", () => { - const config = {} as OpenClawConfig; - currentPluginMetadataSnapshotMock - .mockReturnValueOnce(manifestModelCatalogSnapshot({ id: "external-fast" })) - .mockReturnValue(manifestModelCatalogSnapshot({ id: "external-slow" })); - - const first = loadManifestModelCatalog({ config }); - const second = loadManifestModelCatalog({ config }); - - expect(second).not.toBe(first); - expect(first[0]?.id).toBe("external-fast"); - expect(second[0]?.id).toBe("external-slow"); - }); - - it("lets read-only manifest catalog reuse the current workspace-scoped snapshot", () => { - loadManifestModelCatalog({ - config: {} as OpenClawConfig, - fallbackToMetadataScan: false, - }); - - const call = requireMockCallParam(currentPluginMetadataSnapshotMock, "current snapshot params"); - expect(call.allowWorkspaceScopedSnapshot).toBe(true); - expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); - }); - - it("passes explicit env when checking current manifest catalog snapshot compatibility", () => { - const env = { HOME: "/tmp/openclaw-model-catalog-env" } as NodeJS.ProcessEnv; - - loadManifestModelCatalog({ - config: {} as OpenClawConfig, - env, - fallbackToMetadataScan: false, - }); - - const call = requireMockCallParam(currentPluginMetadataSnapshotMock, "current snapshot params"); - expect(call.env).toBe(env); - expect(call.allowWorkspaceScopedSnapshot).toBe(true); - expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); - }); - - it("dedupes supplemental models against registry entries", async () => { - mockSingleOpenAiCatalogModel(); - augmentCatalogMock.mockResolvedValueOnce([ - { - provider: "ollama", - id: "llama3.2", - name: "Llama 3.2", - reasoning: true, - input: ["text"], - contextWindow: 1048576, - }, - { - provider: "openai", - id: "gpt-4.1", - name: "Duplicate GPT-4.1", - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - expect(requireCatalogEntry(result, "ollama", "llama3.2").name).toBe("Llama 3.2"); - expect( - result.filter((entry) => entry.provider === "openai" && entry.id === "gpt-4.1"), - ).toHaveLength(1); - }); - - it("refreshes discovered rows with provider supplemental catalog metadata", async () => { - mockAgentDiscoveryModels([ - { - provider: "github-copilot", - id: "claude-opus-4.8", - name: "Claude Opus 4.8", - reasoning: false, - input: ["text"], - contextWindow: 200_000, - }, - ]); - augmentCatalogMock.mockResolvedValueOnce([ - { - provider: "github-copilot", - id: "claude-opus-4.8", - name: "Claude Opus 4.8 Live", - reasoning: true, - input: ["text", "image"], - contextWindow: 1_000_000, - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - const entry = requireCatalogEntry(result, "github-copilot", "claude-opus-4.8"); - expect(result.filter((entryValue) => entryValue.provider === "github-copilot")).toHaveLength(1); - expect(entry.name).toBe("Claude Opus 4.8"); - expect(entry.contextWindow).toBe(1_000_000); - expect(entry.input).toEqual(["text", "image"]); - expect(entry.reasoning).toBe(true); - }); - - it("passes configured provider rows to provider catalog augment hooks", async () => { - mockAgentDiscoveryModels([]); - augmentCatalogMock.mockResolvedValueOnce([ - { - provider: "ollama", - id: "minimax-m3:cloud", - name: "Minimax M3 Live", - reasoning: true, - input: ["text", "image"], - contextWindow: 1_048_576, - compat: { supportsTools: true }, - }, - ]); - - const result = await loadModelCatalog({ - agentDir: "/tmp/catalog-agent", - workspaceDir: "/tmp/catalog-workspace", - config: { - models: { - providers: { - ollama: { - baseUrl: "http://127.0.0.1:11434", - api: "ollama", - models: [ - { - id: "minimax-m3:cloud", - name: "Minimax M3 Configured", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 8192, - compat: { supportsTools: false }, - }, - ], - }, - }, - }, - } as OpenClawConfig, - }); - - const entry = requireCatalogEntry(result, "ollama", "minimax-m3:cloud"); - expect(entry.name).toBe("Minimax M3 Live"); - expect(entry.contextWindow).toBe(128_000); - expect(entry.input).toEqual(["text"]); - expect(entry.reasoning).toBe(false); - expect(entry.compat).toEqual({ supportsTools: false }); - expect(augmentCatalogMock.mock.calls[0]?.[0]?.context.entries).toContainEqual( - expect.objectContaining({ - provider: "ollama", - id: "minimax-m3:cloud", - name: "Minimax M3 Configured", - contextWindow: 128_000, - }), - ); - expect(augmentCatalogMock.mock.calls[0]?.[0]).toMatchObject({ - workspaceDir: "/tmp/catalog-workspace", - context: { agentDir: "/tmp/catalog-agent", workspaceDir: "/tmp/catalog-workspace" }, - }); - }); - - it("includes configured provider models missing from discovery", async () => { - mockSingleOpenAiCatalogModel(); - - const result = await loadModelCatalog({ - config: { - models: { - providers: { - modelscope: { - baseUrl: "https://api-inference.modelscope.cn/v1", - models: [ - { - id: "Qwen/Qwen3.5-35B-A3B", - name: "Qwen3.5 35B", - input: ["text", "image"], - reasoning: true, - contextWindow: 128_000, - maxTokens: 8192, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - ], - }, - }, - }, - } as OpenClawConfig, - }); - - const entry = requireCatalogEntry(result, "modelscope", "Qwen/Qwen3.5-35B-A3B"); - expect(entry.name).toBe("Qwen3.5 35B"); - expect(entry.input).toEqual(["text", "image"]); - expect(entry.reasoning).toBe(true); - expect(entry.contextWindow).toBe(128_000); - }); - - it("overlays configured model compat onto discovered catalog rows", async () => { - mockAgentDiscoveryModels([ - { - id: "Qwen/Qwen3-8B", - name: "Qwen3 8B", - provider: "vllm", - reasoning: false, - compat: { supportsStrictMode: false }, - }, - ]); - - const result = await loadModelCatalog({ - config: { - models: { - providers: { - vllm: { - baseUrl: "http://localhost:9000/v1", - api: "openai-completions", - models: [ - { - id: "vllm/Qwen/Qwen3-8B", - name: "Configured Qwen3 8B", - compat: { thinkingFormat: "qwen-chat-template" }, - }, - ], - }, - }, - }, - } as unknown as OpenClawConfig, - }); - - const entry = requireCatalogEntry(result, "vllm", "Qwen/Qwen3-8B"); - expect(result.filter((entryValue) => entryValue.provider === "vllm")).toHaveLength(1); - expect(entry.name).toBe("Qwen3 8B"); - expect(entry.reasoning).toBe(true); - expect(entry.compat).toEqual( - expect.objectContaining({ - supportsStrictMode: false, - thinkingFormat: "qwen-chat-template", - }), - ); - }); - - it("overlays configured model compat onto persisted read-only catalog rows", async () => { - readFileMock.mockResolvedValue( - JSON.stringify({ - providers: { - vllm: { - models: [ - { - id: "Qwen/Qwen3-8B", - name: "Qwen3 8B", - reasoning: false, - compat: { supportsStrictMode: false }, - }, - ], - }, - }, - }), - ); - - const result = await loadModelCatalog({ - config: { - models: { - providers: { - vllm: { - baseUrl: "http://localhost:9000/v1", - api: "openai-completions", - models: [ - { - id: "vllm/Qwen/Qwen3-8B", - name: "Configured Qwen3 8B", - compat: { thinkingFormat: "qwen-chat-template" }, - }, - ], - }, - }, - }, - } as unknown as OpenClawConfig, - readOnly: true, - }); - - const entry = requireCatalogEntry(result, "vllm", "Qwen/Qwen3-8B"); - expect(result.filter((entryLocal) => entryLocal.provider === "vllm")).toHaveLength(1); - expect(entry.name).toBe("Qwen3 8B"); - expect(entry.reasoning).toBe(true); - expect(entry.compat).toEqual( - expect.objectContaining({ - supportsStrictMode: false, - thinkingFormat: "qwen-chat-template", - }), - ); - }); - - it("merges manifest model catalog rows on the normal catalog path", async () => { - mockSingleOpenAiCatalogModel(); - currentPluginMetadataSnapshotMock.mockReturnValue({ - ...emptyPluginMetadataSnapshot(), - plugins: [ - { - id: "byteplus", - origin: "bundled", - providers: ["byteplus"], - modelCatalog: { - providers: { - byteplus: { - baseUrl: "https://ark.ap-southeast.bytepluses.com/api/v3", - api: "openai-completions", - models: [ - { - id: "seed-1-8-251228", - name: "Doubao Seed 1.8", - input: ["text", "image"], - contextWindow: 256_000, - }, - ], - }, - }, - }, - }, - ], - }); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - const entry = requireCatalogEntry(result, "byteplus", "seed-1-8-251228"); - expect(entry.name).toBe("Doubao Seed 1.8"); - expect(entry.input).toEqual(["text", "image"]); - expect(entry.contextWindow).toBe(256_000); - }); - - it("refreshes discovered rows with manifest catalog metadata", async () => { - mockAgentDiscoveryModels([ - { - provider: "xai", - id: "grok-4.3", - name: "Grok 4.3", - api: "openai-completions", - reasoning: false, - input: ["text"], - contextWindow: 200_000, - }, - ]); - currentPluginMetadataSnapshotMock.mockReturnValue({ - ...emptyPluginMetadataSnapshot(), - plugins: [ - { - id: "xai", - origin: "bundled", - providers: ["xai"], - modelCatalog: { - providers: { - xai: { - api: "openai-responses", - models: [ - { - id: "grok-4.3", - name: "Grok 4.3", - reasoning: true, - input: ["text", "image"], - contextWindow: 1_000_000, - }, - ], - }, - }, - }, - }, - ], - }); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - const entry = requireCatalogEntry(result, "xai", "grok-4.3"); - expect(result.filter((entryValue) => entryValue.provider === "xai")).toHaveLength(1); - expect(entry.api).toBe("openai-responses"); - expect(entry.contextWindow).toBe(1_000_000); - expect(entry.input).toEqual(["text", "image"]); - expect(entry.reasoning).toBe(true); - }); - - it("keeps configured LM Studio models visible without runtime catalog augmentation", async () => { - mockSingleOpenAiCatalogModel(); - augmentCatalogMock.mockResolvedValueOnce([]); - - const result = await loadModelCatalog({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://127.0.0.1:1234/v1", - models: [ - { - id: "qwen3.6-27b@iq3_xxs", - name: "Qwen 3.6 27B", - input: ["text"], - reasoning: false, - contextWindow: 128_000, - maxTokens: 8192, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - ], - }, - }, - }, - } as OpenClawConfig, - }); - - const entry = requireCatalogEntry(result, "lmstudio", "qwen3.6-27b@iq3_xxs"); - expect(entry.name).toBe("Qwen 3.6 27B"); - expect(entry.contextWindow).toBe(128_000); - }); - - it("dedupes configured models without rewriting provider ids", async () => { - mockAgentDiscoveryModels([{ id: "glm-5", provider: "z.ai", name: "GLM-5" }]); - - const result = await loadModelCatalog({ - config: { - models: { - providers: { - "z-ai": { - baseUrl: "https://api.z.ai/v1", - models: [ - { - id: "glm-5", - name: "Configured GLM-5", - input: ["text", "image"], - reasoning: false, - contextWindow: 128_000, - maxTokens: 8192, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - ], - }, - }, - }, - } as OpenClawConfig, - }); - - const matches = result.filter((entry) => findModelInCatalog([entry], "z-ai", "glm-5")); - expect(matches).toHaveLength(1); - const match = matches[0]; - expect(match?.provider).toBe("z-ai"); - expect(match?.id).toBe("glm-5"); - expect(match?.name).toBe("Configured GLM-5"); - }); - - it("does not add unrelated models when provider plugins return nothing", async () => { - mockSingleOpenAiCatalogModel(); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - expect( - result.some((entry) => entry.provider === "qianfan" && entry.id === "deepseek-v3.2"), - ).toBe(false); - }); - - it("does not duplicate provider-owned supplemental models already present in ModelRegistry", async () => { - mockAgentDiscoveryModels([ - { - id: "kilo-auto/balanced", - provider: "kilocode", - name: "Auto Balanced", - }, - ]); - augmentCatalogMock.mockResolvedValueOnce([ - { - provider: "kilocode", - id: "kilo-auto/balanced", - name: "Configured Auto Balanced", - reasoning: true, - input: ["text", "image"], - contextWindow: 1000000, - }, - ]); - - const result = await loadModelCatalog({ config: {} as OpenClawConfig }); - - const matches = result.filter( - (entry) => entry.provider === "kilocode" && entry.id === "kilo-auto/balanced", - ); - expect(matches).toHaveLength(1); - expect(matches[0]?.name).toBe("Auto Balanced"); - }); - - it("does not match models across provider id variants", () => { - expect( - findModelInCatalog([{ provider: "z.ai", id: "glm-5", name: "GLM-5" }], "z-ai", "glm-5"), - ).toBeUndefined(); - }); - - it("resolves catalog entries with explicit providers and unique providerless matches", () => { - const catalog = [ - { provider: "first", id: "shared", name: "First", input: ["text"] }, - { provider: "second", id: "shared", name: "Second", input: ["text", "image"] }, - { provider: "modelscope", id: "qwen/qwen3.5-35b-a3b", name: "Qwen", input: ["text"] }, - ] satisfies Awaited>; - - expect(findModelCatalogEntry(catalog, { provider: "second", modelId: "SHARED" })).toEqual( - catalog[1], - ); - expect( - findModelCatalogEntry(catalog, { provider: "modelscope", modelId: "Qwen/Qwen3.5-35B-A3B" }), - ).toEqual(catalog[2]); - expect(findModelCatalogEntry(catalog, { modelId: "shared" })).toBeUndefined(); - expect(findModelCatalogEntry(catalog, { modelId: "Qwen/Qwen3.5-35B-A3B" })).toEqual(catalog[2]); - expect(modelSupportsInput(catalog[1], "image")).toBe(true); - expect(modelSupportsInput(catalog[2], "image")).toBe(false); + expect(modelSupportsVision(entry)).toBe(true); + expect(modelSupportsDocument(entry)).toBe(true); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 7dffbb6d31b..a6f91fe7e54 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -1,42 +1,28 @@ /** * Loads bundled, manifest, and discovered model catalog entries. */ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { resolveClaudeFable5ModelIdentity } from "@openclaw/llm-core"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isDiagnosticFlagEnabled } from "../infra/diagnostic-flags.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js"; import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; -import { - isManifestPluginAvailableForControlPlane, - loadManifestMetadataSnapshot, -} from "../plugins/manifest-contract-eligibility.js"; +import { isManifestPluginAvailableForControlPlane } from "../plugins/manifest-contract-eligibility.js"; import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; -import { resolveDefaultAgentDir } from "./agent-scope.js"; -import { ensureAuthProfileStoreWithoutExternalProfiles } from "./auth-profiles.js"; import { modelSupportsInput as modelCatalogEntrySupportsInput } from "./model-catalog-lookup.js"; -import { - buildAgentModelCatalogCacheKey, - readCachedAgentModelCatalogSnapshot, - writeCachedAgentModelCatalog, -} from "./model-catalog-state-cache.js"; import type { ModelCatalogEntry, ModelCatalogSnapshot, ModelInputType, } from "./model-catalog.types.js"; -import { resolveModelWorkspaceDir } from "./model-discovery-context.js"; import { modelKey, normalizeConfiguredProviderCatalogModelId, @@ -46,18 +32,9 @@ import { buildConfiguredModelCatalog, hasConfiguredProviderModelRows, } from "./model-selection-shared.js"; -import { - buildModelsJsonSourceFingerprint, - prepareOpenClawModelsJsonSource, -} from "./models-config.js"; -import { - filterGeneratedPluginModelCatalogProviders, - listPluginModelCatalogFiles, - type PluginModelCatalogMetadataSnapshot, -} from "./plugin-model-catalog.js"; +import type { AuthStorageData, ModelRegistry } from "./sessions/index.js"; const log = createSubsystemLogger("model-catalog"); -const AGENT_CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 128_000; export type { ModelCatalogEntry, @@ -84,49 +61,23 @@ type DiscoveredModel = { baseUrl?: string; }; -type AgentDiscoveryModule = typeof import("./agent-model-discovery.js"); - -export type LoadModelCatalogParams = { - agentDir?: string; - config?: OpenClawConfig; - useCache?: boolean; - cacheOnly?: boolean; +export type BuildPreparedModelCatalogParams = { + agentDir: string; + authCredentials: Readonly; + config: OpenClawConfig; + modelRegistry: ModelRegistry; readOnly?: boolean; - metadataSnapshot?: PluginMetadataSnapshot; + metadataSnapshot: PluginMetadataSnapshot; workspaceDir?: string; + env?: NodeJS.ProcessEnv; }; -let modelCatalogPromise: Promise | null = null; -let loadedModelCatalogSnapshot: ModelCatalogSnapshot | undefined; -let loadedModelCatalogGeneration = -1; -let modelCatalogGeneration = 0; let hasLoggedModelCatalogError = false; -let hasLoggedReadOnlyStaticCatalogError = false; type ManifestModelCatalogCacheEntry = { snapshot: PluginMetadataSnapshot; rows: ModelCatalogEntry[]; }; let manifestModelCatalogCache = new WeakMap(); -function buildLoadModelCatalogStateCacheKey(params: { - agentDir: string; - config: OpenClawConfig; - metadataSnapshot?: PluginMetadataSnapshot; - sourceFingerprint: string; - workspaceDir?: string; -}): string { - return buildAgentModelCatalogCacheKey({ - agentDir: params.agentDir, - cacheScope: { - source: "load-model-catalog", - sourceFingerprint: params.sourceFingerprint, - }, - config: params.config, - metadataSnapshot: params.metadataSnapshot, - workspaceDir: params.workspaceDir, - }); -} -const defaultImportAgentDiscovery = () => import("./agent-model-discovery.js"); -let importAgentDiscovery = defaultImportAgentDiscovery; const modelSuppressionLoader = createLazyImportLoader( () => import("./model-suppression.runtime.js"), ); @@ -142,24 +93,28 @@ function loadProviderApiKeyResolver() { return providerApiKeyResolverLoader.load(); } -export function resetModelCatalogCache() { - modelCatalogPromise = null; - modelCatalogGeneration += 1; +export function resetModelCatalogBuilderCacheForTest() { manifestModelCatalogCache = new WeakMap(); hasLoggedModelCatalogError = false; - hasLoggedReadOnlyStaticCatalogError = false; } -export function resetModelCatalogCacheForTest() { - resetModelCatalogCache(); - loadedModelCatalogSnapshot = undefined; - loadedModelCatalogGeneration = -1; - importAgentDiscovery = defaultImportAgentDiscovery; -} - -// Test-only escape hatch: allow mocking discovery failures without touching module state. -export function setModelCatalogImportForTest(loader?: () => Promise) { - importAgentDiscovery = loader ?? defaultImportAgentDiscovery; +/** Canonicalizes a provider alias against the metadata captured with a prepared catalog. */ +export function canonicalizePreparedModelCatalogProvider( + provider: string, + metadataSnapshot: Pick, +): string { + const normalizedProvider = normalizeProviderId(provider); + for (const plugin of metadataSnapshot.manifestRegistry.plugins) { + for (const [alias, target] of Object.entries(plugin.modelCatalog?.aliases ?? {})) { + if (normalizeProviderId(alias) === normalizedProvider) { + const canonicalProvider = normalizeProviderId(target.provider); + if (canonicalProvider) { + return canonicalProvider; + } + } + } + } + return normalizedProvider; } function catalogEntryDedupeKey(provider: string, id: string): string { @@ -331,21 +286,13 @@ function mergeCatalogRouteVariants( function createModelCatalogSnapshot( entries: ModelCatalogEntry[], routeVariants: ModelCatalogRouteVariantCollector, - authoritative = true, ): ModelCatalogSnapshot { return { entries: sortModelCatalogEntries(entries), routeVariants: sortModelCatalogEntries(routeVariants.entries), - authoritative, }; } -const EMPTY_DEGRADED_MODEL_CATALOG_SNAPSHOT: ModelCatalogSnapshot = { - entries: [], - routeVariants: [], - authoritative: false, -}; - export function loadManifestModelCatalog(params: { config: OpenClawConfig; workspaceDir?: string; @@ -429,562 +376,164 @@ function sortModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntr }); } -function normalizePersistedModelCatalogEntry( - providerRaw: string, - entry: Record, - defaults?: { - api?: ModelCatalogEntry["api"]; - baseUrl?: string; - contextWindow?: number; - contextTokens?: number; - }, - options: { - manifestPlugins?: ProviderModelIdNormalizationOptions["manifestPlugins"]; - } = {}, -): ModelCatalogEntry | undefined { - const rawId = normalizeOptionalString(entry.id) ?? ""; - if (!rawId) { - return undefined; - } - const provider = normalizeProviderId(providerRaw); - if (!provider) { - return undefined; - } - const id = normalizeConfiguredProviderCatalogModelId(provider, rawId, options); - const name = normalizeOptionalString(entry.name ?? id) || id; - const contextWindow = - typeof entry?.contextWindow === "number" && entry.contextWindow > 0 - ? entry.contextWindow - : defaults?.contextWindow !== undefined - ? defaults.contextWindow - : AGENT_CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW; - const contextTokens = - typeof entry?.contextTokens === "number" && entry.contextTokens > 0 - ? entry.contextTokens - : defaults?.contextTokens !== undefined - ? defaults.contextTokens - : undefined; - const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : false; - const api = - typeof entry?.api === "string" ? (entry.api as ModelCatalogEntry["api"]) : defaults?.api; - const baseUrl = normalizeOptionalString(entry?.baseUrl) ?? defaults?.baseUrl; - const parsedInput = Array.isArray(entry?.input) - ? entry.input.filter((value): value is ModelInputType => - ["text", "image", "audio", "video", "document"].includes(String(value)), - ) - : undefined; - const input: ModelInputType[] = parsedInput?.length ? parsedInput : ["text"]; - const compat = - entry?.compat && typeof entry.compat === "object" - ? (entry.compat as ModelCatalogEntry["compat"]) - : undefined; - const modelParams = - entry?.params && typeof entry.params === "object" - ? (entry.params as ModelCatalogEntry["params"]) - : undefined; - return { - id, - name, - provider, - ...(api ? { api } : {}), - ...(baseUrl ? { baseUrl } : {}), - contextWindow, - ...(contextTokens !== undefined ? { contextTokens } : {}), - reasoning, - input, - ...(modelParams ? { params: modelParams } : {}), - compat, - }; -} - -function readProviderCatalogRows(parsed: unknown): Record> { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - const providers = (parsed as { providers?: unknown }).providers; - return providers && typeof providers === "object" && !Array.isArray(providers) - ? (providers as Record>) - : {}; -} - -async function loadReadOnlyPersistedProviderRows( - agentDir: string, - getPluginMetadataSnapshot: () => PluginModelCatalogMetadataSnapshot, -): Promise>> { - const raw = await readFile(join(agentDir, "models.json"), "utf8"); - const providers = { ...readProviderCatalogRows(JSON.parse(raw) as unknown) }; - for (const catalogFile of listPluginModelCatalogFiles(agentDir)) { - const catalogRaw = await readFile(catalogFile.path, "utf8").catch(() => undefined); - if (!catalogRaw) { - continue; - } - let parsed: unknown; - try { - parsed = JSON.parse(catalogRaw) as unknown; - } catch { - continue; - } - Object.assign( - providers, - filterGeneratedPluginModelCatalogProviders({ - catalogPluginId: catalogFile.pluginId, - parsedCatalog: parsed, - pluginMetadataSnapshot: getPluginMetadataSnapshot(), - providers: readProviderCatalogRows(parsed), - }), - ); - } - return providers; -} - -async function loadReadOnlyPersistedModelCatalog(params?: { - config?: OpenClawConfig; - metadataSnapshot?: PluginMetadataSnapshot; -}): Promise { - const cfg = params?.config ?? getRuntimeConfig(); - const agentDir = resolveDefaultAgentDir(cfg); - const workspaceDir = resolveModelWorkspaceDir(cfg, undefined); - const models: ModelCatalogEntry[] = []; - const routeVariants = createModelCatalogRouteVariantCollector(); - const { buildShouldSuppressBuiltInModel } = await loadModelSuppression(); - const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ config: cfg }); - let metadataSnapshot: PluginMetadataSnapshot | undefined = params?.metadataSnapshot; - const getMetadataSnapshot = () => { - metadataSnapshot ??= loadManifestMetadataSnapshot({ - config: cfg, - env: process.env, - workspaceDir, - }); - return metadataSnapshot; - }; - let manifestPlugins: ProviderModelIdNormalizationOptions["manifestPlugins"]; - const getManifestPlugins = () => { - manifestPlugins ??= getMetadataSnapshot().plugins; - return manifestPlugins; - }; - const providers = await loadReadOnlyPersistedProviderRows(agentDir, getMetadataSnapshot); - for (const [providerRaw, providerConfig] of Object.entries(providers)) { - if (!Array.isArray(providerConfig?.models)) { - continue; - } - const providerContextWindow = - typeof providerConfig?.contextWindow === "number" && providerConfig.contextWindow > 0 - ? providerConfig.contextWindow - : undefined; - const providerContextTokens = - typeof providerConfig?.contextTokens === "number" && providerConfig.contextTokens > 0 - ? providerConfig.contextTokens - : undefined; - const providerApi = - typeof providerConfig?.api === "string" - ? (providerConfig.api as ModelCatalogEntry["api"]) - : undefined; - const providerBaseUrl = normalizeOptionalString(providerConfig?.baseUrl); - for (const entry of providerConfig.models as Record[]) { - const normalized = normalizePersistedModelCatalogEntry( - providerRaw, - entry, - { - api: providerApi, - baseUrl: providerBaseUrl, - contextWindow: providerContextWindow, - contextTokens: providerContextTokens, - }, - { manifestPlugins: getManifestPlugins() }, - ); - if (normalized && !shouldSuppressBuiltInModel(normalized)) { - models.push(normalized); - mergeCatalogRouteVariants(routeVariants, [normalized]); - } - } - } - if (models.length === 0) { - throw new Error("persisted model catalog has no usable model rows"); - } - try { - const manifestModels = loadManifestModelCatalog({ - config: cfg, - env: process.env, - fallbackToMetadataScan: false, - metadataSnapshot: getMetadataSnapshot(), - }); - mergeCatalogRouteVariants(routeVariants, manifestModels); - mergeCatalogEntries(models, manifestModels); - } catch { - // Persisted rows are still valid when manifest metadata is temporarily unavailable. - } - const configuredModels = buildConfiguredModelCatalog({ - cfg, - manifestPlugins: hasConfiguredProviderModelRows(cfg) ? getManifestPlugins() : undefined, - }); - if (configuredModels.length > 0) { - mergeCatalogRouteVariants(routeVariants, configuredModels); - mergeCatalogEntries(models, configuredModels, { preserveBaseName: true }); - } - return createModelCatalogSnapshot(models, routeVariants); -} - -function hasConfiguredProviderRowsNeedingManifestLookup(cfg: OpenClawConfig): boolean { - const providers = cfg.models?.providers; - if (!providers || typeof providers !== "object") { - return false; - } - return Object.entries(providers).some( - ([providerRaw, provider]) => - Array.isArray(provider?.models) && normalizeProviderId(providerRaw) !== "openai", - ); -} - -function loadReadOnlyStaticModelCatalog(params?: { - config?: OpenClawConfig; - metadataSnapshot?: PluginMetadataSnapshot; -}): ModelCatalogSnapshot { - const cfg = params?.config ?? getRuntimeConfig(); - const models: ModelCatalogEntry[] = []; - const routeVariants = createModelCatalogRouteVariantCollector(); - try { - const manifestModels = loadManifestModelCatalog({ - config: cfg, - env: process.env, - fallbackToMetadataScan: false, - metadataSnapshot: params?.metadataSnapshot, - }); - mergeCatalogRouteVariants(routeVariants, manifestModels); - mergeCatalogEntries(models, manifestModels); - } catch (error) { - if (!hasLoggedReadOnlyStaticCatalogError) { - hasLoggedReadOnlyStaticCatalogError = true; - log.warn(`Failed to load read-only manifest model catalog: ${String(error)}`); - } - } - - const configuredManifestPlugins = hasConfiguredProviderRowsNeedingManifestLookup(cfg) - ? (params?.metadataSnapshot?.plugins ?? - resolvePluginMetadataSnapshot({ - config: cfg, - env: process.env, - allowWorkspaceScopedCurrent: true, - }).plugins) - : []; - const configuredModels = buildConfiguredModelCatalog({ - cfg, - manifestPlugins: configuredManifestPlugins, - }); - if (configuredModels.length > 0) { - mergeCatalogRouteVariants(routeVariants, configuredModels); - mergeCatalogEntries(models, configuredModels, { preserveBaseName: true }); - } - // Static-only catalog: discovery/persisted rows were unavailable, so this is degraded. - return createModelCatalogSnapshot(models, routeVariants, false); -} - -/** Loads logical entries together with browse-only physical route provenance. */ -export async function loadModelCatalogSnapshot( - params?: LoadModelCatalogParams, +/** Builds the catalog once for a lifecycle generation. No request-time discovery or cache IO. */ +export async function buildPreparedModelCatalogSnapshot( + params: BuildPreparedModelCatalogParams, ): Promise { - if (params?.cacheOnly === true) { - return loadedModelCatalogGeneration === modelCatalogGeneration - ? (loadedModelCatalogSnapshot ?? EMPTY_DEGRADED_MODEL_CATALOG_SNAPSHOT) - : EMPTY_DEGRADED_MODEL_CATALOG_SNAPSHOT; - } - const readOnly = params?.readOnly === true; - if (readOnly) { - try { - return await loadReadOnlyPersistedModelCatalog(params); - } catch { - // Keep gateway models.list on side-effect-free sources. The RPC timeout - // cannot fire while provider discovery blocks the event loop. - return loadReadOnlyStaticModelCatalog(params); - } - } - if (!readOnly && params?.useCache === false) { - modelCatalogPromise = null; - modelCatalogGeneration += 1; - } - const useSharedCache = !readOnly && !params?.metadataSnapshot; - if (useSharedCache && modelCatalogPromise) { - return modelCatalogPromise; - } - - const loadCatalog = async () => { - const models: ModelCatalogEntry[] = []; - const routeVariants = createModelCatalogRouteVariantCollector(); - const cfg = params?.config ?? getRuntimeConfig(); - const timingEnabled = isDiagnosticFlagEnabled("ingress.timing", cfg); - const startMs = timingEnabled ? Date.now() : 0; - const logStage = (stage: string, extra?: string) => { - if (!timingEnabled) { - return; - } - const suffix = extra ? ` ${extra}` : ""; - log.info(`model-catalog stage=${stage} elapsedMs=${Date.now() - startMs}${suffix}`); - }; - try { - const workspaceDir = params?.workspaceDir ?? resolveModelWorkspaceDir(cfg, undefined); - let manifestMetadataSnapshot: PluginMetadataSnapshot | undefined; - let manifestPlugins: ProviderModelIdNormalizationOptions["manifestPlugins"]; - const getManifestMetadataSnapshot = () => { - manifestMetadataSnapshot ??= - params?.metadataSnapshot ?? - loadManifestMetadataSnapshot({ - config: cfg, - env: process.env, - workspaceDir, - }); - return manifestMetadataSnapshot; - }; - const getManifestPlugins = () => { - manifestPlugins ??= getManifestMetadataSnapshot().plugins; - return manifestPlugins; - }; - const agentDir = params?.agentDir ?? resolveDefaultAgentDir(cfg); - const sourceFingerprint = await buildModelsJsonSourceFingerprint(cfg, agentDir, { - pluginMetadataSnapshot: params?.metadataSnapshot, - workspaceDir, - }); - let catalogKey = buildLoadModelCatalogStateCacheKey({ - agentDir, - config: cfg, - metadataSnapshot: params?.metadataSnapshot, - sourceFingerprint: sourceFingerprint.fingerprint, - workspaceDir, - }); - if (!readOnly && params?.useCache !== false) { - const cachedSnapshot = readCachedAgentModelCatalogSnapshot({ agentDir, catalogKey }) as - | { entries: ModelCatalogEntry[]; routeVariants: ModelCatalogEntry[] } - | undefined; - if (cachedSnapshot?.entries.length) { - logStage("state-cache-hit", `entries=${cachedSnapshot.entries.length}`); - return cachedSnapshot; - } - } - if (!readOnly) { - const preparedSource = await prepareOpenClawModelsJsonSource(cfg, agentDir, { - pluginMetadataSnapshot: params?.metadataSnapshot, - workspaceDir, - }); - const preparedCatalogKey = buildLoadModelCatalogStateCacheKey({ - agentDir, - config: cfg, - metadataSnapshot: params?.metadataSnapshot, - sourceFingerprint: preparedSource.fingerprint, - workspaceDir: preparedSource.workspaceDir ?? workspaceDir, - }); - logStage("models-json-ready"); - if (preparedCatalogKey !== catalogKey) { - catalogKey = preparedCatalogKey; - if (params?.useCache !== false) { - const cachedSnapshot = readCachedAgentModelCatalogSnapshot({ - agentDir, - catalogKey, - }) as { entries: ModelCatalogEntry[]; routeVariants: ModelCatalogEntry[] } | undefined; - if (cachedSnapshot?.entries.length) { - logStage("state-cache-hit", `entries=${cachedSnapshot.entries.length}`); - return cachedSnapshot; - } - } - } - } - // Keep discovery inside try/catch so transient filesystem/config failures do not poison - // the shared catalog cache until restart. - const agentDiscovery = await importAgentDiscovery(); - logStage("agent-discovery-imported"); - const { buildShouldSuppressBuiltInModel } = await loadModelSuppression(); - logStage("catalog-deps-ready"); - const authStorage = agentDiscovery.discoverAuthStorage(agentDir, { - ...(readOnly ? { readOnly: true } : {}), - config: cfg, - workspaceDir, - }); - logStage("auth-storage-ready"); - const registry = agentDiscovery.discoverModels(authStorage, agentDir, { - config: cfg, - pluginMetadataSnapshot: getManifestMetadataSnapshot(), - workspaceDir, - }); - logStage("registry-ready"); - const entries = registry.getAll() as DiscoveredModel[]; - logStage("registry-read", `entries=${entries.length}`); - - const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ config: cfg }); - logStage("suppress-resolver-ready"); - - for (const entry of entries) { - const rawId = normalizeOptionalString(entry?.id) ?? ""; - if (!rawId) { - continue; - } - const provider = normalizeOptionalString(entry?.provider) ?? ""; - if (!provider) { - continue; - } - const id = normalizeConfiguredProviderCatalogModelId(provider, rawId, { - manifestPlugins: getManifestPlugins(), - }); - const baseUrl = normalizeOptionalString(entry?.baseUrl); - if (shouldSuppressBuiltInModel({ provider, id, baseUrl })) { - continue; - } - const name = normalizeOptionalString(entry?.name ?? id) || id; - const contextWindow = - typeof entry?.contextWindow === "number" && entry.contextWindow > 0 - ? entry.contextWindow - : undefined; - const contextTokens = - typeof entry?.contextTokens === "number" && entry.contextTokens > 0 - ? entry.contextTokens - : undefined; - const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined; - const api = typeof entry?.api === "string" ? entry.api : undefined; - const input = Array.isArray(entry?.input) ? entry.input : undefined; - const modelParams = - entry?.params && typeof entry.params === "object" ? entry.params : undefined; - const compat = entry?.compat && typeof entry.compat === "object" ? entry.compat : undefined; - const model = { - id, - name, - provider, - ...(api ? { api } : {}), - ...(baseUrl ? { baseUrl } : {}), - contextWindow, - ...(contextTokens !== undefined ? { contextTokens } : {}), - reasoning, - input, - ...(modelParams ? { params: modelParams } : {}), - compat, - } satisfies ModelCatalogEntry; - models.push(model); - mergeCatalogRouteVariants(routeVariants, [model]); - } - const manifestModels = loadManifestModelCatalog({ - config: cfg, - env: process.env, - metadataSnapshot: getManifestMetadataSnapshot(), - }); - mergeCatalogRouteVariants(routeVariants, manifestModels); - mergeCatalogEntries(models, manifestModels); - logStage("manifest-models-merged", `entries=${models.length}`); - const configuredModels = buildConfiguredModelCatalog({ - cfg, - manifestPlugins: hasConfiguredProviderModelRows(cfg) ? getManifestPlugins() : undefined, - }); - let augmentEntries: ModelCatalogEntry[] | undefined; - if (configuredModels.length > 0) { - const entriesForAugment = [...models]; - mergeCatalogEntries(entriesForAugment, configuredModels, { preserveBaseName: true }); - augmentEntries = entriesForAugment; - } - logStage("configured-models-prepared", `entries=${models.length}`); - - if (!readOnly) { - const { createProviderApiKeyResolver } = await loadProviderApiKeyResolver(); - let authStore: ReturnType | undefined; - const resolveProviderApiKeyForProvider = createProviderApiKeyResolver( - process.env, - () => - (authStore ??= ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { - allowKeychainPrompt: false, - })), - cfg, - ); - const resolveProviderApiKey = (providerId?: string) => - providerId?.trim() - ? resolveProviderApiKeyForProvider(providerId) - : { apiKey: undefined, discoveryApiKey: undefined }; - const supplemental = await augmentModelCatalogWithProviderPlugins({ - config: cfg, - workspaceDir, - env: process.env, - context: { - config: cfg, - agentDir, - workspaceDir, - env: process.env, - resolveProviderApiKey, - entries: augmentEntries ?? [...models], - }, - }); - if (supplemental.length > 0) { - const normalizedSupplemental: ModelCatalogEntry[] = []; - for (const entry of supplemental) { - normalizedSupplemental.push({ - ...entry, - id: normalizeConfiguredProviderCatalogModelId(entry.provider, entry.id, { - manifestPlugins: getManifestPlugins(), - }), - }); - } - mergeCatalogRouteVariants(routeVariants, normalizedSupplemental); - mergeCatalogEntries(models, normalizedSupplemental); - } - } - logStage("plugin-models-merged", `entries=${models.length}`); - - if (configuredModels.length > 0) { - mergeCatalogRouteVariants(routeVariants, configuredModels); - mergeCatalogEntries(models, configuredModels, { preserveBaseName: true }); - } - logStage("configured-models-finalized", `entries=${models.length}`); - - if (models.length === 0) { - // If we found nothing, don't cache this result so we can try again. - if (useSharedCache) { - modelCatalogPromise = null; - } - } - - const snapshot = createModelCatalogSnapshot(models, routeVariants); - if (!readOnly) { - writeCachedAgentModelCatalog({ - agentDir, - catalogKey, - entries: snapshot.entries, - routeVariants: snapshot.routeVariants, - }); - } - logStage("complete", `entries=${snapshot.entries.length}`); - return snapshot; - } catch (error) { - if (!hasLoggedModelCatalogError) { - hasLoggedModelCatalogError = true; - log.warn(`Failed to load model catalog: ${String(error)}`); - } - // Don't poison the cache on transient dependency/filesystem issues. - if (useSharedCache) { - modelCatalogPromise = null; - } - if (models.length > 0) { - return createModelCatalogSnapshot(models, routeVariants, false); - } - return EMPTY_DEGRADED_MODEL_CATALOG_SNAPSHOT; + const models: ModelCatalogEntry[] = []; + const routeVariants = createModelCatalogRouteVariantCollector(); + const cfg = params.config; + const env = params.env ?? process.env; + const timingEnabled = isDiagnosticFlagEnabled("ingress.timing", cfg); + const startMs = timingEnabled ? Date.now() : 0; + const logStage = (stage: string, extra?: string) => { + if (!timingEnabled) { + return; } + const suffix = extra ? ` ${extra}` : ""; + log.info(`model-catalog stage=${stage} elapsedMs=${Date.now() - startMs}${suffix}`); }; + try { + const workspaceDir = params.workspaceDir; + const manifestMetadataSnapshot = params.metadataSnapshot; + let manifestPlugins: ProviderModelIdNormalizationOptions["manifestPlugins"]; + const getManifestPlugins = () => { + manifestPlugins ??= manifestMetadataSnapshot.plugins; + return manifestPlugins; + }; + const { buildShouldSuppressBuiltInModel } = await loadModelSuppression(); + logStage("catalog-deps-ready"); + const entries = params.modelRegistry.getAll() as DiscoveredModel[]; + logStage("registry-read", `entries=${entries.length}`); - if (readOnly || params?.metadataSnapshot) { - return loadCatalog(); - } + const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ config: cfg }); + logStage("suppress-resolver-ready"); - const loadGeneration = modelCatalogGeneration; - const publishedPromise = loadCatalog().then((snapshot) => { - if ( - snapshot.entries.length > 0 && - modelCatalogGeneration === loadGeneration && - modelCatalogPromise === publishedPromise - ) { - loadedModelCatalogSnapshot = snapshot; - loadedModelCatalogGeneration = loadGeneration; + for (const entry of entries) { + const rawId = normalizeOptionalString(entry?.id) ?? ""; + if (!rawId) { + continue; + } + const provider = normalizeOptionalString(entry?.provider) ?? ""; + if (!provider) { + continue; + } + const id = normalizeConfiguredProviderCatalogModelId(provider, rawId, { + manifestPlugins: getManifestPlugins(), + }); + const baseUrl = normalizeOptionalString(entry?.baseUrl); + if (shouldSuppressBuiltInModel({ provider, id, baseUrl })) { + continue; + } + const name = normalizeOptionalString(entry?.name ?? id) || id; + const contextWindow = + typeof entry?.contextWindow === "number" && entry.contextWindow > 0 + ? entry.contextWindow + : undefined; + const contextTokens = + typeof entry?.contextTokens === "number" && entry.contextTokens > 0 + ? entry.contextTokens + : undefined; + const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined; + const api = typeof entry?.api === "string" ? entry.api : undefined; + const input = Array.isArray(entry?.input) ? entry.input : undefined; + const modelParams = + entry?.params && typeof entry.params === "object" ? entry.params : undefined; + const compat = entry?.compat && typeof entry.compat === "object" ? entry.compat : undefined; + const model = { + id, + name, + provider, + ...(api ? { api } : {}), + ...(baseUrl ? { baseUrl } : {}), + contextWindow, + ...(contextTokens !== undefined ? { contextTokens } : {}), + reasoning, + input, + ...(modelParams ? { params: modelParams } : {}), + compat, + } satisfies ModelCatalogEntry; + models.push(model); + mergeCatalogRouteVariants(routeVariants, [model]); } - return snapshot; - }); - modelCatalogPromise = publishedPromise; - return publishedPromise; -} + const manifestModels = loadManifestModelCatalog({ + config: cfg, + env, + metadataSnapshot: manifestMetadataSnapshot, + }); + mergeCatalogRouteVariants(routeVariants, manifestModels); + mergeCatalogEntries(models, manifestModels); + logStage("manifest-models-merged", `entries=${models.length}`); + const configuredModels = buildConfiguredModelCatalog({ + cfg, + manifestPlugins: hasConfiguredProviderModelRows(cfg) ? getManifestPlugins() : undefined, + }); + let augmentEntries: ModelCatalogEntry[] | undefined; + if (configuredModels.length > 0) { + const entriesForAugment = [...models]; + mergeCatalogEntries(entriesForAugment, configuredModels, { preserveBaseName: true }); + augmentEntries = entriesForAugment; + } + logStage("configured-models-prepared", `entries=${models.length}`); -/** Loads the deduplicated logical catalog for runtime and legacy consumers. */ -export async function loadModelCatalog( - params?: LoadModelCatalogParams, -): Promise { - return (await loadModelCatalogSnapshot(params)).entries; + if (!params.readOnly) { + const { createProviderApiKeyResolverFromPreparedCredentials } = + await loadProviderApiKeyResolver(); + const resolveProviderApiKeyForProvider = createProviderApiKeyResolverFromPreparedCredentials( + env, + params.authCredentials, + cfg, + ); + const resolveProviderApiKey = (providerId?: string) => + providerId?.trim() + ? resolveProviderApiKeyForProvider(providerId) + : { apiKey: undefined, discoveryApiKey: undefined }; + const supplemental = await augmentModelCatalogWithProviderPlugins({ + config: cfg, + workspaceDir, + env, + metadataSnapshot: manifestMetadataSnapshot, + context: { + config: cfg, + agentDir: params.agentDir, + workspaceDir, + env, + resolveProviderApiKey, + entries: augmentEntries ?? [...models], + }, + }); + if (supplemental.length > 0) { + const normalizedSupplemental: ModelCatalogEntry[] = []; + for (const entry of supplemental) { + normalizedSupplemental.push({ + ...entry, + id: normalizeConfiguredProviderCatalogModelId(entry.provider, entry.id, { + manifestPlugins: getManifestPlugins(), + }), + }); + } + mergeCatalogRouteVariants(routeVariants, normalizedSupplemental); + mergeCatalogEntries(models, normalizedSupplemental); + } + } + logStage("plugin-models-merged", `entries=${models.length}`); + + if (configuredModels.length > 0) { + mergeCatalogRouteVariants(routeVariants, configuredModels); + mergeCatalogEntries(models, configuredModels, { preserveBaseName: true }); + } + logStage("configured-models-finalized", `entries=${models.length}`); + + const snapshot = createModelCatalogSnapshot(models, routeVariants); + logStage("complete", `entries=${snapshot.entries.length}`); + return snapshot; + } catch (error) { + if (!hasLoggedModelCatalogError) { + hasLoggedModelCatalogError = true; + log.warn(`Failed to load model catalog: ${String(error)}`); + } + throw error; + } } /** @@ -1000,4 +549,3 @@ export function modelSupportsVision(entry: ModelCatalogEntry | undefined): boole export function modelSupportsDocument(entry: ModelCatalogEntry | undefined): boolean { return modelCatalogEntrySupportsInput(entry, "document"); } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/model-catalog.types.ts b/src/agents/model-catalog.types.ts index 8b9e15c2e36..a98e20e0c8d 100644 --- a/src/agents/model-catalog.types.ts +++ b/src/agents/model-catalog.types.ts @@ -30,6 +30,8 @@ export type ModelCatalogEntry = { export type ModelCatalogSnapshot = { entries: ModelCatalogEntry[]; routeVariants: ModelCatalogEntry[]; + /** Static provider-hook rows captured alongside the full lifecycle generation. */ + staticEntries?: ModelCatalogEntry[]; /** * `false` only when this snapshot came from a degraded load (discovery threw, * static or empty fallback). Absent/`true` means authoritative — consumers that diff --git a/src/agents/model-provider-auth.test.ts b/src/agents/model-provider-auth.test.ts index e26a7b97139..c182373ca83 100644 --- a/src/agents/model-provider-auth.test.ts +++ b/src/agents/model-provider-auth.test.ts @@ -15,6 +15,7 @@ import { publishProviderAuthWarmSnapshot } from "./model-provider-auth-state.js" const modelCatalogMocks = vi.hoisted(() => ({ loadModelCatalog: vi.fn<(params?: unknown) => Promise>(), + ownerWorkspaceDir: undefined as string | undefined, })); const modelAuthMocks = vi.hoisted(() => ({ @@ -64,8 +65,16 @@ const authProfilesMocks = vi.hoisted(() => ({ listProfilesForProvider: vi.fn(() => []), })); -vi.mock("./model-catalog.js", () => ({ - loadModelCatalog: modelCatalogMocks.loadModelCatalog, +vi.mock("./prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogOwnerSnapshot: async (params?: unknown) => ({ + ...(modelCatalogMocks.ownerWorkspaceDir + ? { workspaceDir: modelCatalogMocks.ownerWorkspaceDir } + : {}), + modelCatalog: { + entries: await modelCatalogMocks.loadModelCatalog(params), + routeVariants: [], + }, + }), })); vi.mock("./model-auth.js", () => ({ @@ -120,6 +129,7 @@ describe("prepared provider auth state", () => { afterEach(() => { clearCurrentProviderAuthState(); vi.clearAllMocks(); + modelCatalogMocks.ownerWorkspaceDir = undefined; modelAuthAvailabilityMocks.evaluateModelAuth.mockReturnValue({ availability: false, routeResolution: null, @@ -155,9 +165,31 @@ describe("prepared provider auth state", () => { await publishCurrentProviderAuthStateSnapshot(cfg); - expect(modelCatalogMocks.loadModelCatalog).toHaveBeenCalledWith({ - config: cfg, - readOnly: true, + expect(modelCatalogMocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + agentDir: expect.any(String), + readOnly: true, + }), + ); + expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty( + "workspaceDir", + ); + }); + + it("uses the prepared owner's authoritative workspace for auth discovery", async () => { + const cfg = {} as OpenClawConfig; + modelCatalogMocks.ownerWorkspaceDir = "/warm/gateway-launch-workspace"; + modelCatalogMocks.loadModelCatalog.mockResolvedValue([ + { id: "gpt", name: "gpt", provider: "openai" }, + ]); + modelAuthMocks.hasRuntimeAvailableProviderAuth.mockReturnValue(false); + + await buildCurrentProviderAuthStateSnapshot(cfg); + + expect(modelAuthMocks.createRuntimeProviderAuthLookup).toHaveBeenCalledWith({ + cfg, + workspaceDir: "/warm/gateway-launch-workspace", }); }); diff --git a/src/agents/model-provider-auth.ts b/src/agents/model-provider-auth.ts index bd12f4ddabc..e22e4215a36 100644 --- a/src/agents/model-provider-auth.ts +++ b/src/agents/model-provider-auth.ts @@ -36,7 +36,6 @@ import { hasRuntimeAvailableProviderAuth, type RuntimeProviderAuthLookup, } from "./model-auth.js"; -import { loadModelCatalog } from "./model-catalog.js"; import { cancelCurrentProviderAuthWarmWorker, claimCurrentProviderAuthStateGeneration, @@ -399,26 +398,31 @@ export async function buildCurrentProviderAuthStateSnapshot( } = {}, ): Promise { const isWarmStale = () => options.isCancelled?.() === true; - const catalog = await loadModelCatalog({ config: cfg, readOnly: true }); - if (isWarmStale()) { - return { agents: [] }; - } - const providers = new Set(); - for (const entry of catalog) { - providers.add(normalizeProviderId(entry.provider)); - } - const providerList = [...providers]; const configFingerprint = resolveProviderAuthConfigFingerprint(cfg) ?? ""; const states = new Map(); - // Warm one entry per configured agent so callers hit the prepared map for - // any agentId. The catalog above is shared across agents; the per-agent - // work is the auth-discovery sweep against that agent's store. + // Catalog generations are agent-scoped because provider plugins and auth stores can differ. + // Keep each auth snapshot paired with the same lifecycle owner that supplied its model rows. for (const agentId of listAgentIds(cfg)) { if (isWarmStale()) { return { agents: [] }; } - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const agentDir = resolveAgentDir(cfg, agentId); + // Worker warmup is the only path that may need to construct a read-only catalog generation. + // Keep the lifecycle graph out of foreground provider-auth module initialization. + const { loadPreparedModelCatalogOwnerSnapshot } = await import("./prepared-model-catalog.js"); + const preparedOwner = await loadPreparedModelCatalogOwnerSnapshot({ + config: cfg, + agentId, + agentDir, + readOnly: true, + }); + const workspaceDir = preparedOwner.workspaceDir ?? resolveAgentWorkspaceDir(cfg, agentId); + const catalog = preparedOwner.modelCatalog.entries; + if (isWarmStale()) { + return { agents: [] }; + } + const providers = new Set(catalog.map((entry) => normalizeProviderId(entry.provider))); + const providerList = [...providers]; const runtimeAuthLookup = options.runtimeAuthLookups?.get(agentId) ?? createRuntimeProviderAuthLookup({ diff --git a/src/agents/model-provider-auth.worker.test.ts b/src/agents/model-provider-auth.worker.test.ts index 3341e26618d..6b56d1b7860 100644 --- a/src/agents/model-provider-auth.worker.test.ts +++ b/src/agents/model-provider-auth.worker.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withEnvAsync } from "../test-utils/env.js"; import { clearRuntimeAuthProfileStoreSnapshots } from "./auth-profiles.js"; @@ -11,6 +11,27 @@ import { runProviderAuthWarmWorkerInput } from "./model-provider-auth.worker.js" const tempDirs: string[] = []; +vi.mock("./prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogOwnerSnapshot: vi.fn( + async (params: { agentDir: string; agentId?: string; config: OpenClawConfig }) => ({ + agentDir: params.agentDir, + agentId: params.agentId, + config: params.config, + modelCatalog: { + entries: Object.entries(params.config.models?.providers ?? {}).flatMap( + ([provider, providerConfig]) => + (providerConfig.models ?? []).map((model) => ({ + id: model.id, + name: model.name ?? model.id, + provider, + })), + ), + routeVariants: [], + }, + }), + ), +})); + describe("provider auth warm worker", () => { afterEach(() => { clearCurrentProviderAuthState(); diff --git a/src/agents/model-registry-loader.ts b/src/agents/model-registry-loader.ts index 1741badee1c..71e5fdbc1fc 100644 --- a/src/agents/model-registry-loader.ts +++ b/src/agents/model-registry-loader.ts @@ -1,44 +1,20 @@ -/** - * Shared model-registry loader for agent paths that need auth storage and - * plugin metadata resolved together before model discovery. - */ +/** Lifecycle-backed model-registry view for command paths. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; -import { resolveDefaultAgentDir } from "./agent-scope.js"; -import { resolveModelPluginMetadataSnapshot } from "./model-discovery-context.js"; +import { + loadPreparedAgentModelRegistry, + type LoadPreparedAgentModelRegistryOptions, +} from "./prepared-model-registry.js"; import type { ModelRegistry } from "./sessions/index.js"; -/** Options controlling model discovery, credential reads, and normalization. */ -type LoadAgentModelRegistryOptions = { - providerFilter?: string; - normalizeModels?: boolean; +/** Options controlling the prepared registry view. */ +type LoadAgentModelRegistryOptions = LoadPreparedAgentModelRegistryOptions & { readOnly?: boolean; - skipCredentials?: boolean; - workspaceDir?: string; }; -/** Load the agent model registry with optional provider filtering/normalization. */ -export function loadAgentModelRegistry( +/** Forks a registry from the generation prepared by the owning command lifecycle. */ +export async function loadAgentModelRegistry( config: OpenClawConfig, options: LoadAgentModelRegistryOptions = {}, -): { agentDir: string; registry: ModelRegistry } { - const agentDir = resolveDefaultAgentDir(config); - const authStorage = discoverAuthStorage(agentDir, { - readOnly: options.readOnly ?? true, - skipCredentials: options.skipCredentials, - config, - workspaceDir: options.workspaceDir, - }); - const pluginMetadataSnapshot = resolveModelPluginMetadataSnapshot({ - config, - workspaceDir: options.workspaceDir, - }); - const registry = discoverModels(authStorage, agentDir, { - config, - ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), - providerFilter: options.providerFilter, - ...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), - normalizeModels: options.normalizeModels, - }); - return { agentDir, registry }; +): Promise<{ agentDir: string; config: OpenClawConfig; registry: ModelRegistry }> { + return await loadPreparedAgentModelRegistry(config, options); } diff --git a/src/agents/model-thinking-default.ts b/src/agents/model-thinking-default.ts index f34d9a63762..1d80cdf8ed1 100644 --- a/src/agents/model-thinking-default.ts +++ b/src/agents/model-thinking-default.ts @@ -111,7 +111,7 @@ export async function resolveThinkingDefaultWithRuntimeCatalog(params: { cfg: OpenClawConfig; provider: string; model: string; - loadModelCatalog: () => Promise; + loadRuntimeCatalog: () => Promise; agentRuntime?: string | null; }): Promise { const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg }); @@ -122,7 +122,7 @@ export async function resolveThinkingDefaultWithRuntimeCatalog(params: { configuredCatalog.length === 0 || !configuredSelectedEntry || configuredSelectedEntry.reasoning === undefined; - const runtimeCatalog = needsRuntimeCatalog ? await params.loadModelCatalog() : undefined; + const runtimeCatalog = needsRuntimeCatalog ? await params.loadRuntimeCatalog() : undefined; const runtimeSelectedEntry = runtimeCatalog?.find( (entry) => entry.provider === params.provider && entry.id === params.model, ); diff --git a/src/agents/models-config.providers.secrets.ts b/src/agents/models-config.providers.secrets.ts index 0edcd2a6e56..6c45e1d5693 100644 --- a/src/agents/models-config.providers.secrets.ts +++ b/src/agents/models-config.providers.secrets.ts @@ -13,6 +13,7 @@ import { resolveProviderEnvAuthLookupMaps } from "./model-auth-env-vars.js"; import { isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, + resolveOAuthApiKeyMarker, resolveNonEnvSecretRefApiKeyMarker, } from "./model-auth-markers.js"; import { @@ -26,6 +27,7 @@ import { type ProviderAuthResolver, } from "./models-config.providers.secret-helpers.js"; import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; +import type { AuthStorageData } from "./sessions/index.js"; export type { ProviderApiKeyResolver, @@ -50,6 +52,42 @@ function resolveAuthProfileStoreInput(input: AuthProfileStoreInput) { return typeof input === "function" ? input() : input; } +/** Create a resolver over the credential map already selected for one lifecycle generation. */ +export function createProviderApiKeyResolverFromPreparedCredentials( + env: NodeJS.ProcessEnv, + credentials: Readonly, + config?: OpenClawConfig, +): ProviderApiKeyResolver { + const resolveConfiguredOrEnvironment = createProviderApiKeyResolver( + env, + { version: 1, profiles: {} }, + config, + ); + const getLookupCaches = createProviderAuthLookupCaches(env, config); + return (provider: string) => { + const authProvider = resolveProviderIdForAuthFromCaches(provider, getLookupCaches()); + // Discovery already collapsed profile and environment precedence into this generation. + // Rechecking ambient env first would make catalog augmentation describe a different account. + const credential = credentials[authProvider]; + if (!credential) { + return resolveConfiguredOrEnvironment(provider); + } + if (credential.type === "oauth") { + return { + apiKey: resolveOAuthApiKeyMarker(authProvider), + discoveryApiKey: toDiscoveryApiKey(credential.access), + }; + } + if (!credential.key.trim()) { + return resolveConfiguredOrEnvironment(provider); + } + return { + apiKey: credential.key, + discoveryApiKey: toDiscoveryApiKey(credential.key), + }; + }; +} + function createProviderAuthLookupCaches( env: NodeJS.ProcessEnv, config?: OpenClawConfig, diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 754b47c2da4..b81ddf4b9f4 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -12,6 +12,7 @@ import { type OpenClawConfig, } from "../config/config.js"; import { createConfigRuntimeEnv } from "../config/env-vars.js"; +import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js"; import { privateFileStore } from "../infra/private-file-store.js"; import { resolveInstalledManifestRegistryIndexFingerprint } from "../plugins/manifest-registry-installed.js"; import { @@ -45,6 +46,7 @@ type PreparedOpenClawModelsJsonSource = ModelsJsonReadyResult & { }; type EnsureOpenClawModelsJsonOptions = { + env?: NodeJS.ProcessEnv; pluginMetadataSnapshot?: Pick; workspaceDir?: string; providerDiscoveryProviderIds?: readonly string[]; @@ -82,20 +84,21 @@ async function buildModelsJsonFingerprint(params: { providerDiscoveryProviderIds?: readonly string[]; providerDiscoveryTimeoutMs?: number; providerDiscoveryEntriesOnly?: boolean; + env?: NodeJS.ProcessEnv; }): Promise { const authProfilesSqlitePath = resolveAuthProfileDatabasePath(params.agentDir); const authProfilesMtimeMs = await readFileMtimeMs(authProfilesSqlitePath); const authProfilesWalMtimeMs = await readFileMtimeMs(`${authProfilesSqlitePath}-wal`); const modelsFileMtimeMs = await readFileMtimeMs(path.join(params.agentDir, "models.json")); const pluginCatalogMtimes = await readPluginCatalogMtimes(params.agentDir); - const envShape = createConfigRuntimeEnv(params.config, {}); + const envShape = createConfigRuntimeEnv(params.config, params.env ?? {}); const pluginMetadataSnapshotIndexFingerprint = params.pluginMetadataSnapshot ? resolveInstalledManifestRegistryIndexFingerprint(params.pluginMetadataSnapshot.index) : undefined; return stableStringify({ config: params.config, sourceConfigForSecrets: params.sourceConfigForSecrets, - envShape, + envShape: params.env ? hashRuntimeConfigValue(envShape) : envShape, authProfilesMtimeMs, authProfilesWalMtimeMs, modelsFileMtimeMs, @@ -293,10 +296,11 @@ function resolveModelsConfigInput(config?: OpenClawConfig): { } /** Builds the canonical source freshness fingerprint for generated model catalogs. */ -export async function buildModelsJsonSourceFingerprint( +async function buildModelsJsonSourceFingerprint( config?: OpenClawConfig, agentDirOverride?: string, options: { + env?: NodeJS.ProcessEnv; pluginMetadataSnapshot?: Pick; workspaceDir?: string; providerDiscoveryProviderIds?: readonly string[]; @@ -316,7 +320,7 @@ export async function buildModelsJsonSourceFingerprint( options.pluginMetadataSnapshot ?? resolvePluginMetadataSnapshot({ config: cfg, - env: createConfigRuntimeEnv(cfg), + env: createConfigRuntimeEnv(cfg, options.env), ...(workspaceDir ? { workspaceDir } : {}), ...(providerScopedDiscovery ? { preferPersisted: false } : {}), }); @@ -327,6 +331,7 @@ export async function buildModelsJsonSourceFingerprint( agentDir, ...(workspaceDir ? { workspaceDir } : {}), ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), + ...(options.env ? { env: options.env } : {}), ...(options.providerDiscoveryProviderIds ? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds } : {}), @@ -349,7 +354,7 @@ async function withModelsJsonWriteLock(targetPath: string, run: () => Promise } /** Ensures models.json and plugin catalog sidecars are current for an agent. */ -export async function prepareOpenClawModelsJsonSource( +async function prepareOpenClawModelsJsonSource( config?: OpenClawConfig, agentDirOverride?: string, options: EnsureOpenClawModelsJsonOptions = {}, @@ -366,7 +371,7 @@ export async function prepareOpenClawModelsJsonSource( options.pluginMetadataSnapshot ?? resolvePluginMetadataSnapshot({ config: cfg, - env: createConfigRuntimeEnv(cfg), + env: createConfigRuntimeEnv(cfg, options.env), ...(workspaceDir ? { workspaceDir } : {}), ...(options.providerDiscoveryProviderIds?.length ? { preferPersisted: false } : {}), }); @@ -388,7 +393,7 @@ export async function prepareOpenClawModelsJsonSource( const pending: Promise = withModelsJsonWriteLock(targetPath, async () => { // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are // are available to provider discovery without mutating process.env. - const env = createConfigRuntimeEnv(cfg); + const env = createConfigRuntimeEnv(cfg, options.env); const existingModelsFile = await readExistingModelsFile(targetPath); const existingParsedForMerge = await mergeGeneratedPluginCatalogProvidersIntoExistingParsed({ agentDir, @@ -454,6 +459,7 @@ export async function prepareOpenClawModelsJsonSource( agentDir, ...(workspaceDir ? { workspaceDir } : {}), ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), + ...(options.env ? { env: options.env } : {}), ...(options.providerDiscoveryProviderIds ? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds } : {}), diff --git a/src/agents/openclaw-tools.model-context.ts b/src/agents/openclaw-tools.model-context.ts new file mode 100644 index 00000000000..98a4142f969 --- /dev/null +++ b/src/agents/openclaw-tools.model-context.ts @@ -0,0 +1,17 @@ +import type { AuthProfileStore } from "./auth-profiles/types.js"; +import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.js"; + +export type ModelAwareToolContext = { + agentDir?: string; + /** Lifecycle generation prepared for the active run. */ + preparedModelRuntime?: PreparedModelRuntimeSnapshot; + /** Auth profiles already loaded for prompt-time tool availability. */ + authProfileStore?: AuthProfileStore; + /** Whether the active model can inspect images without the image tool. */ + modelHasVision?: boolean; + /** Active provider/model pair used for tool gating. */ + modelProvider?: string; + modelId?: string; + /** Explicit agent ID override for cron and hook sessions. */ + requesterAgentIdOverride?: string; +}; diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index e2b2cd16712..b4a2a90043c 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -22,23 +22,6 @@ const callGatewayMock = vi.fn(); const buildStatusMessageMock = vi.hoisted(() => vi.fn((_params?: unknown) => "OpenClaw\n🧠 Model: GPT-5.4"), ); -const loadModelCatalogMock = vi.hoisted(() => - vi.fn(async () => [ - { - provider: "anthropic", - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - contextWindow: 200000, - }, - { - provider: "openai", - id: "gpt-5.4", - name: "GPT-5.4", - reasoning: true, - contextWindow: 400000, - }, - ]), -); const resolveQueueSettingsMock = vi.hoisted(() => vi.fn((_params?: unknown) => ({ mode: "interrupt" })), ); @@ -215,7 +198,21 @@ function createConfigModuleMock() { function createModelCatalogModuleMock() { return { - loadModelCatalog: loadModelCatalogMock, + loadPreparedModelCatalog: async () => [ + { + provider: "anthropic", + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + contextWindow: 200000, + }, + { + provider: "openai", + id: "gpt-5.4", + name: "GPT-5.4", + reasoning: true, + contextWindow: 400000, + }, + ], }; } @@ -270,7 +267,6 @@ function createCommandsStatusRuntimeModuleMock() { includeTranscriptUsage?: boolean; taskLineOverride?: string; resolveDefaultThinkingLevel?: () => unknown; - thinkingCatalog?: Array<{ provider: string; id: string; reasoning?: boolean }>; }) => { resolveQueueSettingsMock({ channel: params.statusChannel, @@ -308,7 +304,6 @@ function createCommandsStatusRuntimeModuleMock() { modelAuth, includeTranscriptUsage: params.includeTranscriptUsage, workspaceDir: params.workspaceDir, - thinkingCatalog: params.thinkingCatalog, }); return formatStatusLines(primary, params.taskLineOverride); }, @@ -318,7 +313,7 @@ function createCommandsStatusRuntimeModuleMock() { vi.mock("../config/sessions.js", createSessionsModuleMock); vi.mock("../gateway/call.js", createGatewayCallModuleMock); vi.mock("../config/config.js", createConfigModuleMock); -vi.mock("../agents/model-catalog.js", createModelCatalogModuleMock); +vi.mock("../agents/prepared-model-catalog.js", createModelCatalogModuleMock); vi.mock("../agents/provider-model-normalization.runtime.js", () => ({ normalizeProviderModelIdWithRuntime: () => undefined, })); @@ -534,7 +529,6 @@ function getSessionStatusTool( describe("session_status tool", () => { beforeEach(() => { buildStatusMessageMock.mockClear(); - loadModelCatalogMock.mockClear(); clearInternalHooks(); }); @@ -554,12 +548,6 @@ describe("session_status tool", () => { expect(details.statusText).toContain("OpenClaw"); expect(details.statusText).toContain("🧠 Model:"); expect(details.statusText).not.toContain("OAuth/token status"); - expect(loadModelCatalogMock).toHaveBeenCalledWith({ config: mockConfig, readOnly: true }); - expectRecordFields(mockCallArg(buildStatusMessageMock), { - thinkingCatalog: expect.arrayContaining([ - expect.objectContaining({ provider: "openai", id: "gpt-5.4", reasoning: true }), - ]), - }); expect(tool.outputSchema).toBeDefined(); expect(Value.Check(tool.outputSchema!, result.details)).toBe(true); expect(compactToolOutputHint(tool.outputSchema)).toBe( diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index c7e7c23ab3f..2ce6f54cc92 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -26,7 +26,6 @@ import { isToolWrappedWithBeforeToolCallHook, wrapToolWithBeforeToolCallHook, } from "./agent-tools.before-tool-call.js"; -import type { AuthProfileStore } from "./auth-profiles/types.js"; import type { ConversationRecallContext } from "./conversation-recall.types.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; import { @@ -35,6 +34,7 @@ import { resolveImageToolFactoryAvailable, resolveOptionalMediaToolFactoryPlan, } from "./openclaw-tools.media-factory-plan.js"; +import type { ModelAwareToolContext } from "./openclaw-tools.model-context.js"; import { applyNodesToolWorkspaceGuard } from "./openclaw-tools.nodes-workspace-guard.js"; import { collectPresentOpenClawTools, @@ -130,7 +130,6 @@ export function createOpenClawTools( nativeChannelId?: string; /** Opaque host-issued capability for current-turn channel message actions. */ messageActionTurnCapability?: string; - agentDir?: string; sandboxRoot?: string; sandboxContainerWorkdir?: string; sandboxFsBridge?: SandboxFsBridge; @@ -163,20 +162,12 @@ export function createOpenClawTools( hasRepliedRef?: { value: boolean }; /** Fail closed instead of posting same-channel thread-originated replies at the root. */ sameChannelThreadRequired?: boolean; - /** If true, the model has native vision capability */ - modelHasVision?: boolean; /** Mutable model-context generation used to expire screenshot coordinate frames. */ computerContextEpoch?: { value: number }; - /** Active model provider for provider-specific tool gating. */ - modelProvider?: string; - /** Active model id for provider/model-specific tool gating. */ - modelId?: string; /** Internal review-run restrictions and proposal provenance. */ skillWorkshop?: SkillWorkshopRunOptions; /** If true, nodes action="invoke" can call media-returning commands directly. */ allowMediaInvokeCommands?: boolean; - /** Explicit agent ID override for cron/hook sessions. */ - requesterAgentIdOverride?: string; /** Trusted sender identity bit for channel action auth. */ senderIsOwner?: boolean; /** Server-owned operation-local origin for conversation-read visibility policy. */ @@ -210,8 +201,6 @@ export function createOpenClawTools( recordToolPrepStage?: (name: string) => void; /** Trusted sender id from inbound context (not tool args). */ requesterSenderId?: string | null; - /** Auth profiles already loaded for this run; used for prompt-time tool availability. */ - authProfileStore?: AuthProfileStore; /** Ephemeral session UUID — regenerated on /new and /reset. */ sessionId?: string; /** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */ @@ -234,7 +223,8 @@ export function createOpenClawTools( onYield?: (message: string) => Promise | void; /** Allow plugin tools for this tool set to late-bind the gateway subagent. */ allowGatewaySubagentBinding?: boolean; - } & SpawnedToolContext, + } & SpawnedToolContext & + ModelAwareToolContext, ): AnyAgentTool[] { const resolvedConfig = options?.config; const runtimeSnapshot = getActiveSecretsRuntimeConfigSnapshot(); @@ -315,7 +305,9 @@ export function createOpenClawTools( }) ? createImageTool({ config: availabilityConfig ?? options?.config, + agentId: sessionAgentId, agentDir: imageToolAgentDir!, + preparedModelRuntime: options?.preparedModelRuntime, authProfileStore: options?.authProfileStore, workspaceDir, sandbox, @@ -374,7 +366,9 @@ export function createOpenClawTools( optionalMediaTools.pdf && options?.agentDir?.trim() ? createPdfTool({ config: options?.config, + agentId: sessionAgentId, agentDir: options.agentDir, + preparedModelRuntime: options?.preparedModelRuntime, authProfileStore: options?.authProfileStore, workspaceDir, sandbox, diff --git a/src/agents/prepared-model-catalog.test.ts b/src/agents/prepared-model-catalog.test.ts new file mode 100644 index 00000000000..37956d100c6 --- /dev/null +++ b/src/agents/prepared-model-catalog.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + config: {} as object, + activateSnapshot: vi.fn(), + acquireSnapshot: vi.fn(), + getSnapshot: vi.fn(), + loadSnapshot: vi.fn(), + prepareSnapshot: vi.fn(), + releaseSnapshot: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => mocks.config, +})); + +vi.mock("./agent-scope.js", () => ({ + listAgentIds: () => ["main"], + resolveAgentDir: () => "/tmp/prepared-model-catalog-agent", + resolveAgentWorkspaceDir: () => "/tmp/prepared-model-catalog-workspace", + resolveDefaultAgentDir: () => "/tmp/prepared-model-catalog-agent", + resolveDefaultAgentId: () => "main", +})); + +vi.mock("./prepared-model-runtime.js", () => { + class PreparedModelRuntimeOwnerNotPublishedError extends Error {} + return { + PreparedModelRuntimeOwnerNotPublishedError, + acquireAgentRunPreparedModelRuntime: async (input: Record) => ({ + snapshot: await mocks.acquireSnapshot(input), + release: mocks.releaseSnapshot, + }), + activateStandalonePreparedModelRuntime: (...args: unknown[]) => mocks.activateSnapshot(...args), + acquireReadOnlyPreparedModelRuntime: async (input: Record) => ({ + snapshot: await mocks.loadSnapshot({ ...input, readOnly: true }), + release: mocks.releaseSnapshot, + }), + getPreparedModelRuntimeSnapshot: (...args: unknown[]) => mocks.getSnapshot(...args), + loadPreparedModelRuntimeSnapshot: (...args: unknown[]) => mocks.loadSnapshot(...args), + preparedModelRuntimeConfigsMatch: (left: object, right: object) => + JSON.stringify(left) === JSON.stringify(right), + prepareModelRuntimeSnapshot: (...args: unknown[]) => mocks.prepareSnapshot(...args), + }; +}); + +import { + getPreparedModelCatalogSnapshot, + loadPreparedModelCatalogSnapshot, +} from "./prepared-model-catalog.js"; +import { PreparedModelRuntimeOwnerNotPublishedError } from "./prepared-model-runtime.js"; + +const fullSnapshot = { + config: mocks.config, + modelCatalog: { entries: [{ provider: "test", id: "full", name: "Full" }], routeVariants: [] }, +}; +const readOnlySnapshot = { + config: mocks.config, + modelCatalog: { + entries: [{ provider: "test", id: "read-only", name: "Read only" }], + routeVariants: [], + }, +}; + +describe("prepared model catalog access", () => { + beforeEach(() => { + mocks.activateSnapshot.mockReset(); + mocks.acquireSnapshot.mockReset(); + mocks.getSnapshot.mockReset(); + mocks.loadSnapshot.mockReset(); + mocks.prepareSnapshot.mockReset(); + mocks.releaseSnapshot.mockReset(); + }); + + it("does not return a full nonblocking generation from another config", () => { + mocks.getSnapshot + .mockReturnValueOnce({ ...fullSnapshot, config: { logging: { level: "debug" } } }) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(readOnlySnapshot); + + expect(getPreparedModelCatalogSnapshot({ readOnly: true })).toBe(readOnlySnapshot.modelCatalog); + expect(mocks.getSnapshot).toHaveBeenCalledTimes(3); + expect(mocks.getSnapshot).toHaveBeenLastCalledWith( + expect.objectContaining({ config: mocks.config, readOnly: true }), + ); + }); + + it("prefers the full lifecycle generation for read-only catalog loads", async () => { + mocks.prepareSnapshot.mockResolvedValue(fullSnapshot); + + await expect(loadPreparedModelCatalogSnapshot({ readOnly: true })).resolves.toBe( + fullSnapshot.modelCatalog, + ); + expect(mocks.prepareSnapshot).toHaveBeenCalledOnce(); + expect(mocks.prepareSnapshot.mock.calls[0]?.[0]).not.toHaveProperty("readOnly"); + expect(mocks.loadSnapshot).not.toHaveBeenCalled(); + expect(mocks.releaseSnapshot).not.toHaveBeenCalled(); + }); + + it("carries an explicit dynamic workspace into the read-only loader", async () => { + mocks.prepareSnapshot.mockRejectedValue(new PreparedModelRuntimeOwnerNotPublishedError()); + mocks.loadSnapshot.mockResolvedValue(readOnlySnapshot); + + await expect( + loadPreparedModelCatalogSnapshot({ + workspaceDir: "/tmp/dynamic-workspace", + readOnly: true, + }), + ).resolves.toBe(readOnlySnapshot.modelCatalog); + + expect(mocks.loadSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true, workspaceDir: "/tmp/dynamic-workspace" }), + ); + expect(mocks.releaseSnapshot).toHaveBeenCalledOnce(); + }); + + it("rejects a full generation replaced with another config", async () => { + const committedConfig = { agents: { defaults: { model: "openai/committed" } } }; + const committedSnapshot = { ...fullSnapshot, config: committedConfig }; + mocks.prepareSnapshot.mockResolvedValue(committedSnapshot); + + await expect(loadPreparedModelCatalogSnapshot({ readOnly: true })).rejects.toThrow( + "config was replaced", + ); + expect(mocks.loadSnapshot).not.toHaveBeenCalled(); + }); + + it("prefers the full published generation for read-only access", () => { + mocks.getSnapshot.mockReturnValue(fullSnapshot); + + expect(getPreparedModelCatalogSnapshot({ readOnly: true })).toBe(fullSnapshot.modelCatalog); + expect(mocks.getSnapshot).toHaveBeenCalledOnce(); + expect(mocks.getSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: "/tmp/prepared-model-catalog-agent", + config: mocks.config, + }), + ); + expect(mocks.getSnapshot.mock.calls[0]?.[0]).not.toHaveProperty("workspaceDir"); + expect(mocks.getSnapshot.mock.calls[0]?.[0]).not.toHaveProperty("readOnly"); + }); + + it("activates a persistent full owner for a standalone catalog read", async () => { + mocks.prepareSnapshot.mockRejectedValue(new PreparedModelRuntimeOwnerNotPublishedError()); + mocks.activateSnapshot.mockResolvedValue(fullSnapshot); + + await expect(loadPreparedModelCatalogSnapshot()).resolves.toBe(fullSnapshot.modelCatalog); + + expect(mocks.activateSnapshot).toHaveBeenCalledWith( + expect.not.objectContaining({ readOnly: true }), + ); + expect(mocks.loadSnapshot).not.toHaveBeenCalled(); + expect(mocks.releaseSnapshot).not.toHaveBeenCalled(); + }); + + it("rejects a standalone catalog owner built from another config", async () => { + mocks.prepareSnapshot.mockRejectedValue(new PreparedModelRuntimeOwnerNotPublishedError()); + mocks.activateSnapshot.mockResolvedValue({ + ...fullSnapshot, + config: { agents: { defaults: { model: "openai/old" } } }, + }); + + await expect(loadPreparedModelCatalogSnapshot()).rejects.toThrow("requested config"); + }); + + it("leases a full generation for a gateway preflight in a dynamic workspace", async () => { + mocks.prepareSnapshot.mockRejectedValue(new PreparedModelRuntimeOwnerNotPublishedError()); + mocks.activateSnapshot.mockResolvedValue(undefined); + mocks.acquireSnapshot.mockResolvedValue(fullSnapshot); + + await expect( + loadPreparedModelCatalogSnapshot({ workspaceDir: "/tmp/spawned-workspace" }), + ).resolves.toBe(fullSnapshot.modelCatalog); + + expect(mocks.acquireSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: "/tmp/spawned-workspace" }), + ); + expect(mocks.acquireSnapshot.mock.calls[0]?.[0]).not.toHaveProperty("readOnly"); + expect(mocks.releaseSnapshot).toHaveBeenCalledOnce(); + }); + + it("rejects a full fallback lease built from another config", async () => { + mocks.prepareSnapshot.mockRejectedValue(new PreparedModelRuntimeOwnerNotPublishedError()); + mocks.activateSnapshot.mockResolvedValue(undefined); + mocks.acquireSnapshot.mockResolvedValue({ + ...fullSnapshot, + config: { agents: { defaults: { model: "openai/old" } } }, + }); + + await expect(loadPreparedModelCatalogSnapshot()).rejects.toThrow("requested config"); + expect(mocks.releaseSnapshot).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts new file mode 100644 index 00000000000..0038f6b0b55 --- /dev/null +++ b/src/agents/prepared-model-catalog.ts @@ -0,0 +1,213 @@ +/** Lifecycle-owned model catalog access. */ +import { getRuntimeConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + listAgentIds, + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentDir, + resolveDefaultAgentId, +} from "./agent-scope.js"; +import type { ModelCatalogEntry, ModelCatalogSnapshot } from "./model-catalog.types.js"; +import { + acquireAgentRunPreparedModelRuntime, + acquireReadOnlyPreparedModelRuntime, + activateStandalonePreparedModelRuntime, + getPreparedModelRuntimeSnapshot, + prepareModelRuntimeSnapshot, + PreparedModelRuntimeOwnerNotPublishedError, + preparedModelRuntimeConfigsMatch, + type PreparedModelRuntimeInput, + type PreparedModelRuntimeSnapshot, +} from "./prepared-model-runtime.js"; + +export type LoadPreparedModelCatalogParams = { + agentId?: string; + agentDir?: string; + config?: OpenClawConfig; + readOnly?: boolean; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}; + +function resolveInputs(params: LoadPreparedModelCatalogParams = {}): { + exact: PreparedModelRuntimeInput; + full: PreparedModelRuntimeInput; + activationExact: PreparedModelRuntimeInput; + activationFull: PreparedModelRuntimeInput; +} { + const config = params.config ?? getRuntimeConfig(); + const explicitOrDefaultAgentId = + params.agentId ?? (params.agentDir === undefined ? resolveDefaultAgentId(config) : undefined); + const agentDir = + params.agentDir ?? + (explicitOrDefaultAgentId + ? resolveAgentDir(config, explicitOrDefaultAgentId) + : resolveDefaultAgentDir(config, params.env)); + const matchingAgentIds = + params.agentDir === undefined + ? [] + : listAgentIds(config).filter( + (candidateAgentId) => resolveAgentDir(config, candidateAgentId) === agentDir, + ); + const agentId = + explicitOrDefaultAgentId ?? + (params.agentDir === undefined + ? resolveDefaultAgentId(config) + : matchingAgentIds.length === 1 + ? matchingAgentIds[0] + : undefined); + const explicitWorkspaceDir = params.workspaceDir === undefined ? undefined : params.workspaceDir; + const activationWorkspaceDir = + explicitWorkspaceDir ?? (agentId ? resolveAgentWorkspaceDir(config, agentId) : undefined); + const full: PreparedModelRuntimeInput = { + ...(agentId ? { agentId } : {}), + agentDir, + config, + ...(params.env ? { env: params.env } : {}), + inheritedAuthDir: resolveDefaultAgentDir(config, params.env), + ...(explicitWorkspaceDir ? { workspaceDir: explicitWorkspaceDir } : {}), + }; + const exact = params.readOnly ? { ...full, readOnly: true } : full; + const activationFull = activationWorkspaceDir + ? { ...full, workspaceDir: activationWorkspaceDir } + : full; + return { + exact, + full, + activationFull, + activationExact: params.readOnly ? { ...activationFull, readOnly: true } : activationFull, + }; +} + +/** Returns the current published catalog without waiting or starting discovery. */ +export function getPreparedModelCatalogSnapshot( + params: LoadPreparedModelCatalogParams = {}, +): ModelCatalogSnapshot | undefined { + const { activationExact, activationFull, exact, full } = resolveInputs(params); + const publishedFull = getPreparedModelRuntimeSnapshot(full); + if (publishedFull && preparedModelRuntimeConfigsMatch(publishedFull.config, full.config)) { + return publishedFull.modelCatalog; + } + if (activationFull && activationFull.workspaceDir !== full.workspaceDir) { + const activatedFull = getPreparedModelRuntimeSnapshot(activationFull); + if (activatedFull && preparedModelRuntimeConfigsMatch(activatedFull.config, full.config)) { + return activatedFull.modelCatalog; + } + } + if (exact === full) { + return undefined; + } + const publishedExact = getPreparedModelRuntimeSnapshot(exact); + if (publishedExact && preparedModelRuntimeConfigsMatch(publishedExact.config, exact.config)) { + return publishedExact.modelCatalog; + } + if (!activationExact || activationExact.workspaceDir === exact.workspaceDir) { + return undefined; + } + const activatedExact = getPreparedModelRuntimeSnapshot(activationExact); + return activatedExact && preparedModelRuntimeConfigsMatch(activatedExact.config, exact.config) + ? activatedExact.modelCatalog + : undefined; +} + +/** Resolves the lifecycle owner used for a catalog read. */ +export async function loadPreparedModelCatalogOwnerSnapshot( + params: LoadPreparedModelCatalogParams = {}, +): Promise { + const { activationExact, activationFull, exact, full } = resolveInputs(params); + if (params.readOnly) { + const fullCandidates = + activationFull.workspaceDir === full.workspaceDir ? [full] : [full, activationFull]; + for (const candidate of fullCandidates) { + try { + // Full lifecycle owners include provider augmentation omitted by read-only fallback builds. + const prepared = await prepareModelRuntimeSnapshot(candidate); + if (!preparedModelRuntimeConfigsMatch(prepared.config, candidate.config)) { + throw new Error( + `prepared model catalog owner config was replaced during the read (${candidate.agentDir})`, + ); + } + return prepared; + } catch (error) { + if (!(error instanceof PreparedModelRuntimeOwnerNotPublishedError)) { + throw error; + } + } + } + const lease = await acquireReadOnlyPreparedModelRuntime(activationExact); + try { + if (!preparedModelRuntimeConfigsMatch(lease.snapshot.config, activationExact.config)) { + throw new Error( + `prepared model catalog owner config was replaced during the read (${activationExact.agentDir})`, + ); + } + return lease.snapshot; + } finally { + lease.release(); + } + } + if (exact !== full) { + const fullCandidates = + activationFull.workspaceDir === full.workspaceDir ? [full] : [full, activationFull]; + for (const candidate of fullCandidates) { + try { + const preparedFull = await prepareModelRuntimeSnapshot(candidate); + if (preparedModelRuntimeConfigsMatch(preparedFull.config, full.config)) { + return preparedFull; + } + } catch (error) { + if (!(error instanceof PreparedModelRuntimeOwnerNotPublishedError)) { + throw error; + } + } + } + } + try { + const preparedExact = await prepareModelRuntimeSnapshot(exact); + if (preparedModelRuntimeConfigsMatch(preparedExact.config, exact.config)) { + return preparedExact; + } + } catch (error) { + if (!(error instanceof PreparedModelRuntimeOwnerNotPublishedError)) { + throw error; + } + } + // Direct commands own a persistent standalone generation. During gateway lifetime, writable + // publication belongs exclusively to startup/reload or agent-run admission. + const activated = await activateStandalonePreparedModelRuntime(activationExact); + if (activated && preparedModelRuntimeConfigsMatch(activated.config, activationExact.config)) { + return activated; + } + if (activated) { + throw new PreparedModelRuntimeOwnerNotPublishedError( + `prepared model catalog owner was not published for the requested config (${activationExact.agentDir})`, + ); + } + // Gateway pre-run selection can name a spawned workspace before embedded-run admission. + // Lease a complete exact generation so provider catalog hooks remain visible for this read. + const lease = await acquireAgentRunPreparedModelRuntime(activationFull); + try { + if (!preparedModelRuntimeConfigsMatch(lease.snapshot.config, activationFull.config)) { + throw new PreparedModelRuntimeOwnerNotPublishedError( + `prepared model catalog owner was not published for the requested config (${activationFull.agentDir})`, + ); + } + return lease.snapshot; + } finally { + lease.release(); + } +} + +/** Reads one atomic catalog generation, activating a lifecycle owner when needed. */ +export async function loadPreparedModelCatalogSnapshot( + params: LoadPreparedModelCatalogParams = {}, +): Promise { + return (await loadPreparedModelCatalogOwnerSnapshot(params)).modelCatalog; +} + +export async function loadPreparedModelCatalog( + params: LoadPreparedModelCatalogParams = {}, +): Promise { + return (await loadPreparedModelCatalogSnapshot(params)).entries; +} diff --git a/src/agents/prepared-model-registry.test.ts b/src/agents/prepared-model-registry.test.ts new file mode 100644 index 00000000000..5efcda1c48d --- /dev/null +++ b/src/agents/prepared-model-registry.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + class OwnerNotPublishedError extends Error {} + return { + OwnerNotPublishedError, + activateSnapshot: vi.fn(), + getSnapshot: vi.fn(), + loadSnapshot: vi.fn(), + releaseSnapshot: vi.fn(), + prepareSnapshot: vi.fn(), + normalizeModel: vi.fn((model: { id: string }) => ({ + ...model, + name: `normalized:${model.id}`, + })), + }; +}); + +vi.mock("./agent-scope.js", () => ({ + resolveAgentDir: (_config: unknown, agentId: string) => `/agents/${agentId}`, + resolveAgentWorkspaceDir: (_config: unknown, agentId: string) => `/workspaces/${agentId}`, + resolveDefaultAgentDir: () => "/agents/main", + resolveDefaultAgentId: () => "main", +})); + +vi.mock("./agent-model-discovery.js", () => ({ + normalizeDiscoveredAgentModel: mocks.normalizeModel, +})); + +vi.mock("./prepared-model-runtime.js", () => ({ + acquireReadOnlyPreparedModelRuntime: async (input: Record) => ({ + snapshot: await mocks.loadSnapshot({ ...input, readOnly: true }), + release: mocks.releaseSnapshot, + }), + activateStandalonePreparedModelRuntime: mocks.activateSnapshot, + getPreparedModelRuntimeSnapshot: mocks.getSnapshot, + loadPreparedModelRuntimeSnapshot: mocks.loadSnapshot, + preparedModelRuntimeConfigsMatch: (left: object, right: object) => + JSON.stringify(left) === JSON.stringify(right), + prepareModelRuntimeSnapshot: mocks.prepareSnapshot, + PreparedModelRuntimeOwnerNotPublishedError: mocks.OwnerNotPublishedError, +})); + +const { loadPreparedAgentModelRegistry } = await import("./prepared-model-registry.js"); + +function createSnapshot( + models = [ + { provider: "openai", id: "gpt-test", name: "GPT Test", api: "openai-responses" }, + { provider: "anthropic", id: "claude-test", name: "Claude Test", api: "anthropic-messages" }, + ], +) { + const registry = { + fork: vi.fn(), + getAll: vi.fn(() => models), + getAvailable: vi.fn(() => models), + find: vi.fn((provider: string, id: string) => + models.find((model) => model.provider === provider && model.id === id), + ), + }; + registry.fork.mockReturnValue(registry); + return { + registry, + snapshot: { + agentDir: "/agents/main", + config: {}, + createStores: () => ({ modelRegistry: registry }), + }, + }; +} + +describe("prepared agent model registry", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.normalizeModel.mockImplementation((model: { id: string }) => ({ + ...model, + name: `normalized:${model.id}`, + })); + }); + + it("forks and filters the published lifecycle generation", async () => { + const { registry, snapshot } = createSnapshot(); + mocks.prepareSnapshot.mockResolvedValue(snapshot); + mocks.getSnapshot.mockReturnValue(snapshot); + + const loaded = await loadPreparedAgentModelRegistry( + {}, + { agentId: "worker", providerFilter: "OPENAI", workspaceDir: "/workspace" }, + ); + + expect(mocks.prepareSnapshot).toHaveBeenCalledWith({ + agentId: "worker", + agentDir: "/agents/worker", + config: {}, + inheritedAuthDir: "/agents/main", + workspaceDir: "/workspace", + }); + expect(mocks.activateSnapshot).not.toHaveBeenCalled(); + expect(loaded.registry.getAll()).toEqual([ + expect.objectContaining({ provider: "openai", name: "normalized:gpt-test" }), + ]); + expect(registry.find("anthropic", "claude-test")).toEqual( + expect.objectContaining({ provider: "anthropic", name: "normalized:claude-test" }), + ); + }); + + it("accepts the committed owner when config replacement wins a read race", async () => { + const { snapshot } = createSnapshot(); + const committedSnapshot = { + ...snapshot, + config: { agents: { defaults: { model: "openai/committed" } } }, + }; + mocks.prepareSnapshot.mockResolvedValue(committedSnapshot); + + const loaded = await loadPreparedAgentModelRegistry({ logging: { level: "debug" } }); + + expect(mocks.loadSnapshot).not.toHaveBeenCalled(); + expect(loaded.config).toBe(committedSnapshot.config); + }); + + it("loads a read-only generation when no owner is published", async () => { + const { snapshot } = createSnapshot(); + mocks.prepareSnapshot.mockRejectedValue(new mocks.OwnerNotPublishedError()); + mocks.loadSnapshot.mockImplementation(async () => { + mocks.getSnapshot.mockImplementation((input: { readOnly?: boolean }) => + input.readOnly ? snapshot : undefined, + ); + return snapshot; + }); + + const loaded = await loadPreparedAgentModelRegistry({}, { normalizeModels: false }); + + expect(mocks.loadSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "main", + agentDir: "/agents/main", + readOnly: true, + }), + ); + expect(loaded.registry.getAll()).toHaveLength(2); + expect(mocks.normalizeModel).not.toHaveBeenCalled(); + expect(mocks.releaseSnapshot).toHaveBeenCalledOnce(); + }); + + it("finds identities produced by normalization", async () => { + const { registry, snapshot } = createSnapshot([ + { + provider: "legacy-openai", + id: "raw-gpt-test", + name: "Raw GPT Test", + api: "openai-responses", + }, + ]); + const rawFind = registry.find; + mocks.normalizeModel.mockImplementation((model) => ({ + ...model, + provider: "openai", + id: "gpt-test", + name: "Normalized GPT Test", + })); + mocks.prepareSnapshot.mockResolvedValue(snapshot); + + const loaded = await loadPreparedAgentModelRegistry({}, { providerFilter: "openai" }); + const [normalized] = loaded.registry.getAll(); + + expect(normalized).toMatchObject({ provider: "openai", id: "gpt-test" }); + expect(loaded.registry.find("openai", "gpt-test")).toBe(normalized); + expect(rawFind).toHaveBeenCalledWith("openai", "gpt-test"); + }); + + it("prepares a distinct credential-free lifecycle owner", async () => { + const { snapshot } = createSnapshot(); + mocks.prepareSnapshot.mockRejectedValue(new mocks.OwnerNotPublishedError()); + mocks.loadSnapshot.mockResolvedValue(snapshot); + + await loadPreparedAgentModelRegistry({}, { normalizeModels: false, skipCredentials: true }); + + expect(mocks.prepareSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ skipCredentials: true }), + ); + expect(mocks.loadSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true, skipCredentials: true }), + ); + expect(mocks.releaseSnapshot).toHaveBeenCalledOnce(); + }); + + it("forks empty auth storage when availability loading is disabled", async () => { + const { registry, snapshot } = createSnapshot(); + mocks.prepareSnapshot.mockRejectedValue(new mocks.OwnerNotPublishedError()); + mocks.loadSnapshot.mockImplementation(async () => { + mocks.getSnapshot.mockImplementation((input: { readOnly?: boolean }) => + input.readOnly ? snapshot : undefined, + ); + return snapshot; + }); + + await loadPreparedAgentModelRegistry({}, { loadAvailability: false }); + + expect(mocks.loadSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true, skipCredentials: true }), + ); + expect(registry.fork).toHaveBeenCalledOnce(); + expect(mocks.releaseSnapshot).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/agents/prepared-model-registry.ts b/src/agents/prepared-model-registry.ts new file mode 100644 index 00000000000..9878687d6f4 --- /dev/null +++ b/src/agents/prepared-model-registry.ts @@ -0,0 +1,165 @@ +/** Request-isolated registry views forked from lifecycle-owned model generations. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { Model } from "../llm/types.js"; +import { normalizeDiscoveredAgentModel } from "./agent-model-discovery.js"; +import { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentDir, + resolveDefaultAgentId, +} from "./agent-scope.js"; +import { + acquireReadOnlyPreparedModelRuntime, + prepareModelRuntimeSnapshot, + PreparedModelRuntimeOwnerNotPublishedError, + type PreparedModelRuntimeInput, + type PreparedModelRuntimeLease, +} from "./prepared-model-runtime.js"; +import { AuthStorage, type ModelRegistry } from "./sessions/index.js"; + +export type LoadPreparedAgentModelRegistryOptions = { + agentId?: string; + agentDir?: string; + loadAvailability?: boolean; + providerFilter?: string; + normalizeModels?: boolean; + skipCredentials?: boolean; + workspaceDir?: string; +}; + +function usesCredentialFreeRegistry(options: LoadPreparedAgentModelRegistryOptions): boolean { + return options.skipCredentials === true || options.loadAvailability === false; +} + +function createRegistryView(params: { + registry: ModelRegistry; + agentDir: string; + config: OpenClawConfig; + providerFilter?: string; + normalizeModels?: boolean; + workspaceDir?: string; +}): ModelRegistry { + const { registry } = params; + const getAll = registry.getAll.bind(registry); + const getAvailable = registry.getAvailable.bind(registry); + const find = registry.find.bind(registry); + const providerFilter = params.providerFilter ? normalizeProviderId(params.providerFilter) : ""; + const matchesProviderFilter = (entry: Model) => + !providerFilter || normalizeProviderId(entry.provider) === providerFilter; + const shouldNormalize = params.normalizeModels !== false; + const normalizeEntry = (entry: Model) => + shouldNormalize + ? normalizeDiscoveredAgentModel(entry, params.agentDir, { + config: params.config, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }) + : entry; + let normalizedAll: Model[] | undefined; + let normalizedAvailable: Model[] | undefined; + const loadNormalizedAll = () => (normalizedAll ??= getAll().map(normalizeEntry)); + const loadNormalizedAvailable = () => + (normalizedAvailable ??= getAvailable().map(normalizeEntry)); + const findCache = new Map(); + + registry.getAll = () => loadNormalizedAll().filter(matchesProviderFilter); + registry.getAvailable = () => loadNormalizedAvailable().filter(matchesProviderFilter); + // Provider filters constrain list enumeration only. Direct configured-row lookups historically + // remain available so model-list fallback construction can resolve an explicit entry. + registry.find = (provider: string, modelId: string) => { + const key = `${normalizeProviderId(provider)}\0${modelId}`; + if (findCache.has(key)) { + return findCache.get(key); + } + const entry = find(provider, modelId); + const resolved = entry + ? normalizeEntry(entry) + : loadNormalizedAll().find( + (candidate) => + normalizeProviderId(candidate.provider) === normalizeProviderId(provider) && + candidate.id === modelId, + ); + findCache.set(key, resolved); + return resolved; + }; + return registry; +} + +function registryOwnerCandidates( + input: PreparedModelRuntimeInput, + allowConfiguredWorkspaceFallback: boolean, +): PreparedModelRuntimeInput[] { + if (!allowConfiguredWorkspaceFallback || !input.workspaceDir) { + return [input]; + } + const { workspaceDir: _workspaceDir, ...workspaceFree } = input; + return [workspaceFree, input]; +} + +async function loadReadSnapshot( + input: PreparedModelRuntimeInput, + allowConfiguredWorkspaceFallback: boolean, +): Promise { + for (const candidate of registryOwnerCandidates(input, allowConfiguredWorkspaceFallback)) { + try { + const prepared = await prepareModelRuntimeSnapshot(candidate); + // The lifecycle owner is authoritative when this read overlaps a config replacement. + return { snapshot: prepared, release: () => {} }; + } catch (error) { + if (!(error instanceof PreparedModelRuntimeOwnerNotPublishedError)) { + throw error; + } + } + } + return await acquireReadOnlyPreparedModelRuntime(input); +} + +function resolveInput( + config: OpenClawConfig, + options: LoadPreparedAgentModelRegistryOptions = {}, +): PreparedModelRuntimeInput { + const agentId = options.agentId ?? resolveDefaultAgentId(config); + const agentDir = options.agentDir ?? resolveAgentDir(config, agentId); + const workspaceDir = options.workspaceDir ?? resolveAgentWorkspaceDir(config, agentId); + return { + agentId, + agentDir, + config, + inheritedAuthDir: resolveDefaultAgentDir(config), + ...(usesCredentialFreeRegistry(options) ? { skipCredentials: true } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + }; +} + +/** Loads and forks one registry from the owning command lifecycle generation. */ +export async function loadPreparedAgentModelRegistry( + config: OpenClawConfig, + options: LoadPreparedAgentModelRegistryOptions = {}, +): Promise<{ agentDir: string; config: OpenClawConfig; registry: ModelRegistry }> { + const input = resolveInput(config, options); + const lease = await loadReadSnapshot(input, options.workspaceDir === undefined); + try { + const snapshot = lease.snapshot; + const stores = snapshot.createStores(); + // ModelRegistry.fork() restores the lifecycle template's raw base catalog before this view + // applies normalization. Credential-free owners therefore preserve configured IDs as well as + // preventing credential-dependent discovery during generation construction. + const modelRegistry = usesCredentialFreeRegistry(options) + ? stores.modelRegistry.fork(AuthStorage.inMemory({})) + : stores.modelRegistry; + return { + agentDir: snapshot.agentDir, + config: snapshot.config, + registry: createRegistryView({ + registry: modelRegistry, + agentDir: snapshot.agentDir, + config: snapshot.config, + providerFilter: options.providerFilter, + normalizeModels: options.normalizeModels, + workspaceDir: snapshot.workspaceDir ?? input.workspaceDir, + }), + }; + } finally { + lease.release(); + } +} diff --git a/src/agents/prepared-model-runtime.lifecycle.test.ts b/src/agents/prepared-model-runtime.lifecycle.test.ts new file mode 100644 index 00000000000..5703e1f017f --- /dev/null +++ b/src/agents/prepared-model-runtime.lifecycle.test.ts @@ -0,0 +1,912 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type LoadStaticCatalog = + typeof import("./embedded-agent-runner/model.static-catalog.js").loadBundledProviderStaticCatalogContextModels; + +const mocks = vi.hoisted(() => ({ + authStorage: { getAll: vi.fn(() => ({ custom: { type: "api_key", key: "test-key" } })) }, + modelRegistry: { + fork: vi.fn((authStorage: unknown) => ({ authStorage })), + getAll: vi.fn(() => []), + }, + discoverAuthStorage: vi.fn(), + discoverModels: vi.fn(), + ensureOpenClawModelsJson: vi.fn(async (..._args: unknown[]) => ({ + agentDir: "/tmp/agent", + wrote: false, + })), + buildPreparedModelCatalogSnapshot: vi.fn(async (..._args: unknown[]) => ({ + entries: [], + routeVariants: [], + })), + ensureRuntimePluginsLoaded: vi.fn(), + loadStaticCatalog: vi.fn(async () => []), + configuredAgentIds: [] as string[], + mutationListener: undefined as + | ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void) + | undefined, +})); + +vi.mock("./model-catalog.js", () => ({ + buildPreparedModelCatalogSnapshot: (...args: unknown[]) => + mocks.buildPreparedModelCatalogSnapshot(...args), +})); + +vi.mock("./agent-model-discovery.js", () => ({ + discoverAuthStorage: (...args: unknown[]) => { + mocks.discoverAuthStorage(...args); + return mocks.authStorage; + }, + discoverModels: (...args: unknown[]) => { + mocks.discoverModels(...args); + return mocks.modelRegistry; + }, +})); + +vi.mock("./agent-scope.js", () => ({ + listAgentIds: () => mocks.configuredAgentIds, + resolveAgentDir: (_config: unknown, agentId: string) => + agentId === "default" ? "/tmp/unused-agent" : `/tmp/configured-${agentId}`, + resolveAgentWorkspaceDir: (_config: unknown, agentId: string) => + agentId === "default" ? "/tmp/unused-workspace" : `/tmp/workspace-${agentId}`, + resolveDefaultAgentDir: () => "/tmp/unused-agent", + resolveDefaultAgentId: () => "default", +})); + +vi.mock("./auth-profiles/runtime-snapshots.js", () => ({ + registerRuntimeAuthProfileStoreMutationListener: ( + listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void, + ) => { + mocks.mutationListener = listener; + return () => {}; + }, +})); + +vi.mock("./model-discovery-context.js", () => ({ + resolveModelPluginMetadataSnapshot: () => undefined, +})); + +vi.mock("./models-config.js", () => ({ + ensureOpenClawModelsJson: (...args: unknown[]) => mocks.ensureOpenClawModelsJson(...args), +})); + +vi.mock("./runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: (...args: unknown[]) => mocks.ensureRuntimePluginsLoaded(...args), +})); + +vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({ + loadBundledProviderStaticCatalogContextModels: (...args: Parameters) => + mocks.loadStaticCatalog(...args), +})); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ warn: vi.fn() }), +})); + +import { + acquireAgentRunPreparedModelRuntime, + acquireReadOnlyPreparedModelRuntime, + activateStandalonePreparedModelRuntime, + markPreparedModelRuntimeSnapshotsStale, + prepareModelRuntimeSnapshot, + publishPreparedModelRuntimeSnapshot, + refreshPreparedModelRuntimeSnapshots, +} from "./prepared-model-runtime.js"; + +describe("prepared model runtime snapshots", () => { + const getTesting = () => + (globalThis as Record)[ + Symbol.for("openclaw.preparedModelRuntimeTestApi") + ] as { + resetPreparedModelRuntimeSnapshotsForTest: () => void; + setModelRuntimeBuildTimeoutMsForTest: (timeoutMs: number) => void; + }; + + beforeEach(() => { + getTesting().resetPreparedModelRuntimeSnapshotsForTest(); + mocks.discoverAuthStorage.mockClear(); + mocks.discoverModels.mockClear(); + mocks.ensureOpenClawModelsJson.mockClear(); + mocks.buildPreparedModelCatalogSnapshot.mockClear(); + mocks.ensureRuntimePluginsLoaded.mockClear(); + mocks.loadStaticCatalog.mockClear(); + mocks.modelRegistry.fork.mockClear(); + mocks.configuredAgentIds = []; + }); + + it("does not discover missing owners from a gateway request", async () => { + await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true }); + const input = { config: {}, agentDir: "/tmp/prepared-model-runtime-gateway-missing" }; + + await activateStandalonePreparedModelRuntime(input); + await expect(prepareModelRuntimeSnapshot(input)).rejects.toThrow( + "prepared model runtime owner was not published", + ); + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); + + it("does not let a read-only draft replace a configured gateway owner", async () => { + mocks.configuredAgentIds = ["default"]; + const configured = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(configured, { + gatewayLifecycle: true, + defaultWorkspaceDir: "/tmp/gateway-launch-workspace", + }); + + const activated = await activateStandalonePreparedModelRuntime({ + config: { agents: { defaults: { model: "openai/gpt-5.4" } } }, + agentId: "default", + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/gateway-launch-workspace", + readOnly: true, + }); + + expect(activated).toBeUndefined(); + await expect( + prepareModelRuntimeSnapshot({ + config: configured, + agentId: "default", + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/gateway-launch-workspace", + }), + ).resolves.toMatchObject({ config: configured }); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); + }); + + it("retires a standalone run owner when its final lease releases", async () => { + const input = { + config: {}, + agentId: "default", + agentDir: "/tmp/standalone-run-agent", + workspaceDir: "/tmp/one-off-run-workspace", + }; + const lease = await acquireAgentRunPreparedModelRuntime(input); + + await expect(prepareModelRuntimeSnapshot(input)).resolves.toBe(lease.snapshot); + lease.release(); + await expect(prepareModelRuntimeSnapshot(input)).rejects.toThrow( + "prepared model runtime owner was not published", + ); + }); + + it("publishes an exact dynamic workspace owner at gateway run admission", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + await refreshPreparedModelRuntimeSnapshots(config, { + gatewayLifecycle: true, + defaultWorkspaceDir: "/tmp/gateway-launch-workspace", + }); + + const firstLease = await acquireAgentRunPreparedModelRuntime({ + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/spawned-workspace", + }); + const secondLease = await acquireAgentRunPreparedModelRuntime({ + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/spawned-workspace", + }); + + expect(firstLease.snapshot.workspaceDir).toBe("/tmp/spawned-workspace"); + expect(secondLease.snapshot).toBe(firstLease.snapshot); + firstLease.release(); + await expect( + prepareModelRuntimeSnapshot({ + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/spawned-workspace", + }), + ).resolves.toBe(firstLease.snapshot); + secondLease.release(); + await expect( + prepareModelRuntimeSnapshot({ + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/spawned-workspace", + }), + ).rejects.toThrow("prepared model runtime owner was not published"); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + }); + + it("joins an in-flight dynamic owner publication", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true }); + let finishDynamic!: () => void; + mocks.ensureOpenClawModelsJson.mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: false }>((resolve) => { + finishDynamic = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false }); + }), + ); + const input = { + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/concurrent-dynamic-workspace", + }; + + const firstPending = acquireAgentRunPreparedModelRuntime(input); + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2)); + const secondPending = acquireAgentRunPreparedModelRuntime(input); + await Promise.resolve(); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + finishDynamic(); + const [first, second] = await Promise.all([firstPending, secondPending]); + + expect(second.snapshot).toBe(first.snapshot); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + first.release(); + second.release(); + }); + + it("does not let a stale dynamic lease authorize a replacement generation", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true }); + const input = { + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/stale-dynamic-workspace", + }; + const firstLease = await acquireAgentRunPreparedModelRuntime(input); + + markPreparedModelRuntimeSnapshotsStale("test dynamic owner staling"); + await expect(acquireAgentRunPreparedModelRuntime(input)).rejects.toThrow( + "prepared model runtime owner was not committed", + ); + firstLease.release(); + }); + + it("rebases a stale dynamic owner onto the committed configured generation", async () => { + mocks.configuredAgentIds = ["default"]; + const initialConfig = {}; + const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true }); + const dynamicInput = { + agentId: "default", + config: initialConfig, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/rebased-dynamic-workspace", + }; + const firstLease = await acquireAgentRunPreparedModelRuntime(dynamicInput); + markPreparedModelRuntimeSnapshotsStale("test committed dynamic rebase"); + await publishPreparedModelRuntimeSnapshot( + { + ...dynamicInput, + config: latestConfig, + workspaceDir: "/tmp/unused-workspace", + }, + { force: true, provenance: "configured" }, + ); + + const secondLease = await acquireAgentRunPreparedModelRuntime(dynamicInput); + expect(secondLease.snapshot.config).toBe(latestConfig); + expect(secondLease.snapshot.workspaceDir).toBe(dynamicInput.workspaceDir); + firstLease.release(); + secondLease.release(); + }); + + it("keeps a configured replacement after the matching dynamic lease releases", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + const input = { + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/unused-workspace", + }; + const dynamicLease = await acquireAgentRunPreparedModelRuntime(input); + + await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true }); + const configuredSnapshot = await prepareModelRuntimeSnapshot(input); + + expect(configuredSnapshot).not.toBe(dynamicLease.snapshot); + dynamicLease.release(); + await expect(prepareModelRuntimeSnapshot(input)).resolves.toBe(configuredSnapshot); + }); + + it("blocks new dynamic lease owners until lifecycle replacement commits", async () => { + mocks.configuredAgentIds = ["default"]; + const initialConfig = {}; + const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true }); + let finishReplacement!: () => void; + mocks.ensureOpenClawModelsJson.mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: boolean }>((resolve) => { + finishReplacement = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false }); + }), + ); + + markPreparedModelRuntimeSnapshotsStale("test lease replacement", { + waitForReplacement: true, + }); + const leasePending = acquireAgentRunPreparedModelRuntime({ + agentId: "default", + config: initialConfig, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/dynamic-replacement-workspace", + }); + await Promise.resolve(); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + + const refresh = refreshPreparedModelRuntimeSnapshots(latestConfig); + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2)); + finishReplacement(); + await refresh; + const lease = await leasePending; + + expect(lease.snapshot.config).toBe(latestConfig); + expect(lease.snapshot.workspaceDir).toBe("/tmp/dynamic-replacement-workspace"); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3); + lease.release(); + }); + + it("rebases a stale dynamic run after the replacement gate has closed", async () => { + mocks.configuredAgentIds = ["default"]; + const initialConfig = {}; + const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true }); + await refreshPreparedModelRuntimeSnapshots(latestConfig); + + const lease = await acquireAgentRunPreparedModelRuntime({ + agentId: "default", + config: initialConfig, + agentDir: "/tmp/stale-agent-dir", + inheritedAuthDir: "/tmp/stale-agent-dir", + workspaceDir: "/tmp/dynamic-post-reload-workspace", + }); + + expect(lease.snapshot.config).toBe(latestConfig); + expect(lease.snapshot.agentDir).toBe("/tmp/unused-agent"); + expect(lease.snapshot.workspaceDir).toBe("/tmp/dynamic-post-reload-workspace"); + lease.release(); + }); + + it("rebinds a queued canonical run to committed directories", async () => { + mocks.configuredAgentIds = ["default"]; + const initialConfig = {}; + const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true }); + + markPreparedModelRuntimeSnapshotsStale("test directory replacement", { + waitForReplacement: true, + }); + const leasePending = acquireAgentRunPreparedModelRuntime({ + agentId: "default", + config: initialConfig, + agentDir: "/tmp/old-agent-dir", + inheritedAuthDir: "/tmp/old-agent-dir", + workspaceDir: "/tmp/old-workspace-dir", + preserveWorkspaceDirOnRefresh: false, + }); + const refresh = refreshPreparedModelRuntimeSnapshots(latestConfig); + await refresh; + const lease = await leasePending; + + expect(lease.snapshot.config).toBe(latestConfig); + expect(lease.snapshot.agentDir).toBe("/tmp/unused-agent"); + expect(lease.snapshot.workspaceDir).toBe("/tmp/unused-workspace"); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + lease.release(); + }); + + it("reuses the configured owner at canonical gateway run admission", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + await refreshPreparedModelRuntimeSnapshots(config, { + gatewayLifecycle: true, + defaultWorkspaceDir: "/tmp/gateway-launch-workspace", + }); + + const lease = await acquireAgentRunPreparedModelRuntime({ + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/gateway-launch-workspace", + }); + + expect(lease.snapshot.workspaceDir).toBe("/tmp/gateway-launch-workspace"); + lease.release(); + await expect( + prepareModelRuntimeSnapshot({ + agentId: "default", + config, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/gateway-launch-workspace", + }), + ).resolves.toBe(lease.snapshot); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); + }); + + it("releases a one-read dynamic metadata generation", async () => { + await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true }); + const input = { + agentId: "default", + config: {}, + agentDir: "/tmp/prepared-model-runtime-metadata-agent", + workspaceDir: "/tmp/prepared-model-runtime-metadata-workspace", + }; + + const lease = await acquireReadOnlyPreparedModelRuntime(input); + expect(lease.snapshot.workspaceDir).toBe(input.workspaceDir); + lease.release(); + + await expect(prepareModelRuntimeSnapshot({ ...input, readOnly: true })).rejects.toThrow( + "prepared model runtime owner was not published", + ); + }); + + it("fails a timed-out publication without overlapping its late build with a retry", async () => { + getTesting().setModelRuntimeBuildTimeoutMsForTest(1); + let finishTimedOutBuild: (() => void) | undefined; + mocks.ensureOpenClawModelsJson.mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: false }>((resolve) => { + finishTimedOutBuild = () => resolve({ agentDir: "/tmp/agent", wrote: false }); + }), + ); + const input = { config: {}, agentDir: "/tmp/prepared-model-runtime-timeout" }; + + await expect(publishPreparedModelRuntimeSnapshot(input)).rejects.toThrow( + "prepared model runtime publication timed out", + ); + await expect(prepareModelRuntimeSnapshot(input)).rejects.toThrow( + "prepared model runtime publication timed out", + ); + await expect(publishPreparedModelRuntimeSnapshot(input)).rejects.toThrow( + "prepared model runtime publication timed out", + ); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); + + finishTimedOutBuild?.(); + await vi.waitFor(() => expect(mocks.discoverModels).toHaveBeenCalledOnce()); + await expect(publishPreparedModelRuntimeSnapshot(input)).resolves.toMatchObject({ + agentDir: input.agentDir, + }); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + }); + + it("rebuilds stale owners with the newly published config", async () => { + mocks.configuredAgentIds = ["default"]; + const agentDir = "/tmp/unused-agent"; + const firstConfig = {}; + const secondConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + const input = { + config: firstConfig, + agentDir, + inheritedAuthDir: agentDir, + workspaceDir: "/tmp/unused-workspace", + }; + await publishPreparedModelRuntimeSnapshot(input, { provenance: "configured" }); + + await refreshPreparedModelRuntimeSnapshots(secondConfig); + const refreshed = await prepareModelRuntimeSnapshot({ ...input, config: secondConfig }); + const fromStaleRequest = await prepareModelRuntimeSnapshot(input); + + expect(refreshed.config).toBe(secondConfig); + expect(fromStaleRequest).toBe(refreshed); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + }); + + it("does not serve the old snapshot after lifecycle refresh fails", async () => { + mocks.configuredAgentIds = ["default"]; + const agentDir = "/tmp/unused-agent"; + const firstConfig = {}; + const secondConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + const input = { + config: firstConfig, + agentDir, + inheritedAuthDir: agentDir, + workspaceDir: "/tmp/unused-workspace", + }; + await publishPreparedModelRuntimeSnapshot(input, { provenance: "configured" }); + const refreshError = new Error("catalog refresh failed"); + mocks.ensureOpenClawModelsJson.mockRejectedValueOnce(refreshError); + + await expect(refreshPreparedModelRuntimeSnapshots(secondConfig)).rejects.toBe(refreshError); + await expect(prepareModelRuntimeSnapshot({ ...input, config: secondConfig })).rejects.toBe( + refreshError, + ); + }); + + it("does not serve a retired owner when another owner fails to refresh", async () => { + mocks.configuredAgentIds = ["default", "removed"]; + const firstConfig = {}; + await refreshPreparedModelRuntimeSnapshots(firstConfig); + mocks.configuredAgentIds = ["default"]; + const refreshError = new Error("remaining owner refresh failed"); + mocks.ensureOpenClawModelsJson.mockRejectedValueOnce(refreshError); + + await expect(refreshPreparedModelRuntimeSnapshots({})).rejects.toBe(refreshError); + mocks.mutationListener?.({ + agentDir: "/tmp/configured-removed", + affectsInheritedStores: false, + }); + await expect( + prepareModelRuntimeSnapshot({ + config: firstConfig, + agentDir: "/tmp/configured-removed", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/workspace-removed", + }), + ).rejects.toThrow("owner was not published"); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3); + }); + + it("commits no configured owner when one sibling refresh fails", async () => { + mocks.configuredAgentIds = ["default", "secondary"]; + const firstConfig = {}; + await refreshPreparedModelRuntimeSnapshots(firstConfig); + const refreshError = new Error("secondary refresh failed"); + mocks.ensureOpenClawModelsJson + .mockResolvedValueOnce({ agentDir: "/tmp/unused-agent", wrote: false }) + .mockRejectedValueOnce(refreshError); + + await expect(refreshPreparedModelRuntimeSnapshots({})).rejects.toBe(refreshError); + await expect( + prepareModelRuntimeSnapshot({ + config: {}, + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/unused-workspace", + }), + ).rejects.toBe(refreshError); + await expect( + prepareModelRuntimeSnapshot({ + config: {}, + agentDir: "/tmp/configured-secondary", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/workspace-secondary", + }), + ).rejects.toBe(refreshError); + }); + + it("stales every owner when queued auth refresh fails after config publication", async () => { + mocks.configuredAgentIds = ["default", "secondary"]; + await refreshPreparedModelRuntimeSnapshots({}); + const refreshError = new Error("queued auth refresh failed"); + let finishConfigRefresh!: () => void; + mocks.ensureOpenClawModelsJson + .mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: false }>((resolve) => { + finishConfigRefresh = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false }); + }), + ) + .mockResolvedValueOnce({ agentDir: "/tmp/configured-secondary", wrote: false }) + .mockResolvedValueOnce({ agentDir: "/tmp/unused-agent", wrote: false }) + .mockRejectedValueOnce(refreshError); + + const refresh = refreshPreparedModelRuntimeSnapshots({}); + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(4)); + mocks.mutationListener?.({ affectsInheritedStores: true }); + finishConfigRefresh(); + + await expect(refresh).rejects.toBe(refreshError); + for (const [agentDir, workspaceDir] of [ + ["/tmp/unused-agent", "/tmp/unused-workspace"], + ["/tmp/configured-secondary", "/tmp/workspace-secondary"], + ] as const) { + await expect( + prepareModelRuntimeSnapshot({ + config: {}, + agentDir, + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir, + }), + ).rejects.toBe(refreshError); + } + }); + + it("awaits auth invalidation queued during lifecycle publication", async () => { + mocks.configuredAgentIds = ["default"]; + await refreshPreparedModelRuntimeSnapshots({}); + let finishConfigRefresh: (() => void) | undefined; + let finishAuthRefresh: (() => void) | undefined; + mocks.ensureOpenClawModelsJson + .mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: false }>((resolve) => { + finishConfigRefresh = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false }); + }), + ) + .mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: false }>((resolve) => { + finishAuthRefresh = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false }); + }), + ); + + const publication = refreshPreparedModelRuntimeSnapshots({}); + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2)); + mocks.mutationListener?.({ agentDir: "/tmp/unused-agent", affectsInheritedStores: false }); + finishConfigRefresh?.(); + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3)); + let settled = false; + void publication.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + finishAuthRefresh?.(); + await publication; + expect(settled).toBe(true); + }); + + it("invalidates and refreshes the affected owner at auth publication", async () => { + const config = {}; + const agentDir = "/tmp/prepared-model-runtime-auth"; + const first = await publishPreparedModelRuntimeSnapshot({ config, agentDir }); + + mocks.mutationListener?.({ agentDir, affectsInheritedStores: false }); + await expect(prepareModelRuntimeSnapshot({ config, agentDir })).rejects.toThrow( + "stale after auth mutation", + ); + + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2)); + const refreshed = await prepareModelRuntimeSnapshot({ config, agentDir }); + expect(refreshed).not.toBe(first); + expect(mocks.discoverAuthStorage).toHaveBeenCalledTimes(2); + }); + + it("refreshes owners that inherit the mutated auth directory", async () => { + const config = {}; + const agentDir = "/tmp/prepared-model-runtime-custom-agent"; + const inheritedAuthDir = "/tmp/prepared-model-runtime-main-agent"; + await publishPreparedModelRuntimeSnapshot({ config, agentDir, inheritedAuthDir }); + + mocks.mutationListener?.({ agentDir: inheritedAuthDir, affectsInheritedStores: false }); + + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2)); + expect(mocks.discoverAuthStorage).toHaveBeenLastCalledWith( + agentDir, + expect.objectContaining({ inheritedAuthDir }), + ); + }); + + it("tracks default auth inheritance when the owner omits the directory", async () => { + const config = {}; + const agentDir = "/tmp/prepared-model-runtime-implicit-inheritance"; + await publishPreparedModelRuntimeSnapshot({ config, agentDir }); + + mocks.mutationListener?.({ + agentDir: "/tmp/unused-agent", + affectsInheritedStores: false, + }); + + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2)); + expect(mocks.discoverAuthStorage).toHaveBeenLastCalledWith( + agentDir, + expect.objectContaining({ inheritedAuthDir: "/tmp/unused-agent" }), + ); + }); + + it("retains every owner until an explicit lifecycle invalidation", async () => { + const config = {}; + const firstAgentDir = "/tmp/prepared-model-runtime-concurrent-0"; + await Promise.all( + Array.from({ length: 70 }, async (_, index) => + publishPreparedModelRuntimeSnapshot({ + config, + agentDir: `/tmp/prepared-model-runtime-concurrent-${index}`, + }), + ), + ); + await prepareModelRuntimeSnapshot({ config, agentDir: firstAgentDir }); + + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(70); + expect(mocks.discoverAuthStorage).toHaveBeenCalledTimes(70); + expect(mocks.discoverModels).toHaveBeenCalledTimes(70); + }); + + it("serializes workspace replacements for one agent-owned catalog", async () => { + let finishFirst: (() => void) | undefined; + mocks.ensureOpenClawModelsJson.mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: false }>((resolve) => { + finishFirst = () => resolve({ agentDir: "/tmp/agent", wrote: false }); + }), + ); + const config = {}; + const agentDir = "/tmp/prepared-model-runtime-workspace-replacement"; + const first = publishPreparedModelRuntimeSnapshot({ + config, + agentDir, + workspaceDir: "/tmp/workspace-old", + }); + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce()); + const requestDuringFirstGeneration = prepareModelRuntimeSnapshot({ + config, + agentDir, + workspaceDir: "/tmp/workspace-old", + }); + + const replacement = publishPreparedModelRuntimeSnapshot({ + config, + agentDir, + workspaceDir: "/tmp/workspace-new", + }); + await Promise.resolve(); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); + + finishFirst?.(); + const firstSnapshot = await first; + const replacementSnapshot = await replacement; + expect(await requestDuringFirstGeneration).toBe(firstSnapshot); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenLastCalledWith( + config, + agentDir, + expect.objectContaining({ workspaceDir: "/tmp/workspace-new" }), + ); + expect( + await prepareModelRuntimeSnapshot({ + config, + agentDir, + workspaceDir: "/tmp/workspace-new", + }), + ).toBe(replacementSnapshot); + }); + + it("preserves an authoritative workspace override across config refresh", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + const agentDir = "/tmp/unused-agent"; + await publishPreparedModelRuntimeSnapshot( + { + agentId: "default", + config, + agentDir, + inheritedAuthDir: agentDir, + workspaceDir: "/tmp/explicit-workspace", + preserveWorkspaceDirOnRefresh: true, + }, + { provenance: "configured" }, + ); + + await refreshPreparedModelRuntimeSnapshots({ + agents: { defaults: { model: "openai/gpt-5.5" } }, + }); + const snapshot = await prepareModelRuntimeSnapshot({ + agentId: "default", + config, + agentDir, + inheritedAuthDir: agentDir, + workspaceDir: "/tmp/explicit-workspace", + }); + + expect(snapshot.workspaceDir).toBe("/tmp/explicit-workspace"); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenLastCalledWith( + expect.any(Object), + agentDir, + expect.objectContaining({ workspaceDir: "/tmp/explicit-workspace" }), + ); + }); + + it("finds the configured gateway owner when request config omits its launch workspace", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + + await refreshPreparedModelRuntimeSnapshots(config, { + gatewayLifecycle: true, + defaultWorkspaceDir: "/tmp/gateway-launch-workspace", + }); + const snapshot = await prepareModelRuntimeSnapshot({ + config, + agentDir: "/tmp/unused-agent", + }); + + expect(snapshot.workspaceDir).toBe("/tmp/gateway-launch-workspace"); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); + }); + + it("does not substitute a configured owner captured from another environment", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + await refreshPreparedModelRuntimeSnapshots(config, { + gatewayLifecycle: true, + defaultWorkspaceDir: "/tmp/gateway-launch-workspace", + }); + + await expect( + prepareModelRuntimeSnapshot({ + config, + agentDir: "/tmp/unused-agent", + env: { ...process.env, OPENCLAW_PREPARED_RUNTIME_TEST_SCOPE: "different" }, + }), + ).rejects.toThrow("prepared model runtime owner was not published"); + }); + + it("does not substitute a configured owner for an explicit workspace", async () => { + mocks.configuredAgentIds = ["default"]; + const config = {}; + + await refreshPreparedModelRuntimeSnapshots(config, { + gatewayLifecycle: true, + defaultWorkspaceDir: "/tmp/gateway-launch-workspace", + }); + + await expect( + prepareModelRuntimeSnapshot({ + config, + agentDir: "/tmp/unused-agent", + workspaceDir: "/tmp/other-explicit-workspace", + }), + ).rejects.toThrow("prepared model runtime owner was not published"); + }); + + it("does not choose between configured owners sharing one agent directory", async () => { + const config = {}; + const agentDir = "/tmp/shared-configured-agent"; + await publishPreparedModelRuntimeSnapshot( + { config, agentDir, workspaceDir: "/tmp/shared-workspace-a" }, + { provenance: "configured" }, + ); + await publishPreparedModelRuntimeSnapshot( + { config, agentDir, workspaceDir: "/tmp/shared-workspace-b" }, + { provenance: "configured" }, + ); + + await expect(prepareModelRuntimeSnapshot({ config, agentDir })).rejects.toThrow( + "prepared model runtime owner was not published", + ); + }); + + it("selects a configured owner by agent id when directories are shared", async () => { + const config = {}; + const agentDir = "/tmp/shared-agent-id-directory"; + await publishPreparedModelRuntimeSnapshot( + { agentId: "agent-a", config, agentDir, workspaceDir: "/tmp/shared-agent-id-workspace" }, + { provenance: "configured" }, + ); + const selected = await publishPreparedModelRuntimeSnapshot( + { agentId: "agent-b", config, agentDir, workspaceDir: "/tmp/shared-agent-id-workspace" }, + { provenance: "configured" }, + ); + + await expect( + prepareModelRuntimeSnapshot({ agentId: "agent-b", config, agentDir }), + ).resolves.toBe(selected); + }); + + it("retires configured owners removed by config reload", async () => { + mocks.configuredAgentIds = ["default", "removed"]; + const config = {}; + await refreshPreparedModelRuntimeSnapshots(config); + mocks.configuredAgentIds = ["default"]; + + await refreshPreparedModelRuntimeSnapshots(config); + + await expect( + prepareModelRuntimeSnapshot({ + config, + agentDir: "/tmp/configured-removed", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/workspace-removed", + }), + ).rejects.toThrow("prepared model runtime owner was not published"); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts new file mode 100644 index 00000000000..37238a77d4d --- /dev/null +++ b/src/agents/prepared-model-runtime.owner.ts @@ -0,0 +1,470 @@ +/** Construction and owner identity for prepared model runtime generations. */ +import path from "node:path"; +import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js"; +import { MODEL_APIS } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { withTimeout } from "../node-host/with-timeout.js"; +import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; +import { + listAgentIds, + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentDir, + resolveDefaultAgentId, +} from "./agent-scope.js"; +import { loadBundledProviderStaticCatalogContextModels } from "./embedded-agent-runner/model.static-catalog.js"; +import { buildPreparedModelCatalogSnapshot, type ModelCatalogEntry } from "./model-catalog.js"; +import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; +import { AuthStorage } from "./sessions/auth-storage.js"; +import type { ModelRegistry } from "./sessions/model-registry.js"; + +const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; + +export type PreparedModelRuntimeSnapshot = Readonly<{ + agentId?: string; + agentDir: string; + inheritedAuthDir?: string; + workspaceDir?: string; + config: OpenClawConfig; + metadataSnapshot: PluginMetadataSnapshot; + modelCatalog: ModelCatalogSnapshot; + createStores: () => PreparedModelRuntimeStores; +}>; + +export type PreparedModelRuntimeStores = { + authStorage: AuthStorage; + modelRegistry: ModelRegistry; +}; + +export type PreparedModelRuntimeInput = { + agentId?: string; + agentDir: string; + inheritedAuthDir?: string; + workspaceDir?: string; + preserveWorkspaceDirOnRefresh?: boolean; + readOnly?: boolean; + skipCredentials?: boolean; + env?: NodeJS.ProcessEnv; + config: OpenClawConfig; +}; + +export type PreparedModelRuntimeLease = Readonly<{ + snapshot: PreparedModelRuntimeSnapshot; + release: () => void; +}>; + +export type PreparedModelRuntimeOwner = { + input: PreparedModelRuntimeInput; + environmentFingerprint: string; + provenance: "configured" | "standalone" | "explicit" | "run" | "ephemeral"; + generation: number; + needsRefresh: boolean; + refreshError?: Error; + snapshot?: PreparedModelRuntimeSnapshot; + pending?: Promise; + buildCompletion?: Promise; + leaseCount?: number; +}; + +export type PreparedModelRuntimeReplacement = { + gateId: PreparedModelRuntimeReplacementGateId; + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +}; +export type PreparedModelRuntimeReplacementGateId = symbol; +export class PreparedModelRuntimeOwnerNotPublishedError extends Error {} + +export class PreparedModelRuntimePublicationSupersededError extends PreparedModelRuntimeOwnerNotPublishedError {} + +export function rebindInputToCommittedConfiguredOwner( + owners: Map, + rawInput: PreparedModelRuntimeInput, +): PreparedModelRuntimeInput { + const input = normalizePreparedModelRuntimeInput(rawInput); + const candidates = [...owners.values()].filter( + (owner) => + owner.provenance === "configured" && + owner.snapshot && + !owner.needsRefresh && + !owner.pending && + (input.agentId === undefined + ? owner.input.agentDir === input.agentDir + : owner.input.agentId === input.agentId), + ); + if (candidates.length !== 1) { + throw new PreparedModelRuntimeOwnerNotPublishedError( + `prepared model runtime owner was not committed after replacement for ${input.agentDir}`, + ); + } + const owner = candidates[0]!; + const preserveWorkspaceDir = + input.preserveWorkspaceDirOnRefresh === true && input.workspaceDir !== undefined; + return normalizePreparedModelRuntimeInput({ + ...input, + ...(owner.input.agentId ? { agentId: owner.input.agentId } : {}), + agentDir: owner.input.agentDir, + config: owner.input.config, + inheritedAuthDir: owner.input.inheritedAuthDir, + env: owner.input.env, + workspaceDir: preserveWorkspaceDir ? input.workspaceDir : owner.input.workspaceDir, + preserveWorkspaceDirOnRefresh: preserveWorkspaceDir, + }); +} + +/** Accepts canonical config clones without weakening projected-config isolation. */ +export function preparedModelRuntimeConfigsMatch( + left: OpenClawConfig, + right: OpenClawConfig, +): boolean { + if (left === right) { + return true; + } + try { + return hashRuntimeConfigValue(left) === hashRuntimeConfigValue(right); + } catch { + return false; + } +} + +export function normalizeOptionalDir(dirname: string | undefined): string | undefined { + return dirname ? path.resolve(dirname) : undefined; +} + +export function normalizePreparedModelRuntimeInput( + input: PreparedModelRuntimeInput, +): PreparedModelRuntimeInput { + const { + inheritedAuthDir: _inheritedAuthDir, + readOnly, + skipCredentials, + workspaceDir: _workspaceDir, + ...rest + } = input; + const inheritedAuthDir = normalizeOptionalDir( + input.inheritedAuthDir ?? resolveDefaultAgentDir(input.config, input.env), + ); + const workspaceDir = normalizeOptionalDir(input.workspaceDir); + const env = input.env ? Object.freeze({ ...input.env }) : undefined; + return { + ...rest, + agentDir: path.resolve(input.agentDir), + ...(inheritedAuthDir ? { inheritedAuthDir } : {}), + ...(readOnly === true ? { readOnly: true } : {}), + ...(skipCredentials === true ? { skipCredentials: true } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + ...(env ? { env } : {}), + }; +} + +function environmentFingerprint(env: NodeJS.ProcessEnv | undefined): string | undefined { + return env ? hashRuntimeConfigValue(env) : undefined; +} + +export function effectiveEnvironmentFingerprint(input: PreparedModelRuntimeInput): string { + return hashRuntimeConfigValue(input.env ?? process.env); +} + +function isCatalogModelApi( + value: string | undefined, +): value is NonNullable { + return value !== undefined && (MODEL_APIS as readonly string[]).includes(value); +} + +function toStaticCatalogEntry( + model: Awaited>[number], +): ModelCatalogEntry { + return { + id: model.id, + name: model.name ?? model.id, + provider: model.provider, + ...(isCatalogModelApi(model.api) ? { api: model.api } : {}), + ...(model.baseUrl ? { baseUrl: model.baseUrl } : {}), + ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), + ...(model.contextTokens ? { contextTokens: model.contextTokens } : {}), + ...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}), + ...(model.input ? { input: model.input } : {}), + ...(model.params ? { params: model.params } : {}), + ...(model.compat ? { compat: model.compat } : {}), + ...(model.mediaInput ? { mediaInput: model.mediaInput } : {}), + }; +} + +export function ownerKey(input: PreparedModelRuntimeInput): string { + return JSON.stringify({ + agentId: input.agentId, + agentDir: input.agentDir, + inheritedAuthDir: input.inheritedAuthDir, + readOnly: input.readOnly === true, + skipCredentials: input.skipCredentials === true, + workspaceDir: input.workspaceDir, + env: environmentFingerprint(input.env), + config: input.readOnly ? hashRuntimeConfigValue(input.config) : undefined, + }); +} + +export function resolvePublishedOwner( + owners: Map, + input: PreparedModelRuntimeInput, + options: { allowConfiguredWorkspaceFallback?: boolean } = {}, +): PreparedModelRuntimeOwner | undefined { + const exact = owners.get(ownerKey(input)); + if (exact) { + return exact; + } + if (!options.allowConfiguredWorkspaceFallback) { + return undefined; + } + // Gateway launch may supply an authoritative workspace outside config. Request readers still + // resolve the one configured lifecycle owner by agent; standalone/explicit owners remain exact. + const candidates = [...owners.values()].filter( + (owner) => + owner.provenance === "configured" && + (input.agentId === undefined || owner.input.agentId === input.agentId) && + owner.input.agentDir === input.agentDir && + owner.input.inheritedAuthDir === input.inheritedAuthDir && + owner.input.readOnly === input.readOnly && + owner.input.skipCredentials === input.skipCredentials && + (input.env === undefined || + owner.environmentFingerprint === environmentFingerprint(input.env)) && + (input.workspaceDir === undefined || owner.input.workspaceDir === input.workspaceDir), + ); + return candidates.length === 1 ? candidates[0] : undefined; +} + +export function hasSameLifecycleInput( + left: PreparedModelRuntimeInput, + right: PreparedModelRuntimeInput, +): boolean { + return ( + left.config === right.config && + left.agentId === right.agentId && + left.inheritedAuthDir === right.inheritedAuthDir && + left.readOnly === right.readOnly && + left.skipCredentials === right.skipCredentials && + left.workspaceDir === right.workspaceDir && + environmentFingerprint(left.env) === environmentFingerprint(right.env) && + left.preserveWorkspaceDirOnRefresh === right.preserveWorkspaceDirOnRefresh + ); +} + +export function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +export function createPreparedModelRuntimeReplacement(): PreparedModelRuntimeReplacement { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + // Readers await the original promise. This handler only prevents an unobserved rejected gate + // when a reload fails before any request reaches the stale generation. + void promise.catch(() => undefined); + return { gateId: Symbol("prepared-model-runtime-replacement"), promise, resolve, reject }; +} + +export function listConfiguredOwnerInputs( + config: OpenClawConfig, + defaultWorkspaceDir?: string, +): PreparedModelRuntimeInput[] { + const inheritedAuthDir = resolveDefaultAgentDir(config); + const defaultAgentId = resolveDefaultAgentId(config); + return listAgentIds(config).map((agentId) => { + const preserveWorkspaceDirOnRefresh = agentId === defaultAgentId && defaultWorkspaceDir; + const input: PreparedModelRuntimeInput = { + agentId, + agentDir: resolveAgentDir(config, agentId), + config, + inheritedAuthDir, + workspaceDir: preserveWorkspaceDirOnRefresh + ? defaultWorkspaceDir + : resolveAgentWorkspaceDir(config, agentId), + }; + if (preserveWorkspaceDirOnRefresh) { + input.preserveWorkspaceDirOnRefresh = true; + } + return input; + }); +} + +async function buildSnapshot( + input: PreparedModelRuntimeInput, +): Promise { + const env = input.env ?? process.env; + if (!input.readOnly) { + // Writable lifecycle publication owns process-global runtime plugin activation. Read-only + // drafts consume manifest metadata only and must not mutate live hooks outside that gate. + ensureRuntimePluginsLoaded({ + config: input.config, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + } + const pluginMetadataSnapshot = resolvePluginMetadataSnapshot({ + config: input.config, + env, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + if (!input.readOnly) { + await ensureOpenClawModelsJson(input.config, input.agentDir, { + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + ...(input.env ? { env } : {}), + providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS, + }); + } + const templateAuthStorage = discoverAuthStorage(input.agentDir, { + config: input.config, + // Snapshot construction never initializes, migrates, or externally syncs auth. A writable + // generation performs its file preparation above; ModelRegistry discovery only parses it. + readOnly: true, + ...(input.skipCredentials ? { skipCredentials: true } : {}), + ...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + ...(input.env ? { env } : {}), + }); + const templateModelRegistry = discoverModels(templateAuthStorage, input.agentDir, { + config: input.config, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), + }); + const credentials = templateAuthStorage.getAll(); + const modelCatalog = await buildPreparedModelCatalogSnapshot({ + agentDir: input.agentDir, + authCredentials: credentials, + config: input.config, + modelRegistry: templateModelRegistry, + metadataSnapshot: pluginMetadataSnapshot, + ...(input.env ? { env } : {}), + ...(input.readOnly ? { readOnly: true } : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + const staticEntries = ( + await loadBundledProviderStaticCatalogContextModels({ + cfg: input.config, + env, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }) + ).map(toStaticCatalogEntry); + const createStores = (): PreparedModelRuntimeStores => { + // Runtime API keys and session extensions mutate these objects. Fork them per run while the + // credential map and parsed catalog remain owned by the lifecycle snapshot. + const authStorage = AuthStorage.inMemory(credentials); + return { authStorage, modelRegistry: templateModelRegistry.fork(authStorage) }; + }; + return Object.freeze({ + ...(input.agentId ? { agentId: input.agentId } : {}), + agentDir: input.agentDir, + ...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + config: input.config, + metadataSnapshot: pluginMetadataSnapshot, + modelCatalog: { ...modelCatalog, staticEntries }, + createStores, + }); +} + +export function startSerializedSnapshotBuild( + input: PreparedModelRuntimeInput, + agentBuildCompletions: Map>, + buildTimeoutMs: number, +): { + pending: Promise; + completion: Promise; +} { + const previousBuildCompletion = agentBuildCompletions.get(input.agentDir); + // Lifecycle events may overlap. The timeout covers queueing plus this build, while completion + // follows the real work so a timed-out generation can never overlap a replacement. + const startBuild = (async () => { + if (previousBuildCompletion) { + await previousBuildCompletion; + } + return { actualBuild: buildSnapshot(input) }; + })(); + const completion = startBuild + .then(async ({ actualBuild }) => await actualBuild) + .then( + () => undefined, + () => undefined, + ); + agentBuildCompletions.set(input.agentDir, completion); + void completion.then(() => { + if (agentBuildCompletions.get(input.agentDir) === completion) { + agentBuildCompletions.delete(input.agentDir); + } + }); + return { + pending: withTimeout( + async () => { + const { actualBuild } = await startBuild; + return await actualBuild; + }, + buildTimeoutMs, + "prepared model runtime publication", + ), + completion, + }; +} + +export async function publishModelRuntimeSnapshot( + input: PreparedModelRuntimeInput, + owners: Map, + agentBuildCompletions: Map>, + buildTimeoutMs: number, + existing?: PreparedModelRuntimeOwner, + provenance: PreparedModelRuntimeOwner["provenance"] = "explicit", +): Promise { + const key = ownerKey(input); + const owner: PreparedModelRuntimeOwner = existing ?? { + input, + environmentFingerprint: effectiveEnvironmentFingerprint(input), + provenance, + generation: 0, + needsRefresh: false, + }; + owner.input = input; + owner.environmentFingerprint = effectiveEnvironmentFingerprint(input); + owner.provenance = provenance; + owner.generation += 1; + owner.needsRefresh = true; + owner.refreshError = undefined; + const generation = owner.generation; + const build = startSerializedSnapshotBuild(input, agentBuildCompletions, buildTimeoutMs); + owner.buildCompletion = build.completion; + void build.completion.then(() => { + if (owner.buildCompletion === build.completion) { + owner.buildCompletion = undefined; + } + }); + owners.set(key, owner); + const publication = (async () => { + try { + const snapshot = await build.pending; + if (owner.generation !== generation || owners.get(key) !== owner) { + throw new PreparedModelRuntimePublicationSupersededError( + `prepared model runtime publication was superseded for ${input.agentDir}`, + ); + } + owner.snapshot = snapshot; + owner.pending = undefined; + owner.needsRefresh = false; + return snapshot; + } catch (error) { + const refreshError = toError(error); + if (owner.generation === generation && owners.get(key) === owner) { + owner.pending = undefined; + owner.needsRefresh = true; + owner.refreshError = refreshError; + } + throw refreshError; + } + })(); + // Every waiter observes the publication guard, not the underlying discovery result. This keeps + // invalidated generations from escaping even when callers deduplicate against pending work. + owner.pending = publication; + return await publication; +} diff --git a/src/agents/prepared-model-runtime.test-support.ts b/src/agents/prepared-model-runtime.test-support.ts new file mode 100644 index 00000000000..5fc7f55f725 --- /dev/null +++ b/src/agents/prepared-model-runtime.test-support.ts @@ -0,0 +1,11 @@ +type PreparedModelRuntimeTestApi = { + resetPreparedModelRuntimeSnapshotsForTest(): void; +}; + +/** Clears prepared model owners when the production module is loaded in this test worker. */ +export function resetPreparedModelRuntimeSnapshotsForTest(): void { + const api = (globalThis as Record)[ + Symbol.for("openclaw.preparedModelRuntimeTestApi") + ] as PreparedModelRuntimeTestApi | undefined; + api?.resetPreparedModelRuntimeSnapshotsForTest(); +} diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts new file mode 100644 index 00000000000..b30f8956563 --- /dev/null +++ b/src/agents/prepared-model-runtime.test.ts @@ -0,0 +1,710 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type LoadStaticCatalog = + typeof import("./embedded-agent-runner/model.static-catalog.js").loadBundledProviderStaticCatalogContextModels; + +const mocks = vi.hoisted(() => ({ + authStorage: { getAll: vi.fn(() => ({ custom: { type: "api_key", key: "test-key" } })) }, + modelRegistry: { + fork: vi.fn((authStorage: unknown) => ({ authStorage })), + getAll: vi.fn(() => []), + }, + discoverAuthStorage: vi.fn(), + discoverModels: vi.fn(), + ensureOpenClawModelsJson: vi.fn(async (..._args: unknown[]) => ({ + agentDir: "/tmp/agent", + wrote: false, + })), + buildPreparedModelCatalogSnapshot: vi.fn(async (..._args: unknown[]) => ({ + entries: [], + routeVariants: [], + })), + ensureRuntimePluginsLoaded: vi.fn(), + loadStaticCatalog: vi.fn(async () => []), + configuredAgentIds: [] as string[], + mutationListener: undefined as + | ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void) + | undefined, +})); + +vi.mock("./model-catalog.js", () => ({ + buildPreparedModelCatalogSnapshot: (...args: unknown[]) => + mocks.buildPreparedModelCatalogSnapshot(...args), +})); + +vi.mock("./agent-model-discovery.js", () => ({ + discoverAuthStorage: (...args: unknown[]) => { + mocks.discoverAuthStorage(...args); + return mocks.authStorage; + }, + discoverModels: (...args: unknown[]) => { + mocks.discoverModels(...args); + return mocks.modelRegistry; + }, +})); + +vi.mock("./agent-scope.js", () => ({ + listAgentIds: () => mocks.configuredAgentIds, + resolveAgentDir: (_config: unknown, agentId: string) => + agentId === "default" ? "/tmp/unused-agent" : `/tmp/configured-${agentId}`, + resolveAgentWorkspaceDir: (_config: unknown, agentId: string) => + agentId === "default" ? "/tmp/unused-workspace" : `/tmp/workspace-${agentId}`, + resolveDefaultAgentDir: () => "/tmp/unused-agent", + resolveDefaultAgentId: () => "default", +})); + +vi.mock("./auth-profiles/runtime-snapshots.js", () => ({ + registerRuntimeAuthProfileStoreMutationListener: ( + listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void, + ) => { + mocks.mutationListener = listener; + return () => {}; + }, +})); + +vi.mock("./model-discovery-context.js", () => ({ + resolveModelPluginMetadataSnapshot: () => undefined, +})); + +vi.mock("./models-config.js", () => ({ + ensureOpenClawModelsJson: (...args: unknown[]) => mocks.ensureOpenClawModelsJson(...args), +})); + +vi.mock("./runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: (...args: unknown[]) => mocks.ensureRuntimePluginsLoaded(...args), +})); + +vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({ + loadBundledProviderStaticCatalogContextModels: (...args: Parameters) => + mocks.loadStaticCatalog(...args), +})); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ warn: vi.fn() }), +})); + +import { + acquireReadOnlyPreparedModelRuntime, + activateStandalonePreparedModelRuntime, + getPreparedModelRuntimeSnapshot, + loadPreparedModelRuntimeSnapshot, + markPreparedModelRuntimeSnapshotsStale, + prepareModelRuntimeSnapshot, + publishPreparedModelRuntimeSnapshot, + rejectPendingPreparedModelRuntimeReplacement, + refreshPreparedModelRuntimeSnapshots, +} from "./prepared-model-runtime.js"; + +describe("prepared model runtime snapshots", () => { + const getTesting = () => + (globalThis as Record)[ + Symbol.for("openclaw.preparedModelRuntimeTestApi") + ] as { + resetPreparedModelRuntimeSnapshotsForTest: () => void; + setModelRuntimeBuildTimeoutMsForTest: (timeoutMs: number) => void; + }; + + beforeEach(() => { + getTesting().resetPreparedModelRuntimeSnapshotsForTest(); + mocks.discoverAuthStorage.mockClear(); + mocks.discoverModels.mockClear(); + mocks.ensureOpenClawModelsJson.mockClear(); + mocks.buildPreparedModelCatalogSnapshot.mockClear(); + mocks.ensureRuntimePluginsLoaded.mockClear(); + mocks.loadStaticCatalog.mockClear(); + mocks.modelRegistry.fork.mockClear(); + mocks.configuredAgentIds = []; + }); + + it("reactivates a standalone read-only owner after a publication boundary", async () => { + const input = { + agentDir: "/tmp/prepared-model-runtime-read-only-reactivation", + config: {}, + readOnly: true, + }; + await activateStandalonePreparedModelRuntime(input); + + markPreparedModelRuntimeSnapshotsStale("test config publication"); + + expect(getPreparedModelRuntimeSnapshot(input)).toBeUndefined(); + await expect(loadPreparedModelRuntimeSnapshot(input)).resolves.toMatchObject({ + config: input.config, + }); + expect(mocks.discoverAuthStorage).toHaveBeenCalledTimes(2); + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); + + it("never returns a standalone generation invalidated while it is building", async () => { + const input = { + agentDir: "/tmp/prepared-model-runtime-standalone-build-race", + config: {}, + }; + let finishFirstBuild!: () => void; + mocks.ensureOpenClawModelsJson.mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: boolean }>((resolve) => { + finishFirstBuild = () => resolve({ agentDir: input.agentDir, wrote: false }); + }), + ); + + const activation = activateStandalonePreparedModelRuntime(input); + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce()); + markPreparedModelRuntimeSnapshotsStale("test in-flight standalone publication"); + finishFirstBuild(); + + const published = await activation; + expect(published).toBeDefined(); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + await expect(prepareModelRuntimeSnapshot(input)).resolves.toBe(published); + }); + + it("loads runtime plugins before discovering an immutable generation", async () => { + await publishPreparedModelRuntimeSnapshot({ + config: {}, + agentDir: "/tmp/prepared-model-runtime-plugin-order", + workspaceDir: "/tmp/prepared-model-runtime-plugin-workspace", + }); + + expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: {}, + workspaceDir: "/tmp/prepared-model-runtime-plugin-workspace", + }); + expect(mocks.ensureRuntimePluginsLoaded.mock.invocationCallOrder[0]).toBeLessThan( + mocks.discoverAuthStorage.mock.invocationCallOrder[0]!, + ); + }); + + it("uses an explicit lifecycle environment for catalog and auth discovery", async () => { + const env = { NVIDIA_API_KEY: "test-nvidia-api-key" }; + await publishPreparedModelRuntimeSnapshot({ + config: {}, + agentDir: "/tmp/prepared-model-runtime-explicit-env", + env, + }); + + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledWith( + {}, + "/tmp/prepared-model-runtime-explicit-env", + expect.objectContaining({ env }), + ); + expect(mocks.discoverAuthStorage).toHaveBeenCalledWith( + "/tmp/prepared-model-runtime-explicit-env", + expect.objectContaining({ env }), + ); + expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ env }), + ); + }); + + it("captures static provider-hook rows in the same lifecycle generation", async () => { + mocks.loadStaticCatalog.mockResolvedValueOnce([ + { + provider: "nvidia", + id: "nemotron-static", + name: "Nemotron Static", + api: "openai-completions", + baseUrl: "https://integrate.api.nvidia.com/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ]); + + const snapshot = await publishPreparedModelRuntimeSnapshot({ + config: {}, + agentDir: "/tmp/prepared-model-runtime-static-catalog", + workspaceDir: "/tmp/prepared-model-runtime-static-workspace", + }); + + expect(mocks.loadStaticCatalog).toHaveBeenCalledWith({ + cfg: {}, + env: process.env, + workspaceDir: "/tmp/prepared-model-runtime-static-workspace", + }); + expect(snapshot.modelCatalog.staticEntries).toEqual([ + { + provider: "nvidia", + id: "nemotron-static", + name: "Nemotron Static", + api: "openai-completions", + baseUrl: "https://integrate.api.nvidia.com/v1", + contextWindow: 128_000, + reasoning: false, + input: ["text"], + }, + ]); + }); + + it("omits provider runtime APIs outside the catalog contract", async () => { + mocks.loadStaticCatalog.mockResolvedValueOnce([ + { + provider: "custom", + id: "custom-static", + name: "Custom Static", + api: "mistral-conversations", + baseUrl: "https://example.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 8_192, + }, + ]); + + const snapshot = await publishPreparedModelRuntimeSnapshot({ + config: {}, + agentDir: "/tmp/prepared-model-runtime-unsupported-api", + }); + + expect(snapshot.modelCatalog.staticEntries).toEqual([ + { + provider: "custom", + id: "custom-static", + name: "Custom Static", + baseUrl: "https://example.test/v1", + contextWindow: 32_000, + reasoning: false, + input: ["text"], + }, + ]); + }); + + it("stales a published owner synchronously before replacement", async () => { + const input = { config: {}, agentDir: "/tmp/prepared-model-runtime-stale" }; + await publishPreparedModelRuntimeSnapshot(input); + + markPreparedModelRuntimeSnapshotsStale("test publication boundary"); + + expect(getPreparedModelRuntimeSnapshot(input)).toBeUndefined(); + await expect(prepareModelRuntimeSnapshot(input)).rejects.toThrow("test publication boundary"); + }); + + it("holds stale reads until the committed replacement is published", async () => { + mocks.configuredAgentIds = ["default"]; + const firstConfig = {}; + const secondConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + const input = { + agentId: "default", + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/unused-workspace", + }; + await refreshPreparedModelRuntimeSnapshots(firstConfig); + + markPreparedModelRuntimeSnapshotsStale("test config commit", { waitForReplacement: true }); + const read = prepareModelRuntimeSnapshot({ ...input, config: secondConfig }); + await expect( + Promise.race([ + read.then( + () => "settled", + () => "settled", + ), + Promise.resolve("pending"), + ]), + ).resolves.toBe("pending"); + + const refresh = refreshPreparedModelRuntimeSnapshots(secondConfig); + await expect(read).resolves.toMatchObject({ config: secondConfig }); + await refresh; + }); + + it("rebinds unpublished read-only activation to the committed replacement config", async () => { + mocks.configuredAgentIds = ["default"]; + const initialConfig = {}; + const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true }); + + markPreparedModelRuntimeSnapshotsStale("test read-only replacement", { + waitForReplacement: true, + }); + const read = loadPreparedModelRuntimeSnapshot({ + agentId: "default", + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/dynamic-read-only-workspace", + config: initialConfig, + readOnly: true, + }); + markPreparedModelRuntimeSnapshotsStale("test superseding read-only replacement", { + waitForReplacement: true, + }); + expect( + getPreparedModelRuntimeSnapshot({ + agentId: "default", + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + config: latestConfig, + }), + ).toBeUndefined(); + const refresh = refreshPreparedModelRuntimeSnapshots(latestConfig); + + await expect(read).resolves.toMatchObject({ + config: latestConfig, + workspaceDir: "/tmp/dynamic-read-only-workspace", + }); + await refresh; + }); + + it("does not let a superseded reload reject the current replacement gate", async () => { + mocks.configuredAgentIds = ["default"]; + const initialConfig = {}; + const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(initialConfig); + + const supersededGate = markPreparedModelRuntimeSnapshotsStale("test superseded reload", { + waitForReplacement: true, + }); + markPreparedModelRuntimeSnapshotsStale("test current reload", { waitForReplacement: true }); + rejectPendingPreparedModelRuntimeReplacement( + supersededGate, + new Error("superseded reload cancelled"), + ); + const read = prepareModelRuntimeSnapshot({ + agentId: "default", + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/unused-workspace", + config: latestConfig, + }); + const refresh = refreshPreparedModelRuntimeSnapshots(latestConfig); + + await expect(read).resolves.toMatchObject({ config: latestConfig }); + await refresh; + }); + + it("allows a read-only draft owner while the gateway lifecycle is active", async () => { + await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true }); + const draftConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + + await expect( + activateStandalonePreparedModelRuntime({ + agentDir: "/tmp/prepared-model-runtime-read-only-draft", + config: draftConfig, + readOnly: true, + }), + ).resolves.toMatchObject({ config: draftConfig }); + expect(mocks.discoverAuthStorage).toHaveBeenCalledWith( + "/tmp/prepared-model-runtime-read-only-draft", + expect.objectContaining({ readOnly: true }), + ); + expect(mocks.discoverModels).toHaveBeenCalledOnce(); + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); + }); + + it("builds credential-free command owners separately from runtime owners", async () => { + const config = {}; + const agentDir = "/tmp/prepared-model-runtime-credential-free"; + await publishPreparedModelRuntimeSnapshot({ config, agentDir }); + + const credentialFree = await publishPreparedModelRuntimeSnapshot({ + config, + agentDir, + readOnly: true, + skipCredentials: true, + }); + + expect(credentialFree).not.toBe(await prepareModelRuntimeSnapshot({ config, agentDir })); + expect(mocks.discoverAuthStorage).toHaveBeenNthCalledWith( + 2, + agentDir, + expect.objectContaining({ readOnly: true, skipCredentials: true }), + ); + }); + + it("reuses one lifecycle-owned snapshot without rediscovering files", async () => { + const config = {}; + const input = { config, agentDir: "/tmp/prepared-model-runtime-reuse" }; + + const first = await publishPreparedModelRuntimeSnapshot(input); + const second = await prepareModelRuntimeSnapshot(input); + + expect(second).toBe(first); + expect(Object.isFrozen(first)).toBe(true); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + expect(mocks.discoverAuthStorage).toHaveBeenCalledTimes(1); + expect(mocks.discoverModels).toHaveBeenCalledTimes(1); + expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ authCredentials: mocks.authStorage.getAll() }), + ); + const firstStores = first.createStores(); + const secondStores = first.createStores(); + expect(secondStores.authStorage).not.toBe(firstStores.authStorage); + expect(secondStores.modelRegistry).not.toBe(firstStores.modelRegistry); + }); + + it("ignores request config identity until lifecycle publication", async () => { + const agentDir = "/tmp/prepared-model-runtime-request-config"; + const initialConfig = {}; + const first = await publishPreparedModelRuntimeSnapshot({ config: initialConfig, agentDir }); + + const fromEquivalentClone = await prepareModelRuntimeSnapshot({ config: {}, agentDir }); + + expect(fromEquivalentClone).toBe(first); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + }); + + it("reuses read-only owners for equivalent config clones but rejects projections", async () => { + const agentDir = "/tmp/prepared-model-runtime-read-only-config"; + const config = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + const first = await publishPreparedModelRuntimeSnapshot({ config, agentDir, readOnly: true }); + + await expect( + prepareModelRuntimeSnapshot({ + config: { agents: { defaults: { model: "openai/gpt-5.5" } } }, + agentDir, + readOnly: true, + }), + ).resolves.toBe(first); + await expect( + prepareModelRuntimeSnapshot({ + config: { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } }, + agentDir, + readOnly: true, + }), + ).rejects.toThrow("not published"); + const secondLease = await acquireReadOnlyPreparedModelRuntime({ + config: { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } }, + agentDir, + }); + expect(secondLease.snapshot).not.toBe(first); + expect(mocks.discoverModels).toHaveBeenCalledTimes(2); + secondLease.release(); + }); + + it("keeps synchronous read-only snapshots isolated by config", async () => { + const agentDir = "/tmp/prepared-model-runtime-sync-read-only-config"; + const config = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + const snapshot = await publishPreparedModelRuntimeSnapshot({ + config, + agentDir, + readOnly: true, + }); + + expect( + getPreparedModelRuntimeSnapshot({ + config: { agents: { defaults: { model: "openai/gpt-5.5" } } }, + agentDir, + readOnly: true, + }), + ).toBe(snapshot); + expect( + getPreparedModelRuntimeSnapshot({ + config: { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } }, + agentDir, + readOnly: true, + }), + ).toBeUndefined(); + }); + + it("canonicalizes explicit false owner flags", async () => { + const input = { + agentId: "worker", + config: {}, + agentDir: "/tmp/configured-worker", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/workspace-worker", + }; + await publishPreparedModelRuntimeSnapshot(input, { provenance: "configured" }); + + await expect( + prepareModelRuntimeSnapshot({ + ...input, + readOnly: false, + skipCredentials: false, + workspaceDir: undefined, + }), + ).resolves.toMatchObject({ agentId: "worker", workspaceDir: "/tmp/workspace-worker" }); + }); + + it("uses the explicit lifecycle config when adding an owner after a gateway refresh", async () => { + const explicitConfig = {}; + const publishedConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(publishedConfig); + + const snapshot = await publishPreparedModelRuntimeSnapshot({ + config: explicitConfig, + agentDir: "/tmp/prepared-model-runtime-late-owner", + }); + + expect(snapshot.config).toBe(explicitConfig); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledWith( + explicitConfig, + expect.any(String), + expect.any(Object), + ); + }); + + it("rebuilds a standalone owner when its explicit config changes", async () => { + const agentDir = "/tmp/prepared-model-runtime-standalone-config"; + const firstConfig = {}; + const secondConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + + await activateStandalonePreparedModelRuntime({ config: firstConfig, agentDir }); + await activateStandalonePreparedModelRuntime({ config: secondConfig, agentDir }); + const snapshot = await prepareModelRuntimeSnapshot({ config: secondConfig, agentDir }); + + expect(snapshot.config).toBe(secondConfig); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenLastCalledWith( + secondConfig, + agentDir, + expect.any(Object), + ); + }); + + it("keeps each standalone activation bound to its published generation", async () => { + const agentDir = "/tmp/prepared-model-runtime-overlapping-standalone"; + const firstConfig = {}; + const secondConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + + const first = await activateStandalonePreparedModelRuntime({ config: firstConfig, agentDir }); + const second = await activateStandalonePreparedModelRuntime({ config: secondConfig, agentDir }); + + expect(first?.config).toBe(firstConfig); + expect(second?.config).toBe(secondConfig); + expect(first).not.toBe(second); + }); + + it("serializes conflicting standalone activations for one owner", async () => { + const agentDir = "/tmp/prepared-model-runtime-concurrent-standalone"; + const firstConfig = {}; + const secondConfig = {}; + let finishFirstBuild!: () => void; + mocks.ensureOpenClawModelsJson.mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: boolean }>((resolve) => { + finishFirstBuild = () => resolve({ agentDir, wrote: false }); + }), + ); + + const firstActivation = activateStandalonePreparedModelRuntime({ + config: firstConfig, + agentDir, + }); + await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce()); + const secondActivation = activateStandalonePreparedModelRuntime({ + config: secondConfig, + agentDir, + }); + + await Promise.resolve(); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); + finishFirstBuild(); + + const [first, second] = await Promise.all([firstActivation, secondActivation]); + expect(first?.config).toBe(firstConfig); + expect(second?.config).toBe(secondConfig); + expect(first).not.toBe(second); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2); + }); + + it("does not discover a missing owner from a request lookup", async () => { + await expect( + prepareModelRuntimeSnapshot({ + config: {}, + agentDir: "/tmp/prepared-model-runtime-missing-owner", + }), + ).rejects.toThrow("prepared model runtime owner was not published"); + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); + + it("deduplicates standalone activation while publishing later owners", async () => { + const input = { + config: {}, + agentDir: "/tmp/prepared-model-runtime-standalone", + workspaceDir: "/tmp/prepared-model-runtime-standalone-workspace", + }; + + await activateStandalonePreparedModelRuntime(input); + await activateStandalonePreparedModelRuntime(input); + await activateStandalonePreparedModelRuntime({ + ...input, + agentDir: "/tmp/prepared-model-runtime-standalone-second", + }); + const replacementInput = { ...input, workspaceDir: "/tmp/standalone-replacement-workspace" }; + await activateStandalonePreparedModelRuntime(replacementInput); + await expect(prepareModelRuntimeSnapshot(replacementInput)).resolves.toMatchObject({ + agentDir: input.agentDir, + workspaceDir: replacementInput.workspaceDir, + }); + await expect(prepareModelRuntimeSnapshot(input)).resolves.toMatchObject({ + workspaceDir: input.workspaceDir, + }); + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3); + }); + + it("skips a queued config generation superseded before its build starts", async () => { + mocks.configuredAgentIds = ["default"]; + const firstConfig = { agents: { defaults: { model: "openai/gpt-5.4" } } }; + const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + + const first = refreshPreparedModelRuntimeSnapshots(firstConfig); + const latest = refreshPreparedModelRuntimeSnapshots(latestConfig); + await Promise.all([first, latest]); + + expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); + await expect( + prepareModelRuntimeSnapshot({ + agentDir: "/tmp/unused-agent", + config: latestConfig, + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/unused-workspace", + }), + ).resolves.toMatchObject({ config: latestConfig }); + }); + + it("keeps replacement readers blocked when an earlier refresh is superseded", async () => { + mocks.configuredAgentIds = ["default"]; + const initialConfig = {}; + const skippedConfig = { agents: { defaults: { model: "openai/gpt-5.4" } } }; + const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(initialConfig); + let finishLatestBuild!: () => void; + mocks.ensureOpenClawModelsJson.mockImplementationOnce( + async () => + await new Promise<{ agentDir: string; wrote: boolean }>((resolve) => { + finishLatestBuild = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false }); + }), + ); + + markPreparedModelRuntimeSnapshotsStale("test overlapping config commit", { + waitForReplacement: true, + }); + const skipped = refreshPreparedModelRuntimeSnapshots(skippedConfig); + const latest = refreshPreparedModelRuntimeSnapshots(latestConfig); + const read = prepareModelRuntimeSnapshot({ + agentId: "default", + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/unused-workspace", + config: latestConfig, + }); + + await skipped; + await expect( + Promise.race([ + read.then( + () => "settled", + () => "settled", + ), + Promise.resolve("pending"), + ]), + ).resolves.toBe("pending"); + finishLatestBuild(); + await latest; + await expect(read).resolves.toMatchObject({ config: latestConfig }); + }); + + it("cancels a queued generation at an external publication boundary", async () => { + mocks.configuredAgentIds = ["default"]; + + const queued = refreshPreparedModelRuntimeSnapshots({}); + markPreparedModelRuntimeSnapshotsStale("plugin publication boundary"); + await queued; + + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/prepared-model-runtime.ts b/src/agents/prepared-model-runtime.ts new file mode 100644 index 00000000000..6eb4a2d5a8f --- /dev/null +++ b/src/agents/prepared-model-runtime.ts @@ -0,0 +1,710 @@ +/** Lifecycle-owned auth/model discovery snapshots for agent runs. */ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { registerRuntimeAuthProfileStoreMutationListener } from "./auth-profiles/runtime-snapshots.js"; +import { + PreparedModelRuntimeOwnerNotPublishedError, + PreparedModelRuntimePublicationSupersededError, + createPreparedModelRuntimeReplacement, + effectiveEnvironmentFingerprint, + hasSameLifecycleInput, + listConfiguredOwnerInputs, + normalizeOptionalDir, + normalizePreparedModelRuntimeInput, + ownerKey, + preparedModelRuntimeConfigsMatch, + publishModelRuntimeSnapshot, + rebindInputToCommittedConfiguredOwner, + resolvePublishedOwner, + startSerializedSnapshotBuild, + toError, + type PreparedModelRuntimeOwner, + type PreparedModelRuntimeInput, + type PreparedModelRuntimeLease, + type PreparedModelRuntimeReplacement, + type PreparedModelRuntimeReplacementGateId, + type PreparedModelRuntimeSnapshot, +} from "./prepared-model-runtime.owner.js"; +export { + PreparedModelRuntimeOwnerNotPublishedError, + preparedModelRuntimeConfigsMatch, +} from "./prepared-model-runtime.owner.js"; +export type { PreparedModelRuntimeReplacementGateId } from "./prepared-model-runtime.owner.js"; +export type { + PreparedModelRuntimeInput, + PreparedModelRuntimeLease, + PreparedModelRuntimeSnapshot, + PreparedModelRuntimeStores, +} from "./prepared-model-runtime.owner.js"; + +const log = createSubsystemLogger("agents/prepared-model-runtime"); +const DEFAULT_MODEL_RUNTIME_BUILD_TIMEOUT_MS = 30_000; +let modelRuntimeBuildTimeoutMs = DEFAULT_MODEL_RUNTIME_BUILD_TIMEOUT_MS; + +const owners = new Map(); +const agentBuildCompletions = new Map>(); +const standaloneActivationTails = new Map>(); +let gatewayLifecycleActive = false; +let refreshTail: Promise = Promise.resolve(); +let refreshRequestEpoch = 0; +let pendingModelRuntimeReplacement: PreparedModelRuntimeReplacement | undefined; +type AuthMutationEvent = { agentDir?: string; affectsInheritedStores: boolean }; +const pendingAuthMutations: AuthMutationEvent[] = []; + +/** Resolves a published owner or activates a standalone lifecycle owner. */ +export async function loadPreparedModelRuntimeSnapshot( + rawInput: PreparedModelRuntimeInput, +): Promise { + let input = normalizePreparedModelRuntimeInput({ + ...rawInput, + preserveWorkspaceDirOnRefresh: + rawInput.preserveWorkspaceDirOnRefresh ?? rawInput.workspaceDir !== undefined, + }); + for (;;) { + const replacement = pendingModelRuntimeReplacement; + if (replacement) { + await replacement.promise; + if (pendingModelRuntimeReplacement) { + continue; + } + input = rebindInputToCommittedConfiguredOwner(owners, input); + continue; + } + try { + return await prepareModelRuntimeSnapshot(input); + } catch (error) { + if (!(error instanceof PreparedModelRuntimeOwnerNotPublishedError)) { + throw error; + } + } + const activationGate = pendingModelRuntimeReplacement; + if (activationGate) { + await activationGate.promise; + if (pendingModelRuntimeReplacement) { + continue; + } + input = rebindInputToCommittedConfiguredOwner(owners, input); + continue; + } + const activated = await activateStandalonePreparedModelRuntime(input); + const replacementAfterActivation = pendingModelRuntimeReplacement; + if (replacementAfterActivation) { + await replacementAfterActivation.promise; + if (pendingModelRuntimeReplacement) { + continue; + } + input = rebindInputToCommittedConfiguredOwner(owners, input); + continue; + } + if (!activated) { + return await prepareModelRuntimeSnapshot(input); + } + try { + return await prepareModelRuntimeSnapshot(input); + } catch (error) { + if (!(error instanceof PreparedModelRuntimeOwnerNotPublishedError)) { + throw error; + } + // A concurrent publication boundary may retire the standalone owner between build and read. + // Retry only after proving that no replacement gate owns the next generation. + } + } +} + +/** Returns an already-published generation without starting discovery. */ +export function getPreparedModelRuntimeSnapshot( + rawInput: PreparedModelRuntimeInput, +): PreparedModelRuntimeSnapshot | undefined { + if (pendingModelRuntimeReplacement) { + return undefined; + } + const input = normalizePreparedModelRuntimeInput(rawInput); + const owner = resolvePublishedOwner(owners, input, { + allowConfiguredWorkspaceFallback: + rawInput.workspaceDir === undefined || rawInput.agentId === undefined, + }); + if (!owner?.snapshot || owner.needsRefresh || owner.pending) { + return undefined; + } + if (input.readOnly && !preparedModelRuntimeConfigsMatch(owner.input.config, input.config)) { + return undefined; + } + return owner.snapshot; +} + +/** Publishes one owner from an explicit startup/activation lifecycle boundary. */ +export async function publishPreparedModelRuntimeSnapshot( + rawInput: PreparedModelRuntimeInput, + options: { + force?: boolean; + provenance?: PreparedModelRuntimeOwner["provenance"]; + } = {}, +): Promise { + const input = normalizePreparedModelRuntimeInput(rawInput); + const existing = owners.get(ownerKey(input)); + if (existing?.pending) { + if (!options.force && hasSameLifecycleInput(existing.input, input)) { + return await existing.pending; + } + return await publishModelRuntimeSnapshot( + input, + owners, + agentBuildCompletions, + modelRuntimeBuildTimeoutMs, + existing, + options.provenance, + ); + } + if (existing?.buildCompletion) { + throw ( + existing.refreshError ?? + new Error(`prepared model runtime build is still settling for ${input.agentDir}`) + ); + } + if ( + existing?.snapshot && + !existing.needsRefresh && + !options.force && + hasSameLifecycleInput(existing.input, input) + ) { + return existing.snapshot; + } + return await publishModelRuntimeSnapshot( + input, + owners, + agentBuildCompletions, + modelRuntimeBuildTimeoutMs, + existing, + options.provenance, + ); +} + +/** Activates lifecycle publication for direct embedded runtimes without a gateway startup. */ +export async function activateStandalonePreparedModelRuntime( + rawInput: PreparedModelRuntimeInput, +): Promise { + const input = normalizePreparedModelRuntimeInput(rawInput); + const key = ownerKey(input); + const previous = standaloneActivationTails.get(key) ?? Promise.resolve(); + // One writer per owner key prevents conflicting config activations from alternately + // superseding each other's generation while preserving each caller's requested snapshot. + const activation = previous.then( + async () => await activateStandalonePreparedModelRuntimeNow(input), + ); + const tail = activation.then( + () => undefined, + () => undefined, + ); + standaloneActivationTails.set(key, tail); + try { + return await activation; + } finally { + if (standaloneActivationTails.get(key) === tail) { + standaloneActivationTails.delete(key); + } + } +} + +async function activateStandalonePreparedModelRuntimeNow( + input: PreparedModelRuntimeInput, +): Promise { + for (;;) { + const overlapsConfiguredOwner = [...owners.values()].some( + (owner) => + owner.provenance === "configured" && + owner.input.agentDir === input.agentDir && + (input.agentId === undefined || owner.input.agentId === input.agentId) && + (input.workspaceDir === undefined || owner.input.workspaceDir === input.workspaceDir), + ); + if (gatewayLifecycleActive && (!input.readOnly || overlapsConfiguredOwner)) { + // Gateway startup/reload owns configured identities. Isolated read-only drafts may publish + // separately, but stale drafts must never replace an overlapping configured generation. + return undefined; + } + try { + return await publishPreparedModelRuntimeSnapshot( + { + ...input, + preserveWorkspaceDirOnRefresh: input.workspaceDir !== undefined, + }, + { provenance: "standalone" }, + ); + } catch (error) { + if (!(error instanceof PreparedModelRuntimePublicationSupersededError)) { + throw error; + } + const replacement = pendingModelRuntimeReplacement; + if (replacement) { + await replacement.promise; + } + } + } +} + +async function acquirePreparedModelRuntimeLease( + rawInput: PreparedModelRuntimeInput, + provenance: "run" | "ephemeral", +): Promise { + let input = normalizePreparedModelRuntimeInput({ + ...rawInput, + preserveWorkspaceDirOnRefresh: + rawInput.preserveWorkspaceDirOnRefresh ?? rawInput.workspaceDir !== undefined, + }); + let key = ownerKey(input); + let owner: PreparedModelRuntimeOwner; + let snapshot: PreparedModelRuntimeSnapshot; + for (;;) { + // Replacement owns publication from synchronous staling through atomic generation commit. + // Dynamic work arriving inside that window must retry after the new owners become visible. + const replacement = pendingModelRuntimeReplacement; + if (replacement) { + await replacement.promise; + if (pendingModelRuntimeReplacement) { + continue; + } + input = rebindInputToCommittedConfiguredOwner(owners, input); + key = ownerKey(input); + continue; + } + let existing = owners.get(key); + let staleDynamicOwner = + existing?.needsRefresh && + !existing.pending && + (existing.provenance === "run" || existing.provenance === "ephemeral"); + if (gatewayLifecycleActive && provenance === "run" && (!existing || staleDynamicOwner)) { + // Dynamic workspaces still inherit the committed agent/config generation. Only their + // explicitly pinned workspace may differ from the configured owner. A stale leased owner + // can share this key, so rebase its input before publishing a replacement generation. + input = rebindInputToCommittedConfiguredOwner(owners, input); + key = ownerKey(input); + existing = owners.get(key); + staleDynamicOwner = + existing?.needsRefresh && + !existing.pending && + (existing.provenance === "run" || existing.provenance === "ephemeral"); + } + try { + if (staleDynamicOwner) { + // Existing leases retain their immutable snapshot. Publish a distinct owner so their release + // cannot delete the replacement generation admitted for new work at the same dynamic key. + snapshot = await publishModelRuntimeSnapshot( + input, + owners, + agentBuildCompletions, + modelRuntimeBuildTimeoutMs, + undefined, + provenance, + ); + } else if (existing) { + snapshot = await prepareModelRuntimeSnapshot(input); + } else { + snapshot = await publishPreparedModelRuntimeSnapshot(input, { provenance }); + } + } catch (error) { + if (error instanceof PreparedModelRuntimePublicationSupersededError) { + continue; + } + throw error; + } + const published = owners.get(key); + if ( + pendingModelRuntimeReplacement || + !published || + published.snapshot !== snapshot || + published.needsRefresh || + published.pending + ) { + continue; + } + owner = published; + break; + } + if (owner.provenance !== provenance) { + return { snapshot, release: () => {} }; + } + owner.leaseCount = (owner.leaseCount ?? 0) + 1; + let released = false; + return { + snapshot, + release: () => { + if (released) { + return; + } + released = true; + owner.leaseCount = Math.max(0, (owner.leaseCount ?? 1) - 1); + // Dynamic generations live exactly as long as their admitted run or metadata read. The + // identity check prevents an old lease from deleting a replacement at the same key. + if (owner.leaseCount === 0 && owners.get(key) === owner) { + owners.delete(key); + } + }, + }; +} + +/** Acquires the exact writable workspace generation at agent-run admission. */ +export async function acquireAgentRunPreparedModelRuntime( + rawInput: PreparedModelRuntimeInput, +): Promise { + return await acquirePreparedModelRuntimeLease(rawInput, "run"); +} + +/** Acquires a one-read metadata generation without retaining a dynamic workspace owner. */ +export async function acquireReadOnlyPreparedModelRuntime( + rawInput: PreparedModelRuntimeInput, +): Promise { + return await acquirePreparedModelRuntimeLease({ ...rawInput, readOnly: true }, "ephemeral"); +} + +/** Returns the snapshot published by the lifecycle owner. Request config cannot replace it. */ +export async function prepareModelRuntimeSnapshot( + rawInput: PreparedModelRuntimeInput, +): Promise { + const replacement = pendingModelRuntimeReplacement; + if (replacement) { + // Individual owners may finish before a multi-owner publication commits. The lifecycle gate + // makes the generation visible atomically only after every owner and auth mutation is ready. + await replacement.promise; + return await prepareModelRuntimeSnapshot(rawInput); + } + const input = normalizePreparedModelRuntimeInput(rawInput); + const existing = resolvePublishedOwner(owners, input, { + allowConfiguredWorkspaceFallback: + rawInput.workspaceDir === undefined || rawInput.agentId === undefined, + }); + if ( + input.readOnly && + existing && + !preparedModelRuntimeConfigsMatch(existing.input.config, input.config) + ) { + throw new PreparedModelRuntimeOwnerNotPublishedError( + `prepared read-only model runtime owner was not published for the requested config (${input.agentDir})`, + ); + } + // Generated catalogs are lifecycle artifacts, not a live-edit surface. Config/plugin reload, + // doctor/auth repair, and auth publication replace owners; external edits require restart. + if (existing?.pending) { + try { + await existing.pending; + } catch { + // Re-read the owner below so a superseding generation wins over this result or error. + } + return await prepareModelRuntimeSnapshot(rawInput); + } + if (existing?.needsRefresh) { + throw existing.refreshError ?? new Error("prepared model runtime refresh is pending"); + } + if (existing?.snapshot) { + return existing.snapshot; + } + throw new PreparedModelRuntimeOwnerNotPublishedError( + `prepared model runtime owner was not published for ${input.agentDir}`, + ); +} + +/** Invalidates every published generation before config/plugin runtime replacement. */ +export function markPreparedModelRuntimeSnapshotsStale( + reason = "prepared model runtime owner is stale after config publication", + options: { waitForReplacement?: boolean; preserveReplacementWait?: boolean } = {}, +): PreparedModelRuntimeReplacementGateId | undefined { + if (options.waitForReplacement) { + const superseded = pendingModelRuntimeReplacement; + pendingModelRuntimeReplacement = createPreparedModelRuntimeReplacement(); + // Superseded readers retry against the newer replacement gate. + superseded?.resolve(); + } else if (!options.preserveReplacementWait && pendingModelRuntimeReplacement) { + const cancelled = pendingModelRuntimeReplacement; + pendingModelRuntimeReplacement = undefined; + cancelled.resolve(); + } + refreshRequestEpoch += 1; + const staleError = new Error(reason); + for (const [key, owner] of owners) { + // Standalone owners have no publication controller to rebuild them. Retire them so the next + // standalone lifecycle boundary can activate a fresh generation after publication changes. + if (owner.provenance === "standalone") { + owner.generation += 1; + owners.delete(key); + continue; + } + owner.generation += 1; + owner.needsRefresh = true; + owner.refreshError = staleError; + } + return pendingModelRuntimeReplacement?.gateId; +} + +/** Rejects readers waiting for a replacement when its owning reload cannot continue. */ +export function rejectPendingPreparedModelRuntimeReplacement( + gateId: PreparedModelRuntimeReplacementGateId | undefined, + error: unknown, +): void { + const replacement = pendingModelRuntimeReplacement; + if (!replacement || !gateId || replacement.gateId !== gateId) { + return; + } + pendingModelRuntimeReplacement = undefined; + replacement.reject(toError(error)); +} + +/** Rebuilds active owners after config/plugin runtime publication. */ +async function refreshPreparedModelRuntimeSnapshotsNow( + config: OpenClawConfig, + options: { gatewayLifecycle?: boolean; defaultWorkspaceDir?: string } = {}, +): Promise { + if (options.gatewayLifecycle) { + gatewayLifecycleActive = true; + } + const staleError = new Error("prepared model runtime owner is stale after config publication"); + for (const owner of owners.values()) { + // Invalidate every prior generation before starting any replacement. A failed reload must + // never leave an old-config snapshot available beside partially published new owners. + owner.generation += 1; + owner.needsRefresh = true; + owner.refreshError = staleError; + } + const entries: Array<{ owner?: PreparedModelRuntimeOwner; input: PreparedModelRuntimeInput }> = + []; + const knownKeys = new Set(); + for (const rawInput of listConfiguredOwnerInputs(config, options.defaultWorkspaceDir)) { + let input = normalizePreparedModelRuntimeInput(rawInput); + const preservedOwner = [...owners.values()].find( + (owner) => + owner.provenance === "configured" && + owner.input.agentId === input.agentId && + owner.input.agentDir === input.agentDir && + owner.input.preserveWorkspaceDirOnRefresh && + owner.input.workspaceDir, + ); + if (preservedOwner?.input.workspaceDir) { + input = { + ...input, + workspaceDir: preservedOwner.input.workspaceDir, + preserveWorkspaceDirOnRefresh: true, + }; + } + const key = ownerKey(input); + if (knownKeys.has(key)) { + continue; + } + knownKeys.add(key); + const owner = owners.get(key); + entries.push({ owner, input }); + } + for (const [key, owner] of owners) { + if (!knownKeys.has(key) && (gatewayLifecycleActive || owner.provenance === "configured")) { + owners.delete(key); + } + } + const candidates = entries.map(({ owner: existing, input }) => { + // Dynamic and standalone owners have different lifetime contracts. A configured publication + // must replace them so an older lease release cannot remove the committed generation. + const owner: PreparedModelRuntimeOwner = + existing?.provenance === "configured" + ? existing + : { + input, + environmentFingerprint: effectiveEnvironmentFingerprint(input), + provenance: "configured", + generation: 0, + needsRefresh: true, + }; + owner.input = input; + owner.environmentFingerprint = effectiveEnvironmentFingerprint(input); + owner.provenance = "configured"; + owner.generation += 1; + owner.needsRefresh = true; + owner.refreshError = undefined; + const generation = owner.generation; + const build = startSerializedSnapshotBuild( + input, + agentBuildCompletions, + modelRuntimeBuildTimeoutMs, + ); + owner.buildCompletion = build.completion; + owners.set(ownerKey(input), owner); + void build.completion.then(() => { + if (owner.buildCompletion === build.completion) { + owner.buildCompletion = undefined; + } + }); + return { build, generation, owner }; + }); + const publication = (async () => { + try { + const snapshots = await Promise.all(candidates.map(({ build }) => build.pending)); + for (const [index, candidate] of candidates.entries()) { + if (candidate.owner.generation !== candidate.generation) { + continue; + } + candidate.owner.snapshot = snapshots[index]!; + candidate.owner.pending = undefined; + candidate.owner.needsRefresh = false; + } + return snapshots; + } catch (error) { + const refreshError = toError(error); + await Promise.allSettled(candidates.map(({ build }) => build.pending)); + for (const candidate of candidates) { + if (candidate.owner.generation !== candidate.generation) { + continue; + } + candidate.owner.pending = undefined; + candidate.owner.needsRefresh = true; + candidate.owner.refreshError = refreshError; + } + throw refreshError; + } + })(); + for (const [index, candidate] of candidates.entries()) { + const pending = publication.then((snapshots) => snapshots[index]!); + candidate.owner.pending = pending; + void pending.catch(() => undefined); + } + await publication; +} + +/** Serializes config/plugin publications so only the latest completed refresh retires owners. */ +export function refreshPreparedModelRuntimeSnapshots( + config: OpenClawConfig, + options: { gatewayLifecycle?: boolean; defaultWorkspaceDir?: string } = {}, +): Promise { + // Stale synchronously. Queued publication must never leave the prior generation request-visible. + markPreparedModelRuntimeSnapshotsStale(undefined, { waitForReplacement: true }); + const requestEpoch = refreshRequestEpoch; + const replacement = pendingModelRuntimeReplacement; + const publication = enqueuePreparedModelRuntimePublication(async () => { + if (requestEpoch !== refreshRequestEpoch) { + return; + } + await refreshPreparedModelRuntimeSnapshotsNow(config, options); + if (requestEpoch !== refreshRequestEpoch) { + return; + } + await drainPendingAuthMutations(); + }); + return publication.then( + () => { + if ( + requestEpoch === refreshRequestEpoch && + replacement && + pendingModelRuntimeReplacement === replacement + ) { + pendingModelRuntimeReplacement = undefined; + replacement.resolve(); + } + }, + (error: unknown) => { + const refreshError = toError(error); + if (requestEpoch === refreshRequestEpoch) { + // Candidate and queued auth builds may finish independently. A failed transaction must + // leave no owner from its partially published generation request-visible. + for (const owner of owners.values()) { + owner.generation += 1; + owner.pending = undefined; + owner.needsRefresh = true; + owner.refreshError = refreshError; + } + } + if ( + requestEpoch === refreshRequestEpoch && + replacement && + pendingModelRuntimeReplacement === replacement + ) { + pendingModelRuntimeReplacement = undefined; + replacement.reject(refreshError); + } + throw refreshError; + }, + ); +} + +function enqueuePreparedModelRuntimePublication(task: () => Promise): Promise { + const publication = refreshTail.then(task); + refreshTail = publication.then( + () => undefined, + () => undefined, + ); + return publication; +} + +async function drainPendingAuthMutations(): Promise { + while (pendingAuthMutations.length > 0) { + const events = pendingAuthMutations.splice(0); + for (const event of events) { + event.agentDir = normalizeOptionalDir(event.agentDir); + } + const entries: Array<{ + owner: PreparedModelRuntimeOwner; + input: PreparedModelRuntimeInput; + }> = []; + for (const owner of owners.values()) { + const affected = events.some( + (event) => + event.affectsInheritedStores || + owner.input.agentDir === event.agentDir || + owner.input.inheritedAuthDir === event.agentDir, + ); + if (affected) { + entries.push({ owner, input: owner.input }); + } + } + await Promise.all( + entries.map( + async ({ owner, input }) => + await publishPreparedModelRuntimeSnapshot(input, { + force: true, + provenance: owner.provenance, + }), + ), + ); + } +} + +function invalidateForAuthMutation(event: AuthMutationEvent): void { + const normalizedEvent = { + ...event, + agentDir: normalizeOptionalDir(event.agentDir), + }; + const staleError = new Error("prepared model runtime owner is stale after auth mutation"); + for (const owner of owners.values()) { + if ( + !normalizedEvent.affectsInheritedStores && + owner.input.agentDir !== normalizedEvent.agentDir && + owner.input.inheritedAuthDir !== normalizedEvent.agentDir + ) { + continue; + } + owner.generation += 1; + owner.needsRefresh = true; + owner.refreshError = staleError; + } + pendingAuthMutations.push(normalizedEvent); + void enqueuePreparedModelRuntimePublication(drainPendingAuthMutations).catch((error: unknown) => { + log.warn(`auth-triggered model runtime refresh failed: ${String(error)}`); + }); +} + +registerRuntimeAuthProfileStoreMutationListener(invalidateForAuthMutation); + +function resetPreparedModelRuntimeSnapshotsForTest(): void { + pendingModelRuntimeReplacement?.resolve(); + pendingModelRuntimeReplacement = undefined; + owners.clear(); + agentBuildCompletions.clear(); + standaloneActivationTails.clear(); + gatewayLifecycleActive = false; + refreshTail = Promise.resolve(); + refreshRequestEpoch = 0; + pendingAuthMutations.length = 0; + modelRuntimeBuildTimeoutMs = DEFAULT_MODEL_RUNTIME_BUILD_TIMEOUT_MS; +} + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.preparedModelRuntimeTestApi")] = + { + resetPreparedModelRuntimeSnapshotsForTest, + setModelRuntimeBuildTimeoutMsForTest: (timeoutMs: number) => { + modelRuntimeBuildTimeoutMs = timeoutMs; + }, + }; +} diff --git a/src/agents/sessions/model-registry.test.ts b/src/agents/sessions/model-registry.test.ts index 9e84831e47d..7f2396d0083 100644 --- a/src/agents/sessions/model-registry.test.ts +++ b/src/agents/sessions/model-registry.test.ts @@ -181,6 +181,119 @@ describe("ModelRegistry models.json auth", () => { await expect(registry.getApiKeyForProvider("custom")).resolves.toBe("test-token-placeholder"); }); + it("forks a catalog with request-isolated auth and provider mutations", async () => { + const modelsPath = writeModelsJson({ + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-responses", + models: [{ id: "example-model" }], + }, + }, + }); + const template = ModelRegistry.create(AuthStorage.inMemory(), modelsPath); + const firstAuth = AuthStorage.inMemory(); + const secondAuth = AuthStorage.inMemory(); + const first = template.fork(firstAuth); + const second = template.fork(secondAuth); + + firstAuth.setRuntimeApiKey("custom", "first-runtime-key"); + secondAuth.setRuntimeApiKey("custom", "second-runtime-key"); + first.registerProvider("first-only", oauthProviderConfig("First only", "first")); + + await expect(first.getApiKeyForProvider("custom")).resolves.toBe("first-runtime-key"); + await expect(second.getApiKeyForProvider("custom")).resolves.toBe("second-runtime-key"); + expect(secondAuth.getOAuthProviders().map((provider) => provider.id)).not.toContain( + "first-only", + ); + expect(template.authStorage.getOAuthProviders().map((provider) => provider.id)).not.toContain( + "first-only", + ); + + const firstModel = first.find("custom", "example-model"); + const secondModel = second.find("custom", "example-model"); + expect(firstModel).toBeDefined(); + expect(secondModel).toBeDefined(); + firstModel!.input.push("image"); + firstModel!.cost.input = 42; + expect(secondModel!.input).toEqual(["text"]); + expect(secondModel!.cost.input).toBe(0); + expect(template.find("custom", "example-model")!.input).toEqual(["text"]); + expect(template.find("custom", "example-model")!.cost.input).toBe(0); + + first.unregisterProvider("first-only"); + expect(first.find("custom", "example-model")).toBeDefined(); + }); + + it("preserves models.json provider auth in a catalog fork", async () => { + const modelsPath = writeModelsJson({ + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-responses", + apiKey: "test-token-placeholder", + models: [{ id: "example-model" }], + }, + }, + }); + const template = ModelRegistry.create(AuthStorage.inMemory(), modelsPath); + const fork = template.fork(AuthStorage.inMemory()); + const model = fork.find("custom", "example-model"); + + expect(model).toBeDefined(); + await expect(fork.getApiKeyForProvider("custom")).resolves.toBe("test-token-placeholder"); + await expect(fork.getApiKeyAndHeaders(model!)).resolves.toEqual({ + ok: true, + apiKey: "test-token-placeholder", + headers: undefined, + }); + }); + + it("does not restore a source provider after unregistering it from a fork", () => { + const template = ModelRegistry.inMemory(AuthStorage.inMemory()); + template.registerProvider("template-only", oauthProviderConfig("Template only", "template")); + const forkAuth = AuthStorage.inMemory(); + const fork = template.fork(forkAuth); + + expect(forkAuth.getOAuthProviders().map((provider) => provider.id)).toContain("template-only"); + fork.unregisterProvider("template-only"); + expect(forkAuth.getOAuthProviders().map((provider) => provider.id)).not.toContain( + "template-only", + ); + }); + + it("forks the latest base catalog after the source reloads", () => { + const modelsPath = writeModelsJson({ + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-responses", + models: [{ id: "before-reload" }], + }, + }, + }); + const source = ModelRegistry.create(AuthStorage.inMemory(), modelsPath); + writeFileSync( + modelsPath, + JSON.stringify({ + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-responses", + models: [{ id: "after-reload" }], + }, + }, + }), + "utf-8", + ); + + source.refresh(); + const fork = source.fork(AuthStorage.inMemory()); + + expect(fork.find("custom", "before-reload")).toBeUndefined(); + expect(fork.find("custom", "after-reload")).toBeDefined(); + }); + it("uses stored auth for dynamically registered provider models", () => { const authStorage = AuthStorage.inMemory({ custom: { type: "api_key", key: "test-token-placeholder" }, diff --git a/src/agents/sessions/model-registry.ts b/src/agents/sessions/model-registry.ts index e30d20a7045..6557da5412b 100644 --- a/src/agents/sessions/model-registry.ts +++ b/src/agents/sessions/model-registry.ts @@ -264,9 +264,19 @@ function emptyCustomModelsResult(error?: string): CustomModelsResult { type ModelRegistryOptions = { pluginMetadataSnapshot?: PluginModelCatalogMetadataSnapshot; + sourceSnapshot?: ModelRegistry; workspaceDir?: string; }; +type ModelRegistryCatalogSnapshot = { + models: Model[]; + providerRequestConfigs: Map; + modelRequestHeaders: Map>; + loadError: string | undefined; + pluginMetadataSnapshot: PluginModelCatalogMetadataSnapshot | undefined; + oauthProviders: OAuthProviderInterface[]; +}; + function mergeCompat( baseCompat: Model["compat"], overrideCompat: Model["compat"], @@ -318,6 +328,8 @@ export class ModelRegistry { readonly authStorage: AuthStorage; private modelsJsonPath: string | undefined; private pluginMetadataSnapshot: PluginModelCatalogMetadataSnapshot | undefined; + private baseCatalogSnapshot: ModelRegistryCatalogSnapshot | undefined; + private sourceSnapshot: ModelRegistryCatalogSnapshot | undefined; private constructor( authStorage: AuthStorage, @@ -326,6 +338,24 @@ export class ModelRegistry { ) { this.authStorage = authStorage; initializeModelRegistryRuntime(this); + if (options.sourceSnapshot) { + const source = options.sourceSnapshot; + const sourceSnapshot = source.baseCatalogSnapshot ?? source.captureCatalogSnapshot(); + this.sourceSnapshot = sourceSnapshot; + this.baseCatalogSnapshot = sourceSnapshot; + this.restoreSourceCatalog(sourceSnapshot); + this.registeredProviders = new Map( + [...source.registeredProviders].map(([provider, config]) => [provider, { ...config }]), + ); + getAuthStorageOAuthProviderRegistry(authStorage).reset(); + for (const oauthProvider of sourceSnapshot.oauthProviders) { + getAuthStorageOAuthProviderRegistry(authStorage).register(oauthProvider); + } + for (const [providerName, config] of this.registeredProviders.entries()) { + this.applyProviderConfig(providerName, config); + } + return; + } this.modelsJsonPath = modelsJsonPath; this.pluginMetadataSnapshot = resolveModelPluginMetadataSnapshot({ ...(options.pluginMetadataSnapshot @@ -336,6 +366,34 @@ export class ModelRegistry { useRuntimeConfig: true, }); this.loadModels(); + this.baseCatalogSnapshot = this.captureCatalogSnapshot(); + } + + private captureCatalogSnapshot(): ModelRegistryCatalogSnapshot { + return { + models: structuredClone(this.models), + providerRequestConfigs: new Map( + [...this.providerRequestConfigs].map(([provider, config]) => [provider, { ...config }]), + ), + modelRequestHeaders: new Map( + [...this.modelRequestHeaders].map(([key, headers]) => [key, { ...headers }]), + ), + loadError: this.loadError, + pluginMetadataSnapshot: this.pluginMetadataSnapshot, + oauthProviders: [...this.authStorage.getOAuthProviders()], + }; + } + + private restoreSourceCatalog(source: ModelRegistryCatalogSnapshot): void { + this.models = structuredClone(source.models); + this.providerRequestConfigs = new Map( + [...source.providerRequestConfigs].map(([provider, config]) => [provider, { ...config }]), + ); + this.modelRequestHeaders = new Map( + [...source.modelRequestHeaders].map(([key, headers]) => [key, { ...headers }]), + ); + this.loadError = source.loadError; + this.pluginMetadataSnapshot = source.pluginMetadataSnapshot; } static create( @@ -350,6 +408,11 @@ export class ModelRegistry { return new ModelRegistry(authStorage, undefined); } + /** Creates a request-isolated registry from this lifecycle-owned catalog snapshot. */ + fork(authStorage: AuthStorage): ModelRegistry { + return new ModelRegistry(authStorage, undefined, { sourceSnapshot: this }); + } + /** * Reload models from disk (models.json). */ @@ -362,7 +425,16 @@ export class ModelRegistry { resetModelRegistryRuntime(this); getAuthStorageOAuthProviderRegistry(this.authStorage).reset(); - this.loadModels(); + if (this.sourceSnapshot) { + this.restoreSourceCatalog(this.sourceSnapshot); + for (const oauthProvider of this.sourceSnapshot.oauthProviders) { + getAuthStorageOAuthProviderRegistry(this.authStorage).register(oauthProvider); + } + } else { + this.loadModels(); + // Forks start from the latest disk-backed base, then replay this registry's dynamic providers. + this.baseCatalogSnapshot = this.captureCatalogSnapshot(); + } for (const [providerName, config] of this.registeredProviders.entries()) { this.applyProviderConfig(providerName, config); diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 85ca9bb5c06..125dcad2489 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -21,7 +21,7 @@ import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.runtime. import { resolveAgentDir, resolveAgentEffectiveModelPrimary } from "./agent-scope.js"; import { ensureAuthProfileStore } from "./auth-profiles/store.js"; import { DEFAULT_PROVIDER } from "./defaults.js"; -import { resolveModel, resolveModelAsync } from "./embedded-agent-runner/model.js"; +import { resolveModelAsync } from "./embedded-agent-runner/model.js"; import { fingerprintAuthProfileCredential, fingerprintResolvedProviderAuth, @@ -226,6 +226,7 @@ function hasMissingApiKeyAllowance(params: { export async function prepareSimpleCompletionModel(params: { cfg: OpenClawConfig | undefined; + agentId?: string; provider: string; modelId: string; agentDir?: string; @@ -233,34 +234,29 @@ export async function prepareSimpleCompletionModel(params: { preferredProfile?: string; allowMissingApiKeyModes?: ReadonlyArray; allowBundledStaticCatalogFallback?: boolean; + /** @deprecated Model resolution is lifecycle-backed and always asynchronous. */ useAsyncModelResolution?: boolean; skipAgentDiscovery?: boolean; bindAuthOwner?: boolean; modelResolver?: typeof resolveModelAsync; }): Promise { const workspaceDir = resolveSimpleCompletionModelResolverWorkspace(params.modelResolver); - const resolved = - params.useAsyncModelResolution || params.skipAgentDiscovery - ? await (params.modelResolver ?? resolveModelAsync)( - params.provider, - params.modelId, - params.agentDir, - params.cfg, - { - ...(params.allowBundledStaticCatalogFallback !== undefined - ? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback } - : {}), - ...(params.skipAgentDiscovery ? { skipAgentDiscovery: true } : {}), - workspaceDir, - authProfileId: params.profileId, - preferredProfile: params.preferredProfile, - }, - ) - : resolveModel(params.provider, params.modelId, params.agentDir, params.cfg, { - workspaceDir, - authProfileId: params.profileId, - preferredProfile: params.preferredProfile, - }); + const resolved = await (params.modelResolver ?? resolveModelAsync)( + params.provider, + params.modelId, + params.agentDir, + params.cfg, + { + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.allowBundledStaticCatalogFallback !== undefined + ? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback } + : {}), + ...(params.skipAgentDiscovery ? { skipAgentDiscovery: true } : {}), + workspaceDir, + authProfileId: params.profileId, + preferredProfile: params.preferredProfile, + }, + ); if (!resolved.model) { return { error: resolved.error ?? `Unknown model: ${params.provider}/${params.modelId}`, @@ -472,6 +468,7 @@ export async function prepareSimpleCompletionModelForAgent(params: { preferredProfile?: string; allowMissingApiKeyModes?: ReadonlyArray; allowBundledStaticCatalogFallback?: boolean; + /** @deprecated Model resolution is lifecycle-backed and always asynchronous. */ useAsyncModelResolution?: boolean; skipAgentDiscovery?: boolean; bindAuthOwner?: boolean; @@ -491,6 +488,7 @@ export async function prepareSimpleCompletionModelForAgent(params: { } const prepared = await prepareSimpleCompletionModel({ cfg: params.cfg, + agentId: params.agentId, provider: selection.runtimeProvider ?? selection.provider, modelId: selection.modelId, agentDir: selection.agentDir, diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts index 23a18931d4a..d387ec6ace6 100644 --- a/src/agents/subagent-spawn.runtime.ts +++ b/src/agents/subagent-spawn.runtime.ts @@ -27,7 +27,7 @@ export { } from "../utils/delivery-context.shared.js"; export { resolveAgentConfig } from "./agent-scope.js"; export { AGENT_LANE_SUBAGENT } from "./lanes.js"; -export { loadModelCatalog } from "./model-catalog.js"; +export { loadPreparedModelCatalog } from "./prepared-model-catalog.js"; export { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; export { buildSubagentSystemPrompt } from "./subagent-system-prompt.js"; export { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index 222d1e5511b..c2c6e08292b 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -133,7 +133,7 @@ export async function loadSubagentSpawnModuleForTest(params: { hasInProcessGatewayContextMock?: MockFn; getRuntimeConfig?: () => Record; loadSessionStoreMock?: MockFn; - loadModelCatalogMock?: MockFn; + loadPreparedModelCatalogMock?: MockFn; ensureContextEnginesInitializedMock?: MockFn; updateSessionStoreMock?: MockFn; forkSessionEntryFromParentMock?: MockFn; @@ -266,7 +266,8 @@ export async function loadSubagentSpawnModuleForTest(params: { getRuntimeConfig: () => params.getRuntimeConfig?.() ?? createSubagentSpawnTestConfig(params.workspaceDir ?? os.tmpdir()), - loadModelCatalog: (...args: unknown[]) => params.loadModelCatalogMock?.(...args) ?? [], + loadPreparedModelCatalog: (...args: unknown[]) => + params.loadPreparedModelCatalogMock?.(...args) ?? [], loadSessionEntry: (scope: { storePath?: string; sessionKey: string }) => ((params.loadSessionStoreMock?.(scope.storePath) ?? {}) as SessionStore)[scope.sessionKey], loadSessionStore: params.loadSessionStoreMock ?? (() => ({})), diff --git a/src/agents/subagent-spawn.test-support.ts b/src/agents/subagent-spawn.test-support.ts index c06a62bce19..28802ea4b8d 100644 --- a/src/agents/subagent-spawn.test-support.ts +++ b/src/agents/subagent-spawn.test-support.ts @@ -11,7 +11,7 @@ type SpawnDeps = Omit< | "getGlobalHookRunner" | "getRuntimeConfig" | "hasInProcessGatewayContext" - | "loadModelCatalog" + | "loadPreparedModelCatalog" | "resolveContextEngine" >, "getGlobalHookRunner" diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 8cbf2eaf823..dfcb61fa0fc 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -14,7 +14,7 @@ import { installAcceptedSubagentGatewayMock } from "./test-helpers/subagent-gate const hoisted = vi.hoisted(() => ({ callGatewayMock: vi.fn(), loadSessionStoreMock: vi.fn(), - loadModelCatalogMock: vi.fn(), + loadPreparedModelCatalogMock: vi.fn(), updateSessionStoreMock: vi.fn(), registerSubagentRunMock: vi.fn(), startQueuedSubagentRunMock: vi.fn(), @@ -80,7 +80,7 @@ describe("spawnSubagentDirect seam flow", () => { hasInProcessGatewayContextMock: hoisted.hasInProcessGatewayContextMock, getRuntimeConfig: () => hoisted.configOverride, loadSessionStoreMock: hoisted.loadSessionStoreMock, - loadModelCatalogMock: hoisted.loadModelCatalogMock, + loadPreparedModelCatalogMock: hoisted.loadPreparedModelCatalogMock, updateSessionStoreMock: hoisted.updateSessionStoreMock, registerSubagentRunMock: hoisted.registerSubagentRunMock, startQueuedSubagentRunMock: hoisted.startQueuedSubagentRunMock, @@ -102,7 +102,7 @@ describe("spawnSubagentDirect seam flow", () => { resetSubagentRegistryForTests(); hoisted.callGatewayMock.mockReset(); hoisted.loadSessionStoreMock.mockReset(); - hoisted.loadModelCatalogMock.mockReset().mockResolvedValue([]); + hoisted.loadPreparedModelCatalogMock.mockReset().mockResolvedValue([]); hoisted.updateSessionStoreMock.mockReset(); hoisted.registerSubagentRunMock.mockReset(); hoisted.startQueuedSubagentRunMock.mockReset().mockReturnValue(true); @@ -714,7 +714,7 @@ describe("spawnSubagentDirect seam flow", () => { it("rejects schema collection for a model that cannot call tools", async () => { hoisted.configOverride = createConfigOverride({ tools: { swarm: true } }); - hoisted.loadModelCatalogMock.mockResolvedValue([ + hoisted.loadPreparedModelCatalogMock.mockResolvedValue([ { provider: "openai", id: "no-tools", @@ -735,6 +735,11 @@ describe("spawnSubagentDirect seam flow", () => { expect(rejected.status).toBe("error"); expect(rejected.error).toContain("requires a tool-capable target model"); + expect(hoisted.loadPreparedModelCatalogMock).toHaveBeenCalledWith({ + config: hoisted.configOverride, + agentDir: expect.any(String), + workspaceDir: "/tmp/workspace-main", + }); expect(hoisted.updateSessionStoreMock).not.toHaveBeenCalled(); expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled(); }); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 7ab1914ae6c..7fead301a92 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -98,7 +98,7 @@ import { mergeDeliveryContext, normalizeDeliveryContext, ensureContextEnginesInitialized, - loadModelCatalog, + loadPreparedModelCatalog, resolveAgentConfig, resolveContextEngine, resolveGatewaySessionStoreTarget, @@ -133,7 +133,7 @@ type SubagentSpawnDeps = { getRuntimeConfig: typeof getRuntimeConfig; hasInProcessGatewayContext: typeof hasInProcessGatewayContext; ensureContextEnginesInitialized: typeof ensureContextEnginesInitialized; - loadModelCatalog: typeof loadModelCatalog; + loadPreparedModelCatalog: typeof loadPreparedModelCatalog; resolveContextEngine: typeof resolveContextEngine; }; @@ -145,7 +145,7 @@ const defaultSubagentSpawnDeps: SubagentSpawnDeps = { getRuntimeConfig, hasInProcessGatewayContext, ensureContextEnginesInitialized, - loadModelCatalog, + loadPreparedModelCatalog, resolveContextEngine, }; @@ -317,9 +317,9 @@ async function resolveCollectorOutputModelError(params: { if (!provider || !model) { return undefined; } - let catalog: Awaited>; + let catalog: Awaited>; try { - catalog = await subagentSpawnDeps.loadModelCatalog({ + catalog = await subagentSpawnDeps.loadPreparedModelCatalog({ config: params.cfg, agentDir: params.targetAgentDir, workspaceDir: params.workspaceDir, diff --git a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts index 4eb14fd53bf..4c9251fdb47 100644 --- a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts +++ b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts @@ -230,6 +230,7 @@ export function installEmbeddedRunnerFastRunE2eMocks( })); vi.doMock("../../plugins/provider-runtime.js", () => ({ applyProviderResolvedTransportWithPlugin: vi.fn(() => undefined), + augmentModelCatalogWithProviderPlugins: vi.fn(async () => []), buildProviderMissingAuthMessageWithPlugin: vi.fn(() => undefined), buildProviderUnknownModelHintWithPlugin: vi.fn(() => undefined), normalizeProviderResolvedModelWithPlugin: vi.fn(() => undefined), diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts index 3cde0dcb1f6..759698366bb 100644 --- a/src/agents/tools-effective-inventory.ts +++ b/src/agents/tools-effective-inventory.ts @@ -152,12 +152,14 @@ function resolveConfiguredFallbackApi( function resolveDynamicRuntimeModelContext(params: { cfg: OpenClawConfig; + agentId?: string; agentDir?: string; workspaceDir?: string; provider: string; modelId: string; }): { modelApi?: string; runtimeModel?: ProviderRuntimeModel } { const runtimeModel = resolveModel(params.provider, params.modelId, params.agentDir, params.cfg, { + agentId: params.agentId, workspaceDir: params.workspaceDir, }).model as ProviderRuntimeModel | undefined; if (!runtimeModel) { @@ -235,6 +237,7 @@ export function resolveEffectiveToolInventoryRuntimeModelContext(params: { if (!bundledStaticModel) { return resolveDynamicRuntimeModelContext({ cfg: params.cfg, + agentId, agentDir: params.agentDir, workspaceDir, provider, diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 89c931746df..caaea6fc044 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -50,6 +50,7 @@ import { resolveImageFallbackCandidates, resolveImageFallbackDefaultProvider, } from "../model-fallback.js"; +import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js"; import { optionalFiniteNumberSchema, optionalPositiveIntegerSchema } from "../schema/typebox.js"; import { readFiniteNumberParam, readPositiveIntegerParam } from "./common.js"; import { @@ -678,6 +679,7 @@ type ImageSandboxConfig = { async function runImagePrompt(params: { cfg?: OpenClawConfig; + agentId?: string; agentDir: string; authStore?: AuthProfileStore; imageModelConfig: ImageModelConfig; @@ -685,6 +687,7 @@ async function runImagePrompt(params: { prompt: string; images: Array<{ buffer: Buffer; mimeType: string }>; workspaceDir?: string; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; }): Promise<{ text: string; provider: string; @@ -727,9 +730,13 @@ async function runImagePrompt(params: { maxTokens: resolveImageToolMaxTokens(undefined), timeoutMs, cfg: providerCfg, + ...(params.agentId ? { agentId: params.agentId } : {}), agentDir: params.agentDir, authStore: params.authStore, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.preparedModelRuntime + ? { preparedModelRuntime: params.preparedModelRuntime } + : {}), }); return { text: described.text, provider, model: described.model ?? modelId }; } @@ -750,9 +757,13 @@ async function runImagePrompt(params: { maxTokens: resolveImageToolMaxTokens(undefined), timeoutMs, cfg: providerCfg, + ...(params.agentId ? { agentId: params.agentId } : {}), agentDir: params.agentDir, authStore: params.authStore, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.preparedModelRuntime + ? { preparedModelRuntime: params.preparedModelRuntime } + : {}), }); return { text: described.text, provider, model: described.model ?? modelId }; } @@ -769,9 +780,13 @@ async function runImagePrompt(params: { maxTokens: resolveImageToolMaxTokens(undefined), timeoutMs, cfg: providerCfg, + ...(params.agentId ? { agentId: params.agentId } : {}), agentDir: params.agentDir, authStore: params.authStore, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.preparedModelRuntime + ? { preparedModelRuntime: params.preparedModelRuntime } + : {}), }); parts.push(`Image ${index + 1}:\n${described.text.trim()}`); } @@ -797,9 +812,11 @@ async function runImagePrompt(params: { export function createImageTool(options?: { config?: OpenClawConfig; + agentId?: string; agentDir?: string; authProfileStore?: AuthProfileStore; workspaceDir?: string; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; sandbox?: ImageSandboxConfig; fsPolicy?: ToolFsPolicy; agentChannel?: string | null; @@ -1114,6 +1131,7 @@ export function createImageTool(options?: { // Text-only runs delegate image understanding to the configured fallback model. const result = await runImagePrompt({ cfg: options?.config, + agentId: options?.agentId, agentDir, authStore: options?.authProfileStore, imageModelConfig: imageRoute.imageModelConfig, @@ -1121,6 +1139,7 @@ export function createImageTool(options?: { prompt: promptRaw, images: loadedImages.map((img) => ({ buffer: img.buffer, mimeType: img.mimeType })), workspaceDir: options?.workspaceDir, + preparedModelRuntime: options?.preparedModelRuntime, }); return buildTextToolResult(result, buildImageToolReferenceDetails(loadedImages)); diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index 31b54e10c23..ece48e3e99c 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -8,10 +8,10 @@ import type { OpenClawConfig } from "../../config/config.js"; import * as pdfExtractModule from "../../media/pdf-extract.js"; import * as webMedia from "../../media/web-media.js"; import { withEnvAsync } from "../../test-utils/env.js"; -import * as modelDiscovery from "../agent-model-discovery.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import * as modelAuth from "../model-auth.js"; import * as modelsConfig from "../models-config.js"; +import * as preparedModelRuntime from "../prepared-model-runtime.js"; import { getModelRegistryRuntime, initializeModelRegistryRuntime, @@ -23,6 +23,14 @@ import { resetPdfToolAuthEnv, withTempPdfAgentDir } from "./pdf-tool.test-suppor const completeMock = vi.hoisted(() => vi.fn()); const registerProviderStreamForModelMock = vi.hoisted(() => vi.fn()); +vi.mock("../../llm/stream.js", async () => { + const actual = await vi.importActual("../../llm/stream.js"); + return { + ...actual, + complete: completeMock, + }; +}); + vi.mock("../provider-stream.js", () => ({ registerProviderStreamForModel: registerProviderStreamForModelMock, })); @@ -38,6 +46,7 @@ async function loadCreatePdfTool() { } const ANTHROPIC_PDF_MODEL = "anthropic/claude-opus-4-6"; +const GOOGLE_PDF_MODEL = "google/gemini-2.5-pro"; const OPENAI_PDF_MODEL = "openai/gpt-5.4-mini"; const CODEX_PDF_MODEL = "openai/gpt-5.4"; const FAKE_PDF_MEDIA = { @@ -109,6 +118,13 @@ function firstCompletionContext(): { systemPrompt?: string } | undefined { return context; } +function createPdfModelRegistry(find: () => unknown) { + const modelRegistry = { find }; + initializeModelRegistryRuntime(modelRegistry); + getModelRegistryRuntime(modelRegistry).llmRuntime.complete = completeMock; + return modelRegistry; +} + async function stubPdfToolInfra( agentDir: string, params?: { @@ -127,7 +143,7 @@ async function stubPdfToolInfra( } const setRuntimeApiKey = vi.fn(); - vi.spyOn(modelDiscovery, "discoverAuthStorage").mockReturnValue({ setRuntimeApiKey } as never); + const authStorage = { setRuntimeApiKey }; const find = params?.modelFound === false ? () => null @@ -144,10 +160,19 @@ async function stubPdfToolInfra( maxTokens: 8192, input: params?.input ?? ["text", "document"], }) as never; - const modelRegistry = { find }; - initializeModelRegistryRuntime(modelRegistry); - getModelRegistryRuntime(modelRegistry).llmRuntime.complete = completeMock; - vi.spyOn(modelDiscovery, "discoverModels").mockReturnValue(modelRegistry as never); + const modelRegistry = createPdfModelRegistry(find); + vi.spyOn(preparedModelRuntime, "acquireAgentRunPreparedModelRuntime").mockImplementation( + async (input) => + ({ + snapshot: { + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + createStores: () => ({ authStorage, modelRegistry }), + }, + release: vi.fn(), + }) as never, + ); vi.spyOn(modelsConfig, "ensureOpenClawModelsJson").mockResolvedValue({ agentDir, @@ -266,7 +291,7 @@ describe("createPdfTool", () => { "anthropic:default": { type: "api_key", provider: "anthropic", - key: "test-key", + key: "fixture", }, }, } satisfies AuthProfileStore; @@ -545,12 +570,23 @@ describe("createPdfTool", () => { // reserved for text-only model paths. await withTempPdfAgentDir(async (agentDir) => { const workspaceDir = path.join(agentDir, "workspace"); - await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] }); + await stubPdfToolInfra(agentDir, { + provider: "anthropic", + input: ["text", "document"], + }); + const acquirePreparedRuntimeSpy = vi.mocked( + preparedModelRuntime.acquireAgentRunPreparedModelRuntime, + ); vi.spyOn(pdfNativeProviders, "anthropicAnalyzePdf").mockResolvedValue("native summary"); const extractSpy = vi.spyOn(pdfExtractModule, "extractPdfContent"); const cfg = withPdfModel(ANTHROPIC_PDF_MODEL); const tool = requirePdfTool( - (await loadCreatePdfTool())({ config: cfg, agentDir, workspaceDir }), + (await loadCreatePdfTool())({ + config: cfg, + agentId: "researcher", + agentDir, + workspaceDir, + }), ); const result = await tool.execute("t1", { @@ -558,21 +594,20 @@ describe("createPdfTool", () => { pdf: "/tmp/doc.pdf", }); - const ensureModelsJsonMock = vi.mocked(modelsConfig.ensureOpenClawModelsJson); - const [modelsConfigArg, modelsAgentDir, modelsOptions] = firstMockCall( - ensureModelsJsonMock, - "ensureOpenClawModelsJson", + const [preparedInput] = firstMockCall( + acquirePreparedRuntimeSpy, + "acquireAgentRunPreparedModelRuntime", ); expectFields( - (modelsConfigArg as { agents?: { defaults?: unknown } } | undefined)?.agents?.defaults, + (preparedInput as { config?: { agents?: { defaults?: unknown } } }).config?.agents + ?.defaults, { pdfModel: { primary: ANTHROPIC_PDF_MODEL }, }, ); - expect(modelsAgentDir).toBe(agentDir); - expect(modelsOptions).toEqual({ workspaceDir }); - expect(modelDiscovery.discoverModels).toHaveBeenCalledWith(expect.anything(), agentDir, { - config: modelsConfigArg, + expect(preparedInput).toMatchObject({ + agentId: "researcher", + agentDir, workspaceDir, }); expect(extractSpy).not.toHaveBeenCalled(); @@ -584,6 +619,127 @@ describe("createPdfTool", () => { }); }); + it("reuses the parent run generation for PDF execution", async () => { + await withTempPdfAgentDir(async (agentDir) => { + await stubPdfToolInfra(agentDir, { + provider: "anthropic", + input: ["text", "document"], + }); + const authStorage = { setRuntimeApiKey: vi.fn() }; + const find = () => ({ + provider: "anthropic", + api: "anthropic-messages", + maxTokens: 8192, + input: ["text", "document"], + }); + const modelRegistry = createPdfModelRegistry(find); + const acquirePreparedRuntimeSpy = vi.mocked( + preparedModelRuntime.acquireAgentRunPreparedModelRuntime, + ); + vi.spyOn(pdfNativeProviders, "anthropicAnalyzePdf").mockResolvedValue("parent summary"); + const cfg = withPdfModel(ANTHROPIC_PDF_MODEL); + const parentPreparedModelRuntime = { + agentDir, + config: cfg, + createStores: () => ({ authStorage, modelRegistry }), + } as never; + const tool = requirePdfTool( + (await loadCreatePdfTool())({ + config: cfg, + agentDir, + preparedModelRuntime: parentPreparedModelRuntime, + }), + ); + + const result = await tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf" }); + + expect(result.content).toEqual([{ type: "text", text: "parent summary" }]); + expect(acquirePreparedRuntimeSpy).not.toHaveBeenCalled(); + }); + }); + + it("uses the committed runtime generation for PDF model selection and scope", async () => { + await withTempPdfAgentDir(async (agentDir) => { + const requestedWorkspace = path.join(agentDir, "requested-workspace"); + const committedWorkspace = path.join(agentDir, "committed-workspace"); + await stubPdfToolInfra(agentDir, { + provider: "google", + api: "google-generative-ai", + input: ["text", "document"], + }); + const authStorage = { setRuntimeApiKey: vi.fn() }; + const find = () => ({ + provider: "google", + api: "google-generative-ai", + maxTokens: 8192, + input: ["text", "document"], + }); + const modelRegistry = createPdfModelRegistry(find); + const release = vi.fn(); + vi.mocked(preparedModelRuntime.acquireAgentRunPreparedModelRuntime).mockResolvedValueOnce({ + snapshot: { + agentDir: "/tmp/committed-pdf-agent", + workspaceDir: committedWorkspace, + config: withPdfModel(GOOGLE_PDF_MODEL), + createStores: () => ({ authStorage, modelRegistry }), + }, + release, + } as never); + const geminiSpy = vi + .spyOn(pdfNativeProviders, "geminiAnalyzePdf") + .mockResolvedValue("committed native summary"); + const tool = requirePdfTool( + (await loadCreatePdfTool())({ + config: withPdfModel(ANTHROPIC_PDF_MODEL), + agentDir, + workspaceDir: requestedWorkspace, + }), + ); + + const result = await tool.execute("t1", { + prompt: "summarize", + pdf: "/tmp/doc.pdf", + }); + + expect(geminiSpy).toHaveBeenCalledWith( + expect.objectContaining({ modelId: "gemini-2.5-pro" }), + ); + expectFields(result.details, { model: GOOGLE_PDF_MODEL, native: true }); + expect(release).toHaveBeenCalledOnce(); + }); + }); + + it("releases the prepared runtime when store creation fails", async () => { + await withTempPdfAgentDir(async (agentDir) => { + await stubPdfToolInfra(agentDir, { + provider: "anthropic", + input: ["text", "document"], + }); + const release = vi.fn(); + vi.mocked(preparedModelRuntime.acquireAgentRunPreparedModelRuntime).mockResolvedValueOnce({ + snapshot: { + agentDir, + config: withPdfModel(ANTHROPIC_PDF_MODEL), + createStores: () => { + throw new Error("store fork failed"); + }, + }, + release, + } as never); + const tool = requirePdfTool( + (await loadCreatePdfTool())({ + config: withPdfModel(ANTHROPIC_PDF_MODEL), + agentDir, + }), + ); + + await expect( + tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf" }), + ).rejects.toThrow("store fork failed"); + expect(release).toHaveBeenCalledOnce(); + }); + }); + it("rejects pages parameter for native PDF providers", async () => { await withTempPdfAgentDir(async (agentDir) => { await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] }); @@ -664,7 +820,7 @@ describe("createPdfTool", () => { tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf", - password: "secret", + password: "test-password", }), ).rejects.toThrow("password is not supported with native PDF providers"); }); @@ -742,19 +898,20 @@ describe("createPdfTool", () => { expect(result.content).toEqual([{ type: "text", text: "Bedrock summary" }]); expect(modelAuth.requireApiKey).not.toHaveBeenCalled(); expect(setRuntimeApiKey).not.toHaveBeenCalled(); - expect(registerProviderStreamForModelMock).toHaveBeenCalledWith({ - apiRegistry: expect.anything(), - model: expect.objectContaining({ - provider: "amazon-bedrock", - api: "bedrock-converse-stream", - }), - cfg: expect.objectContaining({ - agents: expect.objectContaining({ - defaults: expect.objectContaining({ pdfModel: { primary: bedrockModel } }), + expect(registerProviderStreamForModelMock).toHaveBeenCalledWith( + expect.objectContaining({ + model: expect.objectContaining({ + provider: "amazon-bedrock", + api: "bedrock-converse-stream", }), + cfg: expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ pdfModel: { primary: bedrockModel } }), + }), + }), + agentDir, }), - agentDir, - }); + ); expect(firstMockCall(completeMock, "complete")[2]).toMatchObject({ apiKey: "" }); }); }); @@ -800,7 +957,6 @@ describe("createPdfTool", () => { const cfg = withPdfModel(OPENAI_PDF_MODEL); const tool = requirePdfTool((await loadCreatePdfTool())({ config: cfg, agentDir })); - await tool.execute("t1", { prompt: "summarize", pdf: "/tmp/doc.pdf", diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index 87d682c265f..ba0667467e7 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -9,6 +9,8 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { Type } from "typebox"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { bindModelLlmRuntime } from "../../llm/model-runtime-binding.js"; +import { complete } from "../../llm/stream.js"; import type { Context } from "../../llm/types.js"; import { classifyMediaReferenceSource, @@ -17,8 +19,13 @@ import { import { extractPdfContent, type PdfExtractedContent } from "../../media/pdf-extract.js"; import { loadWebMediaRaw } from "../../media/web-media.js"; import { resolveUserPath } from "../../utils.js"; +import { resolveDefaultAgentDir } from "../agent-scope.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import { applySecretRefHeaderSentinels } from "../model-auth.js"; +import { + acquireAgentRunPreparedModelRuntime, + type PreparedModelRuntimeSnapshot, +} from "../prepared-model-runtime.js"; import { getModelProviderRequestTransport } from "../provider-request-config.js"; import { registerProviderStreamForModel } from "../provider-stream.js"; import { optionalFiniteNumberSchema } from "../schema/typebox.js"; @@ -48,9 +55,6 @@ import { import { resolvePdfModelConfigForTool } from "./pdf-tool.model-config.js"; import { createSandboxBridgeReadFile, - discoverAuthStorage, - discoverModels, - ensureOpenClawModelsJson, resolveSandboxedBridgeMediaPath, runWithImageModelFallback, type AnyAgentTool, @@ -142,8 +146,10 @@ type PdfSandboxConfig = { async function runPdfPrompt(params: { cfg?: OpenClawConfig; + agentId?: string; agentDir: string; workspaceDir?: string; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; pdfModelConfig: ImageModelConfig; modelOverride?: string; prompt: string; @@ -158,145 +164,167 @@ async function runPdfPrompt(params: { native: boolean; attempts: Array<{ provider: string; model: string; error: string }>; }> { - const effectiveCfg = applyImageModelConfigDefaults(params.cfg, params.pdfModelConfig); + const requestedCfg = applyImageModelConfigDefaults(params.cfg, params.pdfModelConfig); - const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; - await ensureOpenClawModelsJson(effectiveCfg, params.agentDir, modelsOptions); - const authStorage = discoverAuthStorage(params.agentDir, { - config: effectiveCfg, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - }); - const modelRegistry = discoverModels(authStorage, params.agentDir, { - config: effectiveCfg, - ...modelsOptions, - }); - - let extractionCache: PdfExtractedContent[] | null = null; - const getExtractions = async (): Promise => { - if (!extractionCache) { - extractionCache = await params.getExtractions(); - } - return extractionCache; - }; - - const result = await runWithImageModelFallback({ - cfg: effectiveCfg, - modelOverride: params.modelOverride, - run: async (provider, modelId) => { - const model = applySecretRefHeaderSentinels( - resolveModelFromRegistry({ modelRegistry, provider, modelId }), - effectiveCfg, - ); - const apiKey = await resolveModelRuntimeApiKey({ - model, - cfg: effectiveCfg, + const preparedRuntimeLease = params.preparedModelRuntime + ? { snapshot: params.preparedModelRuntime, release: () => {} } + : await acquireAgentRunPreparedModelRuntime({ agentDir: params.agentDir, - authStorage, - }); - - if (providerSupportsNativePdf(provider)) { - if (params.password) { - throw new Error( - `password is not supported with native PDF providers (${provider}/${modelId}). Remove password, or use a non-native model for encrypted PDFs.`, - ); - } - if (params.pageNumbers && params.pageNumbers.length > 0) { - throw new Error( - `pages is not supported with native PDF providers (${provider}/${modelId}). Remove pages, or use a non-native model for page filtering.`, - ); - } - - const pdfs = params.pdfBuffers.map((p) => ({ - base64: p.base64, - filename: p.filename, - })); - - if (provider === "anthropic") { - const text = await anthropicAnalyzePdf({ - apiKey, - modelId, - prompt: params.prompt, - pdfs, - maxTokens: resolvePdfToolMaxTokens(model.maxTokens), - baseUrl: model.baseUrl, - requestConfig: { - headers: model.headers, - request: getModelProviderRequestTransport(model), - }, - }); - return { text, provider, model: modelId, native: true }; - } - - if (provider === "google") { - const text = await geminiAnalyzePdf({ - apiKey, - modelId, - prompt: params.prompt, - pdfs, - baseUrl: model.baseUrl, - requestConfig: { - headers: model.headers, - request: getModelProviderRequestTransport(model), - }, - }); - return { text, provider, model: modelId, native: true }; - } - } - - // PDF-only model selections may not have loaded their provider plugin yet. - // Register before complete() so plugin-owned APIs resolve on first use. - const modelRuntime = getModelRegistryRuntime(modelRegistry); - registerProviderStreamForModel({ - model, - cfg: effectiveCfg, - agentDir: params.agentDir, - apiRegistry: modelRuntime.apiRegistry, + ...(params.agentId ? { agentId: params.agentId } : {}), + config: requestedCfg ?? {}, + inheritedAuthDir: resolveDefaultAgentDir(requestedCfg ?? {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); - const extractions = await getExtractions(); - const hasImages = extractions.some((e) => e.images.length > 0); - if (hasImages && !model.input?.includes("image")) { - const hasText = extractions.some((e) => e.text.trim().length > 0); - if (!hasText) { - throw new Error( - `Model ${provider}/${modelId} does not support images and PDF has no extractable text.`, - ); + try { + const preparedRuntime = preparedRuntimeLease.snapshot; + const runtimeAgentDir = preparedRuntime.agentDir; + const runtimeWorkspaceDir = preparedRuntime.workspaceDir ?? params.workspaceDir; + const { authStorage, modelRegistry } = preparedRuntime.createStores(); + const modelRuntime = getModelRegistryRuntime(modelRegistry); + const committedPdfModelConfig = resolvePdfModelConfigForTool({ + cfg: preparedRuntime.config, + agentDir: runtimeAgentDir, + ...(runtimeWorkspaceDir ? { workspaceDir: runtimeWorkspaceDir } : {}), + }); + if (!committedPdfModelConfig) { + throw new ToolInputError("No PDF model configured in the active runtime generation."); + } + const effectiveCfg = applyImageModelConfigDefaults( + preparedRuntime.config, + committedPdfModelConfig, + ); + let extractionCache: PdfExtractedContent[] | null = null; + const getExtractions = async (): Promise => { + if (!extractionCache) { + extractionCache = await params.getExtractions(); + } + return extractionCache; + }; + + const result = await runWithImageModelFallback({ + cfg: effectiveCfg, + modelOverride: params.modelOverride, + run: async (provider, modelId) => { + const model = bindModelLlmRuntime( + applySecretRefHeaderSentinels( + resolveModelFromRegistry({ modelRegistry, provider, modelId }), + effectiveCfg, + ), + modelRuntime.llmRuntime, + ); + const apiKey = await resolveModelRuntimeApiKey({ + model, + cfg: effectiveCfg, + agentDir: runtimeAgentDir, + authStorage, + }); + + if (providerSupportsNativePdf(provider)) { + if (params.password) { + throw new Error( + `password is not supported with native PDF providers (${provider}/${modelId}). Remove password, or use a non-native model for encrypted PDFs.`, + ); + } + if (params.pageNumbers && params.pageNumbers.length > 0) { + throw new Error( + `pages is not supported with native PDF providers (${provider}/${modelId}). Remove pages, or use a non-native model for page filtering.`, + ); + } + + const pdfs = params.pdfBuffers.map((p) => ({ + base64: p.base64, + filename: p.filename, + })); + + if (provider === "anthropic") { + const text = await anthropicAnalyzePdf({ + apiKey, + modelId, + prompt: params.prompt, + pdfs, + maxTokens: resolvePdfToolMaxTokens(model.maxTokens), + baseUrl: model.baseUrl, + requestConfig: { + headers: model.headers, + request: getModelProviderRequestTransport(model), + }, + }); + return { text, provider, model: modelId, native: true }; + } + + if (provider === "google") { + const text = await geminiAnalyzePdf({ + apiKey, + modelId, + prompt: params.prompt, + pdfs, + baseUrl: model.baseUrl, + requestConfig: { + headers: model.headers, + request: getModelProviderRequestTransport(model), + }, + }); + return { text, provider, model: modelId, native: true }; + } } - const textOnlyExtractions: PdfExtractedContent[] = extractions.map((e) => ({ - text: e.text, - images: [], - })); - const context = buildPdfExtractionContext(params.prompt, textOnlyExtractions, model); - const message = await modelRuntime.llmRuntime.complete(model, context, { + + // PDF-only model selections may not have loaded their provider plugin yet. + // Register before complete() so plugin-owned APIs resolve on first use. + registerProviderStreamForModel({ + model, + cfg: effectiveCfg, + agentDir: runtimeAgentDir, + apiRegistry: modelRuntime.apiRegistry, + ...(runtimeWorkspaceDir ? { workspaceDir: runtimeWorkspaceDir } : {}), + }); + + const extractions = await getExtractions(); + const hasImages = extractions.some((e) => e.images.length > 0); + if (hasImages && !model.input?.includes("image")) { + const hasText = extractions.some((e) => e.text.trim().length > 0); + if (!hasText) { + throw new Error( + `Model ${provider}/${modelId} does not support images and PDF has no extractable text.`, + ); + } + const textOnlyExtractions: PdfExtractedContent[] = extractions.map((e) => ({ + text: e.text, + images: [], + })); + const context = buildPdfExtractionContext(params.prompt, textOnlyExtractions, model); + const message = await complete(model, context, { + apiKey, + maxTokens: resolvePdfToolMaxTokens(model.maxTokens), + }); + const text = coercePdfAssistantText({ message, provider, model: modelId }); + return { text, provider, model: modelId, native: false }; + } + + const context = buildPdfExtractionContext(params.prompt, extractions, model); + const message = await complete(model, context, { apiKey, maxTokens: resolvePdfToolMaxTokens(model.maxTokens), }); const text = coercePdfAssistantText({ message, provider, model: modelId }); return { text, provider, model: modelId, native: false }; - } + }, + }); - const context = buildPdfExtractionContext(params.prompt, extractions, model); - const message = await modelRuntime.llmRuntime.complete(model, context, { - apiKey, - maxTokens: resolvePdfToolMaxTokens(model.maxTokens), - }); - const text = coercePdfAssistantText({ message, provider, model: modelId }); - return { text, provider, model: modelId, native: false }; - }, - }); - - return { - text: result.result.text, - provider: result.result.provider, - model: result.result.model, - native: result.result.native, - attempts: result.attempts.map((a) => ({ - provider: a.provider, - model: a.model, - error: a.error, - })), - }; + return { + text: result.result.text, + provider: result.result.provider, + model: result.result.model, + native: result.result.native, + attempts: result.attempts.map((a) => ({ + provider: a.provider, + model: a.model, + error: a.error, + })), + }; + } finally { + preparedRuntimeLease.release(); + } } // --------------------------------------------------------------------------- @@ -305,9 +333,11 @@ async function runPdfPrompt(params: { export function createPdfTool(options?: { config?: OpenClawConfig; + agentId?: string; agentDir?: string; authProfileStore?: AuthProfileStore; workspaceDir?: string; + preparedModelRuntime?: PreparedModelRuntimeSnapshot; sandbox?: PdfSandboxConfig; fsPolicy?: ToolFsPolicy; /** @@ -536,8 +566,12 @@ export function createPdfTool(options?: { const result = await runPdfPrompt({ cfg: options?.config, + agentId: options?.agentId, agentDir, ...(options?.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), + ...(options?.preparedModelRuntime + ? { preparedModelRuntime: options.preparedModelRuntime } + : {}), pdfModelConfig, modelOverride, prompt: promptRaw, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 52aa090ef5c..65ce96d8a2d 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -44,7 +44,7 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; -import { loadModelCatalog } from "../model-catalog.js"; +import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agent-scope.js"; import { buildModelAliasIndex, modelKey, @@ -53,6 +53,7 @@ import { resolveThinkingDefaultWithRuntimeCatalog, } from "../model-selection.js"; import { createModelVisibilityPolicy } from "../model-visibility-policy.js"; +import { loadPreparedModelCatalog } from "../prepared-model-catalog.js"; import { resolveSessionModelIdentityRef } from "../session-model-ref.js"; import { describeSessionStatusTool, @@ -437,6 +438,8 @@ async function resolveModelOverride(params: { raw: string; sessionEntry?: SessionEntry; agentId: string; + agentDir: string; + workspaceDir: string; }): Promise< | { kind: "reset" } | { @@ -462,7 +465,15 @@ async function resolveModelOverride(params: { cfg: params.cfg, defaultProvider: currentProvider, }); - const catalog = await loadModelCatalog({ config: params.cfg }); + const catalog = await loadPreparedModelCatalog({ + config: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + readOnly: true, + ...(params.sessionEntry?.spawnedWorkspaceDir + ? { workspaceDir: params.sessionEntry.spawnedWorkspaceDir } + : {}), + }); const manifestMetadataSnapshot = loadManifestMetadataSnapshot({ config: params.cfg, workspaceDir: params.sessionEntry?.spawnedWorkspaceDir, @@ -814,6 +825,8 @@ export function createSessionStatusTool(opts?: { } const configured = resolveDefaultModelForAgent({ cfg, agentId }); + const selectedAgentDir = resolveAgentDir(cfg, agentId); + const selectedWorkspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const modelRaw = readStringParam(params, "model"); let changedModel = false; if (typeof modelRaw === "string") { @@ -822,6 +835,8 @@ export function createSessionStatusTool(opts?: { raw: modelRaw, sessionEntry: resolved.entry, agentId, + agentDir: selectedAgentDir, + workspaceDir: selectedWorkspaceDir, }); const modelSelection = selection.kind === "reset" @@ -949,7 +964,15 @@ export function createSessionStatusTool(opts?: { callerOwnerKey: visibilityRequesterKey, }); // Tool status may read persisted/configured facts, but must not start provider discovery. - const thinkingCatalog = await loadModelCatalog({ config: cfg, readOnly: true }); + const thinkingCatalog = await loadPreparedModelCatalog({ + config: cfg, + agentId, + agentDir: selectedAgentDir, + readOnly: true, + ...(statusSessionEntry.spawnedWorkspaceDir + ? { workspaceDir: statusSessionEntry.spawnedWorkspaceDir } + : {}), + }); const { buildStatusText } = await loadCommandsStatusRuntime(); const statusText = await buildStatusText({ cfg, @@ -977,7 +1000,13 @@ export function createSessionStatusTool(opts?: { cfg, provider: providerForCard, model: defaultModelForCard, - loadModelCatalog: () => loadModelCatalog({ config: cfg }), + loadRuntimeCatalog: () => + loadPreparedModelCatalog({ + config: cfg, + agentId, + agentDir: selectedAgentDir, + readOnly: true, + }), }), isGroup, defaultGroupActivation: () => "mention", diff --git a/src/agents/tools/swarm-tools.integration.test.ts b/src/agents/tools/swarm-tools.integration.test.ts index f6bb074f763..6c39d1c6c01 100644 --- a/src/agents/tools/swarm-tools.integration.test.ts +++ b/src/agents/tools/swarm-tools.integration.test.ts @@ -98,7 +98,7 @@ describe("swarm tools integration", () => { getRuntimeConfig: () => config, hasInProcessGatewayContext: () => false, ensureContextEnginesInitialized: vi.fn(), - loadModelCatalog: vi.fn(async () => []), + loadPreparedModelCatalog: vi.fn(async () => []), resolveContextEngine: vi.fn(async () => ({ info: { id: "test", name: "Test", version: "0.0.1" }, ingest: vi.fn(async () => ({ ingested: false })), diff --git a/src/agents/tools/tool-runtime.helpers.ts b/src/agents/tools/tool-runtime.helpers.ts index 531302d62ea..af349cf4286 100644 --- a/src/agents/tools/tool-runtime.helpers.ts +++ b/src/agents/tools/tool-runtime.helpers.ts @@ -6,8 +6,6 @@ */ export { getApiKeyForModel, requireApiKey } from "../model-auth.js"; export { runWithImageModelFallback } from "../model-fallback.js"; -export { ensureOpenClawModelsJson } from "../models-config.js"; -export { discoverAuthStorage, discoverModels } from "../agent-model-discovery.js"; export { createSandboxBridgeReadFile, resolveSandboxedBridgeMediaPath, diff --git a/src/agents/workspace-run.test.ts b/src/agents/workspace-run.test.ts index c070b0dacc4..b0d3525202a 100644 --- a/src/agents/workspace-run.test.ts +++ b/src/agents/workspace-run.test.ts @@ -14,10 +14,27 @@ describe("resolveRunWorkspaceDir", () => { }); expect(result.usedFallback).toBe(false); + expect(result.isCanonicalWorkspace).toBe(false); expect(result.agentId).toBe("main"); expect(result.workspaceDir).toBe(path.resolve(explicit)); }); + it("recognizes an explicitly supplied configured workspace as canonical", () => { + const workspaceDir = path.join(process.cwd(), "tmp", "workspace-run-canonical"); + const cfg = { + agents: { defaults: { workspace: workspaceDir } }, + } satisfies OpenClawConfig; + + const result = resolveRunWorkspaceDir({ + workspaceDir, + sessionKey: "agent:main:subagent:test", + config: cfg, + }); + + expect(result.usedFallback).toBe(false); + expect(result.isCanonicalWorkspace).toBe(true); + }); + it("falls back to configured per-agent workspace when input is missing", () => { const defaultWorkspace = path.join(process.cwd(), "tmp", "workspace-default-main"); const researchWorkspace = path.join(process.cwd(), "tmp", "workspace-research"); @@ -35,6 +52,7 @@ describe("resolveRunWorkspaceDir", () => { }); expect(result.usedFallback).toBe(true); + expect(result.isCanonicalWorkspace).toBe(true); expect(result.fallbackReason).toBe("missing"); expect(result.agentId).toBe("research"); expect(result.workspaceDir).toBe(path.resolve(researchWorkspace)); diff --git a/src/agents/workspace-run.ts b/src/agents/workspace-run.ts index adca9017b6e..71ba5c3123e 100644 --- a/src/agents/workspace-run.ts +++ b/src/agents/workspace-run.ts @@ -19,8 +19,9 @@ import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type"; type AgentIdSource = "explicit" | "session_key" | "default"; -type ResolveRunWorkspaceResult = { +export type ResolveRunWorkspaceResult = { workspaceDir: string; + isCanonicalWorkspace: boolean; usedFallback: boolean; fallbackReason?: WorkspaceFallbackReason; agentId: string; @@ -99,8 +100,14 @@ export function resolveRunWorkspaceDir(params: { if (sanitized !== trimmed) { logWarn("Control/format characters stripped from workspaceDir (OC-19 hardening)."); } + const workspaceDir = resolveUserPath(sanitized, env); + const canonicalWorkspaceDir = resolveUserPath( + resolveAgentWorkspaceDir(params.config ?? {}, agentId, env), + env, + ); return { - workspaceDir: resolveUserPath(sanitized, env), + workspaceDir, + isCanonicalWorkspace: workspaceDir === canonicalWorkspaceDir, usedFallback: false, agentId, agentIdSource, @@ -117,6 +124,7 @@ export function resolveRunWorkspaceDir(params: { } return { workspaceDir: resolveUserPath(sanitizedFallback, env), + isCanonicalWorkspace: true, usedFallback: true, fallbackReason, agentId, diff --git a/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts b/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts index 5aa99dcb301..4aec74a8a9e 100644 --- a/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts +++ b/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts @@ -104,8 +104,8 @@ vi.mock("../agents/embedded-agent.runtime.js", () => ({ waitForEmbeddedAgentRunEnd: vi.fn().mockResolvedValue(true), })); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: loadModelCatalogMock, +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: loadModelCatalogMock, })); vi.mock("../agents/thinking-runtime.js", async (importOriginal) => { diff --git a/src/auto-reply/reply.test-harness.ts b/src/auto-reply/reply.test-harness.ts index c04c9669913..98633c99014 100644 --- a/src/auto-reply/reply.test-harness.ts +++ b/src/auto-reply/reply.test-harness.ts @@ -33,7 +33,8 @@ vi.mock("../agents/embedded-agent.js", () => ({ })); vi.mock("../agents/model-catalog.runtime.js", () => ({ - loadModelCatalog: (...args: unknown[]) => replyRuntimeMockState.mocks.loadModelCatalog(...args), + loadPreparedModelCatalog: (...args: unknown[]) => + replyRuntimeMockState.mocks.loadModelCatalog(...args), })); vi.mock("../agents/auth-profiles/session-override.js", () => ({ diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index d43864f49d3..bb451452390 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -113,9 +113,9 @@ function setFastModelsCliBackendDeps(): void { }); } -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: modelCatalogMocks.loadModelCatalog, - loadModelCatalogSnapshot: async (...args: unknown[]) => { +vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: modelCatalogMocks.loadModelCatalog, + loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await modelCatalogMocks.loadModelCatalog(...args); return { entries, routeVariants: entries }; }, @@ -365,12 +365,17 @@ describe("handleModelsCommand", () => { }); it("keeps explicit all browse on the full catalog path", async () => { - await handleModelsCommand(buildParams("/models openai all"), true); + const params = buildParams("/models openai all"); + params.workspaceDir = "/tmp/spawned-workspace"; + await handleModelsCommand(params, true); expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.readOnly).toBe(false); + expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]?.workspaceDir).toBe( + "/tmp/spawned-workspace", + ); }); - it("reuses the current plugin metadata snapshot for read-only catalog loading", async () => { + it("scopes the prepared catalog without passing plugin metadata", async () => { const metadataSnapshot = { plugins: [], owners: { @@ -381,12 +386,35 @@ describe("handleModelsCommand", () => { await handleModelsCommand(buildParams("/models"), true); + const params = modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]; + expect(params).toMatchObject({ readOnly: true, workspaceDir: "/tmp" }); + expect(params).not.toHaveProperty("metadataSnapshot"); + }); + + it("loads the selected agent lifecycle catalog", async () => { + const cfg = { + agents: { + defaults: { model: { primary: "anthropic/claude-opus-4-5" } }, + list: [ + { + id: "worker", + agentDir: "/tmp/models-worker-agent", + workspace: "/tmp/models-worker-workspace", + }, + ], + }, + } as OpenClawConfig; + + await buildModelsProviderData(cfg, "worker"); + expect(modelCatalogMocks.loadModelCatalog).toHaveBeenCalledWith( expect.objectContaining({ - readOnly: true, - metadataSnapshot, + agentDir: "/tmp/models-worker-agent", }), ); + expect(modelCatalogMocks.loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty( + "workspaceDir", + ); }); it("hides unauthenticated providers by default and keeps all as explicit browse", async () => { diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 168bfe8fe66..e50a8c27953 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -12,13 +12,12 @@ import { import { listCliRuntimeModelBackendBindings } from "../../agents/cli-backends.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; -import { loadModelCatalogSnapshotForBrowse } from "../../agents/model-catalog-browse.js"; +import { loadPreparedModelCatalogSnapshotForBrowse } from "../../agents/model-catalog-browse.js"; import { resolveLogicalModelCatalogEntryState, resolveLogicalVisibleModelCatalog, type ModelCatalogAuthChecker, } from "../../agents/model-catalog-visibility.js"; -import { loadModelCatalogSnapshot } from "../../agents/model-catalog.js"; import { isRetiredModelPickerProvider } from "../../agents/model-picker-visibility.js"; import { createProviderAuthChecker } from "../../agents/model-provider-auth.js"; import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js"; @@ -35,11 +34,11 @@ import { } from "../../agents/model-visibility-policy.js"; import { openAIModelCatalogRoutePolicy } from "../../agents/openai-model-routes.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; +import { loadPreparedModelCatalogSnapshot } from "../../agents/prepared-model-catalog.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; import { resolveAgentRuntimeLabel } from "../../status/agent-runtime-label.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; @@ -160,25 +159,25 @@ export async function buildModelsProviderData( cfg, agentId, }); + const catalogWorkspaceDir = options.workspaceDir; const workspaceDir = options.workspaceDir ?? (agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ?? resolveDefaultAgentWorkspaceDir(); - const metadataSnapshot = getCurrentPluginMetadataSnapshot({ - config: cfg, - workspaceDir, - env: process.env, - allowScopedSnapshot: true, - }); const cliRuntimeProviders = new Set( listCliRuntimeModelBackendBindings().map((binding) => normalizeProviderId(binding.runtime)), ); - const snapshot = await loadModelCatalogSnapshotForBrowse({ + const snapshot = await loadPreparedModelCatalogSnapshotForBrowse({ cfg, view: options.view ?? "default", loadCatalog: ({ readOnly }) => - loadModelCatalogSnapshot({ config: cfg, readOnly, metadataSnapshot }), + loadPreparedModelCatalogSnapshot({ + config: cfg, + readOnly, + ...(agentId ? { agentId, agentDir: resolveAgentDir(cfg, agentId) } : {}), + ...(catalogWorkspaceDir ? { workspaceDir: catalogWorkspaceDir } : {}), + }), }); const catalog = snapshot.entries; const visibilityPolicy = createModelVisibilityPolicy({ diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 2d19c33bf9c..d8cc9aaadce 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -338,14 +338,14 @@ vi.mock("../../agents/agent-scope.js", () => ({ resolveSessionAgentId: vi.fn(() => "main"), })); -vi.mock("../../agents/model-catalog.js", () => { +vi.mock("../../agents/prepared-model-catalog.js", () => { const loadModelCatalog = vi.fn(async () => [ { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus" }, { provider: "localai", id: "ultra-chat", name: "Ultra Chat" }, ]); return { - loadModelCatalog, - loadModelCatalogSnapshot: async () => { + loadPreparedModelCatalog: loadModelCatalog, + loadPreparedModelCatalogSnapshot: async () => { const entries = await loadModelCatalog(); return { entries, routeVariants: entries }; }, diff --git a/src/auto-reply/reply/directive-handling.params.ts b/src/auto-reply/reply/directive-handling.params.ts index c5bd06e3777..2b85354c14e 100644 --- a/src/auto-reply/reply/directive-handling.params.ts +++ b/src/auto-reply/reply/directive-handling.params.ts @@ -26,7 +26,7 @@ type HandleDirectiveOnlyCoreParams = { policyAliasIndex?: ModelAliasIndex; allowedModelKeys: Set; allowedModelCatalog: Awaited< - ReturnType + ReturnType >; thinkingCatalog?: ModelCatalogEntry[]; resetModelOverride: boolean; @@ -68,7 +68,7 @@ export type ApplyInlineDirectivesFastLaneParams = HandleDirectiveOnlyCoreParams resolveThinkingCatalog: () => Promise; allowedModelKeys: Set; allowedModelCatalog: Awaited< - ReturnType + ReturnType >; policyAliasIndex?: ModelAliasIndex; resetModelOverride: boolean; diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index 0ece1ad5c3b..a1461df8cb8 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -1,10 +1,10 @@ // Handles native slash commands before full get-reply pipeline execution. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { loadModelCatalog } from "../../agents/model-catalog.js"; import { resolveThinkingDefaultWithRuntimeCatalog, type ModelAliasIndex, } from "../../agents/model-selection.js"; +import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { SkillCommandSpec } from "../../skills/types.js"; @@ -97,14 +97,24 @@ function shouldRunInternalTextSlashCommandFastPath( async function resolveNativeSlashDefaultThinkingLevel(params: { cfg: OpenClawConfig; + agentId: string; provider: string; model: string; + agentDir: string; + workspaceDir: string; }): Promise { return resolveThinkingDefaultWithRuntimeCatalog({ cfg: params.cfg, provider: params.provider, model: params.model, - loadModelCatalog: () => loadModelCatalog({ config: params.cfg }), + loadRuntimeCatalog: () => + loadPreparedModelCatalog({ + config: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + readOnly: true, + }), }); } @@ -182,14 +192,23 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { const resolveDefaultThinkingLevel = async () => { resolvedDefaultThinkingLevel ??= await resolveNativeSlashDefaultThinkingLevel({ cfg: params.cfg, + agentId: params.agentId, provider: params.provider, model: params.model, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, }); return resolvedDefaultThinkingLevel; }; const resolvedThinkLevel = normalizeThinkLevel(targetSessionEntry?.thinkingLevel); // This fast path has no model-state owner; prepare side-effect-free catalog facts directly. - const thinkingCatalog = await loadModelCatalog({ config: params.cfg, readOnly: true }); + const thinkingCatalog = await loadPreparedModelCatalog({ + config: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + readOnly: true, + }); const { buildStatusReply } = await loadStatusCommandRuntime(); return { handled: true, diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 5bc34431468..3e869c76b87 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -30,7 +30,8 @@ import { import { loadGetReplyModuleForTest } from "./get-reply.test-loader.js"; import "./get-reply.test-runtime-mocks.js"; -type LoadModelCatalogFn = typeof import("../../agents/model-catalog.js").loadModelCatalog; +type LoadModelCatalogFn = + typeof import("../../agents/prepared-model-catalog.js").loadPreparedModelCatalog; type ModelAliasIndex = import("../../agents/model-selection.js").ModelAliasIndex; function emptyAliasIndex(): ModelAliasIndex { @@ -62,15 +63,9 @@ vi.mock("./commands-status.js", () => ({ buildStatusReply: (...args: unknown[]) => mocks.buildStatusReply(...args), })); -vi.mock("../../agents/model-catalog.js", async () => { - const actual = await vi.importActual( - "../../agents/model-catalog.js", - ); - return { - ...actual, - loadModelCatalog: mocks.loadModelCatalog, - }; -}); +vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: mocks.loadModelCatalog, +})); vi.mock("../../agents/workspace.js", () => ({ DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace", @@ -472,7 +467,16 @@ describe("getReplyFromConfig fast test bootstrap", () => { } expect(reply.text.includes("OpenClaw")).toBe(true); expect(reply.text.includes("Think: medium")).toBe(true); - expect(mocks.loadModelCatalog).toHaveBeenCalledWith({ config: cfg }); + expect(mocks.loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + agentId: "main", + agentDir: expect.any(String), + }), + ); + expect(mocks.loadModelCatalog.mock.calls[0]?.[0]).toMatchObject({ + workspaceDir: "/tmp/workspace", + }); expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); @@ -524,7 +528,13 @@ describe("getReplyFromConfig fast test bootstrap", () => { throw new Error("expected single reply payload"); } expect(reply.text).toContain("Think: high"); - expect(mocks.loadModelCatalog).toHaveBeenCalledExactlyOnceWith({ config: cfg, readOnly: true }); + expect(mocks.loadModelCatalog).toHaveBeenCalledExactlyOnceWith({ + config: cfg, + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + readOnly: true, + }); expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); @@ -578,7 +588,13 @@ describe("getReplyFromConfig fast test bootstrap", () => { } expect(reply.text).toContain("Think: xhigh"); expect(getReplyPayloadMetadata(reply)?.deliverDespiteSourceReplySuppression).toBe(true); - expect(mocks.loadModelCatalog).toHaveBeenCalledExactlyOnceWith({ config: cfg, readOnly: true }); + expect(mocks.loadModelCatalog).toHaveBeenCalledExactlyOnceWith({ + config: cfg, + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + readOnly: true, + }); expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled(); expect(mocks.initSessionState).not.toHaveBeenCalled(); expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 0c2b136998c..171dceed054 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -582,6 +582,8 @@ export async function getReplyFromConfig( await applyResetModelOverride({ cfg, agentId, + agentDir, + workspaceDir, resetTriggered, bodyStripped, sessionCtx, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 6da354d6ee2..3bb744bd37c 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -9,7 +9,7 @@ import { } from "../../agents/context-cache.js"; import { loadManifestModelCatalog, - loadModelCatalog as loadModelCatalogLocal, + loadPreparedModelCatalog as loadModelCatalogLocal, } from "../../agents/model-catalog.runtime.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -44,8 +44,8 @@ const catalogRuntimeMocks = vi.hoisted(() => { vi.mock("../../agents/model-catalog.runtime.js", () => ({ loadManifestModelCatalog: vi.fn(() => []), - loadModelCatalog: catalogRuntimeMocks.loadModelCatalog, - loadModelCatalogSnapshot: catalogRuntimeMocks.loadModelCatalogSnapshot, + loadPreparedModelCatalog: catalogRuntimeMocks.loadModelCatalog, + loadPreparedModelCatalogSnapshot: catalogRuntimeMocks.loadModelCatalogSnapshot, })); vi.mock("../../agents/provider-model-normalization.runtime.js", () => ({ @@ -521,6 +521,7 @@ describe("createModelSelectionState catalog loading", () => { }); expect(loadModelCatalogLocal).toHaveBeenCalledOnce(); + expect(vi.mocked(loadModelCatalogLocal).mock.calls[0]?.[0]).not.toHaveProperty("readOnly"); }); it("carries catalog context limits into cold model selection", async () => { @@ -2055,7 +2056,7 @@ describe("createModelSelectionState resolveDefaultReasoningLevel", () => { }); it("returns on when catalog model has reasoning true", async () => { - const { loadModelCatalog: loadModelCatalogForCase } = + const { loadPreparedModelCatalog: loadModelCatalogForCase } = await import("../../agents/model-catalog.runtime.js"); vi.mocked(loadModelCatalogForCase).mockResolvedValueOnce([ { provider: "openrouter", id: "x-ai/grok-4.1-fast", name: "Grok", reasoning: true }, diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index c736a6a5218..bb953cb5147 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -2,6 +2,8 @@ import { hasLegacyAutoFallbackWithoutOrigin, resolveAgentConfig, + resolveAgentDir, + resolveDefaultAgentId, } from "../../agents/agent-scope.js"; import { isStoredCredentialCompatibleWithAuthProvider } from "../../agents/auth-profiles/order.js"; import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; @@ -125,7 +127,7 @@ function normalizeRuntimeModelRef(provider: string, model: string) { return normalizeModelRef(provider, model, RUNTIME_MODEL_VISIBILITY_NORMALIZATION); } -function loadModelCatalogRuntime() { +function loadPreparedModelCatalogRuntime() { return modelCatalogRuntimeLoader.load(); } @@ -189,6 +191,12 @@ export async function createModelSelectionState(params: { defaultProvider, defaultModel, } = params; + const catalogAgentId = params.agentId ?? resolveDefaultAgentId(cfg); + const catalogScope = { + config: cfg, + agentId: catalogAgentId, + agentDir: resolveAgentDir(cfg, catalogAgentId), + }; let provider = params.provider; let model = params.model; @@ -284,8 +292,8 @@ export async function createModelSelectionState(params: { if (needsModelCatalog) { const catalogSnapshot = await ( - await loadModelCatalogRuntime() - ).loadModelCatalogSnapshot({ config: cfg }); + await loadPreparedModelCatalogRuntime() + ).loadPreparedModelCatalogSnapshot(catalogScope); modelCatalog = catalogSnapshot.entries; // Only an explicit false is degraded; absent means authoritative. catalogAuthoritative = catalogSnapshot.authoritative !== false; @@ -513,7 +521,7 @@ export async function createModelSelectionState(params: { if (manifestModelCatalog) { return manifestModelCatalog; } - const { loadManifestModelCatalog } = await loadModelCatalogRuntime(); + const { loadManifestModelCatalog } = await loadPreparedModelCatalogRuntime(); manifestModelCatalog = loadManifestModelCatalog({ config: cfg, fallbackToMetadataScan: false, @@ -554,7 +562,9 @@ export async function createModelSelectionState(params: { const shouldHydrateRuntimeCatalog = !modelCatalog && (!selectedCatalogEntry || selectedCatalogEntry.reasoning === undefined); if (shouldHydrateRuntimeCatalog) { - modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg }); + modelCatalog = await ( + await loadPreparedModelCatalogRuntime() + ).loadPreparedModelCatalog(catalogScope); logStage("catalog-loaded-for-thinking", `entries=${modelCatalog.length}`); const runtimeCatalog = buildThinkingCatalog(modelCatalog); const runtimeSelectedEntry = findSelectedCatalogEntry({ @@ -649,7 +659,9 @@ export async function createModelSelectionState(params: { (!catalogForReasoning || catalogForReasoning.length === 0) && selectedReasoningEntry?.reasoning === undefined ) { - modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg }); + modelCatalog = await ( + await loadPreparedModelCatalogRuntime() + ).loadPreparedModelCatalog(catalogScope); logStage("catalog-loaded-for-reasoning", `entries=${modelCatalog.length}`); catalogForReasoning = modelCatalog; } diff --git a/src/auto-reply/reply/session-reset-model.test.ts b/src/auto-reply/reply/session-reset-model.test.ts index 5e63026c936..5916696be1d 100644 --- a/src/auto-reply/reply/session-reset-model.test.ts +++ b/src/auto-reply/reply/session-reset-model.test.ts @@ -2,13 +2,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js"; import type { ModelAliasIndex } from "./model-selection-directive.js"; + +const loadPreparedModelCatalog = vi.hoisted(() => vi.fn(async () => modelCatalog)); + +vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog, +})); + import { applyResetModelOverride } from "./session-reset-model.js"; const modelCatalog: ModelCatalogEntry[] = [ @@ -57,6 +64,32 @@ async function applyResetFixture(params: { } describe("applyResetModelOverride", () => { + it("loads the reset catalog for the active agent owner", async () => { + const fixture = createResetFixture(); + + await applyResetModelOverride({ + cfg: fixture.cfg, + agentId: "worker", + agentDir: "/tmp/shared-agent", + workspaceDir: "/tmp/shared-workspace", + resetTriggered: true, + bodyStripped: "minimax summarize", + sessionCtx: fixture.sessionCtx, + ctx: fixture.ctx, + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex: fixture.aliasIndex, + }); + + expect(loadPreparedModelCatalog).toHaveBeenCalledWith({ + config: fixture.cfg, + agentId: "worker", + agentDir: "/tmp/shared-agent", + workspaceDir: "/tmp/shared-workspace", + readOnly: true, + }); + }); + it("selects a model hint and strips it from the body", async () => { const { sessionEntry, sessionCtx } = await applyResetFixture({ resetTriggered: true, diff --git a/src/auto-reply/reply/session-reset-model.ts b/src/auto-reply/reply/session-reset-model.ts index dbc67e5063c..2a7ac4fd53c 100644 --- a/src/auto-reply/reply/session-reset-model.ts +++ b/src/auto-reply/reply/session-reset-model.ts @@ -42,14 +42,27 @@ function splitBody(body: string) { }; } -async function loadResetModelCatalog(cfg: OpenClawConfig): Promise { - const { loadModelCatalog } = await import("../../agents/model-catalog.js"); - return loadModelCatalog({ config: cfg }); +async function loadResetModelCatalog(params: { + cfg: OpenClawConfig; + agentId?: string; + agentDir?: string; + workspaceDir?: string; +}): Promise { + const { loadPreparedModelCatalog } = await import("../../agents/prepared-model-catalog.js"); + return loadPreparedModelCatalog({ + config: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + readOnly: true, + }); } async function resolveResetFallbackModels(params: { cfg: OpenClawConfig; agentId?: string; + agentDir?: string; + workspaceDir?: string; }): Promise { if (params.agentId) { const { resolveAgentModelFallbacksOverride } = await import("../../agents/agent-scope.js"); @@ -161,6 +174,8 @@ async function applySelectionToSession(params: { export async function applyResetModelOverride(params: { cfg: OpenClawConfig; agentId?: string; + agentDir?: string; + workspaceDir?: string; resetTriggered: boolean; bodyStripped?: string; sessionCtx: TemplateContext; @@ -188,7 +203,14 @@ export async function applyResetModelOverride(params: { return {}; } - const catalog = params.modelCatalog ?? (await loadResetModelCatalog(params.cfg)); + const catalog = + params.modelCatalog ?? + (await loadResetModelCatalog({ + cfg: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + })); const allowedModelKeys = await buildResetAllowedModelKeys({ cfg: params.cfg, catalog, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 8293a42d26b..56eb3e81838 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -217,8 +217,8 @@ vi.mock("../../agents/session-write-lock.js", async () => { }; }); -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ +vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: vi.fn(async () => [ { provider: "minimax", id: "m2.7", name: "M2.7" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, ]), diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 22eb0a24db0..c9e510807bf 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -247,9 +247,9 @@ vi.mock("../agents/agent-scope.js", () => ({ resolveAgentModelFallbacksOverride: () => [], })); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: - mocks.loadModelCatalog as typeof import("../agents/model-catalog.js").loadModelCatalog, +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: + mocks.loadModelCatalog as typeof import("../agents/prepared-model-catalog.js").loadPreparedModelCatalog, })); vi.mock("../agents/simple-completion-runtime.js", () => ({ diff --git a/src/cli/capability-cli/model.ts b/src/cli/capability-cli/model.ts index c4e186dbc62..2747d770a14 100644 --- a/src/cli/capability-cli/model.ts +++ b/src/cli/capability-cli/model.ts @@ -19,8 +19,8 @@ import { import { updateAuthProfileStoreWithLock } from "../../agents/auth-profiles/store.js"; import { buildExplicitSessionIdSessionKey } from "../../agents/command/session.js"; import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; -import { loadModelCatalog } from "../../agents/model-catalog.js"; import { canonicalizeCaseOnlyCatalogModelRef } from "../../agents/model-selection.js"; +import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js"; import { completeWithPreparedSimpleCompletionModel, prepareSimpleCompletionModelForAgent, @@ -60,7 +60,7 @@ async function canonicalizeModelRunRef(params: { cfg: params.cfg, raw: params.raw, defaultProvider: DEFAULT_PROVIDER, - loadCatalog: () => loadModelCatalog({ config: params.cfg, readOnly: true }), + loadCatalog: () => loadPreparedModelCatalog({ config: params.cfg, readOnly: true }), preserveAuthProfile: params.preserveAuthProfile, }); } @@ -324,7 +324,7 @@ async function runModelRun(params: { async function buildModelProviders() { const cfg = getRuntimeConfig(); - const catalog = await loadModelCatalog({ config: cfg }); + const catalog = await loadPreparedModelCatalog({ config: cfg }); const selectedProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model), ); @@ -458,7 +458,7 @@ export function registerModelCapabilityCommands(capability: Command): void { .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { - const result = await loadModelCatalog({ config: getRuntimeConfig() }); + const result = await loadPreparedModelCatalog({ config: getRuntimeConfig() }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, providerSummaryText); }); }); @@ -471,7 +471,7 @@ export function registerModelCapabilityCommands(capability: Command): void { .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { const target = normalizeStringifiedOptionalString(opts.model) ?? ""; - const catalog = await loadModelCatalog({ config: getRuntimeConfig() }); + const catalog = await loadPreparedModelCatalog({ config: getRuntimeConfig() }); const entry = catalog.find((candidate) => `${candidate.provider}/${candidate.id}` === target) ?? catalog.find((candidate) => candidate.id === target); diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index 21b43621338..68549ed8ce2 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -60,7 +60,10 @@ vi.mock("../agents/embedded-agent.js", () => ({ vi.mock("../agents/model-catalog.js", () => ({ loadManifestModelCatalog: vi.fn(() => []), - loadModelCatalog: vi.fn(), +})); + +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: vi.fn(), })); vi.mock("../agents/model-selection.js", () => { diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index ecbb9bfd872..729d02f376c 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -12,8 +12,9 @@ import * as authProfileStoreModule from "../agents/auth-profiles/store.js"; import * as attemptExecutionRuntime from "../agents/command/attempt-execution.runtime.js"; import { deliverAgentCommandResult } from "../agents/command/delivery.runtime.js"; import { runEmbeddedAgent } from "../agents/embedded-agent.js"; -import { loadManifestModelCatalog, loadModelCatalog } from "../agents/model-catalog.js"; +import { loadManifestModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; +import { loadPreparedModelCatalog } from "../agents/prepared-model-catalog.js"; import { isAgentRunRestartAbortReason } from "../agents/run-termination.js"; import { ensureAgentWorkspace } from "../agents/workspace.js"; import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js"; @@ -357,7 +358,7 @@ async function runAgentWithSessionKey(sessionKey: string): Promise { function mockModelCatalogOnce(entries: ReturnType): void { vi.mocked(loadManifestModelCatalog).mockReturnValueOnce(entries); - vi.mocked(loadModelCatalog).mockResolvedValueOnce(entries); + vi.mocked(loadPreparedModelCatalog).mockResolvedValueOnce(entries); } function installThinkingTestProviders(channels: Parameters[0] = []) { @@ -390,7 +391,7 @@ beforeEach(() => { runtimeSnapshotModule.clearRuntimeConfigSnapshot(); vi.mocked(runEmbeddedAgent).mockResolvedValue(createDefaultAgentResult()); vi.mocked(loadManifestModelCatalog).mockReturnValue([]); - vi.mocked(loadModelCatalog).mockResolvedValue([]); + vi.mocked(loadPreparedModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); configIoMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ snapshot: { valid: false, resolved: {} as OpenClawConfig }, @@ -990,7 +991,7 @@ describe("agentCommand", () => { runtime, ); - expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(loadPreparedModelCatalog).not.toHaveBeenCalled(); expectLastRunProviderModel("openrouter", "openrouter/auto"); const thinkingDefaultCall = vi.mocked(modelSelectionModule.resolveThinkingDefault).mock .calls[0]?.[0]; diff --git a/src/commands/auth-choice.model-check.test.ts b/src/commands/auth-choice.model-check.test.ts index d0baae8cc7a..d8298342979 100644 --- a/src/commands/auth-choice.model-check.test.ts +++ b/src/commands/auth-choice.model-check.test.ts @@ -12,10 +12,12 @@ const loadModelCatalog = vi.hoisted(() => vi.fn()); const modelCatalogMocks = vi.hoisted(() => ({ routeVariants: undefined as unknown[] | undefined, })); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalogSnapshot: async (...args: unknown[]) => { +vi.mock("../agents/prepared-model-runtime.js", () => ({ + publishPreparedModelRuntimeSnapshot: async (...args: unknown[]) => { const entries = await loadModelCatalog(...args); - return { entries, routeVariants: modelCatalogMocks.routeVariants ?? entries }; + return { + modelCatalog: { entries, routeVariants: modelCatalogMocks.routeVariants ?? entries }, + }; }, })); @@ -182,10 +184,41 @@ describe("warnIfModelConfigLooksOff", () => { await warnIfModelConfigLooksOff(config, prompter); - expect(loadModelCatalog).toHaveBeenCalledWith({ - config, - useCache: false, + expect(loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ config, inheritedAuthDir: expect.any(String) }), + { force: true, provenance: "explicit" }, + ); + }); + + it("publishes validation catalogs for the selected agent", async () => { + const prompter = makePrompter({ note: vi.fn(async () => {}) }); + const config = { + agents: { + defaults: { model: "openai/gpt-5.5" }, + list: [ + { + id: "worker", + workspace: "/tmp/openclaw-worker-workspace", + model: "openai/gpt-5.5", + }, + ], + }, + } as OpenClawConfig; + + await warnIfModelConfigLooksOff(config, prompter, { + agentId: "worker", + agentDir: "/tmp/openclaw-worker-agent", }); + + expect(loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + config, + agentId: "worker", + agentDir: "/tmp/openclaw-worker-agent", + workspaceDir: "/tmp/openclaw-worker-workspace", + }), + { force: true, provenance: "explicit" }, + ); }); it("accepts subscription auth but not key sources for gpt-5.3-codex-spark", async () => { diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index aef5b64a683..2eb172ab3ad 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -1,9 +1,16 @@ // Post-selection model/auth sanity checks shown during onboarding and agent setup. import { normalizeProviderIdForAuth } from "@openclaw/model-catalog-core/provider-id"; +import { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { createModelAuthAvailabilityResolver } from "../agents/model-auth-availability.js"; -import { loadModelCatalogSnapshot, type ModelCatalogEntry } from "../agents/model-catalog.js"; +import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; +import { publishPreparedModelRuntimeSnapshot } from "../agents/prepared-model-runtime.js"; import { buildProviderAuthRecoveryHint } from "../agents/provider-auth-recovery-hint.js"; import { canonicalizeProviderModelId } from "../agents/provider-model-route.js"; import type { ModelApi } from "../config/types.models.js"; @@ -157,10 +164,26 @@ export async function warnIfModelConfigLooksOff( agentId: options?.agentId, }); const warnings: string[] = []; + const validationAgentId = options?.agentId ?? resolveDefaultAgentId(config); const snapshot = options?.validateCatalog === false ? { entries: [], routeVariants: [] } - : await loadModelCatalogSnapshot({ config, useCache: false }); + : ( + await publishPreparedModelRuntimeSnapshot( + { + config, + agentId: validationAgentId, + agentDir: + options?.agentDir ?? + (options?.agentId + ? resolveAgentDir(config, options.agentId) + : resolveDefaultAgentDir(config)), + inheritedAuthDir: resolveDefaultAgentDir(config), + workspaceDir: resolveAgentWorkspaceDir(config, validationAgentId), + }, + { force: true, provenance: "explicit" }, + ) + ).modelCatalog; const catalog = snapshot.entries; const catalogFacts = resolveDefaultModelCatalogFacts(config, catalog, { ...(options?.agentId ? { agentId: options.agentId } : {}), diff --git a/src/commands/doctor/shared/active-tool-schema-warnings.ts b/src/commands/doctor/shared/active-tool-schema-warnings.ts index 5ed94565514..2c645dec428 100644 --- a/src/commands/doctor/shared/active-tool-schema-warnings.ts +++ b/src/commands/doctor/shared/active-tool-schema-warnings.ts @@ -24,6 +24,7 @@ import { resolveDoctorPrimaryModelRef } from "./primary-model-ref.js"; function resolveRuntimeModelContext(params: { cfg: OpenClawConfig; + agentId: string; agentDir: string; workspaceDir: string; provider: string; @@ -35,6 +36,7 @@ function resolveRuntimeModelContext(params: { modelContextWindowTokens?: number; } { const model = resolveModel(params.provider, params.modelId, params.agentDir, params.cfg, { + agentId: params.agentId, workspaceDir: params.workspaceDir, }).model as ProviderRuntimeModel | undefined; if (!model) { @@ -97,6 +99,7 @@ export function collectActiveToolSchemaProjectionWarnings(params: { try { runtimeModelContext = resolveRuntimeModelContext({ cfg: params.cfg, + agentId, agentDir, workspaceDir, provider: modelRef.provider, diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 5a979349ae4..3c7dc7b6f5f 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -19,8 +19,8 @@ const loadModelCatalog = vi.hoisted(() => vi.fn()); const modelCatalogRouteVariants = vi.hoisted(() => ({ value: undefined as readonly ModelCatalogEntry[] | undefined, })); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalogSnapshot: async (...args: unknown[]) => { +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await loadModelCatalog(...args); return { entries, routeVariants: modelCatalogRouteVariants.value ?? entries }; }, diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index f3957df6a71..87fd04ee545 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -21,7 +21,7 @@ const resolveEnvApiKey = vi.fn().mockReturnValue(undefined); const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined); const hasUsableCustomProviderApiKey = vi.fn().mockReturnValue(false); const hasSyntheticLocalProviderAuthConfig = vi.fn().mockReturnValue(false); -const loadModelCatalog = vi.fn(async () => []); +const loadModelCatalog = vi.fn<(_params?: unknown) => Promise>(async () => []); const loadProviderCatalogModelsForList = vi.fn<() => Promise>>>( async () => [], ); @@ -97,9 +97,18 @@ vi.mock("../agents/model-auth.js", async (importOriginal) => { }; }); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog, - loadModelCatalogSnapshot: async (...args: Parameters) => { +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: loadModelCatalog, + loadPreparedModelCatalogOwnerSnapshot: async (params: { agentDir?: string; config?: object }) => { + const entries = await loadModelCatalog(params); + return { + agentDir: params.agentDir ?? "/tmp/openclaw-agent", + config: params.config ?? {}, + metadataSnapshot: { manifestRegistry: { plugins: [] } }, + modelCatalog: { entries, routeVariants: entries, staticEntries: entries }, + }; + }, + loadPreparedModelCatalogSnapshot: async (...args: Parameters) => { const entries = await loadModelCatalog(...args); return { entries, routeVariants: entries }; }, @@ -118,7 +127,17 @@ vi.mock("../agents/embedded-agent-runner/model.js", () => ({ })); vi.mock("../agents/agent-model-discovery.js", () => { + class MockAuthStorage { + getAll() { + return {}; + } + } + class MockModelRegistry { + fork() { + return new MockModelRegistry(); + } + find(provider: string, id: string) { if (modelRegistryState.findError !== undefined) { throw toLintErrorObject(modelRegistryState.findError, "Non-Error thrown"); @@ -151,8 +170,9 @@ vi.mock("../agents/agent-model-discovery.js", () => { } return { - discoverAuthStorage: () => ({}) as unknown, + discoverAuthStorage: () => new MockAuthStorage() as unknown, discoverModels: () => new MockModelRegistry() as unknown, + normalizeDiscoveredAgentModel: (model: unknown) => model, }; }); @@ -186,7 +206,8 @@ vi.mock("./models/list.provider-index-catalog.js", () => ({ loadProviderIndexCatalogRowsForList, })); -vi.mock("../agents/model-suppression.js", () => ({ +vi.mock("../agents/model-suppression.js", async (importOriginal) => ({ + ...(await importOriginal()), shouldSuppressBuiltInModel, shouldSuppressBuiltInModelFromManifest, })); @@ -431,7 +452,8 @@ describe("models list/status", () => { beforeAll(async () => { ({ modelsListCommand } = await import("./models/list.list-command.js")); - ({ loadModelRegistry } = await import("./models/list.registry.js")); + const registryModule = await import("./models/list.registry.js"); + loadModelRegistry = registryModule.loadModelRegistry; ({ toModelRow } = await import("./models/list.model-row.js")); }); @@ -618,7 +640,7 @@ describe("models list/status", () => { ); const payload = parseJsonLog(runtime); - expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(loadModelCatalog).toHaveBeenCalledOnce(); expect(payload.models).toHaveLength(1); const model = payload.models[0]; expect(model.key).toBe("moonshot/kimi-k2.6"); diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 6ddfdfa81a0..231b15256ab 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -301,10 +301,10 @@ function installModelsListCommandForwardCompatMocks() { resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), })); - vi.doMock("../../agents/model-catalog.js", async (importOriginal) => ({ - ...(await importOriginal()), - loadModelCatalog: mocks.loadModelCatalog, - loadModelCatalogSnapshot: async (...args: unknown[]) => { + vi.doMock("../../agents/prepared-model-catalog.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadPreparedModelCatalog: mocks.loadModelCatalog, + loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await mocks.loadModelCatalog(...args); return { entries, routeVariants: entries }; }, @@ -805,6 +805,13 @@ describe("modelsListCommand forward-compat", () => { await modelsListCommand({ json: true, local: true }, runtime as never); + expect(mocks.loadModelRegistry).toHaveBeenCalledWith( + mocks.resolvedConfig, + expect.objectContaining({ + agentId: "main", + agentDir: "/tmp/openclaw-agent", + }), + ); expect(mocks.printModelTable).toHaveBeenCalled(); expectRowKeys(lastPrintedRows<{ key: string }>(), ["openai/gpt-5.4"]); }); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 5e31cc0f318..b5047de0682 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -91,10 +91,10 @@ export async function modelsListCommand( commandName: "models list", runtime, }); + const agentId = resolveDefaultAgentId(cfg); const agentDir = resolveDefaultAgentDir(cfg); const authStore = loadAuthProfileStoreWithoutExternalProfiles(agentDir); - const workspaceDir = - resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ?? resolveDefaultAgentWorkspaceDir(); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); const metadataSnapshot = loadManifestMetadataSnapshot({ config: cfg, workspaceDir, @@ -134,6 +134,8 @@ export async function modelsListCommand( enableCascade: enableSourcePlanCascade, providerFilter, cfg, + agentId, + agentDir, metadataSnapshot, }) : undefined; @@ -144,6 +146,8 @@ export async function modelsListCommand( }) => { const { loadListModelRegistry } = await loadRegistryLoadModule(); const loaded = await loadListModelRegistry(cfg, { + agentId, + agentDir, providerFilter, normalizeModels: optsLocal?.normalizeModels ?? Boolean(providerFilter), loadAvailability: optsLocal?.loadAvailability, @@ -160,7 +164,9 @@ export async function modelsListCommand( await loadRegistryState(); } else if (!opts.all && opts.local) { const { loadConfiguredListModelRegistry } = await loadRegistryLoadModule(); - const loaded = loadConfiguredListModelRegistry(cfg, entries, { + const loaded = await loadConfiguredListModelRegistry(cfg, entries, { + agentId, + agentDir, providerFilter, workspaceDir, }); @@ -175,6 +181,7 @@ export async function modelsListCommand( } const buildRowContext = (skipRuntimeModelSuppression: boolean) => ({ cfg, + agentId, agentDir, authIndex, availableKeys, diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index 2893a73d31a..9c55a93faea 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -19,8 +19,8 @@ const resolveAuthProfileEligibilityMock = vi.fn< })); const resolveSecretRefStringMock = vi.fn(async () => "resolved-secret"); -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: loadModelCatalogMock, +vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: loadModelCatalogMock, })); vi.mock("../../agents/model-auth.js", () => ({ hasUsableCustomProviderApiKey: (cfg: OpenClawConfig, provider: string) => { diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts index 3e89eb8d3c4..505e995dd1c 100644 --- a/src/commands/models/list.probe.test.ts +++ b/src/commands/models/list.probe.test.ts @@ -80,8 +80,8 @@ describe("runAuthProbes", () => { resolveProviderEntryApiKeyBinding: vi.fn(), resolveProviderEntryApiKeyProfileReference: () => ({ kind: "none" }), })); - vi.doMock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: async () => [{ provider: "openai", id: "gpt-5.5" }], + vi.doMock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: async () => [{ provider: "openai", id: "gpt-5.5" }], })); try { const module = await importFreshModule( @@ -117,7 +117,7 @@ describe("runAuthProbes", () => { vi.doUnmock("../../agents/embedded-agent.js"); vi.doUnmock("../../agents/auth-profiles.js"); vi.doUnmock("../../agents/model-auth.js"); - vi.doUnmock("../../agents/model-catalog.js"); + vi.doUnmock("../../agents/prepared-model-catalog.js"); } }); @@ -170,8 +170,8 @@ describe("runAuthProbes", () => { source: "models.json", }), })); - vi.doMock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: async () => [{ provider: "openai", id: "gpt-5.5" }], + vi.doMock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: async () => [{ provider: "openai", id: "gpt-5.5" }], })); const providerConfig = { baseUrl: "https://api.openai.com/v1", @@ -235,7 +235,7 @@ describe("runAuthProbes", () => { vi.doUnmock("../../agents/embedded-agent.js"); vi.doUnmock("../../agents/auth-profiles.js"); vi.doUnmock("../../agents/model-auth.js"); - vi.doUnmock("../../agents/model-catalog.js"); + vi.doUnmock("../../agents/prepared-model-catalog.js"); } }); @@ -267,8 +267,8 @@ describe("runAuthProbes", () => { source: "OPENAI_API_KEY", }), })); - vi.doMock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: async () => [{ provider: "openai", id: "gpt-5.5" }], + vi.doMock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: async () => [{ provider: "openai", id: "gpt-5.5" }], })); const cfg = { models: { @@ -320,7 +320,7 @@ describe("runAuthProbes", () => { vi.doUnmock("../../agents/embedded-agent.js"); vi.doUnmock("../../agents/auth-profiles.js"); vi.doUnmock("../../agents/model-auth.js"); - vi.doUnmock("../../agents/model-catalog.js"); + vi.doUnmock("../../agents/prepared-model-catalog.js"); } }); }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index c41b9bd9db6..c3b10f42956 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -31,8 +31,8 @@ import { resolveProviderEntryApiKeyProfileReference, resolveUsableCustomProviderApiKey, } from "../../agents/model-auth.js"; -import { loadModelCatalog } from "../../agents/model-catalog.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/model-selection.js"; +import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { @@ -328,6 +328,7 @@ async function maybeResolveUnresolvedRefIssue(params: { /** Builds probe targets plus preflight failures for missing/invalid credentials. */ export async function buildProbeTargets(params: { cfg: OpenClawConfig; + agentId?: string; agentDir?: string; workspaceDir?: string; providers: string[]; @@ -350,7 +351,12 @@ export async function buildProbeTargets(params: { const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null; const profileFilter = new Set(normalizeUniqueStringEntries(options.profileIds)); const refResolveCache: SecretRefResolveCache = {}; - const catalog = await loadModelCatalog({ config: cfg }); + const catalog = await loadPreparedModelCatalog({ + config: cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(agentDir ? { agentDir } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + }); const candidates = buildProbeCandidateMap(modelCandidates); const targets: AuthProbeTarget[] = []; const results: AuthProbeResult[] = []; @@ -903,6 +909,7 @@ export async function runAuthProbes(params: { const startedAt = Date.now(); const plan = await buildProbeTargets({ cfg: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), agentDir: params.agentDir, workspaceDir: params.workspaceDir, providers: params.providers, diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index df331ea6714..808814a26b6 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -1,757 +1,187 @@ -// Provider catalog list tests cover provider catalog integration for model listing. import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadOwner: vi.fn(), +})); + +vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogOwnerSnapshot: mocks.loadOwner, +})); + import { + hasProviderRuntimeCatalogForFilter, hasProviderStaticCatalogForFilter, loadProviderCatalogModelsForList, - resolveProviderCatalogPluginIdsForFilter, } from "./list.provider-catalog.js"; -const providerDiscoveryMocks = vi.hoisted(() => ({ - buildAgentModelCatalogCacheKey: vi.fn(), - buildModelsJsonSourceFingerprint: vi.fn(), - loadPluginRegistrySnapshotWithMetadata: vi.fn(), - readCachedAgentModelCatalog: vi.fn(), - resolvePluginContributionOwners: vi.fn(), - resolveProviderOwners: vi.fn(), - resolveBundledProviderCompatPluginIds: vi.fn(), - resolveOwningPluginIdsForProvider: vi.fn(), - resolveRuntimePluginDiscoveryProviders: vi.fn(), - resolveProviderContractPluginIdsForProviderAlias: vi.fn(), - writeCachedAgentModelCatalog: vi.fn(), -})); +const emptyMetadataSnapshot = { manifestRegistry: { plugins: [] } } as never; -vi.mock("../../agents/model-catalog-state-cache.js", () => ({ - buildAgentModelCatalogCacheKey: providerDiscoveryMocks.buildAgentModelCatalogCacheKey, - readCachedAgentModelCatalog: providerDiscoveryMocks.readCachedAgentModelCatalog, - writeCachedAgentModelCatalog: providerDiscoveryMocks.writeCachedAgentModelCatalog, -})); - -vi.mock("../../agents/models-config.js", () => ({ - buildModelsJsonSourceFingerprint: providerDiscoveryMocks.buildModelsJsonSourceFingerprint, -})); - -vi.mock("../../plugins/plugin-registry.js", () => ({ - loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }), - loadPluginRegistrySnapshotWithMetadata: - providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata, - resolvePluginContributionOwners: providerDiscoveryMocks.resolvePluginContributionOwners, - resolveProviderOwners: providerDiscoveryMocks.resolveProviderOwners, -})); - -vi.mock("../../plugins/providers.js", () => ({ - resolveBundledProviderCompatPluginIds: - providerDiscoveryMocks.resolveBundledProviderCompatPluginIds, - resolveOwningPluginIdsForProvider: providerDiscoveryMocks.resolveOwningPluginIdsForProvider, - resolveOwningPluginIdsForProviderRef: providerDiscoveryMocks.resolveOwningPluginIdsForProvider, -})); - -vi.mock("../../plugins/contracts/registry.js", () => ({ - resolveProviderContractPluginIdsForProviderAlias: - providerDiscoveryMocks.resolveProviderContractPluginIdsForProviderAlias, -})); - -vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => { - const actual = await importOriginal(); +function ownerSnapshot(modelCatalog: unknown, metadataSnapshot = emptyMetadataSnapshot) { return { - ...actual, - resolveRuntimePluginDiscoveryProviders: - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders, - }; -}); - -const baseParams = { - cfg: { - plugins: { - entries: { - chutes: { enabled: true }, - moonshot: { enabled: true }, - }, - }, - }, - agentDir: "/tmp/openclaw-provider-catalog-test", - env: { - ...process.env, - CHUTES_API_KEY: "", - MOONSHOT_API_KEY: "", - }, -}; - -const chutesProvider = { - id: "chutes", - pluginId: "chutes", - label: "Chutes", - auth: [], - staticCatalog: { - run: async () => ({ - provider: { baseUrl: "https://chutes.example/v1", models: [] }, - }), - }, -}; - -const moonshotProvider = { - id: "moonshot", - pluginId: "moonshot", - label: "Moonshot", - auth: [], - staticCatalog: { - run: async () => ({ - provider: { - baseUrl: "https://api.moonshot.ai/v1", - models: [{ id: "kimi-k2.6", name: "Kimi K2.6" }], - }, - }), - }, -}; - -const openaiProvider = { - id: "openai", - pluginId: "openai", - label: "OpenAI", - aliases: ["azure-openai-responses"], - auth: [], - staticCatalog: { - run: async () => ({ - provider: { baseUrl: "https://api.openai.com/v1", models: [] }, - }), - }, -}; - -const catalogOnlyProvider = { - id: "ollama", - pluginId: "ollama", - label: "Ollama", - auth: [], - catalog: { - run: async () => ({ - provider: { baseUrl: "http://127.0.0.1:11434", models: [] }, - }), - }, -}; - -const hybridCatalogProvider = { - id: "hybrid", - pluginId: "hybrid", - label: "Hybrid", - auth: [], - catalog: { - run: vi.fn(async () => ({ - provider: { - baseUrl: "https://hybrid.example/v1", - models: [{ id: "live-model", name: "Live Model" }], - }, - })), - }, - staticCatalog: { - run: vi.fn(async () => ({ - provider: { - baseUrl: "https://hybrid.example/v1", - models: [{ id: "static-model", name: "Static Model" }], - }, - })), - }, -}; - -const vllmProvider = { - id: "vllm", - pluginId: "vllm", - label: "vLLM", - auth: [], - catalog: { - run: async (ctx: { - config: { - models?: { - providers?: Record< - string, - { - baseUrl?: string; - } - >; - }; - }; - resolveProviderApiKey: (providerId?: string) => { - apiKey: string | undefined; - discoveryApiKey?: string; - }; - }) => ({ - provider: { - baseUrl: ctx.config.models?.providers?.vllm?.baseUrl, - api: "openai-completions", - models: [ - { - id: "runtime-vllm-model", - name: "Runtime vLLM Model", - }, - ], - apiKey: "proof-key", - }, - }), - }, -}; - -const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider]; - -function firstDiscoveryRequest(): { - onlyPluginIds?: string[]; - requireCompleteDiscoveryEntryCoverage?: boolean; - discoveryEntriesOnly?: boolean; - includeUntrustedWorkspacePlugins?: boolean; -} { - const call = providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mock.calls[0]; - if (!call) { - throw new Error("expected runtime plugin discovery call"); - } - return call[0] as { - onlyPluginIds?: string[]; - requireCompleteDiscoveryEntryCoverage?: boolean; - discoveryEntriesOnly?: boolean; - includeUntrustedWorkspacePlugins?: boolean; + agentDir: "/tmp/agent", + metadataSnapshot, + modelCatalog, }; } -function firstCacheKeyInput(): { - cacheScope?: { - envFingerprint?: string; - sourceFingerprint?: string; - }; - metadataSnapshot?: unknown; -} { - const call = providerDiscoveryMocks.buildAgentModelCatalogCacheKey.mock.calls[0]; - if (!call) { - throw new Error("expected state cache key build call"); - } - return call[0] as { - cacheScope?: { - envFingerprint?: string; - sourceFingerprint?: string; - }; - metadataSnapshot?: unknown; - }; -} - -describe("loadProviderCatalogModelsForList", () => { +describe("lifecycle-owned model-list provider catalog", () => { beforeEach(() => { - vi.clearAllMocks(); - providerDiscoveryMocks.buildAgentModelCatalogCacheKey.mockImplementation( - (input: { cacheScope?: { sourceFingerprint?: string } }) => - `provider-cache-key:${input.cacheScope?.sourceFingerprint ?? "none"}`, - ); - providerDiscoveryMocks.buildModelsJsonSourceFingerprint.mockResolvedValue({ - agentDir: baseParams.agentDir, - fingerprint: "provider-source-fingerprint", - workspaceDir: "/tmp/provider-workspace", - }); - providerDiscoveryMocks.readCachedAgentModelCatalog.mockReturnValue(undefined); - providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot: { - plugins: [], - }, - diagnostics: [], - }); - providerDiscoveryMocks.resolveProviderOwners.mockImplementation( - ({ providerId }: { providerId: string }) => - defaultProviders - .filter((provider) => provider.id === providerId) - .map((provider) => provider.pluginId), - ); - providerDiscoveryMocks.resolvePluginContributionOwners.mockReturnValue([]); - providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([ - "chutes", - "moonshot", - "openai", - "ollama", - "hybrid", - ]); - providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockImplementation( - ({ provider }: { provider: string }) => - [...defaultProviders, catalogOnlyProvider, hybridCatalogProvider].some( - (entry) => entry.id === provider, - ) - ? [provider] - : undefined, - ); - providerDiscoveryMocks.resolveProviderContractPluginIdsForProviderAlias.mockImplementation( - (provider: string) => (provider === "azure-openai-responses" ? ["openai"] : undefined), - ); - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockImplementation( - async ({ onlyPluginIds }: { onlyPluginIds?: string[] }) => - defaultProviders.filter((provider) => onlyPluginIds?.includes(provider.pluginId)), - ); + mocks.loadOwner.mockReset(); }); - it("does not use live provider discovery for display-only rows", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("blocked fetch")); - - await loadProviderCatalogModelsForList({ - ...baseParams, - providerFilter: "chutes", - }); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("includes unauthenticated Moonshot static catalog rows", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("blocked fetch")); - - const rows = await loadProviderCatalogModelsForList({ - ...baseParams, - providerFilter: "moonshot", - }); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(rows.map((row) => `${row.provider}/${row.id}`)).toContain("moonshot/kimi-k2.6"); - }); - - it("reuses cached provider catalog rows before runtime provider discovery", async () => { - providerDiscoveryMocks.readCachedAgentModelCatalog.mockReturnValueOnce([ - { provider: "moonshot", id: "cached-kimi", name: "Cached Kimi" }, - ]); - - const rows = await loadProviderCatalogModelsForList({ - ...baseParams, - }); - - expect(rows.map((row) => `${row.provider}/${row.id}`)).toStrictEqual(["moonshot/cached-kimi"]); - expect(providerDiscoveryMocks.readCachedAgentModelCatalog).toHaveBeenCalledWith({ - agentDir: baseParams.agentDir, - catalogKey: "provider-cache-key:provider-source-fingerprint", - }); - expect(providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled(); - expect(providerDiscoveryMocks.writeCachedAgentModelCatalog).not.toHaveBeenCalled(); - }); - - it("separates provider catalog state cache keys by environment fingerprint", async () => { - await loadProviderCatalogModelsForList({ - ...baseParams, - env: { - ...baseParams.env, - MOONSHOT_API_KEY: "first-secret", - }, - providerFilter: "moonshot", - }); - const firstFingerprint = firstCacheKeyInput().cacheScope?.envFingerprint; - - providerDiscoveryMocks.buildAgentModelCatalogCacheKey.mockClear(); - await loadProviderCatalogModelsForList({ - ...baseParams, - env: { - ...baseParams.env, - MOONSHOT_API_KEY: "second-secret", - }, - providerFilter: "moonshot", - }); - const secondFingerprint = firstCacheKeyInput().cacheScope?.envFingerprint; - - expect(firstFingerprint).toEqual(expect.any(String)); - expect(secondFingerprint).toEqual(expect.any(String)); - expect(firstFingerprint).not.toBe(secondFingerprint); - expect(firstFingerprint).not.toContain("first-secret"); - expect(secondFingerprint).not.toContain("second-secret"); - }); - - it("writes provider catalog rows to the state cache after runtime discovery", async () => { - const rows = await loadProviderCatalogModelsForList({ - ...baseParams, - providerFilter: "moonshot", - }); - - expect(rows.map((row) => `${row.provider}/${row.id}`)).toStrictEqual(["moonshot/kimi-k2.6"]); - expect(providerDiscoveryMocks.writeCachedAgentModelCatalog).toHaveBeenCalledWith({ - agentDir: baseParams.agentDir, - catalogKey: "provider-cache-key:provider-source-fingerprint", - entries: rows, - }); - }); - - it("misses cached provider catalog rows when source freshness changes", async () => { - providerDiscoveryMocks.buildModelsJsonSourceFingerprint - .mockResolvedValueOnce({ - agentDir: baseParams.agentDir, - fingerprint: "old-provider-source", - workspaceDir: "/tmp/provider-workspace", - }) - .mockResolvedValueOnce({ - agentDir: baseParams.agentDir, - fingerprint: "new-provider-source", - workspaceDir: "/tmp/provider-workspace", - }); - providerDiscoveryMocks.readCachedAgentModelCatalog.mockImplementation( - ({ catalogKey }: { catalogKey: string }) => - catalogKey.endsWith("old-provider-source") - ? [{ provider: "moonshot", id: "cached-stale", name: "Cached Stale" }] - : undefined, - ); - - await expect(loadProviderCatalogModelsForList({ ...baseParams })).resolves.toEqual([ - { provider: "moonshot", id: "cached-stale", name: "Cached Stale" }, - ]); - await expect(loadProviderCatalogModelsForList({ ...baseParams })).resolves.toEqual([ - expect.objectContaining({ provider: "moonshot", id: "kimi-k2.6" }), - ]); - - expect(providerDiscoveryMocks.readCachedAgentModelCatalog).toHaveBeenNthCalledWith(1, { - agentDir: baseParams.agentDir, - catalogKey: "provider-cache-key:old-provider-source", - }); - expect(providerDiscoveryMocks.readCachedAgentModelCatalog).toHaveBeenNthCalledWith(2, { - agentDir: baseParams.agentDir, - catalogKey: "provider-cache-key:new-provider-source", - }); - }); - - it("requires complete discovery-entry coverage for static-only loads", async () => { - await loadProviderCatalogModelsForList({ - ...baseParams, - providerFilter: "moonshot", - staticOnly: true, - }); - - const discoveryRequest = firstDiscoveryRequest(); - expect(discoveryRequest?.onlyPluginIds).toStrictEqual(["moonshot"]); - expect(discoveryRequest?.requireCompleteDiscoveryEntryCoverage).toBe(true); - expect(discoveryRequest?.discoveryEntriesOnly).toBe(true); - }); - - it("reuses a command metadata snapshot for provider catalog planning", async () => { - const metadataSnapshot = { - index: { - plugins: [{ pluginId: "moonshot", enabled: true, origin: "bundled" }], - }, - manifestRegistry: { - plugins: [], - diagnostics: [], - }, - }; - - await loadProviderCatalogModelsForList({ - ...baseParams, - providerFilter: "moonshot", - staticOnly: true, - metadataSnapshot: metadataSnapshot as unknown as Parameters< - typeof loadProviderCatalogModelsForList - >[0]["metadataSnapshot"], - }); - - expect(providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith( - expect.objectContaining({ index: metadataSnapshot.index }), - ); - expect(providerDiscoveryMocks.resolveBundledProviderCompatPluginIds).toHaveBeenCalledWith( - expect.objectContaining({ manifestRegistry: metadataSnapshot.manifestRegistry }), - ); - expect(providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith( - expect.objectContaining({ pluginMetadataSnapshot: metadataSnapshot }), - ); - expect(firstCacheKeyInput()).toEqual(expect.objectContaining({ metadataSnapshot })); - }); - - it("uses bundled runtime provider catalogs for provider-filtered self-hosted rows", async () => { - providerDiscoveryMocks.resolveProviderOwners.mockImplementation( - ({ providerId }: { providerId: string }) => (providerId === "vllm" ? ["vllm"] : []), - ); - providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["vllm"]); - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([vllmProvider]); - - const rows = await loadProviderCatalogModelsForList({ - ...baseParams, - cfg: { - agents: { - defaults: { - models: { - "vllm/*": {}, - }, - }, - }, - models: { - providers: { - vllm: { - baseUrl: "http://vllm-router.example/v1", - apiKey: "proof-key", - api: "openai-completions", - models: [], - }, - }, - }, - }, - providerFilter: "vllm", - }); - - expect(rows.map((row) => `${row.provider}/${row.id}`)).toStrictEqual([ - "vllm/runtime-vllm-model", - ]); - expect(rows[0]?.baseUrl).toBe("http://vllm-router.example/v1"); - const discoveryRequest = firstDiscoveryRequest(); - expect(discoveryRequest?.onlyPluginIds).toStrictEqual(["vllm"]); - expect(discoveryRequest?.discoveryEntriesOnly).toBe(false); - }); - - it("uses live catalogs before static catalogs for normal list output", async () => { - providerDiscoveryMocks.resolveProviderOwners.mockImplementation( - ({ providerId }: { providerId: string }) => (providerId === "hybrid" ? ["hybrid"] : []), - ); - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([ - hybridCatalogProvider, - ]); - - const rows = await loadProviderCatalogModelsForList({ - ...baseParams, - providerFilter: "hybrid", - }); - - expect(rows.map((row) => `${row.provider}/${row.id}`)).toStrictEqual(["hybrid/live-model"]); - expect(hybridCatalogProvider.catalog.run).toHaveBeenCalledOnce(); - expect(hybridCatalogProvider.staticCatalog.run).not.toHaveBeenCalled(); - }); - - it("keeps explicit static-only list output on static catalogs", async () => { - providerDiscoveryMocks.resolveProviderOwners.mockImplementation( - ({ providerId }: { providerId: string }) => (providerId === "hybrid" ? ["hybrid"] : []), - ); - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([ - hybridCatalogProvider, - ]); - - const rows = await loadProviderCatalogModelsForList({ - ...baseParams, - providerFilter: "hybrid", - staticOnly: true, - }); - - expect(rows.map((row) => `${row.provider}/${row.id}`)).toStrictEqual(["hybrid/static-model"]); - expect(hybridCatalogProvider.catalog.run).not.toHaveBeenCalled(); - expect(hybridCatalogProvider.staticCatalog.run).toHaveBeenCalledOnce(); - }); - - it("falls back to static rows when a live catalog fails", async () => { - providerDiscoveryMocks.resolveProviderOwners.mockImplementation( - ({ providerId }: { providerId: string }) => (providerId === "hybrid" ? ["hybrid"] : []), - ); - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([ - hybridCatalogProvider, - ]); - hybridCatalogProvider.catalog.run.mockRejectedValueOnce(new Error("live catalog offline")); - - const rows = await loadProviderCatalogModelsForList({ - ...baseParams, - providerFilter: "hybrid", - }); - - expect(rows.map((row) => `${row.provider}/${row.id}`)).toStrictEqual(["hybrid/static-model"]); - expect(hybridCatalogProvider.catalog.run).toHaveBeenCalledOnce(); - expect(hybridCatalogProvider.staticCatalog.run).toHaveBeenCalledOnce(); - }); - - it("resolves provider owners from the installed plugin index before manifest fallback", async () => { - await expect( - resolveProviderCatalogPluginIdsForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "moonshot", + it("projects a provider from the lifecycle owner", async () => { + mocks.loadOwner.mockResolvedValue( + ownerSnapshot({ + entries: [ + { provider: "moonshot", id: "kimi-k2.6", name: "Kimi K2.6" }, + { provider: "openai", id: "gpt-5.4", name: "GPT-5.4" }, + { provider: "ollama", id: "local-model", name: "Local Model" }, + ], + staticEntries: [{ provider: "moonshot", id: "kimi-static", name: "Kimi Static" }], + routeVariants: [], }), - ).resolves.toEqual(["moonshot"]); - - expect(providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ - config: baseParams.cfg, - env: baseParams.env, - }); - expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); - }); - - it("uses a supplied registry index for provider ownership", async () => { - const registryIndex = { - plugins: [{ pluginId: "moonshot", enabled: true, origin: "bundled" }], - }; - - await expect( - resolveProviderCatalogPluginIdsForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "moonshot", - registryIndex: registryIndex as unknown as Parameters< - typeof resolveProviderCatalogPluginIdsForFilter - >[0]["registryIndex"], - }), - ).resolves.toEqual(["moonshot"]); - - expect(providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ - config: baseParams.cfg, - env: baseParams.env, - index: registryIndex, - }); - expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); - }); - - it("falls back to manifest ownership when the plugin index is derived", async () => { - providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValueOnce({ - source: "derived", - snapshot: { - plugins: [], - }, - diagnostics: [], - }); - - await expect( - resolveProviderCatalogPluginIdsForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "moonshot", - }), - ).resolves.toEqual(["moonshot"]); - - expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).toHaveBeenCalledWith({ - provider: "moonshot", - config: baseParams.cfg, - env: baseParams.env, - }); - }); - - it("does not fall back to legacy manifest ownership for disabled persisted plugin owners", async () => { - providerDiscoveryMocks.resolveProviderOwners - .mockReturnValueOnce([]) - .mockReturnValueOnce(["moonshot"]); - providerDiscoveryMocks.resolvePluginContributionOwners.mockReturnValue([]); - - await expect( - resolveProviderCatalogPluginIdsForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "moonshot", - }), - ).resolves.toStrictEqual([]); - - expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); - }); - - it("returns an empty catalog when a static provider catalog throws", async () => { - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValueOnce([ - { - id: "moonshot", - pluginId: "moonshot", - label: "Moonshot", - auth: [], - staticCatalog: { - run: async () => { - throw new Error("catalog offline"); - }, - }, - }, - ]); + ); await expect( loadProviderCatalogModelsForList({ - ...baseParams, + cfg: {}, + agentDir: "/tmp/agent", providerFilter: "moonshot", + }), + ).resolves.toEqual([{ provider: "moonshot", id: "kimi-k2.6", name: "Kimi K2.6" }]); + await expect( + loadProviderCatalogModelsForList({ + cfg: {}, + agentDir: "/tmp/agent", + }), + ).resolves.not.toContainEqual(expect.objectContaining({ provider: "ollama" })); + }); + + it("keeps static provider-hook rows separate from the full runtime catalog", async () => { + mocks.loadOwner.mockResolvedValue( + ownerSnapshot({ + entries: [{ provider: "moonshot", id: "kimi-runtime", name: "Kimi Runtime" }], + staticEntries: [{ provider: "nvidia", id: "nemotron-static", name: "Nemotron Static" }], + routeVariants: [], + }), + ); + + await expect( + hasProviderRuntimeCatalogForFilter({ + cfg: {}, + agentId: "worker", + agentDir: "/tmp/agent", + providerFilter: "nvidia", + }), + ).resolves.toBe(false); + await expect( + hasProviderStaticCatalogForFilter({ + cfg: {}, + agentDir: "/tmp/agent", + providerFilter: "nvidia", + }), + ).resolves.toBe(true); + await expect( + hasProviderStaticCatalogForFilter({ + cfg: {}, + agentDir: "/tmp/agent", + }), + ).resolves.toBe(true); + await expect( + loadProviderCatalogModelsForList({ + cfg: {}, + agentDir: "/tmp/agent", staticOnly: true, }), - ).resolves.toStrictEqual([]); + ).resolves.toEqual([{ provider: "nvidia", id: "nemotron-static", name: "Nemotron Static" }]); + expect(mocks.loadOwner).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })); }); - it("only skips registry for providers with actual static catalogs", async () => { - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([ - catalogOnlyProvider, - ]); + it("activates one prepared owner when no generation is published", async () => { + const env = { OPENCLAW_STATE_DIR: "/tmp/model-list-state" }; + mocks.loadOwner.mockResolvedValue( + ownerSnapshot({ + entries: [{ provider: "moonshot", id: "kimi-k2.6", name: "Kimi K2.6" }], + routeVariants: [], + }), + ); await expect( - hasProviderStaticCatalogForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "ollama", + hasProviderRuntimeCatalogForFilter({ + cfg: {}, + agentId: "worker", + agentDir: "/tmp/agent", + env, + providerFilter: "moonshot", }), - ).resolves.toBe(false); - - const discoveryRequest = firstDiscoveryRequest(); - expect(discoveryRequest?.onlyPluginIds).toStrictEqual(["ollama"]); - expect(discoveryRequest?.requireCompleteDiscoveryEntryCoverage).toBe(true); - expect(discoveryRequest?.discoveryEntriesOnly).toBe(true); - }); - - it("does not skip registry when a bundled provider has no lightweight static entry", async () => { - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValueOnce([]); - - await expect( - hasProviderStaticCatalogForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "chutes", - }), - ).resolves.toBe(false); - }); - - it("does not skip registry for non-bundled static catalog owners", async () => { - providerDiscoveryMocks.resolveProviderOwners.mockReturnValueOnce([]); - providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce([ - "workspace-static-provider", - ]); - providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValueOnce(["moonshot"]); - - await expect( - hasProviderStaticCatalogForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "workspace-static-provider", - }), - ).resolves.toBe(false); - - expect(providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled(); - }); - - it("recognizes bundled provider hook aliases before the unknown-provider short-circuit", async () => { - providerDiscoveryMocks.resolveProviderOwners.mockReturnValueOnce([]); - - await expect( - resolveProviderCatalogPluginIdsForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "azure-openai-responses", - }), - ).resolves.toEqual(["openai"]); - }); - - it("does not execute workspace provider static catalogs", async () => { - const workspaceStaticCatalog = vi.fn(async () => ({ - provider: { baseUrl: "https://workspace.example/v1", models: [] }, - })); - providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["bundled-demo"]); - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([ - { - id: "bundled-demo", - pluginId: "bundled-demo", - label: "Bundled Demo", - auth: [], - staticCatalog: { - run: async () => null, - }, - }, - { - id: "workspace-demo", - pluginId: "workspace-demo", - label: "Workspace Demo", - auth: [], - staticCatalog: { - run: workspaceStaticCatalog, - }, - }, - ]); - - const rows = await loadProviderCatalogModelsForList({ - ...baseParams, + ).resolves.toBe(true); + expect(mocks.loadOwner).toHaveBeenCalledWith({ + config: {}, + agentId: "worker", + agentDir: "/tmp/agent", + env, }); - - const discoveryRequest = firstDiscoveryRequest(); - expect(discoveryRequest?.onlyPluginIds).toStrictEqual(["bundled-demo"]); - expect(discoveryRequest?.includeUntrustedWorkspacePlugins).toBe(false); - expect(workspaceStaticCatalog).not.toHaveBeenCalled(); - expect(rows).toStrictEqual([]); }); - it("keeps unknown provider filters eligible for early empty results", async () => { - providerDiscoveryMocks.resolveProviderOwners.mockReturnValueOnce([]); + it("derives the matching directory for an explicit agent", async () => { + const cfg = { + agents: { + list: [{ id: "worker", agentDir: "/tmp/model-list-worker-agent" }], + }, + }; + mocks.loadOwner.mockResolvedValue( + ownerSnapshot({ + entries: [], + staticEntries: [{ provider: "nvidia", id: "worker-model", name: "Worker Model" }], + routeVariants: [], + }), + ); await expect( - resolveProviderCatalogPluginIdsForFilter({ - cfg: baseParams.cfg, - env: baseParams.env, - providerFilter: "unknown-provider-for-catalog-test", + hasProviderStaticCatalogForFilter({ + cfg, + agentId: "worker", + providerFilter: "nvidia", }), - ).resolves.toBeUndefined(); + ).resolves.toBe(true); + expect(mocks.loadOwner).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "worker", + agentDir: "/tmp/model-list-worker-agent", + }), + ); + }); + + it("matches provider aliases from the captured metadata generation", async () => { + const metadataSnapshot = { + manifestRegistry: { + plugins: [ + { + id: "moonshot", + modelCatalog: { + aliases: { kimi: { provider: "moonshot" } }, + }, + }, + ], + }, + } as never; + mocks.loadOwner.mockResolvedValue( + ownerSnapshot( + { + entries: [{ provider: "moonshot", id: "kimi-k2.6", name: "Kimi K2.6" }], + staticEntries: [{ provider: "moonshot", id: "kimi-static", name: "Kimi Static" }], + routeVariants: [], + }, + metadataSnapshot, + ), + ); + + await expect( + loadProviderCatalogModelsForList({ + cfg: {}, + agentDir: "/tmp/agent", + providerFilter: "kimi", + }), + ).resolves.toEqual([{ provider: "moonshot", id: "kimi-k2.6", name: "Kimi K2.6" }]); }); }); diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index f18fc43d21b..61cd5e69707 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -1,462 +1,117 @@ -/** Provider plugin catalog loading for model-list output. */ -import { createHash } from "node:crypto"; +/** Lifecycle-owned provider catalog projection for model-list output. */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { expectDefined } from "@openclaw/normalization-core"; -import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles/store.js"; -import { - buildAgentModelCatalogCacheKey, - readCachedAgentModelCatalog, - writeCachedAgentModelCatalog, -} from "../../agents/model-catalog-state-cache.js"; -import { buildModelsJsonSourceFingerprint } from "../../agents/models-config.js"; -import { - createProviderApiKeyResolver, - createProviderAuthResolver, -} from "../../agents/models-config.providers.secrets.js"; -import type { ModelProviderConfig } from "../../config/types.models.js"; +import { resolveAgentDir, resolveDefaultAgentDir } from "../../agents/agent-scope.js"; +import { loadPreparedModelCatalogOwnerSnapshot } from "../../agents/prepared-model-catalog.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { formatErrorMessage } from "../../infra/errors.js"; import type { Model } from "../../llm/types.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; -import { - loadPluginRegistrySnapshotWithMetadata, - resolvePluginContributionOwners, - resolveProviderOwners, - type PluginRegistrySnapshot, -} from "../../plugins/plugin-registry.js"; -import { - groupPluginDiscoveryProvidersByOrder, - normalizePluginDiscoveryResult, - providerMatchesFilter, - resolveRuntimePluginDiscoveryProviders, - runProviderCatalog, - runProviderStaticCatalog, -} from "../../plugins/provider-discovery.js"; -import { - resolveBundledProviderCompatPluginIds, - resolveOwningPluginIdsForProviderRef, -} from "../../plugins/providers.js"; -import type { ProviderPlugin } from "../../plugins/types.js"; +import { canonicalizeModelCatalogProviderAlias } from "./provider-aliases.js"; -const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const; -const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]); -const log = createSubsystemLogger("models/list-provider-catalog"); - -function buildProviderCatalogEnvCacheFingerprint(env: NodeJS.ProcessEnv): string { - const entries = Object.entries(env) - .filter((entry): entry is [string, string] => entry[1] !== undefined) - .map(([key, value]) => [key, createHash("sha256").update(value).digest("hex")]) - .toSorted(([left], [right]) => - expectDefined(left, "list.provider catalog left").localeCompare( - expectDefined(right, "list.provider catalog right"), - ), - ); - return createHash("sha256").update(JSON.stringify(entries)).digest("hex"); -} - -function collectMatchingContributionOwners( - index: PluginRegistrySnapshot, - contribution: "providers" | "cliBackends", - providerFilter: string, - cfg: OpenClawConfig, - options: { includeDisabled?: boolean } = {}, -): string[] { - if (contribution === "providers") { - return [ - ...resolveProviderOwners({ - index, - providerId: providerFilter, - includeDisabled: options.includeDisabled, - config: cfg, - }), - ]; - } - return [ - ...resolvePluginContributionOwners({ - index, - contribution: "cliBackends", - matches: (contributionId) => normalizeProviderId(contributionId) === providerFilter, - includeDisabled: options.includeDisabled, - config: cfg, - }), - ]; -} - -function resolveInstalledIndexPluginIdsForProviderFilter(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - providerFilter: string; - registryIndex?: PluginRegistrySnapshot; -}): string[] | undefined { - const snapshot = loadPluginRegistrySnapshotWithMetadata({ - config: params.cfg, - env: params.env, - index: params.registryIndex, - }); - if (snapshot.source !== "persisted" && snapshot.source !== "provided") { - return undefined; - } - const index = snapshot.snapshot; - const pluginIds = [ - ...collectMatchingContributionOwners(index, "providers", params.providerFilter, params.cfg), - ...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter, params.cfg), - ]; - if (pluginIds.length > 0) { - return sortUniqueStrings(pluginIds); - } - const disabledPluginIds = [ - ...collectMatchingContributionOwners(index, "providers", params.providerFilter, params.cfg, { - includeDisabled: true, - }), - ...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter, params.cfg, { - includeDisabled: true, - }), - ]; - return disabledPluginIds.length > 0 ? [] : undefined; -} - -/** Resolves plugin ids that can provide catalog rows for a provider filter. */ -export async function resolveProviderCatalogPluginIdsForFilter(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - providerFilter: string; - registryIndex?: PluginRegistrySnapshot; - metadataSnapshot?: PluginMetadataSnapshot; -}): Promise { - const providerFilter = normalizeProviderId(params.providerFilter); - if (!providerFilter) { - return undefined; - } - const installedIndexPluginIds = resolveInstalledIndexPluginIdsForProviderFilter({ - cfg: params.cfg, - env: params.env, - providerFilter, - registryIndex: params.metadataSnapshot?.index ?? params.registryIndex, - }); - if (installedIndexPluginIds) { - // Installed registry metadata is process-stable and knows disabled plugins, - // so it wins over broader manifest/contract alias fallbacks. - return installedIndexPluginIds; - } - const manifestPluginIds = resolveOwningPluginIdsForProviderRef({ - provider: providerFilter, - config: params.cfg, - env: params.env, - manifestRegistry: params.metadataSnapshot?.manifestRegistry, - }); - if (manifestPluginIds) { - return manifestPluginIds; - } - const { resolveProviderContractPluginIdsForProviderAlias } = - await import("../../plugins/contracts/registry.js"); - const bundledAliasPluginIds = resolveProviderContractPluginIdsForProviderAlias(providerFilter); - if (bundledAliasPluginIds) { - return bundledAliasPluginIds; - } - return undefined; -} - -/** Returns true when a provider filter can be satisfied by a static bundled catalog. */ -export async function hasProviderStaticCatalogForFilter(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - providerFilter: string; - registryIndex?: PluginRegistrySnapshot; - metadataSnapshot?: PluginMetadataSnapshot; -}): Promise { - return await hasProviderCatalogForFilter( - params, - (provider) => typeof provider.staticCatalog?.run === "function", - { discoveryEntriesOnly: true }, - ); -} - -export async function hasProviderRuntimeCatalogForFilter(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - providerFilter: string; - registryIndex?: PluginRegistrySnapshot; - metadataSnapshot?: PluginMetadataSnapshot; -}): Promise { - return await hasProviderCatalogForFilter( - params, - (provider) => - typeof provider.catalog?.run === "function" || typeof provider.discovery?.run === "function", - { discoveryEntriesOnly: false }, - ); -} - -async function hasProviderCatalogForFilter( - params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - providerFilter: string; - registryIndex?: PluginRegistrySnapshot; - metadataSnapshot?: PluginMetadataSnapshot; - }, - predicate: (provider: ProviderPlugin) => boolean, - options: { discoveryEntriesOnly: boolean }, -): Promise { - const env = params.env ?? process.env; - const providerFilter = normalizeProviderId(params.providerFilter); - if (!providerFilter) { - return false; - } - const pluginIds = await resolveProviderCatalogPluginIdsForFilter({ - ...params, - env, - registryIndex: params.metadataSnapshot?.index ?? params.registryIndex, - }); - if (!pluginIds || pluginIds.length === 0) { - return false; - } - const bundledPluginIds = resolveBundledProviderCompatPluginIds({ - config: params.cfg, - env, - manifestRegistry: params.metadataSnapshot?.manifestRegistry, - }); - const bundledPluginIdSet = new Set(bundledPluginIds); - const scopedPluginIds = pluginIds.filter((pluginId) => bundledPluginIdSet.has(pluginId)); - if (scopedPluginIds.length === 0) { - return false; - } - const providers = await resolveRuntimePluginDiscoveryProviders({ - config: params.cfg, - env, - onlyPluginIds: scopedPluginIds, - includeUntrustedWorkspacePlugins: false, - requireCompleteDiscoveryEntryCoverage: options.discoveryEntriesOnly, - discoveryEntriesOnly: options.discoveryEntriesOnly, - pluginMetadataSnapshot: params.metadataSnapshot, - }); - return providers.some( - (provider) => predicate(provider) && providerMatchesFilter({ provider, providerFilter }), - ); -} - -function modelFromProviderCatalog(params: { - provider: string; - providerConfig: ModelProviderConfig; - model: ModelProviderConfig["models"][number]; -}): Model { - return { - id: params.model.id, - name: params.model.name || params.model.id, - provider: params.provider, - api: params.model.api ?? params.providerConfig.api ?? "openai-responses", - baseUrl: params.model.baseUrl ?? params.providerConfig.baseUrl, - reasoning: params.model.reasoning, - input: params.model.input ?? ["text"], - cost: params.model.cost, - contextWindow: params.model.contextWindow, - contextTokens: params.model.contextTokens, - maxTokens: params.model.maxTokens, - headers: params.model.headers, - compat: params.model.compat, - } as Model; -} - -async function runProviderCatalogForList(params: { - provider: ProviderPlugin; - cfg: OpenClawConfig; - agentDir: string; - env: NodeJS.ProcessEnv; - staticOnly?: boolean; -}): Promise> | null> { - if (params.staticOnly === true) { - return ( - (await runProviderStaticCatalog({ - provider: params.provider, - config: params.cfg, - agentDir: params.agentDir, - env: params.env, - })) ?? null - ); - } - - const hasRuntimeCatalog = - typeof params.provider.catalog?.run === "function" || - typeof params.provider.discovery?.run === "function"; - if (hasRuntimeCatalog) { - const authStore = loadAuthProfileStoreWithoutExternalProfiles(params.agentDir); - const resolveProviderApiKey = createProviderApiKeyResolver(params.env, authStore, params.cfg); - const resolveProviderAuth = createProviderAuthResolver(params.env, authStore, params.cfg); - try { - const runtimeResult = await runProviderCatalog({ - provider: params.provider, - config: params.cfg, - agentDir: params.agentDir, - env: params.env, - resolveProviderApiKey: (providerId) => - resolveProviderApiKey(providerId?.trim() || params.provider.id), - resolveProviderAuth: (providerId, options) => - resolveProviderAuth(providerId?.trim() || params.provider.id, options), - }); - if (runtimeResult) { - return runtimeResult; - } - } catch (error) { - log.warn( - `provider runtime catalog failed for ${params.provider.id}: ${formatErrorMessage(error)}`, - ); - } - } - - if (typeof params.provider.staticCatalog?.run !== "function") { - return null; - } - return ( - (await runProviderStaticCatalog({ - provider: params.provider, - config: params.cfg, - agentDir: params.agentDir, - env: params.env, - })) ?? null - ); -} - -/** Loads model rows from provider static/runtime catalog hooks for model-list output. */ -export async function loadProviderCatalogModelsForList(params: { +type ProviderCatalogListParams = { cfg: OpenClawConfig; + agentId?: string; agentDir: string; env?: NodeJS.ProcessEnv; providerFilter?: string; staticOnly?: boolean; - registryIndex?: PluginRegistrySnapshot; metadataSnapshot?: PluginMetadataSnapshot; -}): Promise { - const env = params.env ?? process.env; - const providerFilter = params.providerFilter ? normalizeProviderId(params.providerFilter) : ""; - const onlyPluginIds = providerFilter - ? await resolveProviderCatalogPluginIdsForFilter({ - cfg: params.cfg, - env, - providerFilter, - registryIndex: params.metadataSnapshot?.index ?? params.registryIndex, - metadataSnapshot: params.metadataSnapshot, - }) - : undefined; - if (providerFilter && !onlyPluginIds) { - return []; - } +}; - const bundledPluginIds = resolveBundledProviderCompatPluginIds({ +const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]); + +async function loadProviderCatalogSnapshot( + params: ProviderCatalogListParams, + options: { readOnly?: boolean } = {}, +) { + const input = { config: params.cfg, - env, - manifestRegistry: params.metadataSnapshot?.manifestRegistry, - }); - const bundledPluginIdSet = new Set(bundledPluginIds); - const scopedPluginIds = onlyPluginIds - ? onlyPluginIds.filter((pluginId) => bundledPluginIdSet.has(pluginId)) - : bundledPluginIds; - if (scopedPluginIds.length === 0) { - return []; - } - - const sourceFingerprint = await buildModelsJsonSourceFingerprint(params.cfg, params.agentDir, { - pluginMetadataSnapshot: params.metadataSnapshot, - providerDiscoveryEntriesOnly: params.staticOnly === true, - providerDiscoveryProviderIds: scopedPluginIds, - workspaceDir: params.metadataSnapshot?.workspaceDir, - }); - const catalogKey = buildAgentModelCatalogCacheKey({ + ...(params.agentId ? { agentId: params.agentId } : {}), agentDir: params.agentDir, - cacheScope: { - envFingerprint: buildProviderCatalogEnvCacheFingerprint(env), - source: "models-list-provider-catalog", - providerFilter, - scopedPluginIds, - sourceFingerprint: sourceFingerprint.fingerprint, - staticOnly: params.staticOnly === true, - }, - config: params.cfg, - metadataSnapshot: params.metadataSnapshot, - workspaceDir: params.metadataSnapshot?.workspaceDir, + ...(params.metadataSnapshot?.workspaceDir + ? { workspaceDir: params.metadataSnapshot.workspaceDir } + : {}), + ...(params.env ? { env: params.env } : {}), + ...(options.readOnly ? { readOnly: true } : {}), + }; + return await loadPreparedModelCatalogOwnerSnapshot(input); +} + +function resolveProviderFilter( + params: ProviderCatalogListParams, + metadataSnapshot: PluginMetadataSnapshot, +): string { + const providerFilter = normalizeProviderId(params.providerFilter ?? ""); + return providerFilter + ? normalizeProviderId( + canonicalizeModelCatalogProviderAlias(providerFilter, { + cfg: params.cfg, + metadataSnapshot, + }), + ) + : providerFilter; +} + +function resolveProviderCatalogAgentDir( + params: Omit & { agentDir?: string }, +): string { + return ( + params.agentDir ?? + (params.agentId + ? resolveAgentDir(params.cfg, params.agentId, params.env) + : resolveDefaultAgentDir(params.cfg, params.env)) + ); +} + +/** Returns true when the prepared generation contains rows for a provider filter. */ +export async function hasProviderRuntimeCatalogForFilter( + params: Omit & { agentDir?: string }, +): Promise { + const owner = await loadProviderCatalogSnapshot({ + ...params, + agentDir: resolveProviderCatalogAgentDir(params), }); - const cached = readCachedAgentModelCatalog({ - agentDir: params.agentDir, - catalogKey, - }) as Model[] | undefined; - if (cached?.length) { - return cached; - } + const providerFilter = resolveProviderFilter( + { ...params, agentDir: owner.agentDir }, + owner.metadataSnapshot, + ); + return owner.modelCatalog.entries.some( + (entry) => normalizeProviderId(entry.provider) === providerFilter, + ); +} - const providers = ( - await resolveRuntimePluginDiscoveryProviders({ - config: params.cfg, - env, - onlyPluginIds: scopedPluginIds, - includeUntrustedWorkspacePlugins: false, - requireCompleteDiscoveryEntryCoverage: params.staticOnly === true, - discoveryEntriesOnly: params.staticOnly === true, - pluginMetadataSnapshot: params.metadataSnapshot, - }) - ).filter( - (provider) => - typeof provider.pluginId === "string" && bundledPluginIdSet.has(provider.pluginId), +/** Returns true when the prepared generation captured static provider-hook rows. */ +export async function hasProviderStaticCatalogForFilter( + params: Omit & { agentDir?: string }, +): Promise { + const resolvedParams = { + ...params, + agentDir: resolveProviderCatalogAgentDir(params), + }; + const owner = await loadProviderCatalogSnapshot(resolvedParams, { readOnly: true }); + const providerFilter = resolveProviderFilter(resolvedParams, owner.metadataSnapshot); + return (owner.modelCatalog.staticEntries ?? []).some( + (entry) => !providerFilter || normalizeProviderId(entry.provider) === providerFilter, ); - const byOrder = groupPluginDiscoveryProvidersByOrder(providers); - const rows: Model[] = []; - const seen = new Set(); +} - for (const order of DISCOVERY_ORDERS) { - for (const provider of byOrder[order] ?? []) { - if (!providerFilter && SELF_HOSTED_DISCOVERY_PROVIDER_IDS.has(provider.id)) { - continue; +/** Projects provider rows from the committed model catalog without discovery or cache IO. */ +export async function loadProviderCatalogModelsForList( + params: ProviderCatalogListParams, +): Promise { + const owner = await loadProviderCatalogSnapshot(params, { + readOnly: params.staticOnly === true, + }); + const providerFilter = resolveProviderFilter(params, owner.metadataSnapshot); + const entries = params.staticOnly + ? (owner.modelCatalog.staticEntries ?? []) + : owner.modelCatalog.entries; + return entries + .filter((entry) => { + const provider = normalizeProviderId(entry.provider); + if (!providerFilter && SELF_HOSTED_DISCOVERY_PROVIDER_IDS.has(provider)) { + return false; } - let result: Awaited> | null; - try { - result = await runProviderCatalogForList({ - provider, - cfg: params.cfg, - agentDir: params.agentDir, - env, - staticOnly: params.staticOnly, - }); - } catch (error) { - log.warn(`provider catalog failed for ${provider.id}: ${formatErrorMessage(error)}`); - result = null; - } - const normalized = normalizePluginDiscoveryResult({ provider, result }); - for (const [providerIdRaw, providerConfig] of Object.entries(normalized)) { - const providerId = normalizeProviderId(providerIdRaw); - if (providerFilter && providerId !== providerFilter) { - continue; - } - if (!providerId || !Array.isArray(providerConfig.models)) { - continue; - } - for (const model of providerConfig.models) { - const key = `${providerId}/${model.id}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - rows.push( - modelFromProviderCatalog({ - provider: providerId, - providerConfig, - model, - }), - ); - } - } - } - } - - const sorted = rows.toSorted((left, right) => { - const provider = left.provider.localeCompare(right.provider); - if (provider !== 0) { - return provider; - } - return left.id.localeCompare(right.id); - }); - writeCachedAgentModelCatalog({ - agentDir: params.agentDir, - catalogKey, - entries: sorted, - }); - return sorted; + return !providerFilter || provider === providerFilter; + }) + .map((entry) => Object.assign({}, entry) as Model); } diff --git a/src/commands/models/list.registry-load.ts b/src/commands/models/list.registry-load.ts index dbd3a2547fa..8c6f7e84170 100644 --- a/src/commands/models/list.registry-load.ts +++ b/src/commands/models/list.registry-load.ts @@ -12,6 +12,8 @@ import { modelKey } from "./shared.js"; export async function loadListModelRegistry( cfg: OpenClawConfig, opts?: { + agentId?: string; + agentDir?: string; providerFilter?: string; normalizeModels?: boolean; loadAvailability?: boolean; @@ -48,20 +50,30 @@ function findConfiguredRegistryModel(params: { } /** Loads only configured registry entries and their auth availability. */ -export function loadConfiguredListModelRegistry( +export async function loadConfiguredListModelRegistry( cfg: OpenClawConfig, entries: ConfiguredEntry[], - opts?: { providerFilter?: string; workspaceDir?: string }, + opts?: { + agentId?: string; + agentDir?: string; + providerFilter?: string; + workspaceDir?: string; + }, ) { - const { registry } = loadAgentModelRegistry(cfg, { - workspaceDir: opts?.workspaceDir, - providerFilter: opts?.providerFilter, - }); + const registryOptions = { + ...(opts?.agentId ? { agentId: opts.agentId } : {}), + ...(opts?.agentDir ? { agentDir: opts.agentDir } : {}), + ...(opts?.workspaceDir ? { workspaceDir: opts.workspaceDir } : {}), + ...(opts?.providerFilter ? { providerFilter: opts.providerFilter } : {}), + }; + // Preparation and the synchronous fork must address the same credential-aware owner. + // Configured-only rows use registry auth state to report local availability. + const { config: runtimeConfig, registry } = await loadAgentModelRegistry(cfg, registryOptions); const discoveredKeys = new Set(); const availableKeys = new Set(); for (const entry of entries) { - const model = findConfiguredRegistryModel({ registry, entry, cfg }); + const model = findConfiguredRegistryModel({ registry, entry, cfg: runtimeConfig }); if (!model) { continue; } diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index e5205767acf..837e5cf7f80 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -88,6 +88,8 @@ function loadAvailableModels( export async function loadModelRegistry( cfg: OpenClawConfig, opts?: { + agentId?: string; + agentDir?: string; providerFilter?: string; normalizeModels?: boolean; loadAvailability?: boolean; @@ -95,8 +97,11 @@ export async function loadModelRegistry( }, ) { const runtimeSuppression = opts?.normalizeModels !== false; - const { registry } = loadAgentModelRegistry(cfg, { - skipCredentials: opts?.loadAvailability === false, + const skipDiscovery = opts?.loadAvailability === false; + const { config: runtimeConfig, registry } = await loadAgentModelRegistry(cfg, { + ...(opts?.agentId ? { agentId: opts.agentId } : {}), + ...(opts?.agentDir ? { agentDir: opts.agentDir } : {}), + skipCredentials: skipDiscovery, workspaceDir: opts?.workspaceDir, providerFilter: opts?.providerFilter, normalizeModels: opts?.normalizeModels, @@ -107,13 +112,13 @@ export async function loadModelRegistry( provider: model.provider, id: model.id, baseUrl: model.baseUrl, - config: cfg, + config: runtimeConfig, }) : !shouldSuppressBuiltInModelFromManifest({ provider: model.provider, id: model.id, baseUrl: model.baseUrl, - config: cfg, + config: runtimeConfig, }), ); let availableKeys: Set | undefined; @@ -121,7 +126,7 @@ export async function loadModelRegistry( if (opts?.loadAvailability !== false) { try { - const availableModels = loadAvailableModels(registry, cfg, { runtimeSuppression }); + const availableModels = loadAvailableModels(registry, runtimeConfig, { runtimeSuppression }); availableKeys = new Set(availableModels.map((model) => modelKey(model.provider, model.id))); } catch (err) { if (!shouldFallbackToAuthHeuristics(err)) { diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index 23eed4ce8d5..be2d009f7fa 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -16,8 +16,8 @@ vi.mock("../../agents/model-suppression.js", () => ({ shouldSuppressBuiltInModelFromManifest: mocks.shouldSuppressBuiltInModelFromManifest, })); -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalogSnapshot: mocks.loadModelCatalogSnapshot, +vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogSnapshot: mocks.loadModelCatalogSnapshot, })); vi.mock("../../plugins/provider-runtime.js", () => ({ @@ -658,6 +658,7 @@ describe("appendAuthenticatedCatalogRows", () => { context: { cfg: {}, agentDir: "/tmp/openclaw-agent", + workspaceDir: "/tmp/openclaw-workspace", authIndex: { evaluateModelAuth: () => ({ availability: undefined, @@ -677,6 +678,12 @@ describe("appendAuthenticatedCatalogRows", () => { local: true, available: true, }); + expect(mocks.loadModelCatalogSnapshot).toHaveBeenCalledWith({ + config: {}, + agentDir: "/tmp/openclaw-agent", + workspaceDir: "/tmp/openclaw-workspace", + readOnly: true, + }); }); it("still drops catalog rows with unresolved non-synthetic auth", async () => { diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 3bcf13d7d8b..91015aa3853 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -37,7 +37,7 @@ import { canonicalizeModelCatalogProviderAlias } from "./provider-aliases.js"; import { modelKey } from "./shared.js"; type ConfiguredByKey = Map; -type ModelCatalogModule = typeof import("../../agents/model-catalog.js"); +type ModelCatalogModule = typeof import("../../agents/prepared-model-catalog.js"); type ModelResolverModule = typeof import("../../agents/embedded-agent-runner/model.js"); type ProviderCatalogModule = typeof import("./list.provider-catalog.js"); @@ -49,6 +49,7 @@ type RowFilter = { /** Context shared by every model-list row source builder. */ export type RowBuilderContext = { cfg: OpenClawConfig; + agentId?: string; agentDir: string; authIndex: ModelListAuthIndex; availableKeys?: Set; @@ -61,7 +62,7 @@ export type RowBuilderContext = { }; const modelCatalogModuleLoader = createLazyImportLoader( - () => import("../../agents/model-catalog.js"), + () => import("../../agents/prepared-model-catalog.js"), ); const modelResolverModuleLoader = createLazyImportLoader( () => import("../../agents/embedded-agent-runner/model.js"), @@ -70,7 +71,7 @@ const providerCatalogModuleLoader = createLazyImportLoader import("./list.provider-catalog.js"), ); -function loadModelCatalogModule(): Promise { +function loadPreparedModelCatalogModule(): Promise { return modelCatalogModuleLoader.load(); } @@ -548,11 +549,18 @@ export async function appendAuthenticatedCatalogRows(params: { context: RowBuilderContext; seenKeys: Set; }): Promise { - const { loadModelCatalogSnapshot } = await loadModelCatalogModule(); - const { entries: catalog, routeVariants } = await loadModelCatalogSnapshot({ + const { loadPreparedModelCatalogSnapshot } = await loadPreparedModelCatalogModule(); + const { entries: catalog, routeVariants } = await loadPreparedModelCatalogSnapshot({ config: params.context.cfg, + ...(params.context.agentId ? { agentId: params.context.agentId } : {}), + agentDir: params.context.agentDir, + ...((params.context.workspaceDir ?? params.context.metadataSnapshot?.workspaceDir) + ? { + workspaceDir: + params.context.workspaceDir ?? params.context.metadataSnapshot?.workspaceDir, + } + : {}), readOnly: true, - metadataSnapshot: params.context.metadataSnapshot, }); const routeIndex = createModelCatalogLogicalRouteIndex(routeVariants); for (const entry of catalog) { @@ -633,13 +641,20 @@ export async function appendCatalogSupplementRows(params: { seenKeys: Set; }): Promise { const [modelCatalog, { resolveModelWithRegistry }] = await Promise.all([ - loadModelCatalogModule(), + loadPreparedModelCatalogModule(), loadModelResolverModule(), ]); - const { entries: catalog, routeVariants } = await modelCatalog.loadModelCatalogSnapshot({ + const { entries: catalog, routeVariants } = await modelCatalog.loadPreparedModelCatalogSnapshot({ config: params.context.cfg, + ...(params.context.agentId ? { agentId: params.context.agentId } : {}), + agentDir: params.context.agentDir, + ...((params.context.workspaceDir ?? params.context.metadataSnapshot?.workspaceDir) + ? { + workspaceDir: + params.context.workspaceDir ?? params.context.metadataSnapshot?.workspaceDir, + } + : {}), readOnly: true, - metadataSnapshot: params.context.metadataSnapshot, }); const routeIndex = createModelCatalogLogicalRouteIndex(routeVariants); for (const entry of catalog) { @@ -695,6 +710,7 @@ export async function appendProviderCatalogRows(params: { const { loadProviderCatalogModelsForList } = await loadProviderCatalogModule(); catalogModels = await loadProviderCatalogModelsForList({ cfg: params.context.cfg, + ...(params.context.agentId ? { agentId: params.context.agentId } : {}), agentDir: params.context.agentDir, providerFilter: params.context.filter.provider, staticOnly: params.staticOnly, diff --git a/src/commands/models/list.source-plan.ts b/src/commands/models/list.source-plan.ts index 3b05a8cf2d8..1f14cc71bd5 100644 --- a/src/commands/models/list.source-plan.ts +++ b/src/commands/models/list.source-plan.ts @@ -72,6 +72,8 @@ export async function planAllModelListSources(params: { enableCascade?: boolean; providerFilter?: string; cfg: OpenClawConfig; + agentId?: string; + agentDir?: string; metadataSnapshot?: PluginMetadataSnapshot; dependencies?: Partial; }): Promise { @@ -128,6 +130,8 @@ export async function planAllModelListSources(params: { const hasProviderRuntimeCatalog = await hasProviderRuntimeCatalogForFilter({ cfg: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), providerFilter: params.providerFilter, metadataSnapshot: params.metadataSnapshot, }); @@ -172,6 +176,8 @@ export async function planAllModelListSources(params: { const hasProviderStaticCatalog = await hasProviderStaticCatalogForFilter({ cfg: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), providerFilter: params.providerFilter, metadataSnapshot: params.metadataSnapshot, }); diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 3c924dd8b19..5eb9ede05b5 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -38,7 +38,6 @@ import { resolveProviderEnvAuthLookupMaps, } from "../../agents/model-auth-env-vars.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; -import { loadModelCatalogSnapshot } from "../../agents/model-catalog.js"; import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; import { modelCatalogLogicalKey, @@ -53,6 +52,7 @@ import { resolveModelRefFromString, } from "../../agents/model-selection.js"; import { OPENAI_PROVIDER_ID } from "../../agents/openai-routing.js"; +import { loadPreparedModelCatalogSnapshot } from "../../agents/prepared-model-catalog.js"; import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"; import { readUtilityModelSetting, @@ -613,10 +613,10 @@ export async function modelsStatusCommand( registryDiagnostics: metadataSnapshot.registryDiagnostics, }).map((provider) => normalizeProviderId(provider)), ); - const catalog = await loadModelCatalogSnapshot({ + const catalog = await loadPreparedModelCatalogSnapshot({ config: cfg, + ...(agentId ? { agentId } : {}), readOnly: true, - metadataSnapshot, }); const routeSourcesByModel = new Map< string, diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 453b63a638e..39f087e24e1 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -291,8 +291,8 @@ vi.mock("../../agents/harness/runtime-plugin.js", () => ({ vi.mock("../../cli/update-cli/plugin-payload-validation.js", () => ({ runPluginPayloadSmokeCheckForManifestRecords: mocks.runPluginPayloadSmokeCheckForManifestRecords, })); -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalogSnapshot: async (...args: unknown[]) => { +vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await mocks.loadModelCatalog(...args); return { entries, routeVariants: mocks.modelCatalogRouteVariants ?? entries }; }, diff --git a/src/commands/plugin-control-plane-cold-imports.test.ts b/src/commands/plugin-control-plane-cold-imports.test.ts index f3707d3ae79..2e568569f75 100644 --- a/src/commands/plugin-control-plane-cold-imports.test.ts +++ b/src/commands/plugin-control-plane-cold-imports.test.ts @@ -1,6 +1,5 @@ // Plugin control-plane cold-import tests guard setup and plugin metadata paths against runtime-heavy imports. import { afterEach, describe, expect, it } from "vitest"; -import { refreshPluginRegistry } from "../plugins/plugin-registry.js"; import { createColdPluginConfig, createColdPluginFixture, @@ -10,7 +9,6 @@ import { import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js"; import { buildAuthChoiceGroups, formatAuthChoiceChoicesForCli } from "./auth-choice-options.js"; import { listManifestInstalledChannelIds } from "./channel-setup/discovery.js"; -import { resolveProviderCatalogPluginIdsForFilter } from "./models/list.provider-catalog.js"; const tempDirs: string[] = []; @@ -65,28 +63,4 @@ describe("command control-plane plugin discovery", () => { ).toContain(plugin.authChoiceId); expect(isColdPluginRuntimeLoaded(plugin)).toBe(false); }); - - it("resolves models-list provider ownership without importing plugin runtime", async () => { - const plugin = createColdPluginFixture({ rootDir: makeTempDir() }); - const workspaceDir = makeTempDir(); - const cfg = createColdPluginConfig(plugin.rootDir, plugin.pluginId); - const env = createColdPluginHermeticEnv(workspaceDir, { disablePersistedRegistry: false }); - - await refreshPluginRegistry({ - config: cfg, - workspaceDir, - env, - reason: "manual", - }); - expect(isColdPluginRuntimeLoaded(plugin)).toBe(false); - - await expect( - resolveProviderCatalogPluginIdsForFilter({ - cfg, - env, - providerFilter: plugin.providerId, - }), - ).resolves.toEqual([plugin.pluginId]); - expect(isColdPluginRuntimeLoaded(plugin)).toBe(false); - }); }); diff --git a/src/cron/isolated-agent.hook-content-wrapping.test.ts b/src/cron/isolated-agent.hook-content-wrapping.test.ts index 9f177e8f646..02f6212495c 100644 --- a/src/cron/isolated-agent.hook-content-wrapping.test.ts +++ b/src/cron/isolated-agent.hook-content-wrapping.test.ts @@ -2,7 +2,7 @@ import "./isolated-agent.mocks.js"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runEmbeddedAgent } from "../agents/embedded-agent.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; +import { loadPreparedModelCatalog } from "../agents/prepared-model-catalog.js"; import { makeCfg } from "./isolated-agent.test-harness.js"; import { DEFAULT_MESSAGE, @@ -27,7 +27,7 @@ describe("runCronIsolatedAgentTurn hook content wrapping", () => { beforeAll(async () => { process.env.OPENCLAW_TEST_FAST = "1"; vi.spyOn(isolatedAgentRunRuntime, "resolveThinkingDefault").mockReturnValue("off"); - vi.mocked(loadModelCatalog).mockResolvedValue([]); + vi.mocked(loadPreparedModelCatalog).mockResolvedValue([]); await withTempHome(async (home) => { await runCronTurn(home, { jobPayload: { kind: "agentTurn", message: "warm runtime" }, @@ -41,7 +41,7 @@ describe("runCronIsolatedAgentTurn hook content wrapping", () => { process.env.OPENCLAW_TEST_FAST = "1"; vi.spyOn(isolatedAgentRunRuntime, "resolveThinkingDefault").mockReturnValue("off"); vi.mocked(runEmbeddedAgent).mockClear(); - vi.mocked(loadModelCatalog).mockResolvedValue([]); + vi.mocked(loadPreparedModelCatalog).mockResolvedValue([]); }); it("wraps external hook content by default", async () => { @@ -91,6 +91,7 @@ describe("runCronIsolatedAgentTurn hook content wrapping", () => { const resolved = await resolveCronModelSelection({ cfg, + catalogConfig: cfg, cfgWithAgentDefaults: cfg, sessionEntry: {}, payload: { @@ -100,6 +101,8 @@ describe("runCronIsolatedAgentTurn hook content wrapping", () => { }, isGmailHook: true, agentId: "main", + agentDir: `${home}/agents/main/agent`, + workspaceDir: `${home}/workspace`, }); expect(resolved).toEqual({ diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index 83e5c37406d..a9fa312593f 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -10,8 +10,8 @@ vi.mock("../agents/embedded-agent.js", () => ({ resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, })); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(), +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: vi.fn(), })); vi.mock("../agents/model-selection.js", async () => { diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index d766ec6b017..bc9791f5f78 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -36,7 +36,7 @@ vi.mock("./isolated-agent/run-model-selection.runtime.js", () => ({ DEFAULT_MODEL: "claude-opus-4-6", DEFAULT_PROVIDER: "anthropic", getModelRefStatus: getModelRefStatusMock, - loadModelCatalog: loadModelCatalogMock, + loadPreparedModelCatalog: loadModelCatalogMock, normalizeModelSelection: normalizeModelSelectionMock, resolveAllowedModelRef: resolveAllowedModelRefMock, resolveConfiguredModelRef: resolveConfiguredModelRefMock, @@ -138,12 +138,15 @@ async function selectModel(options: SelectModelOptions = {}) { const cfg = options.cfg ?? {}; return resolveCronModelSelection({ cfg: cfg as never, + catalogConfig: cfg as never, cfgWithAgentDefaults: (options.cfgWithAgentDefaults ?? cfg) as never, agentConfigOverride: options.agentConfigOverride, sessionEntry: options.sessionEntry ?? {}, payload: options.payload ?? defaultPayload(), isGmailHook: options.isGmailHook ?? false, agentId: options.agentId, + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", }); } diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index 59cd73f9bdd..5d55053f80a 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -2,7 +2,7 @@ import { expectDefined } from "@openclaw/normalization-core"; // Isolated agent test setup centralizes common mocks for cron agent tests. import { vi } from "vitest"; import { runEmbeddedAgent } from "../agents/embedded-agent.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; +import { loadPreparedModelCatalog } from "../agents/prepared-model-catalog.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { ChannelOutboundAdapter, @@ -168,7 +168,7 @@ export function setupIsolatedAgentTurnMocks(params?: { fast?: boolean }): void { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); } vi.mocked(runEmbeddedAgent).mockReset(); - vi.mocked(loadModelCatalog).mockResolvedValue([]); + vi.mocked(loadPreparedModelCatalog).mockResolvedValue([]); vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true); vi.mocked(callGateway).mockReset().mockResolvedValue({ ok: true, deleted: true }); setActivePluginRegistry( diff --git a/src/cron/isolated-agent/model-selection.ts b/src/cron/isolated-agent/model-selection.ts index 0a9acef21b5..fca8c52a22d 100644 --- a/src/cron/isolated-agent/model-selection.ts +++ b/src/cron/isolated-agent/model-selection.ts @@ -7,7 +7,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER, getModelRefStatus, - loadModelCatalog, + loadPreparedModelCatalog, normalizeModelSelection, resolveAllowedModelRef, resolveConfiguredModelRef, @@ -25,12 +25,15 @@ type CronModelSelectionSource = "default" | "subagent" | "agent" | "hook" | "pay /** Inputs used to resolve the model for one isolated cron run. */ type ResolveCronModelSelectionParams = { cfg: OpenClawConfig; + catalogConfig: OpenClawConfig; cfgWithAgentDefaults: OpenClawConfig; agentConfigOverride?: Pick; sessionEntry: CronSessionModelOverrides; payload: CronJob["payload"]; isGmailHook: boolean; agentId?: string; + agentDir: string; + workspaceDir: string; }; /** Resolved provider/model pair plus the precedence source that selected it. */ @@ -83,10 +86,16 @@ export async function resolveCronModelSelection( let model = resolvedDefault.model; let modelSource: CronModelSelectionSource = "default"; - let catalog: Awaited> | undefined; + let catalog: Awaited> | undefined; const loadCatalogOnce = async () => { if (!catalog) { - catalog = await loadModelCatalog({ config: params.cfgWithAgentDefaults }); + catalog = await loadPreparedModelCatalog({ + config: params.catalogConfig, + agentId: params.agentId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + readOnly: true, + }); } return catalog; }; diff --git a/src/cron/isolated-agent/run-model-catalog.runtime.ts b/src/cron/isolated-agent/run-model-catalog.runtime.ts index 6dd11417c16..0eafbf9c048 100644 --- a/src/cron/isolated-agent/run-model-catalog.runtime.ts +++ b/src/cron/isolated-agent/run-model-catalog.runtime.ts @@ -1,2 +1,2 @@ // Runtime model catalog seam for isolated cron agent model resolution. -export { loadModelCatalog } from "../../agents/model-catalog.js"; +export { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js"; diff --git a/src/cron/isolated-agent/run-model-selection.runtime.ts b/src/cron/isolated-agent/run-model-selection.runtime.ts index 62aacf0ae84..f84908e6e95 100644 --- a/src/cron/isolated-agent/run-model-selection.runtime.ts +++ b/src/cron/isolated-agent/run-model-selection.runtime.ts @@ -1,7 +1,7 @@ // Runtime model-selection seam for isolated cron agent runs. export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; export { resolveSubagentModelConfigSelectionResult } from "../../agents/agent-scope.js"; -export { loadModelCatalog } from "../../agents/model-catalog.js"; +export { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js"; export { getModelRefStatus, normalizeModelSelection, diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 85500b7e8c7..be2fa2c261b 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -178,7 +178,7 @@ vi.mock("./run-context.runtime.js", () => ({ })); vi.mock("./run-model-catalog.runtime.js", () => ({ - loadModelCatalog: loadModelCatalogMock, + loadPreparedModelCatalog: loadModelCatalogMock, })); vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({ @@ -230,7 +230,7 @@ vi.mock("../../skills/runtime/cron-snapshot.runtime.js", () => ({ vi.mock("./run-model-selection.runtime.js", () => ({ DEFAULT_MODEL: "gpt-5.4", DEFAULT_PROVIDER: "openai", - loadModelCatalog: loadModelCatalogMock, + loadPreparedModelCatalog: loadModelCatalogMock, getModelRefStatus: getModelRefStatusMock, normalizeModelSelection: normalizeModelSelectionForTest, resolveAllowedModelRef: resolveAllowedModelRefMock, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 655f5dc4fe6..ec6aa66ae85 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -638,13 +638,16 @@ async function prepareCronRunContext(params: { ...runtimeCfg, agents: Object.assign({}, runtimeCfg.agents, { defaults: agentCfg }), }; - let catalog: Awaited> | undefined; + let catalog: Awaited> | undefined; const loadCatalog = async () => { if (!catalog) { catalog = await ( await loadCronModelCatalogRuntime() - ).loadModelCatalog({ - config: cfgWithAgentDefaults, + ).loadPreparedModelCatalog({ + config: runtimeCfg, + agentId, + agentDir, + readOnly: true, }); } return catalog; @@ -680,8 +683,8 @@ async function prepareCronRunContext(params: { const hookExternalContentSource = payloadHookExternalContentSource ?? resolveHookExternalContentSource(baseSessionKey); - const workspaceDirRaw = resolveAgentWorkspaceDir(input.cfg, agentId); - const agentDir = resolveAgentDir(input.cfg, agentId); + const workspaceDirRaw = resolveAgentWorkspaceDir(runtimeCfg, agentId); + const agentDir = resolveAgentDir(runtimeCfg, agentId); const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !agentCfg?.skipBootstrap && !params.isFastTestEnv, @@ -773,12 +776,15 @@ async function prepareCronRunContext(params: { // Authorization needs the unflattened active config so inherited policy // aliases cannot be rebound by the selected agent's metadata aliases. cfg: runtimeCfg, + catalogConfig: runtimeCfg, cfgWithAgentDefaults, agentConfigOverride, sessionEntry: cronSession.sessionEntry, payload: input.job.payload, isGmailHook, agentId, + agentDir, + workspaceDir, }); if (!resolvedModelSelection.ok) { return { diff --git a/src/flows/doctor-core-checks.runtime-errors.test.ts b/src/flows/doctor-core-checks.runtime-errors.test.ts index 8b14e99a88d..5c273f3e122 100644 --- a/src/flows/doctor-core-checks.runtime-errors.test.ts +++ b/src/flows/doctor-core-checks.runtime-errors.test.ts @@ -18,7 +18,10 @@ vi.mock("../agents/model-catalog.js", () => ({ provider: string, modelId: string, ) => catalog.find((entry) => entry.provider === provider && entry.id === modelId), - loadModelCatalog: mocks.loadModelCatalog, +})); + +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: mocks.loadModelCatalog, })); vi.mock("../agents/model-selection.js", async (importOriginal) => ({ diff --git a/src/flows/doctor-core-checks.runtime.test.ts b/src/flows/doctor-core-checks.runtime.test.ts index 69ae03b0e26..05186759890 100644 --- a/src/flows/doctor-core-checks.runtime.test.ts +++ b/src/flows/doctor-core-checks.runtime.test.ts @@ -23,7 +23,10 @@ vi.mock("../agents/model-catalog.js", () => ({ provider: string, modelId: string, ) => catalog.find((entry) => entry.provider === provider && entry.id === modelId), - loadModelCatalog: mocks.loadModelCatalog, +})); + +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: mocks.loadModelCatalog, })); vi.mock("../agents/model-selection.js", async (importOriginal) => ({ @@ -375,6 +378,15 @@ describe("doctor runtime tool schema checks", () => { expect(mocks.createOpenClawCodingTools).toHaveBeenCalledWith( expect.objectContaining({ agentId: "worker", toolPolicyAuditLogLevel: "debug" }), ); + expect(mocks.loadModelCatalog).toHaveBeenCalledTimes(2); + expect(mocks.loadModelCatalog).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ agentId: "main" }), + ); + expect(mocks.loadModelCatalog).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ agentId: "worker" }), + ); expect(mocks.createBundleMcpToolRuntime).toHaveBeenCalledTimes(1); expect(mocks.disposeBundleRuntime).toHaveBeenCalledTimes(1); }); diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts index 267ce83528f..d6e05ea24f7 100644 --- a/src/flows/doctor-core-checks.runtime.ts +++ b/src/flows/doctor-core-checks.runtime.ts @@ -8,6 +8,7 @@ import { import { listAgentEntries, listAgentIds, + resolveAgentDir, resolveDefaultAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId, @@ -18,13 +19,10 @@ import { resolveConversationCapabilityProfile } from "../agents/conversation-cap import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { applyFinalEffectiveToolPolicy } from "../agents/embedded-agent-runner/effective-tool-policy.js"; import { shouldCreateBundleMcpRuntimeForAttempt } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js"; -import { - findModelInCatalog, - loadModelCatalog, - type ModelCatalogEntry, -} from "../agents/model-catalog.js"; +import { findModelInCatalog, type ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { supportsModelTools } from "../agents/model-tool-support.js"; +import { loadPreparedModelCatalog } from "../agents/prepared-model-catalog.js"; import { normalizeAgentRuntimeTools } from "../agents/runtime-plan/tools.js"; import { collectExplicitAllowlist, normalizeToolName } from "../agents/tool-policy.js"; import { @@ -1080,7 +1078,6 @@ function isAcpRuntimeAgent(cfg: OpenClawConfig, agentId: string): boolean { export async function collectRuntimeToolSchemaFindings( cfg: OpenClawConfig, ): Promise { - const catalog = await loadModelCatalog({ config: cfg }); const findings: HealthFinding[] = []; const bundleRuntimeByWorkspace = new Map(); const bundleRuntimeLoadErrorsByWorkspace = new Map(); @@ -1090,6 +1087,11 @@ export async function collectRuntimeToolSchemaFindings( if (isAcpRuntimeAgent(cfg, agentId)) { continue; } + const catalog = await loadPreparedModelCatalog({ + config: cfg, + agentId, + agentDir: resolveAgentDir(cfg, agentId), + }); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const modelRef = resolveDefaultModelForAgent({ cfg, diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index 3679c3b103e..6b00c5d4289 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -28,8 +28,8 @@ const mocks = vi.hoisted(() => ({ extraGatewayServiceToRepairEffects: vi.fn((): readonly HealthRepairEffect[] => []), })); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: mocks.loadModelCatalog, +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: mocks.loadModelCatalog, })); vi.mock("../commands/doctor-gateway-services.js", () => ({ diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index 05597bdf0e4..13a629cd993 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -377,7 +377,7 @@ const hooksModelCheck: HealthCheck = { return []; } const { DEFAULT_MODEL, DEFAULT_PROVIDER } = await import("../agents/defaults.js"); - const { loadModelCatalog } = await import("../agents/model-catalog.js"); + const { loadPreparedModelCatalog } = await import("../agents/prepared-model-catalog.js"); const { getModelRefStatus, resolveConfiguredModelRef, resolveHooksGmailModel } = await import("../agents/model-selection.js"); const hooksModelRef = resolveHooksGmailModel({ @@ -399,7 +399,7 @@ const hooksModelCheck: HealthCheck = { defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); - const catalog = await loadModelCatalog({ config: ctx.cfg, readOnly: true }); + const catalog = await loadPreparedModelCatalog({ config: ctx.cfg, readOnly: true }); const status = getModelRefStatus({ cfg: ctx.cfg, catalog, diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index c1a94e271f4..d58df877c3b 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -338,10 +338,13 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ })); vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: mocks.loadModelCatalog, findModelCatalogEntry: mocks.findModelCatalogEntry, })); +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: mocks.loadModelCatalog, +})); + vi.mock("../agents/model-selection.js", () => ({ getModelRefStatus: mocks.getModelRefStatus, resolveConfiguredModelRef: mocks.resolveConfiguredModelRef, diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 4e85a8002ac..1cfa488f7bd 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -82,7 +82,9 @@ const loadDoctorCoreChecksModule = async () => await import("./doctor-core-check const loadDoctorStateIntegrityModule = async () => await import("../commands/doctor-state-integrity.js"); const loadHealthCheckRegistryModule = async () => await import("./health-check-registry.js"); -const loadModelCatalogModule = async () => await import("../agents/model-catalog.js"); +const loadCatalogLookupModule = async () => await import("../agents/model-catalog.js"); +const loadPreparedModelCatalogModule = async () => + await import("../agents/prepared-model-catalog.js"); const loadModelSelectionModule = async () => await import("../agents/model-selection.js"); const loadNoteModule = async () => await import("../../packages/terminal-core/src/note.js"); const loadOnboardHelpersModule = async () => await import("../commands/onboard-helpers.js"); @@ -795,7 +797,7 @@ async function runHooksModelHealth(ctx: DoctorHealthFlowContext): Promise return; } const { DEFAULT_MODEL, DEFAULT_PROVIDER } = await loadAgentDefaultsModule(); - const { loadModelCatalog } = await loadModelCatalogModule(); + const { loadPreparedModelCatalog } = await loadPreparedModelCatalogModule(); const { getModelRefStatus, resolveConfiguredModelRef, resolveHooksGmailModel } = await loadModelSelectionModule(); const { note } = await loadNoteModule(); @@ -812,7 +814,7 @@ async function runHooksModelHealth(ctx: DoctorHealthFlowContext): Promise defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); - const catalog = await loadModelCatalog({ config: ctx.cfg, readOnly: true }); + const catalog = await loadPreparedModelCatalog({ config: ctx.cfg, readOnly: true }); const status = getModelRefStatus({ cfg: ctx.cfg, catalog, @@ -912,10 +914,11 @@ async function collectToolResultCapTargetAdvice(params: { }> > { const { DEFAULT_CONTEXT_TOKENS } = await loadAgentDefaultsModule(); - const { loadModelCatalog, findModelCatalogEntry } = await loadModelCatalogModule(); + const { findModelCatalogEntry } = await loadCatalogLookupModule(); + const { loadPreparedModelCatalog } = await loadPreparedModelCatalogModule(); const { resolveContextWindowInfo } = await import("../agents/context-window-guard.js"); const { resolveDefaultModelForAgent, modelKey } = await loadModelSelectionModule(); - const catalog = await loadModelCatalog({ + const catalog = await loadPreparedModelCatalog({ config: params.cfg, ...(params.readOnlyCatalog ? { readOnly: true } : {}), }); diff --git a/src/flows/model-picker.provider-catalog.test.ts b/src/flows/model-picker.provider-catalog.test.ts index b47143ad848..89cb5a4a1d2 100644 --- a/src/flows/model-picker.provider-catalog.test.ts +++ b/src/flows/model-picker.provider-catalog.test.ts @@ -1,165 +1,69 @@ -// Model picker provider catalog tests cover catalog-driven provider options. -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ModelDefinitionConfig } from "../config/types.models.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { ProviderPlugin } from "../plugins/types.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -function textModel(id: string, name: string): ModelDefinitionConfig { - return { - id, - name, - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128_000, - maxTokens: 8_192, - }; -} +const mocks = vi.hoisted(() => ({ loadPreparedModelCatalogOwnerSnapshot: vi.fn() })); -const providerDiscoveryMocks = vi.hoisted(() => ({ - providerMatchesFilter: vi.fn( - ({ - provider, - providerFilter, - }: { - provider: Pick; - providerFilter: string; - }) => - [provider.id, ...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some( - (providerId) => providerId.trim().toLowerCase() === providerFilter, - ), - ), - resolveRuntimePluginDiscoveryProviders: vi.fn<() => Promise>(), - runProviderCatalog: vi.fn( - async ({ provider, ...ctx }: { provider: ProviderPlugin } & Record) => - provider.catalog?.run(ctx as never), - ), - normalizePluginDiscoveryResult: vi.fn( - ({ - provider, - result, - }: { - provider: ProviderPlugin; - result: - | { provider: { models?: unknown[] } } - | { providers: Record } - | null - | undefined; - }) => { - if (!result) { - return {}; - } - if ("provider" in result) { - return { [provider.id]: result.provider }; - } - return result.providers; - }, - ), - groupPluginDiscoveryProvidersByOrder: vi.fn((providers: ProviderPlugin[]) => ({ - simple: providers, - profile: [], - paired: [], - late: [], - })), +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogOwnerSnapshot: mocks.loadPreparedModelCatalogOwnerSnapshot, })); -const providersRuntimeMocks = vi.hoisted(() => ({ - resolvePluginProviders: vi.fn<() => ProviderPlugin[]>(), -})); - -const providerCatalogListMocks = vi.hoisted(() => ({ - resolveProviderCatalogPluginIdsForFilter: vi.fn(async () => ["nvidia"]), -})); - -vi.mock("../plugins/provider-discovery.js", () => providerDiscoveryMocks); -vi.mock("../plugins/providers.runtime.js", () => providersRuntimeMocks); -vi.mock("../commands/models/list.provider-catalog.js", () => providerCatalogListMocks); -vi.mock("../agents/auth-profiles.js", () => ({ - ensureAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({ profiles: {} })), -})); - -const { loadPreferredProviderPickerCatalog } = await import("./model-picker.provider-catalog.js"); +import { loadPreferredProviderPickerCatalog } from "./model-picker.provider-catalog.js"; describe("loadPreferredProviderPickerCatalog", () => { - afterEach(() => { - vi.clearAllMocks(); + beforeEach(() => { + mocks.loadPreparedModelCatalogOwnerSnapshot.mockReset(); }); - it("loads the full live provider when manifest static discovery masks the runtime catalog", async () => { - const manifestStaticProvider = { - id: "nvidia", - label: "nvidia", - auth: [], - staticCatalog: { - run: async () => ({ - provider: { - baseUrl: "https://static.invalid/v1", - models: [textModel("minimaxai/minimax-m2.5", "Static MiniMax M2.5")], - }, - }), + it("filters one committed generation by preferred provider", async () => { + mocks.loadPreparedModelCatalogOwnerSnapshot.mockResolvedValue({ + metadataSnapshot: { manifestRegistry: { plugins: [] } }, + modelCatalog: { + entries: [ + { provider: "nvidia", id: "nvidia/nemotron", name: "Nemotron" }, + { provider: "openai", id: "gpt-5.4", name: "GPT-5.4" }, + ], }, - } satisfies ProviderPlugin; - const liveProvider = { - id: "nvidia", - label: "NVIDIA", - envVars: ["NVIDIA_API_KEY"], - auth: [], - catalog: { - run: async (ctx) => { - expect(ctx.resolveProviderApiKey("nvidia")).toEqual({ - apiKey: "nvapi-test", - discoveryApiKey: "nvapi-test", - }); - return { - provider: { - baseUrl: "https://integrate.api.nvidia.com/v1", - api: "openai-completions", - models: [ - textModel("nvidia/nemotron-3-super-120b-a12b", "Nemotron"), - textModel("minimaxai/minimax-m2.7", "MiniMax M2.7"), - ], + }); + + await expect( + loadPreferredProviderPickerCatalog({ + cfg: {}, + preferredProvider: "NVIDIA", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + env: { NVIDIA_API_KEY: "test-nvidia-api-key" }, + }), + ).resolves.toEqual([{ provider: "nvidia", id: "nvidia/nemotron", name: "Nemotron" }]); + expect(mocks.loadPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledWith({ + config: {}, + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + env: { NVIDIA_API_KEY: "test-nvidia-api-key" }, + }); + }); + + it("matches preferred provider aliases from the prepared metadata generation", async () => { + mocks.loadPreparedModelCatalogOwnerSnapshot.mockResolvedValue({ + metadataSnapshot: { + manifestRegistry: { + plugins: [ + { + id: "moonshot", + modelCatalog: { aliases: { kimi: { provider: "moonshot" } } }, }, - }; + ], }, }, - } satisfies ProviderPlugin; - providerDiscoveryMocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([ - manifestStaticProvider, - ]); - providersRuntimeMocks.resolvePluginProviders.mockReturnValue([liveProvider]); - - const rows = await loadPreferredProviderPickerCatalog({ - cfg: {} as OpenClawConfig, - preferredProvider: "nvidia", - env: { NVIDIA_API_KEY: "nvapi-test" }, + modelCatalog: { + entries: [{ provider: "moonshot", id: "kimi-k2.6", name: "Kimi K2.6" }], + }, }); - expect(rows.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([ - "nvidia/nvidia/nemotron-3-super-120b-a12b", - "nvidia/minimaxai/minimax-m2.7", - ]); - expect(rows.map((entry) => entry.id)).not.toContain("minimaxai/minimax-m2.5"); - expect(rows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - api: "openai-completions", - baseUrl: "https://integrate.api.nvidia.com/v1", - }), - ]), - ); - expect(providersRuntimeMocks.resolvePluginProviders).toHaveBeenCalledWith({ - config: {}, - env: { NVIDIA_API_KEY: "nvapi-test" }, - onlyPluginIds: ["nvidia"], - includeUntrustedWorkspacePlugins: false, - mode: "setup", - activate: false, - cache: false, - }); + await expect( + loadPreferredProviderPickerCatalog({ + cfg: {}, + preferredProvider: "kimi", + agentDir: "/tmp/agent", + }), + ).resolves.toEqual([{ provider: "moonshot", id: "kimi-k2.6", name: "Kimi K2.6" }]); }); }); diff --git a/src/flows/model-picker.provider-catalog.ts b/src/flows/model-picker.provider-catalog.ts index 45afc518448..0fc3fb4ddb0 100644 --- a/src/flows/model-picker.provider-catalog.ts +++ b/src/flows/model-picker.provider-catalog.ts @@ -1,140 +1,14 @@ -// Model picker provider catalog helpers build provider choices from catalog data. +// Model picker provider choices projected from the lifecycle-owned catalog. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; -import { ensureAuthProfileStoreWithoutExternalProfiles } from "../agents/auth-profiles.js"; -import type { ModelCatalogEntry } from "../agents/model-catalog.js"; -import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; import { - createProviderApiKeyResolver, - createProviderAuthResolver, -} from "../agents/models-config.providers.secrets.js"; -import { resolveProviderCatalogPluginIdsForFilter } from "../commands/models/list.provider-catalog.js"; -import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types.models.js"; + canonicalizePreparedModelCatalogProvider, + type ModelCatalogEntry, +} from "../agents/model-catalog.js"; +import { loadPreparedModelCatalogOwnerSnapshot } from "../agents/prepared-model-catalog.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { - groupPluginDiscoveryProvidersByOrder, - normalizePluginDiscoveryResult, - providerMatchesFilter, - resolveRuntimePluginDiscoveryProviders, - runProviderCatalog, -} from "../plugins/provider-discovery.js"; -import type { ProviderPlugin } from "../plugins/types.js"; -// Loads live provider model catalogs for the preferred-provider model picker. -const log = createSubsystemLogger("model-picker-provider-catalog"); -const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const; - -function positiveNumber(value: number | undefined): number | undefined { - return typeof value === "number" && value > 0 ? value : undefined; -} - -function providerAuthIds(provider: ProviderPlugin): string[] { - return [provider.id, ...(provider.aliases ?? []), ...(provider.hookAliases ?? [])] - .map(normalizeProviderId) - .filter(Boolean); -} - -function hasLiveProviderCatalog(provider: ProviderPlugin): boolean { - return ( - typeof provider.catalog?.run === "function" || typeof provider.discovery?.run === "function" - ); -} - -async function resolvePreferredProviderLiveCatalogProviders(params: { - cfg: OpenClawConfig; - env: NodeJS.ProcessEnv; - onlyPluginIds: string[]; - providerFilter: string; - workspaceDir?: string; -}): Promise { - const providers = ( - await resolveRuntimePluginDiscoveryProviders({ - config: params.cfg, - env: params.env, - onlyPluginIds: params.onlyPluginIds, - includeUntrustedWorkspacePlugins: false, - ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), - }) - ).filter((provider) => - providerMatchesFilter({ provider, providerFilter: params.providerFilter }), - ); - const liveProviders = providers.filter(hasLiveProviderCatalog); - if (liveProviders.length > 0) { - return liveProviders; - } - - // Fallback activates setup-mode providers only when discovery returned no live catalog runner. - const { resolvePluginProviders } = await import("../plugins/providers.runtime.js"); - return resolvePluginProviders({ - config: params.cfg, - env: params.env, - onlyPluginIds: params.onlyPluginIds, - includeUntrustedWorkspacePlugins: false, - mode: "setup", - activate: false, - cache: false, - ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), - }).filter( - (provider) => - providerMatchesFilter({ provider, providerFilter: params.providerFilter }) && - hasLiveProviderCatalog(provider), - ); -} - -function resolveProviderEnvApiKey( - provider: ProviderPlugin, - env: NodeJS.ProcessEnv, -): - | { - apiKey: string; - discoveryApiKey?: string; - } - | undefined { - for (const envVar of provider.envVars ?? []) { - const normalized = envVar.trim(); - const value = env[normalized]?.trim(); - if (normalized && value) { - return { - apiKey: value, - discoveryApiKey: value, - }; - } - } - return undefined; -} - -// Converts provider plugin catalog rows into the model picker shape without losing window metadata. -function modelFromProviderCatalog(params: { - provider: string; - providerConfig: ModelProviderConfig; - model: ModelDefinitionConfig; -}): ModelCatalogEntry { - const id = normalizeConfiguredProviderCatalogModelId(params.provider, params.model.id); - const contextWindow = - positiveNumber(params.model.contextWindow) ?? - positiveNumber(params.providerConfig.contextWindow); - const contextTokens = - positiveNumber(params.model.contextTokens) ?? - positiveNumber(params.providerConfig.contextTokens); - const api = params.model.api ?? params.providerConfig.api; - const baseUrl = params.model.baseUrl ?? params.providerConfig.baseUrl; - return { - id, - name: params.model.name || id, - provider: params.provider, - ...(api !== undefined ? { api } : {}), - ...(baseUrl !== undefined ? { baseUrl } : {}), - ...(contextWindow !== undefined ? { contextWindow } : {}), - ...(contextTokens !== undefined ? { contextTokens } : {}), - reasoning: params.model.reasoning, - input: params.model.input, - ...(params.model.compat ? { compat: params.model.compat } : {}), - }; -} - -/** Loads live catalog models for the user's preferred provider, ordered by discovery priority. */ +/** Loads committed catalog models for the user's preferred provider. */ export async function loadPreferredProviderPickerCatalog(params: { cfg: OpenClawConfig; preferredProvider: string; @@ -142,102 +16,21 @@ export async function loadPreferredProviderPickerCatalog(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): Promise { - const env = params.env ?? process.env; - const agentDir = params.agentDir ?? resolveDefaultAgentDir(params.cfg, env); - const providerFilter = normalizeProviderId(params.preferredProvider); - if (!providerFilter) { + const requestedProvider = normalizeProviderId(params.preferredProvider); + if (!requestedProvider) { return []; } - - const onlyPluginIds = await resolveProviderCatalogPluginIdsForFilter({ - cfg: params.cfg, - env, - providerFilter, + const owner = await loadPreparedModelCatalogOwnerSnapshot({ + config: params.cfg, + agentDir: params.agentDir ?? resolveDefaultAgentDir(params.cfg, params.env), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.env ? { env: params.env } : {}), }); - if (!onlyPluginIds || onlyPluginIds.length === 0) { - return []; - } - - const providers = await resolvePreferredProviderLiveCatalogProviders({ - cfg: params.cfg, - env, - onlyPluginIds, - providerFilter, - ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), - }); - if (providers.length === 0) { - return []; - } - - let authStore: ReturnType | undefined; - const getAuthStore = () => - (authStore ??= ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { - allowKeychainPrompt: false, - })); - const resolveProviderApiKey = createProviderApiKeyResolver(env, getAuthStore, params.cfg); - const resolveProviderAuth = createProviderAuthResolver(env, getAuthStore, params.cfg); - const resolveFastProviderApiKey = (provider: ProviderPlugin, providerId = provider.id) => { - const normalizedProviderId = normalizeProviderId(providerId); - // Prefer direct env keys for the current provider before touching the auth profile store. - if (providerAuthIds(provider).includes(normalizedProviderId)) { - const fromEnv = resolveProviderEnvApiKey(provider, env); - if (fromEnv) { - return fromEnv; - } - } - return resolveProviderApiKey(providerId); - }; - const byOrder = groupPluginDiscoveryProvidersByOrder(providers); - const rows: ModelCatalogEntry[] = []; - const seen = new Set(); - - // Discovery order is a contract: simple/profile results win over paired/late duplicates. - for (const order of DISCOVERY_ORDERS) { - for (const provider of byOrder[order]) { - let result: Awaited>; - const resolveCatalogProviderApiKey = (providerId?: string) => - resolveFastProviderApiKey(provider, providerId?.trim() || provider.id); - const resolveCatalogProviderAuth = ( - providerId?: string, - options?: { oauthMarker?: string }, - ) => resolveProviderAuth(providerId?.trim() || provider.id, options); - try { - result = await runProviderCatalog({ - provider, - config: params.cfg, - env, - resolveProviderApiKey: resolveCatalogProviderApiKey, - resolveProviderAuth: resolveCatalogProviderAuth, - agentDir, - ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), - }); - } catch (error) { - log.warn(`provider catalog failed for ${provider.id}: ${formatErrorMessage(error)}`); - continue; - } - - const normalized = normalizePluginDiscoveryResult({ provider, result }); - for (const [providerIdRaw, providerConfig] of Object.entries(normalized)) { - const providerId = normalizeProviderId(providerIdRaw); - if (providerId !== providerFilter || !Array.isArray(providerConfig.models)) { - continue; - } - for (const model of providerConfig.models) { - const entry = modelFromProviderCatalog({ - provider: providerId, - providerConfig, - model, - }); - const key = `${entry.provider}/${entry.id}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - rows.push(entry); - } - } - } - } - - return rows; + const providerFilter = canonicalizePreparedModelCatalogProvider( + requestedProvider, + owner.metadataSnapshot, + ); + return owner.modelCatalog.entries.filter( + (entry) => normalizeProviderId(entry.provider) === providerFilter, + ); } diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 387d9d195fe..216b36de708 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -9,7 +9,6 @@ import { resolveLogicalVisibleModelCatalog, type ModelCatalogAuthChecker, } from "../agents/model-catalog-visibility.js"; -import { loadModelCatalogSnapshot } from "../agents/model-catalog.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js"; import { createModelPickerVisibleProviderPredicate } from "../agents/model-picker-visibility.js"; @@ -29,6 +28,7 @@ import { resolveModelRefFromString, } from "../agents/model-selection.js"; import { openAIModelCatalogRoutePolicy } from "../agents/openai-model-routes.js"; +import { loadPreparedModelCatalogSnapshot } from "../agents/prepared-model-catalog.js"; import { loadStaticManifestCatalogRowsForList } from "../commands/models/list.manifest-catalog.js"; import { formatTokenK } from "../commands/models/shared.js"; import { @@ -195,7 +195,7 @@ function loadPickerModelCatalog( } return opts.providerScoped ? snapshot([]) - : loadModelCatalogSnapshot({ + : loadPreparedModelCatalogSnapshot({ config: cfg, }); }); @@ -212,7 +212,7 @@ function loadPickerModelCatalog( return Promise.resolve(snapshot([])); } } - return loadModelCatalogSnapshot({ + return loadPreparedModelCatalogSnapshot({ config: cfg, }); } diff --git a/src/gateway/local-request-context.test.ts b/src/gateway/local-request-context.test.ts index 96b59fe0d41..843769f1268 100644 --- a/src/gateway/local-request-context.test.ts +++ b/src/gateway/local-request-context.test.ts @@ -1,9 +1,11 @@ /** * Local gateway request-context tests. */ -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import * as preparedModelCatalog from "../agents/prepared-model-catalog.js"; import type { CliDeps } from "../cli/deps.types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { withLocalGatewayRequestScope } from "./local-request-context.js"; import { dispatchGatewayMethodInProcessRaw } from "./server-plugins.js"; @@ -33,4 +35,28 @@ describe("local gateway request context", () => { expect(response.ok).toBe(true); expect(response.payload).toMatchObject({ agentId: "main" }); }); + + it("defaults local model catalog snapshot reads to read-only", async () => { + const cfg = {} as OpenClawConfig; + const loadSnapshot = vi + .spyOn(preparedModelCatalog, "loadPreparedModelCatalogSnapshot") + .mockResolvedValue({ entries: [], routeVariants: [] }); + + await withLocalGatewayRequestScope( + { + deps: {} as CliDeps, + getRuntimeConfig: () => cfg, + }, + async () => { + const context = getPluginRuntimeGatewayRequestScope()?.context; + if (!context) { + throw new Error("expected local gateway request context"); + } + await context.loadGatewayModelCatalogSnapshot(); + }, + ); + + expect(loadSnapshot).toHaveBeenCalledWith({ config: cfg, readOnly: true }); + loadSnapshot.mockRestore(); + }); }); diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index e5515c67f60..c61ce52cf28 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -1,6 +1,9 @@ // Local embedded Gateway request context. // Lets local agent paths reuse Gateway server methods without starting a server. -import { loadManifestModelCatalog, loadModelCatalogSnapshot } from "../agents/model-catalog.js"; +import { + loadPreparedModelCatalog, + loadPreparedModelCatalogSnapshot, +} from "../agents/prepared-model-catalog.js"; import type { CliDeps } from "../cli/deps.types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -84,10 +87,22 @@ function createLocalGatewayRequestContext( notifyPluginMetadataChanged: () => {}, resolveTerminalLaunchPolicy: () => ({ ok: false, block: { kind: "disabled" } }), isTerminalEnabled: () => false, - loadGatewayModelCatalog: async () => - loadManifestModelCatalog({ config: params.getRuntimeConfig() }), - loadGatewayModelCatalogSnapshot: async ({ readOnly } = {}) => - loadModelCatalogSnapshot({ config: params.getRuntimeConfig(), readOnly }), + loadGatewayModelCatalog: async ({ agentId, agentDir, readOnly, workspaceDir } = {}) => + loadPreparedModelCatalog({ + ...(agentId ? { agentId } : {}), + ...(agentDir ? { agentDir } : {}), + config: params.getRuntimeConfig(), + readOnly: readOnly !== false, + ...(workspaceDir ? { workspaceDir } : {}), + }), + loadGatewayModelCatalogSnapshot: async ({ agentId, agentDir, readOnly, workspaceDir } = {}) => + loadPreparedModelCatalogSnapshot({ + ...(agentId ? { agentId } : {}), + ...(agentDir ? { agentDir } : {}), + config: params.getRuntimeConfig(), + readOnly: readOnly !== false, + ...(workspaceDir ? { workspaceDir } : {}), + }), getHealthCache: () => null, refreshHealthSnapshot: async () => ({}) as Awaited>, diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index f2c722e4cb1..68e2c8f2f46 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -297,7 +297,7 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "conversations.turn.cancel", scope: "operator.admin", since: "<=2026.7" }, { name: "send", scope: "operator.write", since: "<=2026.7" }, // Params-aware: ordinary turns need write; /new and /reset mutate lifecycle state as admin. - { name: "agent", scope: "dynamic", since: "<=2026.7" }, + { name: "agent", scope: "dynamic", since: "<=2026.7", startup: true }, { name: "agent.identity.get", scope: "operator.read", since: "<=2026.7" }, { name: "agent.wait", scope: "operator.write", since: "<=2026.7", startup: true }, { name: "chat.history", scope: "operator.read", since: "<=2026.7", startup: true }, @@ -305,7 +305,7 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "chat.metadata", scope: "operator.read", since: "<=2026.7", startup: true }, { name: "chat.message.get", scope: "operator.read", since: "<=2026.7", startup: true }, { name: "chat.abort", scope: "operator.write", since: "<=2026.7" }, - { name: "chat.send", scope: "operator.write", since: "<=2026.7" }, + { name: "chat.send", scope: "operator.write", since: "<=2026.7", startup: true }, // Operator terminal: admin-only PTY surface. Appended to the advertised block // so existing advertised method indices stay stable for older clients. { name: "terminal.open", scope: "operator.admin", since: "2026.7" }, diff --git a/src/gateway/server-import-boundary.test.ts b/src/gateway/server-import-boundary.test.ts index e1757d8424a..423bbbce54b 100644 --- a/src/gateway/server-import-boundary.test.ts +++ b/src/gateway/server-import-boundary.test.ts @@ -23,7 +23,7 @@ describe("gateway startup import boundaries", () => { /import\s+\{[^}]*resolveSessionKeyForRun[^}]*\}\s+from "\.\/server-session-key\.js"/s, ); expect(serverImpl).not.toMatch( - /export\s+\{[^}]*resetModelCatalogCacheForTest[^}]*\}\s+from "\.\/server-model-catalog\.js"/s, + /export\s+\{[^}]*resetPreparedModelCatalogForTest[^}]*\}\s+from "\.\/server-model-catalog\.js"/s, ); expect(readSource("src/gateway/server-runtime-subscriptions.ts")).toContain( 'import("./server-session-key.js")', diff --git a/src/gateway/server-methods/chat-history-handler.ts b/src/gateway/server-methods/chat-history-handler.ts index 5be659014ee..5544068d4c1 100644 --- a/src/gateway/server-methods/chat-history-handler.ts +++ b/src/gateway/server-methods/chat-history-handler.ts @@ -164,7 +164,10 @@ async function buildChatStartupMetadataResult(params: { context: params.context, agentId: params.agentId, params: { view: "configured" }, - preloadedCatalog: params.modelCatalog, + preloadedCatalog: { + agentId: params.agentId, + snapshot: params.modelCatalog, + }, ...(params.catalogProjector ? { catalogProjector: params.catalogProjector } : {}), }); } catch (err) { diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test.ts b/src/gateway/server-methods/models-list-result.openai-routes.test.ts index 6bd26765a95..87342e156e9 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; +import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withEnvAsync } from "../../test-utils/env.js"; @@ -44,6 +44,39 @@ async function listModels(params: { } describe("models.list OpenAI routes", () => { + it("does not reuse a preloaded catalog owned by another agent", async () => { + const loadGatewayModelCatalogSnapshot = vi.fn(() => + Promise.resolve({ entries: [], routeVariants: [] }), + ); + const context = { + getRuntimeConfig: () => + ({ + agents: { + defaults: {}, + list: [{ id: "main", default: true }, { id: "worker" }], + }, + }) as OpenClawConfig, + loadGatewayModelCatalogSnapshot, + logGateway: { debug: vi.fn() }, + } as unknown as GatewayRequestContext; + const preloadedCatalog: ModelCatalogSnapshot = { + entries: [catalogEntry("gpt-main", "openai-responses")], + routeVariants: [], + }; + + await expect( + buildModelsListResult({ + context, + agentId: "worker", + params: { view: "default" }, + preloadedCatalog: { agentId: "main", snapshot: preloadedCatalog }, + }), + ).resolves.toEqual({ models: [] }); + expect(loadGatewayModelCatalogSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "worker" }), + ); + }); + it("keeps route-aware default browse indeterminate without the provider artifact", async () => { const resolveRoutes = vi.fn(() => null); const createResolver = vi.fn(() => resolveRoutes); diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index a05abbc77d6..abcd07e694c 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -19,7 +19,7 @@ import { import { hasSyntheticLocalProviderAuthConfig } from "../../agents/model-auth.js"; import { buildProviderConfigModelCatalogForBrowse, - loadModelCatalogSnapshotForBrowse, + loadPreparedModelCatalogSnapshotForBrowse, type ModelCatalogBrowseView, } from "../../agents/model-catalog-browse.js"; import { @@ -448,7 +448,10 @@ export async function buildModelsListResult(params: { context: GatewayRequestContext; agentId?: string; params: Record; - preloadedCatalog?: ModelCatalogSnapshot; + preloadedCatalog?: { + agentId: string; + snapshot: ModelCatalogSnapshot; + }; catalogProjector?: ReturnType; routeResolverFactory?: typeof createOpenAIModelRoutesResolver; }): Promise<{ models: ModelsListEntryWithCapabilities[] }> { @@ -456,15 +459,19 @@ export async function buildModelsListResult(params: { const agentId = params.agentId ?? resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); const view = resolveModelsListView(params.params); - const snapshot = await loadModelCatalogSnapshotForBrowse({ + const snapshot = await loadPreparedModelCatalogSnapshotForBrowse({ cfg, view, loadCatalog: async (loadParams) => { const readOnlyLoad = loadParams.readOnly ?? true; - if (params.preloadedCatalog && readOnlyLoad) { - return params.preloadedCatalog; + if (params.preloadedCatalog?.agentId === agentId && readOnlyLoad) { + return params.preloadedCatalog.snapshot; } - return await params.context.loadGatewayModelCatalogSnapshot(loadParams); + return await params.context.loadGatewayModelCatalogSnapshot({ + ...loadParams, + agentId, + agentDir: resolveAgentDir(cfg, agentId), + }); }, onTimeout: (timeoutMs) => { if (loggedSlowModelsListCatalog) { diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index dd44f133411..c94ced84d0e 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -50,7 +50,9 @@ function requestModelsList(params: { respond?: ReturnType; runtimeConfig?: OpenClawConfig; loadGatewayModelCatalog: (params?: { + agentDir?: string; readOnly?: boolean; + workspaceDir?: string; }) => Promise>>; reqId?: string; includeProviderCapabilities?: boolean; @@ -211,7 +213,10 @@ describe("models.list", () => { }, undefined, ); - expect(loadGatewayModelCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true }); + expect(loadGatewayModelCatalog).toHaveBeenCalledOnce(); + expect(loadGatewayModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ); } finally { clearRuntimeConfigSnapshot(); } @@ -320,7 +325,9 @@ describe("models.list", () => { }, undefined, ); - expect(loadGatewayModelCatalog).toHaveBeenCalledWith({ readOnly: true }); + expect(loadGatewayModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true }), + ); } finally { vi.useRealTimers(); } @@ -422,7 +429,9 @@ describe("models.list", () => { }, undefined, ); - expect(loadGatewayModelCatalog).toHaveBeenCalledWith({ readOnly: false }); + expect(loadGatewayModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: false }), + ); } finally { vi.useRealTimers(); } @@ -518,7 +527,9 @@ describe("models.list", () => { }, undefined, ); - expect(loadConfiguredCatalog).toHaveBeenCalledWith({ readOnly: false }); + expect(loadConfiguredCatalog).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: false }), + ); const { request: allRequest, respond: allRespond } = requestModelsList({ view: "all", diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 68bb26d1e9e..d31338215ec 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -154,9 +154,17 @@ export type GatewayRequestContext = { sessionKey: string, client: GatewayClient | null, ) => SessionApprovalReplay; - loadGatewayModelCatalog: (params?: { readOnly?: boolean }) => Promise; - loadGatewayModelCatalogSnapshot: (params?: { + loadGatewayModelCatalog: (params?: { + agentId?: string; + agentDir?: string; readOnly?: boolean; + workspaceDir?: string; + }) => Promise; + loadGatewayModelCatalogSnapshot: (params?: { + agentId?: string; + agentDir?: string; + readOnly?: boolean; + workspaceDir?: string; }) => Promise; getHealthCache: () => HealthSummary | null; refreshHealthSnapshot: (opts?: { diff --git a/src/gateway/server-model-catalog.test.ts b/src/gateway/server-model-catalog.test.ts index db8ff3461bf..95228610634 100644 --- a/src/gateway/server-model-catalog.test.ts +++ b/src/gateway/server-model-catalog.test.ts @@ -1,185 +1,76 @@ -/** - * Gateway server model catalog tests. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { createDeferred } from "../test-utils/deferred.js"; -import type { GatewayModelChoice } from "./server-model-catalog.js"; +import { describe, expect, it, vi } from "vitest"; +import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js"; import { - resetModelCatalogCacheForTest, loadGatewayModelCatalog, - markGatewayModelCatalogStaleForReload, + loadGatewayModelCatalogSnapshot, } from "./server-model-catalog.js"; -type LoadModelCatalogForTest = (params: { - config: OpenClawConfig; - readOnly?: boolean; -}) => Promise; +const snapshot: ModelCatalogSnapshot = { + entries: [{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }], + routeVariants: [], +}; -function model(id: string): GatewayModelChoice { - return { id, name: id, provider: "openai" } as GatewayModelChoice; -} +describe("gateway prepared model catalog", () => { + it("reads the published read-only generation directly", async () => { + const config = {}; + const loadPreparedModelCatalogSnapshot = vi.fn(async () => snapshot); -const getConfig = () => ({}) as OpenClawConfig; - -const toSnapshotLoader = - (loadModelCatalog: LoadModelCatalogForTest) => - async (params: Parameters[0]) => { - const entries = await loadModelCatalog(params); - return { entries, routeVariants: entries }; - }; - -function createRefreshingCatalogLoader( - firstCatalog: GatewayModelChoice[], - secondCatalog: GatewayModelChoice[], -) { - return vi - .fn() - .mockResolvedValueOnce(firstCatalog) - .mockResolvedValueOnce(secondCatalog); -} - -async function expectCatalog( - loadModelCatalog: LoadModelCatalogForTest, - catalog: GatewayModelChoice[], - readOnly = true, -) { - await expect( - loadGatewayModelCatalog({ - getConfig, - loadModelCatalogSnapshot: toSnapshotLoader(loadModelCatalog), - ...(readOnly ? {} : { readOnly: false }), - }), - ).resolves.toBe(catalog); -} - -async function markStaleAndExpectPreviousCatalog( - loadModelCatalog: LoadModelCatalogForTest, - catalog: GatewayModelChoice[], -) { - markGatewayModelCatalogStaleForReload(); - await expectCatalog(loadModelCatalog, catalog); - await vi.waitFor(() => expect(loadModelCatalog).toHaveBeenCalledTimes(2)); -} - -describe("loadGatewayModelCatalog", () => { - beforeEach(async () => { - await resetModelCatalogCacheForTest(); - }); - - it("caches the first successful catalog until reload marks it stale", async () => { - const catalog = [model("gpt-5.4")]; - const loadModelCatalog = vi.fn(async () => catalog); - - const loadModelCatalogSnapshot = toSnapshotLoader(loadModelCatalog); - await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalogSnapshot })).resolves.toBe( - catalog, - ); - await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalogSnapshot })).resolves.toBe( - catalog, - ); - - expect(loadModelCatalog).toHaveBeenCalledTimes(1); - expect(loadModelCatalog).toHaveBeenCalledWith({ config: getConfig(), readOnly: true }); - }); - - it("keeps read-only and full catalog caches separate", async () => { - const readOnlyCatalog = [model("configured-only")]; - const fullCatalog = [model("configured-only"), model("browse-only")]; - const loadModelCatalog = vi.fn(async (params) => - params.readOnly === false ? fullCatalog : readOnlyCatalog, - ); - - const loadModelCatalogSnapshot = toSnapshotLoader(loadModelCatalog); - await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalogSnapshot })).resolves.toBe( - readOnlyCatalog, - ); await expect( loadGatewayModelCatalog({ - getConfig, - loadModelCatalogSnapshot: toSnapshotLoader(loadModelCatalog), - readOnly: false, + getConfig: () => config, + loadPreparedModelCatalogSnapshot, }), - ).resolves.toBe(fullCatalog); - await expect(loadGatewayModelCatalog({ getConfig, loadModelCatalogSnapshot })).resolves.toBe( - readOnlyCatalog, - ); - - expect(loadModelCatalog).toHaveBeenCalledTimes(2); - expect(loadModelCatalog).toHaveBeenNthCalledWith(1, { - config: getConfig(), + ).resolves.toBe(snapshot.entries); + expect(loadPreparedModelCatalogSnapshot).toHaveBeenCalledWith({ + config, readOnly: true, }); - expect(loadModelCatalog).toHaveBeenNthCalledWith(2, { - config: getConfig(), + }); + + it("forwards the requested agent lifecycle owner", async () => { + const config = {}; + const loadPreparedModelCatalogSnapshot = vi.fn(async () => snapshot); + + await loadGatewayModelCatalogSnapshot({ + agentDir: "/tmp/gateway-agent", + getConfig: () => config, + loadPreparedModelCatalogSnapshot, + workspaceDir: "/tmp/gateway-workspace", + }); + + expect(loadPreparedModelCatalogSnapshot).toHaveBeenCalledWith({ + agentDir: "/tmp/gateway-agent", + config, + readOnly: true, + workspaceDir: "/tmp/gateway-workspace", + }); + }); + + it("selects the full prepared owner when requested", async () => { + const config = {}; + const loadPreparedModelCatalogSnapshot = vi.fn(async () => snapshot); + + await expect( + loadGatewayModelCatalogSnapshot({ + getConfig: () => config, + loadPreparedModelCatalogSnapshot, + readOnly: false, + }), + ).resolves.toBe(snapshot); + expect(loadPreparedModelCatalogSnapshot).toHaveBeenCalledWith({ + config, readOnly: false, }); }); - it("caches an empty read-only catalog until reload marks it stale", async () => { - const emptyCatalog: GatewayModelChoice[] = []; - const freshCatalog = [model("gpt-5.5")]; - const loadModelCatalog = createRefreshingCatalogLoader(emptyCatalog, freshCatalog); - - await expectCatalog(loadModelCatalog, emptyCatalog); - await expectCatalog(loadModelCatalog, emptyCatalog); - - expect(loadModelCatalog).toHaveBeenCalledTimes(1); - - await markStaleAndExpectPreviousCatalog(loadModelCatalog, emptyCatalog); - await vi.waitFor(async () => { - await expectCatalog(loadModelCatalog, freshCatalog); + it("does not hide lifecycle publication failures behind stale data", async () => { + const error = new Error("generation failed"); + const loadPreparedModelCatalogSnapshot = vi.fn(async () => { + throw error; }); - }); - it("does not cache an empty full catalog so the next all-model request retries", async () => { - const emptyCatalog: GatewayModelChoice[] = []; - const freshCatalog = [model("gpt-5.5")]; - const loadModelCatalog = createRefreshingCatalogLoader(emptyCatalog, freshCatalog); - - await expectCatalog(loadModelCatalog, emptyCatalog, false); - await expectCatalog(loadModelCatalog, freshCatalog, false); - - expect(loadModelCatalog).toHaveBeenCalledTimes(2); - }); - - it("returns the last catalog while a stale reload refresh is still pending", async () => { - const staleCatalog = [model("gpt-5.4")]; - const freshCatalog = [model("gpt-5.5")]; - const refresh = createDeferred(); - const loadModelCatalog = vi - .fn() - .mockResolvedValueOnce(staleCatalog) - .mockReturnValueOnce(refresh.promise); - - await expectCatalog(loadModelCatalog, staleCatalog); - - await markStaleAndExpectPreviousCatalog(loadModelCatalog, staleCatalog); - - refresh.resolve(freshCatalog); - await vi.waitFor(async () => { - await expectCatalog(loadModelCatalog, freshCatalog); - }); - }); - - it("keeps serving the last catalog when a stale background refresh fails", async () => { - const staleCatalog = [model("gpt-5.4")]; - const freshCatalog = [model("gpt-5.5")]; - const loadModelCatalog = vi - .fn() - .mockResolvedValueOnce(staleCatalog) - .mockRejectedValueOnce(new Error("provider offline")) - .mockResolvedValueOnce(freshCatalog); - - await expectCatalog(loadModelCatalog, staleCatalog); - - await markStaleAndExpectPreviousCatalog(loadModelCatalog, staleCatalog); - - await expectCatalog(loadModelCatalog, staleCatalog); - await vi.waitFor(() => expect(loadModelCatalog).toHaveBeenCalledTimes(3)); - - await vi.waitFor(async () => { - await expectCatalog(loadModelCatalog, freshCatalog); - }); + await expect( + loadGatewayModelCatalogSnapshot({ loadPreparedModelCatalogSnapshot }), + ).rejects.toBe(error); }); }); diff --git a/src/gateway/server-model-catalog.ts b/src/gateway/server-model-catalog.ts index 32a4bf1c4c2..3a2325bd2ba 100644 --- a/src/gateway/server-model-catalog.ts +++ b/src/gateway/server-model-catalog.ts @@ -1,137 +1,61 @@ -// Gateway model catalog cache. -// Serves model catalogs with stale-while-refresh behavior for Gateway surfaces. +// Gateway catalog reads use the atomic prepared runtime generation. import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js"; import { getRuntimeConfig } from "../config/io.js"; export type GatewayModelChoice = import("../agents/model-catalog.js").ModelCatalogEntry; type GatewayModelCatalogConfig = ReturnType; -type LoadModelCatalogSnapshot = (params: { +type LoadPreparedModelCatalogSnapshot = (params: { + agentId?: string; + agentDir?: string; config: GatewayModelCatalogConfig; readOnly?: boolean; + workspaceDir?: string; }) => Promise; type LoadGatewayModelCatalogParams = { + agentId?: string; + agentDir?: string; getConfig?: () => GatewayModelCatalogConfig; - loadModelCatalogSnapshot?: LoadModelCatalogSnapshot; + loadPreparedModelCatalogSnapshot?: LoadPreparedModelCatalogSnapshot; readOnly?: boolean; + workspaceDir?: string; }; -type GatewayModelCatalogCache = { - lastSuccessfulCatalog: ModelCatalogSnapshot | null; - inFlightRefresh: Promise | null; - staleGeneration: number; - appliedGeneration: number; -}; - -const loadModelCatalogModule = async () => await import("../agents/model-catalog.js"); - -function createGatewayModelCatalogCache(): GatewayModelCatalogCache { - return { - lastSuccessfulCatalog: null, - inFlightRefresh: null, - staleGeneration: 0, - appliedGeneration: 0, - }; -} - -const readOnlyModelCatalogCache = createGatewayModelCatalogCache(); -const fullModelCatalogCache = createGatewayModelCatalogCache(); - -function resolveGatewayModelCatalogCache( +async function resolveLoader( params?: LoadGatewayModelCatalogParams, -): GatewayModelCatalogCache { - return params?.readOnly === false ? fullModelCatalogCache : readOnlyModelCatalogCache; -} - -function resetGatewayModelCatalogState(): void { - for (const cache of [readOnlyModelCatalogCache, fullModelCatalogCache]) { - cache.lastSuccessfulCatalog = null; - cache.inFlightRefresh = null; - cache.staleGeneration = 0; - cache.appliedGeneration = 0; +): Promise { + if (params?.loadPreparedModelCatalogSnapshot) { + return params.loadPreparedModelCatalogSnapshot; } + const { loadPreparedModelCatalogSnapshot } = await import("../agents/prepared-model-catalog.js"); + return loadPreparedModelCatalogSnapshot; } -function isGatewayModelCatalogStale(cache: GatewayModelCatalogCache): boolean { - return cache.appliedGeneration < cache.staleGeneration; +// Isolated gateway tests share process module state with lifecycle-owner tests. +export async function resetPreparedModelCatalogForTest(): Promise { + const [{ resetPreparedModelRuntimeSnapshotsForTest }, { resetModelCatalogBuilderCacheForTest }] = + await Promise.all([ + import("../agents/prepared-model-runtime.test-support.js"), + import("../agents/model-catalog.js"), + ]); + resetPreparedModelRuntimeSnapshotsForTest(); + resetModelCatalogBuilderCacheForTest(); } -async function resolveLoadModelCatalogSnapshot( - params?: LoadGatewayModelCatalogParams, -): Promise { - if (params?.loadModelCatalogSnapshot) { - return params.loadModelCatalogSnapshot; - } - const { loadModelCatalogSnapshot } = await loadModelCatalogModule(); - return loadModelCatalogSnapshot; -} - -function startGatewayModelCatalogRefresh( - params?: LoadGatewayModelCatalogParams, -): Promise { - const cache = resolveGatewayModelCatalogCache(params); - const config = (params?.getConfig ?? getRuntimeConfig)(); - const readOnly = params?.readOnly !== false; - const refreshGeneration = cache.staleGeneration; - const refresh = resolveLoadModelCatalogSnapshot(params) - .then((loadSnapshot) => loadSnapshot({ config, readOnly })) - .then((snapshot) => { - if ( - (readOnly || snapshot.entries.length > 0) && - refreshGeneration === cache.staleGeneration - ) { - cache.lastSuccessfulCatalog = snapshot; - cache.appliedGeneration = cache.staleGeneration; - } - return snapshot; - }) - .finally(() => { - if (cache.inFlightRefresh === refresh) { - cache.inFlightRefresh = null; - } - }); - cache.inFlightRefresh = refresh; - return refresh; -} - -/** Mark cached model catalogs stale after config/plugin reload changes. */ -export function markGatewayModelCatalogStaleForReload(): void { - readOnlyModelCatalogCache.staleGeneration += 1; - fullModelCatalogCache.staleGeneration += 1; -} - -// Test-only escape hatch: model catalog is cached at module scope for the -// process lifetime, which is fine for the real gateway daemon, but makes -// isolated unit tests harder. Keep this intentionally obscure. -export async function resetModelCatalogCacheForTest(): Promise { - resetGatewayModelCatalogState(); - const { resetModelCatalogCacheForTest: resetModelCatalogCacheForTestLocal } = - await loadModelCatalogModule(); - resetModelCatalogCacheForTestLocal(); -} - -/** Load the Gateway model catalog snapshot, returning cached data while stale refreshes run. */ export async function loadGatewayModelCatalogSnapshot( params?: LoadGatewayModelCatalogParams, ): Promise { - const cache = resolveGatewayModelCatalogCache(params); - const isStale = isGatewayModelCatalogStale(cache); - if (!isStale && cache.lastSuccessfulCatalog !== null) { - return cache.lastSuccessfulCatalog; - } - if (isStale && cache.lastSuccessfulCatalog !== null) { - if (!cache.inFlightRefresh) { - void startGatewayModelCatalogRefresh(params).catch(() => undefined); - } - return cache.lastSuccessfulCatalog; - } - if (cache.inFlightRefresh) { - return await cache.inFlightRefresh; - } - return await startGatewayModelCatalogRefresh(params); + const config = (params?.getConfig ?? getRuntimeConfig)(); + const loadSnapshot = await resolveLoader(params); + return await loadSnapshot({ + ...(params?.agentId ? { agentId: params.agentId } : {}), + ...(params?.agentDir ? { agentDir: params.agentDir } : {}), + config, + readOnly: params?.readOnly !== false, + ...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); } -/** Load the deduplicated Gateway model catalog for entries-only consumers. */ export async function loadGatewayModelCatalog( params?: LoadGatewayModelCatalogParams, ): Promise { diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index a554da7e847..81d843afffa 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -173,6 +173,16 @@ const hoisted = vi.hoisted(() => ({ reloadEvents: [] as string[], loadModelCatalog: vi.fn(async (_params: { config: OpenClawConfig }) => []), resetModelCatalogCache: vi.fn(() => {}), + markPreparedModelRuntimeSnapshotsStale: vi.fn( + ( + _reason?: string, + _options?: { waitForReplacement?: boolean; preserveReplacementWait?: boolean }, + ) => Symbol("prepared-model-runtime-replacement"), + ), + rejectPendingPreparedModelRuntimeReplacement: vi.fn( + (_gateId: symbol | undefined, _error: unknown) => {}, + ), + refreshPreparedModelRuntimeSnapshots: vi.fn(async (_cfg: OpenClawConfig) => {}), refreshContextWindowCache: vi.fn(async (_cfg: OpenClawConfig) => {}), clearCurrentProviderAuthState: vi.fn(() => {}), warmCurrentProviderAuthStateOffMainThread: vi.fn(async (_cfg: OpenClawConfig) => {}), @@ -254,6 +264,22 @@ vi.mock("../agents/model-catalog.js", () => ({ }, })); +vi.mock("../agents/prepared-model-runtime.js", () => ({ + markPreparedModelRuntimeSnapshotsStale: ( + reason?: string, + options?: { waitForReplacement?: boolean; preserveReplacementWait?: boolean }, + ) => { + hoisted.reloadEvents.push("stale-prepared-model-runtime"); + return hoisted.markPreparedModelRuntimeSnapshotsStale(reason, options); + }, + rejectPendingPreparedModelRuntimeReplacement: (gateId: symbol | undefined, error: unknown) => + hoisted.rejectPendingPreparedModelRuntimeReplacement(gateId, error), + refreshPreparedModelRuntimeSnapshots: (cfg: OpenClawConfig) => { + hoisted.reloadEvents.push("refresh-prepared-model-runtime"); + return hoisted.refreshPreparedModelRuntimeSnapshots(cfg); + }, +})); + vi.mock("../agents/context.js", () => ({ refreshContextWindowCache: async (cfg: OpenClawConfig) => { hoisted.reloadEvents.push("refresh-context-window"); @@ -739,8 +765,9 @@ afterEach(() => { hoisted.markRestartAbortedMainSessions.mockClear(); hoisted.runtimeConfig.value = { session: { store: "/tmp/active-sessions.json" } }; hoisted.reloadEvents.length = 0; - hoisted.loadModelCatalog.mockClear(); - hoisted.resetModelCatalogCache.mockClear(); + hoisted.markPreparedModelRuntimeSnapshotsStale.mockClear(); + hoisted.rejectPendingPreparedModelRuntimeReplacement.mockClear(); + hoisted.refreshPreparedModelRuntimeSnapshots.mockClear(); hoisted.refreshContextWindowCache.mockClear(); hoisted.clearCurrentProviderAuthState.mockClear(); hoisted.warmCurrentProviderAuthStateOffMainThread.mockClear(); @@ -1222,8 +1249,10 @@ describe("gateway hot reload model state", () => { }); it("resets prepared model runtime state for every hot reload and rewarms after plugin reload", async () => { - const reloadPlugins = vi.fn(async (): Promise => { - hoisted.reloadEvents.push("reload-plugins"); + const reloadPlugins = vi.fn(async (params): Promise => { + hoisted.reloadEvents.push("prepare-plugins"); + await params.commitRuntime(); + hoisted.reloadEvents.push("replace-plugins"); return { restartChannels: new Set(), activeChannels: new Set(), @@ -1275,20 +1304,24 @@ describe("gateway hot reload model state", () => { nextConfig, ); - const firstResetIndex = hoisted.reloadEvents.indexOf("reset-model-catalog"); + const firstResetIndex = hoisted.reloadEvents.indexOf("clear-provider-auth"); expect(firstResetIndex).toBeGreaterThanOrEqual(0); expect(hoisted.reloadEvents.slice(firstResetIndex)).toEqual([ - "reset-model-catalog", "clear-provider-auth", - "reload-plugins", - "reset-model-catalog", + "prepare-plugins", + "stale-prepared-model-runtime", + "replace-plugins", "clear-provider-auth", + "refresh-prepared-model-runtime", "refresh-context-window", - "load-model-catalog", "warm-provider-auth", ]); expect(hoisted.refreshContextWindowCache).toHaveBeenCalledWith(nextConfig); - expect(hoisted.loadModelCatalog).toHaveBeenCalledWith({ config: nextConfig }); + expect(hoisted.markPreparedModelRuntimeSnapshotsStale).toHaveBeenCalledWith( + "prepared model runtime owner is stale before config publication", + { waitForReplacement: true }, + ); + expect(hoisted.refreshPreparedModelRuntimeSnapshots).toHaveBeenCalledWith(nextConfig); expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith(nextConfig); }); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index aebcea33a33..2689e65c930 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -4,11 +4,16 @@ import { disposeAllSessionMcpRuntimes } from "../agents/agent-bundle-mcp-tools.j import { getActiveBackgroundExecSessionCount } from "../agents/bash-process-registry.js"; import { refreshContextWindowCache } from "../agents/context.js"; import { getActiveEmbeddedRunCount } from "../agents/embedded-agent-runner/run-state.js"; -import { loadModelCatalog, resetModelCatalogCache } from "../agents/model-catalog.js"; import { clearCurrentProviderAuthState, warmCurrentProviderAuthStateOffMainThread, } from "../agents/model-provider-auth.js"; +import { + markPreparedModelRuntimeSnapshotsStale, + rejectPendingPreparedModelRuntimeReplacement, + refreshPreparedModelRuntimeSnapshots, + type PreparedModelRuntimeReplacementGateId, +} from "../agents/prepared-model-runtime.js"; import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; import type { CliDeps } from "../cli/deps.types.js"; @@ -68,7 +73,6 @@ import { resolveHooksConfig } from "./hooks.js"; import type { GatewayCronReconciliation } from "./server-cron-reconciled.js"; import { buildGatewayCronService, type GatewayCronState } from "./server-cron.js"; import { applyGatewayLaneConcurrency, resolveGatewayLaneConcurrency } from "./server-lanes.js"; -import { markGatewayModelCatalogStaleForReload } from "./server-model-catalog.js"; import type { GatewayConfigReloaderHandle } from "./server-runtime-handles.js"; import { type GatewayChannelManager, @@ -295,9 +299,7 @@ function restoreCanonicalSecretRefs( } function resetPreparedModelRuntimeStateForHotReload(): void { - resetModelCatalogCache(); clearCurrentProviderAuthState(); - markGatewayModelCatalogStaleForReload(); } function assertIrreversibleReloadPlanHasRecoveryOwner( @@ -608,6 +610,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) const isPluginReloadAborted = () => pluginReloadAborted || !isTransactionCurrent() || isLifecycleReloadAborted(); let runtimeCommitted = false; + let preparedModelRuntimeReplacementGateId: PreparedModelRuntimeReplacementGateId | undefined; let recoveryRestartScheduled = false; const laneConcurrency = resolveGatewayLaneConcurrency(nextConfig); const candidateEnv = publication?.runtimeEnv ?? process.env; @@ -639,6 +642,12 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) if (plan.restartHeartbeat) { nextState.heartbeatRunner.updateConfig(nextConfig); } + // Config, plugin hooks, and prepared stores publish as one generation. Synchronously + // retire the prior stores at the commit edge so no request can mix generations. + preparedModelRuntimeReplacementGateId = markPreparedModelRuntimeSnapshotsStale( + "prepared model runtime owner is stale before config publication", + { waitForReplacement: true }, + ); params.setState(nextState); // All rejecting work is complete. Publish pre-resolved lane limits at // the final synchronous commit edge, alongside the accepted state. @@ -692,6 +701,12 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) }; const scheduleRecoveryRestart = (surface: string, err?: unknown) => { const detail = err === undefined ? "" : `: ${formatErrorMessage(err)}`; + if (runtimeCommitted) { + rejectPendingPreparedModelRuntimeReplacement( + preparedModelRuntimeReplacementGateId, + err ?? new Error(`prepared model runtime replacement stopped during ${surface}`), + ); + } if (restartRetryStopped) { params.logReload.warn(`${surface} failed during gateway shutdown${detail}`); return; @@ -920,7 +935,11 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) } if (pluginReloadAborted) { params.logChannels.info("channel restart cancelled by config supersession or restart"); - throw new GatewayHotReloadCancelledError(); + const error = new GatewayHotReloadCancelledError(); + if (runtimeCommitted) { + rejectPendingPreparedModelRuntimeReplacement(preparedModelRuntimeReplacementGateId, error); + } + throw error; } try { await commitRuntime(); @@ -932,6 +951,13 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) return; } + try { + await refreshPreparedModelRuntimeSnapshots(nextConfig); + } catch (err) { + scheduleRecoveryRestart("prepared model runtime reload", err); + return; + } + if (plan.restartHealthMonitor) { try { state.channelHealthMonitor?.stop(); @@ -1139,10 +1165,6 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) } catch (err) { scheduleRecoveryRestart("context window cache reload", err); } - // Provider discovery is best-effort; a slow hook must not hold hot reload open. - void loadModelCatalog({ config: nextConfig }).catch((err: unknown) => { - params.logReload.warn(`model catalog rewarm failed: ${String(err)}`); - }); } void warmCurrentProviderAuthStateOffMainThread(nextConfig, { isCancelled: () => !isTransactionCurrent(), @@ -1151,7 +1173,6 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) params.logReload.warn(`provider auth state rewarm failed: ${String(err)}`); } }); - if (plan.hotReasons.length > 0) { params.logReload.info(`config hot reload applied (${plan.hotReasons.join(", ")})`); } else if (plan.noopPaths.length > 0) { diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 74843d2c022..2e5d69f411d 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -61,7 +61,8 @@ const hoisted = vi.hoisted(() => { allowed: true, inCatalog: true, })); - const ensureOpenClawModelsJson = vi.fn(async () => {}); + const prepareModelRuntimeSnapshot = vi.fn(async () => ({})); + const refreshPreparedModelRuntimeSnapshots = vi.fn(async (_cfg?: unknown) => {}); const ensureRuntimePluginsLoaded = vi.fn(); const ensureContextWindowCacheLoaded = vi.fn(async () => {}); const clearCurrentProviderAuthState = vi.fn(); @@ -100,7 +101,8 @@ const hoisted = vi.hoisted(() => { resolveHooksGmailModel, loadModelCatalog, getModelRefStatus, - ensureOpenClawModelsJson, + prepareModelRuntimeSnapshot, + refreshPreparedModelRuntimeSnapshots, ensureRuntimePluginsLoaded, ensureContextWindowCacheLoaded, clearCurrentProviderAuthState, @@ -192,8 +194,8 @@ vi.mock("../infra/update-startup.js", () => ({ scheduleGatewayUpdateCheck: hoisted.scheduleGatewayUpdateCheck, })); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: hoisted.loadModelCatalog, +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: hoisted.loadModelCatalog, })); vi.mock("../agents/model-selection.js", () => ({ @@ -203,8 +205,9 @@ vi.mock("../agents/model-selection.js", () => ({ resolveHooksGmailModel: hoisted.resolveHooksGmailModel, })); -vi.mock("../agents/models-config.js", () => ({ - ensureOpenClawModelsJson: hoisted.ensureOpenClawModelsJson, +vi.mock("../agents/prepared-model-runtime.js", () => ({ + publishPreparedModelRuntimeSnapshot: hoisted.prepareModelRuntimeSnapshot, + refreshPreparedModelRuntimeSnapshots: hoisted.refreshPreparedModelRuntimeSnapshots, })); vi.mock("../agents/runtime-plugins.js", () => ({ @@ -355,8 +358,10 @@ describe("startGatewayPostAttachRuntime", () => { allowed: true, inCatalog: true, }); - hoisted.ensureOpenClawModelsJson.mockReset(); - hoisted.ensureOpenClawModelsJson.mockResolvedValue(undefined); + hoisted.prepareModelRuntimeSnapshot.mockReset(); + hoisted.prepareModelRuntimeSnapshot.mockResolvedValue({}); + hoisted.refreshPreparedModelRuntimeSnapshots.mockReset(); + hoisted.refreshPreparedModelRuntimeSnapshots.mockResolvedValue(undefined); hoisted.ensureRuntimePluginsLoaded.mockReset(); hoisted.ensureContextWindowCacheLoaded.mockReset(); hoisted.ensureContextWindowCacheLoaded.mockResolvedValue(undefined); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 7b979b8b616..c86d0c8ef40 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -36,8 +36,6 @@ import { import type { startGatewayTailscaleExposure } from "./server-tailscale.js"; const ACP_BACKEND_READY_TIMEOUT_MS = 5_000; const ACP_BACKEND_READY_POLL_MS = 50; -const PRIMARY_MODEL_PREWARM_TIMEOUT_MS = 5_000; -const STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; const PROVIDER_AUTH_PREWARM_START_DELAY_MS = 5_000; const PROVIDER_AUTH_REWARM_DELAY_MS = 1_000; const AGENT_RUNTIME_PLUGIN_PREWARM_START_DELAY_MS = 0; @@ -525,21 +523,6 @@ function hasGatewayStartHooks(pluginRegistry: ReturnType hook.hookName === "gateway_start"); } -function isConfiguredCliBackendPrimary(params: { - cfg: OpenClawConfig; - explicitPrimary: string; - normalizeProviderId: (provider: string) => string; -}): boolean { - const slashIndex = params.explicitPrimary.indexOf("/"); - if (slashIndex <= 0) { - return false; - } - const provider = params.normalizeProviderId(params.explicitPrimary.slice(0, slashIndex)); - return Object.keys(params.cfg.agents?.defaults?.cliBackends ?? {}).some( - (backend) => params.normalizeProviderId(backend) === provider, - ); -} - async function hasGatewayStartupInternalHookListeners(): Promise { const { hasInternalHookListeners } = await loadInternalHooksModule(); return hasInternalHookListeners("gateway", "startup"); @@ -577,108 +560,34 @@ async function prewarmConfiguredPrimaryModel(params: { workspaceDir?: string; log: { warn: (msg: string) => void }; }): Promise { - const { resolveAgentModelPrimaryValue } = await import("../config/model-input.js"); - const explicitPrimary = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model)?.trim(); - if (!explicitPrimary) { - return; - } - const { normalizeProviderId } = await import("@openclaw/model-catalog-core/provider-id"); - if ( - isConfiguredCliBackendPrimary({ - cfg: params.cfg, - explicitPrimary, - normalizeProviderId, - }) - ) { - return; - } - const [ - { resolveAgentWorkspaceDir, resolveDefaultAgentDir, resolveDefaultAgentId }, - { DEFAULT_MODEL, DEFAULT_PROVIDER }, - { isCliProvider, resolveConfiguredModelRef }, - ] = await Promise.all([ - import("../agents/agent-scope.js"), - loadAgentDefaultsModule(), - loadAgentModelSelectionModule(), - ]); - const { provider, model } = resolveConfiguredModelRef({ - cfg: params.cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - if (isCliProvider(provider, params.cfg)) { - return; - } - // Keep startup prewarm metadata-only; resolving models can import provider runtimes and block readiness. - const { ensureOpenClawModelsJson } = await import("../agents/models-config.js"); - const agentDir = resolveDefaultAgentDir(params.cfg); - const workspaceDir = - params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); - try { - await ensureOpenClawModelsJson(params.cfg, agentDir, { - workspaceDir, - providerDiscoveryProviderIds: [provider], - providerDiscoveryTimeoutMs: STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS, - providerDiscoveryEntriesOnly: true, - }); - } catch (err) { - params.log.warn(`startup model warmup failed for ${provider}/${model}: ${String(err)}`); - } + await publishConfiguredModelRuntimeSnapshots(params); } -async function prewarmConfiguredPrimaryModelWithTimeout( +async function publishConfiguredModelRuntimeSnapshots(params: { + cfg: OpenClawConfig; + workspaceDir?: string; + log: { warn: (msg: string) => void }; +}): Promise { + const { refreshPreparedModelRuntimeSnapshots } = + await import("../agents/prepared-model-runtime.js"); + await refreshPreparedModelRuntimeSnapshots(params.cfg, { + gatewayLifecycle: true, + ...(params.workspaceDir ? { defaultWorkspaceDir: params.workspaceDir } : {}), + }); +} + +async function publishStartupModelRuntime( params: { cfg: OpenClawConfig; workspaceDir?: string; - log: { warn: (msg: string) => void; debug?: (msg: string) => void }; - timeoutMs?: number; + log: { warn: (msg: string) => void }; }, prewarm: typeof prewarmConfiguredPrimaryModel = prewarmConfiguredPrimaryModel, ): Promise { - let settled = false; - const warmup = prewarm(params) - .catch((err: unknown) => { - params.log.warn(`startup model warmup failed: ${String(err)}`); - }) - .finally(() => { - settled = true; - }); - const timeout = sleep(params.timeoutMs ?? PRIMARY_MODEL_PREWARM_TIMEOUT_MS, undefined, { - ref: false, - }).then(() => { - if (!settled) { - params.log.debug?.( - `startup model warmup timed out after ${params.timeoutMs ?? PRIMARY_MODEL_PREWARM_TIMEOUT_MS}ms; continuing without waiting`, - ); - } - }); - await Promise.race([warmup, timeout]); -} - -function schedulePrimaryModelPrewarm( - params: { - cfg: OpenClawConfig; - workspaceDir?: string; - log: { warn: (msg: string) => void; debug?: (msg: string) => void }; - startupTrace?: GatewayStartupTrace; - }, - prewarm: typeof prewarmConfiguredPrimaryModel = prewarmConfiguredPrimaryModel, -): void { - if (shouldSkipStartupModelPrewarm()) { - return; - } - void measureStartup(params.startupTrace, "sidecars.model-prewarm", () => - prewarmConfiguredPrimaryModelWithTimeout( - { - cfg: params.cfg, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - log: params.log, - }, - prewarm, - ), - ).catch((err: unknown) => { - params.log.warn(`startup model warmup failed: ${String(err)}`); - }); + const publication = shouldSkipStartupModelPrewarm() + ? publishConfiguredModelRuntimeSnapshots + : prewarm; + await publication(params); } /** Start post-ready sidecars such as channels, hooks, plugin services, and cleanup tasks. */ @@ -741,16 +650,17 @@ export async function startGatewaySidecars(params: { const skipChannels = isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) || isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS); - // Agent RPC remains available when transport startup is disabled, so its model metadata must - // warm independently instead of leaving the first headless request on the cold path. - schedulePrimaryModelPrewarm( - { - cfg: params.cfg, - workspaceDir: params.defaultWorkspaceDir, - log: params.log, - startupTrace: params.startupTrace, - }, - params.prewarmPrimaryModel, + // Agent RPC remains available when transports are disabled. Publish its mandatory lifecycle + // owner before accepting work so request paths can only observe pending or ready snapshots. + await measureStartup(params.startupTrace, "sidecars.model-runtime", () => + publishStartupModelRuntime( + { + cfg: params.cfg, + workspaceDir: params.defaultWorkspaceDir, + log: params.log, + }, + params.prewarmPrimaryModel, + ), ); await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => { try { @@ -941,11 +851,11 @@ export async function startGatewaySidecars(params: { run: async (isStopped) => { const [ { DEFAULT_MODEL, DEFAULT_PROVIDER }, - { loadModelCatalog }, + { loadPreparedModelCatalog }, { getModelRefStatus, resolveConfiguredModelRef, resolveHooksGmailModel }, ] = await Promise.all([ loadAgentDefaultsModule(), - import("../agents/model-catalog.js"), + import("../agents/prepared-model-catalog.js"), loadAgentModelSelectionModule(), ]); if (isStopped()) { @@ -962,7 +872,7 @@ export async function startGatewaySidecars(params: { defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); - const catalog = await loadModelCatalog({ config: params.cfg }); + const catalog = await loadPreparedModelCatalog({ config: params.cfg }); const status = getModelRefStatus({ cfg: params.cfg, catalog, @@ -1463,12 +1373,12 @@ export const testing = { providerAuthPrewarmStartDelayMs: PROVIDER_AUTH_PREWARM_START_DELAY_MS, hasRestartSentinelFast, prewarmConfiguredPrimaryModel, - prewarmConfiguredPrimaryModelWithTimeout, + publishConfiguredModelRuntimeSnapshots, + publishStartupModelRuntime, refreshLatestUpdateRestartSentinelIfPresent, resolveGatewayMemoryStartupPolicy, cleanupStaleSessionLocks, scheduleProviderAuthStatePrewarm, - schedulePrimaryModelPrewarm, scheduleRestartSentinelWakeAfterReady, shouldSkipStartupModelPrewarm, stopPostReadySidecarsAfterCloseStarted, diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts index 173bcda57c0..e70a3a71e69 100644 --- a/src/gateway/server-startup.test.ts +++ b/src/gateway/server-startup.test.ts @@ -4,19 +4,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -const ensureOpenClawModelsJsonMock = vi.fn< - ( - config: unknown, - agentDir: unknown, - options?: unknown, - ) => Promise<{ agentDir: string; wrote: boolean }> ->(async () => ({ agentDir: "/tmp/agent", wrote: false })); -const resolveConfiguredModelRefMock = vi.fn(({ cfg }: { cfg: OpenClawConfig }) => { - const configured = cfg.agents?.defaults?.model; - const primary = typeof configured === "string" ? configured : configured?.primary; - const [provider = "openai", ...modelParts] = (primary ?? "openai/gpt-5.5").split("/"); - return { provider, model: modelParts.join("/") }; -}); +const prepareModelRuntimeSnapshotMock = vi.fn(async (_params: unknown) => ({})); +const refreshPreparedModelRuntimeSnapshotsMock = vi.fn( + async ( + _cfg: OpenClawConfig, + _options?: { gatewayLifecycle?: boolean; defaultWorkspaceDir?: string }, + ) => {}, +); vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentDir: () => "/tmp/agent", @@ -24,48 +18,32 @@ vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId: () => "default", })); -vi.mock("../agents/models-config.js", () => ({ - ensureOpenClawModelsJson: (config: unknown, agentDir: unknown, options?: unknown) => - ensureOpenClawModelsJsonMock(config, agentDir, options), -})); - -vi.mock("../agents/model-selection.js", () => ({ - isCliProvider: () => false, - resolveConfiguredModelRef: (params: { cfg: OpenClawConfig }) => - resolveConfiguredModelRefMock(params), +vi.mock("../agents/prepared-model-runtime.js", () => ({ + publishPreparedModelRuntimeSnapshot: (params: unknown) => prepareModelRuntimeSnapshotMock(params), + refreshPreparedModelRuntimeSnapshots: ( + cfg: OpenClawConfig, + options?: { gatewayLifecycle?: boolean; defaultWorkspaceDir?: string }, + ) => refreshPreparedModelRuntimeSnapshotsMock(cfg, options), })); let prewarmConfiguredPrimaryModel: typeof import("./server-startup-post-attach.js").testing.prewarmConfiguredPrimaryModel; -let prewarmConfiguredPrimaryModelWithTimeout: typeof import("./server-startup-post-attach.js").testing.prewarmConfiguredPrimaryModelWithTimeout; +let publishStartupModelRuntime: typeof import("./server-startup-post-attach.js").testing.publishStartupModelRuntime; let shouldSkipStartupModelPrewarm: typeof import("./server-startup-post-attach.js").testing.shouldSkipStartupModelPrewarm; -function expectModelsJsonPrewarmCall(cfg: OpenClawConfig) { - expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledTimes(1); - const [calledConfig, agentDir, options] = ensureOpenClawModelsJsonMock.mock.calls.at(0) ?? []; - expect(calledConfig).toBe(cfg); - expect(agentDir).toBe("/tmp/agent"); - expect(options).toEqual({ - workspaceDir: "/tmp/workspace", - providerDiscoveryProviderIds: ["openai"], - providerDiscoveryTimeoutMs: 5000, - providerDiscoveryEntriesOnly: true, - }); -} - describe("gateway startup primary model warmup", () => { beforeAll(async () => { ({ testing: { prewarmConfiguredPrimaryModel, - prewarmConfiguredPrimaryModelWithTimeout, + publishStartupModelRuntime, shouldSkipStartupModelPrewarm, }, } = await import("./server-startup-post-attach.js")); }); beforeEach(() => { - ensureOpenClawModelsJsonMock.mockClear(); - resolveConfiguredModelRefMock.mockClear(); + prepareModelRuntimeSnapshotMock.mockClear(); + refreshPreparedModelRuntimeSnapshotsMock.mockClear(); }); it("prewarms an explicit configured primary model", async () => { @@ -84,18 +62,21 @@ describe("gateway startup primary model warmup", () => { log: { warn: vi.fn() }, }); - expectModelsJsonPrewarmCall(cfg); - expect(resolveConfiguredModelRefMock).toHaveBeenCalledTimes(1); + expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith(cfg, { + gatewayLifecycle: true, + }); }); - it("skips warmup when no explicit primary model is configured", async () => { + it("prewarms the default catalog when no explicit primary model is configured", async () => { + const cfg = {} as OpenClawConfig; await prewarmConfiguredPrimaryModel({ - cfg: {} as OpenClawConfig, + cfg, log: { warn: vi.fn() }, }); - expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); - expect(resolveConfiguredModelRefMock).not.toHaveBeenCalled(); + expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith(cfg, { + gatewayLifecycle: true, + }); }); it("honors the startup model prewarm skip env", () => { @@ -112,68 +93,84 @@ describe("gateway startup primary model warmup", () => { ).toBe(true); }); - it("skips static warmup for configured CLI backends", async () => { - await prewarmConfiguredPrimaryModel({ - cfg: { - agents: { - defaults: { - model: { - primary: "codex-cli/gpt-5.5", - }, - cliBackends: { - "codex-cli": { - command: "codex", - args: ["exec"], - }, + it("publishes required runtime snapshots when optional startup prewarm is skipped", async () => { + vi.stubEnv("OPENCLAW_SKIP_STARTUP_MODEL_PREWARM", "1"); + const optionalPrewarm = vi.fn(async () => {}); + try { + await publishStartupModelRuntime( + { + cfg: {} as OpenClawConfig, + workspaceDir: "/tmp/skip-explicit-workspace", + log: { warn: vi.fn() }, + }, + optionalPrewarm, + ); + + expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledOnce(); + expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ defaultWorkspaceDir: "/tmp/skip-explicit-workspace" }), + ); + expect(optionalPrewarm).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("publishes lifecycle owners for configured CLI backends", async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "codex-cli/gpt-5.5", + }, + cliBackends: { + "codex-cli": { + command: "codex", + args: ["exec"], }, }, }, - } as OpenClawConfig, + }, + } as OpenClawConfig; + await prewarmConfiguredPrimaryModel({ cfg, log: { warn: vi.fn() } }); + + expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith(cfg, { + gatewayLifecycle: true, + }); + }); + + it("preserves the explicit startup workspace in the published default owner", async () => { + const cfg = {} as OpenClawConfig; + await prewarmConfiguredPrimaryModel({ + cfg, + workspaceDir: "/tmp/explicit-workspace", log: { warn: vi.fn() }, }); - expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); - expect(resolveConfiguredModelRefMock).not.toHaveBeenCalled(); + expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith(cfg, { + gatewayLifecycle: true, + defaultWorkspaceDir: "/tmp/explicit-workspace", + }); }); - it("warns when scoped models.json preparation fails", async () => { - ensureOpenClawModelsJsonMock.mockRejectedValueOnce(new Error("models write failed")); - const warn = vi.fn(); + it("propagates lifecycle catalog preparation failure", async () => { + const error = new Error("models write failed"); + refreshPreparedModelRuntimeSnapshotsMock.mockRejectedValueOnce(error); - await prewarmConfiguredPrimaryModel({ - cfg: { - agents: { - defaults: { - model: { - primary: "codex/gpt-5.4", + await expect( + prewarmConfiguredPrimaryModel({ + cfg: { + agents: { + defaults: { + model: { + primary: "codex/gpt-5.4", + }, }, }, - }, - } as OpenClawConfig, - log: { warn }, - }); - - expect(warn).toHaveBeenCalledWith( - "startup model warmup failed for codex/gpt-5.4: Error: models write failed", - ); - }); - - it("debug-logs an optional warmup timeout without warning", async () => { - const warn = vi.fn(); - const debug = vi.fn(); - - await prewarmConfiguredPrimaryModelWithTimeout( - { - cfg: {} as OpenClawConfig, - log: { warn, debug }, - timeoutMs: 1, - }, - async () => await new Promise(() => {}), - ); - - expect(debug).toHaveBeenCalledWith( - "startup model warmup timed out after 1ms; continuing without waiting", - ); - expect(warn).not.toHaveBeenCalled(); + } as OpenClawConfig, + log: { warn: vi.fn() }, + }), + ).rejects.toBe(error); }); }); diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index e8501d0ec56..8c652adc560 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -14,7 +14,7 @@ import { createDirectOutboundTestAdapter, } from "../test-utils/channel-plugins.js"; import { waitForAgentCommandCall } from "./agent-command.test-helpers.js"; -import { resetModelCatalogCacheForTest as resetGatewayModelCatalogCacheForTest } from "./server-model-catalog.js"; +import { resetPreparedModelCatalogForTest } from "./server-model-catalog.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { installConnectedSessionStoreGatewaySuite } from "./test-helpers.connected-session-store.js"; @@ -98,9 +98,16 @@ async function runMainAgentDeliveryWithSession(params: { async function setGatewayModelCatalogForTest( models: typeof agentDiscoveryMock.models, ): Promise { + testState.sessionStorePath = gatewaySuite.sessionStorePath; agentDiscoveryMock.enabled = true; agentDiscoveryMock.models = models; - await resetGatewayModelCatalogCacheForTest(); + await resetPreparedModelCatalogForTest(); + const [ + { refreshPreparedModelRuntimeSnapshots }, + { clearRuntimeConfigSnapshot, getRuntimeConfig }, + ] = await Promise.all([import("../agents/prepared-model-runtime.js"), import("../config/io.js")]); + clearRuntimeConfigSnapshot(); + await refreshPreparedModelRuntimeSnapshots(getRuntimeConfig(), { gatewayLifecycle: true }); } const baseImageAttachment = () => ({ diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 1c9a90678e2..b24fca8fda9 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -168,10 +168,10 @@ const loadWorkerPlacementStartupModule = createLazyRuntimeModule( () => import("./server-worker-placement-startup.js"), ); -export async function resetModelCatalogCacheForTest(): Promise { - const { resetModelCatalogCacheForTest: resetModelCatalogCacheForTestLocal } = +export async function resetPreparedModelCatalogForTest(): Promise { + const { resetPreparedModelCatalogForTest: resetPreparedModelCatalogForTestLocal } = await loadGatewayModelCatalogModule(); - await resetModelCatalogCacheForTestLocal(); + await resetPreparedModelCatalogForTestLocal(); } ensureOpenClawCliOnPath(); diff --git a/src/gateway/server.lazy.test.ts b/src/gateway/server.lazy.test.ts index b36ef0a0dbe..a464c05b3bc 100644 --- a/src/gateway/server.lazy.test.ts +++ b/src/gateway/server.lazy.test.ts @@ -29,7 +29,7 @@ describe("gateway server boundary", () => { expect.stringContaining("gateway.server-impl-import"), ); - await mod.resetModelCatalogCacheForTest(); + await mod.resetPreparedModelCatalogForTest(); expect(stderrWrite).toHaveBeenCalledWith(expect.stringContaining("gateway.server-impl-import")); }); diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index 0d7625f809d..f6396a97f59 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -12,7 +12,7 @@ import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createTempHomeEnv } from "../test-utils/temp-home.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; -import { resetModelCatalogCacheForTest as resetGatewayModelCatalogCacheForTest } from "./server-model-catalog.js"; +import { resetPreparedModelCatalogForTest } from "./server-model-catalog.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { connectOk, @@ -234,7 +234,17 @@ describe("gateway server models + voicewake", () => { const setAgentCatalog = async (entries: AgentCatalogFixtureEntry[]) => { agentDiscoveryMock.enabled = true; agentDiscoveryMock.models = entries; - await resetGatewayModelCatalogCacheForTest(); + await resetPreparedModelCatalogForTest(); + const [ + { refreshPreparedModelRuntimeSnapshots }, + { clearRuntimeConfigSnapshot: clearIoRuntimeConfigSnapshot, getRuntimeConfig }, + ] = await Promise.all([ + import("../agents/prepared-model-runtime.js"), + import("../config/io.js"), + ]); + clearIoRuntimeConfigSnapshot(); + const publishedConfig = getRuntimeConfig(); + await refreshPreparedModelRuntimeSnapshots(publishedConfig, { gatewayLifecycle: true }); }; const seedAgentModelCatalog = async () => { @@ -577,7 +587,7 @@ describe("gateway server models + voicewake", () => { async () => { await setAgentCatalog(remoteUnauthModels()); const res = await listModels(); - expect(res.ok).toBe(true); + expect(res.ok, JSON.stringify(res)).toBe(true); expectSingleModel(res.payload?.models ?? [], { id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed", @@ -587,7 +597,7 @@ describe("gateway server models + voicewake", () => { ); }); - test("models.list configured view does not run runtime discovery without a read-only catalog", async () => { + test("models.list configured view reuses the prepared generation", async () => { await withEnvAsync( { ANTHROPIC_API_KEY: undefined, @@ -704,9 +714,10 @@ describe("gateway server models + voicewake", () => { expected: [ { id: "claude-test-a", - name: "claude-test-a", + name: "A-Model", provider: "anthropic", available: false, + contextWindow: 200_000, }, { id: "gpt-test-z", diff --git a/src/gateway/server.ts b/src/gateway/server.ts index b8568d5d77c..8eed07a2414 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -35,8 +35,8 @@ export async function startGatewayServer( return await mod.startGatewayServer(...args); } -/** Clears the server implementation's model-catalog cache between tests. */ -export async function resetModelCatalogCacheForTest(): Promise { +/** Clears prepared model-catalog generations between tests. */ +export async function resetPreparedModelCatalogForTest(): Promise { const mod = await loadServerImpl(); - await mod.resetModelCatalogCacheForTest(); + await mod.resetPreparedModelCatalogForTest(); } diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 6b8080dba67..0fa7f35798d 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -168,7 +168,7 @@ export async function projectSessionsPatchEntry(params: { ); }; let loadedModelCatalog: ModelCatalogEntry[] | undefined; - const loadModelCatalogForPatch = async () => { + const loadPreparedModelCatalogForPatch = async () => { if (loadedModelCatalog) { return loadedModelCatalog; } @@ -453,7 +453,7 @@ export async function projectSessionsPatchEntry(params: { const hintProvider = normalizeOptionalString(existing?.providerOverride) || resolvedDefault.provider; const hintModel = normalizeOptionalString(existing?.modelOverride) || resolvedDefault.model; - const thinkingCatalog = await loadModelCatalogForPatch(); + const thinkingCatalog = await loadPreparedModelCatalogForPatch(); const thinkingRuntime = resolveThinkingRuntime(hintProvider, hintModel, existing); return invalid( `invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|", thinkingCatalog, thinkingRuntime)})`, @@ -627,7 +627,7 @@ export async function projectSessionsPatchEntry(params: { error: errorShape(ErrorCodes.UNAVAILABLE, "model catalog unavailable"), }; } - const catalog = await loadModelCatalogForPatch(); + const catalog = await loadPreparedModelCatalogForPatch(); if (!catalog) { return { ok: false, @@ -675,7 +675,7 @@ export async function projectSessionsPatchEntry(params: { const effectiveProvider = next.providerOverride ?? resolvedDefault.provider; const effectiveModel = next.modelOverride ?? resolvedDefault.model; const thinkingLevel = normalizeThinkLevel(next.thinkingLevel); - const thinkingCatalog = await loadModelCatalogForPatch(); + const thinkingCatalog = await loadPreparedModelCatalogForPatch(); if (!thinkingLevel) { delete next.thinkingLevel; } else { diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 3f0c6bc3062..38a6389e2dd 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -425,7 +425,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { } resetAgentEventsForTest(); const mod = await getServerModule(); - await mod.resetModelCatalogCacheForTest(); + await mod.resetPreparedModelCatalogForTest(); agentDiscoveryMock.enabled = false; agentDiscoveryMock.discoverCalls = 0; agentDiscoveryMock.models = []; diff --git a/src/gateway/worker-environments/inference-runtime.test.ts b/src/gateway/worker-environments/inference-runtime.test.ts index ef58790f3ec..b0e3eb461a6 100644 --- a/src/gateway/worker-environments/inference-runtime.test.ts +++ b/src/gateway/worker-environments/inference-runtime.test.ts @@ -6,7 +6,7 @@ import { import type { applyExtraParamsToAgent } from "../../agents/embedded-agent-runner/extra-params.js"; import type { resolveModelAsync } from "../../agents/embedded-agent-runner/model.js"; import type { resolveEmbeddedAgentStreamFn } from "../../agents/embedded-agent-runner/stream-resolution.js"; -import type { loadModelCatalog } from "../../agents/model-catalog.js"; +import type { acquireAgentRunPreparedModelRuntime } from "../../agents/prepared-model-runtime.js"; import type { registerProviderStreamForModel } from "../../agents/provider-stream.js"; import type { prepareSimpleCompletionModel } from "../../agents/simple-completion-runtime.js"; import { resolveSimpleCompletionModelResolverWorkspace } from "../../agents/simple-completion-scope.js"; @@ -16,7 +16,6 @@ import { onTrustedInternalDiagnosticEvent } from "../../infra/diagnostic-events. import { bindModelLlmRuntime } from "../../llm/model-runtime-binding.js"; import type { AssistantMessage, Model, StreamFn, Usage } from "../../llm/types.js"; import { createAssistantMessageEventStream } from "../../llm/utils/event-stream.js"; -import type { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import type { WorkerConnectionIdentity } from "./connection-identity.js"; import { createWorkerInferenceExecutor, @@ -25,14 +24,9 @@ import { } from "./inference-runtime.js"; import { createWorkerToolCallStream } from "./inference-tool-call-stream.js"; -vi.mock("../../agents/sessions/model-registry-runtime.js", () => ({ - getModelRegistryRuntime: (owner: unknown) => owner, -})); - type Deps = { applyStreamPolicy: typeof applyExtraParamsToAgent; - loadCatalog: typeof loadModelCatalog; - loadManifestSnapshot: typeof loadManifestMetadataSnapshot; + acquireRuntimeLease: typeof acquireAgentRunPreparedModelRuntime; prepareModel: typeof prepareSimpleCompletionModel; resolveAuthProfileMode: () => string | undefined; resolveModel: typeof resolveModelAsync; @@ -189,10 +183,9 @@ function setup(entry: SessionEntry = sessionEntry) { modelParams.modelResolver, ); await modelParams.modelResolver?.(PROVIDER, MODEL, modelParams.agentDir, modelParams.cfg, {}); - const apiRegistry = {}; return { model: bindModelLlmRuntime(logicalModel, { - registry: apiRegistry, + registry: {}, streamSimple: fallbackStream, } as never), auth: { @@ -206,12 +199,7 @@ function setup(entry: SessionEntry = sessionEntry) { const resolveAuthProfileMode = vi.fn(() => undefined); const stream = vi.fn(() => providerStream()); const fallbackStream = vi.fn(() => providerStream()); - const loadManifestSnapshot = vi.fn( - () => ({ plugins: [] }) as unknown as ReturnType, - ); - const resolveProviderStream = vi.fn(() => { - return stream; - }); + const resolveProviderStream = vi.fn(() => stream); const resolveStream = vi.fn((streamParams) => { scope.authProfile = streamParams.authProfileId; return streamParams.providerStreamFn ?? streamParams.currentStreamFn ?? fallbackStream; @@ -219,6 +207,28 @@ function setup(entry: SessionEntry = sessionEntry) { const applyStreamPolicy = vi.fn(() => ({ effectiveExtraParams: {}, })); + const releaseRuntime = vi.fn(); + const acquireRuntimeLease = vi.fn(async (runtimeParams) => { + scope.agentDir = runtimeParams.agentDir; + scope.catalogWorkspace = WORKSPACE; + return { + snapshot: { + agentDir: runtimeParams.agentDir, + workspaceDir: WORKSPACE, + config, + metadataSnapshot: { plugins: [] } as never, + modelCatalog: { + entries: [ + { provider: PROVIDER, id: MODEL, name: "Approved model" }, + { provider: PROVIDER, id: "known-but-unapproved", name: "Unapproved model" }, + ], + routeVariants: [], + }, + createStores: () => ({ authStorage: {} as never, modelRegistry: {} as never }), + }, + release: releaseRuntime, + }; + }); const dependencies = { now: vi.fn<() => number>().mockReturnValueOnce(100).mockReturnValue(125), resolveSessionTarget: vi.fn(() => ({ @@ -228,15 +238,7 @@ function setup(entry: SessionEntry = sessionEntry) { sessionStore: { [SESSION_KEY]: entry }, storePath: "runtime-sessions.json", })), - loadManifestSnapshot, - loadCatalog: vi.fn(async (catalogParams) => { - scope.agentDir = catalogParams?.agentDir; - scope.catalogWorkspace = catalogParams?.workspaceDir; - return [ - { provider: PROVIDER, id: MODEL, name: "Approved model" }, - { provider: PROVIDER, id: "known-but-unapproved", name: "Unapproved model" }, - ]; - }), + acquireRuntimeLease, resolveDefaultModel: vi.fn(() => ({ provider: PROVIDER, model: MODEL })), resolveSessionAuthProfile: vi.fn(async () => entry.authProfileOverride), resolveModel, @@ -251,7 +253,9 @@ function setup(entry: SessionEntry = sessionEntry) { return { applyStreamPolicy, executor: createWorkerInferenceExecutor(dependencies), + acquireRuntimeLease, prepareModel, + releaseRuntime, resolveAuthProfileMode, scope, stream, @@ -355,6 +359,8 @@ describe("worker inference provider runtime", () => { const execution = params(inferenceRequest, (event) => emitted.push(event)); const outcome = await runtime.executor(execution).finally(unsubscribe); + expect(runtime.releaseRuntime).toHaveBeenCalledOnce(); + expect(runtime.prepareModel).toHaveBeenCalledWith( expect.objectContaining({ modelId: MODEL, @@ -371,6 +377,12 @@ describe("worker inference provider runtime", () => { catalogWorkspace: WORKSPACE, prepareWorkspace: WORKSPACE, }); + expect(runtime.acquireRuntimeLease).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "runtime-agent", + inheritedAuthDir: expect.any(String), + }), + ); const [streamModel, streamContext, streamOptions] = runtime.stream.mock.calls[0] ?? []; expect(streamModel).toMatchObject({ baseUrl: ENDPOINT }); expect(streamContext?.messages).toEqual(inferenceRequest.context.messages); diff --git a/src/gateway/worker-environments/inference-runtime.ts b/src/gateway/worker-environments/inference-runtime.ts index ff668bcc006..984613b3fcd 100644 --- a/src/gateway/worker-environments/inference-runtime.ts +++ b/src/gateway/worker-environments/inference-runtime.ts @@ -10,6 +10,7 @@ import { resolveAgentDir, resolveAgentEffectiveModelPrimary, resolveAgentWorkspaceDir, + resolveDefaultAgentDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; @@ -20,7 +21,6 @@ import { wrapStreamFnWithDiagnosticModelCallEvents } from "../../agents/embedded import { resolveEmbeddedAgentStreamFn } from "../../agents/embedded-agent-runner/stream-resolution.js"; import { mapThinkingLevel } from "../../agents/embedded-agent-runner/utils.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; -import { loadModelCatalog } from "../../agents/model-catalog.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js"; import { modelCatalogLogicalKey } from "../../agents/model-selection-shared.js"; @@ -35,6 +35,7 @@ import { RUNTIME_MODEL_VISIBILITY_NORMALIZATION, } from "../../agents/model-visibility-policy.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; +import { acquireAgentRunPreparedModelRuntime } from "../../agents/prepared-model-runtime.js"; import { resolveProviderModelRouteAuthRequirement } from "../../agents/provider-model-route-auth.js"; import { projectProviderModelRouteConfig } from "../../agents/provider-model-route.js"; import { registerProviderStreamForModel } from "../../agents/provider-stream.js"; @@ -64,7 +65,6 @@ import type { Tool, Usage, } from "../../llm/types.js"; -import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import { resolveProviderModelRoutes } from "../../plugins/provider-model-routes.js"; import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js"; import { @@ -99,8 +99,7 @@ type WorkerInferenceRuntimeDependencies = { config: OpenClawConfig, sessionId: string, ) => WorkerInferenceSessionTarget | undefined; - loadManifestSnapshot: typeof loadManifestMetadataSnapshot; - loadCatalog: typeof loadModelCatalog; + acquireRuntimeLease: typeof acquireAgentRunPreparedModelRuntime; resolveDefaultModel: typeof resolveDefaultModelForAgent; resolveSessionAuthProfile: typeof resolveSessionAuthProfileOverride; resolveAuthProfileMode: typeof resolveWorkerInferenceAuthProfileMode; @@ -379,8 +378,7 @@ const DEFAULT_DEPENDENCIES: WorkerInferenceRuntimeDependencies = { agentId: target.agentId ?? resolveDefaultAgentId(config), }; }, - loadManifestSnapshot: loadManifestMetadataSnapshot, - loadCatalog: loadModelCatalog, + acquireRuntimeLease: acquireAgentRunPreparedModelRuntime, resolveDefaultModel: resolveDefaultModelForAgent, resolveSessionAuthProfile: resolveSessionAuthProfileOverride, resolveAuthProfileMode: resolveWorkerInferenceAuthProfileMode, @@ -419,9 +417,11 @@ async function resolveApprovedModel(params: { | { provider: string; model: string; + config: OpenClawConfig; agentDir: string; workspaceDir: string; prepared: PreparedSimpleCompletionModel; + release: () => void; } | undefined > { @@ -430,159 +430,179 @@ async function resolveApprovedModel(params: { if (splitTrailingAuthProfile(rawRef).profile) { return undefined; } - const workspaceDir = resolveAgentWorkspaceDir(config, target.agentId); - const agentDir = resolveAgentDir(config, target.agentId); - const manifestSnapshot = dependencies.loadManifestSnapshot({ config, workspaceDir }); - const defaultModel = dependencies.resolveDefaultModel({ - cfg: config, + const runtimeLease = await dependencies.acquireRuntimeLease({ + config, agentId: target.agentId, - manifestPlugins: manifestSnapshot.plugins, - ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + agentDir: resolveAgentDir(config, target.agentId), + inheritedAuthDir: resolveDefaultAgentDir(config), }); - const agentModels = resolveAgentConfig(config, target.agentId)?.models; - const aliasConfig = agentModels - ? { - ...config, - agents: { - ...config.agents, - defaults: { - ...config.agents?.defaults, - models: { ...config.agents?.defaults?.models, ...agentModels }, + const runtimeSnapshot = runtimeLease.snapshot; + try { + const lifecycleConfig = runtimeSnapshot.config; + const agentDir = runtimeSnapshot.agentDir; + const workspaceDir = + runtimeSnapshot.workspaceDir ?? resolveAgentWorkspaceDir(lifecycleConfig, target.agentId); + const manifestSnapshot = runtimeSnapshot.metadataSnapshot; + const preparedStores = runtimeSnapshot.createStores(); + const defaultModel = dependencies.resolveDefaultModel({ + cfg: lifecycleConfig, + agentId: target.agentId, + manifestPlugins: manifestSnapshot.plugins, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + }); + const agentModels = resolveAgentConfig(lifecycleConfig, target.agentId)?.models; + const aliasConfig = agentModels + ? { + ...lifecycleConfig, + agents: { + ...lifecycleConfig.agents, + defaults: { + ...lifecycleConfig.agents?.defaults, + models: { ...lifecycleConfig.agents?.defaults?.models, ...agentModels }, + }, }, - }, - } - : config; - const aliasIndex = buildModelAliasIndex({ - cfg: aliasConfig, - defaultProvider: defaultModel.provider, - manifestPlugins: manifestSnapshot.plugins, - ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, - }); - const resolved = resolveModelRefFromString({ - cfg: aliasConfig, - raw: rawRef, - defaultProvider: defaultModel.provider, - aliasIndex, - manifestPlugins: manifestSnapshot.plugins, - ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, - }); - if ( - !resolved || - normalizeProviderId(resolved.ref.provider) !== normalizeProviderId(request.modelRef.provider) - ) { - return undefined; - } - const catalog = await dependencies.loadCatalog({ - agentDir, - config, - metadataSnapshot: manifestSnapshot, - useCache: false, - workspaceDir, - }); - const policy = createModelVisibilityPolicy({ - cfg: config, - catalog, - defaultProvider: defaultModel.provider, - defaultModel: `${defaultModel.provider}/${defaultModel.model}`, - agentId: target.agentId, - manifestPlugins: manifestSnapshot.plugins, - ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, - }); - const resolvedKey = modelCatalogLogicalKey({ - provider: resolved.ref.provider, - id: resolved.ref.model, - }); - // Retained refs stay approved during cold discovery. - const known = - policy.allowedCatalog.some( - (entry: ModelCatalogEntry) => resolvedKey === modelCatalogLogicalKey(entry), - ) || policy.retainedKeys.has(resolvedKey); - if (!known || !policy.allows(resolved.ref)) { - return undefined; - } - const configuredDefaultProfile = - resolvedKey === - modelCatalogLogicalKey({ provider: defaultModel.provider, id: defaultModel.model }) - ? splitTrailingAuthProfile(resolveAgentEffectiveModelPrimary(config, target.agentId) ?? "") - .profile - : undefined; - const harnessPolicy = resolveAgentHarnessPolicy({ - provider: resolved.ref.provider, - modelId: resolved.ref.model, - config, - agentId: target.agentId, - sessionKey: target.sessionKey, - }); - const agentRuntimeId = - harnessPolicy.runtimeSource !== "implicit" || config.plugins?.entries?.codex?.enabled === true - ? harnessPolicy.runtime - : undefined; - const sessionProfileId = await dependencies.resolveSessionAuthProfile({ - cfg: config, - provider: resolved.ref.provider, - acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({ + } + : lifecycleConfig; + const aliasIndex = buildModelAliasIndex({ + cfg: aliasConfig, + defaultProvider: defaultModel.provider, + manifestPlugins: manifestSnapshot.plugins, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + }); + const resolved = resolveModelRefFromString({ + cfg: aliasConfig, + raw: rawRef, + defaultProvider: defaultModel.provider, + aliasIndex, + manifestPlugins: manifestSnapshot.plugins, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + }); + if ( + !resolved || + normalizeProviderId(resolved.ref.provider) !== normalizeProviderId(request.modelRef.provider) + ) { + runtimeLease.release(); + return undefined; + } + const catalog = runtimeSnapshot.modelCatalog.entries; + const policy = createModelVisibilityPolicy({ + cfg: lifecycleConfig, + catalog, + defaultProvider: defaultModel.provider, + defaultModel: `${defaultModel.provider}/${defaultModel.model}`, + agentId: target.agentId, + manifestPlugins: manifestSnapshot.plugins, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, + }); + const resolvedKey = modelCatalogLogicalKey({ provider: resolved.ref.provider, - harnessRuntime: harnessPolicy.runtime, - config, - }), - agentDir, - sessionEntry: target.sessionEntry, - sessionStore: target.sessionStore, - sessionKey: target.sessionKey, - storePath: target.storePath, - isNewSession: false, - }); - const sessionProfileSource = resolveReturnedProfileSource(target.sessionEntry, sessionProfileId); - const selectedProfile = - sessionProfileId && sessionProfileSource === "user" - ? { id: sessionProfileId, source: sessionProfileSource } - : configuredDefaultProfile - ? { id: configuredDefaultProfile, source: "user" as const } - : sessionProfileId - ? { id: sessionProfileId, source: sessionProfileSource } - : undefined; - const modelConfig = projectWorkerInferenceModelRouteConfig({ - config, - provider: resolved.ref.provider, - modelId: resolved.ref.model, - authMode: selectedProfile - ? dependencies.resolveAuthProfileMode({ - config, - agentDir, - profileId: selectedProfile.id, - }) - : undefined, - }); - const modelResolver = bindSimpleCompletionModelResolverWorkspace( - (provider, modelId, resolvedAgentDir, cfg, options) => - dependencies.resolveModel(provider, modelId, resolvedAgentDir, cfg, { - ...options, - ...(agentRuntimeId ? { agentRuntimeId } : {}), - workspaceDir, + id: resolved.ref.model, + }); + // Retained refs stay approved during cold discovery. + const known = + policy.allowedCatalog.some( + (entry: ModelCatalogEntry) => resolvedKey === modelCatalogLogicalKey(entry), + ) || policy.retainedKeys.has(resolvedKey); + if (!known || !policy.allows(resolved.ref)) { + runtimeLease.release(); + return undefined; + } + const configuredDefaultProfile = + resolvedKey === + modelCatalogLogicalKey({ provider: defaultModel.provider, id: defaultModel.model }) + ? splitTrailingAuthProfile( + resolveAgentEffectiveModelPrimary(lifecycleConfig, target.agentId) ?? "", + ).profile + : undefined; + const harnessPolicy = resolveAgentHarnessPolicy({ + provider: resolved.ref.provider, + modelId: resolved.ref.model, + config: lifecycleConfig, + agentId: target.agentId, + sessionKey: target.sessionKey, + }); + const agentRuntimeId = + harnessPolicy.runtimeSource !== "implicit" || + lifecycleConfig.plugins?.entries?.codex?.enabled === true + ? harnessPolicy.runtime + : undefined; + const sessionProfileId = await dependencies.resolveSessionAuthProfile({ + cfg: lifecycleConfig, + provider: resolved.ref.provider, + acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({ + provider: resolved.ref.provider, + harnessRuntime: harnessPolicy.runtime, + config: lifecycleConfig, }), - workspaceDir, - ); - // Route projection and credential selection are one decision. Pin even an - // automatic profile so generic auth fallback cannot cross to another route. - const prepared = await dependencies.prepareModel({ - cfg: modelConfig, - provider: resolved.ref.provider, - modelId: resolved.ref.model, - agentDir, - ...(selectedProfile ? { profileId: selectedProfile.id } : {}), - ...(selectedProfile ? { preferredProfile: selectedProfile.id } : {}), - ...(selectedProfile ? { bindAuthOwner: true } : {}), - allowMissingApiKeyModes: ["aws-sdk"], - useAsyncModelResolution: true, - modelResolver, - }); - return { - provider: resolved.ref.provider, - model: resolved.ref.model, - agentDir, - workspaceDir, - prepared, - }; + agentDir, + sessionEntry: target.sessionEntry, + sessionStore: target.sessionStore, + sessionKey: target.sessionKey, + storePath: target.storePath, + isNewSession: false, + }); + const sessionProfileSource = resolveReturnedProfileSource( + target.sessionEntry, + sessionProfileId, + ); + const selectedProfile = + sessionProfileId && sessionProfileSource === "user" + ? { id: sessionProfileId, source: sessionProfileSource } + : configuredDefaultProfile + ? { id: configuredDefaultProfile, source: "user" as const } + : sessionProfileId + ? { id: sessionProfileId, source: sessionProfileSource } + : undefined; + const modelConfig = projectWorkerInferenceModelRouteConfig({ + config: lifecycleConfig, + provider: resolved.ref.provider, + modelId: resolved.ref.model, + authMode: selectedProfile + ? dependencies.resolveAuthProfileMode({ + config: lifecycleConfig, + agentDir, + profileId: selectedProfile.id, + }) + : undefined, + }); + const modelResolver = bindSimpleCompletionModelResolverWorkspace( + (provider, modelId, resolvedAgentDir, cfg, options) => + dependencies.resolveModel(provider, modelId, resolvedAgentDir, cfg, { + ...options, + authStorage: preparedStores.authStorage, + modelRegistry: preparedStores.modelRegistry, + ...(agentRuntimeId ? { agentRuntimeId } : {}), + workspaceDir, + }), + workspaceDir, + ); + // Route projection and credential selection are one decision. Pin even an + // automatic profile so generic auth fallback cannot cross to another route. + const prepared = await dependencies.prepareModel({ + cfg: modelConfig, + provider: resolved.ref.provider, + modelId: resolved.ref.model, + agentDir, + ...(selectedProfile ? { profileId: selectedProfile.id } : {}), + ...(selectedProfile ? { preferredProfile: selectedProfile.id } : {}), + ...(selectedProfile ? { bindAuthOwner: true } : {}), + allowMissingApiKeyModes: ["aws-sdk"], + useAsyncModelResolution: true, + modelResolver, + }); + return { + provider: resolved.ref.provider, + model: resolved.ref.model, + config: lifecycleConfig, + agentDir, + workspaceDir, + prepared, + release: runtimeLease.release, + }; + } catch (error) { + runtimeLease.release(); + throw error; + } } export function createWorkerInferenceExecutor(overrides?: object): WorkerInferenceExecutor; @@ -622,201 +642,209 @@ export function createWorkerInferenceExecutor( if (!approved) { return inferenceError("model-not-approved"); } - if ("error" in approved.prepared) { - return inferenceError("provider-error"); - } - // Keep logical identity separate from transport endpoint encoding. - const modelIdentity: WorkerInferenceModelIdentity = { - api: approved.prepared.model.api, - provider: approved.provider, - model: approved.model, - }; - const logicalModel = approved.prepared.model; - const llmRuntime = getModelLlmRuntime(logicalModel); - if (!llmRuntime) { - throw new Error("Prepared worker model has no lifecycle runtime owner"); - } - const providerModel = - logicalModel.provider === "openai" && logicalModel.api === "openai-chatgpt-responses" - ? { - ...logicalModel, - baseUrl: normalizeCodexResponsesBaseUrlForOpenAISdk(logicalModel.baseUrl), - } - : logicalModel; - const providerStream = dependencies.resolveProviderStream({ - model: providerModel, - cfg: config, - agentDir: approved.agentDir, - workspaceDir: approved.workspaceDir, - }); - const authValue = approved.prepared.auth.apiKey; - const streamAgent = { - streamFn: dependencies.resolveStream({ - llmRuntime, - currentStreamFn: llmRuntime.streamSimple, - ...(providerStream ? { providerStreamFn: providerStream } : {}), - sessionId: request.sessionId, - signal, - model: providerModel, - resolvedApiKey: authValue, - authProfileId: approved.prepared.auth.profileId, - }), - }; - const streamPolicyOptions: WorkerInferenceStartParams["options"] = { - ...(request.options.temperature !== undefined - ? { temperature: request.options.temperature } - : {}), - ...(request.options.maxTokens !== undefined ? { maxTokens: request.options.maxTokens } : {}), - ...(request.options.reasoning !== undefined ? { reasoning: request.options.reasoning } : {}), - ...(request.options.thinkingBudgets - ? { thinkingBudgets: { ...request.options.thinkingBudgets } } - : {}), - }; - dependencies.applyStreamPolicy( - streamAgent, - config, - approved.provider, - approved.model, - streamPolicyOptions, - streamPolicyOptions.reasoning, - target.agentId, - approved.workspaceDir, - providerModel, - approved.agentDir, - ); - const scopedStream = streamAgent.streamFn; - const model = providerModel; - if (!optionBudgetsFitModel(request.options, model)) { - return inferenceError("invalid-context"); - } - if (signal.aborted || !params.isCurrent()) { - return inferenceError("cancelled"); - } - - const startedAt = dependencies.now(); - const trace = dependencies.createTrace(); - let modelCallSeq = 0; - const stream = dependencies.wrapStream(scopedStream, { - runId: request.runId, - sessionKey: target.sessionKey, - sessionId: request.sessionId, - provider: model.provider, - model: model.id, - api: model.api, - contextTokenBudget: model.contextTokens ?? model.contextWindow, - trace, - contentCapture: resolveDiagnosticModelContentCapturePolicy(config), - nextCallId: () => `${request.runId}:${request.turnId}:worker-model:${(modelCallSeq += 1)}`, - }); - let usageRecorded = false; - const recordUsage = (usage: Usage) => { - if (usageRecorded) { - return; - } - usageRecorded = true; - dependencies.recordUsage({ - config, - target, - request, - model, - usage, - durationMs: Math.max(0, dependencies.now() - startedAt), - trace, - }); - }; - const executionIsCurrent = () => !signal.aborted && params.isCurrent(); - const toolCalls = createWorkerToolCallStream({ - emit: params.emit, - isCurrent: executionIsCurrent, - }); - - const providerAbort = new AbortController(); - const providerSignal = AbortSignal.any([signal, providerAbort.signal]); try { - const events = await stream( - model, - context, - buildStreamOptions({ - request, - signal: providerSignal, - apiKey: authValue, + if ("error" in approved.prepared) { + return inferenceError("provider-error"); + } + // Keep logical identity separate from transport endpoint encoding. + const modelIdentity: WorkerInferenceModelIdentity = { + api: approved.prepared.model.api, + provider: approved.provider, + model: approved.model, + }; + const logicalModel = approved.prepared.model; + const llmRuntime = getModelLlmRuntime(logicalModel); + if (!llmRuntime) { + throw new Error("Prepared worker model has no lifecycle runtime owner"); + } + const providerModel = + logicalModel.provider === "openai" && logicalModel.api === "openai-chatgpt-responses" + ? { + ...logicalModel, + baseUrl: normalizeCodexResponsesBaseUrlForOpenAISdk(logicalModel.baseUrl), + } + : logicalModel; + const providerStream = dependencies.resolveProviderStream({ + model: providerModel, + cfg: approved.config, + agentDir: approved.agentDir, + workspaceDir: approved.workspaceDir, + }); + const authValue = approved.prepared.auth.apiKey; + const streamAgent = { + streamFn: dependencies.resolveStream({ + llmRuntime, + currentStreamFn: llmRuntime.streamSimple, + ...(providerStream ? { providerStreamFn: providerStream } : {}), + sessionId: request.sessionId, + signal, + model: providerModel, + resolvedApiKey: authValue, + authProfileId: approved.prepared.auth.profileId, }), + }; + const streamPolicyOptions: WorkerInferenceStartParams["options"] = { + ...(request.options.temperature !== undefined + ? { temperature: request.options.temperature } + : {}), + ...(request.options.maxTokens !== undefined + ? { maxTokens: request.options.maxTokens } + : {}), + ...(request.options.reasoning !== undefined + ? { reasoning: request.options.reasoning } + : {}), + ...(request.options.thinkingBudgets + ? { thinkingBudgets: { ...request.options.thinkingBudgets } } + : {}), + }; + dependencies.applyStreamPolicy( + streamAgent, + approved.config, + approved.provider, + approved.model, + streamPolicyOptions, + streamPolicyOptions.reasoning, + target.agentId, + approved.workspaceDir, + providerModel, + approved.agentDir, ); - for await (const event of events) { - if (event.type === "done") { - recordUsage(event.message.usage); - if (signal.aborted || !params.isCurrent()) { - return inferenceError("cancelled", event.message.usage); - } - for (const [contentIndex, content] of event.message.content.entries()) { - if (content.type === "toolCall") { - const endResult = toolCalls.end(contentIndex, event.message, content); - if (endResult === "cancelled") { - return inferenceError("cancelled", event.message.usage); - } - if (endResult === "invalid") { - return inferenceError("provider-error"); + const scopedStream = streamAgent.streamFn; + const model = providerModel; + if (!optionBudgetsFitModel(request.options, model)) { + return inferenceError("invalid-context"); + } + if (signal.aborted || !params.isCurrent()) { + return inferenceError("cancelled"); + } + + const startedAt = dependencies.now(); + const trace = dependencies.createTrace(); + let modelCallSeq = 0; + const stream = dependencies.wrapStream(scopedStream, { + runId: request.runId, + sessionKey: target.sessionKey, + sessionId: request.sessionId, + provider: model.provider, + model: model.id, + api: model.api, + contextTokenBudget: model.contextTokens ?? model.contextWindow, + trace, + contentCapture: resolveDiagnosticModelContentCapturePolicy(approved.config), + nextCallId: () => `${request.runId}:${request.turnId}:worker-model:${(modelCallSeq += 1)}`, + }); + let usageRecorded = false; + const recordUsage = (usage: Usage) => { + if (usageRecorded) { + return; + } + usageRecorded = true; + dependencies.recordUsage({ + config: approved.config, + target, + request, + model, + usage, + durationMs: Math.max(0, dependencies.now() - startedAt), + trace, + }); + }; + const executionIsCurrent = () => !signal.aborted && params.isCurrent(); + const toolCalls = createWorkerToolCallStream({ + emit: params.emit, + isCurrent: executionIsCurrent, + }); + + const providerAbort = new AbortController(); + const providerSignal = AbortSignal.any([signal, providerAbort.signal]); + try { + const events = await stream( + model, + context, + buildStreamOptions({ + request, + signal: providerSignal, + apiKey: authValue, + }), + ); + for await (const event of events) { + if (event.type === "done") { + recordUsage(event.message.usage); + if (signal.aborted || !params.isCurrent()) { + return inferenceError("cancelled", event.message.usage); + } + for (const [contentIndex, content] of event.message.content.entries()) { + if (content.type === "toolCall") { + const endResult = toolCalls.end(contentIndex, event.message, content); + if (endResult === "cancelled") { + return inferenceError("cancelled", event.message.usage); + } + if (endResult === "invalid") { + return inferenceError("provider-error"); + } } } + if (!toolCalls.matchesTerminal(event.message)) { + return inferenceError("provider-error"); + } + return { + type: "done", + message: projectWorkerInferenceTerminalMessage({ + message: event.message, + modelIdentity, + stopReason: event.reason, + }), + }; } - if (!toolCalls.matchesTerminal(event.message)) { - return inferenceError("provider-error"); + if (event.type === "error") { + recordUsage(event.error.usage); + return inferenceError( + event.reason === "aborted" ? "cancelled" : "provider-error", + event.error.usage, + ); } - return { - type: "done", - message: projectWorkerInferenceTerminalMessage({ - message: event.message, - modelIdentity, - stopReason: event.reason, - }), - }; - } - if (event.type === "error") { - recordUsage(event.error.usage); - return inferenceError( - event.reason === "aborted" ? "cancelled" : "provider-error", - event.error.usage, - ); - } - if (signal.aborted || !params.isCurrent()) { - return inferenceError("cancelled"); - } - if (event.type === "toolcall_start") { - if (toolCalls.start(event.contentIndex, event.partial) === "cancelled") { + if (signal.aborted || !params.isCurrent()) { return inferenceError("cancelled"); } - continue; - } - if (event.type === "toolcall_delta") { - const deltaResult = toolCalls.delta(event.contentIndex, event.delta, event.partial); - if (deltaResult === "cancelled") { - return inferenceError("cancelled"); + if (event.type === "toolcall_start") { + if (toolCalls.start(event.contentIndex, event.partial) === "cancelled") { + return inferenceError("cancelled"); + } + continue; } - if (deltaResult === "invalid") { - return inferenceError("provider-error"); + if (event.type === "toolcall_delta") { + const deltaResult = toolCalls.delta(event.contentIndex, event.delta, event.partial); + if (deltaResult === "cancelled") { + return inferenceError("cancelled"); + } + if (deltaResult === "invalid") { + return inferenceError("provider-error"); + } + continue; } - continue; - } - if (event.type === "toolcall_end") { - const endResult = toolCalls.end(event.contentIndex, event.partial, event.toolCall); - if (endResult === "cancelled") { - return inferenceError("cancelled"); + if (event.type === "toolcall_end") { + const endResult = toolCalls.end(event.contentIndex, event.partial, event.toolCall); + if (endResult === "cancelled") { + return inferenceError("cancelled"); + } + if (endResult === "invalid") { + return inferenceError("provider-error"); + } + continue; } - if (endResult === "invalid") { - return inferenceError("provider-error"); + const workerEvent = toWorkerStreamEvent(event, modelIdentity); + if (workerEvent) { + params.emit(workerEvent); } - continue; - } - const workerEvent = toWorkerStreamEvent(event, modelIdentity); - if (workerEvent) { - params.emit(workerEvent); } + return inferenceError(signal.aborted ? "cancelled" : "provider-error"); + } catch { + return inferenceError(signal.aborted ? "cancelled" : "provider-error"); + } finally { + providerAbort.abort(); } - return inferenceError(signal.aborted ? "cancelled" : "provider-error"); - } catch { - return inferenceError(signal.aborted ? "cancelled" : "provider-error"); } finally { - providerAbort.abort(); + approved.release(); } }; } diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index 28ea010f47e..e005ffa483f 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -120,6 +120,7 @@ describe("control UI assets helpers (fs-mocked)", () => { const root = abs("fixtures/build-failure"); const argv1 = path.join(root, "src", "index.ts"); const originalArgv1 = process.argv[1]; + setFile(path.join(root, "package.json"), '{"name":"openclaw"}\n'); setFile(path.join(root, "ui", "vite.config.ts"), "export {};\n"); setFile(path.join(root, "scripts", "ui.js"), ""); state.runCommandWithTimeout.mockResolvedValueOnce({ diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index b95f0771c74..644e995f42f 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -82,11 +82,8 @@ function readGatewayRunLoopSource(): string { return readFileSync(new URL("../cli/gateway-cli/run-loop.ts", import.meta.url), "utf8"); } -function readAgentModelDiscoveryCacheSource(): string { - return readFileSync( - new URL("../agents/embedded-agent-runner/model-discovery-cache.ts", import.meta.url), - "utf8", - ); +function readAgentAuthDiscoverySource(): string { + return readFileSync(new URL("../agents/agent-auth-discovery.ts", import.meta.url), "utf8"); } describe("tsdown config", () => { @@ -174,12 +171,12 @@ describe("tsdown config", () => { it("keeps PI model discovery synthetic auth refs behind one stable runtime dist entry", () => { const distGraph = requireUnifiedDistGraph(); const importSpecifiers = [ - ...readAgentModelDiscoveryCacheSource().matchAll( + ...readAgentAuthDiscoverySource().matchAll( /from ["']([^"']*synthetic-auth\.runtime\.js)["']/gu, ), ].map((match) => match[1]); - expect(importSpecifiers).toEqual(["../../plugins/synthetic-auth.runtime.js"]); + expect(importSpecifiers).toEqual(["../plugins/synthetic-auth.runtime.js"]); expect(entrySources(distGraph)["plugins/synthetic-auth.runtime"]).toBe( "src/plugins/synthetic-auth.runtime.ts", ); diff --git a/src/media-understanding/image-model-runtime.ts b/src/media-understanding/image-model-runtime.ts index 02d9fffe733..0f94593fa33 100644 --- a/src/media-understanding/image-model-runtime.ts +++ b/src/media-understanding/image-model-runtime.ts @@ -1,4 +1,5 @@ // Resolves image-capable model metadata and credential-bound runtime auth. +import { resolveAgentWorkspaceDir, resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { resolveModelAsync } from "../agents/embedded-agent-runner/model.js"; import { isMinimaxVlmModel } from "../agents/minimax-vlm.js"; import { @@ -7,7 +8,7 @@ import { requireApiKey, } from "../agents/model-auth.js"; import { normalizeModelRef } from "../agents/model-selection.js"; -import { ensureOpenClawModelsJson } from "../agents/models-config.js"; +import { acquireAgentRunPreparedModelRuntime } from "../agents/prepared-model-runtime.js"; import { resolveProviderModelMaterializationAuthMode } from "../agents/provider-model-route-auth.js"; import { protectPreparedProviderRuntimeAuth } from "../agents/provider-secret-egress.js"; import { providerUsesCredentialScopedModelMetadata } from "../agents/runtime-plan/credential-scoped-model.js"; @@ -25,9 +26,45 @@ type ImageRuntimeParams = { profile?: string; preferredProfile?: string; authStore?: ImageDescriptionRequest["authStore"]; + agentId?: string; + workspaceDir?: string; + preparedModelRuntime?: ImageDescriptionRequest["preparedModelRuntime"]; +}; + +type ResolvedImageRuntimeContext = { + cfg: ImageRuntimeParams["cfg"]; + agentDir: string; workspaceDir?: string; }; +type PreparedImageRuntime = { + runtimeValue: string; + model: Model; +}; + +type ResolvedImageRuntime = PreparedImageRuntime & { release: () => void }; + +const resolvedImageRuntimeContexts = new WeakMap(); + +export function getResolvedImageRuntimeContext( + model: Model, +): ResolvedImageRuntimeContext | undefined { + return resolvedImageRuntimeContexts.get(model); +} + +function bindResolvedImageRuntime( + params: ImageRuntimeParams, + apiKey: string, + model: Model, +): PreparedImageRuntime { + resolvedImageRuntimeContexts.set(model, { + cfg: params.cfg, + agentDir: params.agentDir, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); + return { runtimeValue: apiKey, model }; +} + function formatModelInputCapabilities(input: Model["input"] | undefined): string { return input && input.length > 0 ? input.join(", ") : "none"; } @@ -60,7 +97,7 @@ async function prepareResolvedImageRuntime( resolvedModel: Model, authStorage: Awaited>["authStorage"], modelRegistry: Awaited>["modelRegistry"], -): Promise<{ apiKey: string; model: Model }> { +): Promise { let model = resolvedModel; const modelRuntime = getModelRegistryRuntime(modelRegistry); const apiKeyInfo = await getApiKeyForModel({ @@ -116,13 +153,14 @@ async function prepareResolvedImageRuntime( apiKeyInfo.mode === "aws-sdk" && model.api === "bedrock-converse-stream" ) { - return { - apiKey: "", - model: bindModelLlmRuntime( + return bindResolvedImageRuntime( + params, + "", + bindModelLlmRuntime( applySecretRefHeaderSentinels(model, params.cfg), modelRuntime.llmRuntime, ), - }; + ); } let apiKey = requireApiKey(apiKeyInfo, model.provider); const preparedAuth = protectPreparedProviderRuntimeAuth({ @@ -151,85 +189,126 @@ async function prepareResolvedImageRuntime( model = { ...model, baseUrl: runtimeBaseUrl }; } authStorage.setRuntimeApiKey(model.provider, apiKey); - return { + return bindResolvedImageRuntime( + params, apiKey, - model: bindModelLlmRuntime( - applySecretRefHeaderSentinels(model, params.cfg), - modelRuntime.llmRuntime, - ), - }; + bindModelLlmRuntime(applySecretRefHeaderSentinels(model, params.cfg), modelRuntime.llmRuntime), + ); } export async function resolveImageRuntime( params: ImageRuntimeParams, -): Promise<{ apiKey: string; model: Model }> { - // Fast static resolution avoids provider runtime hooks during tool discovery; - // execution falls back to full model discovery when static metadata lacks images. +): Promise { + // Fast static resolution avoids provider runtime hooks during tool discovery. The bounded lease + // admits dynamic workspaces before attachment preprocessing reaches the embedded run boundary. const resolvedRef = normalizeModelRef(params.provider, params.model); + const workspaceDir = + params.workspaceDir ?? + (params.agentId ? resolveAgentWorkspaceDir(params.cfg ?? {}, params.agentId) : undefined); + const runtimeParams = workspaceDir ? { ...params, workspaceDir } : params; const authProfileOptions = { ...(params.profile ? { authProfileId: params.profile } : {}), ...(params.preferredProfile ? { preferredProfile: params.preferredProfile } : {}), }; - const fastResolved = await resolveModelAsync( - resolvedRef.provider, - resolvedRef.model, - params.agentDir, - params.cfg, - { - allowBundledStaticCatalogFallback: true, - skipAgentDiscovery: true, - skipProviderRuntimeHooks: true, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - ...authProfileOptions, - }, - ); - if (fastResolved.model?.input?.includes("image")) { - const normalizedResolved = await resolveModelAsync( + const preparedRuntimeLease = params.preparedModelRuntime + ? { snapshot: params.preparedModelRuntime, release: () => {} } + : await acquireAgentRunPreparedModelRuntime({ + agentDir: params.agentDir, + ...(params.agentId ? { agentId: params.agentId } : {}), + config: params.cfg ?? {}, + inheritedAuthDir: resolveDefaultAgentDir(params.cfg ?? {}), + ...(runtimeParams.workspaceDir ? { workspaceDir: runtimeParams.workspaceDir } : {}), + }); + let leaseRetained = false; + const retainLease = (resolved: PreparedImageRuntime): ResolvedImageRuntime => { + leaseRetained = true; + return { ...resolved, release: preparedRuntimeLease.release }; + }; + try { + const preparedRuntime = preparedRuntimeLease.snapshot; + const preparedWorkspaceDir = preparedRuntime.workspaceDir ?? runtimeParams.workspaceDir; + const preparedParams: ImageRuntimeParams = { + ...runtimeParams, + agentDir: preparedRuntime.agentDir, + cfg: preparedRuntime.config, + ...(preparedWorkspaceDir ? { workspaceDir: preparedWorkspaceDir } : {}), + }; + // Media request types carry this agent-owned handle opaquely to avoid importing the agent + // runtime graph into provider contracts. This is the sole boundary that consumes its stores. + const preparedStores = preparedRuntime.createStores() as Required< + Pick[4]>, "authStorage" | "modelRegistry"> + >; + const fastResolved = await resolveModelAsync( resolvedRef.provider, resolvedRef.model, - params.agentDir, - params.cfg, + preparedParams.agentDir, + preparedParams.cfg, { allowBundledStaticCatalogFallback: true, + ...preparedStores, skipAgentDiscovery: true, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + skipProviderRuntimeHooks: true, + ...(preparedParams.workspaceDir ? { workspaceDir: preparedParams.workspaceDir } : {}), ...authProfileOptions, }, ); - if (normalizedResolved.model?.input?.includes("image")) { - return await prepareResolvedImageRuntime( - params, - normalizedResolved.model, - normalizedResolved.authStorage, - normalizedResolved.modelRegistry, + if (fastResolved.model?.input?.includes("image")) { + const normalizedResolved = await resolveModelAsync( + resolvedRef.provider, + resolvedRef.model, + preparedParams.agentDir, + preparedParams.cfg, + { + allowBundledStaticCatalogFallback: true, + ...preparedStores, + skipAgentDiscovery: true, + ...(preparedParams.workspaceDir ? { workspaceDir: preparedParams.workspaceDir } : {}), + ...authProfileOptions, + }, ); + if (normalizedResolved.model?.input?.includes("image")) { + return retainLease( + await prepareResolvedImageRuntime( + preparedParams, + normalizedResolved.model, + normalizedResolved.authStorage, + normalizedResolved.modelRegistry, + ), + ); + } + } + + const resolved = await resolveModelAsync( + resolvedRef.provider, + resolvedRef.model, + preparedParams.agentDir, + preparedParams.cfg, + { + allowBundledStaticCatalogFallback: true, + ...preparedStores, + skipAgentDiscovery: true, + ...(preparedParams.workspaceDir ? { workspaceDir: preparedParams.workspaceDir } : {}), + ...authProfileOptions, + }, + ); + const model = requireImageCapableModel({ + model: resolved.model, + resolvedProvider: resolvedRef.provider, + resolvedModel: resolvedRef.model, + requestedProvider: params.provider, + requestedModel: params.model, + }); + return retainLease( + await prepareResolvedImageRuntime( + preparedParams, + model, + resolved.authStorage, + resolved.modelRegistry, + ), + ); + } finally { + if (!leaseRetained) { + preparedRuntimeLease.release(); } } - - const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined; - await ensureOpenClawModelsJson(params.cfg, params.agentDir, modelsOptions); - const resolved = await resolveModelAsync( - resolvedRef.provider, - resolvedRef.model, - params.agentDir, - params.cfg, - { - allowBundledStaticCatalogFallback: true, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - ...authProfileOptions, - }, - ); - const model = requireImageCapableModel({ - model: resolved.model, - resolvedProvider: resolvedRef.provider, - resolvedModel: resolvedRef.model, - requestedProvider: params.provider, - requestedModel: params.model, - }); - return await prepareResolvedImageRuntime( - params, - model, - resolved.authStorage, - resolved.modelRegistry, - ); } diff --git a/src/media-understanding/image.runtime-profile.test.ts b/src/media-understanding/image.runtime-profile.test.ts new file mode 100644 index 00000000000..7723b7ba131 --- /dev/null +++ b/src/media-understanding/image.runtime-profile.test.ts @@ -0,0 +1,885 @@ +// Image runtime tests cover model-backed image routing, auth/profile handling, +// provider payload transforms, and MiniMax/Copilot special paths. +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + looksLikeSecretSentinel, + mintSecretSentinel, + resolveSecretSentinel, +} from "../secrets/sentinel.js"; + +const API_KEY_FIELD = ["api", "Key"].join("") as "apiKey"; +const REQUIRE_API_KEY_FIELD = ["require", "ApiKey"].join(""); +const SET_RUNTIME_API_KEY_FIELD = ["setRuntime", "ApiKey"].join(""); + +const hoisted = vi.hoisted(() => ({ + completeMock: vi.fn(), + ensureOpenClawModelsJsonMock: vi.fn(async () => {}), + getApiKeyForModelMock: vi.fn( + async (): Promise<{ + apiKey: string; + source: string; + mode: string; + profileId?: string; + }> => ({ + [API_KEY_FIELD]: "test-token", + source: "test", + mode: "oauth", + }), + ), + resolveApiKeyForProviderMock: vi.fn(async () => ({ + [API_KEY_FIELD]: "test-token", + source: "test", + mode: "oauth", + })), + requireApiKeyMock: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""), + setRuntimeApiKeyMock: vi.fn(), + discoverModelsMock: vi.fn(), + fetchMock: vi.fn(), + registerProviderStreamForModelMock: vi.fn(), + prepareProviderDynamicModelMock: vi.fn(async () => {}), + prepareProviderRuntimeAuthMock: vi.fn(), + acquireAgentRunPreparedModelRuntimeMock: vi.fn(), + releasePreparedModelRuntimeMock: vi.fn(), + resolveModelAsyncMock: vi.fn(), + resolveModelWithRegistryMock: vi.fn(), + shouldPreferProviderRuntimeResolvedModelMock: vi.fn(() => false), + unwrapSecretSentinelsForProviderEgressMock: vi.fn((value: string) => value), +})); +const { + completeMock, + ensureOpenClawModelsJsonMock, + getApiKeyForModelMock, + resolveApiKeyForProviderMock, + requireApiKeyMock, + setRuntimeApiKeyMock, + discoverModelsMock, + fetchMock, + registerProviderStreamForModelMock, + prepareProviderDynamicModelMock, + prepareProviderRuntimeAuthMock, + acquireAgentRunPreparedModelRuntimeMock, + releasePreparedModelRuntimeMock, + resolveModelAsyncMock, + resolveModelWithRegistryMock, + shouldPreferProviderRuntimeResolvedModelMock, + unwrapSecretSentinelsForProviderEgressMock, +} = hoisted; +const preparedAuthStorage = { [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock }; + +type ResolveModelWithRegistryTestParams = { + modelRegistry: { find: (provider: string, modelId: string) => unknown }; + provider: string; + modelId: string; +}; + +type AuthRequestCall = { + profileId?: string; + preferredProfile?: string; + store?: unknown; +}; + +function requireMockCallAt( + mock: { mock: { calls: Calls } }, + index: number, + label: string, +): Calls[number] { + // Tests inspect exact dependency calls because image runtime behavior is + // mostly provider/auth orchestration. + const call = mock.mock.calls[index]; + if (!call) { + throw new Error(`Expected ${label} call ${index}`); + } + return call as Calls[number]; +} + +function requireFirstMockCall( + mock: { mock: { calls: Calls } }, + label: string, +): Calls[number] { + return requireMockCallAt(mock, 0, label); +} + +vi.mock("../llm/stream.js", async () => { + const actual = await vi.importActual("../llm/stream.js"); + return { + ...actual, + complete: completeMock, + }; +}); + +vi.mock("../agents/models-config.js", async () => ({ + ...(await vi.importActual( + "../agents/models-config.js", + )), + ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock, +})); + +vi.mock("../agents/model-auth.js", () => ({ + applySecretRefHeaderSentinels: (model: unknown) => model, + getApiKeyForModel: getApiKeyForModelMock, + resolveApiKeyForProvider: resolveApiKeyForProviderMock, + [REQUIRE_API_KEY_FIELD]: requireApiKeyMock, +})); + +vi.mock("../agents/provider-stream.js", () => ({ + registerProviderStreamForModel: registerProviderStreamForModelMock, +})); + +vi.mock("../agents/sessions/model-registry-runtime.js", () => ({ + getModelRegistryRuntime: () => ({ apiRegistry: {}, llmRuntime: {} }), +})); + +vi.mock("../agents/provider-secret-egress.js", async () => ({ + ...(await vi.importActual( + "../agents/provider-secret-egress.js", + )), + unwrapSecretSentinelsForProviderEgress: unwrapSecretSentinelsForProviderEgressMock, +})); + +vi.mock("../agents/agent-model-discovery.js", () => ({ + discoverAuthStorage: () => ({ + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, + }), + discoverModels: discoverModelsMock, +})); + +vi.mock("../agents/prepared-model-runtime.js", () => ({ + acquireAgentRunPreparedModelRuntime: acquireAgentRunPreparedModelRuntimeMock, +})); + +vi.mock("../plugins/provider-runtime.js", async () => ({ + ...(await vi.importActual( + "../plugins/provider-runtime.js", + )), + prepareProviderDynamicModel: prepareProviderDynamicModelMock, + shouldPreferProviderRuntimeResolvedModel: shouldPreferProviderRuntimeResolvedModelMock, +})); + +vi.mock("../plugins/provider-runtime.runtime.js", () => ({ + prepareProviderRuntimeAuth: prepareProviderRuntimeAuthMock, +})); + +vi.mock("../agents/embedded-agent-runner/model.js", () => ({ + resolveModelAsync: resolveModelAsyncMock, +})); + +vi.mock("../plugin-sdk/provider-auth.js", () => ({ + buildCopilotIdeHeaders: () => ({ + "Editor-Version": "vscode/1.107.0", + "User-Agent": "GitHubCopilotChat/0.35.0", + }), + COPILOT_INTEGRATION_ID: "vscode-chat", +})); + +const imageTestFetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); +vi.mock("../infra/net/fetch-guard.js", async () => { + const mod = await vi.importActual( + "../infra/net/fetch-guard.js", + ); + return { + ...mod, + fetchWithSsrFGuard: imageTestFetchWithSsrFGuardMock, + }; +}); + +const { describeImageWithModel } = await import("./image.js"); + +describe("describeImageWithModel", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + beforeEach(() => { + // Provider endpoint policy comes from manifests. Pin source manifests so a + // prior local build cannot make this source-checkout test read partial dist output. + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(process.cwd(), "extensions")); + vi.stubGlobal("fetch", fetchMock); + vi.clearAllMocks(); + acquireAgentRunPreparedModelRuntimeMock.mockImplementation( + async (input: { agentDir: string; config: object; workspaceDir?: string }) => ({ + snapshot: { + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + createStores: () => ({ + authStorage: preparedAuthStorage, + modelRegistry: {}, + }), + }, + release: releasePreparedModelRuntimeMock, + }), + ); + fetchMock.mockImplementation(async () => + Response.json({ + base_resp: { status_code: 0 }, + content: "portal ok", + }), + ); + // Bridge fetchWithSsrFGuard through the globally-stubbed fetch so existing + // assertions on fetchMock call count and arguments continue to work. + imageTestFetchWithSsrFGuardMock.mockImplementation( + async (opts: { url: string; init: RequestInit; timeoutMs?: number }) => { + const signal = AbortSignal.timeout(opts.timeoutMs ?? 60_000); + const init = { ...opts.init, signal }; + const response = await globalThis.fetch(opts.url, init); + return { response, release: vi.fn(), finalUrl: opts.url }; + }, + ); + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "minimax-portal", + id: "MiniMax-VL-01", + input: ["text", "image"], + baseUrl: "https://api.minimax.io/anthropic", + })), + }); + resolveModelWithRegistryMock.mockImplementation( + // Delegate to modelRegistry.find so tests that override discoverModelsMock + // automatically get the right model through resolveModelWithRegistry. + ({ modelRegistry, provider, modelId }: ResolveModelWithRegistryTestParams) => + modelRegistry.find(provider, modelId), + ); + resolveModelAsyncMock.mockImplementation( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => { + const authStorage = { + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, + }; + const modelRegistry = discoverModelsMock(authStorage, agentDir); + const model = resolveModelWithRegistryMock({ + provider, + modelId, + modelRegistry, + cfg, + agentDir, + }); + return { authStorage, model, modelRegistry }; + }, + ); + prepareProviderRuntimeAuthMock.mockImplementation(async (params: { provider: string }) => { + return params.provider === "github-copilot" + ? { + [API_KEY_FIELD]: "test-token", + baseUrl: "https://api.githubcopilot.com", + } + : undefined; + }); + }); + + function getApiKeyForModelCall(index = 0): AuthRequestCall { + const call = (getApiKeyForModelMock.mock.calls as unknown[][]).at(index); + if (!call) { + throw new Error(`Expected getApiKeyForModel call ${index}`); + } + return call[0] as AuthRequestCall; + } + + it("normalizes deprecated google flash ids and keeps profile model/auth selection", async () => { + const findMock = vi.fn((provider: string, modelId: string) => { + expect(provider).toBe("google"); + expect(modelId).toBe("gemini-3-flash-preview"); + return { + provider: "google", + id: "gemini-3-flash-preview", + input: ["text", "image"], + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + }; + }); + discoverModelsMock.mockReturnValue({ find: findMock }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "google-generative-ai", + provider: "google", + model: "gemini-3-flash-preview", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "flash ok" }], + }); + + const result = await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "google", + model: "gemini-3.1-flash-preview", + profile: "google:default", + preferredProfile: "google:preferred", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(result).toEqual({ + text: "flash ok", + model: "gemini-3-flash-preview", + }); + expect(findMock).toHaveBeenCalled(); + for (const call of resolveModelAsyncMock.mock.calls) { + expect(call[4]).toEqual( + expect.objectContaining({ + authProfileId: "google:default", + preferredProfile: "google:preferred", + }), + ); + } + const authRequest = getApiKeyForModelCall(); + expect(authRequest?.profileId).toBe("google:default"); + expect(authRequest?.preferredProfile).toBe("google:preferred"); + expect(setRuntimeApiKeyMock).toHaveBeenCalledWith("google", "test-token"); + }); + + it("keeps stable GA gemini 3.1 flash-lite ids during lookup and keeps profile auth selection", async () => { + const findMock = vi.fn((provider: string, modelId: string) => { + expect(provider).toBe("google"); + expect(modelId).toBe("gemini-3.1-flash-lite"); + return { + provider: "google", + id: "gemini-3.1-flash-lite", + input: ["text", "image"], + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + }; + }); + discoverModelsMock.mockReturnValue({ find: findMock }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "google-generative-ai", + provider: "google", + model: "gemini-3.1-flash-lite", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "flash lite ok" }], + }); + + const result = await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "google", + model: "gemini-3.1-flash-lite", + profile: "google:default", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(result).toEqual({ + text: "flash lite ok", + model: "gemini-3.1-flash-lite", + }); + expect(findMock).toHaveBeenCalled(); + const authRequest = getApiKeyForModelCall(); + expect(authRequest?.profileId).toBe("google:default"); + expect(setRuntimeApiKeyMock).toHaveBeenCalledWith("google", "test-token"); + }); + + it("rematerializes profile-scoped image metadata after auth selects a backup profile", async () => { + const authStorage = { [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock }; + const modelRegistry = {}; + const hintedModel = { + provider: "github-copilot", + id: "gpt-5.6-sol", + api: "openai-responses", + input: ["text", "image"], + contextWindow: 200_000, + maxTokens: 64_000, + }; + const authoritativeModel = { + ...hintedModel, + contextWindow: 1_050_000, + maxTokens: 128_000, + }; + resolveModelAsyncMock + .mockResolvedValueOnce({ model: hintedModel, authStorage, modelRegistry }) + .mockResolvedValueOnce({ model: hintedModel, authStorage, modelRegistry }) + .mockResolvedValueOnce({ model: authoritativeModel, authStorage, modelRegistry }); + getApiKeyForModelMock.mockResolvedValueOnce({ + [API_KEY_FIELD]: "test-token", + source: "profile:github-copilot:backup", + mode: "token", + profileId: "github-copilot:backup", + }); + shouldPreferProviderRuntimeResolvedModelMock.mockReturnValueOnce(true); + completeMock.mockResolvedValue({ + role: "assistant", + api: "openai-responses", + provider: "github-copilot", + model: "gpt-5.6-sol", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "profile-scoped image ok" }], + }); + + await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "github-copilot", + model: "gpt-5.6-sol", + profile: "github-copilot:preferred", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(resolveModelAsyncMock).toHaveBeenCalledTimes(3); + expect(resolveModelAsyncMock.mock.calls[2]?.[4]).toEqual( + expect.objectContaining({ + authStorage, + modelRegistry, + authProfileId: "github-copilot:backup", + }), + ); + const [completionModel] = requireFirstMockCall(completeMock, "complete"); + expect(completionModel).toEqual( + expect.objectContaining({ + contextWindow: 1_050_000, + maxTokens: 128_000, + }), + ); + }); + + it("places image prompt in user content for github-copilot provider", async () => { + const providerStreamResult = { + role: "assistant", + api: "openai-completions", + provider: "github-copilot", + model: "gemini-3.1-pro-preview", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "A solid red square." }], + }; + const providerStreamFn = vi.fn((_model: unknown, _context: unknown, _options: unknown) => ({ + result: vi.fn(async () => providerStreamResult), + })); + registerProviderStreamForModelMock.mockReturnValueOnce(providerStreamFn); + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "github-copilot", + id: "gemini-3.1-pro-preview", + input: ["text", "image"], + api: "openai-completions", + baseUrl: "https://stale.example.test", + })), + }); + + await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "github-copilot", + model: "gemini-3.1-pro-preview", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(completeMock).not.toHaveBeenCalled(); + expect(providerStreamFn).toHaveBeenCalledOnce(); + expect(prepareProviderRuntimeAuthMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "github-copilot", + context: expect.objectContaining({ [API_KEY_FIELD]: "test-token", authMode: "oauth" }), + }), + ); + const storedValue = setRuntimeApiKeyMock.mock.calls[0]?.[1] as string; + expect(setRuntimeApiKeyMock.mock.calls[0]?.[0]).toBe("github-copilot"); + expect(looksLikeSecretSentinel(storedValue)).toBe(true); + expect(storedValue).not.toBe("test-token"); + expect(resolveSecretSentinel(storedValue)).toBe("test-token"); + const [completionModel, context, options] = providerStreamFn.mock.calls[0] as unknown as [ + { baseUrl?: string }, + { systemPrompt?: string; messages?: Array<{ role: string; content: unknown[] }> }, + { apiKey?: string; headers?: Record }, + ]; + expect(completionModel.baseUrl).toBe("https://api.githubcopilot.com"); + expect(options.apiKey).toBe(storedValue); + expect(options.headers).toMatchObject({ + "Copilot-Integration-Id": "vscode-chat", + "Copilot-Vision-Request": "true", + "Editor-Version": "vscode/1.107.0", + "User-Agent": "GitHubCopilotChat/0.35.0", + }); + expect(context.systemPrompt).toBeUndefined(); + const userMessage = context.messages?.find((m) => m.role === "user"); + expect(userMessage).toBeDefined(); + const contentTypes = userMessage!.content.map((block) => (block as { type: string }).type); + expect(contentTypes).toContain("text"); + expect(contentTypes).toContain("image"); + }); + + it("keeps an exchanged Copilot image token opaque for sentinel-backed auth", async () => { + const sourceValue = "test-token"; + const preparedValue = mintSecretSentinel(sourceValue, { + label: "model-auth:github-copilot", + }); + getApiKeyForModelMock.mockResolvedValueOnce({ + [API_KEY_FIELD]: preparedValue, + source: "test", + mode: "token", + }); + unwrapSecretSentinelsForProviderEgressMock.mockReturnValueOnce(sourceValue); + const providerStreamFn = vi.fn((_model: unknown, _context: unknown, _options: unknown) => ({ + result: vi.fn(async () => ({ + role: "assistant", + api: "openai-completions", + provider: "github-copilot", + model: "gpt-4.1", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "ok" }], + })), + })); + registerProviderStreamForModelMock.mockReturnValueOnce(providerStreamFn); + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "github-copilot", + id: "gpt-4.1", + input: ["text", "image"], + api: "openai-completions", + })), + }); + + await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "github-copilot", + model: "gpt-4.1", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + timeoutMs: 1000, + }); + + expect(prepareProviderRuntimeAuthMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "github-copilot", + context: expect.objectContaining({ [API_KEY_FIELD]: preparedValue, authMode: "token" }), + }), + ); + const storedValue = setRuntimeApiKeyMock.mock.calls[0]?.[1] as string; + expect(looksLikeSecretSentinel(storedValue)).toBe(true); + expect(resolveSecretSentinel(storedValue)).toBe("test-token"); + const streamOptions = providerStreamFn.mock.calls[0]?.[2] as { apiKey?: string }; + expect(streamOptions.apiKey).toBe(storedValue); + }); + + it("fails github-copilot image runtime setup when token exchange fails", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "github-copilot", + id: "gemini-3.1-pro-preview", + input: ["text", "image"], + api: "openai-completions", + baseUrl: "https://api.githubcopilot.com", + })), + }); + prepareProviderRuntimeAuthMock.mockRejectedValueOnce( + new Error("Copilot token exchange failed: HTTP 401"), + ); + + await expect( + describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "github-copilot", + model: "gemini-3.1-pro-preview", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }), + ).rejects.toThrow("Copilot token exchange failed: HTTP 401"); + + expect(setRuntimeApiKeyMock).not.toHaveBeenCalledWith("github-copilot", "test-token"); + expect(completeMock).not.toHaveBeenCalled(); + }); + + it("does not place image prompt in user content for non-copilot providers", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "openai", + id: "gpt-4o", + input: ["text", "image"], + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "openai-responses", + provider: "openai", + model: "gpt-4o", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "A solid red square." }], + }); + + await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "openai", + model: "gpt-4o", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(completeMock).toHaveBeenCalledOnce(); + const [, context] = completeMock.mock.calls[0] as [ + unknown, + { systemPrompt?: string; messages?: Array<{ role: string; content: unknown[] }> }, + ]; + // Non-Copilot providers keep prompt in system message, images in user message + expect(context.systemPrompt).toBe("Describe the image."); + const userMessage = context.messages?.find((m) => m.role === "user"); + expect(userMessage).toBeDefined(); + const contentTypes = userMessage!.content.map((block) => (block as { type: string }).type); + expect(contentTypes).not.toContain("text"); + expect(contentTypes).toContain("image"); + }); + + it("defaults image-describe maxTokens to 4096 for reasoning-capable VLMs", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + api: "openai-completions", + provider: "agent-plan", + id: "doubao-seed-2.0-pro", + input: ["text", "image"], + baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3", + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "openai-completions", + provider: "agent-plan", + model: "doubao-seed-2.0-pro", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "ok" }], + }); + + await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "agent-plan", + model: "doubao-seed-2.0-pro", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + const options = requireFirstMockCall(completeMock, "image completion")[2]; + expect(options.maxTokens).toBe(4096); + }); + + it("caps image-describe maxTokens by the resolved model's own maxTokens", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + api: "openai-completions", + provider: "fake", + id: "small-vlm", + input: ["text", "image"], + baseUrl: "https://example.test", + maxTokens: 1024, + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "openai-completions", + provider: "fake", + model: "small-vlm", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "ok" }], + }); + + await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "fake", + model: "small-vlm", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + const options = requireFirstMockCall(completeMock, "image completion")[2]; + expect(options.maxTokens).toBe(1024); + }); + + it("derives workspaceDir from agentId for image runtime resolution", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "google", + id: "gemini-2.5-flash", + api: "google-generative-ai", + input: ["text", "image"], + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "google-generative-ai", + provider: "google", + model: "gemini-2.5-flash", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "workspace ok" }], + }); + const cfg = { + agents: { + list: [ + { + id: "vision-agent", + agentDir: "/tmp/openclaw-agent", + workspace: "/tmp/openclaw-workspace", + }, + ], + }, + }; + + await describeImageWithModel({ + cfg, + agentId: "vision-agent", + agentDir: "/tmp/openclaw-agent", + provider: "google", + model: "gemini-2.5-flash", + buffer: Buffer.alloc(1), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(acquireAgentRunPreparedModelRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: "/tmp/openclaw-workspace" }), + ); + expect(resolveModelAsyncMock).toHaveBeenCalledWith( + "google", + "gemini-2.5-flash", + "/tmp/openclaw-agent", + cfg, + expect.objectContaining({ workspaceDir: "/tmp/openclaw-workspace" }), + ); + }); + + it("uses one committed prepared generation for image setup and streaming", async () => { + const requestedCfg: OpenClawConfig = { logging: { level: "info" } }; + const committedCfg: OpenClawConfig = { logging: { level: "debug" } }; + acquireAgentRunPreparedModelRuntimeMock.mockResolvedValueOnce({ + snapshot: { + agentDir: "/tmp/committed-agent", + config: committedCfg, + workspaceDir: "/tmp/committed-workspace", + createStores: () => ({ + authStorage: preparedAuthStorage, + modelRegistry: {}, + }), + }, + release: releasePreparedModelRuntimeMock, + }); + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "google", + id: "gemini-2.5-flash", + api: "google-generative-ai", + input: ["text", "image"], + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "google-generative-ai", + provider: "google", + model: "gemini-2.5-flash", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "committed runtime" }], + }); + + await describeImageWithModel({ + cfg: requestedCfg, + agentDir: "/tmp/requested-agent", + workspaceDir: "/tmp/requested-workspace", + provider: "google", + model: "gemini-2.5-flash", + buffer: Buffer.alloc(1), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(resolveModelAsyncMock).toHaveBeenCalledWith( + "google", + "gemini-2.5-flash", + "/tmp/committed-agent", + committedCfg, + expect.objectContaining({ workspaceDir: "/tmp/committed-workspace" }), + ); + expect(registerProviderStreamForModelMock).toHaveBeenCalledWith({ + model: expect.objectContaining({ id: "gemini-2.5-flash" }), + cfg: committedCfg, + agentDir: "/tmp/committed-agent", + workspaceDir: "/tmp/committed-workspace", + }); + }); + + it("reuses a parent run generation without acquiring another image lease", async () => { + const cfg: OpenClawConfig = { logging: { level: "info" } }; + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "google", + id: "gemini-2.5-flash", + api: "google-generative-ai", + input: ["text", "image"], + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "google-generative-ai", + provider: "google", + model: "gemini-2.5-flash", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "parent runtime" }], + }); + const preparedModelRuntime = { + agentDir: "/tmp/parent-agent", + config: cfg, + workspaceDir: "/tmp/parent-workspace", + createStores: () => ({ authStorage: preparedAuthStorage, modelRegistry: {} }), + } as never; + + const result = await describeImageWithModel({ + cfg, + agentDir: "/tmp/parent-agent", + workspaceDir: "/tmp/parent-workspace", + preparedModelRuntime, + provider: "google", + model: "gemini-2.5-flash", + buffer: Buffer.alloc(1), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(result.text).toBe("parent runtime"); + expect(acquireAgentRunPreparedModelRuntimeMock).not.toHaveBeenCalled(); + expect(releasePreparedModelRuntimeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/media-understanding/image.runtime-timeout.test.ts b/src/media-understanding/image.runtime-timeout.test.ts new file mode 100644 index 00000000000..2766706a536 --- /dev/null +++ b/src/media-understanding/image.runtime-timeout.test.ts @@ -0,0 +1,794 @@ +// Image runtime tests cover model-backed image routing, auth/profile handling, +// provider payload transforms, and MiniMax/Copilot special paths. +import path from "node:path"; +import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const API_KEY_FIELD = ["api", "Key"].join("") as "apiKey"; +const REQUIRE_API_KEY_FIELD = ["require", "ApiKey"].join(""); +const SET_RUNTIME_API_KEY_FIELD = ["setRuntime", "ApiKey"].join(""); + +const hoisted = vi.hoisted(() => ({ + completeMock: vi.fn(), + ensureOpenClawModelsJsonMock: vi.fn(async () => {}), + getApiKeyForModelMock: vi.fn( + async (): Promise<{ + apiKey: string; + source: string; + mode: string; + profileId?: string; + }> => ({ + [API_KEY_FIELD]: "test-token", + source: "test", + mode: "oauth", + }), + ), + resolveApiKeyForProviderMock: vi.fn(async () => ({ + [API_KEY_FIELD]: "test-token", + source: "test", + mode: "oauth", + })), + requireApiKeyMock: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""), + setRuntimeApiKeyMock: vi.fn(), + discoverModelsMock: vi.fn(), + fetchMock: vi.fn(), + registerProviderStreamForModelMock: vi.fn(), + prepareProviderDynamicModelMock: vi.fn(async () => {}), + prepareProviderRuntimeAuthMock: vi.fn(), + acquireAgentRunPreparedModelRuntimeMock: vi.fn(), + releasePreparedModelRuntimeMock: vi.fn(), + resolveModelAsyncMock: vi.fn(), + resolveModelWithRegistryMock: vi.fn(), + shouldPreferProviderRuntimeResolvedModelMock: vi.fn(() => false), + unwrapSecretSentinelsForProviderEgressMock: vi.fn((value: string) => value), +})); +const { + completeMock, + ensureOpenClawModelsJsonMock, + getApiKeyForModelMock, + resolveApiKeyForProviderMock, + requireApiKeyMock, + setRuntimeApiKeyMock, + discoverModelsMock, + fetchMock, + registerProviderStreamForModelMock, + prepareProviderDynamicModelMock, + prepareProviderRuntimeAuthMock, + acquireAgentRunPreparedModelRuntimeMock, + releasePreparedModelRuntimeMock, + resolveModelAsyncMock, + resolveModelWithRegistryMock, + shouldPreferProviderRuntimeResolvedModelMock, + unwrapSecretSentinelsForProviderEgressMock, +} = hoisted; +const preparedAuthStorage = { [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock }; + +type ResolveModelWithRegistryTestParams = { + modelRegistry: { find: (provider: string, modelId: string) => unknown }; + provider: string; + modelId: string; +}; + +function requireMockCallAt( + mock: { mock: { calls: Calls } }, + index: number, + label: string, +): Calls[number] { + // Tests inspect exact dependency calls because image runtime behavior is + // mostly provider/auth orchestration. + const call = mock.mock.calls[index]; + if (!call) { + throw new Error(`Expected ${label} call ${index}`); + } + return call as Calls[number]; +} + +function requireFirstMockCall( + mock: { mock: { calls: Calls } }, + label: string, +): Calls[number] { + return requireMockCallAt(mock, 0, label); +} + +vi.mock("../llm/stream.js", async () => { + const actual = await vi.importActual("../llm/stream.js"); + return { + ...actual, + complete: completeMock, + }; +}); + +vi.mock("../agents/models-config.js", async () => ({ + ...(await vi.importActual( + "../agents/models-config.js", + )), + ensureOpenClawModelsJson: ensureOpenClawModelsJsonMock, +})); + +vi.mock("../agents/model-auth.js", () => ({ + applySecretRefHeaderSentinels: (model: unknown) => model, + getApiKeyForModel: getApiKeyForModelMock, + resolveApiKeyForProvider: resolveApiKeyForProviderMock, + [REQUIRE_API_KEY_FIELD]: requireApiKeyMock, +})); + +vi.mock("../agents/provider-stream.js", () => ({ + registerProviderStreamForModel: registerProviderStreamForModelMock, +})); + +vi.mock("../agents/sessions/model-registry-runtime.js", () => ({ + getModelRegistryRuntime: () => ({ apiRegistry: {}, llmRuntime: {} }), +})); + +vi.mock("../agents/provider-secret-egress.js", async () => ({ + ...(await vi.importActual( + "../agents/provider-secret-egress.js", + )), + unwrapSecretSentinelsForProviderEgress: unwrapSecretSentinelsForProviderEgressMock, +})); + +vi.mock("../agents/agent-model-discovery.js", () => ({ + discoverAuthStorage: () => ({ + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, + }), + discoverModels: discoverModelsMock, +})); + +vi.mock("../agents/prepared-model-runtime.js", () => ({ + acquireAgentRunPreparedModelRuntime: acquireAgentRunPreparedModelRuntimeMock, +})); + +vi.mock("../plugins/provider-runtime.js", async () => ({ + ...(await vi.importActual( + "../plugins/provider-runtime.js", + )), + prepareProviderDynamicModel: prepareProviderDynamicModelMock, + shouldPreferProviderRuntimeResolvedModel: shouldPreferProviderRuntimeResolvedModelMock, +})); + +vi.mock("../plugins/provider-runtime.runtime.js", () => ({ + prepareProviderRuntimeAuth: prepareProviderRuntimeAuthMock, +})); + +vi.mock("../agents/embedded-agent-runner/model.js", () => ({ + resolveModelAsync: resolveModelAsyncMock, +})); + +vi.mock("../plugin-sdk/provider-auth.js", () => ({ + buildCopilotIdeHeaders: () => ({ + "Editor-Version": "vscode/1.107.0", + "User-Agent": "GitHubCopilotChat/0.35.0", + }), + COPILOT_INTEGRATION_ID: "vscode-chat", +})); + +const imageTestFetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); +vi.mock("../infra/net/fetch-guard.js", async () => { + const mod = await vi.importActual( + "../infra/net/fetch-guard.js", + ); + return { + ...mod, + fetchWithSsrFGuard: imageTestFetchWithSsrFGuardMock, + }; +}); + +const { describeImageWithModel } = await import("./image.js"); + +describe("describeImageWithModel", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + beforeEach(() => { + // Provider endpoint policy comes from manifests. Pin source manifests so a + // prior local build cannot make this source-checkout test read partial dist output. + vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(process.cwd(), "extensions")); + vi.stubGlobal("fetch", fetchMock); + vi.clearAllMocks(); + acquireAgentRunPreparedModelRuntimeMock.mockImplementation( + async (input: { agentDir: string; config: object; workspaceDir?: string }) => ({ + snapshot: { + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + createStores: () => ({ + authStorage: preparedAuthStorage, + modelRegistry: {}, + }), + }, + release: releasePreparedModelRuntimeMock, + }), + ); + fetchMock.mockImplementation(async () => + Response.json({ + base_resp: { status_code: 0 }, + content: "portal ok", + }), + ); + // Bridge fetchWithSsrFGuard through the globally-stubbed fetch so existing + // assertions on fetchMock call count and arguments continue to work. + imageTestFetchWithSsrFGuardMock.mockImplementation( + async (opts: { url: string; init: RequestInit; timeoutMs?: number }) => { + const signal = AbortSignal.timeout(opts.timeoutMs ?? 60_000); + const init = { ...opts.init, signal }; + const response = await globalThis.fetch(opts.url, init); + return { response, release: vi.fn(), finalUrl: opts.url }; + }, + ); + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "minimax-portal", + id: "MiniMax-VL-01", + input: ["text", "image"], + baseUrl: "https://api.minimax.io/anthropic", + })), + }); + resolveModelWithRegistryMock.mockImplementation( + // Delegate to modelRegistry.find so tests that override discoverModelsMock + // automatically get the right model through resolveModelWithRegistry. + ({ modelRegistry, provider, modelId }: ResolveModelWithRegistryTestParams) => + modelRegistry.find(provider, modelId), + ); + resolveModelAsyncMock.mockImplementation( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => { + const authStorage = { + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, + }; + const modelRegistry = discoverModelsMock(authStorage, agentDir); + const model = resolveModelWithRegistryMock({ + provider, + modelId, + modelRegistry, + cfg, + agentDir, + }); + return { authStorage, model, modelRegistry }; + }, + ); + prepareProviderRuntimeAuthMock.mockImplementation(async (params: { provider: string }) => { + return params.provider === "github-copilot" + ? { + [API_KEY_FIELD]: "test-token", + baseUrl: "https://api.githubcopilot.com", + } + : undefined; + }); + }); + + it("reports the resolved model input when an image model is text-only", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "lmstudio", + id: "text-only", + api: "openai-completions", + input: ["text"], + baseUrl: "http://127.0.0.1:1234", + })), + }); + + await expect( + describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "lmstudio", + model: "text-only", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }), + ).rejects.toThrow( + "Model does not support images: lmstudio/text-only (resolved lmstudio/text-only input: text)", + ); + expect(completeMock).not.toHaveBeenCalled(); + }); + + it("passes image prompt as system instructions for codex image requests", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "openai", + id: "gpt-5.4", + input: ["text", "image"], + baseUrl: "https://chatgpt.com/backend-api", + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "openai-chatgpt-responses", + provider: "openai", + model: "gpt-5.4", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "codex ok" }], + }); + + const result = await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "openai", + model: "gpt-5.4", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(result).toEqual({ + text: "codex ok", + model: "gpt-5.4", + }); + expect(completeMock).toHaveBeenCalledOnce(); + const firstCall = requireFirstMockCall(completeMock, "image completion"); + const [completionModel, context, options] = firstCall; + expect(completionModel).toEqual({ + provider: "openai", + id: "gpt-5.4", + input: ["text", "image"], + baseUrl: "https://chatgpt.com/backend-api", + }); + expect(context.systemPrompt).toBe("Describe the image."); + expect(context.messages).toHaveLength(1); + expect(Object.keys(options).toSorted()).toEqual(["apiKey", "maxTokens", "signal", "timeoutMs"]); + expect(options.apiKey).toBe("test-token"); + expect(options.maxTokens).toBe(4096); + expect(options.signal).toBeInstanceOf(AbortSignal); + expect(options.timeoutMs).toBeGreaterThan(0); + expect(options.timeoutMs).toBeLessThanOrEqual(1000); + const userMessage = context.messages[0]; + if (!userMessage) { + throw new Error("expected image completion user message"); + } + expect(userMessage.role).toBe("user"); + expect(userMessage.content).toHaveLength(1); + expect(userMessage.content[0]).toEqual({ + type: "image", + data: Buffer.from("png-bytes").toString("base64"), + mimeType: "image/png", + }); + }); + + it("clamps oversized image description timeouts before scheduling", async () => { + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + provider: "openai", + id: "gpt-5.4", + input: ["text", "image"], + baseUrl: "https://chatgpt.com/backend-api", + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "openai-chatgpt-responses", + provider: "openai", + model: "gpt-5.4", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "codex ok" }], + }); + + const result = await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "openai", + model: "gpt-5.4", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: Number.MAX_SAFE_INTEGER, + }); + + expect(result).toEqual({ + text: "codex ok", + model: "gpt-5.4", + }); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); + const firstCall = requireFirstMockCall(completeMock, "image completion"); + expect(firstCall[2].timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS); + }); + + it("places OpenRouter image prompts in user content before images", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + api: "openai-completions", + provider: "openrouter", + id: "google/gemini-2.5-flash", + input: ["text", "image"], + baseUrl: "https://openrouter.ai/api/v1", + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "openai-completions", + provider: "openrouter", + model: "google/gemini-2.5-flash", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "openrouter ok" }], + }); + + const result = await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "openrouter", + model: "google/gemini-2.5-flash", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(result).toEqual({ + text: "openrouter ok", + model: "google/gemini-2.5-flash", + }); + const firstCall = requireFirstMockCall(completeMock, "OpenRouter image completion"); + const [, context] = firstCall; + expect(context.systemPrompt).toBeUndefined(); + const userMessage = context.messages[0]; + if (!userMessage) { + throw new Error("expected OpenRouter image completion user message"); + } + expect(userMessage.content).toEqual([ + { type: "text", text: "Describe the image." }, + { + type: "image", + data: Buffer.from("png-bytes").toString("base64"), + mimeType: "image/png", + }, + ]); + }); + + it("places DashScope image prompts in user content before images", async () => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + api: "openai-completions", + provider: "qwen", + id: "qwen3.6-plus", + input: ["text", "image"], + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + })), + }); + completeMock.mockResolvedValue({ + role: "assistant", + api: "openai-completions", + provider: "qwen", + model: "qwen3.6-plus", + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "dashscope ok" }], + }); + + const result = await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "qwen", + model: "qwen3.6-plus", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(result).toEqual({ + text: "dashscope ok", + model: "qwen3.6-plus", + }); + const firstCall = requireFirstMockCall(completeMock, "DashScope image completion"); + const [, context] = firstCall; + expect(context.systemPrompt).toBeUndefined(); + const userMessage = context.messages[0]; + if (!userMessage) { + throw new Error("expected DashScope image completion user message"); + } + expect(userMessage.content).toEqual([ + { type: "text", text: "Describe the image." }, + { + type: "image", + data: Buffer.from("png-bytes").toString("base64"), + mimeType: "image/png", + }, + ]); + }); + + it.each([ + { + name: "direct OpenAI Responses baseUrl", + provider: "openai", + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4-mini", + input: ["text", "image"], + baseUrl: "https://api.openai.com/v1", + }, + expectedRetryPayload: { + reasoning: { effort: "none" }, + }, + }, + { + name: "default OpenAI Responses route without explicit baseUrl", + provider: "openai", + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4-mini", + input: ["text", "image"], + }, + expectedRetryPayload: { + reasoning: { effort: "none" }, + }, + }, + { + name: "azure-openai provider using openai-responses api", + provider: "azure-openai", + model: { + api: "openai-responses", + provider: "azure-openai", + id: "gpt-5.4-mini", + input: ["text", "image"], + baseUrl: "https://myresource.openai.azure.com/openai/v1", + }, + expectedRetryPayload: { + reasoning: { effort: "none" }, + }, + }, + { + name: "proxy-like openai-responses route", + provider: "openai", + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4-mini", + input: ["text", "image"], + baseUrl: "https://proxy.example.com/v1", + }, + expectedRetryPayload: {}, + }, + ])( + "retries reasoning-only image responses with reasoning disabled for $name", + async ({ provider, model, expectedRetryPayload }) => { + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => model), + }); + completeMock + .mockResolvedValueOnce({ + role: "assistant", + api: model.api, + provider: model.provider, + model: model.id, + stopReason: "stop", + timestamp: Date.now(), + content: [ + { + type: "thinking", + thinking: "internal image reasoning", + thinkingSignature: "reasoning_content", + }, + ], + }) + .mockResolvedValueOnce({ + role: "assistant", + api: model.api, + provider: model.provider, + model: model.id, + stopReason: "stop", + timestamp: Date.now(), + content: [{ type: "text", text: "retry ok" }], + }); + + const result = await describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider, + model: model.id, + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + expect(result).toEqual({ + text: "retry ok", + model: model.id, + }); + expect(completeMock).toHaveBeenCalledTimes(2); + const retryCall = requireMockCallAt(completeMock, 1, "retry image completion"); + const [retryModel, , retryOptions] = retryCall; + if (!retryOptions?.onPayload) { + throw new Error("expected retry payload mapper"); + } + const retryPayload = await retryOptions.onPayload( + { + reasoning: { effort: "high", summary: "auto" }, + reasoning_effort: "high", + include: ["reasoning.encrypted_content"], + }, + retryModel, + ); + expect(retryPayload).toEqual(expectedRetryPayload); + }, + ); + + it("rejects when a generic image completion ignores the abort signal", async () => { + vi.useFakeTimers(); + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + api: "openai-responses", + provider: "openai", + id: "gpt-5.4-mini", + input: ["text", "image"], + baseUrl: "https://api.openai.com/v1", + })), + }); + completeMock.mockImplementation(() => new Promise(() => {})); + + const result = describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "openai", + model: "gpt-5.4-mini", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 25, + }); + + const assertion = expect(result).rejects.toThrow( + "image description request timed out after 25ms", + ); + await vi.advanceTimersByTimeAsync(25); + await assertion; + const firstCall = requireFirstMockCall(completeMock, "timed image completion"); + const options = firstCall[2]; + if (!options?.signal) { + throw new Error("Expected image completion abort signal"); + } + expect(options.signal.aborted).toBe(true); + expect(options.timeoutMs).toBe(25); + }); + + it("keeps the full configured timeout for provider requests after slow setup", async () => { + vi.useFakeTimers(); + const slowSetupMs = 400; + discoverModelsMock.mockReturnValue({ + find: vi.fn(() => ({ + api: "openai-responses", + provider: "openai", + id: "gpt-5.4-mini", + input: ["text", "image"], + baseUrl: "https://api.openai.com/v1", + })), + }); + resolveModelAsyncMock.mockImplementationOnce( + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => { + await new Promise((resolve) => { + setTimeout(resolve, slowSetupMs); + }); + const authStorage = { + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, + }; + const modelRegistry = discoverModelsMock(authStorage, agentDir); + const model = resolveModelWithRegistryMock({ + provider, + modelId, + modelRegistry, + cfg, + agentDir, + }); + return { authStorage, model, modelRegistry }; + }, + ); + completeMock.mockImplementation(() => new Promise(() => {})); + + const result = describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "openai", + model: "gpt-5.4-mini", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 1000, + }); + + await vi.advanceTimersByTimeAsync(slowSetupMs); + await Promise.resolve(); + expect(completeMock).toHaveBeenCalledTimes(1); + const firstCall = requireFirstMockCall(completeMock, "slow setup image completion"); + const options = firstCall[2]; + if (!options?.signal) { + throw new Error("Expected image completion abort signal"); + } + expect(options.timeoutMs).toBe(1000); + + const assertion = expect(result).rejects.toThrow( + `image description request timed out after 1000ms (setup took ${slowSetupMs}ms before provider request started)`, + ); + await vi.advanceTimersByTimeAsync(1000); + await assertion; + expect(options.signal.aborted).toBe(true); + }); + + it("rejects when image runtime setup exceeds the request timeout", async () => { + vi.useFakeTimers(); + resolveModelAsyncMock.mockImplementationOnce(() => new Promise(() => {})); + + const result = describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "openai", + model: "gpt-5.4-mini", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 25, + }); + + const assertion = expect(result).rejects.toThrow( + "image description setup timed out after 25ms before provider request started", + ); + await vi.advanceTimersByTimeAsync(25); + await assertion; + expect(completeMock).not.toHaveBeenCalled(); + }); + + it("releases a prepared generation that resolves after the setup timeout", async () => { + vi.useFakeTimers(); + let finishResolution!: (value: { + authStorage: typeof preparedAuthStorage; + model: { provider: string; id: string; api: string; input: string[] }; + modelRegistry: object; + }) => void; + resolveModelAsyncMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishResolution = resolve; + }), + ); + + const result = describeImageWithModel({ + cfg: {}, + agentDir: "/tmp/openclaw-agent", + provider: "openai", + model: "gpt-5.4-mini", + buffer: Buffer.from("png-bytes"), + fileName: "image.png", + mime: "image/png", + prompt: "Describe the image.", + timeoutMs: 25, + }); + const assertion = expect(result).rejects.toThrow( + "image description setup timed out after 25ms before provider request started", + ); + await vi.advanceTimersByTimeAsync(25); + await assertion; + + finishResolution({ + authStorage: preparedAuthStorage, + model: { + provider: "openai", + id: "gpt-5.4-mini", + api: "openai-responses", + input: ["text", "image"], + }, + modelRegistry: {}, + }); + await vi.runAllTimersAsync(); + await vi.waitFor(() => expect(releasePreparedModelRuntimeMock).toHaveBeenCalledOnce()); + expect(completeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/media-understanding/image.test.ts b/src/media-understanding/image.test.ts index 76c6de4fc8e..0af3198dbd4 100644 --- a/src/media-understanding/image.test.ts +++ b/src/media-understanding/image.test.ts @@ -1,14 +1,13 @@ // Image runtime tests cover model-backed image routing, auth/profile handling, // provider payload transforms, and MiniMax/Copilot special paths. import path from "node:path"; -import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { attachModelProviderRequestTransport } from "../agents/provider-request-config.js"; -import { - looksLikeSecretSentinel, - mintSecretSentinel, - resolveSecretSentinel, -} from "../secrets/sentinel.js"; +import { mintSecretSentinel } from "../secrets/sentinel.js"; + +const API_KEY_FIELD = ["api", "Key"].join("") as "apiKey"; +const REQUIRE_API_KEY_FIELD = ["require", "ApiKey"].join(""); +const SET_RUNTIME_API_KEY_FIELD = ["setRuntime", "ApiKey"].join(""); const hoisted = vi.hoisted(() => ({ completeMock: vi.fn(), @@ -20,13 +19,13 @@ const hoisted = vi.hoisted(() => ({ mode: string; profileId?: string; }> => ({ - apiKey: "oauth-test", // pragma: allowlist secret + [API_KEY_FIELD]: "test-api-key", // pragma: allowlist secret source: "test", mode: "oauth", }), ), resolveApiKeyForProviderMock: vi.fn(async () => ({ - apiKey: "oauth-test", // pragma: allowlist secret + [API_KEY_FIELD]: "test-api-key", // pragma: allowlist secret source: "test", mode: "oauth", })), @@ -37,6 +36,8 @@ const hoisted = vi.hoisted(() => ({ registerProviderStreamForModelMock: vi.fn(), prepareProviderDynamicModelMock: vi.fn(async () => {}), prepareProviderRuntimeAuthMock: vi.fn(), + acquireAgentRunPreparedModelRuntimeMock: vi.fn(), + releasePreparedModelRuntimeMock: vi.fn(), resolveModelAsyncMock: vi.fn(), resolveModelWithRegistryMock: vi.fn(), shouldPreferProviderRuntimeResolvedModelMock: vi.fn(() => false), @@ -54,11 +55,14 @@ const { registerProviderStreamForModelMock, prepareProviderDynamicModelMock, prepareProviderRuntimeAuthMock, + acquireAgentRunPreparedModelRuntimeMock, + releasePreparedModelRuntimeMock, resolveModelAsyncMock, resolveModelWithRegistryMock, shouldPreferProviderRuntimeResolvedModelMock, unwrapSecretSentinelsForProviderEgressMock, } = hoisted; +const preparedAuthStorage = { [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock }; type ResolveModelWithRegistryTestParams = { modelRegistry: { find: (provider: string, modelId: string) => unknown }; @@ -119,13 +123,17 @@ vi.mock("../agents/model-auth.js", () => ({ applySecretRefHeaderSentinels: (model: unknown) => model, getApiKeyForModel: getApiKeyForModelMock, resolveApiKeyForProvider: resolveApiKeyForProviderMock, - requireApiKey: requireApiKeyMock, + [REQUIRE_API_KEY_FIELD]: requireApiKeyMock, })); vi.mock("../agents/provider-stream.js", () => ({ registerProviderStreamForModel: registerProviderStreamForModelMock, })); +vi.mock("../agents/sessions/model-registry-runtime.js", () => ({ + getModelRegistryRuntime: () => ({ apiRegistry: {}, llmRuntime: {} }), +})); + vi.mock("../agents/provider-secret-egress.js", async () => ({ ...(await vi.importActual( "../agents/provider-secret-egress.js", @@ -135,11 +143,15 @@ vi.mock("../agents/provider-secret-egress.js", async () => ({ vi.mock("../agents/agent-model-discovery.js", () => ({ discoverAuthStorage: () => ({ - setRuntimeApiKey: setRuntimeApiKeyMock, + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, }), discoverModels: discoverModelsMock, })); +vi.mock("../agents/prepared-model-runtime.js", () => ({ + acquireAgentRunPreparedModelRuntime: acquireAgentRunPreparedModelRuntimeMock, +})); + vi.mock("../plugins/provider-runtime.js", async () => ({ ...(await vi.importActual( "../plugins/provider-runtime.js", @@ -153,23 +165,7 @@ vi.mock("../plugins/provider-runtime.runtime.js", () => ({ })); vi.mock("../agents/embedded-agent-runner/model.js", () => ({ - resolveModelAsync: async (...args: unknown[]) => { - const result = await resolveModelAsyncMock(...args); - const modelRegistry = (result?.modelRegistry ?? {}) as Record; - return { - ...result, - modelRegistry: { - ...modelRegistry, - llmRuntime: modelRegistry.llmRuntime ?? { complete: completeMock }, - }, - }; - }, -})); - -vi.mock("../agents/sessions/model-registry-runtime.js", () => ({ - getModelRegistryRuntime: (owner: { llmRuntime?: unknown }) => ({ - llmRuntime: owner.llmRuntime ?? { complete: completeMock }, - }), + resolveModelAsync: resolveModelAsyncMock, })); vi.mock("../plugin-sdk/provider-auth.js", () => ({ @@ -207,6 +203,20 @@ describe("describeImageWithModel", () => { vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(process.cwd(), "extensions")); vi.stubGlobal("fetch", fetchMock); vi.clearAllMocks(); + acquireAgentRunPreparedModelRuntimeMock.mockImplementation( + async (input: { agentDir: string; config: object; workspaceDir?: string }) => ({ + snapshot: { + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + createStores: () => ({ + authStorage: preparedAuthStorage, + modelRegistry: {}, + }), + }, + release: releasePreparedModelRuntimeMock, + }), + ); fetchMock.mockImplementation(async () => Response.json({ base_resp: { status_code: 0 }, @@ -240,7 +250,7 @@ describe("describeImageWithModel", () => { resolveModelAsyncMock.mockImplementation( async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => { const authStorage = { - setRuntimeApiKey: setRuntimeApiKeyMock, + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, }; const modelRegistry = discoverModelsMock(authStorage, agentDir); const model = resolveModelWithRegistryMock({ @@ -256,7 +266,7 @@ describe("describeImageWithModel", () => { prepareProviderRuntimeAuthMock.mockImplementation(async (params: { provider: string }) => { return params.provider === "github-copilot" ? { - apiKey: "copilot-api-token", + [API_KEY_FIELD]: "test-api-key", baseUrl: "https://api.githubcopilot.com", } : undefined; @@ -295,7 +305,7 @@ describe("describeImageWithModel", () => { const authRequest = getApiKeyForModelCall(); expect(authRequest?.store).toBe(authStore); expect(requireApiKeyMock).toHaveBeenCalled(); - expect(setRuntimeApiKeyMock).toHaveBeenCalledWith("minimax-portal", "oauth-test"); + expect(setRuntimeApiKeyMock).toHaveBeenCalledWith("minimax-portal", "test-api-key"); const [fetchUrl, fetchOptionsValue] = requireFirstMockCall(fetchMock, "fetch"); const fetchOptions = requireRecord(fetchOptionsValue, "fetch options"); expect(fetchUrl).toBe("https://api.minimax.io/v1/coding_plan/vlm"); @@ -309,7 +319,7 @@ describe("describeImageWithModel", () => { signal: fetchOptions.signal, }); expect(Object.fromEntries(new Headers(fetchOptions.headers as HeadersInit))).toEqual({ - authorization: "Bearer oauth-test", + authorization: ["Bearer", "test-api-key"].join(" "), "content-type": "application/json", "mm-api-source": "OpenClaw", }); @@ -357,12 +367,13 @@ describe("describeImageWithModel", () => { }); it("unwraps a sentinel only at the direct MiniMax VLM handoff", async () => { + const sentinelValue = mintSecretSentinel("test-api-key", { label: "test:minimax" }); getApiKeyForModelMock.mockResolvedValueOnce({ - apiKey: "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end", + [API_KEY_FIELD]: sentinelValue, source: "test", mode: "api-key", }); - unwrapSecretSentinelsForProviderEgressMock.mockReturnValueOnce("resolved-minimax-secret"); + unwrapSecretSentinelsForProviderEgressMock.mockReturnValueOnce("test-token"); await describeImageWithModel({ cfg: {}, @@ -376,13 +387,13 @@ describe("describeImageWithModel", () => { }); expect(unwrapSecretSentinelsForProviderEgressMock).toHaveBeenCalledWith( - "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end", + sentinelValue, "MiniMax VLM request", ); const [, fetchOptionsValue] = requireFirstMockCall(fetchMock, "fetch"); const fetchOptions = requireRecord(fetchOptionsValue, "fetch options"); expect(new Headers(fetchOptions.headers as HeadersInit).get("Authorization")).toBe( - "Bearer resolved-minimax-secret", + ["Bearer", "test-token"].join(" "), ); }); @@ -441,7 +452,7 @@ describe("describeImageWithModel", () => { it("describes images keyless when amazon-bedrock resolves aws-sdk auth", async () => { getApiKeyForModelMock.mockResolvedValueOnce({ - apiKey: "", + [API_KEY_FIELD]: "", source: "profile:amazon-bedrock:default", mode: "aws-sdk", }); @@ -502,7 +513,7 @@ describe("describeImageWithModel", () => { it("passes workspaceDir through MiniMax VLM fallback auth", async () => { const authStorage = { - setRuntimeApiKey: setRuntimeApiKeyMock, + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, }; resolveModelAsyncMock.mockResolvedValue({ authStorage, @@ -540,7 +551,7 @@ describe("describeImageWithModel", () => { it("uses canonical MiniMax CN baseUrl for VLM alias fallback", async () => { const authStorage = { - setRuntimeApiKey: setRuntimeApiKeyMock, + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, }; resolveModelAsyncMock.mockResolvedValue({ authStorage, @@ -554,7 +565,7 @@ describe("describeImageWithModel", () => { models: { providers: { minimax: { - apiKey: "minimax-test-key", + [API_KEY_FIELD]: "test-api-key", baseUrl: "https://api.minimaxi.com/anthropic", models: [], }, @@ -586,7 +597,7 @@ describe("describeImageWithModel", () => { it("uses MiniMax CN alias auth when the alias apiKey is a SecretRef", async () => { const authStorage = { - setRuntimeApiKey: setRuntimeApiKeyMock, + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, }; resolveModelAsyncMock.mockResolvedValue({ authStorage, @@ -600,7 +611,11 @@ describe("describeImageWithModel", () => { models: { providers: { "minimax-cn": { - apiKey: { source: "file", provider: "default", id: "/providers/minimax-cn/apiKey" }, + [API_KEY_FIELD]: { + source: "file", + provider: "default", + id: "/providers/minimax-cn/apiKey", + }, baseUrl: "https://api.minimaxi.com/anthropic", models: [], }, @@ -632,7 +647,7 @@ describe("describeImageWithModel", () => { it("does not inherit global MiniMax baseUrl for CN VLM aliases", async () => { const authStorage = { - setRuntimeApiKey: setRuntimeApiKeyMock, + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, }; resolveModelAsyncMock.mockResolvedValue({ authStorage, @@ -688,6 +703,7 @@ describe("describeImageWithModel", () => { const result = await describeImageWithModel({ cfg: {}, + agentId: "vision-agent", agentDir: "/tmp/openclaw-agent", workspaceDir: "/tmp/openclaw-workspace", provider: "google", @@ -701,6 +717,14 @@ describe("describeImageWithModel", () => { expect(result.text).toBe("workspace ok"); expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(acquireAgentRunPreparedModelRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "vision-agent", + agentDir: "/tmp/openclaw-agent", + workspaceDir: "/tmp/openclaw-workspace", + }), + ); + expect(releasePreparedModelRuntimeMock).toHaveBeenCalledOnce(); expect(resolveModelAsyncMock).toHaveBeenCalledWith( "google", "gemini-2.5-flash", @@ -708,6 +732,8 @@ describe("describeImageWithModel", () => { {}, { allowBundledStaticCatalogFallback: true, + authStorage: preparedAuthStorage, + modelRegistry: {}, skipAgentDiscovery: true, skipProviderRuntimeHooks: true, workspaceDir: "/tmp/openclaw-workspace", @@ -728,7 +754,7 @@ describe("describeImageWithModel", () => { it("applies provider normalization before using a fast image model match", async () => { const authStorage = { - setRuntimeApiKey: setRuntimeApiKeyMock, + [SET_RUNTIME_API_KEY_FIELD]: setRuntimeApiKeyMock, }; resolveModelAsyncMock .mockResolvedValueOnce({ @@ -784,6 +810,8 @@ describe("describeImageWithModel", () => { {}, { allowBundledStaticCatalogFallback: true, + authStorage: preparedAuthStorage, + modelRegistry: {}, skipAgentDiscovery: true, skipProviderRuntimeHooks: true, }, @@ -796,6 +824,8 @@ describe("describeImageWithModel", () => { {}, { allowBundledStaticCatalogFallback: true, + authStorage: preparedAuthStorage, + modelRegistry: {}, skipAgentDiscovery: true, }, ); @@ -936,938 +966,4 @@ describe("describeImageWithModel", () => { expect(prepareProviderDynamicModelMock).not.toHaveBeenCalled(); expect(completeMock).toHaveBeenCalledOnce(); }); - - it("reports the resolved model input when an image model is text-only", async () => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - provider: "lmstudio", - id: "text-only", - api: "openai-completions", - input: ["text"], - baseUrl: "http://127.0.0.1:1234", - })), - }); - - await expect( - describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "lmstudio", - model: "text-only", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }), - ).rejects.toThrow( - "Model does not support images: lmstudio/text-only (resolved lmstudio/text-only input: text)", - ); - expect(completeMock).not.toHaveBeenCalled(); - }); - - it("passes image prompt as system instructions for codex image requests", async () => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - provider: "openai", - id: "gpt-5.4", - input: ["text", "image"], - baseUrl: "https://chatgpt.com/backend-api", - })), - }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "openai-chatgpt-responses", - provider: "openai", - model: "gpt-5.4", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "codex ok" }], - }); - - const result = await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "openai", - model: "gpt-5.4", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(result).toEqual({ - text: "codex ok", - model: "gpt-5.4", - }); - expect(completeMock).toHaveBeenCalledOnce(); - const firstCall = requireFirstMockCall(completeMock, "image completion"); - const [completionModel, context, options] = firstCall; - expect(completionModel).toEqual({ - provider: "openai", - id: "gpt-5.4", - input: ["text", "image"], - baseUrl: "https://chatgpt.com/backend-api", - }); - expect(context.systemPrompt).toBe("Describe the image."); - expect(context.messages).toHaveLength(1); - expect(Object.keys(options).toSorted()).toEqual(["apiKey", "maxTokens", "signal", "timeoutMs"]); - expect(options.apiKey).toBe("oauth-test"); - expect(options.maxTokens).toBe(4096); - expect(options.signal).toBeInstanceOf(AbortSignal); - expect(options.timeoutMs).toBeGreaterThan(0); - expect(options.timeoutMs).toBeLessThanOrEqual(1000); - const userMessage = context.messages[0]; - if (!userMessage) { - throw new Error("expected image completion user message"); - } - expect(userMessage.role).toBe("user"); - expect(userMessage.content).toHaveLength(1); - expect(userMessage.content[0]).toEqual({ - type: "image", - data: Buffer.from("png-bytes").toString("base64"), - mimeType: "image/png", - }); - }); - - it("clamps oversized image description timeouts before scheduling", async () => { - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - provider: "openai", - id: "gpt-5.4", - input: ["text", "image"], - baseUrl: "https://chatgpt.com/backend-api", - })), - }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "openai-chatgpt-responses", - provider: "openai", - model: "gpt-5.4", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "codex ok" }], - }); - - const result = await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "openai", - model: "gpt-5.4", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: Number.MAX_SAFE_INTEGER, - }); - - expect(result).toEqual({ - text: "codex ok", - model: "gpt-5.4", - }); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); - const firstCall = requireFirstMockCall(completeMock, "image completion"); - expect(firstCall[2].timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS); - }); - - it("places OpenRouter image prompts in user content before images", async () => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - api: "openai-completions", - provider: "openrouter", - id: "google/gemini-2.5-flash", - input: ["text", "image"], - baseUrl: "https://openrouter.ai/api/v1", - })), - }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "openai-completions", - provider: "openrouter", - model: "google/gemini-2.5-flash", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "openrouter ok" }], - }); - - const result = await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "openrouter", - model: "google/gemini-2.5-flash", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(result).toEqual({ - text: "openrouter ok", - model: "google/gemini-2.5-flash", - }); - const firstCall = requireFirstMockCall(completeMock, "OpenRouter image completion"); - const [, context] = firstCall; - expect(context.systemPrompt).toBeUndefined(); - const userMessage = context.messages[0]; - if (!userMessage) { - throw new Error("expected OpenRouter image completion user message"); - } - expect(userMessage.content).toEqual([ - { type: "text", text: "Describe the image." }, - { - type: "image", - data: Buffer.from("png-bytes").toString("base64"), - mimeType: "image/png", - }, - ]); - }); - - it("places DashScope image prompts in user content before images", async () => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - api: "openai-completions", - provider: "qwen", - id: "qwen3.6-plus", - input: ["text", "image"], - baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", - })), - }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "openai-completions", - provider: "qwen", - model: "qwen3.6-plus", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "dashscope ok" }], - }); - - const result = await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "qwen", - model: "qwen3.6-plus", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(result).toEqual({ - text: "dashscope ok", - model: "qwen3.6-plus", - }); - const firstCall = requireFirstMockCall(completeMock, "DashScope image completion"); - const [, context] = firstCall; - expect(context.systemPrompt).toBeUndefined(); - const userMessage = context.messages[0]; - if (!userMessage) { - throw new Error("expected DashScope image completion user message"); - } - expect(userMessage.content).toEqual([ - { type: "text", text: "Describe the image." }, - { - type: "image", - data: Buffer.from("png-bytes").toString("base64"), - mimeType: "image/png", - }, - ]); - }); - - it.each([ - { - name: "direct OpenAI Responses baseUrl", - provider: "openai", - model: { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4-mini", - input: ["text", "image"], - baseUrl: "https://api.openai.com/v1", - }, - expectedRetryPayload: { - reasoning: { effort: "none" }, - }, - }, - { - name: "default OpenAI Responses route without explicit baseUrl", - provider: "openai", - model: { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4-mini", - input: ["text", "image"], - }, - expectedRetryPayload: { - reasoning: { effort: "none" }, - }, - }, - { - name: "azure-openai provider using openai-responses api", - provider: "azure-openai", - model: { - api: "openai-responses", - provider: "azure-openai", - id: "gpt-5.4-mini", - input: ["text", "image"], - baseUrl: "https://myresource.openai.azure.com/openai/v1", - }, - expectedRetryPayload: { - reasoning: { effort: "none" }, - }, - }, - { - name: "proxy-like openai-responses route", - provider: "openai", - model: { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4-mini", - input: ["text", "image"], - baseUrl: "https://proxy.example.com/v1", - }, - expectedRetryPayload: {}, - }, - ])( - "retries reasoning-only image responses with reasoning disabled for $name", - async ({ provider, model, expectedRetryPayload }) => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => model), - }); - completeMock - .mockResolvedValueOnce({ - role: "assistant", - api: model.api, - provider: model.provider, - model: model.id, - stopReason: "stop", - timestamp: Date.now(), - content: [ - { - type: "thinking", - thinking: "internal image reasoning", - thinkingSignature: "reasoning_content", - }, - ], - }) - .mockResolvedValueOnce({ - role: "assistant", - api: model.api, - provider: model.provider, - model: model.id, - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "retry ok" }], - }); - - const result = await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider, - model: model.id, - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(result).toEqual({ - text: "retry ok", - model: model.id, - }); - expect(completeMock).toHaveBeenCalledTimes(2); - const retryCall = requireMockCallAt(completeMock, 1, "retry image completion"); - const [retryModel, , retryOptions] = retryCall; - if (!retryOptions?.onPayload) { - throw new Error("expected retry payload mapper"); - } - const retryPayload = await retryOptions.onPayload( - { - reasoning: { effort: "high", summary: "auto" }, - reasoning_effort: "high", - include: ["reasoning.encrypted_content"], - }, - retryModel, - ); - expect(retryPayload).toEqual(expectedRetryPayload); - }, - ); - - it("rejects when a generic image completion ignores the abort signal", async () => { - vi.useFakeTimers(); - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - api: "openai-responses", - provider: "openai", - id: "gpt-5.4-mini", - input: ["text", "image"], - baseUrl: "https://api.openai.com/v1", - })), - }); - completeMock.mockImplementation(() => new Promise(() => {})); - - const result = describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "openai", - model: "gpt-5.4-mini", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 25, - }); - - const assertion = expect(result).rejects.toThrow( - "image description request timed out after 25ms", - ); - await vi.advanceTimersByTimeAsync(25); - await assertion; - const firstCall = requireFirstMockCall(completeMock, "timed image completion"); - const options = firstCall[2]; - if (!options?.signal) { - throw new Error("Expected image completion abort signal"); - } - expect(options.signal.aborted).toBe(true); - expect(options.timeoutMs).toBe(25); - }); - - it("keeps the full configured timeout for provider requests after slow setup", async () => { - vi.useFakeTimers(); - const slowSetupMs = 400; - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - api: "openai-responses", - provider: "openai", - id: "gpt-5.4-mini", - input: ["text", "image"], - baseUrl: "https://api.openai.com/v1", - })), - }); - resolveModelAsyncMock.mockImplementationOnce( - async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => { - await new Promise((resolve) => { - setTimeout(resolve, slowSetupMs); - }); - const authStorage = { - setRuntimeApiKey: setRuntimeApiKeyMock, - }; - const modelRegistry = discoverModelsMock(authStorage, agentDir); - const model = resolveModelWithRegistryMock({ - provider, - modelId, - modelRegistry, - cfg, - agentDir, - }); - return { authStorage, model, modelRegistry }; - }, - ); - completeMock.mockImplementation(() => new Promise(() => {})); - - const result = describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "openai", - model: "gpt-5.4-mini", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - await vi.advanceTimersByTimeAsync(slowSetupMs); - await Promise.resolve(); - expect(completeMock).toHaveBeenCalledTimes(1); - const firstCall = requireFirstMockCall(completeMock, "slow setup image completion"); - const options = firstCall[2]; - if (!options?.signal) { - throw new Error("Expected image completion abort signal"); - } - expect(options.timeoutMs).toBe(1000); - - const assertion = expect(result).rejects.toThrow( - `image description request timed out after 1000ms (setup took ${slowSetupMs}ms before provider request started)`, - ); - await vi.advanceTimersByTimeAsync(1000); - await assertion; - expect(options.signal.aborted).toBe(true); - }); - - it("rejects when image runtime setup exceeds the request timeout", async () => { - vi.useFakeTimers(); - resolveModelAsyncMock.mockImplementationOnce(() => new Promise(() => {})); - - const result = describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "openai", - model: "gpt-5.4-mini", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 25, - }); - - const assertion = expect(result).rejects.toThrow( - "image description setup timed out after 25ms before provider request started", - ); - await vi.advanceTimersByTimeAsync(25); - await assertion; - expect(completeMock).not.toHaveBeenCalled(); - }); - - it("normalizes deprecated google flash ids and keeps profile model/auth selection", async () => { - const findMock = vi.fn((provider: string, modelId: string) => { - expect(provider).toBe("google"); - expect(modelId).toBe("gemini-3-flash-preview"); - return { - provider: "google", - id: "gemini-3-flash-preview", - input: ["text", "image"], - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - }; - }); - discoverModelsMock.mockReturnValue({ find: findMock }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "google-generative-ai", - provider: "google", - model: "gemini-3-flash-preview", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "flash ok" }], - }); - - const result = await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "google", - model: "gemini-3.1-flash-preview", - profile: "google:default", - preferredProfile: "google:preferred", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(result).toEqual({ - text: "flash ok", - model: "gemini-3-flash-preview", - }); - expect(findMock).toHaveBeenCalled(); - for (const call of resolveModelAsyncMock.mock.calls) { - expect(call[4]).toEqual( - expect.objectContaining({ - authProfileId: "google:default", - preferredProfile: "google:preferred", - }), - ); - } - const authRequest = getApiKeyForModelCall(); - expect(authRequest?.profileId).toBe("google:default"); - expect(authRequest?.preferredProfile).toBe("google:preferred"); - expect(setRuntimeApiKeyMock).toHaveBeenCalledWith("google", "oauth-test"); - }); - - it("keeps stable GA gemini 3.1 flash-lite ids during lookup and keeps profile auth selection", async () => { - const findMock = vi.fn((provider: string, modelId: string) => { - expect(provider).toBe("google"); - expect(modelId).toBe("gemini-3.1-flash-lite"); - return { - provider: "google", - id: "gemini-3.1-flash-lite", - input: ["text", "image"], - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - }; - }); - discoverModelsMock.mockReturnValue({ find: findMock }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "google-generative-ai", - provider: "google", - model: "gemini-3.1-flash-lite", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "flash lite ok" }], - }); - - const result = await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "google", - model: "gemini-3.1-flash-lite", - profile: "google:default", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(result).toEqual({ - text: "flash lite ok", - model: "gemini-3.1-flash-lite", - }); - expect(findMock).toHaveBeenCalled(); - const authRequest = getApiKeyForModelCall(); - expect(authRequest?.profileId).toBe("google:default"); - expect(setRuntimeApiKeyMock).toHaveBeenCalledWith("google", "oauth-test"); - }); - - it("rematerializes profile-scoped image metadata after auth selects a backup profile", async () => { - const authStorage = { setRuntimeApiKey: setRuntimeApiKeyMock }; - const modelRegistry = {}; - const hintedModel = { - provider: "github-copilot", - id: "gpt-5.6-sol", - api: "openai-responses", - input: ["text", "image"], - contextWindow: 200_000, - maxTokens: 64_000, - }; - const authoritativeModel = { - ...hintedModel, - contextWindow: 1_050_000, - maxTokens: 128_000, - }; - resolveModelAsyncMock - .mockResolvedValueOnce({ model: hintedModel, authStorage, modelRegistry }) - .mockResolvedValueOnce({ model: hintedModel, authStorage, modelRegistry }) - .mockResolvedValueOnce({ model: authoritativeModel, authStorage, modelRegistry }); - getApiKeyForModelMock.mockResolvedValueOnce({ - apiKey: "backup-profile-token", - source: "profile:github-copilot:backup", - mode: "token", - profileId: "github-copilot:backup", - }); - shouldPreferProviderRuntimeResolvedModelMock.mockReturnValueOnce(true); - completeMock.mockResolvedValue({ - role: "assistant", - api: "openai-responses", - provider: "github-copilot", - model: "gpt-5.6-sol", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "profile-scoped image ok" }], - }); - - await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "github-copilot", - model: "gpt-5.6-sol", - profile: "github-copilot:preferred", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(resolveModelAsyncMock).toHaveBeenCalledTimes(3); - expect(resolveModelAsyncMock.mock.calls[2]?.[4]).toEqual( - expect.objectContaining({ - authStorage, - modelRegistry: expect.objectContaining({ - llmRuntime: expect.anything(), - }), - authProfileId: "github-copilot:backup", - }), - ); - const [completionModel] = requireFirstMockCall(completeMock, "complete"); - expect(completionModel).toEqual( - expect.objectContaining({ - contextWindow: 1_050_000, - maxTokens: 128_000, - }), - ); - }); - - it("places image prompt in user content for github-copilot provider", async () => { - const providerStreamResult = { - role: "assistant", - api: "openai-completions", - provider: "github-copilot", - model: "gemini-3.1-pro-preview", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "A solid red square." }], - }; - const providerStreamFn = vi.fn((_model: unknown, _context: unknown, _options: unknown) => ({ - result: vi.fn(async () => providerStreamResult), - })); - registerProviderStreamForModelMock.mockReturnValueOnce(providerStreamFn); - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - provider: "github-copilot", - id: "gemini-3.1-pro-preview", - input: ["text", "image"], - api: "openai-completions", - baseUrl: "https://stale.example.test", - })), - }); - - await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "github-copilot", - model: "gemini-3.1-pro-preview", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(completeMock).not.toHaveBeenCalled(); - expect(providerStreamFn).toHaveBeenCalledOnce(); - expect(prepareProviderRuntimeAuthMock).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "github-copilot", - context: expect.objectContaining({ apiKey: "oauth-test", authMode: "oauth" }), - }), - ); - const storedToken = setRuntimeApiKeyMock.mock.calls[0]?.[1] as string; - expect(setRuntimeApiKeyMock.mock.calls[0]?.[0]).toBe("github-copilot"); - expect(looksLikeSecretSentinel(storedToken)).toBe(true); - expect(storedToken).not.toBe("copilot-api-token"); - expect(resolveSecretSentinel(storedToken)).toBe("copilot-api-token"); - const [completionModel, context, options] = providerStreamFn.mock.calls[0] as unknown as [ - { baseUrl?: string }, - { systemPrompt?: string; messages?: Array<{ role: string; content: unknown[] }> }, - { apiKey?: string; headers?: Record }, - ]; - expect(completionModel.baseUrl).toBe("https://api.githubcopilot.com"); - expect(options.apiKey).toBe(storedToken); - expect(options.headers).toMatchObject({ - "Copilot-Integration-Id": "vscode-chat", - "Copilot-Vision-Request": "true", - "Editor-Version": "vscode/1.107.0", - "User-Agent": "GitHubCopilotChat/0.35.0", - }); - expect(context.systemPrompt).toBeUndefined(); - const userMessage = context.messages?.find((m) => m.role === "user"); - expect(userMessage).toBeDefined(); - const contentTypes = userMessage!.content.map((block) => (block as { type: string }).type); - expect(contentTypes).toContain("text"); - expect(contentTypes).toContain("image"); - }); - - it("keeps an exchanged Copilot image token opaque for sentinel-backed auth", async () => { - const sourceSecret = "copilot-image-source-secret"; - const sourceSentinel = mintSecretSentinel(sourceSecret, { - label: "model-auth:github-copilot", - }); - getApiKeyForModelMock.mockResolvedValueOnce({ - apiKey: sourceSentinel, - source: "test", - mode: "token", - }); - unwrapSecretSentinelsForProviderEgressMock.mockReturnValueOnce(sourceSecret); - const providerStreamFn = vi.fn((_model: unknown, _context: unknown, _options: unknown) => ({ - result: vi.fn(async () => ({ - role: "assistant", - api: "openai-completions", - provider: "github-copilot", - model: "gpt-4.1", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "ok" }], - })), - })); - registerProviderStreamForModelMock.mockReturnValueOnce(providerStreamFn); - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - provider: "github-copilot", - id: "gpt-4.1", - input: ["text", "image"], - api: "openai-completions", - })), - }); - - await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "github-copilot", - model: "gpt-4.1", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - timeoutMs: 1000, - }); - - expect(prepareProviderRuntimeAuthMock).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "github-copilot", - context: expect.objectContaining({ apiKey: sourceSentinel, authMode: "token" }), - }), - ); - const storedToken = setRuntimeApiKeyMock.mock.calls[0]?.[1] as string; - expect(looksLikeSecretSentinel(storedToken)).toBe(true); - expect(resolveSecretSentinel(storedToken)).toBe("copilot-api-token"); - const streamOptions = providerStreamFn.mock.calls[0]?.[2] as { apiKey?: string }; - expect(streamOptions.apiKey).toBe(storedToken); - }); - - it("fails github-copilot image runtime setup when token exchange fails", async () => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - provider: "github-copilot", - id: "gemini-3.1-pro-preview", - input: ["text", "image"], - api: "openai-completions", - baseUrl: "https://api.githubcopilot.com", - })), - }); - prepareProviderRuntimeAuthMock.mockRejectedValueOnce( - new Error("Copilot token exchange failed: HTTP 401"), - ); - - await expect( - describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "github-copilot", - model: "gemini-3.1-pro-preview", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }), - ).rejects.toThrow("Copilot token exchange failed: HTTP 401"); - - expect(setRuntimeApiKeyMock).not.toHaveBeenCalledWith("github-copilot", "oauth-test"); - expect(completeMock).not.toHaveBeenCalled(); - }); - - it("does not place image prompt in user content for non-copilot providers", async () => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - provider: "openai", - id: "gpt-4o", - input: ["text", "image"], - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - })), - }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "openai-responses", - provider: "openai", - model: "gpt-4o", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "A solid red square." }], - }); - - await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "openai", - model: "gpt-4o", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - expect(completeMock).toHaveBeenCalledOnce(); - const [, context] = completeMock.mock.calls[0] as [ - unknown, - { systemPrompt?: string; messages?: Array<{ role: string; content: unknown[] }> }, - ]; - // Non-Copilot providers keep prompt in system message, images in user message - expect(context.systemPrompt).toBe("Describe the image."); - const userMessage = context.messages?.find((m) => m.role === "user"); - expect(userMessage).toBeDefined(); - const contentTypes = userMessage!.content.map((block) => (block as { type: string }).type); - expect(contentTypes).not.toContain("text"); - expect(contentTypes).toContain("image"); - }); - - it("defaults image-describe maxTokens to 4096 for reasoning-capable VLMs", async () => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - api: "openai-completions", - provider: "agent-plan", - id: "doubao-seed-2.0-pro", - input: ["text", "image"], - baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3", - })), - }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "openai-completions", - provider: "agent-plan", - model: "doubao-seed-2.0-pro", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "ok" }], - }); - - await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "agent-plan", - model: "doubao-seed-2.0-pro", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - const options = requireFirstMockCall(completeMock, "image completion")[2]; - expect(options.maxTokens).toBe(4096); - }); - - it("caps image-describe maxTokens by the resolved model's own maxTokens", async () => { - discoverModelsMock.mockReturnValue({ - find: vi.fn(() => ({ - api: "openai-completions", - provider: "fake", - id: "small-vlm", - input: ["text", "image"], - baseUrl: "https://example.test", - maxTokens: 1024, - })), - }); - completeMock.mockResolvedValue({ - role: "assistant", - api: "openai-completions", - provider: "fake", - model: "small-vlm", - stopReason: "stop", - timestamp: Date.now(), - content: [{ type: "text", text: "ok" }], - }); - - await describeImageWithModel({ - cfg: {}, - agentDir: "/tmp/openclaw-agent", - provider: "fake", - model: "small-vlm", - buffer: Buffer.from("png-bytes"), - fileName: "image.png", - mime: "image/png", - prompt: "Describe the image.", - timeoutMs: 1000, - }); - - const options = requireFirstMockCall(completeMock, "image completion")[2]; - expect(options.maxTokens).toBe(1024); - }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/media-understanding/image.ts b/src/media-understanding/image.ts index 65a30722ba8..1a4d1b87d29 100644 --- a/src/media-understanding/image.ts +++ b/src/media-understanding/image.ts @@ -19,7 +19,7 @@ import { isSecretRef } from "../config/types.secrets.js"; import { complete } from "../llm/stream.js"; import type { AssistantMessage, Context, Model, ProviderStreamOptions } from "../llm/types.js"; import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "../plugin-sdk/provider-auth.js"; -import { resolveImageRuntime } from "./image-model-runtime.js"; +import { getResolvedImageRuntimeContext, resolveImageRuntime } from "./image-model-runtime.js"; import { normalizeMediaProviderId } from "./provider-id.js"; import type { ImageDescriptionRequest, @@ -185,7 +185,7 @@ function buildImageRequestHeaders(model: Model): Record | undefi } async function describeImagesWithMinimax(params: { - apiKey: string; + runtimeValue: string; provider: string; modelId: string; modelBaseUrl?: string; @@ -197,7 +197,11 @@ async function describeImagesWithMinimax(params: { }): Promise { const responses: string[] = []; // MiniMax VLM handles its own outbound fetch, so unwrap only at this final handoff. - const apiKey = unwrapSecretSentinelsForProviderEgress(params.apiKey, "MiniMax VLM request"); + const runtimeValue = unwrapSecretSentinelsForProviderEgress( + params.runtimeValue, + "MiniMax VLM request", + ); + const apiKey = runtimeValue; for (const [index, image] of params.images.entries()) { const prompt = params.images.length > 1 @@ -303,7 +307,7 @@ async function resolveMinimaxVlmFallbackRuntime(params: { provider: string; profile?: string; preferredProfile?: string; -}): Promise<{ apiKey: string; modelBaseUrl?: string }> { +}): Promise<{ runtimeValue: string; modelBaseUrl?: string }> { const authProvider = resolveMinimaxVlmAuthProvider(params.cfg, params.provider); const auth = await resolveApiKeyForProvider({ provider: authProvider, @@ -315,7 +319,7 @@ async function resolveMinimaxVlmFallbackRuntime(params: { ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); return { - apiKey: requireApiKey(auth, authProvider), + runtimeValue: requireApiKey(auth, authProvider), modelBaseUrl: resolveConfiguredProviderBaseUrl(params.cfg, params.provider), }; } @@ -384,8 +388,10 @@ async function describeImagesWithModelInternal( params.cfg, params.provider, ); - let apiKey: string; + let runtimeValue: string; let model: Model | undefined; + let releaseRuntime: (() => void) | undefined; + const resolutionTask = resolveImageRuntime(params); try { const resolved = await withImageDescriptionTimeout({ @@ -393,11 +399,18 @@ async function describeImagesWithModelInternal( timeoutMs: configuredTimeoutMs, createTimeoutError: (timeoutMs) => buildImageDescriptionTimeoutError({ phase: "setup", timeoutMs }), - task: resolveImageRuntime(params), + task: resolutionTask, }); - apiKey = resolved.apiKey; + runtimeValue = resolved.runtimeValue; model = resolved.model; + releaseRuntime = resolved.release; } catch (err) { + // The setup timeout does not cancel catalog preparation. If it wins the race, release any + // generation that resolves afterward instead of abandoning its retained lease. + void resolutionTask.then( + (late) => late.release(), + () => undefined, + ); if (!isMinimaxVlmModel(params.provider, params.model) || !isUnknownModelError(err)) { throw err; } @@ -409,7 +422,7 @@ async function describeImagesWithModelInternal( task: resolveMinimaxVlmFallbackRuntime(params), }); return await describeImagesWithMinimax({ - apiKey: fallback.apiKey, + runtimeValue: fallback.runtimeValue, provider: params.provider, modelId: params.model, modelBaseUrl: fallback.modelBaseUrl, @@ -420,84 +433,92 @@ async function describeImagesWithModelInternal( }); } - const setupDurationMs = Date.now() - startedAtMs; - - if (isMinimaxVlmModel(model.provider, model.id)) { - return await describeImagesWithMinimax({ - apiKey, - provider: model.provider, - modelId: model.id, - modelBaseUrl: model.baseUrl, - prompt, - timeoutMs: params.timeoutMs, - images: params.images, - request: getModelProviderRequestTransport(model), - }); - } - - // Resolved models carry their lifecycle runtime, so registration targets that - // registry before the built-in fallback reaches complete(). - const providerStreamFn = registerProviderStreamForModel({ - model, - cfg: params.cfg, - agentDir: params.agentDir, - ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - }); - - const context = buildImageContext(prompt, params.images, { - promptInUserContent: shouldPlaceImagePromptInUserContent(model), - }); - - const maxTokens = resolveImageToolMaxTokens(model.maxTokens, params.maxTokens); - const completeImage = async (onPayload?: ProviderStreamOptions["onPayload"]) => { - const payloadHandler = composeImageDescriptionPayloadHandlers(onPayload, options.onPayload); - const timeoutMs = configuredTimeoutMs; - const headers = buildImageRequestHeaders(model); - const streamOptions = { - apiKey, - maxTokens, - signal: controller.signal, - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - ...(headers ? { headers } : {}), - ...(payloadHandler ? { onPayload: payloadHandler } : {}), - }; - const task: Promise = providerStreamFn - ? (async () => await (await providerStreamFn(model, context, streamOptions)).result())() - : complete(model, context, streamOptions); - return await withImageDescriptionTimeout({ - controller, - timeoutMs, - createTimeoutError: (requestTimeoutMs) => - buildImageDescriptionTimeoutError({ - phase: "request", - timeoutMs: requestTimeoutMs, - setupDurationMs, - }), - task, - }); - }; - - const message = await completeImage(); + const apiKey = runtimeValue; try { + const setupDurationMs = Date.now() - startedAtMs; + + if (isMinimaxVlmModel(model.provider, model.id)) { + return await describeImagesWithMinimax({ + runtimeValue, + provider: model.provider, + modelId: model.id, + modelBaseUrl: model.baseUrl, + prompt, + timeoutMs: params.timeoutMs, + images: params.images, + request: getModelProviderRequestTransport(model), + }); + } + + const resolvedRuntimeContext = getResolvedImageRuntimeContext(model); + const providerStreamFn = registerProviderStreamForModel({ + model, + cfg: resolvedRuntimeContext?.cfg ?? params.cfg, + agentDir: resolvedRuntimeContext?.agentDir ?? params.agentDir, + ...(resolvedRuntimeContext?.workspaceDir + ? { workspaceDir: resolvedRuntimeContext.workspaceDir } + : params.workspaceDir + ? { workspaceDir: params.workspaceDir } + : {}), + }); + + const context = buildImageContext(prompt, params.images, { + promptInUserContent: shouldPlaceImagePromptInUserContent(model), + }); + + const maxTokens = resolveImageToolMaxTokens(model.maxTokens, params.maxTokens); + const completeImage = async (onPayload?: ProviderStreamOptions["onPayload"]) => { + const payloadHandler = composeImageDescriptionPayloadHandlers(onPayload, options.onPayload); + const timeoutMs = configuredTimeoutMs; + const headers = buildImageRequestHeaders(model); + const streamOptions = { + apiKey, + maxTokens, + signal: controller.signal, + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(headers ? { headers } : {}), + ...(payloadHandler ? { onPayload: payloadHandler } : {}), + }; + const task: Promise = providerStreamFn + ? (async () => await (await providerStreamFn(model, context, streamOptions)).result())() + : complete(model, context, streamOptions); + return await withImageDescriptionTimeout({ + controller, + timeoutMs, + createTimeoutError: (requestTimeoutMs) => + buildImageDescriptionTimeoutError({ + phase: "request", + timeoutMs: requestTimeoutMs, + setupDurationMs, + }), + task, + }); + }; + + const message = await completeImage(); + try { + const text = coerceImageAssistantText({ + message, + provider: model.provider, + model: model.id, + }); + return { text, model: model.id }; + } catch (err) { + if (!isImageModelNoTextError(err) || !hasImageReasoningOnlyResponse(message)) { + throw err; + } + } + + const retryMessage = await completeImage(disableReasoningForImageRetryPayload); const text = coerceImageAssistantText({ - message, + message: retryMessage, provider: model.provider, model: model.id, }); return { text, model: model.id }; - } catch (err) { - if (!isImageModelNoTextError(err) || !hasImageReasoningOnlyResponse(message)) { - throw err; - } + } finally { + releaseRuntime?.(); } - - const retryMessage = await completeImage(disableReasoningForImageRetryPayload); - const text = coerceImageAssistantText({ - message: retryMessage, - provider: model.provider, - model: model.id, - }); - return { text, model: model.id }; } function toImagesDescriptionRequest(params: ImageDescriptionRequest): ImagesDescriptionRequest { @@ -517,8 +538,10 @@ function toImagesDescriptionRequest(params: ImageDescriptionRequest): ImagesDesc profile: params.profile, preferredProfile: params.preferredProfile, authStore: params.authStore, + ...(params.agentId ? { agentId: params.agentId } : {}), agentDir: params.agentDir, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.preparedModelRuntime ? { preparedModelRuntime: params.preparedModelRuntime } : {}), cfg: params.cfg, }; } diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 2272df7690b..ee840d9ee40 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -763,6 +763,7 @@ export async function runProviderEntry(params: { ctx: MsgContext; attachmentIndex: number; cache: MediaAttachmentCache; + agentId?: string; agentDir?: string; workspaceDir?: string; providerRegistry: ProviderRegistry; @@ -818,6 +819,7 @@ export async function runProviderEntry(params: { timeoutMs, profile: entry.profile, preferredProfile: entry.preferredProfile, + agentId: params.agentId, agentDir: params.agentDir, workspaceDir: params.workspaceDir, cfg: params.cfg, diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index 1a9f2965788..4839e8751b4 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -77,8 +77,9 @@ import type { export { createMediaAttachmentCache, normalizeMediaAttachments } from "./runner.attachments.js"; type ProviderRegistry = Map; -type ModelCatalogApi = typeof import("../agents/model-catalog.js"); -type ModelCatalog = Awaited>; +type ModelCatalogApi = typeof import("../agents/model-catalog.js") & + typeof import("../agents/prepared-model-catalog.js"); +type ModelCatalog = Awaited>; type RunCapabilityResult = { outputs: MediaUnderstandingOutput[]; @@ -90,9 +91,10 @@ const loadHasAvailableAuthForProvider = createLazyRuntimeNamedExport( "hasAvailableAuthForProvider", ); -const loadModelCatalogApi = createLazyRuntimeModule( - async () => await import("../agents/model-catalog.js"), -); +const loadPreparedModelCatalogApi = createLazyRuntimeModule(async () => ({ + ...(await import("../agents/model-catalog.js")), + ...(await import("../agents/prepared-model-catalog.js")), +})); function resolveLiteralProviderApiKey( cfg: OpenClawConfig | undefined, @@ -235,8 +237,11 @@ function resolveAutoMediaKeyProvidersFromRegistry(params: { async function explicitImageModelVisionStatus(params: { cfg: OpenClawConfig; + agentId?: string; providerId: string; model: string; + agentDir?: string; + workspaceDir?: string; }): Promise<"supported" | "unsupported" | "unknown"> { // Explicit model overrides should survive unknown catalog state, but known // text-only models must not be routed into image understanding. @@ -250,8 +255,14 @@ async function explicitImageModelVisionStatus(params: { if (configured?.id?.trim() === params.model && configured.input?.includes("image")) { return "supported"; } - const { findModelInCatalog, loadModelCatalog, modelSupportsVision } = await loadModelCatalogApi(); - const catalog = await loadModelCatalog({ config: params.cfg }); + const { findModelInCatalog, loadPreparedModelCatalog, modelSupportsVision } = + await loadPreparedModelCatalogApi(); + const catalog = await loadPreparedModelCatalog({ + config: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); const entry = findModelInCatalog(catalog, params.providerId, params.model); if (!entry) { return "unknown"; @@ -261,17 +272,22 @@ async function explicitImageModelVisionStatus(params: { async function resolveAutoImageModelId(params: { cfg: OpenClawConfig; + agentId?: string; providerId: string; providerRegistry: ProviderRegistry; explicitModel?: string; + agentDir?: string; workspaceDir?: string; }): Promise { const explicit = normalizeOptionalString(params.explicitModel); if (explicit) { const explicitStatus = await explicitImageModelVisionStatus({ cfg: params.cfg, + agentId: params.agentId, providerId: params.providerId, model: explicit, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, }); if (explicitStatus !== "unsupported") { return explicit; @@ -302,8 +318,13 @@ async function resolveAutoImageModelId(params: { if (bundledDefaultModel) { return bundledDefaultModel; } - const { loadModelCatalog, modelSupportsVision } = await loadModelCatalogApi(); - const catalog = await loadModelCatalog({ config: params.cfg }); + const { loadPreparedModelCatalog, modelSupportsVision } = await loadPreparedModelCatalogApi(); + const catalog = await loadPreparedModelCatalog({ + config: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); return resolveCatalogImageModelId({ providerId: params.providerId, catalog, @@ -499,13 +520,14 @@ async function resolveAntigravityCliEntry( async function resolveKeyEntry(params: { cfg: OpenClawConfig; + agentId?: string; agentDir?: string; workspaceDir?: string; providerRegistry: ProviderRegistry; capability: MediaUnderstandingCapability; activeModel?: ActiveMediaModel; }): Promise { - const { cfg, agentDir, workspaceDir, providerRegistry, capability } = params; + const { cfg, agentId, agentDir, workspaceDir, providerRegistry, capability } = params; const checkProvider = async ( providerId: string, model?: string, @@ -540,9 +562,11 @@ async function resolveKeyEntry(params: { capability === "image" ? await resolveAutoImageModelId({ cfg, + agentId, providerId, providerRegistry, explicitModel: model, + agentDir, workspaceDir, }) : capability === "audio" @@ -655,7 +679,10 @@ function isMinimaxNativeVisionModel(params: { provider: string; model?: string } async function activeModelSupportsNativeVision(params: { cfg: OpenClawConfig; + agentId?: string; activeModel?: ActiveMediaModel; + agentDir?: string; + workspaceDir?: string; }): Promise { const activeProvider = params.activeModel?.provider?.trim(); if (!activeProvider) { @@ -670,8 +697,14 @@ async function activeModelSupportsNativeVision(params: { ) { return false; } - const { findModelInCatalog, loadModelCatalog, modelSupportsVision } = await loadModelCatalogApi(); - const catalog = await loadModelCatalog({ config: params.cfg }); + const { findModelInCatalog, loadPreparedModelCatalog, modelSupportsVision } = + await loadPreparedModelCatalogApi(); + const catalog = await loadPreparedModelCatalog({ + config: params.cfg, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }); const entry = findModelInCatalog(catalog, activeProvider, params.activeModel?.model ?? ""); return modelSupportsVision(entry); } @@ -688,7 +721,10 @@ async function resolveAutoEntries(params: { if (params.capability === "image") { const activeSupportsVision = await activeModelSupportsNativeVision({ cfg: params.cfg, + agentId: params.agentId, activeModel: params.activeModel, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, }); if (!activeSupportsVision) { const imageModelEntries = resolveImageModelFromAgentDefaults({ @@ -755,6 +791,7 @@ export async function resolveAutoImageModel(params: { } const activeEntry = await resolveActiveModelEntry({ cfg: params.cfg, + agentId: params.agentId, agentDir: params.agentDir, workspaceDir: params.workspaceDir, providerRegistry, @@ -767,6 +804,7 @@ export async function resolveAutoImageModel(params: { } const keyEntry = await resolveKeyEntry({ cfg: params.cfg, + agentId: params.agentId, agentDir: params.agentDir, workspaceDir: params.workspaceDir, providerRegistry, @@ -778,6 +816,7 @@ export async function resolveAutoImageModel(params: { async function resolveActiveModelEntry(params: { cfg: OpenClawConfig; + agentId?: string; agentDir?: string; workspaceDir?: string; providerRegistry: ProviderRegistry; @@ -819,9 +858,11 @@ async function resolveActiveModelEntry(params: { if (params.capability === "image") { model = await resolveAutoImageModelId({ cfg: params.cfg, + agentId: params.agentId, providerId, providerRegistry: params.providerRegistry, explicitModel: params.activeModel?.model, + agentDir: params.agentDir, workspaceDir: params.workspaceDir, }); } else if (params.capability === "audio") { @@ -854,6 +895,7 @@ async function runAttachmentEntries(params: { cfg: OpenClawConfig; ctx: MsgContext; attachmentIndex: number; + agentId?: string; agentDir?: string; workspaceDir?: string; providerRegistry: ProviderRegistry; @@ -888,6 +930,7 @@ async function runAttachmentEntries(params: { ctx: params.ctx, attachmentIndex: params.attachmentIndex, cache: params.cache, + agentId: params.agentId, agentDir: params.agentDir, workspaceDir: params.workspaceDir, providerRegistry: params.providerRegistry, @@ -1012,7 +1055,15 @@ export async function runCapability(params: { config, }) ) { - if (await activeModelSupportsNativeVision({ cfg, activeModel: params.activeModel })) { + if ( + await activeModelSupportsNativeVision({ + cfg, + agentId: params.agentId, + activeModel: params.activeModel, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + }) + ) { if (shouldLogVerbose()) { logVerbose("Skipping image understanding: primary model supports vision natively"); } @@ -1081,6 +1132,7 @@ export async function runCapability(params: { cfg, ctx, attachmentIndex: attachment.index, + agentId: params.agentId, agentDir: params.agentDir, workspaceDir: params.workspaceDir, providerRegistry: params.providerRegistry, diff --git a/src/media-understanding/runner.vision-skip.test.ts b/src/media-understanding/runner.vision-skip.test.ts index 0829a98df56..3ca497e4b69 100644 --- a/src/media-understanding/runner.vision-skip.test.ts +++ b/src/media-understanding/runner.vision-skip.test.ts @@ -34,7 +34,7 @@ const baseCatalog: TestCatalogEntry[] = [ let catalog: TestCatalogEntry[] = [...baseCatalog]; const plantedVisionSentinel = "PLANTED_VISION_DESC_zq7x"; -const loadModelCatalog = vi.hoisted(() => vi.fn(async () => catalog)); +const loadModelCatalog = vi.hoisted(() => vi.fn(async (_params: unknown) => catalog)); vi.mock("../agents/model-auth.js", async () => { const { createAvailableModelAuthMockModule } = await import("./runner.test-mocks.js"); @@ -60,10 +60,13 @@ vi.mock("../agents/model-catalog.js", async () => { ); return { ...actual, - loadModelCatalog, }; }); +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: loadModelCatalog, +})); + let buildProviderRegistry: typeof import("./runner.js").buildProviderRegistry; let applyMediaUnderstanding: typeof import("./apply.js").applyMediaUnderstanding; let resolveAutoImageModel: typeof import("./runner.js").resolveAutoImageModel; @@ -119,14 +122,8 @@ function requireCapabilityOutput(result: CapabilityResult, index: number) { describe("runCapability image skip", () => { beforeAll(async () => { - vi.doMock("../agents/model-catalog.js", async () => { - const actual = await vi.importActual( - "../agents/model-catalog.js", - ); - return { - ...actual, - loadModelCatalog, - }; + vi.doMock("../agents/prepared-model-catalog.js", () => { + return { loadPreparedModelCatalog: loadModelCatalog }; }); ({ buildProviderRegistry, resolveAutoImageModel, runCapability } = await import("./runner.js")); ({ applyMediaUnderstanding } = await import("./apply.js")); @@ -149,6 +146,7 @@ describe("runCapability image skip", () => { const result = await runCapability({ capability: "image", cfg, + agentId: "vision-agent", ctx, attachments: cache, media, @@ -167,6 +165,10 @@ describe("runCapability image skip", () => { } expect(attempt.outcome).toBe("skipped"); expect(attempt.reason).toBe("primary model supports vision natively"); + expect(loadModelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "vision-agent" }), + ); + expect(loadModelCatalog.mock.calls[0]?.[0]).not.toHaveProperty("readOnly"); } finally { await cache.cleanup(); } diff --git a/src/media-understanding/runtime-types.ts b/src/media-understanding/runtime-types.ts index 4ada7b08c22..637d60654ac 100644 --- a/src/media-understanding/runtime-types.ts +++ b/src/media-understanding/runtime-types.ts @@ -15,6 +15,7 @@ export type RunMediaUnderstandingFileParams = { filePath: string; mediaUrl?: string; cfg: OpenClawConfig; + agentId?: string; agentDir?: string; workspaceDir?: string; mime?: string; @@ -42,6 +43,7 @@ export type DescribeImageFileParams = { filePath: string; mediaUrl?: string; cfg: OpenClawConfig; + agentId?: string; agentDir?: string; workspaceDir?: string; mime?: string; @@ -55,6 +57,7 @@ export type DescribeImageFileWithModelParams = { filePath: string; mediaUrl?: string; cfg: OpenClawConfig; + agentId?: string; agentDir?: string; workspaceDir?: string; mime?: string; diff --git a/src/media-understanding/runtime.test.ts b/src/media-understanding/runtime.test.ts index 5176b55b197..8cd8ae4d6f9 100644 --- a/src/media-understanding/runtime.test.ts +++ b/src/media-understanding/runtime.test.ts @@ -175,6 +175,30 @@ describe("media-understanding runtime", () => { expect(mocks.cleanup).toHaveBeenCalledTimes(1); }); + it("resolves the agent directory for agent-scoped file media", async () => { + mocks.normalizeMediaAttachments.mockReturnValue([ + { index: 0, path: "/tmp/sample.ogg", mime: "audio/ogg" }, + ]); + mocks.runCapability.mockResolvedValue({ + outputs: [], + decision: { capability: "audio", outcome: "skipped", attachments: [] }, + }); + + await runMediaUnderstandingFile({ + capability: "audio", + filePath: "/tmp/sample.ogg", + mime: "audio/ogg", + cfg: { + agents: { list: [{ id: "worker", agentDir: "/tmp/worker-agent" }] }, + } as OpenClawConfig, + agentId: "worker", + }); + + expect(mocks.runCapability).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "worker", agentDir: "/tmp/worker-agent" }), + ); + }); + it("returns the matching capability output", async () => { const output: MediaUnderstandingOutput = { kind: "image.description", @@ -751,6 +775,26 @@ describe("media-understanding runtime", () => { expect(describeImageOptions?.agentDir).toBe("/tmp/agent"); }); + it("resolves the agent directory when direct image description only names an agent", async () => { + mocks.readLocalFileSafely.mockResolvedValue({ buffer: Buffer.from("image-bytes") }); + + await describeImageFileWithModel({ + filePath: "/tmp/sample.jpg", + mime: "image/jpeg", + provider: "gemini", + model: "vision-v1", + prompt: "Describe the sample.", + cfg: { + agents: { list: [{ id: "worker", agentDir: "/tmp/worker-agent" }] }, + } as OpenClawConfig, + agentId: "worker", + }); + + expect(mocks.describeImageWithModel).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "worker", agentDir: "/tmp/worker-agent" }), + ); + }); + it("routes structured extraction to a provider by id and model", async () => { const providerRegistry = new Map(); const authStore = {} as AuthProfileStore; diff --git a/src/media-understanding/runtime.ts b/src/media-understanding/runtime.ts index 6de446f1628..f48ce3c8cde 100644 --- a/src/media-understanding/runtime.ts +++ b/src/media-understanding/runtime.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { detectMime, kindFromMime, mimeTypeFromFilePath } from "@openclaw/media-core/mime"; import { hasHttpUrlPrefix } from "@openclaw/net-policy/url-protocol"; +import { resolveAgentDir, resolveDefaultAgentDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.js"; import { readLocalFileSafely } from "../infra/fs-safe.js"; import { DEFAULT_MAX_BYTES } from "./defaults.constants.js"; @@ -196,6 +197,8 @@ export async function runMediaUnderstandingFile( } const providerRegistry = buildProviderRegistry(undefined, cfg); + const agentDir = + params.agentDir ?? (params.agentId ? resolveAgentDir(cfg, params.agentId) : undefined); const cache = createMediaAttachmentCache(attachments, { localPathRoots: params.mediaUrl ? undefined : resolveFileLocalRoots(params.filePath), ssrfPolicy: cfg.tools?.web?.fetch?.ssrfPolicy, @@ -208,7 +211,8 @@ export async function runMediaUnderstandingFile( ctx, attachments: cache, media: attachments, - agentDir: params.agentDir, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(agentDir ? { agentDir } : {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), providerRegistry, config, @@ -275,6 +279,11 @@ export async function describePreparedImageWithModel(params: DescribePreparedIma const providerRegistry = buildProviderRegistry(undefined, params.cfg); const provider = providerRegistry.get(normalizeMediaProviderId(params.provider)); const describeImage = provider?.describeImage ?? describeImageWithModel; + const agentDir = + params.agentDir ?? + (params.agentId + ? resolveAgentDir(params.cfg, params.agentId) + : resolveDefaultAgentDir(params.cfg)); return await describeImage({ buffer: params.image.buffer, fileName: params.image.fileName, @@ -285,7 +294,8 @@ export async function describePreparedImageWithModel(params: DescribePreparedIma maxTokens: params.maxTokens, timeoutMs, cfg: params.cfg, - agentDir: params.agentDir ?? "", + ...(params.agentId ? { agentId: params.agentId } : {}), + agentDir, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); } diff --git a/src/media-understanding/types.ts b/src/media-understanding/types.ts index 16f6b16d0fc..24fd24ad776 100644 --- a/src/media-understanding/types.ts +++ b/src/media-understanding/types.ts @@ -4,6 +4,14 @@ import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +/** Agent-owned runtime handle carried opaquely through media provider requests. */ +type MediaPreparedModelRuntime = Readonly<{ + agentDir: string; + workspaceDir?: string; + config: OpenClawConfig; + createStores: () => unknown; +}>; + type MediaUnderstandingKind = "audio.transcription" | "video.description" | "image.description"; export type MediaUnderstandingCapability = "image" | "audio" | "video"; @@ -148,8 +156,10 @@ export type ImageDescriptionRequest = { profile?: string; preferredProfile?: string; authStore?: AuthProfileStore; + agentId?: string; agentDir: string; workspaceDir?: string; + preparedModelRuntime?: MediaPreparedModelRuntime; cfg: OpenClawConfig; model: string; provider: string; @@ -171,8 +181,10 @@ export type ImagesDescriptionRequest = { profile?: string; preferredProfile?: string; authStore?: AuthProfileStore; + agentId?: string; agentDir: string; workspaceDir?: string; + preparedModelRuntime?: MediaPreparedModelRuntime; cfg: OpenClawConfig; }; diff --git a/src/plugin-sdk/agent-runtime-model-catalog-contract.test.ts b/src/plugin-sdk/agent-runtime-model-catalog-contract.test.ts new file mode 100644 index 00000000000..25d3dcf91d8 --- /dev/null +++ b/src/plugin-sdk/agent-runtime-model-catalog-contract.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSnapshot: vi.fn(), + loadCatalog: vi.fn(), +})); + +vi.mock("../agents/prepared-model-catalog.js", () => ({ + getPreparedModelCatalogSnapshot: (...args: unknown[]) => mocks.getSnapshot(...args), + loadPreparedModelCatalog: (...args: unknown[]) => mocks.loadCatalog(...args), +})); + +import { loadModelCatalog } from "./agent-runtime.js"; + +describe("agent-runtime model catalog compatibility", () => { + beforeEach(() => { + mocks.getSnapshot.mockReset(); + mocks.loadCatalog.mockReset(); + }); + + it("keeps legacy cache-only reads nonblocking", async () => { + mocks.getSnapshot.mockReturnValue({ + entries: [{ provider: "test", id: "cached", name: "Cached" }], + routeVariants: [], + }); + + await expect(loadModelCatalog({ cacheOnly: true, useCache: true })).resolves.toEqual([ + { provider: "test", id: "cached", name: "Cached" }, + ]); + expect(mocks.loadCatalog).not.toHaveBeenCalled(); + }); + + it("accepts legacy options without overriding lifecycle metadata", async () => { + mocks.loadCatalog.mockResolvedValue([]); + const config = {}; + const env = { OPENCLAW_STATE_DIR: "/tmp/plugin-state" }; + + await loadModelCatalog({ + agentDir: "/tmp/plugin-agent", + config, + env, + metadataSnapshot: {} as never, + readOnly: true, + useCache: false, + workspaceDir: "/tmp/plugin-workspace", + }); + + expect(mocks.loadCatalog).toHaveBeenCalledWith({ + agentDir: "/tmp/plugin-agent", + config, + env, + readOnly: true, + workspaceDir: "/tmp/plugin-workspace", + }); + }); +}); diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 8de33b17cb1..50bf6c2aa88 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -1,7 +1,13 @@ +import { + getPreparedModelCatalogSnapshot, + loadPreparedModelCatalog, + type LoadPreparedModelCatalogParams, +} from "../agents/prepared-model-catalog.js"; /** * @deprecated Broad public SDK barrel. Prefer focused agent/runtime subpaths * and avoid adding new imports here. */ +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; export { listAgentIds, @@ -25,12 +31,35 @@ export { } from "../agents/identity.js"; export { resolveApiKeyForProvider } from "../agents/model-auth.js"; -export { - findModelInCatalog, - loadModelCatalog, - modelSupportsVision, -} from "../agents/model-catalog.js"; +export { findModelInCatalog, modelSupportsVision } from "../agents/model-catalog.js"; export type { ModelCatalogEntry } from "../agents/model-catalog.js"; +export { getPreparedModelCatalogSnapshot, loadPreparedModelCatalog }; + +type LoadModelCatalogCompatibilityParams = LoadPreparedModelCatalogParams & { + /** @deprecated Lifecycle publication owns refreshes; retained for source compatibility. */ + useCache?: boolean; + /** @deprecated Use getPreparedModelCatalogSnapshot for new nonblocking readers. */ + cacheOnly?: boolean; + /** @deprecated Plugin metadata belongs to the published lifecycle generation. */ + metadataSnapshot?: PluginMetadataSnapshot; +}; + +/** @deprecated Use loadPreparedModelCatalog or getPreparedModelCatalogSnapshot. */ +export async function loadModelCatalog(params: LoadModelCatalogCompatibilityParams = {}) { + const { agentId, agentDir, cacheOnly, config, env, readOnly, workspaceDir } = params; + const preparedParams: LoadPreparedModelCatalogParams = { + ...(agentId ? { agentId } : {}), + ...(agentDir ? { agentDir } : {}), + ...(config ? { config } : {}), + ...(env ? { env } : {}), + ...(readOnly !== undefined ? { readOnly } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + }; + if (cacheOnly) { + return getPreparedModelCatalogSnapshot(preparedParams)?.entries ?? []; + } + return await loadPreparedModelCatalog(preparedParams); +} export { buildModelAliasIndex, diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 54027ae915b..49c9d60631f 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -1,5 +1,4 @@ // Plugin contract registry assembles bundled plugin fixtures for shared contract tests. -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js"; import { discoverOpenClawPlugins } from "../discovery.js"; @@ -130,14 +129,6 @@ function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { })); } -function resolveBundledProviderContractPluginIds(): string[] { - return uniqueStrings( - resolveBundledManifestContracts() - .filter((entry) => entry.providerIds.length > 0) - .map((entry) => entry.pluginId), - ).toSorted((left, right) => left.localeCompare(right)); -} - export let providerContractLoadError: Error | undefined; function formatBundledCapabilityPluginLoadError(params: { @@ -355,30 +346,6 @@ function createLazyArrayView(load: () => T[]): T[] { }, }); } -export function resolveProviderContractPluginIdsForProviderAlias( - providerId: string, -): string[] | undefined { - const normalizedProvider = normalizeProviderId(providerId); - if (!normalizedProvider) { - return undefined; - } - const pluginIds = uniqueStrings( - loadProviderContractEntriesForPluginIds(resolveBundledProviderContractPluginIds()) - .filter((entry) => { - const providerIds = [ - entry.provider.id, - ...(entry.provider.aliases ?? []), - ...(entry.provider.hookAliases ?? []), - ]; - return providerIds.some( - (candidate) => normalizeProviderId(candidate) === normalizedProvider, - ); - }) - .map((entry) => entry.pluginId), - ).toSorted((left, right) => left.localeCompare(right)); - return pluginIds.length > 0 ? pluginIds : undefined; -} - export function resolveProviderContractProvidersForPluginIds( pluginIds: readonly string[], ): ProviderPlugin[] { diff --git a/src/plugins/plugin-registry-contributions.ts b/src/plugins/plugin-registry-contributions.ts index a80918e6121..29b03ffa1bc 100644 --- a/src/plugins/plugin-registry-contributions.ts +++ b/src/plugins/plugin-registry-contributions.ts @@ -1,5 +1,4 @@ /** Loads manifest and installed-index contributions used to build plugin registry snapshots. */ -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -63,10 +62,6 @@ type ListPluginContributionIdsParams = PluginRegistryContributionOptions & { contribution: PluginRegistryContributionKey; }; -type ResolveProviderOwnersParams = PluginRegistryContributionOptions & { - providerId: string; -}; - type ResolveManifestContractPluginIdsParams = LoadPluginRegistryParams & { contract: PluginManifestContractListKey; origin?: PluginOrigin; @@ -369,33 +364,6 @@ export function resolvePluginContributionOwners( ); } -export function resolveProviderOwners(params: ResolveProviderOwnersParams): readonly string[] { - const providerId = normalizeProviderId(params.providerId); - if (!providerId) { - return []; - } - if (params.lookUpTable) { - const index = params.lookUpTable.index; - const owners: string[] = []; - for (const [contributionId, ownerIds] of params.lookUpTable.owners.providers.entries()) { - if (normalizeProviderId(contributionId) === providerId) { - owners.push(...ownerIds); - } - } - return filterContributionOwnerIds({ - owners, - index, - includeDisabled: params.includeDisabled, - config: params.config, - }); - } - return resolvePluginContributionOwners({ - ...params, - contribution: "providers", - matches: (contributionId) => normalizeProviderId(contributionId) === providerId, - }); -} - export function resolveManifestContractPluginIds( params: ResolveManifestContractPluginIdsParams, ): string[] { diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 9281d8048cf..9931e846eae 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -33,13 +33,27 @@ import { resolveManifestContractPluginIds, resolveManifestContractPluginIdsByCompatibilityRuntimePath, resolvePluginContributionOwners, - resolveProviderOwners, } from "./plugin-registry.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; const tempDirs: string[] = []; const DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV = "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY"; +function resolveProviderOwners( + params: Omit< + Parameters[0], + "contribution" | "matches" + > & { providerId: string }, +) { + const providerId = params.providerId.trim().toLowerCase(); + const { providerId: _providerId, ...options } = params; + return resolvePluginContributionOwners({ + ...options, + contribution: "providers", + matches: (candidate) => candidate.trim().toLowerCase() === providerId, + }); +} + function listPluginRecords(params: { index: InstalledPluginIndex }) { return params.index.plugins; } diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index c5342e93c80..607bb396728 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -79,18 +79,6 @@ export function groupPluginDiscoveryProvidersByOrder( return grouped; } -/** Matches a normalized provider filter against all provider-owned identifiers. */ -export function providerMatchesFilter(params: { - provider: Pick; - providerFilter: string; -}): boolean { - return [ - params.provider.id, - ...(params.provider.aliases ?? []), - ...(params.provider.hookAliases ?? []), - ].some((providerId) => normalizeProviderId(providerId) === params.providerFilter); -} - /** Normalizes a plugin discovery response into safe provider-config keys. */ export function normalizePluginDiscoveryResult(params: { provider: ProviderPlugin; diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 5d5ea81c147..7341dcfa86f 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1035,11 +1035,17 @@ describe("provider-runtime", () => { ...baseConfig, agents: { defaults: { model: "anthropic/claude-sonnet-4-5" } }, } as OpenClawConfig; + const metadataSnapshot = { + index: {}, + manifestRegistry: {}, + workspaceDir: "/tmp/snapshot-workspace", + } as never; expect( await augmentModelCatalogWithProviderPlugins({ config: firstConfig, env: process.env, + metadataSnapshot, context: { config: firstConfig, env: process.env, entries: [] }, }), ).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]); @@ -1047,11 +1053,24 @@ describe("provider-runtime", () => { await augmentModelCatalogWithProviderPlugins({ config: secondConfig, env: process.env, + metadataSnapshot, context: { config: secondConfig, env: process.env, entries: [] }, }), ).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]); expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); + expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + metadataSnapshot, + workspaceDir: "/tmp/snapshot-workspace", + }), + ); + expect(resolvePluginProvidersMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + pluginMetadataSnapshot: metadataSnapshot, + workspaceDir: "/tmp/snapshot-workspace", + }), + ); }); it("resolves catalog hook provider loads when unrelated plugin config changes", async () => { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 339a6fcc84f..002aaf67d64 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -25,7 +25,10 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; -import type { PluginMetadataRegistryView } from "./plugin-metadata-snapshot.types.js"; +import type { + PluginMetadataRegistryView, + PluginMetadataSnapshot, +} from "./plugin-metadata-snapshot.types.js"; import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js"; import { clearProviderRuntimePluginCacheForTest, @@ -187,13 +190,18 @@ function resolveProviderPluginsForCatalogHooks(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; }): ProviderPlugin[] { - const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); + const workspaceDir = + params.workspaceDir ?? + params.metadataSnapshot?.workspaceDir ?? + getActivePluginRegistryWorkspaceDirFromState(); const env = params.env ?? process.env; const onlyPluginIds = resolveCatalogHookProviderPluginIds({ config: params.config, workspaceDir, env, + metadataSnapshot: params.metadataSnapshot, }); if (onlyPluginIds.length === 0) { return []; @@ -203,6 +211,7 @@ function resolveProviderPluginsForCatalogHooks(params: { workspaceDir, env, onlyPluginIds, + pluginMetadataSnapshot: params.metadataSnapshot, }); } @@ -1111,6 +1120,7 @@ export async function augmentModelCatalogWithProviderPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; context: ProviderAugmentModelCatalogContext; }) { const supplemental = [] as ProviderAugmentModelCatalogContext["entries"]; diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index dee5fb94ea0..f9beb792c42 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -807,6 +807,7 @@ export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; env?: PluginLoadOptions["env"]; + metadataSnapshot?: ProviderManifestLoadParams["metadataSnapshot"]; }): string[] { const registry = loadProviderRegistrySnapshot(params); const manifestRegistry = resolveManifestRegistry({ diff --git a/src/plugins/synthetic-auth.runtime.test.ts b/src/plugins/synthetic-auth.runtime.test.ts index c07f8882269..4f104c8e590 100644 --- a/src/plugins/synthetic-auth.runtime.test.ts +++ b/src/plugins/synthetic-auth.runtime.test.ts @@ -46,7 +46,6 @@ vi.mock("./manifest-registry-installed.js", () => ({ })); import { - resolveRuntimeExternalAuthProviderRefs, resolveRuntimeSyntheticAuthProviderRefState, resolveRuntimeSyntheticAuthProviderRefs, } from "./synthetic-auth.runtime.js"; @@ -114,30 +113,6 @@ describe("synthetic auth runtime refs", () => { }); }); - it("uses persisted registry external auth provider refs before the runtime registry exists", () => { - const snapshot = { - plugins: [{ syntheticAuthRefs: [] }], - }; - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "persisted", - snapshot, - diagnostics: [], - }); - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({ - plugins: [ - { contracts: { externalAuthProviders: [" runtime-provider ", "runtime-provider"] } }, - { contracts: { externalAuthProviders: ["external-cli"] } }, - { contracts: {} }, - ], - diagnostics: [], - }); - - expect(resolveRuntimeExternalAuthProviderRefs()).toEqual(["runtime-provider", "external-cli"]); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith({ - index: snapshot, - }); - }); - it("does not derive the registry just to resolve synthetic auth refs", () => { pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ source: "derived", @@ -158,17 +133,6 @@ describe("synthetic auth runtime refs", () => { }); }); - it("does not derive the registry just to resolve external auth refs", () => { - pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ - source: "derived", - snapshot: { plugins: [] }, - diagnostics: [], - }); - - expect(resolveRuntimeExternalAuthProviderRefs()).toStrictEqual([]); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); - }); - it("does not treat a provided index with registry diagnostics as validated synthetic auth", () => { const index = { plugins: [{ syntheticAuthRefs: ["local-provider"] }], @@ -185,22 +149,6 @@ describe("synthetic auth runtime refs", () => { expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); - it("does not treat a provided index with registry diagnostics as validated external auth", () => { - const index = { - plugins: [{ syntheticAuthRefs: [] }], - }; - - expect( - resolveRuntimeExternalAuthProviderRefs({ - index: index as unknown as NonNullable< - Parameters[0] - >["index"], - registryDiagnostics: [{ code: "persisted-registry-missing" }], - }), - ).toStrictEqual([]); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); - }); - it("prefers the active runtime registry when plugins are already loaded", () => { getPluginRegistryState.mockReturnValue({ activeRegistry: { @@ -247,41 +195,4 @@ describe("synthetic auth runtime refs", () => { }); expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); - - it("prefers active runtime registry external auth refs when plugins are already loaded", () => { - getPluginRegistryState.mockReturnValue({ - activeRegistry: { - plugins: [ - { - contracts: { - externalAuthProviders: ["manifest-provider"], - }, - }, - ], - providers: [ - { - provider: { - id: "runtime-provider", - resolveExternalAuthProfiles: () => [], - }, - }, - ], - cliBackends: [ - { - backend: { - id: "runtime-cli", - resolveExternalAuthProfiles: () => [], - }, - }, - ], - }, - }); - - expect(resolveRuntimeExternalAuthProviderRefs()).toEqual([ - "manifest-provider", - "runtime-provider", - "runtime-cli", - ]); - expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); - }); }); diff --git a/src/plugins/synthetic-auth.runtime.ts b/src/plugins/synthetic-auth.runtime.ts index 1873ecaf8a8..ad56eb51e7d 100644 --- a/src/plugins/synthetic-auth.runtime.ts +++ b/src/plugins/synthetic-auth.runtime.ts @@ -1,6 +1,5 @@ /** Resolves synthetic and external auth provider refs from active runtime state or persisted manifests. */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry.js"; import type { LoadPluginRegistryParams, PluginRegistrySnapshot } from "./plugin-registry.js"; import { getPluginRegistryState } from "./runtime-state.js"; @@ -43,24 +42,6 @@ type SyntheticAuthProviderRefParams = LoadPluginRegistryParams & { registryDiagnostics?: readonly unknown[]; }; -function resolveManifestExternalAuthProviderRefs( - params: SyntheticAuthProviderRefParams = {}, -): string[] { - if (params.index && (params.registryDiagnostics?.length ?? 0) > 0) { - return []; - } - const result = loadPluginRegistrySnapshotWithMetadata(params); - if (result.source !== "persisted" && result.source !== "provided") { - return []; - } - const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ - index: result.snapshot, - }); - return uniqueProviderRefs( - manifestRegistry.plugins.flatMap((plugin) => plugin.contracts?.externalAuthProviders ?? []), - ); -} - /** Lists provider refs that can satisfy synthetic auth profile lookups. */ export function resolveRuntimeSyntheticAuthProviderRefs( params: SyntheticAuthProviderRefParams = {}, @@ -97,34 +78,3 @@ export function resolveRuntimeSyntheticAuthProviderRefState( } return resolveManifestSyntheticAuthProviderRefState(params); } - -/** Lists provider refs that can expose external auth profiles to runtime consumers. */ -export function resolveRuntimeExternalAuthProviderRefs( - params: SyntheticAuthProviderRefParams = {}, -): string[] { - const registry = getPluginRegistryState()?.activeRegistry; - if (registry) { - return uniqueProviderRefs([ - ...registry.plugins.flatMap((plugin) => plugin.contracts?.externalAuthProviders ?? []), - ...(registry.providers ?? []) - .filter( - (entry) => - ("resolveExternalAuthProfiles" in entry.provider && - typeof entry.provider.resolveExternalAuthProfiles === "function") || - ("resolveExternalOAuthProfiles" in entry.provider && - typeof entry.provider.resolveExternalOAuthProfiles === "function"), - ) - .map((entry) => entry.provider.id), - ...registry.cliBackends - .filter( - (entry) => - ("resolveExternalAuthProfiles" in entry.backend && - typeof entry.backend.resolveExternalAuthProfiles === "function") || - ("resolveExternalOAuthProfiles" in entry.backend && - typeof entry.backend.resolveExternalOAuthProfiles === "function"), - ) - .map((entry) => entry.backend.id), - ]); - } - return resolveManifestExternalAuthProviderRefs(params); -} diff --git a/src/secrets/runtime.fast-path.test.ts b/src/secrets/runtime.fast-path.test.ts index 6b779c1f386..e3090d047f3 100644 --- a/src/secrets/runtime.fast-path.test.ts +++ b/src/secrets/runtime.fast-path.test.ts @@ -340,12 +340,12 @@ describe("secrets runtime fast path", () => { agentDirs: [agentDir], loadAuthStore: loadInitialAuthStore, }); + activateSecretsRuntimeSnapshot(initialSnapshot); newerSnapshot = await prepareSecretsRuntimeSnapshot({ config: config(19_002), agentDirs: [agentDir], loadAuthStore: emptyAuthStore, }); - activateSecretsRuntimeSnapshot(initialSnapshot); publishNewerSnapshot = true; await expect(refreshActiveProviderAuthRuntimeSnapshot()).resolves.toBe(true); diff --git a/src/skills/workshop/history-scan.resume.test.ts b/src/skills/workshop/history-scan.resume.test.ts index c0fb6fafb48..24e64407d3d 100644 --- a/src/skills/workshop/history-scan.resume.test.ts +++ b/src/skills/workshop/history-scan.resume.test.ts @@ -25,7 +25,7 @@ vi.mock("../../agents/agent-scope.js", () => ({ })); vi.mock("../../agents/embedded-agent-runner/model.js", () => ({ - resolveModel: vi.fn(() => ({ + resolveModelAsync: vi.fn(async () => ({ model: { contextTokens: 8_192, contextWindow: 8_192 }, })), })); diff --git a/src/skills/workshop/history-scan.ts b/src/skills/workshop/history-scan.ts index dfe38259f87..69ba71d7858 100644 --- a/src/skills/workshop/history-scan.ts +++ b/src/skills/workshop/history-scan.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { resolveAgentConfig, resolveAgentDir } from "../../agents/agent-scope.js"; -import { resolveModel } from "../../agents/embedded-agent-runner/model.js"; +import { resolveModelAsync } from "../../agents/embedded-agent-runner/model.js"; import { isEmbeddedAgentRunActive } from "../../agents/embedded-agent-runner/runs.js"; import { resolveDefaultModelForAgent } from "../../agents/model-selection-config.js"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; @@ -212,12 +212,14 @@ async function runSkillHistoryScanCore( const modelRef = resolveDefaultModelForAgent({ cfg: params.config, agentId: params.agentId }); const resolvedModel = eligible.length > 0 - ? resolveModel( - modelRef.provider, - modelRef.model, - resolveAgentDir(params.config, params.agentId, params.env), - params.config, - { workspaceDir: params.workspaceDir }, + ? ( + await resolveModelAsync( + modelRef.provider, + modelRef.model, + resolveAgentDir(params.config, params.agentId, params.env), + params.config, + { agentId: params.agentId, workspaceDir: params.workspaceDir }, + ) ).model : undefined; const contextTokens = resolvedModel diff --git a/src/system-agent/tui-backend.ts b/src/system-agent/tui-backend.ts index 743998248a1..4b39eba0674 100644 --- a/src/system-agent/tui-backend.ts +++ b/src/system-agent/tui-backend.ts @@ -504,15 +504,18 @@ async function requireTuiVerifiedInference( try { const route = await resolveSystemAgentVerifiedInferenceRoute(binding, opts.deps); if (route) { - const [{ loadModelCatalog }, { resolveThinkingDefault }] = await Promise.all([ - import("../agents/model-catalog.js"), + const [{ loadPreparedModelCatalog }, { resolveThinkingDefault }] = await Promise.all([ + import("../agents/prepared-model-catalog.js"), import("../agents/model-thinking-default.js"), ]); // Catalog metadata improves the label but must not become a new startup // dependency after this exact inference route has already been verified. - const catalog = await loadModelCatalog({ config: route.runConfig, readOnly: true }).catch( - () => undefined, - ); + const catalog = await loadPreparedModelCatalog({ + config: route.runConfig, + agentId: route.agentId, + agentDir: route.agentDir, + readOnly: true, + }).catch(() => undefined); const model = splitModelRef(route.modelLabel); return { model: model.model, diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 7c36d8cb1da..fd500db01cc 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -245,8 +245,8 @@ vi.mock("../commands/auth-choice.js", () => ({ warnIfModelConfigLooksOff: vi.fn(), })); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalogSnapshot: async (...args: unknown[]) => { +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await loadModelCatalog(...args); return { entries, routeVariants: entries }; }, diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 33bf4858b30..f25578f93fa 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -615,12 +615,15 @@ export async function finalizeSetupWizard( // route facts must not turn the onboarding greeting into a guaranteed failure. const [ { resolveDefaultModelAuthStatus, resolveDefaultModelCatalogFacts }, - { loadModelCatalogSnapshot }, + { loadPreparedModelCatalogSnapshot }, ] = await Promise.all([ import("../commands/auth-choice.js"), - import("../agents/model-catalog.js"), + import("../agents/prepared-model-catalog.js"), ]); - const modelCatalog = await loadModelCatalogSnapshot({ config: nextConfig, readOnly: true }); + const modelCatalog = await loadPreparedModelCatalogSnapshot({ + config: nextConfig, + readOnly: true, + }); const modelCatalogFacts = resolveDefaultModelCatalogFacts(nextConfig, modelCatalog.entries, { routeVariants: modelCatalog.routeVariants, }); diff --git a/test/helpers/auto-reply/trigger-handling-test-harness.ts b/test/helpers/auto-reply/trigger-handling-test-harness.ts index d33a7b124ee..8544592027a 100644 --- a/test/helpers/auto-reply/trigger-handling-test-harness.ts +++ b/test/helpers/auto-reply/trigger-handling-test-harness.ts @@ -120,8 +120,7 @@ const DEFAULT_MODEL_CATALOG = [ const modelCatalogMocks = getSharedMocks("openclaw.trigger-handling.model-catalog-mocks", () => ({ loadManifestModelCatalog: vi.fn(() => DEFAULT_MODEL_CATALOG), - loadModelCatalog: vi.fn().mockResolvedValue(DEFAULT_MODEL_CATALOG), - resetModelCatalogCacheForTest: vi.fn(), + loadPreparedModelCatalog: vi.fn().mockResolvedValue(DEFAULT_MODEL_CATALOG), })); const installModelCatalogMock = () => @@ -129,9 +128,23 @@ const installModelCatalogMock = () => installModelCatalogMock(); +vi.doMock("../../../src/agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: (...args: unknown[]) => + modelCatalogMocks.loadPreparedModelCatalog(...args), + loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { + const entries = await modelCatalogMocks.loadPreparedModelCatalog(...args); + return { entries, routeVariants: entries, authoritative: true }; + }, +})); + vi.doMock("../../../src/agents/model-catalog.runtime.js", () => ({ loadManifestModelCatalog: () => modelCatalogMocks.loadManifestModelCatalog(), - loadModelCatalog: (...args: unknown[]) => modelCatalogMocks.loadModelCatalog(...args), + loadPreparedModelCatalog: (...args: unknown[]) => + modelCatalogMocks.loadPreparedModelCatalog(...args), + loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { + const entries = await modelCatalogMocks.loadPreparedModelCatalog(...args); + return { entries, routeVariants: entries, authoritative: true }; + }, })); vi.doMock("../../../src/plugins/provider-runtime.runtime.js", () => ({ diff --git a/test/setup-openclaw-runtime.ts b/test/setup-openclaw-runtime.ts index 93b0e2188bd..a81ad77f7ed 100644 --- a/test/setup-openclaw-runtime.ts +++ b/test/setup-openclaw-runtime.ts @@ -32,6 +32,7 @@ type WorkerCleanupHelpers = { resetContextWindowCacheForTest: typeof import("../src/agents/context-runtime-state.js").resetContextWindowCacheForTest; resetFileLockStateForTest: typeof import("../src/infra/file-lock.js").resetFileLockStateForTest; resetModelsJsonReadyCacheForTest: typeof import("../src/agents/models-config-state.test-support.js").resetModelsJsonReadyCacheForTest; + resetPreparedModelRuntimeSnapshotsForTest: typeof import("../src/agents/prepared-model-runtime.test-support.js").resetPreparedModelRuntimeSnapshotsForTest; resetSessionWriteLockStateForTest: typeof import("../src/agents/session-write-lock.test-support.js").resetSessionWriteLockStateForTest; }; @@ -78,6 +79,7 @@ function loadWorkerCleanupHelpers(): Promise { const [ contextRuntimeState, modelsConfigState, + preparedModelRuntime, sessionWriteLockTestSupport, sessionStoreCache, sessionStoreWriterState, @@ -89,6 +91,9 @@ function loadWorkerCleanupHelpers(): Promise { vi.importActual( "../src/agents/models-config-state.test-support.js", ), + vi.importActual( + "../src/agents/prepared-model-runtime.test-support.js", + ), vi.importActual( "../src/agents/session-write-lock.test-support.js", ), @@ -109,6 +114,8 @@ function loadWorkerCleanupHelpers(): Promise { resetContextWindowCacheForTest: contextRuntimeState.resetContextWindowCacheForTest, resetFileLockStateForTest: fileLock.resetFileLockStateForTest, resetModelsJsonReadyCacheForTest: modelsConfigState.resetModelsJsonReadyCacheForTest, + resetPreparedModelRuntimeSnapshotsForTest: + preparedModelRuntime.resetPreparedModelRuntimeSnapshotsForTest, resetSessionWriteLockStateForTest: sessionWriteLockTestSupport.resetSessionWriteLockStateForTest, }; @@ -384,6 +391,7 @@ afterEach(async () => { resetContextWindowCacheForTest, resetFileLockStateForTest, resetModelsJsonReadyCacheForTest, + resetPreparedModelRuntimeSnapshotsForTest, resetSessionWriteLockStateForTest, } = await loadWorkerCleanupHelpers(); await drainSessionStoreWriterQueuesForTest(); @@ -393,6 +401,7 @@ afterEach(async () => { resetFileLockStateForTest(); resetContextWindowCacheForTest(); resetModelsJsonReadyCacheForTest(); + resetPreparedModelRuntimeSnapshotsForTest(); resetSessionWriteLockStateForTest(); await installDefaultPluginRegistry(); });