diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts index d1773f890158..f37de0accd30 100644 --- a/src/agents/model-auth-availability.ts +++ b/src/agents/model-auth-availability.ts @@ -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, diff --git a/src/agents/model-auth-provider-config.ts b/src/agents/model-auth-provider-config.ts index 23a9c831f967..821cb61a3468 100644 --- a/src/agents/model-auth-provider-config.ts +++ b/src/agents/model-auth-provider-config.ts @@ -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, diff --git a/src/agents/model-auth-provider.ts b/src/agents/model-auth-provider.ts index a77ddd599f31..bbb1519f8a44 100644 --- a/src/agents/model-auth-provider.ts +++ b/src/agents/model-auth-provider.ts @@ -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"; diff --git a/src/agents/model-auth-runtime-config.ts b/src/agents/model-auth-runtime-config.ts new file mode 100644 index 000000000000..f6f1daa55c87 --- /dev/null +++ b/src/agents/model-auth-runtime-config.ts @@ -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); + } +} diff --git a/src/agents/model-auth-runtime.ts b/src/agents/model-auth-runtime.ts index 167ea07bf200..7f5a7a91f5a7 100644 --- a/src/agents/model-auth-runtime.ts +++ b/src/agents/model-auth-runtime.ts @@ -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)`, diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index eda5d0c96a67..7f5248c6b32d 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -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 { diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 7cb1db4ec993..b71f0da97f17 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -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, diff --git a/src/commands/models/list.auth-index.test.ts b/src/commands/models/list.auth-index.test.ts index 392d7b2615be..787cebc5b0af 100644 --- a/src/commands/models/list.auth-index.test.ts +++ b/src/commands/models/list.auth-index.test.ts @@ -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(() => []), })); -vi.mock("../../plugins/plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); - 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[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(); }); }); diff --git a/src/commands/models/list.auth-index.ts b/src/commands/models/list.auth-index.ts index 7b4330a2d75d..2638e3c417dc 100644 --- a/src/commands/models/list.auth-index.ts +++ b/src/commands/models/list.auth-index.ts @@ -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, }), }); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 430afa756cd3..86107b31df1e 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -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; diff --git a/src/commands/models/list.manifest-catalog.ts b/src/commands/models/list.manifest-catalog.ts index d1a5a448e098..32c9718af6d2 100644 --- a/src/commands/models/list.manifest-catalog.ts +++ b/src/commands/models/list.manifest-catalog.ts @@ -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; diff --git a/src/commands/models/list.persisted-catalog.test.ts b/src/commands/models/list.persisted-catalog.test.ts new file mode 100644 index 000000000000..cdf5bf2f8349 --- /dev/null +++ b/src/commands/models/list.persisted-catalog.test.ts @@ -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 }) => 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([]); + }); +}); diff --git a/src/commands/models/list.persisted-catalog.ts b/src/commands/models/list.persisted-catalog.ts new file mode 100644 index 000000000000..a6a1a6428994 --- /dev/null +++ b/src/commands/models/list.persisted-catalog.ts @@ -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(MODEL_APIS); +const modelInputs = new Set(["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; + 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; +} diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index 7cc575f9853e..1e2e23cb97c4 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -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: [], }); }); diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 193c2b5c7ed9..b031d8165ff9 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -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 } : {}), }); } diff --git a/src/commands/models/list.scoped-catalog.test.ts b/src/commands/models/list.scoped-catalog.test.ts index 0c0afec51325..c4c3138f9f20 100644 --- a/src/commands/models/list.scoped-catalog.test.ts +++ b/src/commands/models/list.scoped-catalog.test.ts @@ -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"], + ); }); }); diff --git a/src/commands/models/list.scoped-catalog.ts b/src/commands/models/list.scoped-catalog.ts index 7751363cc606..d937f67ee520 100644 --- a/src/commands/models/list.scoped-catalog.ts +++ b/src/commands/models/list.scoped-catalog.ts @@ -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( + () => import("./list.persisted-catalog.js"), +); +const preparedScopedCatalogModuleLoader = createLazyImportLoader( + () => 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): 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(); + const staticEntries = new Map(); + const routeVariants = new Map(); + 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 { 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(); + 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]); } diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 34073b78273c..b409f1d3952b 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -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, diff --git a/src/plugins/plugin-metadata-snapshot.test.ts b/src/plugins/plugin-metadata-snapshot.test.ts index 9198666a0759..e55d98e0d11d 100644 --- a/src/plugins/plugin-metadata-snapshot.test.ts +++ b/src/plugins/plugin-metadata-snapshot.test.ts @@ -21,8 +21,8 @@ const { loadPluginRegistrySnapshotWithMetadata, loadPluginManifestRegistryForIns }; }); -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadPluginRegistrySnapshotWithMetadata: (params: unknown) => diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 3a53048e02a8..229d733be9e5 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -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 = [