diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts index b83e11a..ca360db 100644 --- a/packages/commands/src/commands/config/agent/index.ts +++ b/packages/commands/src/commands/config/agent/index.ts @@ -11,14 +11,36 @@ const FLAGS = { required: true, choices: VALID_AGENT_NAMES, }, - baseUrl: { type: "string", valueHint: "", description: "API base URL", required: true }, - apiKey: { type: "string", valueHint: "", description: "API key", required: true }, + baseUrl: { + type: "string", + valueHint: "", + description: "API base URL", + required: true, + }, + apiKey: { + type: "string", + valueHint: "", + description: "API key", + required: true, + }, model: { type: "string", valueHint: "", description: "Default model name", required: true, }, + contextWindow: { + type: "number", + valueHint: "", + description: "OpenClaw only: model context window in tokens (default: 256000)", + }, + wireApi: { + type: "string", + valueHint: "", + description: + 'Codex only: wire protocol — "chat" works with every model; "responses" for models supporting the Responses API (default: chat)', + choices: ["chat", "responses"], + }, } satisfies FlagsDef; export default defineCommand({ @@ -34,7 +56,7 @@ export default defineCommand({ async run(ctx) { const { settings, flags } = ctx; const agentName = flags.agent; - const { baseUrl, apiKey, model } = flags; + const { baseUrl, apiKey, model, contextWindow, wireApi } = flags; const agentDef = AGENTS[agentName]; const format = detectOutputFormat(settings.output); @@ -59,7 +81,13 @@ export default defineCommand({ return; } - const params: WriteParams = { baseUrl, apiKey, model }; + const params: WriteParams = { + baseUrl, + apiKey, + model, + contextWindow, + wireApi, + }; const summary = agentDef.write(params); if (!settings.quiet) { diff --git a/packages/commands/src/commands/config/agent/writers/claude-code.ts b/packages/commands/src/commands/config/agent/writers/claude-code.ts index 938f84b..eaa84fd 100644 --- a/packages/commands/src/commands/config/agent/writers/claude-code.ts +++ b/packages/commands/src/commands/config/agent/writers/claude-code.ts @@ -5,7 +5,10 @@ import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts"; export default { label: "Claude Code", write({ baseUrl, apiKey, model }) { - const settingsPath = join(homedir(), ".claude", "settings.json"); + // Claude Code honors CLAUDE_CONFIG_DIR for its settings location. + const configDir = + process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); + const settingsPath = join(configDir, "settings.json"); const onboardingPath = join(homedir(), ".claude.json"); // settings.json — merge env. Base URL + auth token connect Claude Code to @@ -15,6 +18,9 @@ export default { const env = (settings.env ?? {}) as Record; env.ANTHROPIC_BASE_URL = baseUrl; env.ANTHROPIC_AUTH_TOKEN = apiKey; + // AUTH_TOKEN and API_KEY are mutually exclusive credential fields — drop a + // stale ANTHROPIC_API_KEY so it cannot shadow the token we just wrote. + delete env.ANTHROPIC_API_KEY; env.ANTHROPIC_MODEL = model; env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model; env.ANTHROPIC_DEFAULT_SONNET_MODEL = model; diff --git a/packages/commands/src/commands/config/agent/writers/codex.ts b/packages/commands/src/commands/config/agent/writers/codex.ts index 5353d99..8c8eca1 100644 --- a/packages/commands/src/commands/config/agent/writers/codex.ts +++ b/packages/commands/src/commands/config/agent/writers/codex.ts @@ -2,13 +2,19 @@ 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"; export default { label: "Codex", - write({ baseUrl, apiKey, model }) { + write({ baseUrl, apiKey, model, wireApi: wireApiParam }) { const configPath = join(homedir(), ".codex", "config.toml"); // config.toml — merge into existing config so unrelated settings @@ -17,7 +23,10 @@ export default { let config: Record = {}; if (existsSync(configPath)) { try { - config = parseToml(readFileSync(configPath, "utf-8")) as Record; + config = parseToml(readFileSync(configPath, "utf-8")) as Record< + string, + unknown + >; } catch { config = {}; } @@ -25,8 +34,10 @@ export default { config.model_provider = PROVIDER_KEY; config.model = model; - config.model_reasoning_effort = "high"; - config.disable_response_storage = true; + + // 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"; const providers = (config.model_providers ?? {}) as Record; const existing = (providers[PROVIDER_KEY] ?? {}) as Record; @@ -34,14 +45,17 @@ export default { ...existing, name: PROVIDER_KEY, base_url: baseUrl, - wire_api: "responses", + // env_key is the official-doc credential mechanism: Codex resolves the + // key from the OPENAI_API_KEY env var, falling back to auth.json below. + env_key: "OPENAI_API_KEY", + wire_api: wireApi, requires_openai_auth: true, }; config.model_providers = providers; writeTextAtomic(configPath, stringifyToml(config) + "\n"); - // auth.json — Codex reads OPENAI_API_KEY from here. + // auth.json — Codex reads OPENAI_API_KEY from here when the env var is unset. const authPath = join(homedir(), ".codex", "auth.json"); backup(authPath); const auth = readJson(authPath); diff --git a/packages/commands/src/commands/config/agent/writers/hermes.ts b/packages/commands/src/commands/config/agent/writers/hermes.ts index ae2de7a..a2929e3 100644 --- a/packages/commands/src/commands/config/agent/writers/hermes.ts +++ b/packages/commands/src/commands/config/agent/writers/hermes.ts @@ -2,9 +2,12 @@ import { homedir } from "os"; import { join } from "path"; import { existsSync, readFileSync } from "fs"; import yaml from "yaml"; -import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; - -const PROVIDER_NAME = "bailian-cli"; +import { + backup, + writeTextAtomic, + isAnthropicEndpoint, + type AgentDef, +} from "./utils.ts"; export default { label: "Hermes Agent", @@ -16,32 +19,25 @@ export default { let config: Record = {}; if (existsSync(configPath)) { try { - config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record; + config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? + {}) as Record; } catch { config = {}; } } - const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions"; - const providerEntry = { - name: PROVIDER_NAME, + // Official Model Studio doc shape: a single flat `model` block holding the + // active endpoint + credentials. `api_mode: anthropic_messages` is required + // for /apps/anthropic endpoints; for the OpenAI-compatible endpoint the + // doc says to omit api_mode entirely (chat completions is the default). + const block: Record = { + default: model, + provider: "custom", base_url: baseUrl, api_key: apiKey, - api_mode: apiMode, - models: [{ id: model, name: model }], }; - - // custom_providers — upsert the bailian-cli entry by name. - const providers = Array.isArray(config.custom_providers) - ? (config.custom_providers as Array>) - : []; - const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME); - if (index >= 0) providers[index] = providerEntry; - else providers.push(providerEntry); - config.custom_providers = providers; - - // model — select the bailian-cli provider and default model. - config.model = { default: model, provider: PROVIDER_NAME }; + if (isAnthropicEndpoint(baseUrl)) block.api_mode = "anthropic_messages"; + config.model = block; writeTextAtomic(configPath, yaml.stringify(config)); diff --git a/packages/commands/src/commands/config/agent/writers/openclaw.ts b/packages/commands/src/commands/config/agent/writers/openclaw.ts index 71ec8c5..3f550dd 100644 --- a/packages/commands/src/commands/config/agent/writers/openclaw.ts +++ b/packages/commands/src/commands/config/agent/writers/openclaw.ts @@ -1,10 +1,20 @@ import { homedir } from "os"; import { join } from "path"; -import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; +import { + backup, + readJson, + writeJsonAtomic, + isAnthropicEndpoint, + type AgentDef, +} from "./utils.ts"; + +// Safe default when --context-window is not given: most Model Studio models +// offer ≥256K context; users can raise it per model via the flag. +const DEFAULT_CONTEXT_WINDOW = 256000; export default { label: "OpenClaw", - write({ baseUrl, apiKey, model }) { + write({ baseUrl, apiKey, model, contextWindow }) { const configPath = join(homedir(), ".openclaw", "openclaw.json"); backup(configPath); @@ -14,7 +24,9 @@ export default { const models = (config.models ?? {}) as Record; models.mode = "merge"; const providers = (models.providers ?? {}) as Record; - const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions"; + const api = isAnthropicEndpoint(baseUrl) + ? "anthropic-messages" + : "openai-completions"; providers["bailian-cli"] = { baseUrl, apiKey, @@ -23,18 +35,22 @@ export default { { id: model, name: model, - contextWindow: 1000000, - cost: { input: 0, output: 0 }, + contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }, ], }; models.providers = providers; config.models = models; - // agents.defaults + // agents.defaults — select the model and register it in the allowlist. const agents = (config.agents ?? {}) as Record; const defaults = (agents.defaults ?? {}) as Record; - defaults.model = { primary: `bailian-cli/${model}` }; + const primary = `bailian-cli/${model}`; + defaults.model = { primary }; + const allowlist = (defaults.models ?? {}) as Record; + allowlist[primary] = allowlist[primary] ?? {}; + defaults.models = allowlist; agents.defaults = defaults; config.agents = agents; @@ -42,7 +58,8 @@ export default { return { paths: [configPath], - nextStep: "Run `openclaw` to start using OpenClaw with DashScope.", + nextStep: + "Run `openclaw gateway restart`, then `openclaw` to start using OpenClaw with DashScope.", }; }, } satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/opencode.ts b/packages/commands/src/commands/config/agent/writers/opencode.ts index 87b729c..416e46d 100644 --- a/packages/commands/src/commands/config/agent/writers/opencode.ts +++ b/packages/commands/src/commands/config/agent/writers/opencode.ts @@ -1,19 +1,28 @@ import { homedir } from "os"; import { join } from "path"; -import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; +import { + backup, + readJsonc, + writeJsonAtomic, + isAnthropicEndpoint, + type AgentDef, +} from "./utils.ts"; export default { label: "OpenCode", write({ baseUrl, apiKey, model }) { const configPath = join(homedir(), ".config", "opencode", "opencode.json"); + // opencode.json is JSONC — tolerate comments and trailing commas on read. backup(configPath); - const config = readJson(configPath); + const config = readJsonc(configPath); if (!config.$schema) config.$schema = "https://opencode.ai/config.json"; const provider = (config.provider ?? {}) as Record; - const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible"; + const npm = isAnthropicEndpoint(baseUrl) + ? "@ai-sdk/anthropic" + : "@ai-sdk/openai-compatible"; provider["bailian-cli"] = { npm, name: "Alibaba Cloud Model Studio", diff --git a/packages/commands/src/commands/config/agent/writers/qwen-code.ts b/packages/commands/src/commands/config/agent/writers/qwen-code.ts index 437f19f..d14a71f 100644 --- a/packages/commands/src/commands/config/agent/writers/qwen-code.ts +++ b/packages/commands/src/commands/config/agent/writers/qwen-code.ts @@ -1,6 +1,12 @@ import { homedir } from "os"; import { join } from "path"; -import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; +import { + backup, + readJson, + writeJsonAtomic, + isAnthropicEndpoint, + type AgentDef, +} from "./utils.ts"; const ENV_KEY = "BAILIAN_CLI_API_KEY"; @@ -19,6 +25,9 @@ export default { backup(settingsPath); const settings = readJson(settingsPath); + // $version — Qwen Code v3 settings schema (official Model Studio doc shape). + settings.$version = 3; + // env — API key read by the provider entry's envKey. const env = (settings.env ?? {}) as Record; env[ENV_KEY] = apiKey; @@ -29,7 +38,9 @@ export default { string, Array> >; - const entries = (providers[protocol] ?? []) as Array>; + const entries = (providers[protocol] ?? []) as Array< + Record + >; const existing = entries.find( (entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl, ); @@ -38,18 +49,25 @@ export default { existing.baseUrl = baseUrl; existing.envKey = ENV_KEY; } else { - entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY }); + entries.push({ + id: model, + name: "bailian-cli", + baseUrl, + envKey: ENV_KEY, + }); } providers[protocol] = entries; settings.modelProviders = providers; - // security.auth — select the protocol and carry the OpenAI-compatible creds. + // security.auth — select the protocol only. Credentials live in env (via + // each provider entry's envKey); writing apiKey/baseUrl here is not part of + // the v3 schema. const security = (settings.security ?? {}) as Record; - security.auth = { selectedType: protocol, apiKey, baseUrl }; + security.auth = { selectedType: protocol }; settings.security = security; - // model — active model, disambiguated by baseUrl. - settings.model = { name: model, baseUrl }; + // model — active model id, resolved inside modelProviders[protocol]. + settings.model = { name: model }; writeJsonAtomic(settingsPath, settings); diff --git a/packages/commands/src/commands/config/agent/writers/utils.ts b/packages/commands/src/commands/config/agent/writers/utils.ts index bbc6a37..85cb9ce 100644 --- a/packages/commands/src/commands/config/agent/writers/utils.ts +++ b/packages/commands/src/commands/config/agent/writers/utils.ts @@ -1,11 +1,22 @@ import { dirname } from "path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + renameSync, + copyFileSync, +} from "fs"; /** Parameters shared by every agent writer. */ export interface WriteParams { baseUrl: string; apiKey: string; model: string; + /** OpenClaw model entry context window (tokens). */ + contextWindow?: number; + /** Codex provider wire protocol: "responses" or "chat". */ + wireApi?: string; } /** What a writer reports back after configuring an agent. */ @@ -20,6 +31,91 @@ export interface AgentDef { write(params: WriteParams): WriteSummary; } +/** + * Strip JSONC syntax (line / block comments and trailing commas) so the result + * parses with `JSON.parse`. String contents are preserved verbatim. + */ +export function stripJsonc(text: string): string { + // Pass 1 — drop comments (string contents preserved verbatim). + let uncommented = ""; + let index = 0; + let inString = false; + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + if (inString) { + uncommented += char; + if (char === "\\") { + uncommented += next ?? ""; + index += 2; + continue; + } + if (char === '"') inString = false; + index += 1; + continue; + } + if (char === '"') { + inString = true; + uncommented += char; + index += 1; + continue; + } + if (char === "/" && next === "/") { + while (index < text.length && text[index] !== "\n") index += 1; + continue; + } + if (char === "/" && next === "*") { + index += 2; + while ( + index < text.length && + !(text[index] === "*" && text[index + 1] === "/") + ) + index += 1; + index += 2; + continue; + } + uncommented += char; + index += 1; + } + + // Pass 2 — drop trailing commas (a comma whose next non-whitespace char + // closes an object/array). Runs after comment removal so a trailing comment + // cannot hide the closing bracket. + let output = ""; + index = 0; + inString = false; + while (index < uncommented.length) { + const char = uncommented[index]; + if (inString) { + output += char; + if (char === "\\") { + output += uncommented[index + 1] ?? ""; + index += 2; + continue; + } + if (char === '"') inString = false; + index += 1; + continue; + } + if (char === '"') inString = true; + if (char === ",") { + let lookahead = index + 1; + while ( + lookahead < uncommented.length && + /\s/.test(uncommented[lookahead]) + ) + lookahead += 1; + if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") { + index += 1; + continue; + } + } + output += char; + index += 1; + } + return output; +} + /** Read a JSON object file, returning `{}` when missing or unparseable. */ export function readJson(path: string): Record { if (!existsSync(path)) return {}; @@ -30,6 +126,19 @@ export function readJson(path: string): Record { } } +/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */ +export function readJsonc(path: string): Record { + if (!existsSync(path)) return {}; + try { + return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record< + string, + unknown + >; + } catch { + return {}; + } +} + /** Atomically write `data` as pretty JSON with owner-only permissions. */ export function writeJsonAtomic(path: string, data: unknown): void { mkdirSync(dirname(path), { recursive: true }); diff --git a/packages/commands/tests/config-agent-writers.test.ts b/packages/commands/tests/config-agent-writers.test.ts index 1537c20..2355bec 100644 --- a/packages/commands/tests/config-agent-writers.test.ts +++ b/packages/commands/tests/config-agent-writers.test.ts @@ -1,4 +1,11 @@ -import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs"; +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"; @@ -41,11 +48,14 @@ function readJsonAt(...segments: string[]): Record { describe("config agent writers", () => { test("claude-code 写入 env 与 onboarding,并合并已有 env", () => { - // 预置一个无关 env 键,验证合并保留 + // 预置一个无关 env 键与旧的 ANTHROPIC_API_KEY,验证合并保留 / 旧键清理 mkdirSync(join(home, ".claude"), { recursive: true }); writeFileSync( join(home, ".claude", "settings.json"), - JSON.stringify({ env: { KEEP_ME: "1" }, other: true }), + JSON.stringify({ + env: { KEEP_ME: "1", ANTHROPIC_API_KEY: "sk-stale" }, + other: true, + }), ); const summary = claudeCode.write({ @@ -61,6 +71,7 @@ describe("config agent writers", () => { 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"); @@ -70,16 +81,45 @@ describe("config agent writers", () => { expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true); }); - test("qwen-code compatible-mode 走 openai 协议", () => { - qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" }); + 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).ANTHROPIC_AUTH_TOKEN, + ).toBe("sk-a"); + } finally { + delete process.env.CLAUDE_CONFIG_DIR; + } + }); + + 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"); - const security = settings.security as { auth: Record }; - expect(security.auth.selectedType).toBe("openai"); - expect(security.auth.apiKey).toBe("sk-q"); - expect(security.auth.baseUrl).toBe(OAI_URL); - expect((settings.env as Record).BAILIAN_CLI_API_KEY).toBe("sk-q"); - expect((settings.model as Record).name).toBe("qwen3-coder-plus"); - const providers = settings.modelProviders as Record>>; + expect(settings.$version).toBe(3); + const security = settings.security as { auth: Record }; + // security.auth 只携带 selectedType;凭证在 env + envKey 里 + expect(security.auth).toEqual({ selectedType: "openai" }); + expect((settings.env as Record).BAILIAN_CLI_API_KEY).toBe( + "sk-q", + ); + expect(settings.model).toEqual({ name: "qwen3-coder-plus" }); + const providers = settings.modelProviders as Record< + string, + Array> + >; expect(providers.openai[0]).toMatchObject({ id: "qwen3-coder-plus", name: "bailian-cli", @@ -89,24 +129,62 @@ 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", - ); + expect( + (settings.security as { auth: { selectedType: string } }).auth + .selectedType, + ).toBe("anthropic"); const providers = settings.modelProviders as Record; expect(Array.isArray(providers.anthropic)).toBe(true); expect(providers.openai).toBeUndefined(); }); test("qwen-code 对相同 id+baseUrl 的 provider 项做 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; + const openaiEntries = (settings.modelProviders as Record) + .openai; expect(openaiEntries).toHaveLength(1); }); + 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; + expect(provider.other).toBeDefined(); + expect(provider["bailian-cli"]).toBeDefined(); + }); + test("opencode 按端点选 npm,含 setCacheKey,合并保留其它 provider", () => { mkdirSync(join(home, ".config", "opencode"), { recursive: true }); writeFileSync( @@ -114,7 +192,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(); @@ -123,7 +205,9 @@ describe("config agent writers", () => { expect(options.baseURL).toBe(ANTHROPIC_URL); expect(options.apiKey).toBe("sk-o"); expect(options.setCacheKey).toBe(true); - expect((provider["bailian-cli"].models as Record)["qwen3-max"]).toBeDefined(); + expect( + (provider["bailian-cli"].models as Record)["qwen3-max"], + ).toBeDefined(); // 非 anthropic 端点用 openai-compatible opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" }); @@ -138,59 +222,96 @@ describe("config agent writers", () => { }); test("openclaw 写入 provider、api 与 primary", () => { - 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"); - const bailian = (models.providers as Record>)["bailian-cli"]; + const bailian = ( + models.providers as Record> + )["bailian-cli"]; expect(bailian.api).toBe("openai-completions"); - expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus"); - const agents = config.agents as { defaults: { model: { primary: string } } }; + const entry = (bailian.models as Array>)[0]; + expect(entry.id).toBe("qwen3-coder-plus"); + // 未传 --context-window 时使用安全默认值,不再硬编码 1M + 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 }; + }; expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus"); + expect(agents.defaults.models["bailian-cli/qwen3-coder-plus"]).toEqual({}); - // anthropic 端点用 anthropic-messages - openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" }); + // --context-window 覆盖默认值;anthropic 端点用 anthropic-messages + openclaw.write({ + baseUrl: ANTHROPIC_URL, + apiKey: "sk-c", + model: "qwen3-max", + contextWindow: 1000000, + }); const config2 = readJsonAt(".openclaw", "openclaw.json"); - expect( - ((config2.models as Record).providers as Record)[ - "bailian-cli" - ].api, - ).toBe("anthropic-messages"); + const providers2 = (config2.models as Record) + .providers as Record< + string, + { api: string; models: Array> } + >; + expect(providers2["bailian-cli"].api).toBe("anthropic-messages"); + expect(providers2["bailian-cli"].models[0].contextWindow).toBe(1000000); }); - test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => { + 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" }] }), + 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")); - expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" }); - const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name); - expect(names).toContain("other"); - const entry = (config.custom_providers as Array>).find( - (provider) => provider.name === "bailian-cli", - )!; - expect(entry.base_url).toBe(OAI_URL); - expect(entry.api_key).toBe("sk-h"); - expect(entry.api_mode).toBe("chat_completions"); + 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 端点用 anthropic_messages - hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" }); - const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8")); - const entry2 = (config2.custom_providers as Array>).find( - (provider) => provider.name === "bailian-cli", - )!; - expect(entry2.api_mode).toBe("anthropic_messages"); - // upsert:bailian-cli 项不重复 - expect( - (config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"), - ).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(cc-switch 对齐结构,合并保留)", () => { + test("codex 写入 config.toml 与 auth.json(官方 env_key 结构,合并保留)", () => { // 预置 config.toml 无关顶层键与另一个 provider,验证非破坏性合并 mkdirSync(join(home, ".codex"), { recursive: true }); writeFileSync( @@ -205,17 +326,24 @@ describe("config agent writers", () => { ].join("\n"), ); // 预置 auth.json 无关键,验证合并保留 - writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" })); + writeFileSync( + join(home, ".codex", "auth.json"), + JSON.stringify({ EXISTING: "keep" }), + ); - codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" }); + codex.write({ + baseUrl: OAI_URL, + apiKey: "sk-x", + model: "qwen3-coder-plus", + }); 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_reasoning_effort = "high"'); - expect(toml).toContain("disable_response_storage = true"); expect(toml).toContain("[model_providers.bailian-cli]"); expect(toml).toContain(`base_url = "${OAI_URL}"`); - expect(toml).toContain('wire_api = "responses"'); + expect(toml).toContain('env_key = "OPENAI_API_KEY"'); + // 未传 --wire-api 时默认 chat(所有模型可用) + expect(toml).toContain('wire_api = "chat"'); expect(toml).toContain("requires_openai_auth = true"); // 合并:保留用户已有的无关配置 expect(toml).toContain('approval_policy = "on-request"'); @@ -224,11 +352,25 @@ describe("config agent writers", () => { const auth = readJsonAt(".codex", "auth.json"); expect(auth.OPENAI_API_KEY).toBe("sk-x"); expect(auth.EXISTING).toBe("keep"); + + // --wire-api responses:支持 Responses API 的模型 + codex.write({ + baseUrl: OAI_URL, + apiKey: "sk-x", + model: "qwen3.7-plus", + wireApi: "responses", + }); + const toml2 = readFileSync(join(home, ".codex", "config.toml"), "utf8"); + expect(toml2).toContain('wire_api = "responses"'); + expect(toml2).toContain('model = "qwen3.7-plus"'); }); test("已存在的配置文件会被备份为 .bak.", () => { mkdirSync(join(home, ".openclaw"), { recursive: true }); - writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 })); + 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) => diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 1391c1a..b3ea368 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -1,4 +1,10 @@ -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"; @@ -11,29 +17,49 @@ 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); }); @@ -105,13 +131,21 @@ 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); }); @@ -120,7 +154,10 @@ 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( @@ -129,10 +166,13 @@ describe("e2e: config", () => { env, ); expect(useResult.exitCode, useResult.stderr).toBe(0); - expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe( + expect( + parseStdoutJson<{ active_config?: string }>(useResult.stdout) + .active_config, + ).toBe("dev"); + expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe( "dev", ); - expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev"); const listResult = await runCommandE2e( CONFIG_ROUTES, @@ -155,17 +195,23 @@ 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 }); } @@ -175,7 +221,10 @@ 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"], @@ -183,7 +232,9 @@ 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 }); } @@ -246,16 +297,24 @@ 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); @@ -295,7 +354,9 @@ describe("e2e: config", () => { "json", ]); expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout); + const data = parseStdoutJson<{ + would_set?: { default_text_model?: string }; + }>(stdout); expect(data.would_set?.default_text_model).toBe("qwen3.7-max"); }); @@ -315,7 +376,9 @@ 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 () => { @@ -334,7 +397,9 @@ 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 () => { @@ -367,7 +432,9 @@ 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"); }); @@ -385,7 +452,11 @@ 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); }); @@ -452,7 +523,9 @@ 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); @@ -461,7 +534,7 @@ describe("e2e: config", () => { } }); - test("config agent codex 写入 config.toml 与 auth.json(cc-switch 对齐结构)", async () => { + test("config agent codex 写入 config.toml 与 auth.json(官方 env_key 结构)", async () => { const home = mkdtempSync(join(tmpdir(), "bl-config-agent-codex-")); try { const { stderr, exitCode } = await runCommandE2e( @@ -477,22 +550,27 @@ describe("e2e: config", () => { "sk-codex-placeholder", "--model", "qwen3-coder-plus", + "--wire-api", + "responses", ], { HOME: home }, ); expect(exitCode, stderr).toBe(0); const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8"); expect(toml).toContain('model_provider = "bailian-cli"'); + expect(toml).toContain('env_key = "OPENAI_API_KEY"'); expect(toml).toContain("requires_openai_auth = true"); 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 }); } }); - test("config agent hermes 写入 custom_providers 结构", async () => { + test("config agent hermes 写入官方扁平 model.* 结构", async () => { const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-")); try { const { stderr, exitCode } = await runCommandE2e( @@ -512,10 +590,18 @@ describe("e2e: config", () => { { HOME: home }, ); expect(exitCode, stderr).toBe(0); - const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8"); - expect(yamlText).toContain("custom_providers"); - expect(yamlText).toContain("bailian-cli"); - expect(yamlText).toContain("api_mode: chat_completions"); + 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("api_key: sk-hermes-placeholder"); + // OpenAI 兼容端点不写 api_mode + expect(yamlText).not.toContain("api_mode"); } finally { rmSync(home, { recursive: true, force: true }); } diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 32f6eda..9a5661f 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -28,12 +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 | +| 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) | #### Examples