mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(gateway): isolate full model catalog discovery
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user