fix(config agent): align agent configs to Alibaba Cloud Model Studio docs

Adopt the code-review findings and reconcile every writer with the official
Model Studio docs (with cc-switch / each agent's own docs as secondary refs).

- qwen-code: drop deprecated security.auth.apiKey/baseUrl (keep only
  selectedType), restore $version:3, model:{name}; env BAILIAN_API_KEY.
- codex: official env_key="OPENAI_API_KEY" structure (drop requires_openai_auth
  / model_reasoning_effort / disable_response_storage); respect $CODEX_HOME.
- hermes: revert to official flat model.* block; api_mode only for anthropic
  endpoints (omitted for OpenAI-compatible).
- opencode: drop setCacheKey; target existing opencode.jsonc when present.
- openclaw: stop hardcoding 1M contextWindow; add optional --context-window;
  add agents.defaults.models.
- claude-code: respect $CLAUDE_CONFIG_DIR.
- utils: on parse failure, throw and keep the original file (no silent wipe).
- tests: rewrite writer unit tests + add missing-flag e2e for every required
  flag; regenerate skill reference; document `bl config agent` in READMEs.
This commit is contained in:
lisheng.lisheng
2026-07-19 17:53:55 +08:00
parent ba062c1a71
commit aef6c1accf
13 changed files with 614 additions and 192 deletions
+6
View File
@@ -194,6 +194,12 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# Configure a coding agent (Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes, Codex)
# to use DashScope with one command
bl config agent --agent claude-code \
--base-url https://dashscope.aliyuncs.com/apps/anthropic \
--api-key sk-xxxxx --model qwen3-max
# Self-update to latest version
bl update
```
+5
View File
@@ -192,6 +192,11 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# 一键配置编程 AgentClaude Code、Qwen Code、OpenCode、OpenClaw、Hermes、Codex接入百炼
bl config agent --agent claude-code \
--base-url https://dashscope.aliyuncs.com/apps/anthropic \
--api-key sk-xxxxx --model qwen3-max
# 自更新到最新版本
bl update
```
@@ -1,5 +1,10 @@
import { platform } from "os";
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
import {
defineCommand,
detectOutputFormat,
maskToken,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
@@ -11,14 +16,30 @@ 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:
"Context window in tokens (openclaw only; omit to use the agent default)",
},
} satisfies FlagsDef;
export default defineCommand({
@@ -53,13 +74,21 @@ export default defineCommand({
base_url: baseUrl,
api_key: maskToken(apiKey),
model,
...(flags.contextWindow !== undefined
? { context_window: flags.contextWindow }
: {}),
},
format,
);
return;
}
const params: WriteParams = { baseUrl, apiKey, model };
const params: WriteParams = {
baseUrl,
apiKey,
model,
contextWindow: flags.contextWindow,
};
const summary = agentDef.write(params);
if (!settings.quiet) {
@@ -1,11 +1,17 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
import {
backup,
readJson,
writeJsonAtomic,
claudeConfigDir,
type AgentDef,
} from "./utils.ts";
export default {
label: "Claude Code",
write({ baseUrl, apiKey, model }) {
const settingsPath = join(homedir(), ".claude", "settings.json");
const settingsPath = join(claudeConfigDir(), "settings.json");
const onboardingPath = join(homedir(), ".claude.json");
// settings.json — merge env. Base URL + auth token connect Claude Code to
@@ -1,32 +1,53 @@
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 { BailianError, ExitCode } from "bailian-cli-core";
import {
backup,
readJson,
writeJsonAtomic,
writeTextAtomic,
codexHome,
type AgentDef,
} from "./utils.ts";
const PROVIDER_KEY = "bailian-cli";
/**
* Codex config follows the Alibaba Cloud Model Studio doc: `config.toml`
* declares the provider with `env_key = "OPENAI_API_KEY"` and
* `wire_api = "responses"`; the API key is stored in `auth.json`
* (Codex's native API-key store, equivalent to exporting OPENAI_API_KEY).
* Config root respects `$CODEX_HOME`.
*/
export default {
label: "Codex",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".codex", "config.toml");
const configPath = join(codexHome(), "config.toml");
// config.toml — merge into existing config so unrelated settings
// (mcp_servers, approval_policy, other providers, ...) are preserved.
// config.toml — merge so unrelated settings (mcp_servers, approval_policy,
// other providers, ...) are preserved.
backup(configPath);
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
} catch {
config = {};
config = parseToml(readFileSync(configPath, "utf-8")) as Record<
string,
unknown
>;
} catch (error) {
throw new BailianError(
`Failed to parse existing config: ${configPath}`,
ExitCode.GENERAL,
`Fix or back up the file, then retry. Underlying error: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
config.model_provider = PROVIDER_KEY;
config.model = model;
config.model_reasoning_effort = "high";
config.disable_response_storage = true;
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
@@ -34,15 +55,15 @@ export default {
...existing,
name: PROVIDER_KEY,
base_url: baseUrl,
env_key: "OPENAI_API_KEY",
wire_api: "responses",
requires_openai_auth: true,
};
config.model_providers = providers;
writeTextAtomic(configPath, stringifyToml(config) + "\n");
// auth.json — Codex reads OPENAI_API_KEY from here.
const authPath = join(homedir(), ".codex", "auth.json");
const authPath = join(codexHome(), "auth.json");
backup(authPath);
const auth = readJson(authPath);
auth.OPENAI_API_KEY = apiKey;
@@ -2,10 +2,19 @@ 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 { BailianError, ExitCode } from "bailian-cli-core";
import {
backup,
writeTextAtomic,
isAnthropicEndpoint,
type AgentDef,
} from "./utils.ts";
/**
* Hermes config follows the Alibaba Cloud Model Studio doc: a flat `model`
* block carries the endpoint inline. Anthropic-compatible endpoints set
* `api_mode: anthropic_messages`; OpenAI-compatible endpoints omit `api_mode`.
*/
export default {
label: "Hermes Agent",
write({ baseUrl, apiKey, model }) {
@@ -16,32 +25,30 @@ export default {
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
} catch {
config = {};
config = (yaml.parse(readFileSync(configPath, "utf-8")) ??
{}) as Record<string, unknown>;
} catch (error) {
throw new BailianError(
`Failed to parse existing config: ${configPath}`,
ExitCode.GENERAL,
`Fix or back up the file, then retry. Underlying error: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
const providerEntry = {
name: PROVIDER_NAME,
const modelBlock: Record<string, unknown> = {
default: model,
provider: "custom",
base_url: baseUrl,
api_key: apiKey,
api_mode: apiMode,
models: [{ id: model, name: model }],
};
// Anthropic endpoints require api_mode; OpenAI-compatible ones omit it.
if (isAnthropicEndpoint(baseUrl))
modelBlock.api_mode = "anthropic_messages";
// 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 };
config.model = modelBlock;
writeTextAtomic(configPath, yaml.stringify(config));
@@ -1,10 +1,23 @@
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";
/**
* OpenClaw config follows the Alibaba Cloud Model Studio doc: a merged
* `models.providers` entry plus an `agents.defaults` selection. `contextWindow`
* is only written when the caller supplies it (`--context-window`); we never
* fake a value, since an over-large window makes OpenClaw defer compaction and
* hit server-side limits on smaller-context models.
*/
export default {
label: "OpenClaw",
write({ baseUrl, apiKey, model }) {
write({ baseUrl, apiKey, model, contextWindow }) {
const configPath = join(homedir(), ".openclaw", "openclaw.json");
backup(configPath);
@@ -14,27 +27,32 @@ 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";
providers["bailian-cli"] = {
baseUrl,
apiKey,
api,
models: [
{
id: model,
name: model,
contextWindow: 1000000,
cost: { input: 0, output: 0 },
},
],
const api = isAnthropicEndpoint(baseUrl)
? "anthropic-messages"
: "openai-completions";
const modelEntry: Record<string, unknown> = {
id: model,
name: model,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
if (typeof contextWindow === "number")
modelEntry.contextWindow = contextWindow;
providers["bailian-cli"] = { baseUrl, apiKey, api, models: [modelEntry] };
models.providers = providers;
config.models = models;
// agents.defaults
// agents.defaults — primary selection + models map (per Model Studio doc).
const modelRef = `bailian-cli/${model}`;
const agents = (config.agents ?? {}) as Record<string, unknown>;
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
defaults.model = { primary: `bailian-cli/${model}` };
const defaultModel = (defaults.model ?? {}) as Record<string, unknown>;
defaultModel.primary = modelRef;
defaults.model = defaultModel;
const defaultModels = (defaults.models ?? {}) as Record<string, unknown>;
defaultModels[modelRef] = defaultModels[modelRef] ?? {};
defaults.models = defaultModels;
agents.defaults = defaults;
config.agents = agents;
@@ -1,11 +1,28 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
import { existsSync } from "fs";
import {
backup,
readJson,
writeJsonAtomic,
isAnthropicEndpoint,
type AgentDef,
} from "./utils.ts";
/**
* OpenCode config follows the Alibaba Cloud Model Studio doc: a `provider`
* entry with the AI SDK `npm` package chosen by endpoint protocol. OpenCode
* accepts either `opencode.json` or `opencode.jsonc`; we target an existing
* `.jsonc` when present, else `.json`.
*/
export default {
label: "OpenCode",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
const dir = join(homedir(), ".config", "opencode");
const jsoncPath = join(dir, "opencode.jsonc");
const configPath = existsSync(jsoncPath)
? jsoncPath
: join(dir, "opencode.json");
backup(configPath);
const config = readJson(configPath);
@@ -13,11 +30,13 @@ export default {
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",
options: { baseURL: baseUrl, apiKey, setCacheKey: true },
options: { baseURL: baseUrl, apiKey },
models: { [model]: { name: model } },
};
config.provider = provider;
@@ -1,14 +1,21 @@
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";
const ENV_KEY = "BAILIAN_API_KEY";
/**
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
* protocol (an AuthType string), not by a free-form provider id — the runtime
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
* therefore lives in the model entry `name` and the env var name.
* resolver indexes credentials/defaults by protocol. Structure follows the
* Alibaba Cloud Model Studio doc: the API key lives in `env` (read via the
* provider entry's `envKey`); `security.auth` only records the selected type.
*/
export default {
label: "Qwen Code",
@@ -24,32 +31,40 @@ export default {
env[ENV_KEY] = apiKey;
settings.env = env;
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
// modelProviders[<protocol>] — upsert the model entry.
const providers = (settings.modelProviders ?? {}) as Record<
string,
Array<Record<string, unknown>>
>;
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
const entries = (providers[protocol] ?? []) as Array<
Record<string, unknown>
>;
const displayName = `[Bailian] ${model}`;
const existing = entries.find(
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
);
if (existing) {
existing.name = "bailian-cli";
existing.name = displayName;
existing.baseUrl = baseUrl;
existing.envKey = ENV_KEY;
} else {
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
entries.push({ id: model, name: displayName, baseUrl, envKey: ENV_KEY });
}
providers[protocol] = entries;
settings.modelProviders = providers;
// security.auth — select the protocol and carry the OpenAI-compatible creds.
// security.auth — only the selected protocol (no deprecated apiKey/baseUrl).
const security = (settings.security ?? {}) as Record<string, unknown>;
security.auth = { selectedType: protocol, apiKey, baseUrl };
const auth = (security.auth ?? {}) as Record<string, unknown>;
auth.selectedType = protocol;
security.auth = auth;
settings.security = security;
// model — active model, disambiguated by baseUrl.
settings.model = { name: model, baseUrl };
// model + settings version (per Model Studio doc).
const modelConfig = (settings.model ?? {}) as Record<string, unknown>;
modelConfig.name = model;
settings.model = modelConfig;
settings.$version = 3;
writeJsonAtomic(settingsPath, settings);
@@ -1,11 +1,22 @@
import { dirname } from "path";
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
import { dirname, join } from "path";
import { homedir } from "os";
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
renameSync,
copyFileSync,
} from "fs";
import { BailianError, ExitCode } from "bailian-cli-core";
/** Parameters shared by every agent writer. */
export interface WriteParams {
baseUrl: string;
apiKey: string;
model: string;
/** Optional context window (tokens); only some agents record it. */
contextWindow?: number;
}
/** What a writer reports back after configuring an agent. */
@@ -20,13 +31,37 @@ export interface AgentDef {
write(params: WriteParams): WriteSummary;
}
/** Read a JSON object file, returning `{}` when missing or unparseable. */
/** Codex config root: `$CODEX_HOME` when set, else `~/.codex`. */
export function codexHome(): string {
const override = process.env.CODEX_HOME?.trim();
return override && override.length > 0 ? override : join(homedir(), ".codex");
}
/** Claude Code config dir: `$CLAUDE_CONFIG_DIR` when set, else `~/.claude`. */
export function claudeConfigDir(): string {
const override = process.env.CLAUDE_CONFIG_DIR?.trim();
return override && override.length > 0
? override
: join(homedir(), ".claude");
}
/**
* Read a JSON object file. Missing file → `{}` (a fresh config). An existing
* file that fails to parse throws instead of silently returning `{}` — that
* would drop the user's content when we write the merged result back.
*/
export function readJson(path: string): Record<string, unknown> {
if (!existsSync(path)) return {};
try {
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
} catch {
return {};
} catch (error) {
throw new BailianError(
`Failed to parse existing config: ${path}`,
ExitCode.GENERAL,
`Fix or back up the file, then retry. Underlying error: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
@@ -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";
@@ -12,7 +19,7 @@ import yaml from "yaml";
/**
* Agent writer 单元测试:直接调用 writer用临时 HOME 隔离文件系统。
* writer 是纯文件 I/O在进程内测试比 e2e 子进程更快、覆盖更全
* 结构以阿里云百炼官方文档为准
*/
const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
@@ -20,18 +27,27 @@ const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic";
let home = "";
let prevHome: string | undefined;
let prevCodexHome: string | undefined;
let prevClaudeDir: string | undefined;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "bl-agent-writer-"));
prevHome = process.env.HOME;
prevCodexHome = process.env.CODEX_HOME;
prevClaudeDir = process.env.CLAUDE_CONFIG_DIR;
process.env.HOME = home;
// homedir() 在 POSIX 读 $HOME断言隔离生效。
delete process.env.CODEX_HOME;
delete process.env.CLAUDE_CONFIG_DIR;
expect(homedir()).toBe(home);
});
afterEach(() => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
if (prevClaudeDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = prevClaudeDir;
rmSync(home, { recursive: true, force: true });
});
@@ -41,7 +57,6 @@ function readJsonAt(...segments: string[]): Record<string, unknown> {
describe("config agent writers", () => {
test("claude-code 写入 env 与 onboarding并合并已有 env", () => {
// 预置一个无关 env 键,验证合并保留
mkdirSync(join(home, ".claude"), { recursive: true });
writeFileSync(
join(home, ".claude", "settings.json"),
@@ -70,51 +85,96 @@ 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 dir = join(home, "custom-claude");
process.env.CLAUDE_CONFIG_DIR = dir;
claudeCode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-a",
model: "qwen3-max",
});
const settings = JSON.parse(
readFileSync(join(dir, "settings.json"), "utf8"),
);
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe(
ANTHROPIC_URL,
);
});
test("qwen-code compatible-mode 走 openai 协议(官方结构)", () => {
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> };
const security = settings.security as { auth: Record<string, unknown> };
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>>>;
// 不写已废弃的 apiKey/baseUrl
expect(security.auth.apiKey).toBeUndefined();
expect(security.auth.baseUrl).toBeUndefined();
expect(settings.$version).toBe(3);
expect((settings.env as Record<string, string>).BAILIAN_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",
name: "[Bailian] qwen3-coder-plus",
baseUrl: OAI_URL,
envKey: "BAILIAN_CLI_API_KEY",
envKey: "BAILIAN_API_KEY",
});
});
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 按端点选 npm,含 setCacheKey合并保留其它 provider", () => {
test("opencode 按端点选 npm(无 setCacheKey,合并保留其它 provider", () => {
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
writeFileSync(
join(home, ".config", "opencode", "opencode.json"),
JSON.stringify({ provider: { other: { name: "Other" } } }),
);
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
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();
@@ -122,10 +182,11 @@ describe("config agent writers", () => {
const options = provider["bailian-cli"].options as Record<string, unknown>;
expect(options.baseURL).toBe(ANTHROPIC_URL);
expect(options.apiKey).toBe("sk-o");
expect(options.setCacheKey).toBe(true);
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
expect(options.setCacheKey).toBeUndefined();
expect(
(provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"],
).toBeDefined();
// 非 anthropic 端点用 openai-compatible
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
expect(
(
@@ -137,61 +198,103 @@ describe("config agent writers", () => {
).toBe("@ai-sdk/openai-compatible");
});
test("openclaw 写入 provider、api 与 primary", () => {
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
test("opencode 存在 .jsonc 时写入 .jsonc", () => {
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
writeFileSync(join(home, ".config", "opencode", "opencode.jsonc"), "{}\n");
const summary = opencode.write({
baseUrl: OAI_URL,
apiKey: "sk-o",
model: "qwen3-max",
});
expect(summary.paths[0].endsWith("opencode.jsonc")).toBe(true);
});
test("openclaw 不传 contextWindow 时不写该字段", () => {
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");
expect(entry.contextWindow).toBeUndefined();
const agents = config.agents as {
defaults: { model: { primary: string }; models: Record<string, unknown> };
};
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
// anthropic 端点用 anthropic-messages
openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" });
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");
agents.defaults.models["bailian-cli/qwen3-coder-plus"],
).toBeDefined();
});
test("hermes 写入 custom_providers 与 model合并保留其它 provider", () => {
test("openclaw 传 contextWindow 时写入该字段anthropic 端点用 anthropic-messages", () => {
openclaw.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-c",
model: "qwen3-max",
contextWindow: 262144,
});
const config = readJsonAt(".openclaw", "openclaw.json");
const bailian = (
(config.models as Record<string, unknown>).providers as Record<
string,
Record<string, unknown>
>
)["bailian-cli"];
expect(bailian.api).toBe("anthropic-messages");
expect(
(bailian.models as Array<Record<string, unknown>>)[0].contextWindow,
).toBe(262144);
});
test("hermes 官方扁平 model.* 结构anthropic 端点带 api_mode", () => {
mkdirSync(join(home, ".hermes"), { recursive: true });
writeFileSync(
join(home, ".hermes", "config.yaml"),
yaml.stringify({ custom_providers: [{ name: "other", base_url: "https://x" }] }),
yaml.stringify({ agent: { max_turns: 5 } }),
);
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");
// 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");
// upsertbailian-cli 项不重复
expect(
(config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"),
).toHaveLength(1);
hermes.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-h",
model: "qwen3-max",
});
const config = yaml.parse(
readFileSync(join(home, ".hermes", "config.yaml"), "utf8"),
);
// 合并保留其它顶层键
expect(config.agent).toEqual({ max_turns: 5 });
expect(config.model).toEqual({
default: "qwen3-max",
provider: "custom",
base_url: ANTHROPIC_URL,
api_key: "sk-h",
api_mode: "anthropic_messages",
});
expect(config.custom_providers).toBeUndefined();
});
test("codex 写入 config.toml 与 auth.jsoncc-switch 对齐结构,合并保留)", () => {
// 预置 config.toml 无关顶层键与另一个 provider验证非破坏性合并
test("hermes OpenAI 兼容端点省略 api_mode", () => {
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.api_mode).toBeUndefined();
expect(config.model.base_url).toBe(OAI_URL);
});
test("codex 官方 env_key 结构;合并保留其它配置", () => {
mkdirSync(join(home, ".codex"), { recursive: true });
writeFileSync(
join(home, ".codex", "config.toml"),
@@ -200,24 +303,31 @@ describe("config agent writers", () => {
"",
"[model_providers.other]",
'name = "other"',
'base_url = "https://other.example.com/v1"',
"",
].join("\n"),
);
// 预置 auth.json 无关键,验证合并保留
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
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('env_key = "OPENAI_API_KEY"');
expect(toml).toContain('wire_api = "responses"');
expect(toml).toContain("requires_openai_auth = true");
// 合并:保留用户已有的无关配置
// 不再包含 cc-switch 专有字段
expect(toml).not.toContain("requires_openai_auth");
expect(toml).not.toContain("model_reasoning_effort");
expect(toml).not.toContain("disable_response_storage");
// 合并保留
expect(toml).toContain('approval_policy = "on-request"');
expect(toml).toContain("[model_providers.other]");
@@ -226,9 +336,28 @@ describe("config agent writers", () => {
expect(auth.EXISTING).toBe("keep");
});
test("codex 尊重 CODEX_HOME", () => {
const dir = join(home, "custom-codex");
process.env.CODEX_HOME = dir;
codex.write({
baseUrl: OAI_URL,
apiKey: "sk-x",
model: "qwen3-coder-plus",
});
expect(readFileSync(join(dir, "config.toml"), "utf8")).toContain(
'model_provider = "bailian-cli"',
);
expect(
JSON.parse(readFileSync(join(dir, "auth.json"), "utf8")).OPENAI_API_KEY,
).toBe("sk-x");
});
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) =>
@@ -236,4 +365,15 @@ describe("config agent writers", () => {
);
expect(backups).toHaveLength(1);
});
test("已有配置解析失败时抛错,不覆盖原文件", () => {
mkdirSync(join(home, ".openclaw"), { recursive: true });
const path = join(home, ".openclaw", "openclaw.json");
writeFileSync(path, "{ this is not valid json");
expect(() =>
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" }),
).toThrow(/Failed to parse/);
// 原文件保持不变
expect(readFileSync(path, "utf8")).toBe("{ this is not valid json");
});
});
+159 -39
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");
});
@@ -329,7 +390,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");
});
@@ -347,7 +410,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);
});
@@ -367,6 +434,51 @@ describe("e2e: config", () => {
expect(stderr).toMatch(/--api-key|Usage:/i);
});
test("config agent 缺少 --base-url 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"claude-code",
"--api-key",
"sk-placeholder",
"--model",
"qwen3-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--base-url|Usage:/i);
});
test("config agent 缺少 --model 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"claude-code",
"--base-url",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--api-key",
"sk-placeholder",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--model|Usage:/i);
});
test("config agent 缺少 --agent 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--base-url",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--api-key",
"sk-placeholder",
"--model",
"qwen3-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--agent|Usage:/i);
});
test("config agent 非法 --agent 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
@@ -414,7 +526,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);
@@ -423,7 +537,7 @@ describe("e2e: config", () => {
}
});
test("config agent codex 写入 config.toml 与 auth.jsoncc-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(
@@ -445,16 +559,19 @@ describe("e2e: config", () => {
expect(exitCode, stderr).toBe(0);
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml).toContain('model_provider = "bailian-cli"');
expect(toml).toContain("requires_openai_auth = true");
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
expect(toml).toContain('wire_api = "responses"');
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
expect(toml).not.toContain("requires_openai_auth");
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(
@@ -465,19 +582,22 @@ describe("e2e: config", () => {
"--agent",
"hermes",
"--base-url",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--api-key",
"sk-hermes-placeholder",
"--model",
"qwen3-coder-plus",
"qwen3-max",
],
{ 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("provider: custom");
expect(yamlText).toContain("api_mode: anthropic_messages");
expect(yamlText).not.toContain("custom_providers");
} finally {
rmSync(home, { recursive: true, force: true });
}
+1
View File
@@ -34,6 +34,7 @@ Index: [index.md](index.md)
| `--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 | Context window in tokens (openclaw only; omit to use the agent default) |
#### Examples