refactor: own model discovery by runtime lifecycle (#111173)

* refactor(agents): prepare model runtime catalogs

Build lifecycle-owned model and auth snapshots, carry prepared stores into hot agent paths, and serialize config/auth publication.

Credits @zeroaltitude's #90741 investigation and benchmark approach.

* refactor: finish lifecycle-owned model discovery

* fix: align prepared model catalog contracts

* test: align lifecycle catalog mocks

* test: fix prepared catalog type fixtures

* refactor: split prepared model runtime ownership

* fix: import prepared runtime replacement gate type

* test: split media runtime coverage

* test: preserve image auth fixture key types

* test: isolate lifecycle gate fixtures

* chore: keep release changelog owned

* refactor: finish prepared model catalog migration

* test: keep catalog review fixtures scanner-safe

* refactor: preserve lifecycle model runtime ownership

* fix: close prepared runtime lifecycle races

* fix: preserve compaction workspace fallback

* chore: document btw generation rebinding

* fix: preserve prepared generation boundaries

* fix: keep model-list discovery flag explicit

* fix: serialize standalone model runtime activation

* refactor: migrate subagent model catalog lookup

* refactor: clarify doctor catalog lookup seam

* chore: refresh plugin sdk api baseline

* test: migrate swarm catalog dependency

* refactor(telegram): rename runtime catalog seam

* refactor: extract model-aware tool context

* test(models): isolate lifecycle catalog fixtures

* refactor(agents): avoid btw parameter rebinding

* fix(net-policy): align root ipaddr dependency

* fix(build): keep net policy dependency bundled

* fix(deadcode): document net policy compile dependency
This commit is contained in:
Peter Steinberger
2026-07-19 05:30:54 -07:00
committed by GitHub
parent e24420bc1c
commit 06f5f73e47
223 changed files with 10178 additions and 8314 deletions
+3
View File
@@ -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",
-3
View File
@@ -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
@@ -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
+6
View File
@@ -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)
+1
View File
@@ -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
@@ -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,
@@ -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);
@@ -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<boolean>;
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 =
@@ -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,
});
@@ -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,
@@ -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,
+1 -1
View File
@@ -17,7 +17,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async () => {
);
return {
...actual,
loadModelCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: vi.fn(async () => []),
};
});
+15 -2
View File
@@ -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,
@@ -1,7 +1,7 @@
// Telegram plugin module implements bot message dispatch.agent behavior.
export {
findModelInCatalog,
loadModelCatalog,
loadPreparedModelCatalog,
modelSupportsVision,
resolveAgentDir,
resolveDefaultModelForAgent,
@@ -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,
}));
@@ -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;
@@ -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,
+10 -4
View File
@@ -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<ReturnType<typeof loadModelCatalog>>;
catalog: Awaited<ReturnType<typeof loadPreparedModelCatalog>>;
}): Promise<string> {
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({
@@ -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",
@@ -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,
}));
+14 -3
View File
@@ -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) {
@@ -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<boolean> {
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,
+1
View File
@@ -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",
+3
View File
@@ -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
+6 -3
View File
@@ -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(
+8 -4
View File
@@ -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";
@@ -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;
-6
View File
@@ -176,9 +176,3 @@ export function discoverModels(
options,
);
}
export {
addEnvBackedAgentCredentials,
resolveAgentCredentialsForDiscovery,
type DiscoverAuthStorageOptions,
} from "./agent-auth-discovery.js";
+3
View File
@@ -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,
@@ -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");
+102 -6
View File
@@ -9,6 +9,11 @@ import { resolveAuthStorePath } from "./path-resolve.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
const runtimeAuthStoreSnapshots = new Map<string, RuntimeAuthProfileStore>();
type RuntimeAuthProfileStoreMutationListener = (event: {
agentDir?: string;
affectsInheritedStores: boolean;
}) => void;
const runtimeAuthStoreMutationListeners = new Set<RuntimeAuthProfileStoreMutationListener>();
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 = {
+24 -13
View File
@@ -58,6 +58,7 @@ type LoadAuthProfileStoreOptions = {
config?: OpenClawConfig;
database?: OpenClawAgentDatabase;
externalCli?: ExternalCliAuthDiscovery;
inheritedAuthDir?: string;
readOnly?: boolean;
syncExternalCli?: boolean;
externalCliProviderIds?: Iterable<string>;
@@ -241,11 +242,11 @@ function shouldUseMainOwnerForLocalOAuthCredential(params: {
function resolveRuntimeAuthProfileStore(
agentDir?: string,
options?: Pick<LoadAuthProfileStoreOptions, "allowKeychainPrompt">,
options?: Pick<LoadAuthProfileStoreOptions, "allowKeychainPrompt" | "inheritedAuthDir">,
): 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<LoadAuthProfileStoreOptions, "allowKeychainPrompt">;
options?: Pick<LoadAuthProfileStoreOptions, "allowKeychainPrompt" | "inheritedAuthDir">;
}): 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<LoadAuthProfileStoreOptions, "allowKeychainPrompt">,
loadOptions?: Pick<LoadAuthProfileStoreOptions, "allowKeychainPrompt" | "inheritedAuthDir">,
): 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<string>;
externalCliProfileIds?: Iterable<string>;
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,
+35
View File
@@ -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", () => ({
+65 -32
View File
@@ -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<typeof discoverAuthStorage>;
modelRegistry: ReturnType<typeof discoverModels>;
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<typeof discoverAuthStorage>;
modelRegistry: ReturnType<typeof discoverModels>;
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<ReplyPayload | undefined> {
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<string, AgentHarness>();
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;
+34 -27
View File
@@ -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<DiscoveredModel[]>(() => {}),
contextTestState.loadModelCatalogOwnerSnapshot.mockImplementationOnce(
() => new Promise<never>(() => {}),
);
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();
+20 -14
View File
@@ -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<string, number>;
@@ -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) {
+27 -5
View File
@@ -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<string, unknown>;
expect(attempt).toMatchObject({
agentHarnessId: "codex",
@@ -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";
@@ -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<string, unknown>) => ({
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(
@@ -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",
});
});
+80 -34
View File
@@ -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<EmbeddedAgentCompactResult>({
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<EmbeddedAgentCompactResult> {
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}`;
@@ -1,15 +0,0 @@
import "./model-discovery-cache.js";
type ModelDiscoveryCacheTestApi = {
resetModelDiscoveryCacheForTest(): void;
};
function getTestApi(): ModelDiscoveryCacheTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.modelDiscoveryCacheTestApi")
] as ModelDiscoveryCacheTestApi;
}
export function resetModelDiscoveryCacheForTest(): void {
getTestApi().resetModelDiscoveryCacheForTest();
}
@@ -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<string, CacheEntry>();
/** 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<typeof fileFingerprint>]> {
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<DiscoverCachedAgentStoresOptions, "config" | "workspaceDir">,
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<PropertyKey, unknown>)[Symbol.for("openclaw.modelDiscoveryCacheTestApi")] =
{ resetModelDiscoveryCacheForTest };
}
@@ -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<typeof createSnapshot>[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);
});
@@ -114,9 +114,7 @@ type ManifestModelCatalogAliasPlugin = Pick<
| "modelCatalog"
>;
export type ManifestModelCatalogProviderTransport = Readonly<
Pick<ModelCatalogAlias, "api" | "baseUrl">
>;
type ManifestModelCatalogProviderTransport = Readonly<Pick<ModelCatalogAlias, "api" | "baseUrl">>;
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;
}
@@ -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,
@@ -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<typeof resolveManifestModelCatalogProviderAliasMetadata>[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",
}),
@@ -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<ProviderRuntimeModel | undefined> {
const resolveModel = createScopedBundledProviderStaticCatalogModelResolver(params);
+135 -20
View File
@@ -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<Record<string, unknown>>,
snapshots: new Map<string, unknown>(),
}));
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<typeof createSnapshot>[0]) => {
preparedSnapshotState.getInputs.push(input);
return preparedSnapshotState.enabled ? createSnapshot(input) : undefined;
},
loadPreparedModelRuntimeSnapshot: async (input: Parameters<typeof createSnapshot>[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 () => {
+80 -47
View File
@@ -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<typeof getPreparedModelRuntimeSnapshot> {
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 {
@@ -87,6 +87,7 @@ export async function runPreparedEmbeddedLoop(
markStartupStage: (stage) => startupStages.mark(stage),
notifyExecutionPhase,
fallbackConfigured,
preparedModelRuntime: input.preparedModelRuntime,
});
provider = preparedRuntime.provider;
modelId = preparedRuntime.modelId;
@@ -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<EmbeddedAgentRunResult> {
@@ -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);
@@ -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<string, unknown>) => ({
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,
@@ -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,
@@ -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,
@@ -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<typeof createEmbeddedRunLaneController>;
lifecycleGeneration: NonNullable<RunEmbeddedAgentParams["lifecycleGeneration"]>;
suspendForFailure: (params: Omit<SessionSuspensionParams, "laneId">) => void;
preparedModelRuntime?: PreparedModelRuntimeSnapshot;
};
@@ -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<typeof resolveHookModelSelection>[0]["hookRunner"];
hookContext: Parameters<typeof resolveHookModelSelection>[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,
@@ -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",
});
});
});
@@ -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,
};
}
@@ -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
@@ -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<Parameters<NonNullable<RunEmbeddedAgentParams["onExecutionPhase"]>>[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;
@@ -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;
@@ -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,
});
+15 -12
View File
@@ -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<ModelCatalogSnapshot>(() => {}));
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,
+2 -2
View File
@@ -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<T>(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<ModelCatalogSnapshot>;
@@ -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<typeof captureEnv>;
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);
});
});
-191
View File
@@ -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<OpenClawStateKyselyDatabase, "agent_model_catalogs">;
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<string, unknown>;
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<AgentModelCatalogDatabase>(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<AgentModelCatalogDatabase>(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.
}
}
+5 -5
View File
@@ -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";
File diff suppressed because it is too large Load Diff
+178 -630
View File
@@ -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<AuthStorageData>;
config: OpenClawConfig;
modelRegistry: ModelRegistry;
readOnly?: boolean;
metadataSnapshot?: PluginMetadataSnapshot;
metadataSnapshot: PluginMetadataSnapshot;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
};
let modelCatalogPromise: Promise<ModelCatalogSnapshot> | 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<OpenClawConfig, ManifestModelCatalogCacheEntry>();
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<AgentDiscoveryModule>) {
importAgentDiscovery = loader ?? defaultImportAgentDiscovery;
/** Canonicalizes a provider alias against the metadata captured with a prepared catalog. */
export function canonicalizePreparedModelCatalogProvider(
provider: string,
metadataSnapshot: Pick<PluginMetadataSnapshot, "manifestRegistry">,
): 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<string, unknown>,
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<string, Record<string, unknown>> {
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<string, Record<string, unknown>>)
: {};
}
async function loadReadOnlyPersistedProviderRows(
agentDir: string,
getPluginMetadataSnapshot: () => PluginModelCatalogMetadataSnapshot,
): Promise<Record<string, Record<string, unknown>>> {
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<ModelCatalogSnapshot> {
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<string, unknown>[]) {
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<ModelCatalogSnapshot> {
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<typeof ensureAuthProfileStoreWithoutExternalProfiles> | 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<ModelCatalogEntry[]> {
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. */
+2
View File
@@ -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
+37 -5
View File
@@ -15,6 +15,7 @@ import { publishProviderAuthWarmSnapshot } from "./model-provider-auth-state.js"
const modelCatalogMocks = vi.hoisted(() => ({
loadModelCatalog: vi.fn<(params?: unknown) => Promise<ModelCatalogEntry[]>>(),
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",
});
});
+18 -14
View File
@@ -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<ProviderAuthWarmSnapshot> {
const isWarmStale = () => options.isCancelled?.() === true;
const catalog = await loadModelCatalog({ config: cfg, readOnly: true });
if (isWarmStale()) {
return { agents: [] };
}
const providers = new Set<string>();
for (const entry of catalog) {
providers.add(normalizeProviderId(entry.provider));
}
const providerList = [...providers];
const configFingerprint = resolveProviderAuthConfigFingerprint(cfg) ?? "";
const states = new Map<string, PreparedProviderAuthState>();
// 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({
+22 -1
View File
@@ -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();
+11 -35
View File
@@ -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);
}
+2 -2
View File
@@ -111,7 +111,7 @@ export async function resolveThinkingDefaultWithRuntimeCatalog(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
loadModelCatalog: () => Promise<ModelCatalogEntry[]>;
loadRuntimeCatalog: () => Promise<ModelCatalogEntry[]>;
agentRuntime?: string | null;
}): Promise<ThinkLevel> {
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,
);
@@ -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<AuthStorageData>,
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,
+13 -7
View File
@@ -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<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
workspaceDir?: string;
providerDiscoveryProviderIds?: readonly string[];
@@ -82,20 +84,21 @@ async function buildModelsJsonFingerprint(params: {
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<string> {
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<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
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<T>(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<ModelsJsonReadyState> = 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 }
: {}),
@@ -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;
};
@@ -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(
+7 -13
View File
@@ -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> | 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,
+192
View File
@@ -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<string, unknown>) => ({
snapshot: await mocks.acquireSnapshot(input),
release: mocks.releaseSnapshot,
}),
activateStandalonePreparedModelRuntime: (...args: unknown[]) => mocks.activateSnapshot(...args),
acquireReadOnlyPreparedModelRuntime: async (input: Record<string, unknown>) => ({
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();
});
});
+213
View File
@@ -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<PreparedModelRuntimeSnapshot> {
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<ModelCatalogSnapshot> {
return (await loadPreparedModelCatalogOwnerSnapshot(params)).modelCatalog;
}
export async function loadPreparedModelCatalog(
params: LoadPreparedModelCatalogParams = {},
): Promise<ModelCatalogEntry[]> {
return (await loadPreparedModelCatalogSnapshot(params)).entries;
}
+204
View File
@@ -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<string, unknown>) => ({
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();
});
});
+165
View File
@@ -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<string, Model | undefined>();
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<PreparedModelRuntimeLease> {
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();
}
}
@@ -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<LoadStaticCatalog>(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<LoadStaticCatalog>) =>
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<PropertyKey, unknown>)[
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);
});
});
+470
View File
@@ -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<PreparedModelRuntimeSnapshot>;
buildCompletion?: Promise<void>;
leaseCount?: number;
};
export type PreparedModelRuntimeReplacement = {
gateId: PreparedModelRuntimeReplacementGateId;
promise: Promise<void>;
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<string, PreparedModelRuntimeOwner>,
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<ModelCatalogEntry["api"]> {
return value !== undefined && (MODEL_APIS as readonly string[]).includes(value);
}
function toStaticCatalogEntry(
model: Awaited<ReturnType<typeof loadBundledProviderStaticCatalogContextModels>>[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<string, PreparedModelRuntimeOwner>,
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<void>((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<PreparedModelRuntimeSnapshot> {
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<string, Promise<void>>,
buildTimeoutMs: number,
): {
pending: Promise<PreparedModelRuntimeSnapshot>;
completion: Promise<void>;
} {
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<string, PreparedModelRuntimeOwner>,
agentBuildCompletions: Map<string, Promise<void>>,
buildTimeoutMs: number,
existing?: PreparedModelRuntimeOwner,
provenance: PreparedModelRuntimeOwner["provenance"] = "explicit",
): Promise<PreparedModelRuntimeSnapshot> {
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;
}
@@ -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<PropertyKey, unknown>)[
Symbol.for("openclaw.preparedModelRuntimeTestApi")
] as PreparedModelRuntimeTestApi | undefined;
api?.resetPreparedModelRuntimeSnapshotsForTest();
}
+710
View File
@@ -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<LoadStaticCatalog>(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<LoadStaticCatalog>) =>
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<PropertyKey, unknown>)[
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();
});
});
+710
View File
@@ -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<string, PreparedModelRuntimeOwner>();
const agentBuildCompletions = new Map<string, Promise<void>>();
const standaloneActivationTails = new Map<string, Promise<void>>();
let gatewayLifecycleActive = false;
let refreshTail: Promise<void> = 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<PreparedModelRuntimeSnapshot> {
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<PreparedModelRuntimeSnapshot> {
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<PreparedModelRuntimeSnapshot | undefined> {
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<PreparedModelRuntimeSnapshot | undefined> {
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<PreparedModelRuntimeLease> {
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<PreparedModelRuntimeLease> {
return await acquirePreparedModelRuntimeLease(rawInput, "run");
}
/** Acquires a one-read metadata generation without retaining a dynamic workspace owner. */
export async function acquireReadOnlyPreparedModelRuntime(
rawInput: PreparedModelRuntimeInput,
): Promise<PreparedModelRuntimeLease> {
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<PreparedModelRuntimeSnapshot> {
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<void> {
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<string>();
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<void> {
// 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<void>): Promise<void> {
const publication = refreshTail.then(task);
refreshTail = publication.then(
() => undefined,
() => undefined,
);
return publication;
}
async function drainPendingAuthMutations(): Promise<void> {
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<PropertyKey, unknown>)[Symbol.for("openclaw.preparedModelRuntimeTestApi")] =
{
resetPreparedModelRuntimeSnapshotsForTest,
setModelRuntimeBuildTimeoutMsForTest: (timeoutMs: number) => {
modelRuntimeBuildTimeoutMs = timeoutMs;
},
};
}
+113
View File
@@ -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" },
+73 -1
View File
@@ -264,9 +264,19 @@ function emptyCustomModelsResult(error?: string): CustomModelsResult {
type ModelRegistryOptions = {
pluginMetadataSnapshot?: PluginModelCatalogMetadataSnapshot;
sourceSnapshot?: ModelRegistry;
workspaceDir?: string;
};
type ModelRegistryCatalogSnapshot = {
models: Model[];
providerRequestConfigs: Map<string, ProviderRequestConfig>;
modelRequestHeaders: Map<string, Record<string, string>>;
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);
+21 -23
View File
@@ -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<AllowedMissingApiKeyMode>;
allowBundledStaticCatalogFallback?: boolean;
/** @deprecated Model resolution is lifecycle-backed and always asynchronous. */
useAsyncModelResolution?: boolean;
skipAgentDiscovery?: boolean;
bindAuthOwner?: boolean;
modelResolver?: typeof resolveModelAsync;
}): Promise<PreparedSimpleCompletionModel> {
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<AllowedMissingApiKeyMode>;
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,
+1 -1
View File
@@ -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";
+3 -2
View File
@@ -133,7 +133,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
hasInProcessGatewayContextMock?: MockFn;
getRuntimeConfig?: () => Record<string, unknown>;
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 ?? (() => ({})),
+1 -1
View File
@@ -11,7 +11,7 @@ type SpawnDeps = Omit<
| "getGlobalHookRunner"
| "getRuntimeConfig"
| "hasInProcessGatewayContext"
| "loadModelCatalog"
| "loadPreparedModelCatalog"
| "resolveContextEngine"
>,
"getGlobalHookRunner"
+9 -4
View File
@@ -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();
});
+5 -5
View File
@@ -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<ReturnType<typeof loadModelCatalog>>;
let catalog: Awaited<ReturnType<typeof loadPreparedModelCatalog>>;
try {
catalog = await subagentSpawnDeps.loadModelCatalog({
catalog = await subagentSpawnDeps.loadPreparedModelCatalog({
config: params.cfg,
agentDir: params.targetAgentDir,
workspaceDir: params.workspaceDir,
@@ -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),
+3
View File
@@ -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,

Some files were not shown because too many files have changed in this diff Show More