fix(gateway): isolate full model catalog discovery

This commit is contained in:
joshavant
2026-08-12 03:22:41 -05:00
parent ce3d1d22be
commit d5dd5fe0f7
38 changed files with 1040 additions and 312 deletions
+1
View File
@@ -157,6 +157,7 @@ const rootEntries = [
"src/config/sessions/session-accessor.sqlite-archive.worker.ts!",
"src/state/openclaw-database-verify.worker.ts!",
"src/agents/model-provider-auth.worker.ts!",
"src/agents/prepared-model-catalog.worker.ts!",
// Loaded by URL from setup-inference-detection.ts; no static import edge exists.
"src/system-agent/setup-inference-detection.worker.ts!",
// Split runtime loaded through a path assembled in subagent-registry.ts.
+1
View File
@@ -112,6 +112,7 @@ const requiredPathGroups = [
"scripts/postinstall-bundled-plugins.mjs",
"dist/agents/compaction-planning.worker.js",
"dist/agents/model-provider-auth.worker.js",
"dist/agents/prepared-model-catalog.worker.js",
"dist/audit/audit-event-writer.worker.js",
"dist/config/sessions/session-accessor.sqlite-archive.worker.js",
"dist/config/sessions/session-transcript-reconcile.worker.js",
+13 -4
View File
@@ -15,6 +15,7 @@ import {
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
} from "./auth-profiles/store.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
/** Options for discovering credentials without prompting for secret material. */
export type DiscoverAuthStorageOptions = {
@@ -83,11 +84,11 @@ export function resolveAmbientAgentCredentialsForDiscovery(
return credentials;
}
/** Resolves agent credentials from auth profiles, env, and synthetic auth hooks. */
export function resolveAgentCredentialsForDiscovery(
/** Resolves the effective auth store and provider credentials for one discovery generation. */
export function resolveAgentDiscoveryAuthFacts(
agentDir: string,
options?: DiscoverAuthStorageOptions,
): AgentCredentialMap {
): { store: AuthProfileStore; credentials: AgentCredentialMap } {
const storeOptions = {
allowKeychainPrompt: false,
...(options?.config ? { config: options.config } : {}),
@@ -124,5 +125,13 @@ export function resolveAgentCredentialsForDiscovery(
// Ambient auth is a lifecycle-owned fallback. Agent-local profiles remain authoritative.
credentials[provider] = credential;
}
return credentials;
return { store, credentials };
}
/** Resolves agent credentials from auth profiles, env, and synthetic auth hooks. */
export function resolveAgentCredentialsForDiscovery(
agentDir: string,
options?: DiscoverAuthStorageOptions,
): AgentCredentialMap {
return resolveAgentDiscoveryAuthFacts(agentDir, options).credentials;
}
+18 -4
View File
@@ -11,7 +11,7 @@ import {
} from "../plugins/provider-runtime.js";
import { isRecord } from "../utils.js";
import {
resolveAgentCredentialsForDiscovery,
resolveAgentDiscoveryAuthFacts,
type DiscoverAuthStorageOptions,
} from "./agent-auth-discovery.js";
import { resolveModelPluginMetadataSnapshot } from "./model-discovery-context.js";
@@ -196,9 +196,23 @@ export function discoverAuthStorage(
agentDir: string,
options?: DiscoverAuthStorageOptions,
): AgentAuthStorage {
const credentials =
options?.skipCredentials === true ? {} : resolveAgentCredentialsForDiscovery(agentDir, options);
return AuthStorage.inMemory(credentials);
return discoverAuthStorageFacts(agentDir, options).authStorage;
}
/** Captures the effective profile store and its AuthStorage projection as one generation. */
export function discoverAuthStorageFacts(
agentDir: string,
options?: DiscoverAuthStorageOptions,
): {
authStorage: AgentAuthStorage;
store: import("./auth-profiles/types.js").AuthProfileStore;
credentials: import("./agent-auth-credentials.js").AgentCredentialMap;
} {
const facts =
options?.skipCredentials === true
? { store: { version: 1, profiles: {} }, credentials: {} }
: resolveAgentDiscoveryAuthFacts(agentDir, options);
return { ...facts, authStorage: AuthStorage.inMemory(facts.credentials) };
}
/** Creates the model registry used by agent model discovery. */
+6
View File
@@ -8,6 +8,7 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot
import type { ProviderCatalogOutcome } from "../plugins/provider-catalog.types.js";
import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js";
import { isRecord } from "../utils.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import {
mergeProviders,
mergeWithExistingProviderSecrets,
@@ -32,6 +33,7 @@ type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
/** Dependency hook for resolving implicit model providers while planning models.json. */
type ResolveImplicitProvidersForModelsJson = (params: {
agentDir: string;
authStore?: AuthProfileStore;
config: OpenClawConfig;
discoveryAuthConfig?: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -102,6 +104,7 @@ function buildPluginCatalogWrites(
async function resolveProvidersForModelsJsonWithDeps(
params: {
cfg: OpenClawConfig;
authStore?: AuthProfileStore;
discoveryAuthConfig?: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
@@ -131,6 +134,7 @@ async function resolveProvidersForModelsJsonWithDeps(
const resolveImplicitProvidersImpl = deps?.resolveImplicitProviders ?? resolveImplicitProviders;
const implicitProviders = await resolveImplicitProvidersImpl({
agentDir,
...(params.authStore ? { authStore: params.authStore } : {}),
config: cfg,
...(params.discoveryAuthConfig ? { discoveryAuthConfig: params.discoveryAuthConfig } : {}),
env,
@@ -220,6 +224,7 @@ function filterWritableProviders(
async function planOpenClawModelsJsonWithDeps(
params: {
cfg: OpenClawConfig;
authStore?: AuthProfileStore;
discoveryAuthConfig?: OpenClawConfig;
sourceConfigForSecrets?: OpenClawConfig;
agentDir: string;
@@ -245,6 +250,7 @@ async function planOpenClawModelsJsonWithDeps(
const providers = await resolveProvidersForModelsJsonWithDeps(
{
cfg,
...(params.authStore ? { authStore: params.authStore } : {}),
...(params.discoveryAuthConfig ? { discoveryAuthConfig: params.discoveryAuthConfig } : {}),
agentDir,
env,
+5 -1
View File
@@ -30,6 +30,7 @@ import {
resolveDefaultAgentId,
} from "./agent-scope.js";
import { resolveAuthProfileDatabasePath } from "./auth-profiles/sqlite.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import {
MODELS_JSON_STATE,
type ModelsJsonReadyResult,
@@ -68,7 +69,9 @@ type EnsureOpenClawModelsJsonOptions = {
onProviderCatalogOutcome?: (outcome: ProviderCatalogOutcome) => void;
};
type PlanOpenClawModelsJsonSourceOptions = EnsureOpenClawModelsJsonOptions;
type PlanOpenClawModelsJsonSourceOptions = EnsureOpenClawModelsJsonOptions & {
authStore?: AuthProfileStore;
};
type PlannedOpenClawModelsJsonSource = Readonly<{
agentDir: string;
@@ -536,6 +539,7 @@ export async function planOpenClawModelsJsonSource(
const env = createConfigRuntimeEnv(cfg, options.env);
const plan = await planOpenClawModelsJson({
cfg,
...(options.authStore ? { authStore: options.authStore } : {}),
discoveryAuthConfig: resolved.discoveryAuthConfig,
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
agentDir,
@@ -42,6 +42,9 @@ export function resolvePublishedModelCatalogOwner(
agentDir: snapshot.agentDir,
workspaceDir,
config: snapshot.config,
authModes: snapshot.authModes,
authStore: snapshot.authStore,
metadataSnapshot: snapshot.metadataSnapshot,
modelCatalog: snapshot.modelCatalog,
});
}
@@ -0,0 +1,192 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
} from "./auth-profiles/runtime-snapshots.js";
import {
encodePluginModelCatalogRelativePath,
PLUGIN_MODEL_CATALOG_GENERATED_BY,
replacePersistedPluginModelCatalogs,
} from "./plugin-model-catalog.js";
import { startSerializedSnapshotBuild } from "./prepared-model-runtime.build.js";
const PROVIDER_ID = "worker-catalog-fixture";
const SHARED_AUTH_PROVIDER_ID = `${PROVIDER_ID}-shared-auth`;
const PLUGIN_ID = "worker-catalog-fixture";
const PROFILE_ID = `${SHARED_AUTH_PROVIDER_ID}:named`;
const MATERIALIZED_SECRET = "materialized-worker-secret-not-real";
const UNRELATED_SECRET = "unrelated-worker-secret-not-real";
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
closeOpenClawAgentDatabasesForTest();
cleanup();
});
});
function writeFixturePlugin(params: { root: string; spinMs: number }): string {
const pluginDir = path.join(params.root, "plugin");
fs.mkdirSync(pluginDir, { recursive: true });
const pluginFile = path.join(pluginDir, "index.cjs");
fs.writeFileSync(
pluginFile,
`const fs = require("node:fs");
module.exports = {
id: ${JSON.stringify(PLUGIN_ID)},
register(api) {
api.registerProvider({
id: ${JSON.stringify(PROVIDER_ID)},
label: "Worker catalog fixture",
auth: [],
augmentModelCatalog(context) {
fs.appendFileSync(process.env.OPENCLAW_WORKER_CATALOG_MARKER, "start\\n");
const until = Date.now() + ${params.spinMs};
while (Date.now() < until) {}
const hasSqlite = context.entries.some((entry) =>
entry.provider === ${JSON.stringify(PROVIDER_ID)} && entry.id === "sqlite-model");
const hasShared = context.resolveProviderApiKey(${JSON.stringify(SHARED_AUTH_PROVIDER_ID)}).apiKey === ${JSON.stringify(MATERIALIZED_SECRET)};
const hasUnrelated = context.resolveProviderApiKey("unrelated-provider").apiKey === ${JSON.stringify(UNRELATED_SECRET)};
fs.appendFileSync(process.env.OPENCLAW_WORKER_CATALOG_MARKER, "done\\n");
return [{
provider: ${JSON.stringify(PROVIDER_ID)},
id: \`proof-sqlite-\${hasSqlite}-shared-\${hasShared}-unrelated-\${hasUnrelated}\`,
name: "Worker boundary proof",
}];
},
});
},
};
`,
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: PLUGIN_ID,
providers: [PROVIDER_ID],
configSchema: { type: "object", additionalProperties: false, properties: {} },
modelCatalog: { discovery: { [PROVIDER_ID]: "runtime" }, runtimeAugment: true },
}),
"utf8",
);
return pluginFile;
}
async function createStaticSnapshot(spinMs: number) {
const root = tempDirs.make("openclaw-model-catalog-worker-");
const stateDir = path.join(root, "state");
const agentDir = path.join(stateDir, "agents", "main", "agent");
const workspaceDir = path.join(root, "workspace");
const marker = path.join(root, "worker-marker.txt");
fs.mkdirSync(agentDir, { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
const pluginFile = writeFixturePlugin({ root, spinMs });
const env = {
...process.env,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_WORKER_CATALOG_MARKER: marker,
};
const config = {
agents: { defaults: { model: `${PROVIDER_ID}/sqlite-model` } },
plugins: {
allow: [PLUGIN_ID],
load: { paths: [pluginFile] },
entries: { [PLUGIN_ID]: { enabled: true } },
},
} satisfies OpenClawConfig;
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
version: 1,
profiles: {
[PROFILE_ID]: {
type: "token",
provider: SHARED_AUTH_PROVIDER_ID,
token: MATERIALIZED_SECRET,
tokenRef: { source: "env", provider: "default", id: "SHARED_SECRET_REF" },
},
"unrelated-provider:default": {
type: "api_key",
provider: "unrelated-provider",
key: UNRELATED_SECRET,
keyRef: { source: "env", provider: "default", id: "UNRELATED_SECRET_REF" },
},
},
order: { [SHARED_AUTH_PROVIDER_ID]: [PROFILE_ID] },
},
},
]);
replacePersistedPluginModelCatalogs({
agentDir,
pluginCatalogWrites: {
[encodePluginModelCatalogRelativePath(PLUGIN_ID)]: JSON.stringify({
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
providers: {
[PROVIDER_ID]: {
baseUrl: "https://worker-catalog.invalid/v1",
api: "openai-completions",
apiKey: "WORKER_CATALOG_API_KEY",
models: [{ id: "sqlite-model", name: "SQLite model" }],
},
},
}),
},
});
let current = true;
const build = await startSerializedSnapshotBuild(
{ agentId: "main", agentDir, inheritedAuthDir: agentDir, workspaceDir, config, env },
new Map(),
30_000,
"static",
() => current,
).pending;
return { marker, snapshot: build.snapshot, supersede: () => (current = false) };
}
async function waitForMarker(marker: string): Promise<void> {
await expect.poll(() => fs.existsSync(marker), { timeout: 30_000 }).toBe(true);
}
describe("prepared model catalog worker boundary", () => {
it("keeps the event loop responsive and preserves complete prepared auth and SQLite facts", async () => {
const fixture = await createStaticSnapshot(750);
let settled = false;
const first = fixture.snapshot.loadFullModelCatalog?.().finally(() => {
settled = true;
});
const second = fixture.snapshot.loadFullModelCatalog?.();
await waitForMarker(fixture.marker);
expect(settled).toBe(false);
const [catalog, sharedCatalog] = await Promise.all([first, second]);
expect(sharedCatalog).toBe(catalog);
expect(catalog?.entries).toContainEqual(
expect.objectContaining({
provider: PROVIDER_ID,
id: "proof-sqlite-true-shared-true-unrelated-true",
}),
);
await expect(fixture.snapshot.loadFullModelCatalog?.()).resolves.toBe(catalog);
expect(fs.readFileSync(fixture.marker, "utf8")).toBe("start\ndone\n");
});
it("terminates discovery when its owning generation is superseded", async () => {
const fixture = await createStaticSnapshot(10_000);
const catalog = fixture.snapshot.loadFullModelCatalog?.();
await waitForMarker(fixture.marker);
fixture.supersede();
await expect(catalog).rejects.toThrow("superseded");
await new Promise<void>((resolve) => {
setTimeout(resolve, 100);
});
expect(fs.readFileSync(fixture.marker, "utf8")).toBe("start\n");
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { createPreparedModelCatalogWorkerInput } from "./prepared-model-catalog-worker.js";
import type { PreparedModelRuntimeAgentFacts } from "./prepared-model-runtime.facts.js";
vi.mock("../plugins/manifest-registry-installed.js", () => ({
resolveInstalledManifestRegistryIndexFingerprint: () => "test-plugin-index",
}));
describe("prepared model catalog worker input", () => {
it("preserves the complete materialized auth generation without transferring SecretRefs", () => {
const authStore = {
version: 1,
profiles: {
"shared:named": {
type: "oauth" as const,
provider: "shared",
access: "access-token",
refresh: "refresh-token",
expires: 4_102_444_800_000,
projectId: "project-id",
},
"unrelated:default": {
type: "api_key" as const,
provider: "unrelated",
key: "materialized-key",
keyRef: { source: "env" as const, provider: "default", id: "UNRELATED_KEY" },
},
},
order: { shared: ["shared:named"] },
lastGood: { shared: "shared:named" },
};
const workerInput = createPreparedModelCatalogWorkerInput({
agentFacts: {
input: { agentDir: "/tmp/agent", config: {}, workspaceDir: "/tmp/workspace" },
env: {},
authStore,
credentials: { shared: { type: "oauth", ...authStore.profiles["shared:named"] } },
providerIds: ["configured"],
configuredModelRefs: [],
configuredRuntimeModels: [],
configuredGeneratedCatalogPluginIds: [],
templateAuthStorage: {} as never,
} satisfies PreparedModelRuntimeAgentFacts,
pluginMetadataSnapshot: {
policyHash: "test-policy",
configFingerprint: "test-config",
index: {} as never,
plugins: [],
} as unknown as PluginMetadataSnapshot,
});
const cloned = structuredClone(workerInput);
expect(cloned.authStore.profiles).toEqual({
"shared:named": authStore.profiles["shared:named"],
"unrelated:default": {
type: "api_key",
provider: "unrelated",
key: "materialized-key",
},
});
expect(cloned.authStore.order).toEqual(authStore.order);
expect(cloned.authStore.lastGood).toEqual(authStore.lastGood);
});
});
+232
View File
@@ -0,0 +1,232 @@
/** Runs complete model-catalog discovery outside the Gateway event loop. */
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import { resolveInstalledManifestRegistryIndexFingerprint } from "../plugins/manifest-registry-installed.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js";
import {
fingerprintPreparedRuntimeFacts,
markPreparedModelCatalogFull,
type PreparedModelRuntimeAgentFacts,
} from "./prepared-model-runtime.facts.js";
import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js";
import type { AuthStorageData } from "./sessions/auth-storage.js";
export type PreparedModelCatalogWorkerInput = Readonly<{
generationFingerprint: string;
input: PreparedModelRuntimeInput;
authStore: AuthProfileStore;
credentials: Readonly<AuthStorageData>;
providerIds: readonly string[];
}>;
export type PreparedModelCatalogWorkerResult =
| Readonly<{
status: "ok";
generationFingerprint: string;
snapshot: ModelCatalogSnapshot;
}>
| Readonly<{ status: "failed"; error: string }>;
// Cold source/plugin loading can take well over a minute. Three minutes preserves exact full-view
// discovery while bounding a wedged provider; expiry rejects and never returns partial results.
const PREPARED_MODEL_CATALOG_WORKER_TIMEOUT_MS = 180_000;
const PREPARED_MODEL_CATALOG_WORKER_GENERATION_POLL_MS = 25;
function fingerprintPreparedModelCatalogPlugins(snapshot: PluginMetadataSnapshot): string {
return fingerprintPreparedRuntimeFacts({
config: snapshot.configFingerprint ?? null,
index: resolveInstalledManifestRegistryIndexFingerprint(snapshot.index),
pluginIds: snapshot.pluginIds ?? null,
policy: snapshot.policyHash,
workspaceDir: snapshot.workspaceDir ?? null,
});
}
export function fingerprintPreparedModelCatalogGeneration(params: {
input: PreparedModelRuntimeInput;
authStore: AuthProfileStore;
credentials: Readonly<AuthStorageData>;
providerIds: readonly string[];
pluginMetadataSnapshot: PluginMetadataSnapshot;
}): string {
return fingerprintPreparedRuntimeFacts({
input: params.input,
authStore: params.authStore,
credentials: params.credentials,
providerIds: params.providerIds,
pluginFingerprint: fingerprintPreparedModelCatalogPlugins(params.pluginMetadataSnapshot),
});
}
function projectWorkerAuthStore(store: AuthProfileStore): AuthProfileStore {
return {
...store,
profiles: Object.fromEntries(
Object.entries(store.profiles).map(([profileId, credential]) => {
// SecretRefs have already been materialized by the lifecycle owner. Do not transfer the
// original reference descriptors to a worker that only needs the resolved credential.
const projected = { ...credential } as AuthProfileCredential & Record<string, unknown>;
delete projected.keyRef;
delete projected.tokenRef;
return [profileId, projected];
}),
),
};
}
export function createPreparedModelCatalogWorkerInput(params: {
agentFacts: PreparedModelRuntimeAgentFacts;
pluginMetadataSnapshot: PluginMetadataSnapshot;
}): PreparedModelCatalogWorkerInput {
const source = params.agentFacts.input;
// Registries and closures stay process-local. The worker reconstructs them from this exact
// lifecycle plan and receives only already-materialized auth facts.
const input: PreparedModelRuntimeInput = {
...(source.agentId ? { agentId: source.agentId } : {}),
agentDir: source.agentDir,
inheritedAuthDir: source.agentDir,
...(source.workspaceDir ? { workspaceDir: source.workspaceDir } : {}),
...(source.readOnly ? { readOnly: true } : {}),
skipCredentials: true,
env: { ...params.agentFacts.env },
...(source.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}),
...(source.runtimePluginSelections
? { runtimePluginSelections: source.runtimePluginSelections }
: {}),
config: source.config,
};
const authStore = projectWorkerAuthStore(params.agentFacts.authStore);
const credentials = { ...params.agentFacts.credentials };
const providerIds = [...params.agentFacts.providerIds];
return {
generationFingerprint: fingerprintPreparedModelCatalogGeneration({
input,
authStore,
credentials,
providerIds,
pluginMetadataSnapshot: params.pluginMetadataSnapshot,
}),
input,
authStore,
credentials,
providerIds,
};
}
function resolvePreparedModelCatalogWorkerUrl(currentModuleUrl = import.meta.url): URL {
const currentPath = fileURLToPath(currentModuleUrl);
const normalized = currentPath.replaceAll(path.sep, "/");
const distMarker = "/dist/";
const distIndex = normalized.lastIndexOf(distMarker);
if (distIndex >= 0) {
const distRoot = currentPath.slice(0, distIndex + distMarker.length);
return pathToFileURL(path.join(distRoot, "agents", "prepared-model-catalog.worker.js"));
}
const extension = path.extname(currentPath) || ".js";
return new URL(`./prepared-model-catalog.worker${extension}`, currentModuleUrl);
}
export function runPreparedModelCatalogWorker(params: {
input: PreparedModelCatalogWorkerInput;
isCurrent: () => boolean;
}): Promise<ModelCatalogSnapshot> {
const superseded = () =>
new PreparedModelRuntimePublicationSupersededError(
`prepared model runtime catalog generation was superseded for ${params.input.input.agentDir}`,
);
if (!params.isCurrent()) {
return Promise.reject(superseded());
}
const workerUrl = resolvePreparedModelCatalogWorkerUrl();
let worker: Worker;
try {
worker = new Worker(workerUrl, {
workerData: params.input,
...(workerUrl.pathname.endsWith(".ts") ? { execArgv: ["--import", "tsx"] } : {}),
// Establish state/config environment before worker module initialization reads process.env.
env: { ...process.env, ...params.input.input.env },
});
} catch (error) {
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
}
worker.unref();
return new Promise<ModelCatalogSnapshot>((resolve, reject) => {
let settled = false;
type Outcome =
| { status: "resolved"; snapshot: ModelCatalogSnapshot }
| { status: "rejected"; error: Error };
const settle = (outcome: Outcome, terminate = true) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
clearInterval(generationPoll);
worker.removeAllListeners();
const finish = () => {
if (outcome.status === "resolved") {
resolve(markPreparedModelCatalogFull(outcome.snapshot));
} else {
reject(outcome.error);
}
};
if (!terminate) {
finish();
return;
}
void worker.terminate().then(finish, (terminationError: unknown) => {
const error =
terminationError instanceof Error
? terminationError
: new Error(String(terminationError));
reject(
outcome.status === "rejected"
? new AggregateError([outcome.error, error], outcome.error.message)
: new Error("prepared model catalog worker termination failed", { cause: error }),
);
});
};
const fail = (error: Error, terminate = true) =>
settle({ status: "rejected", error }, terminate);
const timeout = setTimeout(
() => fail(new Error("prepared model catalog worker timed out")),
PREPARED_MODEL_CATALOG_WORKER_TIMEOUT_MS,
);
timeout.unref();
const generationPoll = setInterval(() => {
if (!params.isCurrent()) {
fail(superseded());
}
}, PREPARED_MODEL_CATALOG_WORKER_GENERATION_POLL_MS);
generationPoll.unref();
worker.once("message", (message: PreparedModelCatalogWorkerResult) => {
if (!params.isCurrent()) {
fail(superseded());
} else if (message.status === "failed") {
fail(new Error(message.error));
} else if (message.generationFingerprint !== params.input.generationFingerprint) {
fail(new Error("prepared model catalog worker returned a stale generation"));
} else {
settle({ status: "resolved", snapshot: message.snapshot });
}
});
worker.once("error", (error) =>
fail(error instanceof Error ? error : new Error(String(error))),
);
worker.once("exit", (code) =>
fail(
new Error(
`prepared model catalog worker exited with code ${code} before returning a result`,
),
false,
),
);
});
}
@@ -1,4 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { PreparedAgentCredentialModes } from "./agent-auth-credentials.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
export type PublishedModelCatalogOwnerCandidate = Readonly<{
@@ -6,6 +9,9 @@ export type PublishedModelCatalogOwnerCandidate = Readonly<{
agentDir: string;
workspaceDir?: string;
config: OpenClawConfig;
authModes: PreparedAgentCredentialModes;
authStore: AuthProfileStore;
metadataSnapshot: PluginMetadataSnapshot;
modelCatalog: ModelCatalogSnapshot;
}>;
@@ -14,5 +20,8 @@ export type ResolvedPublishedModelCatalogOwner = Readonly<{
agentDir: string;
workspaceDir: string;
config: OpenClawConfig;
authModes: PreparedAgentCredentialModes;
authStore: AuthProfileStore;
metadataSnapshot: PluginMetadataSnapshot;
modelCatalog: ModelCatalogSnapshot;
}>;
@@ -0,0 +1,72 @@
/** Worker-thread entrypoint for complete model-catalog discovery. */
import { parentPort, workerData } from "node:worker_threads";
import { replaceRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js";
import {
fingerprintPreparedModelCatalogGeneration,
type PreparedModelCatalogWorkerInput,
type PreparedModelCatalogWorkerResult,
} from "./prepared-model-catalog-worker.js";
import {
prepareAgentCatalogSource,
prepareFullCatalogFacts,
prepareWorkspaceBuildGroup,
} from "./prepared-model-runtime.facts.js";
import { AuthStorage } from "./sessions/auth-storage.js";
export async function runPreparedModelCatalogWorkerInput(
value: PreparedModelCatalogWorkerInput,
): Promise<PreparedModelCatalogWorkerResult> {
try {
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir: value.input.agentDir, store: value.authStore },
]);
const prepared = await prepareWorkspaceBuildGroup([value.input], "live");
const agentFacts = prepared.agentFacts[0];
if (!agentFacts) {
throw new Error("prepared model catalog worker produced no agent facts");
}
const exactAgentFacts = {
...agentFacts,
authStore: value.authStore,
templateAuthStorage: AuthStorage.inMemory({ ...value.credentials }),
credentials: value.credentials,
providerIds: [...value.providerIds],
};
const reconstructedFingerprint = fingerprintPreparedModelCatalogGeneration({
input: value.input,
authStore: value.authStore,
credentials: value.credentials,
providerIds: value.providerIds,
pluginMetadataSnapshot: prepared.pluginGeneration.pluginMetadataSnapshot,
});
if (reconstructedFingerprint !== value.generationFingerprint) {
throw new Error("prepared model catalog worker reconstructed a different runtime generation");
}
const source = await prepareAgentCatalogSource(
exactAgentFacts,
prepared.pluginGeneration,
"live",
false,
{ authStore: value.authStore },
);
const facts = await prepareFullCatalogFacts(
exactAgentFacts,
prepared.pluginGeneration,
"live",
source,
);
return {
status: "ok",
generationFingerprint: value.generationFingerprint,
snapshot: facts.modelCatalog,
};
} catch (error) {
return { status: "failed", error: error instanceof Error ? error.message : String(error) };
}
}
if (parentPort) {
const send: (message: PreparedModelCatalogWorkerResult) => void =
parentPort.postMessage.bind(parentPort);
send(await runPreparedModelCatalogWorkerInput(workerData as PreparedModelCatalogWorkerInput));
}
+13 -17
View File
@@ -6,6 +6,10 @@ import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { resolveUsableAgentCredentialModes } from "./agent-auth-credentials.js";
import { getPreparedRuntimeAuthMaterializations } from "./auth-profiles/runtime-materializations.js";
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
import {
createPreparedModelCatalogWorkerInput,
runPreparedModelCatalogWorker,
} from "./prepared-model-catalog-worker.js";
import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js";
import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js";
import {
@@ -131,24 +135,15 @@ function createFullModelCatalogAccess(params: {
// Full inventory belongs to explicit control-plane reads. The generation queue
// prevents a stale plan from overlapping or following a replacement build.
assertCurrent();
// Agent facts remain bound to the published turn generation. Auth mutations advance
// that owner generation, while plugin facts remain bound to this exact generation.
const fullCatalogMode: PreparedModelRuntimeCatalogMode = "live";
const catalogSource = await prepareAgentCatalogSource(
params.agentFacts,
params.pluginGeneration,
fullCatalogMode,
false,
);
const catalog = await runPreparedModelCatalogWorker({
input: createPreparedModelCatalogWorkerInput({
agentFacts: params.agentFacts,
pluginMetadataSnapshot: params.pluginGeneration.pluginMetadataSnapshot,
}),
isCurrent: params.isCurrent,
});
assertCurrent();
const facts = await prepareFullCatalogFacts(
params.agentFacts,
params.pluginGeneration,
fullCatalogMode,
catalogSource,
);
assertCurrent();
fullCatalog = facts.modelCatalog;
fullCatalog = catalog;
return fullCatalog;
}),
}).finally(() => {
@@ -184,6 +179,7 @@ function createSnapshot(
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
config: input.config,
authStore: agentFacts.authStore,
authModes: resolveUsableAgentCredentialModes(credentials),
metadataSnapshot: pluginMetadataSnapshot,
allowGatewaySubagentBinding: input.allowGatewaySubagentBinding === true,
+19 -5
View File
@@ -21,10 +21,11 @@ import { resolveRuntimeSyntheticAuthProviderRefs } from "../plugins/synthetic-au
import type { AgentCredentialMap } from "./agent-auth-credentials.js";
import { resolveAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js";
import {
discoverAuthStorage,
discoverAuthStorageFacts,
discoverModels,
discoverModelsFromCapturedSources,
} from "./agent-model-discovery.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import {
buildInlineProviderModels,
type InlineModelEntry,
@@ -73,7 +74,7 @@ import type {
PreparedModelRuntimeInput,
PreparedModelRuntimePluginGeneration,
} from "./prepared-model-runtime.types.js";
import type { AuthStorage, AuthStorageData } from "./sessions/auth-storage.js";
import { AuthStorage, type AuthStorageData } from "./sessions/auth-storage.js";
import type { ModelRegistry } from "./sessions/model-registry.js";
const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000;
@@ -82,6 +83,7 @@ const fullModelCatalogSnapshots = new WeakSet<ModelCatalogSnapshot>();
type PreparedModelRuntimeAgentBaseFacts = {
input: PreparedModelRuntimeInput;
env: NodeJS.ProcessEnv;
authStore: AuthProfileStore;
templateAuthStorage: AuthStorage;
credentials: Readonly<AuthStorageData>;
providerIds: string[];
@@ -120,7 +122,7 @@ function prepareAgentFacts(
additionalProviderIds: readonly string[] = [],
): PreparedModelRuntimeAgentBaseFacts {
const env = input.env ?? process.env;
const templateAuthStorage = discoverAuthStorage(input.agentDir, {
const authFacts = discoverAuthStorageFacts(input.agentDir, {
config: input.config,
// Prepared owners consume only the already-published runtime auth generation. External CLI
// hydration belongs to startup/control-plane and turn-time producers, never rebuilds.
@@ -131,7 +133,8 @@ function prepareAgentFacts(
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
...(input.env ? { env } : {}),
});
const credentials = templateAuthStorage.getAll();
const credentials = authFacts.credentials;
const templateAuthStorage = authFacts.authStorage;
const configuredModelRefs = collectPreparedModelRuntimeConfiguredRefs(
input.config,
input.agentId,
@@ -139,6 +142,7 @@ function prepareAgentFacts(
return {
input,
env,
authStore: authFacts.store,
templateAuthStorage,
credentials,
configuredModelRefs,
@@ -486,6 +490,12 @@ export function isPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): bool
return fullModelCatalogSnapshots.has(snapshot);
}
/** Restores process-local provenance after a complete catalog crosses a worker boundary. */
export function markPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): ModelCatalogSnapshot {
fullModelCatalogSnapshots.add(snapshot);
return snapshot;
}
function captureModelsJsonContents(agentDir: string): string | null {
try {
return fs.readFileSync(path.join(agentDir, "models.json"), "utf8");
@@ -645,7 +655,10 @@ export async function prepareAgentCatalogSource(
pluginGeneration: PreparedModelRuntimePluginGeneration,
catalogMode: PreparedModelRuntimeCatalogMode,
persist = true,
sourceOptions: { providerDiscoveryProviderIds?: readonly string[] } = {},
sourceOptions: {
authStore?: AuthProfileStore;
providerDiscoveryProviderIds?: readonly string[];
} = {},
): Promise<PreparedModelRuntimeCatalogSource> {
const { env, input, providerIds } = agentFacts;
const providerOutcomes = new Map<string, ProviderCatalogOutcome>();
@@ -683,6 +696,7 @@ export async function prepareAgentCatalogSource(
if (!persist) {
const source = await planOpenClawModelsJsonSource(input.config, input.agentDir, {
...options,
...(sourceOptions.authStore ? { authStore: sourceOptions.authStore } : {}),
...(catalogMode === "live" ? { onProviderCatalogOutcome: recordProviderOutcome } : {}),
});
return {
@@ -408,8 +408,7 @@ describe("prepared model runtime owner selection", () => {
});
await snapshot?.loadFullModelCatalog?.();
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce();
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledOnce();
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce();
});
it("shares workspace facts while isolating each agent's configured model projection", async () => {
@@ -595,12 +594,12 @@ describe("prepared model runtime owner selection", () => {
}));
let activePlans = 0;
let peakActivePlans = 0;
mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => {
mocks.runPreparedModelCatalogWorker.mockImplementation(async () => {
activePlans += 1;
peakActivePlans = Math.max(peakActivePlans, activePlans);
await Promise.resolve();
activePlans -= 1;
return { agentDir: String(agentDir), modelsJsonContents: null, pluginCatalogs: [] };
return { entries: [], routeVariants: [] };
});
const config = { agents: { defaults: { model: "openai/gpt-5.5" } } };
@@ -623,12 +622,12 @@ describe("prepared model runtime owner selection", () => {
await Promise.all([loadAgentCatalog("agent-a"), loadAgentCatalog("agent-b")]);
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledTimes(2);
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledTimes(2);
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2);
expect(peakActivePlans).toBe(1);
expect(
mocks.buildPreparedModelCatalogSnapshot.mock.calls.map((call) => {
const credential = call[0].authCredentials.custom;
mocks.runPreparedModelCatalogWorker.mock.calls.map((call) => {
const credential = (call[0] as { input: { credentials: Record<string, unknown> } }).input
.credentials.custom as { type?: string; key?: string } | undefined;
if (credential?.type !== "api_key") {
throw new Error("expected prepared custom API key");
}
@@ -653,13 +652,13 @@ describe("prepared model runtime owner selection", () => {
workspaceDir: "/tmp/shared-prepared-runtime-workspace",
});
let releaseLazyPlan: (() => void) | undefined;
mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => {
mocks.runPreparedModelCatalogWorker.mockImplementation(async () => {
if (!releaseLazyPlan) {
await new Promise<void>((resolve) => {
releaseLazyPlan = resolve;
});
}
return { agentDir: String(agentDir), modelsJsonContents: null, pluginCatalogs: [] };
return { entries: [], routeVariants: [] };
});
const staleCatalogLoad = snapshot?.loadFullModelCatalog?.();
@@ -669,13 +668,13 @@ describe("prepared model runtime owner selection", () => {
{ gatewayLifecycle: true, catalogMode: "live" },
);
await Promise.resolve();
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce();
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce();
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
releaseLazyPlan?.();
await expect(staleCatalogLoad).rejects.toThrow("superseded");
await replacement;
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce();
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce();
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
});
@@ -58,6 +58,7 @@ const mocks = vi.hoisted(() => {
}),
),
buildPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [] })),
runPreparedModelCatalogWorker: vi.fn(async () => ({ entries: [], routeVariants: [] })),
loadAgentRuntimePluginRegistryHandle: vi.fn(),
loadStaticCatalog: vi.fn(async () => []),
prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({
@@ -110,7 +111,32 @@ vi.mock("./agent-auth-discovery.js", () => ({
resolveAmbientAgentCredentialsForDiscovery: mocks.resolveAmbientCredentials,
}));
vi.mock("./prepared-model-catalog-worker.js", () => ({
createPreparedModelCatalogWorkerInput: ({ agentFacts }: { agentFacts: unknown }) => ({
generationFingerprint: "test-generation",
input: (agentFacts as { input: unknown }).input,
}),
runPreparedModelCatalogWorker: mocks.runPreparedModelCatalogWorker,
}));
vi.mock("./agent-model-discovery.js", () => ({
discoverAuthStorageFacts: (...args: unknown[]) => {
const authStorage = mocks.discoverAuthStorage(...args);
const credentials = authStorage.getAll();
return {
authStorage,
store: {
version: 1,
profiles: Object.fromEntries(
Object.entries(credentials).map(([provider, credential]) => [
`${provider}:default`,
{ ...(credential as object), provider },
]),
),
},
credentials,
};
},
discoverAuthStorage: mocks.discoverAuthStorage,
discoverModels: mocks.discoverModels,
discoverModelsFromCapturedSources: mocks.discoverModels,
@@ -382,37 +408,18 @@ describe("prepared model runtime Gateway catalog mode", () => {
expect(snapshot?.mediaCapabilityProviders).toBeDefined();
const fullCatalog = await snapshot?.loadFullModelCatalog?.();
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledWith(
config,
"/tmp/prepared-static-agent",
expect.objectContaining({
pluginMetadataSnapshot: mocks.metadataSnapshot,
providerDiscoveryTimeoutMs: 5_000,
}),
);
const fullCatalogOptions = mocks.planOpenClawModelsJsonSource.mock.calls[0]?.[2];
expect(fullCatalogOptions).not.toHaveProperty("providerDiscoveryProviderIds");
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledWith(
expect.objectContaining({ includeProviderPluginAugmentation: true }),
);
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce();
expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledOnce();
expect(mocks.loadAgentRuntimePluginRegistryHandle.mock.invocationCallOrder[0]).toBeLessThan(
mocks.buildPreparedModelCatalogSnapshot.mock.invocationCallOrder[0]!,
);
expect(mocks.loadStaticCatalog).toHaveBeenCalledWith(
expect.objectContaining({ metadataSnapshot: mocks.metadataSnapshot }),
);
mocks.mutationListener?.({
agentDir: "/tmp/prepared-static-agent",
affectsInheritedStores: false,
});
await expect(snapshot?.loadFullModelCatalog?.()).resolves.toBe(fullCatalog);
await vi.waitFor(() => expect(mocks.discoverModels).toHaveBeenCalledTimes(3));
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce();
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce();
expect(mocks.prepareStaticCatalog).toHaveBeenCalledOnce();
expect(mocks.discoverModels).toHaveBeenCalledTimes(3);
expect(mocks.discoverModels).toHaveBeenCalledTimes(2);
});
it("publishes exact dynamic configured models without building a live catalog", async () => {
@@ -64,6 +64,10 @@ const preparedModelRuntimeMocks = vi.hoisted(() => ({
pluginCatalogs: [],
})),
prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({ entries: [] })),
runPreparedModelCatalogWorker: vi.fn(async (..._args: unknown[]) => ({
entries: [],
routeVariants: [],
})),
resolveAmbientCredentials: vi.fn((..._args: unknown[]) => ({})),
resolveStaticCatalogModel: vi.fn<StaticCatalogResolver>(() => undefined),
warn: vi.fn(),
@@ -84,6 +88,27 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
resolvePluginMetadataSnapshot: () => preparedModelRuntimeMocks.pluginMetadataSnapshot,
}));
vi.mock("./prepared-model-catalog-worker.js", () => ({
createPreparedModelCatalogWorkerInput: ({
agentFacts,
}: {
agentFacts: {
input: unknown;
authStore: unknown;
credentials: unknown;
providerIds: unknown;
};
}) => ({
generationFingerprint: "test-generation",
input: agentFacts.input,
authStore: agentFacts.authStore,
credentials: agentFacts.credentials,
providerIds: agentFacts.providerIds,
}),
runPreparedModelCatalogWorker: (...args: unknown[]) =>
preparedModelRuntimeMocks.runPreparedModelCatalogWorker(...args),
}));
vi.mock("./model-catalog.js", () => ({
buildPreparedModelCatalogSnapshot: (...args: Parameters<BuildPreparedModelCatalogSnapshot>) =>
preparedModelRuntimeMocks.buildPreparedModelCatalogSnapshot(...args),
@@ -95,6 +120,34 @@ vi.mock("./agent-auth-discovery.js", () => ({
}));
vi.mock("./agent-model-discovery.js", () => ({
discoverAuthStorageFacts: (...args: unknown[]) => {
if ((args[1] as { skipCredentials?: boolean } | undefined)?.skipCredentials === true) {
return {
authStorage: { getAll: () => ({}), getOAuthProviders: () => [] },
store: { version: 1, profiles: {} },
credentials: {},
};
}
const authStorage = (preparedModelRuntimeMocks.discoverAuthStorage(...args) ??
preparedModelRuntimeMocks.authStorage) as {
getAll(): AuthStorageData;
getOAuthProviders(): unknown[];
};
const credentials = authStorage.getAll();
return {
authStorage,
store: preparedModelRuntimeMocks.preparedAuthStore ?? {
version: 1,
profiles: Object.fromEntries(
Object.entries(credentials).map(([provider, credential]) => [
`${provider}:default`,
{ ...credential, provider },
]),
),
},
credentials,
};
},
discoverAuthStorage: (...args: unknown[]) =>
preparedModelRuntimeMocks.discoverAuthStorage(...args) ?? preparedModelRuntimeMocks.authStorage,
discoverModels: (...args: unknown[]) => {
@@ -307,6 +360,10 @@ export function resetPreparedModelRuntimeHarness(): void {
pluginCatalogs: [],
}));
preparedModelRuntimeMocks.prepareStaticCatalog.mockReset().mockResolvedValue({ entries: [] });
preparedModelRuntimeMocks.runPreparedModelCatalogWorker.mockReset().mockResolvedValue({
entries: [],
routeVariants: [],
});
preparedModelRuntimeMocks.resolveAmbientCredentials.mockReset().mockReturnValue({});
preparedModelRuntimeMocks.resolveStaticCatalogModel.mockReset().mockReturnValue(undefined);
preparedModelRuntimeMocks.createStaticCatalogResolver
+2 -5
View File
@@ -557,11 +557,8 @@ describe("prepared model runtime snapshots", () => {
});
expect(credentialFree).not.toBe(await prepareModelRuntimeSnapshot({ config, agentDir }));
expect(mocks.discoverAuthStorage).toHaveBeenNthCalledWith(
2,
agentDir,
expect.objectContaining({ readOnly: true, skipCredentials: true }),
);
expect(mocks.discoverAuthStorage).toHaveBeenCalledOnce();
expect(credentialFree.authStore).toEqual({ version: 1, profiles: {} });
});
it("reuses one lifecycle-owned snapshot without rediscovering files", async () => {
@@ -6,6 +6,7 @@ import type { PreparedProviderStaticCatalog } from "../plugins/provider-discover
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import type { PluginRegistry } from "../plugins/registry-types.js";
import type { PreparedAgentCredentialModes } from "./agent-auth-credentials.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import type { InlineModelEntry } from "./embedded-agent-runner/model.inline-provider.js";
import type { AgentHarnessPluginSelection } from "./harness/runtime-plugin-load-plan.js";
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "./model-catalog.types.js";
@@ -40,6 +41,8 @@ export type PreparedModelRuntimeSnapshot = Readonly<{
/** Session active project set, ordered most-recent first; empty before run binding. */
activeProjectKeys: readonly string[];
config: OpenClawConfig;
/** Effective materialized auth store captured by this exact lifecycle generation. */
authStore: AuthProfileStore;
/** Secret-free usable auth modes captured by this exact lifecycle generation. */
authModes: PreparedAgentCredentialModes;
metadataSnapshot: PluginMetadataSnapshot;
@@ -81,6 +81,7 @@ function createEmptyPreparedModelRuntimeSnapshot(
...(input.workspaceDir !== undefined ? { workspaceDir: input.workspaceDir } : {}),
activeProjectKeys: [],
config: input.config,
authStore: { version: 1, profiles: {} },
authModes: {},
metadataSnapshot: createEmptyPluginMetadataSnapshot(input.workspaceDir),
pluginRegistry: createEmptyPluginRegistry(),
+4
View File
@@ -135,6 +135,10 @@ function createLocalGatewayRequestContext(
agentDir: owner.agentDir,
workspaceDir: owner.workspaceDir,
config: owner.config,
authModes: owner.authModes,
authStore: owner.authStore,
metadataSnapshot: owner.metadataSnapshot,
authMaterializations: [],
};
},
readPreparedGatewayModelCatalog: async (loadParams) =>
+3
View File
@@ -84,6 +84,7 @@ export async function startGatewayCoreRuntime(input: {
loadGatewayPluginBootstrapModule: () => Promise<typeof import("./server-plugin-bootstrap.js")>;
loadGatewayModelCatalog: typeof import("./server-model-catalog.js").loadGatewayModelCatalog;
loadGatewayModelCatalogSnapshot: typeof import("./server-model-catalog.js").loadGatewayModelCatalogSnapshot;
readPreparedGatewayModelCatalogSnapshot: typeof import("./server-model-catalog.js").readPreparedGatewayModelCatalogSnapshot;
readPreparedGatewayModelCatalog: typeof import("./server-model-catalog.js").readPreparedGatewayModelCatalog;
}) {
const {
@@ -97,6 +98,7 @@ export async function startGatewayCoreRuntime(input: {
loadGatewayPluginBootstrapModule,
loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalog,
} = input;
const {
@@ -653,6 +655,7 @@ export async function startGatewayCoreRuntime(input: {
refreshAttachedGatewayDiscovery,
loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalog,
};
}
@@ -44,6 +44,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
startupTrace,
loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalog,
refreshGatewayHealthSnapshotWithRuntime,
getRuntimeSnapshot,
@@ -129,6 +130,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
listSessionPendingApprovals: approvalSessionEvents.replay,
loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalog,
readChatMetadata: chatMetadataLifecycle.read,
readChatStartupProjection: chatMetadataLifecycle.readStartup,
+9
View File
@@ -17,6 +17,8 @@ type LoadGatewayModelCatalogSnapshot =
typeof import("./server-model-catalog.js").loadGatewayModelCatalogSnapshot;
type ReadPreparedGatewayModelCatalog =
typeof import("./server-model-catalog.js").readPreparedGatewayModelCatalog;
type ReadPreparedGatewayModelCatalogSnapshot =
typeof import("./server-model-catalog.js").readPreparedGatewayModelCatalogSnapshot;
const loadGatewayModelCatalogModule = createLazyRuntimeModule(
() => import("./server-model-catalog.js"),
@@ -77,6 +79,12 @@ const readPreparedGatewayModelCatalog: ReadPreparedGatewayModelCatalog = async (
const mod = await loadGatewayModelCatalogModule();
return mod.readPreparedGatewayModelCatalog(...args);
};
const readPreparedGatewayModelCatalogSnapshot: ReadPreparedGatewayModelCatalogSnapshot = async (
...args
) => {
const mod = await loadGatewayModelCatalogModule();
return mod.readPreparedGatewayModelCatalogSnapshot(...args);
};
function formatRuntimeGatewayAuthTokenWarning(): string {
const base =
@@ -156,6 +164,7 @@ export async function createGatewayKernel(port = 18789, opts: GatewayServerOptio
loadGatewayPluginBootstrapModule,
loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalog,
});
return await prepareGatewayKernelRequestRuntime({ coreRuntime, log, logHealth });
@@ -1,42 +1,19 @@
import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credentials.js";
import { resolveAgentDir } from "../../agents/agent-scope.js";
import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js";
import { resolveExternalCliAuthProfiles } from "../../agents/auth-profiles/external-cli-sync.js";
import {
recordRuntimeAuthMaterialization,
type RuntimeAuthMaterialization,
} from "../../agents/auth-profiles/runtime-materializations.js";
import type { RuntimeAuthMaterialization } from "../../agents/auth-profiles/runtime-materializations.js";
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
import {
createModelAuthAvailabilityResolver,
type ModelAuthAvailabilityEvaluation,
type ModelAuthAvailabilityRef,
type ModelAuthAvailabilityResolver,
} from "../../agents/model-auth-availability.js";
import { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { loadPluginRegistrySnapshotWithMetadata } from "../../plugins/plugin-registry.js";
function listEnabledSyntheticAuthProviderRefs(params: {
cfg: OpenClawConfig;
metadataSnapshot?: PluginMetadataSnapshot;
workspaceDir: string;
}): readonly string[] {
if (params.metadataSnapshot) {
return params.metadataSnapshot.index.plugins
.filter((plugin) => plugin.enabled)
.flatMap((plugin) => plugin.syntheticAuthRefs ?? []);
}
const result = loadPluginRegistrySnapshotWithMetadata({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: process.env,
});
if (result.source !== "persisted" && result.source !== "provided") {
return [];
}
return result.snapshot.plugins
function listEnabledSyntheticAuthProviderRefs(
metadataSnapshot: PluginMetadataSnapshot,
): readonly string[] {
return metadataSnapshot.index.plugins
.filter((plugin) => plugin.enabled)
.flatMap((plugin) => plugin.syntheticAuthRefs ?? []);
}
@@ -44,38 +21,17 @@ function listEnabledSyntheticAuthProviderRefs(params: {
export function createModelsListAuthResolver(params: {
cfg: OpenClawConfig;
agentId: string;
includeOpenAIExternalProfiles: boolean;
metadataSnapshot?: PluginMetadataSnapshot;
preparedAuthStore?: AuthProfileStore;
metadataSnapshot: PluginMetadataSnapshot;
preparedAuthStore: AuthProfileStore;
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
workspaceDir: string;
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
}): ModelAuthAvailabilityResolver {
const agentDir = resolveAgentDir(params.cfg, params.agentId);
// Browse reads persisted auth because another CLI process may have refreshed
// it after the Gateway execution snapshot was built.
const authStore =
params.preparedAuthStore ??
loadAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
});
// A prepared projection must hydrate from its own auth-store generation. Reading the global
// snapshot can mix generations; treating this store as persisted loses resolved SecretRefs.
const preparedRuntimeAuthStore = params.preparedAuthStore;
const externalCliProviderIds =
!params.preparedAuthStore && params.includeOpenAIExternalProfiles ? ["openai"] : [];
const externalProfileIds = new Set(
externalCliProviderIds.length
? resolveExternalCliAuthProfiles(authStore, {
allowKeychainPrompt: false,
providerIds: externalCliProviderIds,
}).map(({ profileId }) => profileId)
: [],
);
const resolver = createModelAuthAvailabilityResolver({
return createModelAuthAvailabilityResolver({
cfg: params.cfg,
authStore,
authStore: params.preparedAuthStore,
agentDir,
workspaceDir: params.workspaceDir,
env: process.env,
@@ -83,50 +39,9 @@ export function createModelsListAuthResolver(params: {
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
skipSetupProviderFallback: true,
syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params),
externalCliProviderIds,
...(preparedRuntimeAuthStore ? { preparedRuntimeAuthStore } : {}),
syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params.metadataSnapshot),
externalCliProviderIds: [],
preparedRuntimeAuthStore: params.preparedAuthStore,
routeResolverFactory: params.routeResolverFactory,
});
if (externalProfileIds.size === 0) {
return resolver;
}
const evaluateModelAuth = (
provider: string,
ref: ModelAuthAvailabilityRef = {},
): ModelAuthAvailabilityEvaluation => {
const evaluation = resolver.evaluateModelAuth(provider, ref);
const route = evaluation.selectedRoute;
const profileId = evaluation.selectedProfileId;
if (
evaluation.availability === true &&
route &&
profileId &&
externalProfileIds.has(profileId)
) {
const modelId = ref.modelId?.trim();
if (modelId) {
recordRuntimeAuthMaterialization({
agentDir,
provider,
modelId,
modelApi: route.api,
modelBaseUrl: route.baseUrl,
requestTransportOverrides: route.requestTransportOverrides,
authMode:
evaluation.selectedAuthMode ??
(route.authRequirement === "subscription" ? "oauth" : "api-key"),
runtimeOwnerId: "external-cli",
authProfileId: profileId,
});
}
}
return evaluation;
};
return {
...resolver,
evaluateModelAuth,
resolveProviderAuthAvailability: (provider, ref) =>
evaluateModelAuth(provider, ref).availability,
};
}
@@ -1,6 +1,8 @@
import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import type { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
import { buildModelsListResult } from "./models-list-result.js";
import type { GatewayRequestContext } from "./types.js";
@@ -35,6 +37,10 @@ export async function listModels(params: {
agentId: "main",
agentDir: "/tmp/models-list-openai-agent",
config,
authStore: loadAuthProfileStoreWithoutExternalProfiles("/tmp/models-list-openai-agent", {
allowKeychainPrompt: false,
}),
metadataSnapshot: loadManifestMetadataSnapshot({ config, env: process.env }),
entries: params.catalog,
routeVariants: params.catalog,
}),
@@ -52,10 +58,13 @@ export async function listModels(params: {
},
catalogProjector: {
metadataSnapshot: {
index: { plugins: [] },
manifestRegistry: { plugins: [] },
plugins: [
{ id: "test-provider", modelCatalog: { discovery: params.discoveryModes } },
],
},
authStore: { version: 1, profiles: {} },
} as never,
}
: {}),
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { buildModelsListResult } from "./models-list-result.js";
@@ -15,6 +16,13 @@ import type { GatewayRequestContext } from "./types.js";
const IMPLICIT_CODEX_RUNTIME = { id: "codex", source: "implicit" } as const;
const IMPLICIT_OPENCLAW_RUNTIME = { id: "openclaw", source: "implicit" } as const;
function preparedOwnerFacts(config: OpenClawConfig) {
return {
authStore: { version: 1, profiles: {} },
metadataSnapshot: loadManifestMetadataSnapshot({ config, env: process.env }),
} as const;
}
describe("models.list OpenAI routes", () => {
it("does not reuse a preloaded catalog owned by another agent", async () => {
const config = {
@@ -27,6 +35,7 @@ describe("models.list OpenAI routes", () => {
Promise.resolve({
agentDir: "/tmp/models-list-openai-agent",
config,
...preparedOwnerFacts(config),
entries: [],
routeVariants: [],
}),
@@ -34,6 +43,14 @@ describe("models.list OpenAI routes", () => {
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalogSnapshot: async () => ({
agentId: "main",
agentDir: "/tmp/models-list-openai-agent",
config,
entries: [],
routeVariants: [],
...preparedOwnerFacts(config),
}),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
const preloadedCatalog: ModelCatalogSnapshot = {
@@ -55,11 +72,12 @@ describe("models.list OpenAI routes", () => {
});
it("does not reuse a preloaded catalog from another config generation", async () => {
const config = {} as OpenClawConfig;
const config = { agents: { defaults: { model: "openai/current" } } } as OpenClawConfig;
const loadGatewayModelCatalogSnapshot = vi.fn(() =>
Promise.resolve({
agentDir: "/tmp/models-list-openai-agent",
config,
...preparedOwnerFacts(config),
entries: [],
routeVariants: [],
}),
@@ -76,7 +94,7 @@ describe("models.list OpenAI routes", () => {
params: { view: "default" },
preloadedCatalog: {
agentId: "main",
config: {} as OpenClawConfig,
config: { agents: { defaults: { model: "openai/stale" } } } as OpenClawConfig,
snapshot: { entries: [catalogEntry("stale", "openai-responses")], routeVariants: [] },
},
}),
@@ -91,6 +109,7 @@ describe("models.list OpenAI routes", () => {
Promise.resolve({
agentDir: "/tmp/models-list-openai-agent",
config: replacementConfig,
...preparedOwnerFacts(replacementConfig),
entries: [],
routeVariants: [],
}),
@@ -99,6 +118,14 @@ describe("models.list OpenAI routes", () => {
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalogSnapshot: async () => ({
agentId: "main",
agentDir: "/tmp/models-list-openai-agent",
config,
entries: [],
routeVariants: [],
...preparedOwnerFacts(config),
}),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
@@ -127,6 +154,14 @@ describe("models.list OpenAI routes", () => {
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalogSnapshot: async () => ({
agentId: "main",
agentDir: "/tmp/models-list-openai-agent",
config,
entries: [],
routeVariants: [],
...preparedOwnerFacts(config),
}),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
@@ -170,6 +205,7 @@ describe("models.list OpenAI routes", () => {
agentId: "main",
agentDir: "/tmp/models-list-openai-agent",
config,
...preparedOwnerFacts(config),
entries: [ownerEntry],
routeVariants: [ownerEntry],
}),
@@ -215,6 +251,7 @@ describe("models.list OpenAI routes", () => {
agentDir: "/tmp/models-list-main-agent",
workspaceDir: "/tmp/models-list-main-workspace",
config: replacementConfig,
...preparedOwnerFacts(replacementConfig),
entries: [entry],
routeVariants: [entry],
})
@@ -223,6 +260,7 @@ describe("models.list OpenAI routes", () => {
agentDir: "/tmp/models-list-main-agent",
workspaceDir: "/tmp/models-list-main-workspace",
config: replacementConfig,
...preparedOwnerFacts(replacementConfig),
entries: [entry],
routeVariants: [entry],
});
@@ -258,6 +296,7 @@ describe("models.list OpenAI routes", () => {
agentDir: "/tmp/models-list-main-agent",
workspaceDir: "/tmp/models-list-main-workspace",
config: replacementConfig,
...preparedOwnerFacts(replacementConfig),
entries: [entry],
routeVariants: [entry],
})
@@ -266,6 +305,7 @@ describe("models.list OpenAI routes", () => {
agentDir: "/tmp/models-list-worker-agent",
workspaceDir: "/tmp/models-list-worker-workspace",
config: replacementConfig,
...preparedOwnerFacts(replacementConfig),
entries: [entry],
routeVariants: [entry],
});
@@ -291,6 +331,7 @@ describe("models.list OpenAI routes", () => {
agentDir: "/tmp/models-list-openai-agent",
workspaceDir: "/tmp/models-list-openai-workspace",
config,
...preparedOwnerFacts(config),
entries: [],
routeVariants: [],
}),
@@ -330,6 +371,7 @@ describe("models.list OpenAI routes", () => {
Promise.resolve({
agentDir: "/tmp/models-list-openai-agent",
config,
...preparedOwnerFacts(config),
entries: [ownerlessEntry],
routeVariants: [ownerlessEntry],
}),
@@ -361,6 +403,7 @@ describe("models.list OpenAI routes", () => {
agentId: "main",
agentDir: "/tmp/models-list-main-agent",
config,
...preparedOwnerFacts(config),
entries: [mainEntry],
routeVariants: [mainEntry],
}),
@@ -398,6 +441,7 @@ describe("models.list OpenAI routes", () => {
agentId: "worker",
agentDir: "/tmp/models-list-worker-agent",
config,
...preparedOwnerFacts(config),
entries: [workerEntry],
routeVariants: [workerEntry],
}),
@@ -1,26 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import type { GatewayRequestContext } from "./types.js";
const getCurrentPluginMetadataSnapshotMock = vi.hoisted(() => vi.fn());
const loadPluginRegistrySnapshotWithMetadataMock = vi.hoisted(() => vi.fn());
vi.mock("../../plugins/current-plugin-metadata-snapshot.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/current-plugin-metadata-snapshot.js")>()),
getCurrentPluginMetadataSnapshot: getCurrentPluginMetadataSnapshotMock,
}));
vi.mock("../../plugins/plugin-registry.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/plugin-registry.js")>()),
loadPluginRegistrySnapshotWithMetadata: loadPluginRegistrySnapshotWithMetadataMock,
}));
import {
buildModelsListResult,
createGatewayAgentModelCatalogProjector,
} from "./models-list-result.js";
import type { GatewayRequestContext } from "./types.js";
function catalogEntry(id: string): ModelCatalogEntry {
return { id, name: id, provider: "custom", api: "openai-responses" };
@@ -53,11 +39,6 @@ function preparedMetadataSnapshot() {
}
describe("models.list plugin metadata handoff", () => {
beforeEach(() => {
getCurrentPluginMetadataSnapshotMock.mockReset();
loadPluginRegistrySnapshotWithMetadataMock.mockReset();
});
it("reuses one Gateway-owned metadata snapshot across startup projection and browse", async () => {
await withOpenClawTestState(
{
@@ -82,11 +63,12 @@ describe("models.list plugin metadata handoff", () => {
entries: [catalogEntry("modern"), catalogEntry("another")],
routeVariants: [],
};
getCurrentPluginMetadataSnapshotMock.mockReturnValue(preparedMetadataSnapshot());
const projector = createGatewayAgentModelCatalogProjector({
cfg,
agentId: "main",
snapshot,
metadataSnapshot: preparedMetadataSnapshot(),
preparedAuthStore: { version: 1, profiles: {} },
});
await projector.projectCatalog();
@@ -103,55 +85,37 @@ describe("models.list plugin metadata handoff", () => {
preloadedOnly: true,
catalogProjector: projector,
});
expect(getCurrentPluginMetadataSnapshotMock).toHaveBeenCalledWith({
config: cfg,
allowWorkspaceScopedSnapshot: true,
});
expect(loadPluginRegistrySnapshotWithMetadataMock).not.toHaveBeenCalled();
},
);
});
it("preserves registry fallback when no compatible Gateway snapshot exists", async () => {
await withOpenClawTestState(
{
layout: "state-only",
prefix: "openclaw-models-list-plugin-runtime-fallback-",
agentEnv: "main",
},
async (state) => {
const cfg = {
agents: {
defaults: {
workspace: state.workspaceDir,
model: { primary: "custom/modern" },
models: { "custom/modern": {} },
},
},
} as OpenClawConfig;
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
loadPluginRegistrySnapshotWithMetadataMock.mockReturnValue({
source: "provided",
snapshot: {
plugins: [
{
enabled: true,
syntheticAuthRefs: ["custom"],
},
],
},
});
const projector = createGatewayAgentModelCatalogProjector({
cfg,
agentId: "main",
snapshot: { entries: [catalogEntry("modern")], routeVariants: [] },
});
it("keeps prepared owner facts when preloaded-only browse requires full discovery", async () => {
const cfg = {
agents: { defaults: { models: { "custom/*": {} } } },
} as OpenClawConfig;
const snapshot: ModelCatalogSnapshot = { entries: [], routeVariants: [] };
const loadGatewayModelCatalogSnapshot = vi.fn();
const context = {
getRuntimeConfig: () => cfg,
loadGatewayModelCatalogSnapshot,
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
const projector = createGatewayAgentModelCatalogProjector({
cfg,
agentId: "main",
snapshot,
metadataSnapshot: preparedMetadataSnapshot(),
preparedAuthStore: { version: 1, profiles: {} },
});
await projector.projectCatalog();
await buildModelsListResult({
context,
params: { view: "configured" },
preloadedCatalog: { agentId: "main", config: cfg, snapshot },
preloadedOnly: true,
catalogProjector: projector,
});
expect(loadPluginRegistrySnapshotWithMetadataMock).toHaveBeenCalled();
},
);
expect(loadGatewayModelCatalogSnapshot).not.toHaveBeenCalled();
});
});
@@ -6,6 +6,13 @@ import {
} from "./models-list-result.js";
import type { GatewayRequestContext } from "./types.js";
const metadataSnapshot = {
index: { plugins: [] },
manifestRegistry: { plugins: [] },
plugins: [],
} as never;
const emptyAuthStore = { version: 1, profiles: {} } as const;
describe("models.list provider catalog outcomes", () => {
it("preserves an auth rejection when no usable models are visible", async () => {
const config = {} as OpenClawConfig;
@@ -13,6 +20,8 @@ describe("models.list provider catalog outcomes", () => {
agentId: "main",
agentDir: "/tmp/models-list-provider-outcomes-agent",
config,
authStore: emptyAuthStore,
metadataSnapshot,
entries: [],
routeVariants: [],
providerOutcomes: [
@@ -68,6 +77,7 @@ describe("models.list provider catalog outcomes", () => {
cfg: config,
agentId: "main",
snapshot,
metadataSnapshot,
preparedAuthStore: {
version: 1,
profiles: {
@@ -143,6 +153,7 @@ describe("models.list provider catalog outcomes", () => {
cfg: config,
agentId: "main",
snapshot,
metadataSnapshot,
preferredProfileId: "openai:accepted",
preparedAuthStore: {
version: 1,
@@ -46,11 +46,11 @@ import {
openAIModelCatalogRoutePolicy,
} from "../../agents/openai-model-routes.js";
import { publishedModelCatalogOwnerMatchesAgent } from "../../agents/prepared-model-catalog-owner.js";
import { preparedModelRuntimeConfigsMatch } from "../../agents/prepared-model-runtime.js";
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { getRuntimeConfigSourceSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth-choices.js";
import type { ProviderCatalogOutcome } from "../../plugins/provider-catalog.types.js";
@@ -130,6 +130,7 @@ function resolveLegacyEntryAvailability(params: {
primaryAvailability: ModelsListAvailability;
cfg: OpenClawConfig;
agentId: string;
metadataSnapshot: PluginMetadataSnapshot;
}): ModelsListAvailability {
if (params.primaryAvailability === true) {
return true;
@@ -140,6 +141,7 @@ function resolveLegacyEntryAvailability(params: {
cfg: params.cfg,
agentId: params.agentId,
modelId: params.entry.id,
metadataSnapshot: params.metadataSnapshot,
});
if (
runtimeProvider &&
@@ -160,6 +162,7 @@ function createModelsListEntryEvaluator(params: {
cfg: OpenClawConfig;
agentId: string;
authResolver: ModelAuthAvailabilityResolver;
metadataSnapshot: PluginMetadataSnapshot;
providerOutcomes?: readonly ProviderCatalogOutcome[];
preferredProfileId?: string;
lockedProfileId?: string;
@@ -195,6 +198,7 @@ function createModelsListEntryEvaluator(params: {
primaryAvailability: evaluation.availability,
cfg: params.cfg,
agentId: params.agentId,
metadataSnapshot: params.metadataSnapshot,
}),
}
: evaluation;
@@ -297,32 +301,17 @@ export function createGatewayAgentModelCatalogProjector(params: {
cfg: OpenClawConfig;
agentId: string;
snapshot: ModelCatalogSnapshot;
metadataSnapshot?: PluginMetadataSnapshot;
preparedAuthStore?: AuthProfileStore;
metadataSnapshot: PluginMetadataSnapshot;
preparedAuthStore: AuthProfileStore;
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
preferredProfileId?: string;
lockedProfileId?: string;
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
}) {
const defaultModel = resolveAgentEffectiveModelPrimary(params.cfg, params.agentId);
// The Gateway owns one process-lifecycle plugin metadata snapshot. Carry it
// through the whole projection so per-model normalization cannot rediscover it.
const metadataSnapshot =
params.metadataSnapshot ??
getCurrentPluginMetadataSnapshot({
config: params.cfg,
allowWorkspaceScopedSnapshot: true,
});
const visibilityPolicy = createModelVisibilityPolicy({
cfg: params.cfg,
catalog: params.snapshot.entries,
defaultProvider: DEFAULT_PROVIDER,
defaultModel,
agentId: params.agentId,
...RUNTIME_MODEL_VISIBILITY_NORMALIZATION,
manifestPlugins: metadataSnapshot?.plugins,
});
const metadataSnapshot = params.metadataSnapshot;
const workspaceDir =
resolveAgentWorkspaceDir(params.cfg, params.agentId) ?? resolveDefaultAgentWorkspaceDir();
const projectionCatalog =
@@ -350,11 +339,8 @@ export function createGatewayAgentModelCatalogProjector(params: {
const authResolver = createModelsListAuthResolver({
cfg: params.cfg,
agentId: params.agentId,
includeOpenAIExternalProfiles:
projectionCatalog.some((entry) => normalizeProviderId(entry.provider) === "openai") ||
[...visibilityPolicy.configuredKeys].some((key) => key.startsWith("openai/")),
metadataSnapshot,
...(params.preparedAuthStore ? { preparedAuthStore: params.preparedAuthStore } : {}),
preparedAuthStore: params.preparedAuthStore,
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
workspaceDir,
@@ -364,6 +350,7 @@ export function createGatewayAgentModelCatalogProjector(params: {
cfg: params.cfg,
agentId: params.agentId,
authResolver,
metadataSnapshot,
providerOutcomes: params.snapshot.providerOutcomes,
...(params.preferredProfileId ? { preferredProfileId: params.preferredProfileId } : {}),
...(params.lockedProfileId ? { lockedProfileId: params.lockedProfileId } : {}),
@@ -372,6 +359,9 @@ export function createGatewayAgentModelCatalogProjector(params: {
return {
evaluateEntry,
metadataSnapshot,
authStore: params.preparedAuthStore,
authModes: params.preparedRuntimeAuthModes,
authMaterializations: params.preparedRuntimeAuthMaterializations,
projectCatalog: () =>
(projectedCatalog ??= Promise.all(
logicalEntries.map(async (entry) => {
@@ -470,6 +460,7 @@ async function buildPublicModelsListEntries(params: {
function apiKeyProviderCapabilities(params: {
cfg: OpenClawConfig;
metadataSnapshot: PluginMetadataSnapshot;
workspaceDir: string;
}): ApiKeyProviderCapabilities {
const capabilities = new Map<string, boolean>();
@@ -479,12 +470,14 @@ function apiKeyProviderCapabilities(params: {
workspaceDir: params.workspaceDir,
env: process.env,
includeUntrustedWorkspacePlugins: false,
metadataSnapshot: params.metadataSnapshot,
});
for (const choice of resolveManifestProviderAuthChoices({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: process.env,
includeUntrustedWorkspacePlugins: false,
metadataSnapshot: params.metadataSnapshot,
})) {
const provider = resolveProvider(choice.providerId);
capabilities.set(
@@ -519,9 +512,15 @@ export async function buildModelsListResult(
const view = resolveModelsListView(params.params);
const preloadedCatalog =
params.preloadedCatalog?.agentId === initialAgentId &&
params.preloadedCatalog.config === initialConfig
preparedModelRuntimeConfigsMatch(params.preloadedCatalog.config, initialConfig)
? params.preloadedCatalog
: undefined;
const preparedOwnerSnapshot =
preloadedCatalog && params.catalogProjector
? undefined
: await params.context.readPreparedGatewayModelCatalogSnapshot?.({
agentId: initialAgentId,
});
let loadedSnapshot:
| Awaited<ReturnType<GatewayRequestContext["loadGatewayModelCatalogSnapshot"]>>
| undefined;
@@ -606,25 +605,26 @@ export async function buildModelsListResult(
) {
return { models: [] };
}
const cfg = loadedSnapshot?.config ?? initialConfig;
const agentId = loadedSnapshot?.agentId ?? initialAgentId;
const ownerSnapshot = loadedSnapshot ?? preparedOwnerSnapshot;
const cfg = ownerSnapshot?.config ?? initialConfig;
const agentId = ownerSnapshot?.agentId ?? initialAgentId;
const workspaceDir =
loadedSnapshot?.workspaceDir ??
ownerSnapshot?.workspaceDir ??
resolveAgentWorkspaceDir(cfg, agentId) ??
resolveDefaultAgentWorkspaceDir();
const catalog = snapshot.entries;
const routeVariants = snapshot.routeVariants;
const providerOutcomes = snapshot.providerOutcomes;
const { entries: catalog, routeVariants, providerOutcomes } = snapshot;
const outcomeProjection = providerOutcomes?.length ? { providerOutcomes } : {};
const metadataSnapshot =
(usedPreloadedCatalog ? params.catalogProjector?.metadataSnapshot : undefined) ??
getCurrentPluginMetadataSnapshot({
config: cfg,
allowWorkspaceScopedSnapshot: true,
});
const preparedProjectionOwner = ownerSnapshot ?? params.catalogProjector;
const metadataSnapshot = preparedProjectionOwner?.metadataSnapshot;
const preparedAuthStore = preparedProjectionOwner?.authStore;
if (!metadataSnapshot || !preparedAuthStore) {
throw new Error("Gateway model catalog owner omitted prepared metadata or auth state");
}
const preparedRuntimeAuthModes = preparedProjectionOwner?.authModes;
const preparedRuntimeAuthMaterializations = preparedProjectionOwner?.authMaterializations;
const includeProviderCapabilities = params.params.includeProviderCapabilities === true;
const capableProviders = includeProviderCapabilities
? apiKeyProviderCapabilities({ cfg, workspaceDir })
? apiKeyProviderCapabilities({ cfg, metadataSnapshot, workspaceDir })
: undefined;
if (view === "provider-config") {
const sourceConfig = getRuntimeConfigSourceSnapshot() ?? cfg;
@@ -648,6 +648,10 @@ export async function buildModelsListResult(
cfg,
agentId,
snapshot: inventorySnapshot,
metadataSnapshot,
preparedAuthStore,
preparedRuntimeAuthModes,
preparedRuntimeAuthMaterializations,
...(params.routeResolverFactory ? { routeResolverFactory: params.routeResolverFactory } : {}),
});
const inventory = await inventoryProjector.projectCatalog();
@@ -682,13 +686,14 @@ export async function buildModelsListResult(
authResolver: createModelsListAuthResolver({
cfg,
agentId,
includeOpenAIExternalProfiles:
catalog.some((entry) => normalizeProviderId(entry.provider) === "openai") ||
[...visibilityPolicy.configuredKeys].some((key) => key.startsWith("openai/")),
metadataSnapshot,
preparedAuthStore,
preparedRuntimeAuthModes,
preparedRuntimeAuthMaterializations,
workspaceDir,
routeResolverFactory: params.routeResolverFactory,
}),
metadataSnapshot,
providerOutcomes,
});
const models = await resolveLogicalVisibleModelCatalog({
+28 -7
View File
@@ -4,13 +4,16 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
getPreparedRuntimeAuthProfileStoreSnapshot,
loadAuthProfileStoreWithoutExternalProfiles,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../../agents/auth-profiles.js";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { modelsHandlers } from "./models.js";
@@ -62,6 +65,20 @@ function requestModelsList(params: {
const respond = params.respond ?? vi.fn();
const runtimeConfig = params.runtimeConfig ?? ({} as OpenClawConfig);
const getRuntimeConfig = params.getRuntimeConfig ?? (() => runtimeConfig);
const resolveOwnerFacts = () => {
const config = getRuntimeConfig();
const agentId = params.agentId ?? resolveDefaultAgentId(config);
const agentDir = resolveAgentDir(config, agentId);
return {
agentId,
agentDir,
config,
authStore:
getPreparedRuntimeAuthProfileStoreSnapshot(agentDir) ??
loadAuthProfileStoreWithoutExternalProfiles(agentDir, { allowKeychainPrompt: false }),
metadataSnapshot: loadManifestMetadataSnapshot({ config, env: process.env }),
};
};
const request = expectDefined(
modelsHandlers["models.list"],
'modelsHandlers["models.list"] test invariant',
@@ -91,15 +108,19 @@ function requestModelsList(params: {
loadParams: Parameters<typeof params.loadGatewayModelCatalog>[0],
) => {
const entries = await params.loadGatewayModelCatalog(loadParams);
const config = getRuntimeConfig();
const owner = resolveOwnerFacts();
return {
agentId: loadParams?.agentId ?? resolveDefaultAgentId(config),
agentDir: "/tmp/models-list-agent",
config,
...owner,
...(loadParams?.agentId ? { agentId: loadParams.agentId } : {}),
entries,
routeVariants: entries,
};
},
readPreparedGatewayModelCatalogSnapshot: async () => ({
...resolveOwnerFacts(),
entries: [],
routeVariants: [],
}),
logGateway: {
debug: vi.fn(),
},
@@ -1168,7 +1189,7 @@ describe("models.list", () => {
);
});
it("uses refreshed persisted OAuth when the runtime auth snapshot is stale", async () => {
it("does not mix refreshed persisted OAuth into a stale runtime generation", async () => {
await withOpenClawTestState(
{
layout: "state-only",
@@ -1213,7 +1234,7 @@ describe("models.list", () => {
id: "demo-model",
name: "Demo Model",
provider: "demo-provider",
available: true,
available: false,
},
],
},
@@ -227,6 +227,11 @@ type GatewayKernelContext = {
readOnly?: boolean;
workspaceDir?: string;
}) => Promise<GatewayModelCatalogSnapshot>;
readPreparedGatewayModelCatalogSnapshot?: (params?: {
agentId?: string;
agentDir?: string;
workspaceDir?: string;
}) => Promise<GatewayModelCatalogSnapshot | undefined>;
readPreparedGatewayModelCatalog?: (params?: {
agentId?: string;
agentDir?: string;
+57 -14
View File
@@ -1,5 +1,7 @@
import { resolvePublishedModelCatalogOwner } from "../agents/prepared-model-catalog-owner.js";
import type { PublishedModelCatalogOwnerCandidate } from "../agents/prepared-model-catalog.types.js";
import { getPreparedModelRuntimeAuthMaterializations } from "../agents/prepared-model-runtime-auth.js";
import type { PreparedModelRuntimeSnapshot } from "../agents/prepared-model-runtime.types.js";
// Gateway catalog reads use the atomic prepared runtime generation.
import { getRuntimeConfig } from "../config/io.js";
import type {
@@ -51,32 +53,51 @@ export async function resetPreparedModelCatalogStateForTest(): Promise<void> {
async function loadGatewayModelCatalogOwnerSnapshot(
params?: LoadGatewayModelCatalogParams,
): Promise<GatewayModelCatalogOwnerSnapshot> {
): Promise<
GatewayModelCatalogOwnerSnapshot & {
authMaterializations: GatewayModelCatalogSnapshot["authMaterializations"];
}
> {
const loadOwner = await resolveLoader(params);
return resolvePublishedModelCatalogOwner(
await loadOwner({
...(params?.agentId ? { agentId: params.agentId } : {}),
...(params?.agentDir ? { agentDir: params.agentDir } : {}),
config: (params?.getConfig ?? getRuntimeConfig)(),
readOnly: params?.readOnly !== false,
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
}),
);
const candidate = await loadOwner({
...(params?.agentId ? { agentId: params.agentId } : {}),
...(params?.agentDir ? { agentDir: params.agentDir } : {}),
config: (params?.getConfig ?? getRuntimeConfig)(),
readOnly: params?.readOnly !== false,
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
return {
...resolvePublishedModelCatalogOwner(candidate),
authMaterializations: getPreparedModelRuntimeAuthMaterializations(
candidate as PreparedModelRuntimeSnapshot,
),
};
}
export async function loadGatewayModelCatalogSnapshot(
params?: LoadGatewayModelCatalogParams,
): Promise<GatewayModelCatalogSnapshot> {
const owner = await loadGatewayModelCatalogOwnerSnapshot(params);
function projectGatewayModelCatalogSnapshot(
owner: GatewayModelCatalogOwnerSnapshot & {
authMaterializations?: GatewayModelCatalogSnapshot["authMaterializations"];
},
): GatewayModelCatalogSnapshot {
return {
...owner.modelCatalog,
agentId: owner.agentId,
agentDir: owner.agentDir,
workspaceDir: owner.workspaceDir,
config: owner.config,
authModes: owner.authModes,
authStore: owner.authStore,
metadataSnapshot: owner.metadataSnapshot,
authMaterializations: owner.authMaterializations,
};
}
export async function loadGatewayModelCatalogSnapshot(
params?: LoadGatewayModelCatalogParams,
): Promise<GatewayModelCatalogSnapshot> {
return projectGatewayModelCatalogSnapshot(await loadGatewayModelCatalogOwnerSnapshot(params));
}
export async function loadGatewayModelCatalog(
params?: LoadGatewayModelCatalogParams,
): Promise<GatewayModelChoice[]> {
@@ -97,3 +118,25 @@ export async function readPreparedGatewayModelCatalog(
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
})?.entries;
}
/** Reads the published owner generation without activating full catalog discovery. */
export async function readPreparedGatewayModelCatalogSnapshot(
params?: LoadGatewayModelCatalogParams,
): Promise<GatewayModelCatalogSnapshot | undefined> {
const { getPublishedPreparedModelCatalogOwnerSnapshot } =
await import("../agents/prepared-model-catalog.js");
const config = (params?.getConfig ?? getRuntimeConfig)();
const candidate = getPublishedPreparedModelCatalogOwnerSnapshot({
...(params?.agentId ? { agentId: params.agentId } : {}),
...(params?.agentDir ? { agentDir: params.agentDir } : {}),
config,
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
if (!candidate) {
return undefined;
}
return projectGatewayModelCatalogSnapshot({
...resolvePublishedModelCatalogOwner(candidate),
authMaterializations: getPreparedModelRuntimeAuthMaterializations(candidate),
});
}
+8 -4
View File
@@ -1,10 +1,14 @@
import type { RuntimeAuthMaterialization } from "../agents/auth-profiles/runtime-materializations.js";
import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js";
import type { ResolvedPublishedModelCatalogOwner } from "../agents/prepared-model-catalog.types.js";
export type GatewayModelCatalogOwnerSnapshot = Omit<
export type GatewayModelCatalogOwnerSnapshot = Pick<
ResolvedPublishedModelCatalogOwner,
"pluginRegistry"
>;
"agentId" | "agentDir" | "workspaceDir" | "config" | "modelCatalog"
> &
Partial<Pick<ResolvedPublishedModelCatalogOwner, "authModes" | "authStore" | "metadataSnapshot">>;
export type GatewayModelCatalogSnapshot = ModelCatalogSnapshot &
Omit<GatewayModelCatalogOwnerSnapshot, "modelCatalog">;
Omit<GatewayModelCatalogOwnerSnapshot, "modelCatalog"> & {
authMaterializations?: readonly RuntimeAuthMaterialization[];
};
+4
View File
@@ -49,6 +49,7 @@ type GatewayRequestContextParams = {
listSessionPendingApprovals: GatewayRequestContext["listSessionPendingApprovals"];
loadGatewayModelCatalog: GatewayRequestContext["loadGatewayModelCatalog"];
loadGatewayModelCatalogSnapshot: GatewayRequestContext["loadGatewayModelCatalogSnapshot"];
readPreparedGatewayModelCatalogSnapshot?: GatewayRequestContext["readPreparedGatewayModelCatalogSnapshot"];
readPreparedGatewayModelCatalog?: GatewayRequestContext["readPreparedGatewayModelCatalog"];
readChatMetadata: GatewayRequestContext["readChatMetadata"];
readChatStartupProjection?: GatewayRequestContext["readChatStartupProjection"];
@@ -201,6 +202,9 @@ export function createGatewayRequestContext(
listSessionPendingApprovals: params.listSessionPendingApprovals,
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot: params.loadGatewayModelCatalogSnapshot,
...(params.readPreparedGatewayModelCatalogSnapshot
? { readPreparedGatewayModelCatalogSnapshot: params.readPreparedGatewayModelCatalogSnapshot }
: {}),
...(params.readPreparedGatewayModelCatalog
? { readPreparedGatewayModelCatalog: params.readPreparedGatewayModelCatalog }
: {}),
+12 -12
View File
@@ -8,6 +8,7 @@ import {
getOfficialExternalPluginCatalogManifest,
listOfficialExternalProviderCatalogEntries,
} from "./official-external-plugin-catalog.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
export type ProviderAuthChoiceMetadata = {
@@ -57,6 +58,7 @@ type ManifestProviderAuthChoiceParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
metadataSnapshot?: PluginMetadataSnapshot;
includeUntrustedWorkspacePlugins?: boolean;
includeWorkspacePlugins?: boolean;
};
@@ -193,18 +195,16 @@ function stripChoiceOrigin(choice: ProviderAuthChoiceCandidate): ProviderAuthCho
return metadata;
}
function resolveManifestProviderAuthChoiceCandidates(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
includeUntrustedWorkspacePlugins?: boolean;
includeWorkspacePlugins?: boolean;
}): ProviderAuthChoiceCandidate[] {
const metadataSnapshot = loadManifestMetadataSnapshot({
config: params?.config ?? {},
workspaceDir: params?.workspaceDir,
env: params?.env ?? process.env,
});
function resolveManifestProviderAuthChoiceCandidates(
params?: ManifestProviderAuthChoiceParams,
): ProviderAuthChoiceCandidate[] {
const metadataSnapshot =
params?.metadataSnapshot ??
loadManifestMetadataSnapshot({
config: params?.config ?? {},
workspaceDir: params?.workspaceDir,
env: params?.env ?? process.env,
});
const registry = metadataSnapshot.manifestRegistry;
const normalizedConfig = normalizePluginsConfig(params?.config?.plugins);
return registry.plugins.flatMap((plugin) => {
+2
View File
@@ -725,6 +725,7 @@ describe("collectMissingPackPaths", () => {
"scripts/postinstall-bundled-plugins.mjs",
"dist/agents/compaction-planning.worker.js",
"dist/agents/model-provider-auth.worker.js",
"dist/agents/prepared-model-catalog.worker.js",
"dist/audit/audit-event-writer.worker.js",
"dist/config/sessions/session-accessor.sqlite-archive.worker.js",
"dist/config/sessions/session-transcript-reconcile.worker.js",
@@ -765,6 +766,7 @@ describe("collectMissingPackPaths", () => {
"scripts/postinstall-bundled-plugins.mjs",
"dist/agents/compaction-planning.worker.js",
"dist/agents/model-provider-auth.worker.js",
"dist/agents/prepared-model-catalog.worker.js",
"dist/audit/audit-event-writer.worker.js",
"dist/config/sessions/session-accessor.sqlite-archive.worker.js",
"dist/config/sessions/session-transcript-reconcile.worker.js",
+1
View File
@@ -292,6 +292,7 @@ function buildCoreDistEntries(): Record<string, string> {
"agents/code-mode.worker": "src/agents/code-mode.worker.ts",
"agents/compaction-planning.worker": "src/agents/compaction-planning.worker.ts",
"agents/model-provider-auth.worker": "src/agents/model-provider-auth.worker.ts",
"agents/prepared-model-catalog.worker": "src/agents/prepared-model-catalog.worker.ts",
"audit/audit-event-writer.worker": "src/audit/audit-event-writer.worker.ts",
"config/sessions/session-accessor.sqlite-archive.worker":
"src/config/sessions/session-accessor.sqlite-archive.worker.ts",