feat: pluginize cli inference backends

This commit is contained in:
Peter Steinberger
2026-03-26 15:09:01 +00:00
parent 24dd7aec90
commit a4a00aa1da
49 changed files with 657 additions and 248 deletions

View File

@@ -1,7 +1,34 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { buildAnthropicCliBackend } from "../../extensions/anthropic/cli-backend.js";
import { buildGoogleGeminiCliBackend } from "../../extensions/google/cli-backend.js";
import { buildOpenAICodexCliBackend } from "../../extensions/openai/cli-backend.js";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
beforeEach(() => {
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
{
pluginId: "anthropic",
backend: buildAnthropicCliBackend(),
source: "test",
},
{
pluginId: "openai",
backend: buildOpenAICodexCliBackend(),
source: "test",
},
{
pluginId: "google",
backend: buildGoogleGeminiCliBackend(),
source: "test",
},
];
setActivePluginRegistry(registry);
});
describe("resolveCliBackendConfig reliability merge", () => {
it("defaults codex-cli to workspace-write for fresh and resume runs", () => {
const resolved = resolveCliBackendConfig("codex-cli");
@@ -166,3 +193,24 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions");
});
});
describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
it("uses Gemini CLI json args and existing-session resume mode", () => {
const resolved = resolveCliBackendConfig("google-gemini-cli");
expect(resolved).not.toBeNull();
expect(resolved?.bundleMcp).toBe(false);
expect(resolved?.config.args).toEqual(["--prompt", "--output-format", "json"]);
expect(resolved?.config.resumeArgs).toEqual([
"--resume",
"{sessionId}",
"--prompt",
"--output-format",
"json",
]);
expect(resolved?.config.modelArg).toBe("--model");
expect(resolved?.config.sessionMode).toBe("existing");
expect(resolved?.config.sessionIdFields).toEqual(["session_id", "sessionId"]);
expect(resolved?.config.modelAliases?.pro).toBe("gemini-3.1-pro-preview");
});
});

View File

@@ -1,110 +1,13 @@
import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import {
CLI_FRESH_WATCHDOG_DEFAULTS,
CLI_RESUME_WATCHDOG_DEFAULTS,
} from "./cli-watchdog-defaults.js";
import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js";
import { normalizeProviderId } from "./model-selection.js";
export type ResolvedCliBackend = {
id: string;
config: CliBackendConfig;
};
const CLAUDE_MODEL_ALIASES: Record<string, string> = {
opus: "opus",
"opus-4.6": "opus",
"opus-4.5": "opus",
"opus-4": "opus",
"claude-opus-4-6": "opus",
"claude-opus-4-5": "opus",
"claude-opus-4": "opus",
sonnet: "sonnet",
"sonnet-4.6": "sonnet",
"sonnet-4.5": "sonnet",
"sonnet-4.1": "sonnet",
"sonnet-4.0": "sonnet",
"claude-sonnet-4-6": "sonnet",
"claude-sonnet-4-5": "sonnet",
"claude-sonnet-4-1": "sonnet",
"claude-sonnet-4-0": "sonnet",
haiku: "haiku",
"haiku-3.5": "haiku",
"claude-haiku-3-5": "haiku",
};
const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
const CLAUDE_BYPASS_PERMISSIONS_MODE = "bypassPermissions";
const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
command: "claude",
args: ["-p", "--output-format", "json", "--permission-mode", "bypassPermissions"],
resumeArgs: [
"-p",
"--output-format",
"json",
"--permission-mode",
"bypassPermissions",
"--resume",
"{sessionId}",
],
output: "json",
input: "arg",
modelArg: "--model",
modelAliases: CLAUDE_MODEL_ALIASES,
sessionArg: "--session-id",
sessionMode: "always",
sessionIdFields: ["session_id", "sessionId", "conversation_id", "conversationId"],
systemPromptArg: "--append-system-prompt",
systemPromptMode: "append",
systemPromptWhen: "first",
clearEnv: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD"],
reliability: {
watchdog: {
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },
resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS },
},
},
serialize: true,
};
const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
command: "codex",
args: [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"workspace-write",
"--skip-git-repo-check",
],
resumeArgs: [
"exec",
"resume",
"{sessionId}",
"--color",
"never",
"--sandbox",
"workspace-write",
"--skip-git-repo-check",
],
output: "jsonl",
resumeOutput: "text",
input: "arg",
modelArg: "--model",
sessionIdFields: ["thread_id"],
sessionMode: "existing",
imageArg: "--image",
imageMode: "repeat",
reliability: {
watchdog: {
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },
resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS },
},
},
serialize: true,
bundleMcp: boolean;
pluginId?: string;
};
function normalizeBackendKey(key: string): string {
@@ -123,6 +26,11 @@ function pickBackendConfig(
return undefined;
}
function resolveRegisteredBackend(provider: string) {
const normalized = normalizeBackendKey(provider);
return resolveRuntimeCliBackends().find((entry) => normalizeBackendKey(entry.id) === normalized);
}
function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig): CliBackendConfig {
if (!override) {
return { ...base };
@@ -160,53 +68,11 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig)
};
}
function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
if (!args) {
return args;
}
const normalized: string[] = [];
let sawLegacySkip = false;
let hasPermissionMode = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) {
sawLegacySkip = true;
continue;
}
if (arg === CLAUDE_PERMISSION_MODE_ARG) {
hasPermissionMode = true;
normalized.push(arg);
const maybeValue = args[i + 1];
if (typeof maybeValue === "string") {
normalized.push(maybeValue);
i += 1;
}
continue;
}
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
hasPermissionMode = true;
}
normalized.push(arg);
}
if (sawLegacySkip && !hasPermissionMode) {
normalized.push(CLAUDE_PERMISSION_MODE_ARG, CLAUDE_BYPASS_PERMISSIONS_MODE);
}
return normalized;
}
function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
return {
...config,
args: normalizeClaudePermissionArgs(config.args),
resumeArgs: normalizeClaudePermissionArgs(config.resumeArgs),
};
}
export function resolveCliBackendIds(cfg?: OpenClawConfig): Set<string> {
const ids = new Set<string>([
normalizeBackendKey("claude-cli"),
normalizeBackendKey("codex-cli"),
]);
const ids = new Set<string>();
for (const backend of resolveRuntimeCliBackends()) {
ids.add(normalizeBackendKey(backend.id));
}
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
for (const key of Object.keys(configured)) {
ids.add(normalizeBackendKey(key));
@@ -221,23 +87,20 @@ export function resolveCliBackendConfig(
const normalized = normalizeBackendKey(provider);
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
const override = pickBackendConfig(configured, normalized);
if (normalized === "claude-cli") {
const merged = mergeBackendConfig(DEFAULT_CLAUDE_BACKEND, override);
const config = normalizeClaudeBackendConfig(merged);
const registered = resolveRegisteredBackend(normalized);
if (registered) {
const merged = mergeBackendConfig(registered.config, override);
const config = registered.normalizeConfig ? registered.normalizeConfig(merged) : merged;
const command = config.command?.trim();
if (!command) {
return null;
}
return { id: normalized, config: { ...config, command } };
}
if (normalized === "codex-cli") {
const merged = mergeBackendConfig(DEFAULT_CODEX_BACKEND, override);
const command = merged.command?.trim();
if (!command) {
return null;
}
return { id: normalized, config: { ...merged, command } };
return {
id: normalized,
config: { ...config, command },
bundleMcp: registered.bundleMcp === true,
pluginId: registered.pluginId,
};
}
if (!override) {
@@ -247,5 +110,5 @@ export function resolveCliBackendConfig(
if (!command) {
return null;
}
return { id: normalized, config: { ...override, command } };
return { id: normalized, config: { ...override, command }, bundleMcp: false };
}

View File

@@ -50,7 +50,9 @@ import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildSystemPromptReport } from "./system-prompt-report.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "./workspace-run.js";
const log = createSubsystemLogger("agent/claude-cli");
const log = createSubsystemLogger("agent/cli-backend");
const CLI_BACKEND_LOG_OUTPUT_ENV = "OPENCLAW_CLI_BACKEND_LOG_OUTPUT";
const LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV = "OPENCLAW_CLAUDE_CLI_LOG_OUTPUT";
export async function runCliAgent(params: {
sessionId: string;
@@ -97,7 +99,7 @@ export async function runCliAgent(params: {
throw new Error(`Unknown CLI backend: ${params.provider}`);
}
const preparedBackend = await prepareCliBundleMcpConfig({
backendId: backendResolved.id,
enabled: backendResolved.bundleMcp,
backend: backendResolved.config,
workspaceDir,
config: params.config,
@@ -264,7 +266,9 @@ export async function runCliAgent(params: {
log.info(
`cli exec: provider=${params.provider} model=${normalizedModel} promptChars=${params.prompt.length}`,
);
const logOutputText = isTruthyEnvValue(process.env.OPENCLAW_CLAUDE_CLI_LOG_OUTPUT);
const logOutputText =
isTruthyEnvValue(process.env[CLI_BACKEND_LOG_OUTPUT_ENV]) ||
isTruthyEnvValue(process.env[LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV]);
if (logOutputText) {
const logArgs: string[] = [];
for (let i = 0; i < args.length; i += 1) {

View File

@@ -15,7 +15,7 @@ afterEach(async () => {
});
describe("prepareCliBundleMcpConfig", () => {
it("injects a merged --mcp-config overlay for claude-cli", async () => {
it("injects a merged --mcp-config overlay for bundle-MCP-enabled backends", async () => {
const env = captureEnv(["HOME"]);
try {
const homeDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-home-");
@@ -33,7 +33,7 @@ describe("prepareCliBundleMcpConfig", () => {
};
const prepared = await prepareCliBundleMcpConfig({
backendId: "claude-cli",
enabled: true,
backend: {
command: "node",
args: ["./fake-claude.mjs"],
@@ -57,4 +57,18 @@ describe("prepareCliBundleMcpConfig", () => {
env.restore();
}
});
it("leaves args untouched when bundle MCP is disabled", async () => {
const prepared = await prepareCliBundleMcpConfig({
enabled: false,
backend: {
command: "node",
args: ["./fake-cli.mjs"],
},
workspaceDir: "/tmp/openclaw-bundle-mcp-disabled",
});
expect(prepared.backend.args).toEqual(["./fake-cli.mjs"]);
expect(prepared.cleanup).toBeUndefined();
});
});

View File

@@ -63,13 +63,13 @@ function injectMcpConfigArgs(args: string[] | undefined, mcpConfigPath: string):
}
export async function prepareCliBundleMcpConfig(params: {
backendId: string;
enabled: boolean;
backend: CliBackendConfig;
workspaceDir: string;
config?: OpenClawConfig;
warn?: (message: string) => void;
}): Promise<PreparedCliBundleMcpConfig> {
if (params.backendId !== "claude-cli") {
if (!params.enabled) {
return { backend: params.backend };
}

View File

@@ -6,6 +6,7 @@ import {
toAgentModelListLike,
} from "../config/model-input.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import {
resolveAgentConfig,
@@ -81,10 +82,9 @@ export {
export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean {
const normalized = normalizeProviderId(provider);
if (normalized === "claude-cli") {
return true;
}
if (normalized === "codex-cli") {
if (
resolveRuntimeCliBackends().some((backend) => normalizeProviderId(backend.id) === normalized)
) {
return true;
}
const backends = cfg?.agents?.defaults?.cliBackends ?? {};