fix(config-agent): 优化 Codex 配置写入与兼容性处理

- 调整 Codex 代理默认 wire_api 为 "responses",兼容新版 Codex
- 增加对 legacy Codex <= 0.80.0 使用 wire_api "chat" 的警告提示
- 修正 agent flags 描述,更准确说明 wire_api 默认与兼容范围
- 优化代码格式,统一 import 语句风格
- 增加测试用例覆盖不同 wire_api 配置及环境变量警告
- 修复写入过程中文件备份及合并逻辑,保留用户已有配置
- 修复多个 provider 写入时键名与内容匹配,避免重复添加
- 改善测试代码格式,提高可读性与一致性
This commit is contained in:
lisheng.lisheng
2026-07-27 17:10:26 +08:00
parent ff469ce717
commit 0221e35803
5 changed files with 96 additions and 150 deletions
@@ -38,7 +38,7 @@ const FLAGS = {
type: "string",
valueHint: "<api>",
description:
'Codex only: wire protocol — "chat" works with every model; "responses" for models supporting the Responses API (default: chat)',
'Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0',
choices: ["chat", "responses"],
},
} satisfies FlagsDef;
@@ -2,13 +2,7 @@ import { homedir } from "os";
import { join } from "path";
import { existsSync, readFileSync } from "fs";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import {
backup,
readJson,
writeJsonAtomic,
writeTextAtomic,
type AgentDef,
} from "./utils.ts";
import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts";
const PROVIDER_KEY = "bailian-cli";
@@ -16,6 +10,7 @@ export default {
label: "Codex",
write({ baseUrl, apiKey, model, wireApi: wireApiParam }) {
const configPath = join(homedir(), ".codex", "config.toml");
const warnings: string[] = [];
// config.toml — merge into existing config so unrelated settings
// (mcp_servers, approval_policy, other providers, ...) are preserved.
@@ -23,10 +18,7 @@ export default {
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = parseToml(readFileSync(configPath, "utf-8")) as Record<
string,
unknown
>;
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
} catch {
config = {};
}
@@ -35,9 +27,18 @@ export default {
config.model_provider = PROVIDER_KEY;
config.model = model;
// wire_api: "responses" for models supporting the Responses API (e.g.
// qwen3.7/3.8 series); "chat" works with every model via Chat Completions.
const wireApi = wireApiParam === "responses" ? "responses" : "chat";
// wire_api — current Codex releases only load `wire_api = "responses"`
// ("chat" is rejected at config load, see openai/codex discussion #7782).
// "chat" remains an explicit opt-in for users pinned to legacy Codex
// <= 0.80.0 (the Model Studio path for models without Responses support).
const wireApi = wireApiParam === "chat" ? "chat" : "responses";
if (wireApi === "chat") {
warnings.push(
'Current Codex releases refuse to load `wire_api = "chat"`; ' +
"only use --wire-api chat with legacy Codex <= 0.80.0 " +
"(e.g. `npm install -g @openai/codex@0.80.0`).",
);
}
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
@@ -65,6 +66,7 @@ export default {
return {
paths: [configPath, authPath],
nextStep: "Run `codex` to start using Codex with DashScope.",
warnings: warnings.length > 0 ? warnings : undefined,
};
},
} satisfies AgentDef;
@@ -214,7 +214,11 @@ describe("config agent writers", () => {
});
test("qwen-code anthropic 端点走 anthropic 协议", () => {
qwenCode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-q", model: "qwen3-max" });
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",
@@ -225,8 +229,16 @@ describe("config agent writers", () => {
});
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" });
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);
@@ -327,7 +339,11 @@ describe("config agent writers", () => {
JSON.stringify({ provider: { other: { name: "Other" } } }),
);
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
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();
@@ -351,7 +367,11 @@ describe("config agent writers", () => {
});
test("openclaw 写入 provider、api、primary,并登记 defaults.models", () => {
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
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");
@@ -496,19 +516,21 @@ describe("config agent writers", () => {
// 预置 auth.json 无关键,验证合并保留
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
codex.write({
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 时默认 chat(所有模型可用)
expect(toml).toContain('wire_api = "chat"');
// 未传 --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"');
@@ -518,16 +540,17 @@ describe("config agent writers", () => {
expect(auth.OPENAI_API_KEY).toBe("sk-x");
expect(auth.EXISTING).toBe("keep");
// --wire-api responses:支持 Responses API 的模型
codex.write({
// --wire-api chat:仅旧版 Codex <= 0.80.0 可用,附带警告
const summary2 = codex.write({
baseUrl: OAI_URL,
apiKey: "sk-x",
model: "qwen3.7-plus",
wireApi: "responses",
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 = "responses"');
expect(toml2).toContain('model = "qwen3.7-plus"');
expect(toml2).toContain('wire_api = "chat"');
expect(toml2).toContain('model = "glm-5"');
});
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
+34 -113
View File
@@ -1,10 +1,4 @@
import {
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
existsSync,
} from "fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
@@ -17,49 +11,29 @@ import { CONFIG_ROUTES } from "./topic-routes.ts";
describe("e2e: config", () => {
test("config show --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"show",
"--help",
]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "show", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/show|config/i);
});
test("config set --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--help",
]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/set|--key|--value/i);
});
test("config list/use --help 正常退出", async () => {
const listResult = await runCommandE2e(CONFIG_ROUTES, [
"config",
"list",
"--help",
]);
const listResult = await runCommandE2e(CONFIG_ROUTES, ["config", "list", "--help"]);
expect(listResult.exitCode, listResult.stderr).toBe(0);
expect(listResult.stderr).toMatch(/list|active|profile/i);
const useResult = await runCommandE2e(CONFIG_ROUTES, [
"config",
"use",
"--help",
]);
const useResult = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--help"]);
expect(useResult.exitCode, useResult.stderr).toBe(0);
expect(useResult.stderr).toMatch(/use|--name|active/i);
});
test("config ui --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"ui",
"--help",
]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/ui|--port|--no-open|web/i);
});
@@ -131,21 +105,13 @@ describe("e2e: config", () => {
});
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--quiet",
]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--key|--value|Usage:/i);
});
test("config use 缺少 --name 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"use",
"--quiet",
]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--name|Usage:/i);
});
@@ -154,10 +120,7 @@ describe("e2e: config", () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n",
);
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
const env = { BAILIAN_CONFIG_DIR: configDir };
const useResult = await runCommandE2e(
@@ -166,13 +129,10 @@ describe("e2e: config", () => {
env,
);
expect(useResult.exitCode, useResult.stderr).toBe(0);
expect(
parseStdoutJson<{ active_config?: string }>(useResult.stdout)
.active_config,
).toBe("dev");
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe(
expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe(
"dev",
);
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev");
const listResult = await runCommandE2e(
CONFIG_ROUTES,
@@ -195,23 +155,17 @@ describe("e2e: config", () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-dry-run-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n",
);
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
const result = await runCommandE2e(
CONFIG_ROUTES,
["config", "use", "--name", "dev", "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(result.exitCode, result.stderr).toBe(0);
expect(
parseStdoutJson<{ would_activate?: string }>(result.stdout)
.would_activate,
).toBe("dev");
expect(
JSON.parse(readFileSync(configPath, "utf8")).active_config,
).toBeUndefined();
expect(parseStdoutJson<{ would_activate?: string }>(result.stdout).would_activate).toBe(
"dev",
);
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
} finally {
rmSync(configDir, { recursive: true, force: true });
}
@@ -221,10 +175,7 @@ describe("e2e: config", () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-missing-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ output: "text" }, null, 2) + "\n",
);
writeFileSync(configPath, JSON.stringify({ output: "text" }, null, 2) + "\n");
const result = await runCommandE2e(
CONFIG_ROUTES,
["config", "use", "--name", "missing", "--output", "json"],
@@ -232,9 +183,7 @@ describe("e2e: config", () => {
);
expect(result.exitCode).toBe(2);
expect(result.stderr).toMatch(/does not exist/);
expect(
JSON.parse(readFileSync(configPath, "utf8")).active_config,
).toBeUndefined();
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
} finally {
rmSync(configDir, { recursive: true, force: true });
}
@@ -297,24 +246,16 @@ describe("e2e: config", () => {
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
expect(
parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url,
).toBe("https://proxy.example.com/bailian");
expect(
JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))
.base_url,
).toBe("https://proxy.example.com/bailian");
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
"https://proxy.example.com/bailian",
);
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
"https://proxy.example.com/bailian",
);
const invalidResult = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"set",
"--key",
"base_url",
"--value",
"ftp://example.com/models",
],
["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(invalidResult.exitCode).toBe(2);
@@ -376,9 +317,7 @@ describe("e2e: config", () => {
const data = parseStdoutJson<{
would_set?: { default_image_to_video_model?: string };
}>(stdout);
expect(data.would_set?.default_image_to_video_model).toBe(
"happyhorse-1.1-i2v",
);
expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v");
});
test("config set --dry-run 支持参考生视频默认模型别名", async () => {
@@ -397,9 +336,7 @@ describe("e2e: config", () => {
const data = parseStdoutJson<{
would_set?: { default_reference_to_video_model?: string };
}>(stdout);
expect(data.would_set?.default_reference_to_video_model).toBe(
"happyhorse-1.1-r2v",
);
expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v");
});
test("config set --dry-run 展示归一化后的 Base URL", async () => {
@@ -432,9 +369,7 @@ describe("e2e: config", () => {
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(
stdout,
);
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(stdout);
expect(data.would_set?.access_key_id).toBe("LTAI-config-placeholder");
});
@@ -452,11 +387,7 @@ describe("e2e: config", () => {
});
test("config agent --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--help",
]);
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "agent", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/agent|--base-url|--model/i);
});
@@ -523,9 +454,7 @@ describe("e2e: config", () => {
api_key?: string;
}>(stdout);
expect(data.agent).toBe("claude-code");
expect(data.base_url).toBe(
"https://dashscope.aliyuncs.com/apps/anthropic",
);
expect(data.base_url).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
expect(data.model).toBe("qwen3-max");
expect(stdout).not.toContain("sk-secret-placeholder");
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
@@ -550,8 +479,6 @@ describe("e2e: config", () => {
"sk-codex-placeholder",
"--model",
"qwen3-coder-plus",
"--wire-api",
"responses",
],
{ HOME: home },
);
@@ -560,10 +487,9 @@ describe("e2e: config", () => {
expect(toml).toContain('model_provider = "bailian-cli"');
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
expect(toml).toContain("requires_openai_auth = true");
// 默认即 responses(新版 Codex 已不支持 chat)
expect(toml).toContain('wire_api = "responses"');
const auth = JSON.parse(
readFileSync(join(home, ".codex", "auth.json"), "utf8"),
);
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder");
} finally {
rmSync(home, { recursive: true, force: true });
@@ -590,15 +516,10 @@ describe("e2e: config", () => {
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const yamlText = readFileSync(
join(home, ".hermes", "config.yaml"),
"utf8",
);
const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8");
expect(yamlText).toContain("default: qwen3-coder-plus");
expect(yamlText).toContain("provider: custom");
expect(yamlText).toContain(
"base_url: https://dashscope.aliyuncs.com/compatible-mode/v1",
);
expect(yamlText).toContain("base_url: https://dashscope.aliyuncs.com/compatible-mode/v1");
expect(yamlText).toContain("api_key: sk-hermes-placeholder");
// OpenAI 兼容端点不写 api_mode
expect(yamlText).not.toContain("api_mode");
+8 -8
View File
@@ -28,14 +28,14 @@ Index: [index.md](index.md)
#### Flags
| Flag | Type | Required | Description |
| --------------------------------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `--agent <claude-code\|qwen-code\|opencode\|openclaw\|hermes\|codex>` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex |
| `--base-url <url>` | string | yes | API base URL |
| `--api-key <key>` | string | yes | API key |
| `--model <model>` | string | yes | Default model name |
| `--context-window <tokens>` | number | no | OpenClaw only: model context window in tokens (default: 256000) |
| `--wire-api <chat\|responses>` | string | no | Codex only: wire protocol — "chat" works with every model; "responses" for models supporting the Responses API (default: chat) |
| Flag | Type | Required | Description |
| --------------------------------------------------------------------- | ------ | -------- | --------------------------------------------------------------------------------------------- |
| `--agent <claude-code\|qwen-code\|opencode\|openclaw\|hermes\|codex>` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex |
| `--base-url <url>` | string | yes | API base URL |
| `--api-key <key>` | string | yes | API key |
| `--model <model>` | string | yes | Default model name |
| `--context-window <tokens>` | number | no | OpenClaw only: model context window in tokens (default: 256000) |
| `--wire-api <chat\|responses>` | string | no | Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0 |
#### Examples