fix(plugins): restore provider compatibility fallbacks

This commit is contained in:
Peter Steinberger
2026-03-15 16:09:29 -07:00
parent 684e5ea249
commit 9eed6e674b
10 changed files with 308 additions and 12 deletions

View File

@@ -11,6 +11,7 @@ import {
type ProviderAuthResult,
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/core";
import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js";
const PROVIDER_ID = "ollama";
const DEFAULT_API_KEY = "ollama-local";
@@ -72,7 +73,7 @@ const ollamaPlugin = {
...explicit,
baseUrl:
typeof explicit.baseUrl === "string" && explicit.baseUrl.trim()
? explicit.baseUrl.trim().replace(/\/+$/, "")
? resolveOllamaApiBase(explicit.baseUrl)
: OLLAMA_DEFAULT_BASE_URL,
api: explicit.api ?? "ollama",
apiKey: ollamaKey ?? explicit.apiKey ?? DEFAULT_API_KEY,

View File

@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { resolveCloudflareAiGatewayBaseUrl } from "./cloudflare-ai-gateway.js";
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
@@ -73,4 +74,86 @@ describe("cloudflare-ai-gateway profile provenance", () => {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
});
it("keeps Cloudflare gateway metadata and apiKey from the same auth profile", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"cloudflare-ai-gateway:key-only": {
type: "api_key",
provider: "cloudflare-ai-gateway",
key: "sk-first",
},
"cloudflare-ai-gateway:gateway": {
type: "api_key",
provider: "cloudflare-ai-gateway",
key: "sk-second",
metadata: {
accountId: "acct_456",
gatewayId: "gateway_789",
},
},
},
},
null,
2,
),
"utf8",
);
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("sk-second");
expect(providers?.["cloudflare-ai-gateway"]?.baseUrl).toBe(
resolveCloudflareAiGatewayBaseUrl({
accountId: "acct_456",
gatewayId: "gateway_789",
}),
);
});
it("prefers the runtime env marker over stored profile secrets", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["CLOUDFLARE_AI_GATEWAY_API_KEY"]);
process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "rotated-secret"; // pragma: allowlist secret
await writeFile(
join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"cloudflare-ai-gateway:default": {
type: "api_key",
provider: "cloudflare-ai-gateway",
key: "stale-stored-secret",
metadata: {
accountId: "acct_123",
gatewayId: "gateway_456",
},
},
},
},
null,
2,
),
"utf8",
);
try {
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY");
expect(providers?.["cloudflare-ai-gateway"]?.baseUrl).toBe(
resolveCloudflareAiGatewayBaseUrl({
accountId: "acct_123",
gatewayId: "gateway_456",
}),
);
} finally {
envSnapshot.restore();
}
});
});

View File

@@ -0,0 +1,53 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("implicit provider plugin allowlist compatibility", () => {
it("keeps bundled implicit providers discoverable when plugins.allow is set", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY", "MOONSHOT_API_KEY"]);
process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret
process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProvidersForTest({
agentDir,
config: {
plugins: {
allow: ["openrouter"],
},
},
});
expect(providers?.kilocode).toBeDefined();
expect(providers?.moonshot).toBeDefined();
} finally {
envSnapshot.restore();
}
});
it("still honors explicit plugin denies over compat allowlist injection", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY", "MOONSHOT_API_KEY"]);
process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret
process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProvidersForTest({
agentDir,
config: {
plugins: {
allow: ["openrouter"],
deny: ["kilocode"],
},
},
});
expect(providers?.kilocode).toBeUndefined();
expect(providers?.moonshot).toBeDefined();
} finally {
envSnapshot.restore();
}
});
});

View File

@@ -25,10 +25,10 @@ export function isCacheTtlEligibleProvider(provider: string, modelId: string): b
if (pluginEligibility !== undefined) {
return pluginEligibility;
}
if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) {
if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) {
return true;
}
if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) {
if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) {
return true;
}
return false;

View File

@@ -2,6 +2,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Context, Model } from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { captureEnv } from "../../test-utils/env.js";
import { applyExtraParamsToAgent } from "./extra-params.js";
@@ -10,10 +11,21 @@ type CapturedCall = {
payload?: Record<string, unknown>;
};
const TEST_CFG = {
plugins: {
entries: {
kilocode: {
enabled: true,
},
},
},
} satisfies OpenClawConfig;
function applyAndCapture(params: {
provider: string;
modelId: string;
callerHeaders?: Record<string, string>;
cfg?: OpenClawConfig;
}): CapturedCall {
const captured: CapturedCall = {};
@@ -24,7 +36,7 @@ function applyAndCapture(params: {
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId);
applyExtraParamsToAgent(agent, params.cfg ?? TEST_CFG, params.provider, params.modelId);
const model = {
api: "openai-completions",
@@ -81,6 +93,22 @@ describe("extra-params: Kilocode wrapper", () => {
expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw");
});
it("keeps Kilocode runtime wrapping under restrictive plugins.allow", () => {
delete process.env.KILOCODE_FEATURE;
const { headers } = applyAndCapture({
provider: "kilocode",
modelId: "anthropic/claude-sonnet-4",
cfg: {
plugins: {
allow: ["openrouter"],
},
},
});
expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw");
});
it("does not inject header for non-kilocode providers", () => {
const { headers } = applyAndCapture({
provider: "openrouter",
@@ -104,7 +132,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => {
const agent = { streamFn: baseStreamFn };
// Pass thinking level explicitly (6th parameter) to trigger reasoning injection
applyExtraParamsToAgent(agent, undefined, "kilocode", "kilo/auto", undefined, "high");
applyExtraParamsToAgent(agent, TEST_CFG, "kilocode", "kilo/auto", undefined, "high");
const model = {
api: "openai-completions",
@@ -133,7 +161,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => {
applyExtraParamsToAgent(
agent,
undefined,
TEST_CFG,
"kilocode",
"anthropic/claude-sonnet-4",
undefined,
@@ -153,6 +181,42 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => {
expect(capturedPayload?.reasoning).toEqual({ effort: "high" });
});
it("still normalizes reasoning for Kilocode under restrictive plugins.allow", () => {
let capturedPayload: Record<string, unknown> | undefined;
const baseStreamFn: StreamFn = (model, _context, options) => {
const payload: Record<string, unknown> = {};
options?.onPayload?.(payload, model);
capturedPayload = payload;
return createAssistantMessageEventStream();
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(
agent,
{
plugins: {
allow: ["openrouter"],
},
},
"kilocode",
"anthropic/claude-sonnet-4",
undefined,
"high",
);
const model = {
api: "openai-completions",
provider: "kilocode",
id: "anthropic/claude-sonnet-4",
} as Model<"openai-completions">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(capturedPayload?.reasoning).toEqual({ effort: "high" });
});
it("does not inject reasoning.effort for x-ai models", () => {
let capturedPayload: Record<string, unknown> | undefined;
@@ -164,7 +228,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => {
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "kilocode", "x-ai/grok-3", undefined, "high");
applyExtraParamsToAgent(agent, TEST_CFG, "kilocode", "x-ai/grok-3", undefined, "high");
const model = {
api: "openai-completions",

View File

@@ -15,7 +15,10 @@ export function resolvePluginDiscoveryProviders(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
return resolvePluginProviders(params).filter((provider) => resolveProviderCatalogHook(provider));
return resolvePluginProviders({
...params,
bundledProviderAllowlistCompat: true,
}).filter((provider) => resolveProviderCatalogHook(provider));
}
export function groupPluginDiscoveryProvidersByOrder(

View File

@@ -51,6 +51,12 @@ describe("provider-runtime", () => {
const plugin = resolveProviderRuntimePlugin({ provider: "Open Router" });
expect(plugin?.id).toBe("openrouter");
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "Open Router",
bundledProviderAllowlistCompat: true,
}),
);
});
it("dispatches runtime hooks for the matched provider", async () => {

View File

@@ -29,9 +29,10 @@ export function resolveProviderRuntimePlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin | undefined {
return resolvePluginProviders(params).find((plugin) =>
matchesProviderId(plugin, params.provider),
);
return resolvePluginProviders({
...params,
bundledProviderAllowlistCompat: true,
}).find((plugin) => matchesProviderId(plugin, params.provider));
}
export function runProviderDynamicModel(params: {

View File

@@ -31,4 +31,25 @@ describe("resolvePluginProviders", () => {
}),
);
});
it("can augment restrictive allowlists for bundled provider compatibility", () => {
resolvePluginProviders({
config: {
plugins: {
allow: ["openrouter"],
},
},
bundledProviderAllowlistCompat: true,
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining(["openrouter", "kilocode", "moonshot"]),
}),
}),
}),
);
});
});

View File

@@ -4,15 +4,79 @@ import { createPluginLoaderLogger } from "./logger.js";
import type { ProviderPlugin } from "./types.js";
const log = createSubsystemLogger("plugins");
const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [
"byteplus",
"cloudflare-ai-gateway",
"copilot-proxy",
"github-copilot",
"google-gemini-cli-auth",
"huggingface",
"kilocode",
"kimi-coding",
"minimax",
"minimax-portal-auth",
"modelstudio",
"moonshot",
"nvidia",
"ollama",
"openai-codex",
"openrouter",
"qianfan",
"qwen-portal-auth",
"sglang",
"synthetic",
"together",
"venice",
"vercel-ai-gateway",
"volcengine",
"vllm",
"xiaomi",
] as const;
function withBundledProviderAllowlistCompat(
config: PluginLoadOptions["config"],
): PluginLoadOptions["config"] {
const allow = config?.plugins?.allow;
if (!Array.isArray(allow) || allow.length === 0) {
return config;
}
const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean));
let changed = false;
for (const pluginId of BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS) {
if (!allowSet.has(pluginId)) {
allowSet.add(pluginId);
changed = true;
}
}
if (!changed) {
return config;
}
return {
...config,
plugins: {
...config?.plugins,
// Backward compat: bundled implicit providers historically stayed
// available even when operators kept a restrictive plugin allowlist.
allow: [...allowSet],
},
};
}
export function resolvePluginProviders(params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
/** Use an explicit env when plugin roots should resolve independently from process.env. */
env?: PluginLoadOptions["env"];
bundledProviderAllowlistCompat?: boolean;
}): ProviderPlugin[] {
const config = params.bundledProviderAllowlistCompat
? withBundledProviderAllowlistCompat(params.config)
: params.config;
const registry = loadOpenClawPlugins({
config: params.config,
config,
workspaceDir: params.workspaceDir,
env: params.env,
logger: createPluginLoaderLogger(log),