diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index 798259247274..c109cb3b13ba 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -1,5 +1,5 @@ // Verifies models.json planning applies config env vars and discovery scope. -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { createConfigRuntimeEnv } from "../config/env-vars.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; @@ -17,6 +17,15 @@ import { import type { ProviderConfig } from "./models-config.providers.secrets.js"; import { encodePluginModelCatalogRelativePath } from "./plugin-model-catalog.js"; +const providerRuntimeMocks = vi.hoisted(() => ({ + normalizeProviderConfigWithPlugin: vi.fn< + typeof import("../plugins/provider-runtime.js").normalizeProviderConfigWithPlugin + >(() => undefined), + resolveProviderConfigApiKeyWithPlugin: vi.fn< + typeof import("../plugins/provider-runtime.js").resolveProviderConfigApiKeyWithPlugin + >(() => undefined), +})); + vi.mock("./provider-auth-aliases.js", () => ({ resolveProviderAuthAliasMap: () => Object.create(null) as Record, resolveProviderIdForAuth: (provider: string) => provider.trim().toLowerCase(), @@ -26,8 +35,8 @@ vi.mock("./provider-auth-aliases.js", () => ({ // provider markers local instead of loading the bundled plugin/runtime catalog. vi.mock("../plugins/provider-runtime.js", () => ({ applyProviderNativeStreamingUsageCompatWithPlugin: () => undefined, - normalizeProviderConfigWithPlugin: () => undefined, - resolveProviderConfigApiKeyWithPlugin: () => undefined, + normalizeProviderConfigWithPlugin: providerRuntimeMocks.normalizeProviderConfigWithPlugin, + resolveProviderConfigApiKeyWithPlugin: providerRuntimeMocks.resolveProviderConfigApiKeyWithPlugin, resolveExternalAuthProfilesWithPlugins: () => [], resolveProviderSyntheticAuthWithPlugin: () => undefined, })); @@ -51,6 +60,13 @@ vi.mock("./model-auth-env-vars.js", () => ({ const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV"; +afterEach(() => { + providerRuntimeMocks.normalizeProviderConfigWithPlugin.mockReset(); + providerRuntimeMocks.normalizeProviderConfigWithPlugin.mockReturnValue(undefined); + providerRuntimeMocks.resolveProviderConfigApiKeyWithPlugin.mockReset(); + providerRuntimeMocks.resolveProviderConfigApiKeyWithPlugin.mockReturnValue(undefined); +}); + function createImplicitOpenRouterProvider(): ProviderConfig { return { baseUrl: "https://openrouter.ai/api/v1", @@ -393,6 +409,64 @@ describe("models-config", () => { expect(observedSnapshot).toBe(pluginMetadataSnapshot); }); + it.each([ + { label: "full", pluginIds: undefined, expectedRegistry: true }, + { label: "scoped", pluginIds: ["owner"], expectedRegistry: false }, + ])( + "threads provider policy metadata only from a $label plugin snapshot", + async ({ pluginIds, expectedRegistry }) => { + const manifestRegistry = { plugins: [], diagnostics: [] }; + const pluginMetadataSnapshot = { + index: { plugins: [] }, + manifestRegistry, + owners: { + providers: new Map(), + modelCatalogProviders: new Map(), + setupProviders: new Map(), + }, + ...(pluginIds ? { pluginIds } : {}), + } as unknown as Pick< + PluginMetadataSnapshot, + "index" | "manifestRegistry" | "owners" | "pluginIds" + >; + providerRuntimeMocks.resolveProviderConfigApiKeyWithPlugin.mockReturnValue( + "POLICY_ALIAS_API_KEY", + ); + + await planOpenClawModelsJsonWithDeps( + { + cfg: { models: { providers: {} } }, + agentDir: "/tmp/openclaw-models-config-policy-registry-test", + env: {}, + existingRaw: "", + existingParsed: null, + pluginMetadataSnapshot, + }, + { + resolveImplicitProviders: async () => ({ + "policy-alias": createImplicitOpenAiProvider({ + baseUrl: "https://policy.example/v1", + apiKey: undefined, + }), + }), + }, + ); + + const normalizeParams = + providerRuntimeMocks.normalizeProviderConfigWithPlugin.mock.calls.find( + ([params]) => params.provider === "policy-alias", + )?.[0]; + const apiKeyParams = + providerRuntimeMocks.resolveProviderConfigApiKeyWithPlugin.mock.calls.find( + ([params]) => params.provider === "policy-alias", + )?.[0]; + expect(normalizeParams?.manifestRegistry).toBe( + expectedRegistry ? manifestRegistry : undefined, + ); + expect(apiKeyParams?.manifestRegistry).toBe(expectedRegistry ? manifestRegistry : undefined); + }, + ); + it("does not write unauthenticated model providers that would invalidate models.json", async () => { expect(unauthenticatedProviderWritePlan.action).toBe("write"); expect(unauthenticatedProviderParsed.providers?.openai).toBeUndefined(); diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 77f024226e6b..e18724e11287 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -222,7 +222,10 @@ async function planOpenClawModelsJsonWithDeps( workspaceDir?: string; existingRaw: string; existingParsed: unknown; - pluginMetadataSnapshot?: Pick; + pluginMetadataSnapshot?: Pick< + PluginMetadataSnapshot, + "index" | "manifestRegistry" | "owners" | "pluginIds" + >; preparedStaticProviderCatalog?: PreparedProviderStaticCatalog; providerDiscoveryProviderIds?: readonly string[]; providerDiscoveryTimeoutMs?: number; @@ -273,6 +276,10 @@ async function planOpenClawModelsJsonWithDeps( const mode = cfg.models?.mode ?? "merge"; const secretRefManagedProviders = new Set(); const manifestPlugins = params.pluginMetadataSnapshot?.manifestRegistry.plugins; + const providerPolicyManifestRegistry = + params.pluginMetadataSnapshot?.pluginIds === undefined + ? params.pluginMetadataSnapshot?.manifestRegistry + : undefined; const normalizedProviders = normalizeProviders({ providers, @@ -283,6 +290,9 @@ async function planOpenClawModelsJsonWithDeps( sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults, secretRefManagedProviders, manifestPlugins, + ...(providerPolicyManifestRegistry + ? { manifestRegistry: providerPolicyManifestRegistry } + : {}), }) ?? providers; const mergedProviders = resolveProvidersForMode({ mode, diff --git a/src/agents/models-config.provider-policy-registry.test.ts b/src/agents/models-config.provider-policy-registry.test.ts new file mode 100644 index 000000000000..6bf622bea457 --- /dev/null +++ b/src/agents/models-config.provider-policy-registry.test.ts @@ -0,0 +1,145 @@ +// Verifies models.json planning reuses prepared plugin metadata for provider aliases. +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, + type MockInstance, +} from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import type { ProviderConfig } from "./models-config.providers.secrets.js"; + +const manifestRegistry = { + diagnostics: [], + plugins: [ + { + id: "xai", + channels: [], + cliBackends: [], + hooks: [], + origin: "bundled", + manifestPath: "/tmp/xai/openclaw.plugin.json", + providers: ["xai"], + providerAuthAliases: { "x-ai": "xai" }, + rootDir: "/tmp/xai", + skills: [], + source: "/tmp/xai/index.js", + }, + ], +}; + +vi.mock("./model-auth-env-vars.js", () => ({ + listKnownProviderEnvApiKeyNames: () => ["OPENAI_API_KEY"], + resolveProviderEnvAuthLookupMaps: () => ({ + aliasMap: {}, + envCandidateMap: {}, + authEvidenceMap: {}, + }), +})); + +let planOpenClawModelsJsonWithDeps: typeof import("./models-config.plan.test-support.js").planOpenClawModelsJsonWithDeps; +let loadPluginManifestRegistrySpy: MockInstance | undefined; +let loadBundledPluginPublicArtifactModuleSyncSpy: MockInstance | undefined; +let bundledPluginsDir: string; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const originalTrustBundledPluginsDir = process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; + +beforeAll(async () => { + bundledPluginsDir = tempDirs.make("openclaw-provider-policy-registry-"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; + process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = "1"; + + const manifestRegistryModule = await import("../plugins/manifest-registry.js"); + loadPluginManifestRegistrySpy = vi + .spyOn(manifestRegistryModule, "loadPluginManifestRegistry") + .mockReturnValue(manifestRegistry as never); + const publicSurfaceLoader = await import("../plugins/public-surface-loader.js"); + loadBundledPluginPublicArtifactModuleSyncSpy = vi + .spyOn(publicSurfaceLoader, "loadBundledPluginPublicArtifactModuleSync") + .mockImplementation(({ dirName }: { dirName: string }) => { + if (dirName !== "xai") { + throw new Error(`Unable to resolve bundled plugin public surface ${dirName}`); + } + return { + normalizeConfig: ({ + providerConfig, + }: { + providerConfig: ProviderConfig; + }): ProviderConfig => ({ + ...providerConfig, + baseUrl: "https://normalized.example/v1", + }), + }; + }); + ({ planOpenClawModelsJsonWithDeps } = await import("./models-config.plan.test-support.js")); +}); + +afterAll(() => { + loadPluginManifestRegistrySpy?.mockRestore(); + loadBundledPluginPublicArtifactModuleSyncSpy?.mockRestore(); + if (originalBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; + } + if (originalTrustBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = originalTrustBundledPluginsDir; + } +}); + +describe("models-config provider policy registry", () => { + it("does not reload manifests while resolving an alias-owned provider policy", async () => { + loadPluginManifestRegistrySpy?.mockClear(); + loadBundledPluginPublicArtifactModuleSyncSpy?.mockClear(); + const pluginMetadataSnapshot = { + index: { plugins: [] }, + manifestRegistry, + owners: { + providers: new Map(), + modelCatalogProviders: new Map(), + setupProviders: new Map(), + }, + } as unknown as Pick< + PluginMetadataSnapshot, + "index" | "manifestRegistry" | "owners" | "pluginIds" + >; + + const plan = await planOpenClawModelsJsonWithDeps( + { + cfg: { models: { providers: {} } }, + agentDir: "/tmp/openclaw-provider-policy-registry-test/agent", + env: {}, + existingRaw: "", + existingParsed: null, + pluginMetadataSnapshot, + }, + { + resolveImplicitProviders: async () => ({ + "x-ai": { + baseUrl: "https://mock.example/v1", + api: "openai-responses", + apiKey: "OPENAI_API_KEY", + models: [], + }, + }), + }, + ); + + expect(plan.action).toBe("write"); + expect( + plan.action === "write" ? JSON.parse(plan.contents).providers["x-ai"].baseUrl : null, + ).toBe("https://normalized.example/v1"); + expect(loadBundledPluginPublicArtifactModuleSyncSpy).toHaveBeenCalledWith({ + dirName: "xai", + artifactBasename: "provider-policy-api.js", + }); + expect(loadPluginManifestRegistrySpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/models-config.providers.normalize.ts b/src/agents/models-config.providers.normalize.ts index bf28e9864894..9f5609f7c773 100644 --- a/src/agents/models-config.providers.normalize.ts +++ b/src/agents/models-config.providers.normalize.ts @@ -2,7 +2,7 @@ * Normalizes configured provider model rows for runtime/discovery use. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { ensureAuthProfileStore } from "./auth-profiles/store.js"; import { normalizeConfiguredProviderCatalogModelId } from "./model-ref-shared.js"; import { @@ -124,6 +124,7 @@ export function normalizeProviders(params: { sourceSecretDefaults?: SecretDefaults; secretRefManagedProviders?: Set; manifestPlugins?: ProviderModelNormalizationOptions["manifestPlugins"]; + manifestRegistry?: Pick; }): ModelsConfig["providers"] { const { providers } = params; if (!providers) { @@ -198,7 +199,7 @@ export function normalizeProviders(params: { ); const profileApiKey = needsProfileApiKey ? resolveProfileApiKey(normalizedKey) : undefined; const providerApiKeyResolver = needsProfileApiKey - ? resolveProviderConfigApiKeyResolver(normalizedKey) + ? resolveProviderConfigApiKeyResolver(normalizedKey, undefined, params.manifestRegistry) : undefined; const providerWithApiKey = resolveMissingProviderApiKey({ providerKey: normalizedKey, @@ -216,6 +217,7 @@ export function normalizeProviders(params: { const providerSpecificNormalized = normalizeProviderSpecificConfig( normalizedKey, normalizedProvider, + params.manifestRegistry, ); if (providerSpecificNormalized !== normalizedProvider) { mutated = true; diff --git a/src/agents/models-config.providers.policy.runtime.ts b/src/agents/models-config.providers.policy.runtime.ts index e4783f8959aa..91746c018254 100644 --- a/src/agents/models-config.providers.policy.runtime.ts +++ b/src/agents/models-config.providers.policy.runtime.ts @@ -1,3 +1,4 @@ +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; /** * Runtime-policy bridge for provider config normalization. These helpers call * plugin hooks without triggering runtime plugin loading from config assembly. @@ -10,6 +11,8 @@ import { import { resolveProviderPluginLookupKey } from "./models-config.providers.policy.lookup.js"; import type { ProviderConfig } from "./models-config.providers.secrets.js"; +export type ProviderPolicyManifestRegistry = Pick; + /** Apply provider native-streaming usage compatibility policy. */ export function applyProviderNativeStreamingUsagePolicy( providerKey: string, @@ -32,12 +35,14 @@ export function applyProviderNativeStreamingUsagePolicy( export function normalizeProviderConfigPolicy( providerKey: string, provider: ProviderConfig, + manifestRegistry?: ProviderPolicyManifestRegistry, ): ProviderConfig { const runtimeProviderKey = resolveProviderPluginLookupKey(providerKey, provider); return ( normalizeProviderConfigWithPlugin({ provider: runtimeProviderKey, allowRuntimePluginLoad: false, + ...(manifestRegistry ? { manifestRegistry } : {}), context: { provider: providerKey, providerConfig: provider, @@ -50,12 +55,14 @@ export function normalizeProviderConfigPolicy( export function resolveProviderConfigApiKeyPolicy( providerKey: string, provider?: ProviderConfig, + manifestRegistry?: ProviderPolicyManifestRegistry, ): ((env: NodeJS.ProcessEnv) => string | undefined) | undefined { const runtimeProviderKey = resolveProviderPluginLookupKey(providerKey, provider).trim(); return (env) => resolveProviderConfigApiKeyWithPlugin({ provider: runtimeProviderKey, allowRuntimePluginLoad: false, + ...(manifestRegistry ? { manifestRegistry } : {}), context: { provider: providerKey, env, diff --git a/src/agents/models-config.providers.policy.ts b/src/agents/models-config.providers.policy.ts index 28eabff3fb40..063f5dbbb1f4 100644 --- a/src/agents/models-config.providers.policy.ts +++ b/src/agents/models-config.providers.policy.ts @@ -5,6 +5,7 @@ import { applyProviderNativeStreamingUsagePolicy, normalizeProviderConfigPolicy, resolveProviderConfigApiKeyPolicy, + type ProviderPolicyManifestRegistry, } from "./models-config.providers.policy.runtime.js"; import type { ProviderConfig } from "./models-config.providers.secrets.js"; @@ -34,8 +35,9 @@ export function applyNativeStreamingUsageCompat( export function normalizeProviderSpecificConfig( providerKey: string, provider: ProviderConfig, + manifestRegistry?: ProviderPolicyManifestRegistry, ): ProviderConfig { - const normalized = normalizeProviderConfigPolicy(providerKey, provider); + const normalized = normalizeProviderConfigPolicy(providerKey, provider, manifestRegistry); if (normalized && normalized !== provider) { return normalized; } @@ -46,6 +48,7 @@ export function normalizeProviderSpecificConfig( export function resolveProviderConfigApiKeyResolver( providerKey: string, provider?: ProviderConfig, + manifestRegistry?: ProviderPolicyManifestRegistry, ): ((env: NodeJS.ProcessEnv) => string | undefined) | undefined { - return resolveProviderConfigApiKeyPolicy(providerKey, provider); + return resolveProviderConfigApiKeyPolicy(providerKey, provider, manifestRegistry); } diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 88666672191e..628818a347a9 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -51,9 +51,14 @@ type PreparedOpenClawModelsJsonSource = ModelsJsonReadyResult & { workspaceDir?: string; }; +type ModelsConfigPluginMetadataSnapshot = Pick< + PluginMetadataSnapshot, + "index" | "manifestRegistry" | "owners" | "pluginIds" +>; + type EnsureOpenClawModelsJsonOptions = { env?: NodeJS.ProcessEnv; - pluginMetadataSnapshot?: Pick; + pluginMetadataSnapshot?: ModelsConfigPluginMetadataSnapshot; preparedStaticProviderCatalog?: PreparedProviderStaticCatalog; workspaceDir?: string; providerDiscoveryProviderIds?: readonly string[]; @@ -92,7 +97,7 @@ async function buildModelsJsonFingerprint(params: { sourceConfigForSecrets: OpenClawConfig; agentDir: string; workspaceDir?: string; - pluginMetadataSnapshot?: Pick; + pluginMetadataSnapshot?: Pick; providerDiscoveryProviderIds?: readonly string[]; providerDiscoveryTimeoutMs?: number; providerDiscoveryEntriesOnly?: boolean; @@ -120,6 +125,10 @@ async function buildModelsJsonFingerprint(params: { pluginCatalogFingerprint, workspaceDir: params.workspaceDir, pluginMetadataSnapshotIndexFingerprint, + pluginMetadataSnapshotPluginIds: + params.pluginMetadataSnapshot?.pluginIds === undefined + ? null + : params.pluginMetadataSnapshot.pluginIds.toSorted(), providerDiscoveryProviderIds: params.providerDiscoveryProviderIds, providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs, providerDiscoveryEntriesOnly: params.providerDiscoveryEntriesOnly === true, @@ -287,7 +296,7 @@ async function buildModelsJsonSourceFingerprint( agentDirOverride?: string, options: { env?: NodeJS.ProcessEnv; - pluginMetadataSnapshot?: Pick; + pluginMetadataSnapshot?: ModelsConfigPluginMetadataSnapshot; workspaceDir?: string; providerDiscoveryProviderIds?: readonly string[]; providerDiscoveryTimeoutMs?: number; diff --git a/src/agents/models-config.write-serialization.test.ts b/src/agents/models-config.write-serialization.test.ts index ea94503b1f10..27b107459627 100644 --- a/src/agents/models-config.write-serialization.test.ts +++ b/src/agents/models-config.write-serialization.test.ts @@ -624,6 +624,34 @@ describe("models-config write serialization", () => { }); }); + it("keeps full and scoped plugin metadata snapshots in distinct cache entries", async () => { + await withModelsTempHome(async (home) => { + planOpenClawModelsJsonMock.mockImplementation(async () => ({ action: "noop" })); + const workspaceDir = path.join(home, "workspace"); + const agentDir = path.join(home, "agent"); + const fullSnapshot = createPluginMetadataSnapshot(workspaceDir); + const scopedSnapshot = { + ...createPluginMetadataSnapshot(workspaceDir), + pluginIds: ["owner"], + }; + + await ensureOpenClawModelsJson({}, agentDir, { + workspaceDir, + pluginMetadataSnapshot: fullSnapshot, + }); + await ensureOpenClawModelsJson({}, agentDir, { + workspaceDir, + pluginMetadataSnapshot: scopedSnapshot, + }); + await ensureOpenClawModelsJson({}, agentDir, { + workspaceDir, + pluginMetadataSnapshot: fullSnapshot, + }); + + expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2); + }); + }); + it("serializes concurrent models.json writes to avoid overlap", async () => { await withModelsTempHome(async () => { const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); diff --git a/src/plugins/provider-public-artifacts.test.ts b/src/plugins/provider-public-artifacts.test.ts index d28530cdf09e..075711ac43e6 100644 --- a/src/plugins/provider-public-artifacts.test.ts +++ b/src/plugins/provider-public-artifacts.test.ts @@ -648,7 +648,7 @@ describe("provider public artifacts", () => { expect(loadPluginManifestRegistry).not.toHaveBeenCalled(); }); - it("loads provider policy surfaces without package-manager repair", async () => { + it("keeps canonical provider policy lookup on the direct artifact path", async () => { const loadBundledPluginPublicArtifactModuleSync = vi.fn(() => ({ normalizeConfig: (ctx: { providerConfig: ModelProviderConfig }) => ctx.providerConfig, })); @@ -660,7 +660,12 @@ describe("provider public artifacts", () => { typeof import("./provider-public-artifacts.js") >(import.meta.url, "./provider-public-artifacts.js?scope=no-runtime-deps"); - const surface = resolvePolicySurface("openai"); + const manifestRegistry = { + get plugins(): never { + throw new Error("direct provider policy lookup must not inspect manifest metadata"); + }, + }; + const surface = resolvePolicySurface("openai", { manifestRegistry }); expect(surface?.normalizeConfig).toBeTypeOf("function"); expect(loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith({ dirName: "openai", diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index e1f574b023a1..ae70f10f02dd 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -279,8 +279,9 @@ describe("provider-runtime", () => { beforeAll(async () => { vi.resetModules(); vi.doMock("./provider-public-artifacts.js", () => ({ - resolveBundledProviderPolicySurface: (provider: string) => - resolveBundledProviderPolicySurfaceMock(provider), + resolveBundledProviderPolicySurface: ( + ...args: Parameters + ) => resolveBundledProviderPolicySurfaceMock(...args), })); vi.doMock("./providers.js", () => ({ resolveCatalogHookProviderPluginIds: (params: unknown) => @@ -1613,6 +1614,50 @@ describe("provider-runtime", () => { expect(resolvePluginProvidersMock).not.toHaveBeenCalled(); }); + it("forwards prepared manifest metadata to bundled config policy resolution", () => { + const manifestRegistry = { plugins: [] }; + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.example.com/v1", + api: "openai-completions", + models: [], + }; + resolveBundledProviderPolicySurfaceMock.mockReturnValue({ + normalizeConfig: ({ providerConfig: candidateConfig }) => ({ + ...candidateConfig, + baseUrl: "https://normalized.example.com/v1", + }), + resolveConfigApiKey: () => "EXAMPLE_API_KEY", + }); + + expect( + normalizeProviderConfigWithPlugin({ + provider: "example-alias", + manifestRegistry, + context: { + provider: "example-alias", + providerConfig, + }, + })?.baseUrl, + ).toBe("https://normalized.example.com/v1"); + expect( + resolveProviderConfigApiKeyWithPlugin({ + provider: "example-alias", + manifestRegistry, + context: { + provider: "example-alias", + env: {}, + }, + }), + ).toBe("EXAMPLE_API_KEY"); + + expect(resolveBundledProviderPolicySurfaceMock).toHaveBeenNthCalledWith(1, "example-alias", { + manifestRegistry, + }); + expect(resolveBundledProviderPolicySurfaceMock).toHaveBeenNthCalledWith(2, "example-alias", { + manifestRegistry, + }); + }); + it("resolves thinking profiles from bundled policy surface before runtime plugins", () => { const resolveThinkingProfile = vi.fn(() => ({ levels: [{ id: "off" as const }], diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 7b2e84f23c28..77d3c6a2fc8b 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -19,7 +19,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { UsageProviderId } from "../infra/provider-usage.types.js"; import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js"; -import type { PluginManifestRecord } from "./manifest-registry.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; import type { PluginMetadataRegistryView, @@ -446,12 +446,15 @@ export function normalizeProviderConfigWithPlugin(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + manifestRegistry?: Pick; context: ProviderNormalizeConfigContext; allowRuntimePluginLoad?: boolean; }): ModelProviderConfig | undefined { const hasConfigChange = (normalized: ModelProviderConfig) => normalized !== params.context.providerConfig; - const bundledSurface = resolveBundledProviderPolicySurface(params.provider); + const bundledSurface = resolveBundledProviderPolicySurface(params.provider, { + manifestRegistry: params.manifestRegistry, + }); if (bundledSurface?.normalizeConfig) { const normalized = bundledSurface.normalizeConfig(params.context); return normalized && hasConfigChange(normalized) ? normalized : undefined; @@ -489,10 +492,13 @@ export function resolveProviderConfigApiKeyWithPlugin(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + manifestRegistry?: Pick; context: ProviderResolveConfigApiKeyContext; allowRuntimePluginLoad?: boolean; }): string | undefined { - const bundledSurface = resolveBundledProviderPolicySurface(params.provider); + const bundledSurface = resolveBundledProviderPolicySurface(params.provider, { + manifestRegistry: params.manifestRegistry, + }); if (bundledSurface?.resolveConfigApiKey) { return normalizeOptionalString(bundledSurface.resolveConfigApiKey(params.context)); }