mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 22:10:51 +00:00
fix(plugins): restore provider compatibility fallbacks
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user