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 @@ -10,7 +10,7 @@ Status: in-progress(2026-07-29 调研完成,已拆出 04–07)

## 清单

- [ ] Pi Agent → `05-pi-agent-adapter.md`
- [x] Pi Agent → `05-pi-agent-adapter.md`(适配器已实现,待冒烟发版)
- [x] OpenCode → `04-opencode-adapter.md`(适配器已实现,待冒烟发版)
- [ ] Openclaw → `06-openclaw-adapter.md`
- [ ] Hermes → `07-hermes-adapter.md`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 05 Pi Agent 适配器

Status: ready-for-agent(调研已落,2026-07-29)
Status: implemented(2026-07-29 适配器+测试完成,待真机冒烟后发 minor

从 03 占位拆出。Pi = badlogic/pi-mono(earendil-works/pi 镜像)coding agent,bin `pi`。

Expand Down Expand Up @@ -28,13 +28,17 @@ Status: ready-for-agent(调研已落,2026-07-29)
- 默认模型:交互 `/model` 或 `--model` 旗标;**落盘持久化机制(settings.json?)文档未写清,实施时读源码确认**。
- Anthropic 兼容 compat 项(supportsStrictTools 等)仅在走 anthropic-messages 时需要,走 openai-completions 可忽略。

## 待实施核对项
## 核对结论(读 badlogic/pi-mono 源码,2026-07-29)

- [ ] 默认模型持久化位置(~/.pi/agent/settings.json?)
- [ ] models.json 与内置 provider 的 merge 语义(同名覆盖?)
- [ ] detect 依据:`~/.pi/` 目录
- [x] **模型元数据全可选**:ModelDefinitionSchema 只有 `id` 必填(model-config.ts),"要求全量元数据"是过时文档误导 → models 数组只写 `{id}`。
- [x] 默认模型持久化:`~/.pi/agent/settings.json` 的 `defaultProvider` + `defaultModel`(resolver 要求两者同有且 provider 已配置鉴权,model-resolver.ts)。
- [x] key 走 pi 原生凭证库 `~/.pi/agent/auth.json`:`{"apiflux":{"type":"api_key","key":...}}`(0600/目录 0700);provider-composer.ts 里 stored credential 优先于 models.json `apiKey` 字段(后者另支持 `$ENV`/`!命令`)。
- [x] merge:models.json 的 providers 与内置按 id 叠加,新 id `apiflux` 纯新增无冲突。
- [x] detect:`~/.pi/`;路径尊重 `PI_CODING_AGENT_DIR` 覆盖(config.ts getAgentDir)。
- [x] ⚠️ pi 读 JSON 会 strip 注释(JSONC):适配器安全读取,解析失败不动文件、打印手动 snippet(models.json/settings.json 各自独立降级,auth.json 照写)。

## 方案

- 写 `providers.apiflux`(openai-completions + withV1),key 字面量;仅写入用户选中的模型(不同步全目录),contextWindow/maxTokens 用保守默认(128k/32k),cost 全 0(网关侧计费,本地显示不作数——README 注明)。
- 未选模型时的行为:Pi 要求 models 数组非空才有意义,倾向「未选模型则提示必须 --model / 交互选择」——实施时定。
- 已实现 `src/adapters/pi.ts`:models.json 写 `providers.apiflux`(openai-completions + withV1 + 全量模型 `{id}` 列表);auth.json 写凭证(0600);选中模型时 settings.json 写 defaultProvider/defaultModel。
- plan() 冲突项:baseUrl 变更 / auth key 变更(脱敏)/ 默认模型变更 / JSONC 无法解析(manual merge 提示)。
- 冒烟步骤:`apiflux init --tool pi --model <id>` → 启动 pi 确认 /model 列出 ApiFlux 模型、默认模型生效、对话跑通。
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Paste your ApiFlux API key when prompted, pick the tools to configure, done. The
| Claude Code | `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` in `~/.claude/settings.json` |
| Codex CLI | `model_providers.apiflux` in `~/.codex/config.toml` (key stays in the `APIFLUX_API_KEY` env var, never in the file); with `--model`, also sets it as the default provider/model |
| 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` |
| 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 @@ -35,7 +36,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`, `export` |
| `--tool <id>` | Configure only this tool (repeatable): `claude-code`, `codex`, `opencode`, `pi`, `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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "apiflux-cli",
"version": "0.4.0",
"description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode)",
"description": "One-command setup of ApiFlux for local AI coding tools (Claude Code, Codex CLI, OpenCode, Pi)",
"type": "module",
"bin": {
"apiflux": "./dist/apiflux.js"
Expand Down
196 changes: 196 additions & 0 deletions src/adapters/pi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { piAdapter } from "./pi";

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-pi-"));
}

function agentDir(home: string): string {
return join(home, ".pi", "agent");
}

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

// Pi honors PI_CODING_AGENT_DIR (config.ts getAgentDir); pin it to unset so
// paths derive from the temp home.
let savedAgentDir: string | undefined;
beforeEach(() => {
savedAgentDir = process.env.PI_CODING_AGENT_DIR;
delete process.env.PI_CODING_AGENT_DIR;
});
afterEach(() => {
if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = savedAgentDir;
});

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

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

describe("piAdapter.write", () => {
test("writes provider to models.json without key material and key to auth.json", () => {
const home = tempHome();
const notes = piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
const models = readJson(join(agentDir(home), "models.json"));
const provider = models.providers.apiflux;
expect(provider.name).toBe("ApiFlux");
expect(provider.baseUrl).toBe(`${BASE_URL}/v1`);
expect(provider.api).toBe("openai-completions");
expect(provider.models.map((m: any) => m.id).sort()).toEqual([...MODELS].sort());
expect(readFileSync(join(agentDir(home), "models.json"), "utf8")).not.toContain(KEY);
const auth = readJson(join(agentDir(home), "auth.json"));
expect(auth.apiflux).toEqual({ type: "api_key", key: KEY });
expect(statSync(join(agentDir(home), "auth.json")).mode & 0o777).toBe(0o600);
expect(notes.join("\n")).toContain("models.json");
});

test("chosen model persists defaultProvider/defaultModel in settings.json", () => {
const home = tempHome();
piAdapter.write(home, {
baseUrl: BASE_URL,
key: KEY,
model: "deepseek-v4-pro",
availableModels: MODELS,
});
const settings = readJson(join(agentDir(home), "settings.json"));
expect(settings.defaultProvider).toBe("apiflux");
expect(settings.defaultModel).toBe("deepseek-v4-pro");
});

test("no chosen model leaves settings.json untouched", () => {
const home = tempHome();
mkdirSync(agentDir(home), { recursive: true });
writeFileSync(
join(agentDir(home), "settings.json"),
JSON.stringify({ defaultProvider: "anthropic", defaultModel: "claude-sonnet-5", theme: "dark" }),
);
piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
const settings = readJson(join(agentDir(home), "settings.json"));
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModel).toBe("claude-sonnet-5");
expect(settings.theme).toBe("dark");
});

test("without availableModels falls back to the chosen model only", () => {
const home = tempHome();
piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, model: "kimi-k2.6" });
const models = readJson(join(agentDir(home), "models.json"));
expect(models.providers.apiflux.models.map((m: any) => m.id)).toEqual(["kimi-k2.6"]);
});

test("preserves other providers, credentials, and settings keys", () => {
const home = tempHome();
mkdirSync(agentDir(home), { recursive: true });
writeFileSync(
join(agentDir(home), "models.json"),
JSON.stringify({
providers: { ollama: { baseUrl: "http://localhost:11434/v1", api: "openai-completions" } },
}),
);
writeFileSync(join(agentDir(home), "auth.json"), JSON.stringify({ openrouter: { type: "api_key", key: "sk-or-1" } }));
piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
const models = readJson(join(agentDir(home), "models.json"));
expect(models.providers.ollama.baseUrl).toBe("http://localhost:11434/v1");
expect(models.providers.apiflux.baseUrl).toBe(`${BASE_URL}/v1`);
const auth = readJson(join(agentDir(home), "auth.json"));
expect(auth.openrouter).toEqual({ type: "api_key", key: "sk-or-1" });
expect(auth.apiflux).toEqual({ type: "api_key", key: KEY });
});

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

test("unparseable models.json (JSONC comments) is left untouched with manual instructions", () => {
const home = tempHome();
mkdirSync(agentDir(home), { recursive: true });
const original = `{\n // my hand-tuned providers\n "providers": {}\n}\n`;
writeFileSync(join(agentDir(home), "models.json"), original);
const notes = piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, availableModels: MODELS });
expect(readFileSync(join(agentDir(home), "models.json"), "utf8")).toBe(original);
// Manual snippet must still be offered, and auth.json still written.
expect(notes.join("\n")).toContain('"apiflux"');
expect(readJson(join(agentDir(home), "auth.json")).apiflux.key).toBe(KEY);
});
});

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

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

test("existing different default model/provider → conflicts when a model is chosen", () => {
const home = tempHome();
mkdirSync(agentDir(home), { recursive: true });
writeFileSync(
join(agentDir(home), "settings.json"),
JSON.stringify({ defaultProvider: "anthropic", defaultModel: "claude-sonnet-5" }),
);
const { conflicts } = piAdapter.plan(home, { baseUrl: BASE_URL, key: KEY, model: "deepseek-v4-pro" });
expect(conflicts.some((line) => line.includes("claude-sonnet-5"))).toBe(true);
});

test("existing different auth key → conflict without echoing key material", () => {
const home = tempHome();
mkdirSync(agentDir(home), { recursive: true });
writeFileSync(
join(agentDir(home), "auth.json"),
JSON.stringify({ apiflux: { type: "api_key", key: "sk-old-secret" } }),
);
const { conflicts } = piAdapter.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("unparseable models.json → conflict announcing manual merge", () => {
const home = tempHome();
mkdirSync(agentDir(home), { recursive: true });
writeFileSync(join(agentDir(home), "models.json"), `{ // comment\n "providers": {} }`);
const { conflicts } = piAdapter.plan(home, { baseUrl: BASE_URL, key: KEY });
expect(conflicts.length).toBe(1);
expect(conflicts[0].toLowerCase()).toContain("manual");
});
});
Loading
Loading