Files
modelstudioai__cli/packages/commands/tests/config-agent-writers.test.ts
T
lisheng.lisheng 96744e3328 feat(config-agent): add --key and --region, default codex wire_api to responses
- --key: decode the web console's obfuscated API key (o1_ prefix) into
  the real key; mutually exclusive with --api-key, exactly one required
- --region: convert a Model Studio region into the Token Plan base URL
  (token-plan.<region>.maas.aliyuncs.com/compatible-mode/v1); mutually
  exclusive with --base-url, exactly one required
- codex: default wire_api to "responses" (current Codex rejects "chat");
  --wire-api chat kept for legacy Codex <= 0.80.0 with a warning
- regenerate skills reference for the new flags
2026-07-28 19:37:13 +08:00

583 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
import { tmpdir, homedir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
import claudeCode from "../src/commands/config/agent/writers/claude-code.ts";
import qwenCode from "../src/commands/config/agent/writers/qwen-code.ts";
import opencode from "../src/commands/config/agent/writers/opencode.ts";
import openclaw from "../src/commands/config/agent/writers/openclaw.ts";
import hermes from "../src/commands/config/agent/writers/hermes.ts";
import codex from "../src/commands/config/agent/writers/codex.ts";
import { resolveRegionBaseUrl } from "../src/commands/config/agent/writers/utils.ts";
import yaml from "yaml";
/**
* Agent writer 单元测试:直接调用 writer用临时 HOME 隔离文件系统。
* writer 是纯文件 I/O在进程内测试比 e2e 子进程更快、覆盖更全。
*/
const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic";
let home = "";
let prevHome: string | undefined;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "bl-agent-writer-"));
prevHome = process.env.HOME;
process.env.HOME = home;
// homedir() 在 POSIX 读 $HOME断言隔离生效。
expect(homedir()).toBe(home);
});
afterEach(() => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(home, { recursive: true, force: true });
});
function readJsonAt(...segments: string[]): Record<string, unknown> {
return JSON.parse(readFileSync(join(home, ...segments), "utf8"));
}
describe("config agent writers", () => {
test("claude-code 写入 env 与 onboarding并合并已有 env", () => {
// 预置一个无关 env 键与旧的 ANTHROPIC_API_KEY验证合并保留 / 旧键清理
mkdirSync(join(home, ".claude"), { recursive: true });
writeFileSync(
join(home, ".claude", "settings.json"),
JSON.stringify({
env: { KEEP_ME: "1", ANTHROPIC_API_KEY: "sk-stale" },
other: true,
}),
);
const summary = claudeCode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-a",
model: "qwen3-max",
});
expect(summary.paths).toHaveLength(2);
const settings = readJsonAt(".claude", "settings.json");
const env = settings.env as Record<string, string>;
expect(env.KEEP_ME).toBe("1");
expect(settings.other).toBe(true);
expect(env.ANTHROPIC_BASE_URL).toBe(ANTHROPIC_URL);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("qwen3-max");
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3-max");
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
});
test("claude-code 尊重 CLAUDE_CONFIG_DIR", () => {
const customDir = join(home, "custom-claude");
process.env.CLAUDE_CONFIG_DIR = customDir;
try {
claudeCode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-a",
model: "qwen3-max",
});
const settings = JSON.parse(readFileSync(join(customDir, "settings.json"), "utf8"));
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
} finally {
delete process.env.CLAUDE_CONFIG_DIR;
}
});
test("claude-code 将 compatible-mode URL 改写为 apps/anthropic", () => {
const tokenPlanOpenAi = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
const summary = claudeCode.write({
baseUrl: tokenPlanOpenAi,
apiKey: "sk-a",
model: "qwen3.8-max-preview",
});
const env = readJsonAt(".claude", "settings.json").env as Record<string, string>;
expect(env.ANTHROPIC_BASE_URL).toBe(
"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
);
expect(summary.warnings?.some((warning) => warning.includes("Rewrote base URL"))).toBe(true);
});
test("claude-code 保留已有分层模型,不整表覆盖", () => {
mkdirSync(join(home, ".claude"), { recursive: true });
writeFileSync(
join(home, ".claude", "settings.json"),
JSON.stringify({
env: {
ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3.6-flash",
CLAUDE_CODE_SUBAGENT_MODEL: "qwen3.7-max",
},
}),
);
claudeCode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-a",
model: "qwen3.8-max-preview",
});
const env = readJsonAt(".claude", "settings.json").env as Record<string, string>;
expect(env.ANTHROPIC_MODEL).toBe("qwen3.8-max-preview");
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3.6-flash");
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3.7-max");
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3.8-max-preview");
});
test("claude-code 拒绝无法改写为 Anthropic 的 base URL", () => {
expect(() =>
claudeCode.write({
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-a",
model: "qwen3-max",
}),
).toThrow(/Anthropic-compatible base URL/);
});
test("qwen-code compatible-mode 走 openai 协议(官方 v3 结构)", () => {
qwenCode.write({
baseUrl: OAI_URL,
apiKey: "sk-q",
model: "qwen3-coder-plus",
});
const settings = readJsonAt(".qwen", "settings.json");
expect(settings.$version).toBe(3);
const security = settings.security as { auth: Record<string, unknown> };
// security.auth 携带 selectedType 以及凭证兜底apiKey/baseUrl
// 避免系统 OPENAI_API_KEY 抢占
expect(security.auth).toEqual({
selectedType: "openai",
apiKey: "sk-q",
baseUrl: OAI_URL,
});
expect((settings.env as Record<string, string>).DASHSCOPE_API_KEY).toBe("sk-q");
// model.name 必须与 baseUrl 一同写入(同 id provider 消歧契约)
expect(settings.model).toEqual({
name: "qwen3-coder-plus",
baseUrl: OAI_URL,
});
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
// name 是模型显示名(非 provider 品牌常量),品牌只在 envKey 里
expect(providers.openai[0]).toMatchObject({
id: "qwen3-coder-plus",
name: "[Bailian] qwen3-coder-plus",
baseUrl: OAI_URL,
envKey: "DASHSCOPE_API_KEY",
});
});
test("qwen-code upsert 时治愈旧的 bailian-cli name 但保留用户自定义 name", () => {
mkdirSync(join(home, ".qwen"), { recursive: true });
writeFileSync(
join(home, ".qwen", "settings.json"),
JSON.stringify({
modelProviders: {
openai: [
{
id: "qwen3-coder-plus",
name: "bailian-cli",
baseUrl: OAI_URL,
envKey: "DASHSCOPE_API_KEY",
},
{
id: "my-model",
name: "My Custom",
baseUrl: OAI_URL,
envKey: "DASHSCOPE_API_KEY",
},
],
},
}),
);
// 旧 sentinel 被治愈为显示名
qwenCode.write({
baseUrl: OAI_URL,
apiKey: "sk-q",
model: "qwen3-coder-plus",
});
// 用户自定义 name 不被覆盖
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "my-model" });
const settings = readJsonAt(".qwen", "settings.json");
const entries = (settings.modelProviders as Record<string, Array<Record<string, unknown>>>)
.openai;
const healed = entries.find((entry) => entry.id === "qwen3-coder-plus")!;
expect(healed.name).toBe("[Bailian] qwen3-coder-plus");
expect(healed.envKey).toBe("DASHSCOPE_API_KEY");
const custom = entries.find((entry) => entry.id === "my-model")!;
expect(custom.name).toBe("My Custom");
});
test("qwen-code anthropic 端点走 anthropic 协议", () => {
qwenCode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-q",
model: "qwen3-max",
});
const settings = readJsonAt(".qwen", "settings.json");
expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe(
"anthropic",
);
const providers = settings.modelProviders as Record<string, unknown>;
expect(Array.isArray(providers.anthropic)).toBe(true);
expect(providers.openai).toBeUndefined();
});
test("qwen-code 对自有 provider 项按 id upsert 而非追加", () => {
qwenCode.write({
baseUrl: OAI_URL,
apiKey: "sk-1",
model: "qwen3-coder-plus",
});
qwenCode.write({
baseUrl: OAI_URL,
apiKey: "sk-2",
model: "qwen3-coder-plus",
});
const settings = readJsonAt(".qwen", "settings.json");
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>).openai;
expect(openaiEntries).toHaveLength(1);
expect((settings.env as Record<string, string>).DASHSCOPE_API_KEY).toBe("sk-2");
});
test("qwen-code 不劫持已有 Token Plan 同 id 条目的 name/envKey", () => {
mkdirSync(join(home, ".qwen"), { recursive: true });
writeFileSync(
join(home, ".qwen", "settings.json"),
JSON.stringify({
env: { BAILIAN_TOKEN_PLAN_API_KEY: "sk-token-plan" },
modelProviders: {
openai: [
{
id: "qwen3.8-max-preview",
name: "[Token Plan 个人版] qwen3.8-max-preview",
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
envKey: "BAILIAN_TOKEN_PLAN_API_KEY",
generationConfig: { extra_body: { enable_thinking: true } },
},
],
},
}),
);
const tokenPlanUrl = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
const summary = qwenCode.write({
baseUrl: tokenPlanUrl,
apiKey: "sk-bailian",
model: "qwen3.8-max-preview",
});
const settings = readJsonAt(".qwen", "settings.json");
const openaiEntries = (
settings.modelProviders as Record<string, Array<Record<string, unknown>>>
).openai;
expect(openaiEntries).toHaveLength(1);
expect(openaiEntries[0]).toMatchObject({
id: "qwen3.8-max-preview",
name: "[Token Plan 个人版] qwen3.8-max-preview",
envKey: "BAILIAN_TOKEN_PLAN_API_KEY",
generationConfig: { extra_body: { enable_thinking: true } },
});
expect((settings.env as Record<string, string>).BAILIAN_TOKEN_PLAN_API_KEY).toBe(
"sk-token-plan",
);
expect((settings.env as Record<string, string>).DASHSCOPE_API_KEY).toBe("sk-bailian");
expect(summary.warnings?.some((warning) => warning.includes("already exists"))).toBe(true);
});
test("qwen-code 在进程环境变量覆盖 settings.env 时给出警告", () => {
const previous = process.env.DASHSCOPE_API_KEY;
process.env.DASHSCOPE_API_KEY = "sk-from-shell";
try {
const summary = qwenCode.write({
baseUrl: OAI_URL,
apiKey: "sk-from-settings",
model: "qwen3-coder-plus",
});
expect(summary.warnings?.some((warning) => warning.includes("overrides settings.json"))).toBe(
true,
);
} finally {
if (previous === undefined) delete process.env.DASHSCOPE_API_KEY;
else process.env.DASHSCOPE_API_KEY = previous;
}
});
test("opencode 容忍 JSONC注释与尾逗号", () => {
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
writeFileSync(
join(home, ".config", "opencode", "opencode.json"),
[
"{",
" // user comment",
' "provider": {',
' "other": { "name": "Other" }, // inline comment',
" },",
" /* block */",
' "theme": "dark",',
"}",
].join("\n"),
);
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
const config = readJsonAt(".config", "opencode", "opencode.json");
expect(config.theme).toBe("dark");
const provider = config.provider as Record<string, unknown>;
expect(provider.other).toBeDefined();
expect(provider["bailian-cli"]).toBeDefined();
});
test("opencode 按端点选 npm含 setCacheKey合并保留其它 provider", () => {
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
writeFileSync(
join(home, ".config", "opencode", "opencode.json"),
JSON.stringify({ provider: { other: { name: "Other" } } }),
);
opencode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-o",
model: "qwen3-max",
});
const config = readJsonAt(".config", "opencode", "opencode.json");
const provider = config.provider as Record<string, Record<string, unknown>>;
expect(provider.other).toBeDefined();
expect(provider["bailian-cli"].npm).toBe("@ai-sdk/anthropic");
const options = provider["bailian-cli"].options as Record<string, unknown>;
expect(options.baseURL).toBe(ANTHROPIC_URL);
expect(options.apiKey).toBe("sk-o");
expect(options.setCacheKey).toBe(true);
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
// 非 anthropic 端点用 openai-compatible
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
expect(
(
readJsonAt(".config", "opencode", "opencode.json").provider as Record<
string,
{ npm: string }
>
)["bailian-cli"].npm,
).toBe("@ai-sdk/openai-compatible");
});
test("openclaw 写入 provider、api、primary并登记 defaults.models", () => {
openclaw.write({
baseUrl: OAI_URL,
apiKey: "sk-c",
model: "qwen3-coder-plus",
});
const config = readJsonAt(".openclaw", "openclaw.json");
const models = config.models as Record<string, unknown>;
expect(models.mode).toBe("merge");
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
expect(bailian.api).toBe("openai-completions");
const entry = (bailian.models as Array<Record<string, unknown>>)[0];
expect(entry.id).toBe("qwen3-coder-plus");
expect(entry.contextWindow).toBe(256000);
expect(entry.cost).toEqual({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
});
const agents = config.agents as {
defaults: { model: { primary: string }; models: Record<string, unknown> };
};
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
expect(agents.defaults.models["bailian-cli/qwen3-coder-plus"]).toEqual({});
// --context-window 覆盖默认值anthropic 端点用 anthropic-messages
openclaw.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-c",
model: "qwen3-max",
contextWindow: 1000000,
});
const config2 = readJsonAt(".openclaw", "openclaw.json");
const providers2 = (config2.models as Record<string, unknown>).providers as Record<
string,
{ api: string; models: Array<Record<string, unknown>> }
>;
expect(providers2["bailian-cli"].api).toBe("anthropic-messages");
expect(providers2["bailian-cli"].models[0].contextWindow).toBe(1000000);
expect(
(config2.agents as { defaults: { model: { primary: string } } }).defaults.model.primary,
).toBe("bailian-cli/qwen3-max");
});
test("openclaw 不抢占已有 token-plan primary", () => {
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(
join(home, ".openclaw", "openclaw.json"),
JSON.stringify({
models: {
mode: "merge",
providers: {
"bailian-token-plan": {
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
apiKey: "sk-token-plan",
api: "anthropic-messages",
models: [{ id: "qwen3.8-max-preview", name: "qwen3.8-max-preview" }],
},
},
},
agents: {
defaults: {
model: { primary: "bailian-token-plan/qwen3.8-max-preview" },
models: { "bailian-token-plan/qwen3.8-max-preview": {} },
},
},
}),
);
const summary = openclaw.write({
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-bailian",
model: "qwen3.8-max-preview",
});
const config = readJsonAt(".openclaw", "openclaw.json");
const agents = config.agents as {
defaults: { model: { primary: string }; models: Record<string, unknown> };
};
expect(agents.defaults.model.primary).toBe("bailian-token-plan/qwen3.8-max-preview");
expect(agents.defaults.models["bailian-cli/qwen3.8-max-preview"]).toEqual({});
expect(
(config.models as { providers: Record<string, unknown> }).providers["bailian-token-plan"],
).toBeDefined();
expect(
(config.models as { providers: Record<string, unknown> }).providers["bailian-cli"],
).toBeDefined();
expect(summary.warnings?.some((warning) => warning.includes("Left existing primary"))).toBe(
true,
);
});
test("hermes 写入官方扁平 model.* 结构,保留其它顶层键", () => {
mkdirSync(join(home, ".hermes"), { recursive: true });
writeFileSync(
join(home, ".hermes", "config.yaml"),
yaml.stringify({
custom_providers: [{ name: "other", base_url: "https://x" }],
}),
);
hermes.write({
baseUrl: OAI_URL,
apiKey: "sk-h",
model: "qwen3-coder-plus",
});
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
// OpenAI 兼容端点:按官方文档省略 api_mode无关顶层键不受影响
expect(config.model).toEqual({
default: "qwen3-coder-plus",
provider: "custom",
base_url: OAI_URL,
api_key: "sk-h",
});
expect(config.custom_providers).toHaveLength(1);
// anthropic 端点:必须带 api_mode = anthropic_messages
hermes.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-h",
model: "qwen3-max",
});
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
expect(config2.model).toEqual({
default: "qwen3-max",
provider: "custom",
base_url: ANTHROPIC_URL,
api_key: "sk-h",
api_mode: "anthropic_messages",
});
});
test("codex 写入 config.toml 与 auth.json官方 env_key 结构,合并保留)", () => {
// 预置 config.toml 无关顶层键与另一个 provider验证非破坏性合并
mkdirSync(join(home, ".codex"), { recursive: true });
writeFileSync(
join(home, ".codex", "config.toml"),
[
'approval_policy = "on-request"',
"",
"[model_providers.other]",
'name = "other"',
'base_url = "https://other.example.com/v1"',
"",
].join("\n"),
);
// 预置 auth.json 无关键,验证合并保留
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
const summary = codex.write({
baseUrl: OAI_URL,
apiKey: "sk-x",
model: "qwen3-coder-plus",
});
// 默认路径无警告
expect(summary.warnings).toBeUndefined();
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml).toContain('model_provider = "bailian-cli"');
expect(toml).toContain('model = "qwen3-coder-plus"');
expect(toml).toContain("[model_providers.bailian-cli]");
expect(toml).toContain(`base_url = "${OAI_URL}"`);
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
// 未传 --wire-api 时默认 responses新版 Codex 已不支持 chat
expect(toml).toContain('wire_api = "responses"');
expect(toml).toContain("requires_openai_auth = true");
// 合并:保留用户已有的无关配置
expect(toml).toContain('approval_policy = "on-request"');
expect(toml).toContain("[model_providers.other]");
const auth = readJsonAt(".codex", "auth.json");
expect(auth.OPENAI_API_KEY).toBe("sk-x");
expect(auth.EXISTING).toBe("keep");
// --wire-api chat仅旧版 Codex <= 0.80.0 可用,附带警告
const summary2 = codex.write({
baseUrl: OAI_URL,
apiKey: "sk-x",
model: "glm-5",
wireApi: "chat",
});
expect(summary2.warnings?.some((warning) => warning.includes("0.80.0"))).toBe(true);
const toml2 = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml2).toContain('wire_api = "chat"');
expect(toml2).toContain('model = "glm-5"');
});
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 }));
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" });
const backups = readdirSync(join(home, ".openclaw")).filter((name) =>
name.startsWith("openclaw.json.bak."),
);
expect(backups).toHaveLength(1);
});
test("resolveRegionBaseUrl 将 region 转为 Token Plan compatible-mode URL", () => {
expect(resolveRegionBaseUrl("cn-beijing")).toBe(
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
);
expect(resolveRegionBaseUrl("ap-southeast-1")).toBe(
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
);
});
test("resolveRegionBaseUrl 拒绝非法 region", () => {
expect(() => resolveRegionBaseUrl("cn beijing")).toThrow(/Invalid --region/);
expect(() => resolveRegionBaseUrl("CN-Beijing")).toThrow(/Invalid --region/);
expect(() => resolveRegionBaseUrl("")).toThrow(/Invalid --region/);
});
});