Skip to content

Commit 0a700ff

Browse files
Merge pull request #250 from modelstudioai/feat/pi-agent-integration
feat: add Pi support for agent configuration and MCP registration
2 parents 3be62c7 + 51048cd commit 0a700ff

13 files changed

Lines changed: 584 additions & 56 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ _Built for AI Agents. Every command works as a structured tool call._
2626
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
2727
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
2828
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
29-
- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork (千问办公), Qwen Code, or Gemini CLI with channel attribution preserved
29+
- **Native MCP setup** — Register Bailian MCP servers in Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork (千问办公), Qwen Code, Gemini CLI, or Pi with channel attribution preserved
3030
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
3131
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
3232
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step

‎README.zh.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
2525
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
2626
- **素材理解** — 图像、文档、音频、长视频的解析与问答
2727
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
28-
- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code 或 Gemini CLI,并保留渠道归因
28+
- **原生 MCP 接入** — 将百炼 MCP 服务注册到 Codex、Claude Code、Cursor、Qoder、Qoder Work、千问办公(QwenWork)、Qwen Code、Gemini CLI 或 Pi,并保留渠道归因
2929
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
3030
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
3131
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent

‎packages/commands/src/commands/config/agent/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export default defineCommand({
132132
model,
133133
contextWindow,
134134
wireApi,
135+
localize: ctx.localize,
135136
};
136137
const summary = agentDef.write(params);
137138

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { homedir } from "node:os";
2+
import { join } from "node:path";
3+
4+
/** Pi's official config directory override, shared by model and MCP writers. */
5+
export function getPiAgentDir(home: string = homedir()): string {
6+
const configured = process.env.PI_CODING_AGENT_DIR;
7+
if (!configured) return join(home, ".pi", "agent");
8+
if (configured === "~") return home;
9+
if (configured.startsWith("~/") || configured.startsWith("~\\")) {
10+
return join(home, configured.slice(2));
11+
}
12+
return configured;
13+
}

‎packages/commands/src/commands/config/agent/writers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import opencode from "./writers/opencode.ts";
77
import openclaw from "./writers/openclaw.ts";
88
import hermes from "./writers/hermes.ts";
99
import codex from "./writers/codex.ts";
10+
import pi from "./writers/pi.ts";
1011

1112
export const AGENTS: Record<string, AgentDef> = {
1213
"claude-code": claudeCode,
@@ -15,6 +16,7 @@ export const AGENTS: Record<string, AgentDef> = {
1516
openclaw,
1617
hermes,
1718
codex,
19+
pi,
1820
};
1921

2022
export const VALID_AGENT_NAMES = Object.keys(AGENTS) as [string, ...string[]];
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { BailianError, ExitCode, type LocalizedText } from "bailian-cli-core";
4+
import { getPiAgentDir } from "../pi-home.ts";
5+
import {
6+
backup,
7+
isAnthropicEndpoint,
8+
stripJsonc,
9+
writeJsonAtomic,
10+
type AgentDef,
11+
} from "./utils.ts";
12+
13+
const PROVIDER_ID = "bailian-cli";
14+
15+
function isObject(value: unknown): value is Record<string, unknown> {
16+
return typeof value === "object" && value !== null && !Array.isArray(value);
17+
}
18+
19+
export default {
20+
label: "Pi",
21+
write({
22+
baseUrl,
23+
apiKey,
24+
model,
25+
localize = (text: LocalizedText) => (typeof text === "string" ? text : text["en-US"]),
26+
}) {
27+
const agentDir = getPiAgentDir();
28+
const paths = ["models.json", "auth.json", "settings.json"].map((filename) =>
29+
join(agentDir, filename),
30+
);
31+
const originals = paths.map((path) =>
32+
existsSync(path) ? readFileSync(path, "utf8") : undefined,
33+
);
34+
// Read every file before writing any of them. Malformed user config must
35+
// fail closed rather than silently discarding providers or credentials.
36+
const configs = paths.map((path, index) => {
37+
try {
38+
const original = originals[index];
39+
const content = original?.replace(/^\uFEFF/, "");
40+
const parsed: unknown =
41+
content === undefined ? {} : JSON.parse(index === 0 ? stripJsonc(content) : content);
42+
if (!isObject(parsed)) throw new Error("root value is not an object");
43+
return parsed;
44+
} catch {
45+
throw new BailianError(
46+
localize({
47+
"en-US": `Cannot update Pi because ${path} is invalid; no configuration was changed.`,
48+
"zh-CN": `无法更新 Pi:${path} 格式无效,配置未被修改。`,
49+
}),
50+
ExitCode.GENERAL,
51+
);
52+
}
53+
});
54+
const [modelsConfig, authConfig, settingsConfig] = configs;
55+
if (modelsConfig.providers !== undefined && !isObject(modelsConfig.providers)) {
56+
throw new BailianError(
57+
localize({
58+
"en-US":
59+
"Cannot update Pi because models.json providers is not an object; no configuration was changed.",
60+
"zh-CN": "无法更新 Pi:models.json 中的 providers 必须为对象,配置未被修改。",
61+
}),
62+
ExitCode.GENERAL,
63+
);
64+
}
65+
const providers = (modelsConfig.providers ?? {}) as Record<string, unknown>;
66+
const existing = providers[PROVIDER_ID];
67+
if (
68+
existing !== undefined &&
69+
(!isObject(existing) || (existing.models !== undefined && !Array.isArray(existing.models)))
70+
) {
71+
throw new BailianError(
72+
localize({
73+
"en-US":
74+
"Cannot update Pi because the bailian-cli provider is invalid; no configuration was changed.",
75+
"zh-CN": "无法更新 Pi:bailian-cli provider 格式无效,配置未被修改。",
76+
}),
77+
ExitCode.GENERAL,
78+
);
79+
}
80+
const provider = (existing ?? {}) as Record<string, unknown>;
81+
const models = (provider.models ?? []) as Record<string, unknown>[];
82+
const existingModel = models.find((entry) => entry.id === model);
83+
const updatedModel: Record<string, unknown> = {
84+
...existingModel,
85+
id: model,
86+
name: existingModel?.name ?? model,
87+
};
88+
// Pi gives model-level connection fields precedence over provider defaults.
89+
// Reconfiguration must use the newly selected endpoint and protocol.
90+
delete updatedModel.baseUrl;
91+
delete updatedModel.api;
92+
provider.models = existingModel
93+
? models.map((entry) => (entry.id === model ? updatedModel : entry))
94+
: [...models, updatedModel];
95+
provider.baseUrl = baseUrl;
96+
provider.api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
97+
// Authentication belongs in Pi's private credential store, not model metadata.
98+
delete provider.apiKey;
99+
providers[PROVIDER_ID] = provider;
100+
modelsConfig.providers = providers;
101+
authConfig[PROVIDER_ID] = { type: "api_key", key: apiKey };
102+
settingsConfig.defaultProvider = PROVIDER_ID;
103+
settingsConfig.defaultModel = model;
104+
105+
for (const [index, path] of paths.entries()) {
106+
backup(path);
107+
writeJsonAtomic(path, configs[index]);
108+
}
109+
return {
110+
paths,
111+
nextStep: localize({
112+
"en-US": "Run `pi` to start using Pi with DashScope.",
113+
"zh-CN": "运行 `pi` 开始使用百炼模型。",
114+
}),
115+
};
116+
},
117+
} satisfies AgentDef;

‎packages/commands/src/commands/config/agent/writers/utils.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { dirname } from "path";
22
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
3-
import { BailianError, ExitCode } from "bailian-cli-core";
3+
import { BailianError, ExitCode, type LocalizedText } from "bailian-cli-core";
44

55
/** Parameters shared by every agent writer. */
66
export interface WriteParams {
@@ -11,6 +11,8 @@ export interface WriteParams {
1111
contextWindow?: number;
1212
/** Codex provider wire protocol: "responses" or "chat". */
1313
wireApi?: string;
14+
/** Locale selection for new agent-specific messages. */
15+
localize?: (text: LocalizedText) => string;
1416
}
1517

1618
/** What a writer reports back after configuring an agent. */

‎packages/commands/src/commands/mcp/agent-config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from "node:path";
44
import { BailianError, ExitCode } from "bailian-cli-core";
55
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
66
import yaml from "yaml";
7+
import { getPiAgentDir } from "../config/agent/pi-home.ts";
78
import {
89
backup,
910
stripJsonc,
@@ -25,6 +26,7 @@ export const MCP_AGENT_IDS = [
2526
"deepseek-harness",
2627
"zcode",
2728
"workbuddy",
29+
"pi",
2830
] as const;
2931

3032
const DSH_MCP_PLUGIN = "@deepseek-ai/dsh-mcp-client";
@@ -345,6 +347,11 @@ function jsonMcpAdapter(options: {
345347
}
346348

347349
const adapters: Record<NativeMcpAgent, AgentAdapter> = {
350+
pi: jsonMcpAdapter({
351+
path: (home) => join(getPiAgentDir(home), "mcp.json"),
352+
installed: (home) => existsSync(getPiAgentDir(home)),
353+
supports: (transport) => transport === "streamable-http",
354+
}),
348355
codex: {
349356
path: (home) => join(process.env.CODEX_HOME || join(home, ".codex"), "config.toml"),
350357
installed: (home) =>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
5+
import { parseStdoutJson, runCommandE2e, runCommandHelp } from "./helpers.ts";
6+
import { CONFIG_ROUTES, MCP_ROUTES } from "./topic-routes.ts";
7+
8+
const PLACEHOLDER_KEY = "sk-pi-e2e-placeholder-secret";
9+
let probeRoot: string;
10+
let agentDir: string;
11+
let probeEnv: NodeJS.ProcessEnv;
12+
13+
beforeEach(() => {
14+
probeRoot = mkdtempSync(join(tmpdir(), "bl-pi-e2e-"));
15+
agentDir = join(probeRoot, "pi-agent");
16+
probeEnv = { PI_CODING_AGENT_DIR: agentDir, BAILIAN_CONFIG_DIR: join(probeRoot, "bailian") };
17+
});
18+
19+
afterEach(() => rmSync(probeRoot, { recursive: true, force: true }));
20+
21+
const modelArgs = [
22+
"config",
23+
"agent",
24+
"--agent",
25+
"pi",
26+
"--region",
27+
"ap-southeast-1",
28+
"--api-key",
29+
PLACEHOLDER_KEY,
30+
"--model",
31+
"qwen3-coder-plus",
32+
];
33+
const mcpArgs = [
34+
"mcp",
35+
"connect",
36+
"--agent",
37+
"pi",
38+
"--server",
39+
"TextGenerateImage",
40+
"--transport",
41+
"streamable-http",
42+
"--api-key",
43+
PLACEHOLDER_KEY,
44+
"--output",
45+
"json",
46+
];
47+
48+
describe("e2e: Pi Agent integration", () => {
49+
test("config agent and mcp connect/disconnect help expose Pi", async () => {
50+
for (const [routes, args] of [
51+
[CONFIG_ROUTES, ["config", "agent", "--help"]],
52+
[MCP_ROUTES, ["mcp", "connect", "--help"]],
53+
[MCP_ROUTES, ["mcp", "disconnect", "--help"]],
54+
] as const) {
55+
const result = await runCommandHelp(routes, [...args]);
56+
expect(result.exitCode, result.stderr).toBe(0);
57+
expect(result.stderr).toMatch(/\bpi\b/);
58+
}
59+
});
60+
61+
test("model dry-run accepts Pi, redacts its key, and leaves its directory absent", async () => {
62+
const result = await runCommandE2e(
63+
CONFIG_ROUTES,
64+
[...modelArgs, "--dry-run", "--output", "json"],
65+
probeEnv,
66+
);
67+
expect(result.exitCode, result.stderr).toBe(0);
68+
const plan = parseStdoutJson<{
69+
agent: string;
70+
base_url: string;
71+
model: string;
72+
api_key: string;
73+
}>(result.stdout);
74+
expect(plan.agent).toBe("pi");
75+
expect(plan.base_url).toBe(
76+
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
77+
);
78+
expect(plan.model).toBe("qwen3-coder-plus");
79+
expect(plan.api_key).not.toBe(PLACEHOLDER_KEY);
80+
expect(result.stdout + result.stderr).not.toContain(PLACEHOLDER_KEY);
81+
expect(existsSync(agentDir)).toBe(false);
82+
});
83+
84+
test("config agent writes Pi's native settings and private auth without disclosing credentials", async () => {
85+
const result = await runCommandE2e(CONFIG_ROUTES, modelArgs, probeEnv);
86+
expect(result.exitCode, result.stderr).toBe(0);
87+
expect(result.stdout + result.stderr).not.toContain(PLACEHOLDER_KEY);
88+
const settings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8"));
89+
expect(settings).toMatchObject({
90+
defaultProvider: "bailian-cli",
91+
defaultModel: "qwen3-coder-plus",
92+
});
93+
const auth = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
94+
expect(auth["bailian-cli"]).toEqual({ type: "api_key", key: PLACEHOLDER_KEY });
95+
expect(readFileSync(join(agentDir, "models.json"), "utf8")).not.toContain(PLACEHOLDER_KEY);
96+
});
97+
98+
test("MCP dry-run reports only non-secret metadata without writing configuration", async () => {
99+
const result = await runCommandE2e(MCP_ROUTES, [...mcpArgs, "--dry-run"], probeEnv);
100+
expect(result.exitCode, result.stderr).toBe(0);
101+
const plan = parseStdoutJson<{ agents: string[]; endpoint: string; header_names: string[] }>(
102+
result.stdout,
103+
);
104+
expect(plan.agents).toEqual(["pi"]);
105+
expect(plan.endpoint).toBe("https://dashscope.aliyuncs.com/api/v1/mcps/TextGenerateImage/mcp");
106+
expect(plan.header_names).toContain("Authorization");
107+
expect(result.stdout + result.stderr).not.toContain(PLACEHOLDER_KEY);
108+
expect(existsSync(agentDir)).toBe(false);
109+
});
110+
111+
test("MCP connect is idempotent and disconnect removes the managed entry without disclosing credentials", async () => {
112+
const first = await runCommandE2e(MCP_ROUTES, mcpArgs, probeEnv);
113+
expect(first.exitCode, first.stderr).toBe(0);
114+
expect(first.stdout + first.stderr).not.toContain(PLACEHOLDER_KEY);
115+
expect(parseStdoutJson<{ results: { status: string }[] }>(first.stdout).results[0].status).toBe(
116+
"added",
117+
);
118+
const repeated = await runCommandE2e(MCP_ROUTES, mcpArgs, probeEnv);
119+
expect(repeated.exitCode, repeated.stderr).toBe(0);
120+
expect(
121+
parseStdoutJson<{ results: { status: string }[] }>(repeated.stdout).results[0].status,
122+
).toBe("unchanged");
123+
const disconnected = await runCommandE2e(
124+
MCP_ROUTES,
125+
["mcp", "disconnect", "--agent", "pi", "--server", "TextGenerateImage", "--output", "json"],
126+
probeEnv,
127+
);
128+
expect(disconnected.exitCode, disconnected.stderr).toBe(0);
129+
expect(
130+
parseStdoutJson<{ results: { status: string }[] }>(disconnected.stdout).results[0].status,
131+
).toBe("removed");
132+
expect(JSON.parse(readFileSync(join(agentDir, "mcp.json"), "utf8")).mcpServers).toEqual({});
133+
});
134+
135+
test("MCP SSE uses the shared adapter rejection before any Pi file is created", async () => {
136+
const sseArgs = mcpArgs.map((argument) => (argument === "streamable-http" ? "sse" : argument));
137+
const result = await runCommandE2e(MCP_ROUTES, sseArgs, probeEnv);
138+
expect(result.exitCode).toBe(2);
139+
expect(result.stderr).toMatch(/pi.*SSE/i);
140+
expect(result.stdout + result.stderr).not.toContain(PLACEHOLDER_KEY);
141+
expect(existsSync(agentDir)).toBe(false);
142+
});
143+
});

0 commit comments

Comments
 (0)