fix(config-agent): align agent writers with cc-switch and official Model Studio docs

- claude-code: honor CLAUDE_CONFIG_DIR; drop stale ANTHROPIC_API_KEY
- qwen-code: write $version:3; security.auth carries selectedType only
- opencode: tolerate JSONC (comments/trailing commas) via stripJsonc
- openclaw: add --context-window flag (default 256000), full cost fields,
  agents.defaults.models allowlist
- hermes: switch to official flat model.* block; api_mode only for
  anthropic endpoints
- codex: official env_key + auth.json fallback; add --wire-api flag
  (default chat, responses for supported models)
This commit is contained in:
lisheng.lisheng
2026-07-23 11:12:34 +08:00
parent 678f60be75
commit 26a69a7c99
11 changed files with 586 additions and 159 deletions
@@ -11,14 +11,36 @@ const FLAGS = {
required: true,
choices: VALID_AGENT_NAMES,
},
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
baseUrl: {
type: "string",
valueHint: "<url>",
description: "API base URL",
required: true,
},
apiKey: {
type: "string",
valueHint: "<key>",
description: "API key",
required: true,
},
model: {
type: "string",
valueHint: "<model>",
description: "Default model name",
required: true,
},
contextWindow: {
type: "number",
valueHint: "<tokens>",
description: "OpenClaw only: model context window in tokens (default: 256000)",
},
wireApi: {
type: "string",
valueHint: "<api>",
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) {
@@ -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<string, string>;
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;
@@ -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<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 = {};
}
@@ -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<string, unknown>;
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
@@ -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);
@@ -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<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
config = (yaml.parse(readFileSync(configPath, "utf-8")) ??
{}) as Record<string, unknown>;
} 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<string, unknown> = {
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<Record<string, unknown>>)
: [];
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));
@@ -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<string, unknown>;
models.mode = "merge";
const providers = (models.providers ?? {}) as Record<string, unknown>;
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<string, unknown>;
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
defaults.model = { primary: `bailian-cli/${model}` };
const primary = `bailian-cli/${model}`;
defaults.model = { primary };
const allowlist = (defaults.models ?? {}) as Record<string, unknown>;
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;
@@ -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<string, unknown>;
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",
@@ -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<string, string>;
env[ENV_KEY] = apiKey;
@@ -29,7 +38,9 @@ export default {
string,
Array<Record<string, unknown>>
>;
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
const entries = (providers[protocol] ?? []) as Array<
Record<string, unknown>
>;
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<string, unknown>;
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);
@@ -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<string, unknown> {
if (!existsSync(path)) return {};
@@ -30,6 +126,19 @@ export function readJson(path: string): Record<string, unknown> {
}
}
/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */
export function readJsonc(path: string): Record<string, unknown> {
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 });
@@ -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<string, unknown> {
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<string, string>).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<string, string> };
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<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
expect((settings.model as Record<string, string>).name).toBe("qwen3-coder-plus");
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
expect(settings.$version).toBe(3);
const security = settings.security as { auth: Record<string, unknown> };
// security.auth 只携带 selectedType;凭证在 env + envKey 里
expect(security.auth).toEqual({ selectedType: "openai" });
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe(
"sk-q",
);
expect(settings.model).toEqual({ name: "qwen3-coder-plus" });
const providers = settings.modelProviders as Record<
string,
Array<Record<string, unknown>>
>;
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<string, unknown>;
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<string, unknown[]>).openai;
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>)
.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<string, unknown>;
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<string, Record<string, unknown>>;
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<string, unknown>)["qwen3-max"]).toBeDefined();
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" });
@@ -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<string, unknown>;
expect(models.mode).toBe("merge");
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
const bailian = (
models.providers as Record<string, Record<string, unknown>>
)["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<Record<string, unknown>>)[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<string, unknown> };
};
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<string, unknown>).providers as Record<string, { api: string }>)[
"bailian-cli"
].api,
).toBe("anthropic-messages");
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);
});
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<Record<string, unknown>>).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<Record<string, unknown>>).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.<epoch>", () => {
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) =>
+124 -38
View File
@@ -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 });
}
+8 -6
View File
@@ -28,12 +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 |
| 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) |
#### Examples