diff --git a/extensions/amazon-bedrock/index.ts b/extensions/amazon-bedrock/index.ts index 4696df6e4e3..9886f663de7 100644 --- a/extensions/amazon-bedrock/index.ts +++ b/extensions/amazon-bedrock/index.ts @@ -1,5 +1,5 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { registerAmazonBedrockPlugin } from "./register.runtime.js"; +import { registerAmazonBedrockPlugin } from "./register.sync.runtime.js"; export default definePluginEntry({ id: "amazon-bedrock", diff --git a/extensions/amazon-bedrock/register.runtime.ts b/extensions/amazon-bedrock/register.sync.runtime.ts similarity index 88% rename from extensions/amazon-bedrock/register.runtime.ts rename to extensions/amazon-bedrock/register.sync.runtime.ts index 64d9e9d0ad3..1b37660d641 100644 --- a/extensions/amazon-bedrock/register.runtime.ts +++ b/extensions/amazon-bedrock/register.sync.runtime.ts @@ -1,10 +1,16 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; import { createBedrockNoCacheWrapper, isAnthropicBedrockModel, streamWithPayloadPatch, } from "openclaw/plugin-sdk/provider-stream"; +import { + mergeImplicitBedrockProvider, + resolveBedrockConfigApiKey, + resolveImplicitBedrockProvider, +} from "./api.js"; type GuardrailConfig = { guardrailIdentifier: string; @@ -38,7 +44,7 @@ function createGuardrailWrapStreamFn( }; } -export async function registerAmazonBedrockPlugin(api: OpenClawPluginApi): Promise { +export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { // Keep registration-local constants inside the function so partial module // initialization during test bootstrap cannot trip TDZ reads. const providerId = "amazon-bedrock"; @@ -48,15 +54,6 @@ export async function registerAmazonBedrockPlugin(api: OpenClawPluginApi): Promi /ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)/i, /ModelStreamErrorException.*(?:Input is too long|too many input tokens)/i, ] as const; - // Defer provider-owned helper loading until registration so test/plugin-loader - // cycles cannot re-enter this module before its constants initialize. - const [ - { buildProviderReplayFamilyHooks }, - { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey, resolveImplicitBedrockProvider }, - ] = await Promise.all([ - import("openclaw/plugin-sdk/provider-model-shared"), - import("./api.js"), - ]); const anthropicByModelReplayHooks = buildProviderReplayFamilyHooks({ family: "anthropic-by-model", }); diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 7afcec80245..251777b8292 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -6,6 +6,6 @@ export default definePluginEntry({ name: "Anthropic Provider", description: "Bundled Anthropic provider plugin", register(api) { - registerAnthropicPlugin(api); + return registerAnthropicPlugin(api); }, }); diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index dc2642ac0b5..498bfe945ee 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -7,6 +7,7 @@ import type { } from "openclaw/plugin-sdk/plugin-entry"; import { applyAuthProfileConfig, + createProviderApiKeyAuthMethod, ensureApiKeyFromOptionEnvOrPrompt, listProfilesForProvider, normalizeApiKeyInput, @@ -17,11 +18,13 @@ import { } from "openclaw/plugin-sdk/provider-auth"; import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared"; import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; +import { buildAnthropicCliBackend } from "./cli-backend.js"; import { buildAnthropicCliMigrationResult, hasClaudeCliAuth } from "./cli-migration.js"; import { applyAnthropicConfigDefaults, normalizeAnthropicProviderConfig, } from "./config-defaults.js"; +import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildAnthropicReplayPolicy } from "./replay-policy.js"; import { wrapAnthropicProviderStream } from "./stream-wrappers.js"; @@ -200,7 +203,7 @@ async function runAnthropicCliMigrationNonInteractive(ctx: { }; } -export async function registerAnthropicPlugin(api: OpenClawPluginApi): Promise { +export function registerAnthropicPlugin(api: OpenClawPluginApi): void { const claudeCliProfileId = "anthropic:claude-cli"; const providerId = "anthropic"; const defaultAnthropicModel = "anthropic/claude-sonnet-4-6"; @@ -211,45 +214,7 @@ export async function registerAnthropicPlugin(api: OpenClawPluginApi): Promise; defaultProvider: string; + defaultModel?: string; }): ProviderModelRef | null { const configuredProviders = params.cfg.models?.providers; if (!configuredProviders || typeof configuredProviders !== "object") { return null; } - if (configuredProviders[params.defaultProvider]) { + const defaultProviderConfig = configuredProviders[params.defaultProvider]; + const defaultModel = params.defaultModel?.trim(); + const defaultProviderHasDefaultModel = + !!defaultProviderConfig && + !!defaultModel && + Array.isArray(defaultProviderConfig.models) && + defaultProviderConfig.models.some((model) => model?.id === defaultModel); + if (defaultProviderConfig && (!defaultModel || defaultProviderHasDefaultModel)) { return null; } const availableProvider = Object.entries(configuredProviders).find( diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index ce0163170c0..a4bda3e9383 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -404,6 +404,7 @@ export function resolveConfiguredModelRef(params: { const fallbackProvider = resolveConfiguredProviderFallback({ cfg: params.cfg, defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, }); if (fallbackProvider) { return fallbackProvider; diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index ddfbda69787..a4cc287b762 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -1,7 +1,11 @@ import { afterEach, beforeEach, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; +import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js"; +import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import { resetModelsJsonReadyCacheForTest } from "./models-config.js"; import { resolveImplicitProviders } from "./models-config.providers.implicit.js"; export function withModelsTempHome(fn: (home: string) => Promise): Promise { @@ -14,10 +18,16 @@ export function installModelsConfigTestHooks(opts?: { restoreFetch?: boolean }) beforeEach(() => { previousHome = process.env.HOME; + resetPluginLoaderTestStateForTest(); + resetModelsJsonReadyCacheForTest(); + resetProviderRuntimeHookCacheForTest(); }); afterEach(() => { process.env.HOME = previousHome; + resetPluginLoaderTestStateForTest(); + resetModelsJsonReadyCacheForTest(); + resetProviderRuntimeHookCacheForTest(); if (opts?.restoreFetch && originalFetch) { globalThis.fetch = originalFetch; } @@ -103,6 +113,7 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "OPENROUTER_API_KEY", "PI_CODING_AGENT_DIR", "QIANFAN_API_KEY", + "QWEN_API_KEY", "MODELSTUDIO_API_KEY", "SYNTHETIC_API_KEY", "STEPFUN_API_KEY", @@ -113,6 +124,7 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "KIMI_API_KEY", "KIMICODE_API_KEY", "GEMINI_API_KEY", + "OPENCLAW_BUNDLED_PLUGINS_DIR", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_PROJECT", @@ -146,6 +158,12 @@ export function snapshotImplicitProviderEnv(env?: NodeJS.ProcessEnv): NodeJS.Pro } } + // Provider discovery tests can temporarily scrub VITEST/NODE_ENV to exercise + // live HTTP paths. Keep the bundled plugin root pinned to the source checkout + // so those tests do not fall back to potentially stale dist-runtime wrappers. + snapshot.OPENCLAW_BUNDLED_PLUGINS_DIR ??= + resolveBundledPluginsDir({ VITEST: "true" } as NodeJS.ProcessEnv) ?? undefined; + return snapshot; } diff --git a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts index 353d5714574..21877f58b25 100644 --- a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts +++ b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts @@ -1,9 +1,10 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; -import { readGeneratedModelsJson } from "./models-config.test-utils.js"; function createGoogleModelsConfig(models: ModelDefinitionConfig[]): OpenClawConfig { return { @@ -20,18 +21,19 @@ function createGoogleModelsConfig(models: ModelDefinitionConfig[]): OpenClawConf }; } -async function readGeneratedProvider(providerKey: string) { - const parsed = await readGeneratedModelsJson<{ +async function readGeneratedProvider(agentDir: string, providerKey: string) { + const parsed = JSON.parse(await fs.readFile(path.join(agentDir, "models.json"), "utf8")) as { providers: Record }>; - }>(); + }; return parsed.providers[providerKey]; } async function expectGeneratedProvider( + agentDir: string, providerKey: string, params: { ids: string[]; baseUrl?: string }, ) { - const provider = await readGeneratedProvider(providerKey); + const provider = await readGeneratedProvider(agentDir, providerKey); expect(provider?.models?.map((model) => model.id)).toEqual(params.ids); if (params.baseUrl !== undefined) { expect(provider?.baseUrl).toBe(params.baseUrl); @@ -66,8 +68,8 @@ describe("models-config", () => { }, ]); - await ensureOpenClawModelsJson(cfg); - await expectGeneratedProvider("google", { + const { agentDir } = await ensureOpenClawModelsJson(cfg); + await expectGeneratedProvider(agentDir, "google", { ids: ["gemini-3-pro-preview", "gemini-3-flash-preview"], }); }); @@ -88,8 +90,8 @@ describe("models-config", () => { }, ]); - await ensureOpenClawModelsJson(cfg); - await expectGeneratedProvider("google", { + const { agentDir } = await ensureOpenClawModelsJson(cfg); + await expectGeneratedProvider(agentDir, "google", { ids: ["gemini-3-flash-preview"], }); }); @@ -121,8 +123,8 @@ describe("models-config", () => { }, } satisfies OpenClawConfig; - await ensureOpenClawModelsJson(cfg); - await expectGeneratedProvider("google-paid", { + const { agentDir } = await ensureOpenClawModelsJson(cfg); + await expectGeneratedProvider(agentDir, "google-paid", { ids: ["gemini-3-pro-preview"], baseUrl: "https://generativelanguage.googleapis.com/v1beta", }); @@ -154,8 +156,8 @@ describe("models-config", () => { }, } satisfies OpenClawConfig; - await ensureOpenClawModelsJson(cfg); - await expectGeneratedProvider("google", { + const { agentDir } = await ensureOpenClawModelsJson(cfg); + await expectGeneratedProvider(agentDir, "google", { ids: ["gemini-3-flash-preview"], baseUrl: "https://generativelanguage.googleapis.com/v1beta", }); diff --git a/src/agents/models-config.providers.policy.test.ts b/src/agents/models-config.providers.policy.test.ts index 4777ba08ce7..37ed0c1e197 100644 --- a/src/agents/models-config.providers.policy.test.ts +++ b/src/agents/models-config.providers.policy.test.ts @@ -1,12 +1,8 @@ -import { beforeAll, describe, expect, it } from "vitest"; - -let normalizeProviderSpecificConfig: typeof import("./models-config.providers.policy.js").normalizeProviderSpecificConfig; -let resolveProviderConfigApiKeyResolver: typeof import("./models-config.providers.policy.js").resolveProviderConfigApiKeyResolver; - -beforeAll(async () => { - ({ normalizeProviderSpecificConfig, resolveProviderConfigApiKeyResolver } = - await import("./models-config.providers.policy.js")); -}); +import { describe, expect, it } from "vitest"; +import { + normalizeProviderSpecificConfig, + resolveProviderConfigApiKeyResolver, +} from "./models-config.providers.policy.js"; describe("models-config.providers.policy", () => { it("resolves config apiKey markers through provider plugin hooks", async () => { diff --git a/src/agents/models-config.providers.policy.ts b/src/agents/models-config.providers.policy.ts index 730a5b03f52..75f80bc274e 100644 --- a/src/agents/models-config.providers.policy.ts +++ b/src/agents/models-config.providers.policy.ts @@ -1,3 +1,8 @@ +import { resolveBedrockConfigApiKey } from "../plugin-sdk/amazon-bedrock.js"; +import { + normalizeGoogleProviderConfig, + shouldNormalizeGoogleProviderConfig, +} from "../plugin-sdk/google.js"; import { applyProviderNativeStreamingUsageCompatWithPlugin, normalizeProviderConfigWithPlugin, @@ -32,20 +37,32 @@ export function normalizeProviderSpecificConfig( providerKey: string, provider: ProviderConfig, ): ProviderConfig { - return ( + const normalized = normalizeProviderConfigWithPlugin({ provider: providerKey, context: { provider: providerKey, providerConfig: provider, }, - }) ?? provider - ); + }) ?? undefined; + if (normalized) { + return normalized; + } + if (shouldNormalizeGoogleProviderConfig(providerKey, provider)) { + return normalizeGoogleProviderConfig(providerKey, provider); + } + return provider; } export function resolveProviderConfigApiKeyResolver( providerKey: string, ): ((env: NodeJS.ProcessEnv) => string | undefined) | undefined { + if (providerKey.trim() === "amazon-bedrock") { + return (env) => { + const resolved = resolveBedrockConfigApiKey(env); + return resolved?.trim() || undefined; + }; + } if (!resolveProviderRuntimePlugin({ provider: providerKey })?.resolveConfigApiKey) { return undefined; } diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 4b799356592..068e27c48d1 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -789,9 +789,7 @@ describe("applyExtraParamsToAgent", () => { void agent.streamFn?.(model, context, {}); expect(payloads).toHaveLength(1); - expect(payloads[0]).toEqual({ - reasoning: { effort: "none", summary: "auto" }, - }); + expect(payloads[0]).not.toHaveProperty("reasoning"); }); it("injects parallel_tool_calls for openai-completions payloads when configured", () => { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 0e05558211c..b39ccd2f1ac 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -3,7 +3,7 @@ import { normalizeTelegramCommandDescription, normalizeTelegramCommandName, resolveTelegramCustomCommands, -} from "../../extensions/telegram/api.js"; +} from "../../extensions/telegram/config-api.js"; import { isSafeScpRemoteHost } from "../infra/scp-host.js"; import { isValidInboundPathRootPattern } from "../media/inbound-path-policy.js"; import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 77c5aa8bdcf..fe6829ac790 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -412,7 +412,25 @@ export function normalizeProviderConfigWithPlugin(params: { env?: NodeJS.ProcessEnv; context: ProviderNormalizeConfigContext; }): ModelProviderConfig | undefined { - return resolveProviderHookPlugin(params)?.normalizeConfig?.(params.context) ?? undefined; + const hasConfigChange = (normalized: ModelProviderConfig) => + normalized !== params.context.providerConfig; + const matchedPlugin = resolveProviderHookPlugin(params); + const normalizedMatched = matchedPlugin?.normalizeConfig?.(params.context); + if (normalizedMatched && hasConfigChange(normalizedMatched)) { + return normalizedMatched; + } + + for (const candidate of resolveProviderPluginsForHooks(params)) { + if (!candidate.normalizeConfig || candidate === matchedPlugin) { + continue; + } + const normalized = candidate.normalizeConfig(params.context); + if (normalized && hasConfigChange(normalized)) { + return normalized; + } + } + + return undefined; } export function applyProviderNativeStreamingUsageCompatWithPlugin(params: {