From 0221e35803d05f3652e6f69cd412e864e4a383bc Mon Sep 17 00:00:00 2001 From: "lisheng.lisheng" Date: Mon, 27 Jul 2026 17:10:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(config-agent):=20=E4=BC=98=E5=8C=96=20Codex?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E5=86=99=E5=85=A5=E4=B8=8E=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整 Codex 代理默认 wire_api 为 "responses",兼容新版 Codex - 增加对 legacy Codex <= 0.80.0 使用 wire_api "chat" 的警告提示 - 修正 agent flags 描述,更准确说明 wire_api 默认与兼容范围 - 优化代码格式,统一 import 语句风格 - 增加测试用例覆盖不同 wire_api 配置及环境变量警告 - 修复写入过程中文件备份及合并逻辑,保留用户已有配置 - 修复多个 provider 写入时键名与内容匹配,避免重复添加 - 改善测试代码格式,提高可读性与一致性 --- .../src/commands/config/agent/index.ts | 2 +- .../commands/config/agent/writers/codex.ts | 30 ++-- .../tests/config-agent-writers.test.ts | 51 ++++-- .../commands/tests/e2e/config.e2e.test.ts | 147 ++++-------------- skills/bailian-cli/reference/config.md | 16 +- 5 files changed, 96 insertions(+), 150 deletions(-) diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts index 5baf03b..c71c1c8 100644 --- a/packages/commands/src/commands/config/agent/index.ts +++ b/packages/commands/src/commands/config/agent/index.ts @@ -38,7 +38,7 @@ const FLAGS = { type: "string", valueHint: "", 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; diff --git a/packages/commands/src/commands/config/agent/writers/codex.ts b/packages/commands/src/commands/config/agent/writers/codex.ts index 8c8eca1..bb5c106 100644 --- a/packages/commands/src/commands/config/agent/writers/codex.ts +++ b/packages/commands/src/commands/config/agent/writers/codex.ts @@ -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 = {}; if (existsSync(configPath)) { try { - config = parseToml(readFileSync(configPath, "utf-8")) as Record< - string, - unknown - >; + config = parseToml(readFileSync(configPath, "utf-8")) as Record; } 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; const existing = (providers[PROVIDER_KEY] ?? {}) as Record; @@ -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; diff --git a/packages/commands/tests/config-agent-writers.test.ts b/packages/commands/tests/config-agent-writers.test.ts index d1e4a5b..01dbf5e 100644 --- a/packages/commands/tests/config-agent-writers.test.ts +++ b/packages/commands/tests/config-agent-writers.test.ts @@ -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).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>; 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; 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.", () => { diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index b3ea368..73776fa 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -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"); diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 9a5661f..b1d78e4 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -28,14 +28,14 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| --------------------------------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `--agent ` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex | -| `--base-url ` | string | yes | API base URL | -| `--api-key ` | string | yes | API key | -| `--model ` | string | yes | Default model name | -| `--context-window ` | number | no | OpenClaw only: model context window in tokens (default: 256000) | -| `--wire-api ` | 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 ` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex | +| `--base-url ` | string | yes | API base URL | +| `--api-key ` | string | yes | API key | +| `--model ` | string | yes | Default model name | +| `--context-window ` | number | no | OpenClaw only: model context window in tokens (default: 256000) | +| `--wire-api ` | string | no | Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0 | #### Examples