feat(install-docs): enhance installation documentation and validation processes

This commit is contained in:
qcq01083097
2026-07-27 11:24:22 +08:00
parent 4751145283
commit ff469ce717
10 changed files with 460 additions and 89 deletions
+1
View File
@@ -68,6 +68,7 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
+1 -1
View File
@@ -98,7 +98,7 @@ npx skills add modelstudioai/cli --all -g
### Agent 安全约束
- **禁止**把真实 API Key 写入仓库、日志、Skill、聊天记录的可公开部分。
- CI / 非交互环境:使用 `bl ... --non-interactive`;通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
- CI / 非交互环境:显式传入必填参数并使用 `--output json` 获取机器可读结果;如需纯文本输出,设置 `NO_COLOR=1`。通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
---
+42
View File
@@ -0,0 +1,42 @@
# 安装文档变更
## 触发条件
- 修改根目录 `INSTALL.md` 的安装、鉴权或验证流程
- 修改发布包 Node.js 要求、全局 flag 或安装文档引用的命令
- 同步或发布 `https://bailian.aliyun.com/cli/install.md`
## 必查清单
### A. CLI 契约
- [ ] `INSTALL.md` 中的 `bl` 命令路径存在于 `packages/cli/src/commands.ts`
- [ ] 示例 flag 属于 `GLOBAL_FLAGS`、命令鉴权域 flag 或命令自身 `flags`
- [ ] Node.js 用户安装要求与 `packages/cli/package.json` 的 `engines.node` 一致,不使用根 `package.json` 的开发环境要求
- [ ] 鉴权流程与 `packages/commands/src/commands/auth/` 的实际校验、保存和 Profile 激活行为一致
### B. 静态副本
- [ ] 将 `INSTALL.md` 同步到 `bailian-cli-static-resources/public/install.txt`
- [ ] 使用 `cmp -s` 确认两份文档逐字节一致
- [ ] 静态资源仓库单独创建分支、提交和发布,不把跨仓库改动遗漏在 CLI PR 之外
### C. 线上验证
- [ ] 发布后读取 `https://bailian.aliyun.com/cli/install.md`,确认内容来自最新静态副本
- [ ] 带随机 query 参数复查,区分 CDN 缓存与源站未更新
- [ ] 验证线上文档中的安装命令、Node.js 要求和配置验证段落,不只检查页面可访问
## 完成后自查
```sh
pnpm -F bailian-cli test -- tests/install-doc.test.ts
cmp -s INSTALL.md ../bailian-cli-static-resources/public/install.txt
curl -L -s "https://bailian.aliyun.com/cli/install.md?verify=$(date +%s)"
```
## 常见漏点
- `--non-interactive` 已从 CLI 移除,但旧安装文档和静态副本仍把它当作全局 flag
- 根 `package.json` 是开发工具链 Node.js 要求;用户安装要求以 `packages/cli/package.json` 为准
- 静态仓库文件名是 `public/install.txt`,线上稳定地址是 `/cli/install.md`;只更新其中一侧不会自动证明发布成功
+73
View File
@@ -0,0 +1,73 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { credentialFlagDefs, GLOBAL_FLAGS, type AnyCommand } from "bailian-cli-core";
import { monorepoRoot } from "e2e/monorepo-root";
import { describe, expect, test } from "vite-plus/test";
import { commands } from "../src/commands.ts";
const repositoryRoot = monorepoRoot();
const installGuide = readFileSync(join(repositoryRoot, "INSTALL.md"), "utf8");
const cliPackage = JSON.parse(
readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"),
) as {
engines?: { node?: string };
};
function toFlagName(key: string): string {
return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
}
function findDocumentedCommand(snippet: string): {
commandPath?: string;
command?: AnyCommand;
} {
const argumentText = snippet.slice("bl ".length).trim();
const commandPath = Object.keys(commands)
.sort((leftPath, rightPath) => rightPath.length - leftPath.length)
.find((candidatePath) => {
return argumentText === candidatePath || argumentText.startsWith(`${candidatePath} `);
});
return commandPath ? { commandPath, command: commands[commandPath] } : {};
}
function documentedCommandSnippets(): string[] {
const fencedCommands = installGuide
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("bl "));
const inlineCommands = Array.from(installGuide.matchAll(/`(bl [^`\n]+)`/g), (match) => match[1]);
return [...new Set([...fencedCommands, ...inlineCommands])];
}
describe("INSTALL.md", () => {
test("发布包 Node.js 要求与安装文档一致", () => {
const nodeEngine = cliPackage.engines?.node;
expect(nodeEngine).toMatch(/^>=\d+\.\d+\.\d+$/);
expect(installGuide).toContain(`要求 **≥ ${nodeEngine?.slice(2)}**`);
});
test("示例只使用当前命令支持的 flags", () => {
for (const snippet of documentedCommandSnippets()) {
const { commandPath, command } = findDocumentedCommand(snippet);
const argumentText = snippet.slice("bl ".length).trim();
if (!commandPath || !command) {
expect(argumentText, `INSTALL.md 中存在未知命令:${snippet}`).toMatch(/^--/);
}
const supportedFlags = {
...GLOBAL_FLAGS,
...(command ? credentialFlagDefs(command) : {}),
...command?.flags,
};
const supportedFlagNames = new Set(Object.keys(supportedFlags).map(toFlagName));
const usedFlagNames = Array.from(snippet.matchAll(/--[a-z0-9-]+/g), (match) => match[0]);
const unsupportedFlagNames = usedFlagNames.filter(
(flagName) => !supportedFlagNames.has(flagName),
);
expect(unsupportedFlagNames, `INSTALL.md 命令使用了未声明的 flag:${snippet}`).toEqual([]);
}
});
});
@@ -94,6 +94,9 @@ export default defineCommand({
emitBare(`${agentDef.label} configured successfully.`);
for (const path of summary.paths) emitBare(` Written: ${path}`);
emitBare(` ${summary.nextStep}`);
for (const warning of summary.warnings ?? []) {
process.stderr.write(`Warning: ${warning}\n`);
}
}
},
});
@@ -1,6 +1,20 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
import {
backup,
readJson,
writeJsonAtomic,
resolveClaudeCodeBaseUrl,
type AgentDef,
} from "./utils.ts";
/** Fill a tier/default model env only when the user has not set it yet. */
function setModelEnvIfAbsent(env: Record<string, string>, key: string, model: string): void {
const current = env[key];
if (current === undefined || current.trim() === "") {
env[key] = model;
}
}
export default {
label: "Claude Code",
@@ -10,22 +24,33 @@ export default {
process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
const settingsPath = join(configDir, "settings.json");
const onboardingPath = join(homedir(), ".claude.json");
const warnings: string[] = [];
const resolved = resolveClaudeCodeBaseUrl(baseUrl);
if (resolved.rewrittenFrom) {
warnings.push(
`Rewrote base URL for Claude Code: "${resolved.rewrittenFrom}" → "${resolved.url}" ` +
`(Claude Code needs /apps/anthropic, not OpenAI compatible-mode).`,
);
}
// settings.json — merge env. Base URL + auth token connect Claude Code to
// the endpoint; the model tier vars force every tier onto the chosen model.
// the Anthropic-compatible endpoint; primary model always updates, while
// tier/subagent defaults are filled only when absent so existing setups
// (e.g. Token Plan Haiku/Subagent splits) are not wiped.
backup(settingsPath);
const settings = readJson(settingsPath);
const env = (settings.env ?? {}) as Record<string, string>;
env.ANTHROPIC_BASE_URL = baseUrl;
env.ANTHROPIC_BASE_URL = resolved.url;
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;
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", model);
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", model);
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", model);
setModelEnvIfAbsent(env, "CLAUDE_CODE_SUBAGENT_MODEL", model);
settings.env = env;
writeJsonAtomic(settingsPath, settings);
@@ -38,6 +63,7 @@ export default {
return {
paths: [settingsPath, onboardingPath],
nextStep: "Run `claude` to start using Claude Code with DashScope.",
warnings: warnings.length > 0 ? warnings : undefined,
};
},
} satisfies AgentDef;
@@ -12,22 +12,32 @@ import {
// offer ≥256K context; users can raise it per model via the flag.
const DEFAULT_CONTEXT_WINDOW = 256000;
const PROVIDER_ID = "bailian-cli";
function readPrimary(defaults: Record<string, unknown>): string | undefined {
const model = defaults.model;
if (!model || typeof model !== "object") return undefined;
const primary = (model as Record<string, unknown>).primary;
return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined;
}
export default {
label: "OpenClaw",
write({ baseUrl, apiKey, model, contextWindow }) {
const configPath = join(homedir(), ".openclaw", "openclaw.json");
const warnings: string[] = [];
const modelRef = `${PROVIDER_ID}/${model}`;
backup(configPath);
const config = readJson(configPath);
// models.providers["bailian-cli"]
// models.providers["bailian-cli"] — upsert without removing other providers
// (e.g. an existing working bailian-token-plan setup).
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"] = {
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
providers[PROVIDER_ID] = {
baseUrl,
apiKey,
api,
@@ -43,14 +53,27 @@ export default {
models.providers = providers;
config.models = models;
// agents.defaults — select the model and register it in the allowlist.
// agents.defaults — register the model in the allow-list. Only set primary
// when unset, or when primary already points at bailian-cli (reconfigure).
// Never steal primary away from another provider such as bailian-token-plan.
const agents = (config.agents ?? {}) as Record<string, unknown>;
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
const primary = `bailian-cli/${model}`;
defaults.model = { primary };
const allowlist = (defaults.models ?? {}) as Record<string, unknown>;
allowlist[primary] = allowlist[primary] ?? {};
defaults.models = allowlist;
const allowedModels = (defaults.models ?? {}) as Record<string, unknown>;
allowedModels[modelRef] = allowedModels[modelRef] ?? {};
defaults.models = allowedModels;
const existingPrimary = readPrimary(defaults);
if (!existingPrimary) {
defaults.model = { primary: modelRef };
} else if (existingPrimary.startsWith(`${PROVIDER_ID}/`)) {
defaults.model = { primary: modelRef };
} else {
warnings.push(
`Left existing primary model unchanged ("${existingPrimary}"). ` +
`Added provider "${PROVIDER_ID}" — switch to "${modelRef}" in OpenClaw if you want to use it.`,
);
}
agents.defaults = defaults;
config.agents = agents;
@@ -60,6 +83,7 @@ export default {
paths: [configPath],
nextStep:
"Run `openclaw gateway restart`, then `openclaw` to start using OpenClaw with DashScope.",
warnings: warnings.length > 0 ? warnings : undefined,
};
},
} satisfies AgentDef;
@@ -4,13 +4,27 @@ import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef }
const ENV_KEY = "BAILIAN_CLI_API_KEY";
function displayName(model: string): string {
return `[Bailian] ${model}`;
}
/** Entries we previously wrote, or still own via envKey / display brand. */
function isBailianCliEntry(entry: Record<string, unknown>): boolean {
if (entry.envKey === ENV_KEY) return true;
const name = typeof entry.name === "string" ? entry.name : "";
return name === "bailian-cli" || name.startsWith("[Bailian]");
}
/**
* 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 only in the env var name (`BAILIAN_CLI_API_KEY`); each model
* entry's `name` stays a human display label (Qwen Code keys models by
* id + baseUrl, never by name).
* therefore lives in the env var name (`BAILIAN_CLI_API_KEY`) and the display
* label (`[Bailian] …`); Qwen Code keys models by id (+ baseUrl), never by name.
*
* Qwen Code does not support duplicate model `id`s (only the first loads), so
* we must never overwrite a pre-existing Token Plan / third-party entry that
* shares the same id.
*
* Credentials are written to BOTH `env` (via the entry's `envKey`) and
* `security.auth` — the resolver reads `security.auth.apiKey/baseUrl` as a
@@ -24,6 +38,7 @@ export default {
write({ baseUrl, apiKey, model }) {
const settingsPath = join(homedir(), ".qwen", "settings.json");
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
const warnings: string[] = [];
backup(settingsPath);
const settings = readJson(settingsPath);
@@ -32,33 +47,48 @@ export default {
settings.$version = 3;
// env — API key read by the provider entry's envKey.
// Qwen Code treats settings.json `env` as lowest priority; a process/shell
// value for the same key wins and can make the first launch fail.
const env = (settings.env ?? {}) as Record<string, string>;
env[ENV_KEY] = apiKey;
settings.env = env;
// modelProviders[<protocol>] — upsert this model's entry, keyed by
// id + baseUrl (the identity Qwen Code's registry uses). `name` is the
// model's DISPLAY label; keep an existing custom name, and heal the old
// "bailian-cli" sentinel a previous version wrote (it collided across every
// configured model in the picker).
const processEnvValue = process.env[ENV_KEY];
if (processEnvValue !== undefined && processEnvValue !== apiKey) {
warnings.push(
`Shell/environment ${ENV_KEY} is set and overrides settings.json. ` +
`Unset it (e.g. \`unset ${ENV_KEY}\`) so the key written here takes effect.`,
);
}
// modelProviders[<protocol>] — upsert only bailian-cli-owned entries.
const providers = (settings.modelProviders ?? {}) as Record<
string,
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.baseUrl = baseUrl;
existing.envKey = ENV_KEY;
const currentName = typeof existing.name === "string" ? existing.name.trim() : "";
if (!currentName || currentName === "bailian-cli") existing.name = displayName;
const owned = entries.find((entry) => isBailianCliEntry(entry) && entry.id === model);
const conflicting = entries.find((entry) => !isBailianCliEntry(entry) && entry.id === model);
if (owned) {
owned.baseUrl = baseUrl;
owned.envKey = ENV_KEY;
const currentName = typeof owned.name === "string" ? owned.name.trim() : "";
if (!currentName || currentName === "bailian-cli") owned.name = displayName(model);
} else if (conflicting) {
const existingName =
typeof conflicting.name === "string" && conflicting.name.length > 0
? conflicting.name
: String(conflicting.id);
warnings.push(
`Model id "${model}" already exists as "${existingName}"; left unchanged ` +
`(Qwen Code loads only the first entry per id). Remove or rename that ` +
`entry if you want bailian-cli to own this model.`,
);
} else {
entries.push({
id: model,
name: displayName,
name: displayName(model),
baseUrl,
envKey: ENV_KEY,
});
@@ -83,6 +113,7 @@ export default {
return {
paths: [settingsPath],
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
warnings: warnings.length > 0 ? warnings : undefined,
};
},
} satisfies AgentDef;
@@ -1,12 +1,6 @@
import { dirname } from "path";
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
renameSync,
copyFileSync,
} from "fs";
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 {
@@ -23,6 +17,8 @@ export interface WriteParams {
export interface WriteSummary {
paths: string[];
nextStep: string;
/** Non-fatal issues the command should surface to the user. */
warnings?: string[];
}
/** An agent configuration writer: a human label plus a `write` that applies it. */
@@ -66,11 +62,7 @@ export function stripJsonc(text: string): string {
}
if (char === "/" && next === "*") {
index += 2;
while (
index < text.length &&
!(text[index] === "*" && text[index + 1] === "/")
)
index += 1;
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1;
index += 2;
continue;
}
@@ -100,11 +92,7 @@ export function stripJsonc(text: string): string {
if (char === '"') inString = true;
if (char === ",") {
let lookahead = index + 1;
while (
lookahead < uncommented.length &&
/\s/.test(uncommented[lookahead])
)
lookahead += 1;
while (lookahead < uncommented.length && /\s/.test(uncommented[lookahead])) lookahead += 1;
if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") {
index += 1;
continue;
@@ -130,10 +118,7 @@ export function readJson(path: string): Record<string, unknown> {
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
>;
return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record<string, unknown>;
} catch {
return {};
}
@@ -166,3 +151,47 @@ export function backup(path: string): void {
export function isAnthropicEndpoint(baseUrl: string): boolean {
return baseUrl.includes("/apps/anthropic");
}
/**
* Claude Code speaks Anthropic Messages only. Users often paste the OpenAI
* compatible-mode URL; rewrite that to `/apps/anthropic` when possible, otherwise
* fail with a clear USAGE error before writing a broken config.
*/
export function resolveClaudeCodeBaseUrl(baseUrl: string): {
url: string;
rewrittenFrom?: string;
} {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
if (isAnthropicEndpoint(trimmed)) {
return { url: trimmed };
}
if (trimmed.includes("/compatible-mode")) {
const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic");
return { url: rewritten, rewrittenFrom: baseUrl.trim() };
}
try {
const parsed = new URL(trimmed);
const host = parsed.hostname;
const isDashScopeHost =
host.includes("dashscope") ||
host.includes("maas.aliyuncs.com") ||
host.includes("token-plan");
if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) {
return {
url: `${parsed.origin}/apps/anthropic`,
rewrittenFrom: baseUrl.trim(),
};
}
} catch {
// Fall through to the USAGE error below.
}
throw new BailianError(
`Claude Code requires an Anthropic-compatible base URL, got "${baseUrl}".`,
ExitCode.USAGE,
"Use a URL ending in /apps/anthropic (not /compatible-mode/v1). Example: https://dashscope.aliyuncs.com/apps/anthropic",
);
}
@@ -90,6 +90,54 @@ describe("config agent writers", () => {
}
});
test("claude-code 将 compatible-mode URL 改写为 apps/anthropic", () => {
const tokenPlanOpenAi = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
const summary = claudeCode.write({
baseUrl: tokenPlanOpenAi,
apiKey: "sk-a",
model: "qwen3.8-max-preview",
});
const env = readJsonAt(".claude", "settings.json").env as Record<string, string>;
expect(env.ANTHROPIC_BASE_URL).toBe(
"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
);
expect(summary.warnings?.some((warning) => warning.includes("Rewrote base URL"))).toBe(true);
});
test("claude-code 保留已有分层模型,不整表覆盖", () => {
mkdirSync(join(home, ".claude"), { recursive: true });
writeFileSync(
join(home, ".claude", "settings.json"),
JSON.stringify({
env: {
ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3.6-flash",
CLAUDE_CODE_SUBAGENT_MODEL: "qwen3.7-max",
},
}),
);
claudeCode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-a",
model: "qwen3.8-max-preview",
});
const env = readJsonAt(".claude", "settings.json").env as Record<string, string>;
expect(env.ANTHROPIC_MODEL).toBe("qwen3.8-max-preview");
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3.6-flash");
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3.7-max");
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3.8-max-preview");
});
test("claude-code 拒绝无法改写为 Anthropic 的 base URL", () => {
expect(() =>
claudeCode.write({
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-a",
model: "qwen3-max",
}),
).toThrow(/Anthropic-compatible base URL/);
});
test("qwen-code compatible-mode 走 openai 协议(官方 v3 结构)", () => {
qwenCode.write({
baseUrl: OAI_URL,
@@ -133,13 +181,13 @@ describe("config agent writers", () => {
id: "qwen3-coder-plus",
name: "bailian-cli",
baseUrl: OAI_URL,
envKey: "OLD",
envKey: "BAILIAN_CLI_API_KEY",
},
{
id: "my-model",
name: "My Custom",
baseUrl: OAI_URL,
envKey: "OLD",
envKey: "BAILIAN_CLI_API_KEY",
},
],
},
@@ -166,11 +214,7 @@ 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",
@@ -180,20 +224,76 @@ describe("config agent writers", () => {
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",
});
test("qwen-code 对自有 provider 项按 id upsert 而非追加", () => {
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" });
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" });
const settings = readJsonAt(".qwen", "settings.json");
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>).openai;
expect(openaiEntries).toHaveLength(1);
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-2");
});
test("qwen-code 不劫持已有 Token Plan 同 id 条目的 name/envKey", () => {
mkdirSync(join(home, ".qwen"), { recursive: true });
writeFileSync(
join(home, ".qwen", "settings.json"),
JSON.stringify({
env: { BAILIAN_TOKEN_PLAN_API_KEY: "sk-token-plan" },
modelProviders: {
openai: [
{
id: "qwen3.8-max-preview",
name: "[Token Plan 个人版] qwen3.8-max-preview",
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
envKey: "BAILIAN_TOKEN_PLAN_API_KEY",
generationConfig: { extra_body: { enable_thinking: true } },
},
],
},
}),
);
const tokenPlanUrl = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
const summary = qwenCode.write({
baseUrl: tokenPlanUrl,
apiKey: "sk-bailian",
model: "qwen3.8-max-preview",
});
const settings = readJsonAt(".qwen", "settings.json");
const openaiEntries = (
settings.modelProviders as Record<string, Array<Record<string, unknown>>>
).openai;
expect(openaiEntries).toHaveLength(1);
expect(openaiEntries[0]).toMatchObject({
id: "qwen3.8-max-preview",
name: "[Token Plan 个人版] qwen3.8-max-preview",
envKey: "BAILIAN_TOKEN_PLAN_API_KEY",
generationConfig: { extra_body: { enable_thinking: true } },
});
expect((settings.env as Record<string, string>).BAILIAN_TOKEN_PLAN_API_KEY).toBe(
"sk-token-plan",
);
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-bailian");
expect(summary.warnings?.some((warning) => warning.includes("already exists"))).toBe(true);
});
test("qwen-code 在进程环境变量覆盖 settings.env 时给出警告", () => {
const previous = process.env.BAILIAN_CLI_API_KEY;
process.env.BAILIAN_CLI_API_KEY = "sk-from-shell";
try {
const summary = qwenCode.write({
baseUrl: OAI_URL,
apiKey: "sk-from-settings",
model: "qwen3-coder-plus",
});
expect(summary.warnings?.some((warning) => warning.includes("overrides settings.json"))).toBe(
true,
);
} finally {
if (previous === undefined) delete process.env.BAILIAN_CLI_API_KEY;
else process.env.BAILIAN_CLI_API_KEY = previous;
}
});
test("opencode 容忍 JSONC(注释与尾逗号)", () => {
@@ -227,11 +327,7 @@ 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();
@@ -254,12 +350,8 @@ 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("openclaw 写入 provider、api、primary,并登记 defaults.models", () => {
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");
@@ -267,7 +359,6 @@ describe("config agent writers", () => {
expect(bailian.api).toBe("openai-completions");
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,
@@ -295,6 +386,57 @@ describe("config agent writers", () => {
>;
expect(providers2["bailian-cli"].api).toBe("anthropic-messages");
expect(providers2["bailian-cli"].models[0].contextWindow).toBe(1000000);
expect(
(config2.agents as { defaults: { model: { primary: string } } }).defaults.model.primary,
).toBe("bailian-cli/qwen3-max");
});
test("openclaw 不抢占已有 token-plan primary", () => {
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(
join(home, ".openclaw", "openclaw.json"),
JSON.stringify({
models: {
mode: "merge",
providers: {
"bailian-token-plan": {
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
apiKey: "sk-token-plan",
api: "anthropic-messages",
models: [{ id: "qwen3.8-max-preview", name: "qwen3.8-max-preview" }],
},
},
},
agents: {
defaults: {
model: { primary: "bailian-token-plan/qwen3.8-max-preview" },
models: { "bailian-token-plan/qwen3.8-max-preview": {} },
},
},
}),
);
const summary = openclaw.write({
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-bailian",
model: "qwen3.8-max-preview",
});
const config = readJsonAt(".openclaw", "openclaw.json");
const agents = config.agents as {
defaults: { model: { primary: string }; models: Record<string, unknown> };
};
expect(agents.defaults.model.primary).toBe("bailian-token-plan/qwen3.8-max-preview");
expect(agents.defaults.models["bailian-cli/qwen3.8-max-preview"]).toEqual({});
expect(
(config.models as { providers: Record<string, unknown> }).providers["bailian-token-plan"],
).toBeDefined();
expect(
(config.models as { providers: Record<string, unknown> }).providers["bailian-cli"],
).toBeDefined();
expect(summary.warnings?.some((warning) => warning.includes("Left existing primary"))).toBe(
true,
);
});
test("hermes 写入官方扁平 model.* 结构,保留其它顶层键", () => {