Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Status: in-progress(2026-07-29 调研完成,已拆出 04–07)

- [x] Pi Agent → `05-pi-agent-adapter.md`(适配器已实现,待冒烟发版)
- [x] OpenCode → `04-opencode-adapter.md`(适配器已实现,待冒烟发版)
- [ ] Openclaw → `06-openclaw-adapter.md`
- [x] Openclaw → `06-openclaw-adapter.md`(适配器已实现,Docker 冒烟通过,待发版)
- [x] Hermes → `07-hermes-adapter.md`(适配器已实现,venv 冒烟通过,待发版)

调研结论(2026-07-29):四个都支持自定义 OpenAI 兼容 base URL,无 wontfix 候选;难点分布——OpenCode(纯 JSON,最顺)< Pi(模型需全量元数据)< Hermes(YAML+.env 两文件)< Openclaw(JSON5 回写丢注释风险)。
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 06 Openclaw 适配器

Status: research-done(2026-07-29 源码级调研闭环,无真机冒烟——用户拍板不在本机安装
Status: implemented(2026-07-29 适配器+测试完成,Docker 隔离冒烟通过,待发 minor

从 03 占位拆出。

Expand Down Expand Up @@ -35,4 +35,5 @@ Status: research-done(2026-07-29 源码级调研闭环,无真机冒烟——

- json5 依赖读(容注释)、`models.providers.apiflux` merge 回写(全量模型 `{id,name}`)、选中模型时置 `agents.defaults.model.primary`。
- plan() 冲突:baseUrl 变更 / apiKey 变更(脱敏)/ primary 变更 / 原文件含注释(提示将被抹掉,同官方行为)/ 含 `$include`(降级手动 snippet)。
- 验证:单测 + 源码级契约(无真机冒烟);可选 docker 冒烟待议(仓库自带 Dockerfile)。
- 验证(2026-07-29 已过):单测 16 例 + **Docker 隔离冒烟**(ghcr.io/openclaw/openclaw:latest,挂载隔离 state dir):`openclaw agent --local` 默认模型与 `--model apiflux/claude-haiku-4-5` 两发 SMOKE-OK,日志确认打到 apiflux.ai /v1/chat/completions 200;冒烟目录(含 key)已删。
- ⚠️ Docker 冒烟坑:宿主目录必须在 Docker Desktop 文件共享列表内(/private/tmp 不在,bind 会静默落到 VM 空目录);容器内以 --user root -e HOME=/root 挂 /root/.openclaw 最省事。
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Paste your ApiFlux API key when prompted, pick the tools to configure, done. The
| OpenCode | `provider.apiflux` in `~/.config/opencode/opencode.json` with every model your key can use; key goes to OpenCode's own credential store (`~/.local/share/opencode/auth.json`, `0600`), never into the config file; with `--model`, also sets it as the default model |
| Pi | `providers.apiflux` in `~/.pi/agent/models.json` with every model your key can use; key goes to Pi's own credential store (`~/.pi/agent/auth.json`, `0600`), never into the config file; with `--model`, also persists it via `defaultProvider`/`defaultModel` in `settings.json` |
| Hermes | `custom_providers` entry in `~/.hermes/config.yaml` (comment-preserving YAML edit) with every model your key can use; key goes to `~/.hermes/.env` as `APIFLUX_API_KEY` (`0600`), never into config.yaml; with `--model`, also sets `model.provider`/`model.default` |
| OpenClaw | `models.providers.apiflux` in `~/.openclaw/openclaw.json` with every model your key can use; with `--model`, also sets `agents.defaults.model.primary` (existing fallbacks kept). Note: like OpenClaw's own config writes, rewriting strips JSON5 comments; `$include` configs get a manual snippet instead |
| Anything OpenAI-compatible | Prints `export OPENAI_BASE_URL` / `OPENAI_API_KEY` (and `OPENAI_MODEL` if chosen) lines for your shell or `.env` |

Any model your key can use works in any tool — the ApiFlux gateway converts between the Anthropic and OpenAI protocols. Picking a non-Claude model for Claude Code also pins its small/fast background model so every request stays on your chosen model.
Expand All @@ -37,7 +38,7 @@ npx apiflux-cli init --key - # read the key from stdin
| --- | --- |
| `--key <key\|->` | API key; `-` reads from stdin; omit for a hidden prompt |
| `--base-url <url>` | Override the API origin (self-hosted / testing) |
| `--tool <id>` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `hermes`, `export` |
| `--tool <id>` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `hermes`, `openclaw`, `export` |
| `--model <id>` | Default model for the configured tools; omit in a terminal to pick from a list, omit in scripts to keep each tool's default |
| `--yes` | Skip confirmations, overwrite conflicting config |
| `--skip-verify` | Don't send the verification request |
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "apiflux-cli",
"version": "0.6.0",
"description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode, Pi, Hermes)",
"description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode, Pi, Hermes, OpenClaw)",
"type": "module",
"bin": {
"apiflux": "./dist/apiflux.js"
Expand Down Expand Up @@ -40,6 +40,7 @@
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.7.0",
"json5": "^2.2.3",
"smol-toml": "^1.7.0",
"yaml": "^2.9.0"
}
Expand Down
225 changes: 225 additions & 0 deletions src/adapters/openclaw.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openclawAdapter } from "./openclaw";

const BASE_URL = "https://apiflux.ai";
const KEY = "sk-test1234";
const MODELS = ["deepseek-v4-pro", "kimi-k2.6", "claude-sonnet-5"];

function tempHome(): string {
return mkdtempSync(join(tmpdir(), "apiflux-cli-openclaw-"));
}

function configPath(home: string): string {
return join(home, ".openclaw", "openclaw.json");
}

function readJson(home: string): any {
return JSON.parse(readFileSync(configPath(home), "utf8"));
}

// OpenClaw honors OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH (src/config/paths.ts);
// pin them to unset so paths derive from the temp home.
let savedStateDir: string | undefined;
let savedConfigPath: string | undefined;
beforeEach(() => {
savedStateDir = process.env.OPENCLAW_STATE_DIR;
savedConfigPath = process.env.OPENCLAW_CONFIG_PATH;
delete process.env.OPENCLAW_STATE_DIR;
delete process.env.OPENCLAW_CONFIG_PATH;
});
afterEach(() => {
if (savedStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR;
else process.env.OPENCLAW_STATE_DIR = savedStateDir;
if (savedConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
else process.env.OPENCLAW_CONFIG_PATH = savedConfigPath;
});

describe("openclawAdapter.detect", () => {
test("false without ~/.openclaw, true with it", () => {
const home = tempHome();
expect(openclawAdapter.detect(home)).toBe(false);
mkdirSync(join(home, ".openclaw"));
expect(openclawAdapter.detect(home)).toBe(true);
});

test("honors OPENCLAW_STATE_DIR override", () => {
const home = tempHome();
process.env.OPENCLAW_STATE_DIR = join(home, "custom-state");
expect(openclawAdapter.detect(home)).toBe(false);
mkdirSync(join(home, "custom-state"), { recursive: true });
expect(openclawAdapter.detect(home)).toBe(true);
});
});

describe("openclawAdapter.write", () => {
test("writes provider with minimal model entries and literal apiKey", () => {
const home = tempHome();
const notes = openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
const config = readJson(home);
const provider = config.models.providers.apiflux;
expect(provider.baseUrl).toBe(`${BASE_URL}/v1`);
expect(provider.apiKey).toBe(KEY);
expect(provider.api).toBe("openai-completions");
// Official docs' minimal shape: {id, name}; runtime fills the rest.
expect(provider.models.map((m: any) => m.id).sort()).toEqual([...MODELS].sort());
for (const model of provider.models) expect(model.name).toBe(model.id);
// models.mode must not be introduced (default is already merge).
expect(config.models.mode).toBeUndefined();
expect(notes.join("\n")).toContain(configPath(home));
});

test("chosen model sets agents.defaults.model.primary", () => {
const home = tempHome();
openclawAdapter.write(home, {
baseUrl: BASE_URL,
key: KEY,
model: "deepseek-v4-pro",
availableModels: MODELS,
});
expect(readJson(home).agents.defaults.model.primary).toBe("apiflux/deepseek-v4-pro");
});

test("keeps existing fallbacks when updating primary", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(
configPath(home),
JSON.stringify({
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-5", fallbacks: ["openai/gpt-5.5"] } } },
}),
);
openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, model: "kimi-k2.6", availableModels: MODELS });
const model = readJson(home).agents.defaults.model;
expect(model.primary).toBe("apiflux/kimi-k2.6");
expect(model.fallbacks).toEqual(["openai/gpt-5.5"]);
});

test("no chosen model leaves agents.defaults untouched", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(configPath(home), JSON.stringify({ agents: { defaults: { model: { primary: "a/b" } } } }));
openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
expect(readJson(home).agents.defaults.model.primary).toBe("a/b");
});

test("preserves other providers, models.mode, and unrelated keys; parses JSON5", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(
configPath(home),
`{
// my config
gateway: { port: 18789 },
models: {
mode: "replace",
providers: {
moonshot: { baseUrl: "https://api.moonshot.ai/v1", apiKey: "sk-moon", api: "openai-completions", models: [] },
},
},
}`,
);
openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
const config = readJson(home);
expect(config.gateway.port).toBe(18789);
expect(config.models.mode).toBe("replace");
expect(config.models.providers.moonshot.apiKey).toBe("sk-moon");
expect(config.models.providers.apiflux.baseUrl).toBe(`${BASE_URL}/v1`);
});

test("backs up existing config once and is idempotent", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(configPath(home), JSON.stringify({ gateway: { port: 1 } }));
openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
const first = readFileSync(configPath(home), "utf8");
openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
expect(readFileSync(configPath(home), "utf8")).toBe(first);
const backups = readdirSync(join(home, ".openclaw")).filter((f) => f.startsWith("openclaw.json.bak."));
expect(backups.length).toBe(1);
});

test("config with $include is left untouched with manual instructions", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
const original = `{ "$include": "./base.json5", "gateway": { "port": 1 } }`;
writeFileSync(configPath(home), original);
const notes = openclawAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
expect(readFileSync(configPath(home), "utf8")).toBe(original);
expect(notes.join("\n")).toContain("apiflux");
});
});

describe("openclawAdapter.plan", () => {
test("no config → no conflicts", () => {
expect(openclawAdapter.plan(tempHome(), { baseUrl: BASE_URL, key: KEY }).conflicts).toEqual([]);
});

test("existing apiflux provider with different baseUrl → conflict", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(
configPath(home),
JSON.stringify({ models: { providers: { apiflux: { baseUrl: "https://old.example.com/v1", models: [] } } } }),
);
const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY });
expect(conflicts.length).toBe(1);
expect(conflicts[0]).toContain("https://old.example.com/v1");
});

test("existing different apiKey → conflict without echoing key material", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(
configPath(home),
JSON.stringify({
models: { providers: { apiflux: { baseUrl: `${BASE_URL}/v1`, apiKey: "sk-old-secret", models: [] } } },
}),
);
const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY });
expect(conflicts.length).toBe(1);
expect(conflicts[0]).not.toContain("sk-old-secret");
expect(conflicts[0]).toContain("sk-***");
});

test("existing different primary → conflict when a model is chosen", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(
configPath(home),
JSON.stringify({ agents: { defaults: { model: { primary: "anthropic/claude-sonnet-5" } } } }),
);
const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY, model: "deepseek-v4-pro" });
expect(conflicts.some((line) => line.includes("anthropic/claude-sonnet-5"))).toBe(true);
});

test("comments in config → warning that they will be stripped (matching official behavior)", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(configPath(home), `{\n // tuned by hand\n gateway: { port: 1 },\n}`);
const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY });
expect(conflicts.length).toBe(1);
expect(conflicts[0].toLowerCase()).toContain("comment");
});

test("comment-looking slashes inside strings are not flagged", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(
configPath(home),
JSON.stringify({ models: { providers: { x: { baseUrl: "https://a//b/*c", models: [] } } } }),
);
expect(openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY }).conflicts).toEqual([]);
});

test("$include config → conflict announcing manual merge", () => {
const home = tempHome();
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(configPath(home), `{ "$include": "./base.json5" }`);
const { conflicts } = openclawAdapter.plan(home, { baseUrl: BASE_URL, key: KEY });
expect(conflicts.length).toBe(1);
expect(conflicts[0].toLowerCase()).toContain("manual");
});
});
Loading
Loading