fix(models): preserve scoped catalog coverage

This commit is contained in:
Vincent Koc
2026-08-01 13:02:36 +02:00
parent 7116a76248
commit 0968efea07
20 changed files with 691 additions and 262 deletions

View File

@@ -15,15 +15,12 @@ import type {
} from "../plugin-sdk/provider-model-types.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { isValidSecretRef } from "../secrets/ref-contract.js";
import {
isConfiguredAwsSdkAuthProfileForProvider,
getRuntimeAuthProfileStoreSnapshot,
resolveAuthProfileEligibility,
} from "./auth-profiles.js";
import { hasUsableOAuthCredential } from "./auth-profiles/credential-state.js";
import { resolveExternalCliAuthProfiles } from "./auth-profiles/external-cli-sync.js";
import {
type AuthProfileOrderResolution,
isConfiguredAwsSdkAuthProfileForProvider,
resolveAuthProfileEligibility,
resolveAuthProfileOrderWithMetadata,
} from "./auth-profiles/order.js";
import {
@@ -31,6 +28,7 @@ import {
resolveSecretRefReadOnlyAvailability,
resolveStoredCredentialReadOnlyAvailability,
} from "./auth-profiles/read-only-availability.js";
import { getRuntimeAuthProfileStoreSnapshot } from "./auth-profiles/runtime-snapshots.js";
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import { isProfileInCooldown } from "./auth-profiles/usage-state.js";
import {
@@ -40,12 +38,12 @@ import {
import { resolveProviderEnvAuthEvidence } from "./model-auth-env.js";
import { isKnownEnvApiKeyMarker, isSecretRefHeaderValueMarker } from "./model-auth-markers.js";
import {
hasUsableCustomProviderApiKey,
hasRuntimeAvailableProviderAuth,
hasSyntheticLocalProviderAuthConfig,
hasUsableCustomProviderApiKey,
resolveProviderEntryApiKeyProfileReference,
shouldPreferExplicitConfigApiKeyAuth,
} from "./model-auth.js";
} from "./model-auth-provider-config.js";
import { resolveManagedSecretRefRuntimeProviderAuth } from "./model-auth-runtime-config.js";
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
import {
createOpenAIModelRoutesResolver,
@@ -538,14 +536,8 @@ export function createModelAuthAvailabilityResolver(
const managed = typeof apiKey === "string" && isSecretRefHeaderValueMarker(apiKey);
return {
availability: managed
? hasRuntimeAvailableProviderAuth({
provider,
modelApi: target.api ?? undefined,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
env,
allowPluginSyntheticAuth: false,
}) || undefined
? Boolean(resolveManagedSecretRefRuntimeProviderAuth({ provider, cfg: params.cfg })) ||
undefined
: undefined,
selectedAuthMode: configuredBearerMode,
evidence: managed ? "runtime" : "synthetic",
@@ -560,14 +552,9 @@ export function createModelAuthAvailabilityResolver(
};
}
const available = resolveSecretRefReadOnlyAvailability(apiKeyRef, params.cfg, env);
const runtimeAvailable = hasRuntimeAvailableProviderAuth({
provider,
modelApi: target.api ?? undefined,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
env,
allowPluginSyntheticAuth: false,
});
const runtimeAvailable = Boolean(
resolveManagedSecretRefRuntimeProviderAuth({ provider, cfg: params.cfg }),
);
return {
availability: runtimeAvailable ? true : available,
selectedAuthMode: configuredBearerMode,

View File

@@ -17,12 +17,10 @@ import { SecretSurfaceUnavailableError } from "../secrets/runtime-degraded-state
import { mintSecretSentinel } from "../secrets/sentinel.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import {
type AuthProfileCredential,
type AuthProfileStore,
isConfiguredAwsSdkAuthProfileForProvider,
isStoredCredentialCompatibleWithAuthProvider,
resolveApiKeyForProfile,
} from "./auth-profiles.js";
} from "./auth-profiles/order.js";
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import { resolveEnvApiKey, type EnvApiKeyResult } from "./model-auth-env.js";
import {
CUSTOM_LOCAL_AUTH_MARKER,
@@ -223,6 +221,35 @@ export function shouldPreferExplicitConfigApiKeyAuth(
);
}
/** True when a custom local provider can use a synthetic no-auth placeholder. */
export function hasSyntheticLocalProviderAuthConfig(params: {
cfg: OpenClawConfig | undefined;
provider: string;
}): boolean {
const providerConfig = resolveProviderConfig(params.cfg, params.provider);
if (!providerConfig) {
return false;
}
const hasApiConfig =
Boolean(providerConfig.api?.trim()) ||
Boolean(providerConfig.baseUrl?.trim()) ||
(Array.isArray(providerConfig.models) && providerConfig.models.length > 0);
if (!hasApiConfig) {
return false;
}
const authOverride = resolveProviderAuthOverride(params.cfg, params.provider);
if (authOverride && authOverride !== "api-key") {
return false;
}
if (
!isCustomLocalProviderConfig(providerConfig) ||
hasExplicitProviderApiKeyConfig(providerConfig)
) {
return false;
}
return Boolean(providerConfig.baseUrl && isLocalAuthProviderBaseUrl(providerConfig.baseUrl));
}
export function resolveProviderAuthOverride(
cfg: OpenClawConfig | undefined,
provider: string,
@@ -435,6 +462,7 @@ export async function resolveProviderEntryApiKeyBinding(params: {
return reference;
}
try {
const { resolveApiKeyForProfile } = await import("./auth-profiles/oauth.js");
const resolved = await resolveApiKeyForProfile({
cfg: params.cfg,
store: params.store,

View File

@@ -27,12 +27,12 @@ import { OAuthRefreshFailureError } from "./auth-profiles/oauth-refresh-failure.
import { isNonSecretApiKeyMarker } from "./model-auth-markers.js";
import { assertAuthModeAllowedForModel, isAuthModeAllowedForModel } from "./model-auth-openai.js";
import * as authConfig from "./model-auth-provider-config.js";
import { ProviderAuthError, type ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
import {
assertRuntimeProviderSecretOwnerAvailable,
resolveManagedSecretRefRuntimeProviderAuth,
resolveSyntheticLocalProviderAuth,
} from "./model-auth-runtime.js";
} from "./model-auth-runtime-config.js";
import { ProviderAuthError, type ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
import { resolveSyntheticLocalProviderAuth } from "./model-auth-runtime.js";
export type ProviderCredentialPrecedence = "profile-first" | "env-first";

View File

@@ -0,0 +1,89 @@
/**
* Runtime-config-backed provider auth that does not require plugin activation.
*/
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
} from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
findActiveDegradedSecretOwner,
SecretSurfaceUnavailableError,
} from "../secrets/runtime-degraded-state.js";
import { mintSecretSentinel } from "../secrets/sentinel.js";
import * as authConfig from "./model-auth-provider-config.js";
import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
/** Reads a runtime-resolved credential for a SecretRef-backed provider entry. */
export function resolveManagedSecretRefRuntimeProviderAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
secretSentinels?: boolean;
}): ResolvedProviderAuth | undefined {
const runtimeConfig = getRuntimeConfigSnapshot();
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
if (params.cfg && params.cfg !== runtimeConfig && !runtimeSourceConfig) {
return undefined;
}
const applicableConfig = selectApplicableRuntimeConfig({
inputConfig: params.cfg,
runtimeConfig,
runtimeSourceConfig,
});
const usesRuntimeProvider =
applicableConfig === runtimeConfig ||
authConfig.providerConfigMatchesRuntimeSnapshot({
inputConfig: params.cfg,
runtimeConfig,
provider: params.provider,
});
const sourceConfig = usesRuntimeProvider ? (runtimeSourceConfig ?? undefined) : params.cfg;
if (!authConfig.hasSecretRefProviderApiKey(sourceConfig, params.provider)) {
return undefined;
}
if (!runtimeConfig || !usesRuntimeProvider) {
return undefined;
}
const resolved = authConfig.resolveLiteralProviderConfigApiKeyAuth({
cfg: runtimeConfig,
provider: params.provider,
});
if (!resolved?.apiKey) {
return undefined;
}
return {
...resolved,
apiKey: params.secretSentinels
? mintSecretSentinel(resolved.apiKey, {
label: `model-auth:${params.provider}`,
})
: resolved.apiKey,
};
}
export function assertRuntimeProviderSecretOwnerAvailable(params: {
cfg: OpenClawConfig | undefined;
provider: string;
}): void {
const provider = normalizeProviderId(params.provider);
const degraded = findActiveDegradedSecretOwner("provider", provider);
if (!degraded) {
return;
}
const runtimeConfig = getRuntimeConfigSnapshot();
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
const usesRuntimeProvider =
!params.cfg ||
params.cfg === runtimeConfig ||
params.cfg === runtimeSourceConfig ||
authConfig.providerConfigMatchesRuntimeSnapshot({
inputConfig: params.cfg,
runtimeConfig,
provider,
});
if (usesRuntimeProvider) {
throw new SecretSurfaceUnavailableError(degraded);
}
}

View File

@@ -3,26 +3,24 @@
*/
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
} from "../config/config.js";
import { getRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveProviderSyntheticAuthWithPlugin } from "../plugins/provider-runtime.js";
import { resolveRuntimeSyntheticAuthProviderRefState } from "../plugins/synthetic-auth.runtime.js";
import {
findActiveDegradedSecretOwner,
SecretSurfaceUnavailableError,
} from "../secrets/runtime-degraded-state.js";
import { mintSecretSentinel } from "../secrets/sentinel.js";
import { resolveProviderEnvAuthLookupMaps } from "./model-auth-env-vars.js";
import { resolveEnvApiKey, type EnvApiKeyLookupOptions } from "./model-auth-env.js";
import { CUSTOM_LOCAL_AUTH_MARKER, isNonSecretApiKeyMarker } from "./model-auth-markers.js";
import { isAuthModeAllowedForModel } from "./model-auth-openai.js";
import * as authConfig from "./model-auth-provider-config.js";
import { resolveManagedSecretRefRuntimeProviderAuth } from "./model-auth-runtime-config.js";
import type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js";
export {
assertRuntimeProviderSecretOwnerAvailable,
resolveManagedSecretRefRuntimeProviderAuth,
} from "./model-auth-runtime-config.js";
/** Precomputed provider-auth lookup tables reused during one runtime turn. */
export type RuntimeProviderAuthLookup = {
envApiKey: Pick<
@@ -103,111 +101,6 @@ function resolveRuntimeEnvApiKeyLookupOptions(params: {
};
}
/** Reads a literal or env-secret marker for a custom provider entry. */
export function resolveManagedSecretRefRuntimeProviderAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
secretSentinels?: boolean;
}): ResolvedProviderAuth | undefined {
const runtimeConfig = getRuntimeConfigSnapshot();
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
if (params.cfg && params.cfg !== runtimeConfig && !runtimeSourceConfig) {
return undefined;
}
const applicableConfig = selectApplicableRuntimeConfig({
inputConfig: params.cfg,
runtimeConfig,
runtimeSourceConfig,
});
const usesRuntimeProvider =
applicableConfig === runtimeConfig ||
authConfig.providerConfigMatchesRuntimeSnapshot({
inputConfig: params.cfg,
runtimeConfig,
provider: params.provider,
});
const sourceConfig = usesRuntimeProvider ? (runtimeSourceConfig ?? undefined) : params.cfg;
if (!authConfig.hasSecretRefProviderApiKey(sourceConfig, params.provider)) {
return undefined;
}
if (!runtimeConfig || !usesRuntimeProvider) {
return undefined;
}
const resolved = authConfig.resolveLiteralProviderConfigApiKeyAuth({
cfg: runtimeConfig,
provider: params.provider,
});
if (!resolved?.apiKey) {
return undefined;
}
return {
...resolved,
apiKey: params.secretSentinels
? mintSecretSentinel(resolved.apiKey, {
label: `model-auth:${params.provider}`,
})
: resolved.apiKey,
};
}
export function assertRuntimeProviderSecretOwnerAvailable(params: {
cfg: OpenClawConfig | undefined;
provider: string;
}): void {
const provider = normalizeProviderId(params.provider);
const degraded = findActiveDegradedSecretOwner("provider", provider);
if (!degraded) {
return;
}
const runtimeConfig = getRuntimeConfigSnapshot();
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot();
const usesRuntimeProvider =
!params.cfg ||
params.cfg === runtimeConfig ||
params.cfg === runtimeSourceConfig ||
authConfig.providerConfigMatchesRuntimeSnapshot({
inputConfig: params.cfg,
runtimeConfig,
provider,
});
if (usesRuntimeProvider) {
throw new SecretSurfaceUnavailableError(degraded);
}
}
/** True when a custom local provider can use a synthetic no-auth placeholder. */
export function hasSyntheticLocalProviderAuthConfig(params: {
cfg: OpenClawConfig | undefined;
provider: string;
}): boolean {
const providerConfig = authConfig.resolveProviderConfig(params.cfg, params.provider);
if (!providerConfig) {
return false;
}
const hasApiConfig =
Boolean(providerConfig.api?.trim()) ||
Boolean(providerConfig.baseUrl?.trim()) ||
(Array.isArray(providerConfig.models) && providerConfig.models.length > 0);
if (!hasApiConfig) {
return false;
}
const authOverride = authConfig.resolveProviderAuthOverride(params.cfg, params.provider);
if (authOverride && authOverride !== "api-key") {
return false;
}
if (!authConfig.isCustomLocalProviderConfig(providerConfig)) {
return false;
}
if (authConfig.hasExplicitProviderApiKeyConfig(providerConfig)) {
return false;
}
return Boolean(
providerConfig.baseUrl && authConfig.isLocalAuthProviderBaseUrl(providerConfig.baseUrl),
);
}
function listProviderSyntheticAuthRefs(params: {
cfg: OpenClawConfig | undefined;
provider: string;
@@ -288,7 +181,7 @@ export function hasRuntimeAvailableProviderAuth(params: {
if (resolveManagedSecretRefRuntimeProviderAuth({ cfg: params.cfg, provider })) {
return true;
}
if (hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider })) {
if (authConfig.hasSyntheticLocalProviderAuthConfig({ cfg: params.cfg, provider })) {
return true;
}
if (
@@ -398,7 +291,7 @@ export function resolveSyntheticLocalProviderAuth(params: {
// Custom providers pointing at a local server (e.g. llama.cpp, vLLM, LocalAI)
// typically don't require auth. Synthesize a local key so the auth resolver
// doesn't reject them when the user left the API key blank during setup.
if (hasSyntheticLocalProviderAuthConfig(params)) {
if (authConfig.hasSyntheticLocalProviderAuthConfig(params)) {
return {
apiKey: CUSTOM_LOCAL_AUTH_MARKER,
source: `models.providers.${params.provider} (synthetic local key)`,

View File

@@ -20,6 +20,7 @@ export type { ModelAuthMode } from "./model-auth-model.js";
export {
canUseProfileAsProviderEntryApiKey,
getCustomProviderApiKey,
hasSyntheticLocalProviderAuthConfig,
hasUsableCustomProviderApiKey,
resolveProviderEntryApiKeyBinding,
resolveProviderEntryApiKeyProfileReference,
@@ -32,7 +33,6 @@ export type { ProviderCredentialPrecedence } from "./model-auth-provider.js";
export {
createRuntimeProviderAuthLookup,
hasRuntimeAvailableProviderAuth,
hasSyntheticLocalProviderAuthConfig,
} from "./model-auth-runtime.js";
export type { RuntimeProviderAuthLookup } from "./model-auth-runtime.js";
export {

View File

@@ -15,7 +15,7 @@ import {
type SupportedGatewaySecretInputPath,
} from "../gateway/secret-input-paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveManifestContractOwnerPluginId } from "../plugins/plugin-registry.js";
import { resolveManifestContractOwnerPluginId } from "../plugins/plugin-registry-contributions.js";
import {
analyzeCommandSecretAssignmentsFromSnapshot,
type UnresolvedCommandSecretAssignment,

View File

@@ -7,41 +7,32 @@ import { createModelListAuthIndex } from "./list.auth-index.js";
type ExternalCliProfilesResolver =
typeof import("../../agents/auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles;
type PluginSnapshotResult = {
source: "persisted" | "provided" | "derived";
snapshot: {
plugins: Array<{ enabled?: boolean; syntheticAuthRefs?: string[] }>;
};
diagnostics: [];
};
const pluginRegistryMocks = vi.hoisted(() => ({
loadPluginRegistrySnapshotWithMetadata: vi.fn(
(): PluginSnapshotResult => ({
source: "persisted",
snapshot: { plugins: [] },
diagnostics: [],
}),
),
}));
const externalCliMocks = vi.hoisted(() => ({
resolveExternalCliAuthProfiles: vi.fn<ExternalCliProfilesResolver>(() => []),
}));
vi.mock("../../plugins/plugin-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../plugins/plugin-registry.js")>();
return {
...actual,
loadPluginRegistrySnapshotWithMetadata:
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata,
};
});
vi.mock("../../agents/auth-profiles/external-cli-sync.js", () => ({
resolveExternalCliAuthProfiles: externalCliMocks.resolveExternalCliAuthProfiles,
}));
const emptyStore: AuthProfileStore = { version: 1, profiles: {} };
const emptyMetadataSnapshot = {
registrySource: "persisted",
registryDiagnostics: [],
plugins: [],
index: { plugins: [] },
} as unknown as PluginMetadataSnapshot;
function createTestModelListAuthIndex(
params: Omit<Parameters<typeof createModelListAuthIndex>[0], "metadataSnapshot"> & {
metadataSnapshot?: PluginMetadataSnapshot;
},
) {
return createModelListAuthIndex({
metadataSnapshot: emptyMetadataSnapshot,
...params,
});
}
const dualRouteResolverFactory = (() => () => ({
kind: "routes",
@@ -68,16 +59,10 @@ describe("createModelListAuthIndex", () => {
beforeEach(() => {
externalCliMocks.resolveExternalCliAuthProfiles.mockReset();
externalCliMocks.resolveExternalCliAuthProfiles.mockReturnValue([]);
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset();
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: { plugins: [] },
diagnostics: [],
});
});
it("keeps a fresh auth scope empty", () => {
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: {},
@@ -88,7 +73,7 @@ describe("createModelListAuthIndex", () => {
});
it("scopes provider discovery to concrete API-key evidence", () => {
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: { ANTHROPIC_API_KEY: "test-key" },
@@ -111,7 +96,7 @@ describe("createModelListAuthIndex", () => {
},
},
]);
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: {},
@@ -123,7 +108,7 @@ describe("createModelListAuthIndex", () => {
});
it("scopes provider discovery to stored credential profiles", () => {
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: {
version: 1,
@@ -143,7 +128,7 @@ describe("createModelListAuthIndex", () => {
});
it("scopes provider discovery to configured auth profiles", () => {
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {
auth: {
profiles: {
@@ -163,7 +148,7 @@ describe("createModelListAuthIndex", () => {
});
it("forwards route-aware evaluation through the command adapter", () => {
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: {
version: 1,
@@ -188,21 +173,23 @@ describe("createModelListAuthIndex", () => {
expect(index.providerDiscoveryProviderIds).toEqual(["openai"]);
});
it("uses enabled synthetic refs from a persisted plugin snapshot", () => {
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: {
it("uses enabled synthetic refs from prepared persisted metadata", () => {
const metadataSnapshot = {
registrySource: "persisted",
registryDiagnostics: [],
plugins: [],
index: {
plugins: [
{ enabled: true, syntheticAuthRefs: ["codex"] },
{ enabled: false, syntheticAuthRefs: ["disabled-provider"] },
],
},
diagnostics: [],
});
const index = createModelListAuthIndex({
} as unknown as PluginMetadataSnapshot;
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: {},
metadataSnapshot,
routeResolverFactory: dualRouteResolverFactory,
});
@@ -227,7 +214,7 @@ describe("createModelListAuthIndex", () => {
],
},
} as unknown as PluginMetadataSnapshot;
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: {},
@@ -240,7 +227,6 @@ describe("createModelListAuthIndex", () => {
evidence: "synthetic",
});
expect(index.evaluateModelAuth("disabled-provider").availability).toBeUndefined();
expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
});
it("maps synthetic auth refs to their configured plugin catalog providers", () => {
@@ -262,7 +248,7 @@ describe("createModelListAuthIndex", () => {
],
},
} as unknown as PluginMetadataSnapshot;
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: {},
@@ -277,17 +263,19 @@ describe("createModelListAuthIndex", () => {
it.each(["derived" as const, "persisted" as const])(
"does not trust unusable synthetic refs from a %s snapshot",
(source) => {
pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source,
snapshot: {
const metadataSnapshot = {
registrySource: source,
registryDiagnostics: [],
plugins: [],
index: {
plugins: [{ enabled: source === "derived", syntheticAuthRefs: ["codex"] }],
},
diagnostics: [],
});
const index = createModelListAuthIndex({
} as unknown as PluginMetadataSnapshot;
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: {},
metadataSnapshot,
routeResolverFactory: dualRouteResolverFactory,
});
@@ -302,7 +290,7 @@ describe("createModelListAuthIndex", () => {
registrySource: "persisted",
plugins: [],
} as unknown as PluginMetadataSnapshot;
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: {},
@@ -312,7 +300,6 @@ describe("createModelListAuthIndex", () => {
});
expect(index.evaluateModelAuth("openai").evidence).toBe("synthetic");
expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
});
it("fails closed before loading refs from a diagnostic-bearing metadata snapshot", () => {
@@ -321,7 +308,7 @@ describe("createModelListAuthIndex", () => {
plugins: [{ enabled: true, syntheticAuthRefs: ["codex"] }],
registryDiagnostics: [{ level: "error", message: "invalid plugin metadata" }],
} as unknown as PluginMetadataSnapshot;
const index = createModelListAuthIndex({
const index = createTestModelListAuthIndex({
cfg: {},
authStore: emptyStore,
env: {},
@@ -330,6 +317,5 @@ describe("createModelListAuthIndex", () => {
});
expect(index.evaluateModelAuth("openai").availability).toBe(false);
expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled();
});
});

View File

@@ -8,7 +8,6 @@ import {
import type { 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";
export type ModelListAuthRef = ModelAuthAvailabilityRef;
export type ModelListAuthEvaluation = ModelAuthAvailabilityEvaluation;
@@ -25,41 +24,22 @@ type CreateModelListAuthIndexParams = {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
syntheticAuthProviderRefs?: readonly string[];
metadataSnapshot?: PluginMetadataSnapshot;
metadataSnapshot: PluginMetadataSnapshot;
externalCliProviderIds?: readonly string[];
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
};
function listValidatedSyntheticAuthProviderRefs(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
metadataSnapshot?: PluginMetadataSnapshot;
metadataSnapshot: PluginMetadataSnapshot;
}): readonly string[] {
if (params.metadataSnapshot) {
if (
params.metadataSnapshot.registryDiagnostics.length > 0 ||
(params.metadataSnapshot.registrySource !== "persisted" &&
params.metadataSnapshot.registrySource !== "provided")
) {
return [];
}
return params.metadataSnapshot.index.plugins
.filter((plugin) => plugin.enabled)
.flatMap((plugin) => plugin.syntheticAuthRefs ?? []);
}
const result = loadPluginRegistrySnapshotWithMetadata({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (
result.diagnostics.length > 0 ||
(result.source !== "persisted" && result.source !== "provided")
params.metadataSnapshot.registryDiagnostics.length > 0 ||
(params.metadataSnapshot.registrySource !== "persisted" &&
params.metadataSnapshot.registrySource !== "provided")
) {
return [];
}
return result.snapshot.plugins
return params.metadataSnapshot.index.plugins
.filter((plugin) => plugin.enabled)
.flatMap((plugin) => plugin.syntheticAuthRefs ?? []);
}
@@ -81,9 +61,6 @@ export function createModelListAuthIndex(
syntheticAuthProviderRefs:
params.syntheticAuthProviderRefs ??
listValidatedSyntheticAuthProviderRefs({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
env,
metadataSnapshot: params.metadataSnapshot,
}),
});

View File

@@ -120,12 +120,21 @@ export async function modelsListCommand(
// The default configured view remains lazy; full and filtered views share
// the registry and the same committed model generation as the Gateway.
const includePreparedCatalog = Boolean(opts.all || providerFilter);
const providerDiscoveryProviderIds =
opts.all && !providerFilter
? undefined
: providerFilter
? [providerFilter]
: authIndex.providerDiscoveryProviderIds;
const providerDiscoveryProviderIds = (() => {
if (opts.all && !providerFilter) {
return undefined;
}
if (providerFilter) {
return [providerFilter];
}
return [
...new Set([
...(authIndex.providerDiscoveryProviderIds ?? []),
...entries.map((entry) => entry.ref.provider),
...Object.keys(cfg.models?.providers ?? {}),
]),
].toSorted((left, right) => left.localeCompare(right));
})();
const loadRegistryState = async (optsLocal?: {
normalizeModels?: boolean;
loadAvailability?: boolean;

View File

@@ -7,12 +7,12 @@ import type { ManifestModelCatalogRowSelection } from "../../model-catalog/manif
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { resolvePluginContributionOwners } from "../../plugins/plugin-registry-contributions.js";
import {
getPluginRecord,
isPluginEnabled,
resolvePluginContributionOwners,
type PluginRegistrySnapshot,
} from "../../plugins/plugin-registry.js";
} from "../../plugins/plugin-registry-snapshot.js";
function planManifestCatalogRowsForPluginIds(params: {
cfg: OpenClawConfig;

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadPersistedPluginModelCatalogsReadOnly: vi.fn(),
isGeneratedPluginModelCatalog: vi.fn(
(value: unknown) =>
typeof value === "object" &&
value !== null &&
(value as { generatedBy?: unknown }).generatedBy === "openclaw-plugin-model-catalog-v1",
),
filterGeneratedPluginModelCatalogProviders: vi.fn(
({ providers }: { providers: Record<string, unknown> }) => providers,
),
}));
vi.mock("../../agents/plugin-model-catalog.js", () => ({
loadPersistedPluginModelCatalogsReadOnly: mocks.loadPersistedPluginModelCatalogsReadOnly,
isGeneratedPluginModelCatalog: mocks.isGeneratedPluginModelCatalog,
filterGeneratedPluginModelCatalogProviders: mocks.filterGeneratedPluginModelCatalogProviders,
}));
import { loadPersistedListCatalogEntries } from "./list.persisted-catalog.js";
beforeEach(() => {
vi.clearAllMocks();
});
describe("loadPersistedListCatalogEntries", () => {
it("projects valid generated provider rows with provider transport defaults", () => {
mocks.loadPersistedPluginModelCatalogsReadOnly.mockReturnValueOnce([
{
pluginId: "openai",
contents: JSON.stringify({
generatedBy: "openclaw-plugin-model-catalog-v1",
providers: {
openai: {
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
models: [
{
id: "gpt-5.6",
name: "GPT-5.6",
input: ["text", "image", "invalid"],
reasoning: true,
contextWindow: 400_000,
},
],
},
},
}),
},
]);
expect(
loadPersistedListCatalogEntries({
agentDir: "/tmp/openclaw-agent",
providerIds: new Set(["openai"]),
}),
).toEqual([
{
provider: "openai",
id: "gpt-5.6",
name: "GPT-5.6",
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
input: ["text", "image"],
reasoning: true,
contextWindow: 400_000,
},
]);
});
it("skips malformed, unscoped, and transportless rows", () => {
mocks.loadPersistedPluginModelCatalogsReadOnly.mockReturnValueOnce([
{ pluginId: "bad-json", contents: "{" },
{
pluginId: "catalog",
contents: JSON.stringify({
generatedBy: "openclaw-plugin-model-catalog-v1",
providers: {
openai: { models: [{ id: "missing-api" }] },
google: {
api: "google-generative-ai",
models: [{ id: "gemini-live" }],
},
},
}),
},
]);
expect(
loadPersistedListCatalogEntries({
agentDir: "/tmp/openclaw-agent",
providerIds: new Set(["openai"]),
}),
).toEqual([]);
});
});

View File

@@ -0,0 +1,113 @@
/** Reads persisted generated catalogs without constructing a model registry. */
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { ModelCatalogEntry, ModelInputType } from "../../agents/model-catalog.types.js";
import {
filterGeneratedPluginModelCatalogProviders,
isGeneratedPluginModelCatalog,
loadPersistedPluginModelCatalogsReadOnly,
} from "../../agents/plugin-model-catalog.js";
import { MODEL_APIS, type ModelApi } from "../../config/types.models.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
const modelApis = new Set<string>(MODEL_APIS);
const modelInputs = new Set<ModelInputType>(["text", "image", "audio", "video", "document"]);
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function readModelApi(value: unknown): ModelApi | undefined {
const api = readString(value);
return api && modelApis.has(api) ? (api as ModelApi) : undefined;
}
function readModelInput(value: unknown): ModelInputType[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const input = value.filter(
(item): item is ModelInputType =>
typeof item === "string" && modelInputs.has(item as ModelInputType),
);
return input.length > 0 ? input : undefined;
}
function parseProviderModels(params: {
providerId: string;
provider: unknown;
}): ModelCatalogEntry[] {
if (!isRecord(params.provider) || !Array.isArray(params.provider.models)) {
return [];
}
const providerApi = readModelApi(params.provider.api);
const providerBaseUrl = readString(params.provider.baseUrl);
return params.provider.models.flatMap((model) => {
if (!isRecord(model)) {
return [];
}
const id = readString(model.id);
const api = readModelApi(model.api) ?? providerApi;
if (!id || !api) {
return [];
}
const baseUrl = readString(model.baseUrl) ?? providerBaseUrl;
const input = readModelInput(model.input);
const contextWindow = readPositiveNumber(model.contextWindow);
const contextTokens = readPositiveNumber(model.contextTokens);
return [
{
id,
name: readString(model.name) ?? id,
provider: normalizeProviderId(params.providerId),
api,
...(baseUrl ? { baseUrl } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(contextTokens !== undefined ? { contextTokens } : {}),
...(typeof model.reasoning === "boolean" ? { reasoning: model.reasoning } : {}),
...(input ? { input } : {}),
},
];
});
}
/** Loads valid provider-owned rows from the agent's generated catalog cache. */
export function loadPersistedListCatalogEntries(params: {
agentDir: string;
providerIds: ReadonlySet<string>;
metadataSnapshot?: PluginMetadataSnapshot;
}): ModelCatalogEntry[] {
const entries: ModelCatalogEntry[] = [];
for (const catalog of loadPersistedPluginModelCatalogsReadOnly(params.agentDir)) {
let parsed: unknown;
try {
parsed = JSON.parse(catalog.contents) as unknown;
} catch {
continue;
}
if (
!isRecord(parsed) ||
!isGeneratedPluginModelCatalog(parsed) ||
!isRecord(parsed.providers)
) {
continue;
}
const providers = filterGeneratedPluginModelCatalogProviders({
catalogPluginId: catalog.pluginId,
parsedCatalog: parsed,
pluginMetadataSnapshot: params.metadataSnapshot,
providers: parsed.providers,
});
for (const [providerId, provider] of Object.entries(providers)) {
if (!params.providerIds.has(normalizeProviderId(providerId))) {
continue;
}
entries.push(...parseProviderModels({ providerId, provider }));
}
}
return entries;
}

View File

@@ -268,7 +268,12 @@ describe("appendPreparedModelCatalogRows", () => {
expect(requireOnlyRow(rows).key).toBe("anthropic/claude-opus-4-7");
expect(mocks.loadScopedModelCatalogSnapshot).toHaveBeenCalledExactlyOnceWith({
cfg: {},
agentId: "worker",
agentDir: "/tmp/openclaw-worker",
inheritedAuthDir: "/tmp/openclaw-default",
workspaceDir: "/tmp/openclaw-workspace",
providerIds: ["anthropic"],
configuredKeys: [],
});
expect(mocks.loadModelCatalogSnapshot).not.toHaveBeenCalled();
});
@@ -939,7 +944,11 @@ describe("appendAuthenticatedCatalogRows", () => {
});
expect(mocks.loadScopedModelCatalogSnapshot).toHaveBeenCalledWith({
cfg: {},
agentDir: "/tmp/openclaw-agent",
inheritedAuthDir: "/tmp/openclaw-agent",
workspaceDir: "/tmp/openclaw-workspace",
providerIds: ["local-openai"],
configuredKeys: [],
});
});

View File

@@ -474,7 +474,12 @@ export async function loadListModelCatalogSnapshot(
const { loadScopedListModelCatalogSnapshot } = await loadScopedModelCatalogModule();
return loadScopedListModelCatalogSnapshot({
cfg: context.cfg,
...(context.agentId ? { agentId: context.agentId } : {}),
agentDir: context.agentDir,
inheritedAuthDir: context.inheritedAuthDir ?? context.agentDir,
...(workspaceDir ? { workspaceDir } : {}),
providerIds: context.providerDiscoveryProviderIds,
configuredKeys: [...context.configuredByKey.keys()],
...(context.metadataSnapshot ? { metadataSnapshot: context.metadataSnapshot } : {}),
});
}

View File

@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadManifestCatalogRowsForList: vi.fn(),
loadStaticManifestCatalogRowsForList: vi.fn(),
loadPersistedListCatalogEntries: vi.fn(),
prepareScopedReadOnlyModelCatalog: vi.fn(),
}));
vi.mock("./list.manifest-catalog.js", () => ({
@@ -10,6 +12,14 @@ vi.mock("./list.manifest-catalog.js", () => ({
loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList,
}));
vi.mock("./list.persisted-catalog.js", () => ({
loadPersistedListCatalogEntries: mocks.loadPersistedListCatalogEntries,
}));
vi.mock("../../agents/prepared-model-runtime.scoped-catalog.js", () => ({
prepareScopedReadOnlyModelCatalog: mocks.prepareScopedReadOnlyModelCatalog,
}));
import { loadScopedListModelCatalogSnapshot } from "./list.scoped-catalog.js";
const runtimeRow = {
@@ -43,23 +53,39 @@ beforeEach(() => {
vi.clearAllMocks();
mocks.loadManifestCatalogRowsForList.mockReturnValue([runtimeRow, staticRow]);
mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([staticRow]);
mocks.loadPersistedListCatalogEntries.mockReturnValue([]);
mocks.prepareScopedReadOnlyModelCatalog.mockResolvedValue({
entries: [],
routeVariants: [],
});
});
describe("loadScopedListModelCatalogSnapshot", () => {
it("returns an empty snapshot without loading manifest catalogs for an empty auth scope", () => {
expect(loadScopedListModelCatalogSnapshot({ cfg: {}, providerIds: [] })).toEqual({
it("returns an empty snapshot without loading catalog sources for an empty scope", async () => {
await expect(
loadScopedListModelCatalogSnapshot({
cfg: {},
agentDir: "/tmp/openclaw-agent",
providerIds: [],
configuredKeys: [],
}),
).resolves.toEqual({
entries: [],
routeVariants: [],
staticEntries: [],
});
expect(mocks.loadManifestCatalogRowsForList).not.toHaveBeenCalled();
expect(mocks.loadStaticManifestCatalogRowsForList).not.toHaveBeenCalled();
expect(mocks.loadPersistedListCatalogEntries).not.toHaveBeenCalled();
expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled();
});
it("admits runtime manifest rows only for authenticated providers", () => {
const snapshot = loadScopedListModelCatalogSnapshot({
it("uses runtime manifest rows to enrich configured model ids", async () => {
const snapshot = await loadScopedListModelCatalogSnapshot({
cfg: {},
agentDir: "/tmp/openclaw-agent",
providerIds: ["openai"],
configuredKeys: ["openai/gpt-5.6"],
});
expect(snapshot.entries).toEqual([
@@ -72,17 +98,95 @@ describe("loadScopedListModelCatalogSnapshot", () => {
]);
expect(snapshot.routeVariants).toEqual(snapshot.entries);
expect(snapshot.staticEntries).toEqual([]);
expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled();
});
it("keeps static rows in the scoped snapshot", () => {
const snapshot = loadScopedListModelCatalogSnapshot({
it("keeps static rows in the scoped snapshot", async () => {
const snapshot = await loadScopedListModelCatalogSnapshot({
cfg: {},
agentDir: "/tmp/openclaw-agent",
providerIds: ["moonshot"],
configuredKeys: [],
});
expect(snapshot.entries).toEqual([
expect.objectContaining({ provider: "moonshot", id: "kimi-k2.6" }),
]);
expect(snapshot.staticEntries).toEqual(snapshot.entries);
expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled();
});
it("uses persisted runtime rows and manifest metadata without loading provider runtimes", async () => {
mocks.loadPersistedListCatalogEntries.mockReturnValueOnce([
{
provider: "openai",
id: "gpt-5.6",
name: "Account GPT-5.6",
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
},
]);
const snapshot = await loadScopedListModelCatalogSnapshot({
cfg: {},
agentDir: "/tmp/openclaw-agent",
providerIds: ["openai"],
configuredKeys: [],
});
expect(snapshot.entries).toEqual([
expect.objectContaining({
provider: "openai",
id: "gpt-5.6",
name: "Account GPT-5.6",
api: "openai-chatgpt-responses",
contextWindow: 1_050_000,
}),
]);
expect(snapshot.routeVariants).toEqual(
expect.arrayContaining([
expect.objectContaining({ api: "openai-chatgpt-responses" }),
expect.objectContaining({ api: "openai-responses" }),
]),
);
expect(mocks.prepareScopedReadOnlyModelCatalog).not.toHaveBeenCalled();
});
it("falls back to scoped provider discovery only for providers with no lightweight rows", async () => {
const discovered = {
provider: "google",
id: "gemini-live",
name: "Gemini Live",
api: "google-generative-ai" as const,
};
mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]);
mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([]);
mocks.prepareScopedReadOnlyModelCatalog.mockResolvedValueOnce({
entries: [discovered],
routeVariants: [discovered],
});
const snapshot = await loadScopedListModelCatalogSnapshot({
cfg: {},
agentId: "main",
agentDir: "/tmp/openclaw-agent",
inheritedAuthDir: "/tmp/openclaw-default",
workspaceDir: "/tmp/openclaw-workspace",
providerIds: ["google"],
configuredKeys: [],
});
expect(snapshot.entries).toEqual([discovered]);
expect(mocks.prepareScopedReadOnlyModelCatalog).toHaveBeenCalledWith(
{
config: {},
agentId: "main",
agentDir: "/tmp/openclaw-agent",
inheritedAuthDir: "/tmp/openclaw-default",
workspaceDir: "/tmp/openclaw-workspace",
readOnly: true,
},
["google"],
);
});
});

View File

@@ -2,13 +2,26 @@ import type { NormalizedModelCatalogRow } from "@openclaw/model-catalog-core/mod
/** Dependency-light model catalog snapshots for default model-list views. */
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js";
import { modelKey } from "../../agents/model-ref-shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import {
loadManifestCatalogRowsForList,
loadStaticManifestCatalogRowsForList,
} from "./list.manifest-catalog.js";
type PersistedCatalogModule = typeof import("./list.persisted-catalog.js");
type PreparedScopedCatalogModule =
typeof import("../../agents/prepared-model-runtime.scoped-catalog.js");
const persistedCatalogModuleLoader = createLazyImportLoader<PersistedCatalogModule>(
() => import("./list.persisted-catalog.js"),
);
const preparedScopedCatalogModuleLoader = createLazyImportLoader<PreparedScopedCatalogModule>(
() => import("../../agents/prepared-model-runtime.scoped-catalog.js"),
);
function toCatalogEntry(row: NormalizedModelCatalogRow): ModelCatalogEntry {
return {
id: row.id,
@@ -36,12 +49,85 @@ function selectProviderRows(
return rows.filter((row) => providerIds.has(normalizeProviderId(row.provider)));
}
function entryKey(entry: Pick<ModelCatalogEntry, "provider" | "id">): string {
return modelKey(normalizeProviderId(entry.provider), entry.id.trim().toLowerCase());
}
function routeKey(entry: ModelCatalogEntry): string {
return `${entryKey(entry)}\0${entry.api ?? ""}\0${entry.baseUrl ?? ""}`;
}
function enrichPersistedEntry(
entry: ModelCatalogEntry,
manifestEntry: ModelCatalogEntry | undefined,
): ModelCatalogEntry {
if (!manifestEntry) {
return entry;
}
return {
...manifestEntry,
...entry,
name: entry.name || manifestEntry.name,
...(entry.contextWindow === undefined && manifestEntry.contextWindow !== undefined
? { contextWindow: manifestEntry.contextWindow }
: {}),
...(entry.contextTokens === undefined && manifestEntry.contextTokens !== undefined
? { contextTokens: manifestEntry.contextTokens }
: {}),
...(entry.reasoning === undefined && manifestEntry.reasoning !== undefined
? { reasoning: manifestEntry.reasoning }
: {}),
...(entry.input === undefined && manifestEntry.input !== undefined
? { input: manifestEntry.input }
: {}),
};
}
function mergeSnapshotEntries(snapshots: readonly ModelCatalogSnapshot[]): ModelCatalogSnapshot {
const entries = new Map<string, ModelCatalogEntry>();
const staticEntries = new Map<string, ModelCatalogEntry>();
const routeVariants = new Map<string, ModelCatalogEntry>();
for (const snapshot of snapshots) {
for (const entry of snapshot.entries) {
entries.set(entryKey(entry), entry);
}
for (const entry of snapshot.staticEntries ?? []) {
staticEntries.set(entryKey(entry), entry);
}
for (const entry of snapshot.routeVariants) {
routeVariants.set(routeKey(entry), entry);
}
}
return {
entries: [...entries.values()].toSorted(
(left, right) =>
left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id),
),
routeVariants: [...routeVariants.values()].toSorted(
(left, right) =>
left.provider.localeCompare(right.provider) ||
left.id.localeCompare(right.id) ||
(left.api ?? "").localeCompare(right.api ?? "") ||
(left.baseUrl ?? "").localeCompare(right.baseUrl ?? ""),
),
staticEntries: [...staticEntries.values()].toSorted(
(left, right) =>
left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id),
),
};
}
/** Builds an auth-scoped snapshot from manifest metadata already loaded by the command. */
export function loadScopedListModelCatalogSnapshot(params: {
export async function loadScopedListModelCatalogSnapshot(params: {
cfg: OpenClawConfig;
agentId?: string;
agentDir: string;
inheritedAuthDir?: string;
workspaceDir?: string;
providerIds: readonly string[];
configuredKeys: readonly string[];
metadataSnapshot?: PluginMetadataSnapshot;
}): ModelCatalogSnapshot {
}): Promise<ModelCatalogSnapshot> {
const providerIds = new Set(params.providerIds.map(normalizeProviderId).filter(Boolean));
if (providerIds.size === 0) {
return { entries: [], routeVariants: [], staticEntries: [] };
@@ -50,16 +136,61 @@ export function loadScopedListModelCatalogSnapshot(params: {
cfg: params.cfg,
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
};
const entries = selectProviderRows(loadManifestCatalogRowsForList(loaderParams), providerIds).map(
toCatalogEntry,
);
const manifestEntries = selectProviderRows(
loadManifestCatalogRowsForList(loaderParams),
providerIds,
).map(toCatalogEntry);
const manifestByKey = new Map(manifestEntries.map((entry) => [entryKey(entry), entry]));
const configuredKeys = new Set(params.configuredKeys.map((key) => key.trim().toLowerCase()));
const staticEntries = selectProviderRows(
loadStaticManifestCatalogRowsForList(loaderParams),
providerIds,
).map(toCatalogEntry);
return {
entries,
routeVariants: [...entries],
const { loadPersistedListCatalogEntries } = await persistedCatalogModuleLoader.load();
const persistedEntries = loadPersistedListCatalogEntries({
agentDir: params.agentDir,
providerIds,
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
}).map((entry) => enrichPersistedEntry(entry, manifestByKey.get(entryKey(entry))));
const admittedEntries = new Map<string, ModelCatalogEntry>();
for (const entry of [...staticEntries, ...persistedEntries]) {
admittedEntries.set(entryKey(entry), entry);
}
for (const entry of manifestEntries) {
if (configuredKeys.has(entryKey(entry)) && !admittedEntries.has(entryKey(entry))) {
admittedEntries.set(entryKey(entry), entry);
}
}
const admittedKeys = new Set(admittedEntries.keys());
const routeVariants = [...persistedEntries];
for (const entry of manifestEntries) {
if (admittedKeys.has(entryKey(entry))) {
routeVariants.push(entry);
}
}
const lightweightSnapshot: ModelCatalogSnapshot = {
entries: [...admittedEntries.values()],
routeVariants,
staticEntries,
};
const coveredProviders = new Set(
lightweightSnapshot.entries.map((entry) => normalizeProviderId(entry.provider)),
);
const uncoveredProviders = [...providerIds].filter((provider) => !coveredProviders.has(provider));
if (uncoveredProviders.length === 0) {
return mergeSnapshotEntries([lightweightSnapshot]);
}
const { prepareScopedReadOnlyModelCatalog } = await preparedScopedCatalogModuleLoader.load();
const fallbackSnapshot = await prepareScopedReadOnlyModelCatalog(
{
config: params.cfg,
...(params.agentId ? { agentId: params.agentId } : {}),
agentDir: params.agentDir,
inheritedAuthDir: params.inheritedAuthDir ?? params.agentDir,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
readOnly: true,
},
uncoveredProviders,
);
return mergeSnapshotEntries([lightweightSnapshot, fallbackSnapshot]);
}

View File

@@ -16,7 +16,7 @@ import {
type ModelAuthAvailabilityEvaluation,
type ModelAuthAvailabilityResolver,
} from "../../agents/model-auth-availability.js";
import { hasSyntheticLocalProviderAuthConfig } from "../../agents/model-auth.js";
import { hasSyntheticLocalProviderAuthConfig } from "../../agents/model-auth-provider-config.js";
import {
buildProviderConfigModelCatalogForBrowse,
loadPreparedModelCatalogSnapshotForBrowse,

View File

@@ -21,8 +21,8 @@ const { loadPluginRegistrySnapshotWithMetadata, loadPluginManifestRegistryForIns
};
});
vi.mock("./plugin-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-registry.js")>();
vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-registry-snapshot.js")>();
return {
...actual,
loadPluginRegistrySnapshotWithMetadata: (params: unknown) =>

View File

@@ -23,7 +23,7 @@ import type {
ResolvePluginMetadataSnapshotParams,
} from "./plugin-metadata-snapshot.types.js";
import { createPluginRegistryIdNormalizer } from "./plugin-registry-id-normalizer.js";
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry.js";
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js";
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
const PLUGIN_METADATA_ENV_KEYS = [