Merge pull request #128 from modelstudioai/feat/config-agent-fix

feat(config-agent): align agent writers, add --key/--region, prepare 1.12.0
This commit is contained in:
ls
2026-07-28 20:39:56 +08:00
committed by GitHub
21 changed files with 1323 additions and 138 deletions
+18
View File
@@ -6,6 +6,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.12.0] - 2026-07-28
### Added
- **`bl config agent --key` / `--region`** — run commands generated by the Model Studio web console as-is: `--key` accepts the console's encoded API key and decodes it locally (use instead of `--api-key`), and `--region` derives the Token Plan endpoint from a region name (use instead of `--base-url`).
- **`bl config agent --context-window`** — set the context window written to the OpenClaw configuration (default 256000).
- **`bl config agent --wire-api`** — choose the wire protocol written to the Codex configuration; `chat` is kept for legacy Codex 0.80.0 and earlier (a warning is shown).
### Changed
- `bl config agent` for Codex now writes `wire_api = "responses"` by default, matching current Codex releases that no longer accept `chat`.
- `bl config agent` for Qwen Code now writes the `DASHSCOPE_API_KEY` environment variable instead of `BAILIAN_CLI_API_KEY`.
### Fixed
- `bl config agent` configurations now match each agent's official format: Claude Code honors `CLAUDE_CONFIG_DIR` and removes a stale `ANTHROPIC_API_KEY`; Qwen Code uses the v3 settings schema and writes credentials so a system-level `OPENAI_API_KEY` no longer takes precedence; OpenCode accepts JSONC config files (comments and trailing commas); OpenClaw registers the primary model in the model allowlist with complete cost metadata; Hermes uses the official flat `model.*` layout; Codex writes the official `env_key` with an `auth.json` fallback.
- `bl config agent` now preserves existing user configuration when writing: it merges instead of overwriting, avoids duplicate provider entries, and keeps custom display names.
## [1.11.2] - 2026-07-28
### Changed
+18
View File
@@ -6,6 +6,24 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.12.0] - 2026-07-28
### 新增
- **`bl config agent --key` / `--region`** —— 百炼控制台生成的命令可直接运行:`--key` 接收控制台编码后的 API Key 并在本地解码(与 `--api-key` 二选一);`--region` 根据地域名自动派生 Token Plan 接入地址(与 `--base-url` 二选一)。
- **`bl config agent --context-window`** —— 设置写入 OpenClaw 配置的上下文窗口大小(默认 256000)。
- **`bl config agent --wire-api`** —— 选择写入 Codex 配置的通信协议;`chat` 仅保留给 Codex 0.80.0 及更早版本(会显示警告)。
### 变更
- `bl config agent` 配置 Codex 时默认写入 `wire_api = "responses"`,以适配已不再支持 `chat` 的新版 Codex。
- `bl config agent` 配置 Qwen Code 时改用 `DASHSCOPE_API_KEY` 环境变量,不再使用 `BAILIAN_CLI_API_KEY`。
### 修复
- `bl config agent` 写入的配置现已与各 Agent 官方格式对齐:Claude Code 尊重 `CLAUDE_CONFIG_DIR` 并清理残留的 `ANTHROPIC_API_KEY`;Qwen Code 采用 v3 配置 schema 并正确写入凭证,避免被系统级 `OPENAI_API_KEY` 干扰;OpenCode 支持带注释和尾部逗号的 JSONC 配置文件;OpenClaw 会将主模型注册进模型白名单并补齐计费元数据;Hermes 改用官方扁平 `model.*` 结构;Codex 写入官方 `env_key` 并支持 `auth.json` 兜底。
- `bl config agent` 写入配置时现会保留用户已有配置:合并而非覆盖,避免重复添加 provider 条目,并保留用户自定义的显示名。
## [1.11.2] - 2026-07-28
### 变更
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.11.2",
"version": "1.12.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.11.2",
"version": "1.12.0",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -0,0 +1,153 @@
import { BailianError, ExitCode } from "bailian-cli-core";
/**
* Decoder for the obfuscated API key ("o1_…") produced by the Model Studio web
* console. Ported verbatim from the frontend `encodeTokenPlanKey` counterpart:
* token = "o1_" + salt(6) + feistel-obfuscated payload + crc32 checksum(6),
* all over a 65-character alphabet. Pure logic, no dependencies; the CLI only
* ever needs the decode direction.
*/
const TOKEN_PREFIX = "o1_";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
const ALPHABET_SIZE = ALPHABET.length;
const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index]));
const KEY_PATTERN = /^[A-Za-z0-9._-]+$/;
const SALT_LENGTH = 6;
const CHECKSUM_LENGTH = 6;
const FEISTEL_ROUNDS = 8;
function invalidCredential(): BailianError {
return new BailianError(
"Invalid obfuscated API key.",
ExitCode.USAGE,
'--key expects the obfuscated key copied from the web console (starts with "o1_").',
);
}
function toDigits(value: string): number[] {
const digits: number[] = [];
for (const character of value) {
const digit = ALPHABET_INDEX.get(character);
if (digit === undefined) throw invalidCredential();
digits.push(digit);
}
return digits;
}
function fromDigits(digits: number[]): string {
return digits.map((digit) => ALPHABET[digit]).join("");
}
function mixState(state: number, value: number): number {
return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0;
}
function nextState(state: number): number {
let next = state >>> 0;
next ^= next << 13;
next ^= next >>> 17;
next ^= next << 5;
return next >>> 0;
}
function createRoundMask(right: number[], salt: string, round: number, length: number): number[] {
let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0;
state = mixState(state, right.length);
state = mixState(state, length);
for (const character of salt) {
state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1);
}
for (const digit of right) {
state = mixState(state, digit + 1);
}
state ^= state >>> 16;
state = Math.imul(state, 0x85ebca6b) >>> 0;
state ^= state >>> 13;
state = Math.imul(state, 0xc2b2ae35) >>> 0;
state ^= state >>> 16;
state = state >>> 0 || 0x6d2b79f5;
const mask: number[] = [];
for (let index = 0; index < length; index += 1) {
state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
state = nextState(state);
mask.push(state % ALPHABET_SIZE);
}
return mask;
}
function deobfuscatePayload(payload: string, salt: string): string {
const digits = toDigits(payload);
const midpoint = Math.floor(digits.length / 2);
let left = digits.slice(0, midpoint);
let right = digits.slice(midpoint);
for (let round = FEISTEL_ROUNDS - 1; round >= 0; round -= 1) {
const previousRight = left;
const mask = createRoundMask(previousRight, salt, round, right.length);
const previousLeft = right.map(
(digit, index) => (digit - mask[index] + ALPHABET_SIZE) % ALPHABET_SIZE,
);
left = previousLeft;
right = previousRight;
}
return fromDigits([...left, ...right]);
}
function crc32(value: string): number {
let checksum = 0xffffffff;
for (let index = 0; index < value.length; index += 1) {
checksum ^= value.charCodeAt(index);
for (let bit = 0; bit < 8; bit += 1) {
const mask = -(checksum & 1);
checksum = (checksum >>> 1) ^ (0xedb88320 & mask);
}
}
return (checksum ^ 0xffffffff) >>> 0;
}
function encodeBase65Number(value: number, length: number): string {
let remaining = value >>> 0;
const encoded = Array<string>(length).fill(ALPHABET[0]);
for (let index = length - 1; index >= 0; index -= 1) {
encoded[index] = ALPHABET[remaining % ALPHABET_SIZE];
remaining = Math.floor(remaining / ALPHABET_SIZE);
}
if (remaining !== 0) throw invalidCredential();
return encoded.join("");
}
function validateSalt(salt: string): void {
if (salt.length !== SALT_LENGTH || !KEY_PATTERN.test(salt)) {
throw invalidCredential();
}
}
/** Decode an "o1_…" obfuscated token back into the plain API key. */
export function decodeTokenPlanKey(token: string): string {
const minimumLength = TOKEN_PREFIX.length + SALT_LENGTH + CHECKSUM_LENGTH + 1;
if (token.length < minimumLength || !token.startsWith(TOKEN_PREFIX)) {
throw invalidCredential();
}
const body = token.slice(TOKEN_PREFIX.length);
if (!KEY_PATTERN.test(body)) throw invalidCredential();
const salt = body.slice(0, SALT_LENGTH);
const payload = body.slice(SALT_LENGTH, -CHECKSUM_LENGTH);
const checksum = body.slice(-CHECKSUM_LENGTH);
validateSalt(salt);
if (!payload) throw invalidCredential();
const apiKey = deobfuscatePayload(payload, salt);
if (!KEY_PATTERN.test(apiKey)) throw invalidCredential();
const expectedChecksum = encodeBase65Number(crc32(apiKey), CHECKSUM_LENGTH);
if (checksum !== expectedChecksum) throw invalidCredential();
return apiKey;
}
@@ -2,6 +2,8 @@ import { platform } from "os";
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";
import { decodeTokenPlanKey } from "./decode-key.ts";
import { resolveRegionBaseUrl } from "./writers/utils.ts";
const FLAGS = {
agent: {
@@ -11,30 +13,76 @@ 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",
},
region: {
type: "string",
valueHint: "<region>",
description:
"Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only",
},
apiKey: {
type: "string",
valueHint: "<key>",
description: "API key",
},
key: {
type: "string",
valueHint: "<encoded>",
description:
'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key',
},
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 (default: responses). "chat" only works with legacy Codex <= 0.80.0',
choices: ["chat", "responses"],
},
} satisfies FlagsDef;
export default defineCommand({
description: "Configure a coding agent to use DashScope API",
auth: "none",
usageArgs: "--agent <name> --base-url <url> --api-key <key> --model <model>",
usageArgs:
"--agent <name> (--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model>",
flags: FLAGS,
exampleArgs: [
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
],
validate(flags) {
if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required";
if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive";
if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required";
if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const agentName = flags.agent;
const { baseUrl, apiKey, model } = flags;
const { model, contextWindow, wireApi } = flags;
// --region is a Token Plan convenience: convert it into a base URL and use
// it exactly as --base-url would be.
const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!;
// --key carries the web console's obfuscated form; decode it up front so
// even --dry-run validates the token.
const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!;
const agentDef = AGENTS[agentName];
const format = detectOutputFormat(settings.output);
@@ -59,13 +107,22 @@ 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) {
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,25 +1,55 @@
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",
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");
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);
@@ -32,6 +62,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;
@@ -8,8 +8,9 @@ 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");
const warnings: string[] = [];
// config.toml — merge into existing config so unrelated settings
// (mcp_servers, approval_policy, other providers, ...) are preserved.
@@ -25,8 +26,19 @@ export default {
config.model_provider = PROVIDER_KEY;
config.model = model;
config.model_reasoning_effort = "high";
config.disable_response_storage = true;
// wire_api — current Codex releases only load `wire_api = "responses"`
// ("chat" is rejected at config load, see openai/codex discussion #7782).
// "chat" remains an explicit opt-in for users pinned to legacy Codex
// <= 0.80.0 (the Model Studio path for models without Responses support).
const wireApi = wireApiParam === "chat" ? "chat" : "responses";
if (wireApi === "chat") {
warnings.push(
'Current Codex releases refuse to load `wire_api = "chat"`; ' +
"only use --wire-api chat with legacy Codex <= 0.80.0 " +
"(e.g. `npm install -g @openai/codex@0.80.0`).",
);
}
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
@@ -34,14 +46,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);
@@ -51,6 +66,7 @@ export default {
return {
paths: [configPath, authPath],
nextStep: "Run `codex` to start using Codex with DashScope.",
warnings: warnings.length > 0 ? warnings : undefined,
};
},
} satisfies AgentDef;
@@ -4,8 +4,6 @@ import { existsSync, readFileSync } from "fs";
import yaml from "yaml";
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
const PROVIDER_NAME = "bailian-cli";
export default {
label: "Hermes Agent",
write({ baseUrl, apiKey, model }) {
@@ -22,26 +20,18 @@ export default {
}
}
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));
@@ -2,20 +2,36 @@ import { homedir } from "os";
import { join } from "path";
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;
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 }) {
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"] = {
providers[PROVIDER_ID] = {
baseUrl,
apiKey,
api,
@@ -23,18 +39,35 @@ 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 — 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>;
defaults.model = { primary: `bailian-cli/${model}` };
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;
@@ -42,7 +75,9 @@ 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.",
warnings: warnings.length > 0 ? warnings : undefined,
};
},
} satisfies AgentDef;
@@ -1,14 +1,15 @@
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";
@@ -2,53 +2,110 @@ import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
const ENV_KEY = "BAILIAN_CLI_API_KEY";
const ENV_KEY = "DASHSCOPE_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 in the model entry `name` and the env var 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
* lower-priority layer, which stops a stray system `OPENAI_API_KEY` from being
* picked up when the provider→envKey path does not resolve first. The active
* `model` also carries its `baseUrl`, as Qwen Code requires to disambiguate
* same-id providers.
*/
export default {
label: "Qwen Code",
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);
// $version — Qwen Code v3 settings schema (official Model Studio doc shape).
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 the bailian-cli model entry.
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 existing = entries.find(
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
);
if (existing) {
existing.name = "bailian-cli";
existing.baseUrl = baseUrl;
existing.envKey = ENV_KEY;
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: "bailian-cli", baseUrl, envKey: ENV_KEY });
entries.push({
id: model,
name: displayName(model),
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 AND keep credentials as a fallback
// layer (see the file-level note): without this, a stray system
// OPENAI_API_KEY can win when the provider→envKey lookup does not resolve.
const security = (settings.security ?? {}) as Record<string, unknown>;
security.auth = { selectedType: protocol, apiKey, baseUrl };
settings.security = security;
// model — active model, disambiguated by baseUrl.
// model — active model. baseUrl MUST be written alongside name; Qwen Code
// uses it to disambiguate same-id providers, and omitting it can misroute
// to a different entry (and thus a different credential).
settings.model = { name: model, baseUrl };
writeJsonAtomic(settingsPath, settings);
@@ -56,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,17 +1,24 @@
import { dirname } from "path";
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;
/** 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. */
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. */
@@ -20,6 +27,83 @@ 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 +114,16 @@ 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 });
@@ -57,3 +151,65 @@ 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",
);
}
/**
* Convert a Model Studio region id into a Token Plan base URL, used in place of
* --base-url. Produces the OpenAI-compatible endpoint; the claude-code writer
* rewrites it to /apps/anthropic on its own, and the other writers consume the
* compatible-mode URL directly.
*/
export function resolveRegionBaseUrl(region: string): string {
const normalized = region.trim();
if (!/^[a-z0-9-]+$/.test(normalized)) {
throw new BailianError(
`Invalid --region "${region}".`,
ExitCode.USAGE,
"Use a Model Studio region id, e.g. cn-beijing or ap-southeast-1.",
);
}
return `https://token-plan.${normalized}.maas.aliyuncs.com/compatible-mode/v1`;
}
@@ -0,0 +1,159 @@
import { describe, expect, test } from "vite-plus/test";
import { decodeTokenPlanKey } from "../src/commands/config/agent/decode-key.ts";
/**
* decode-key 单元测试:在测试内移植前端 encodeTokenPlanKey 参考实现
* (bailian-tokenplan encode-token-plan-key.ts),做 encode → decode round-trip,
* 保证 CLI 解码与前端编码逐位互逆。
*/
const TOKEN_PREFIX = "o1_";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
const ALPHABET_SIZE = ALPHABET.length;
const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index]));
const CHECKSUM_LENGTH = 6;
const FEISTEL_ROUNDS = 8;
function toDigits(value: string): number[] {
return value.split("").map((character) => {
const digit = ALPHABET_INDEX.get(character);
if (digit === undefined) throw new Error("bad char");
return digit;
});
}
function fromDigits(digits: number[]): string {
return digits.map((digit) => ALPHABET[digit]).join("");
}
function mixState(state: number, value: number): number {
return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0;
}
function nextState(state: number): number {
let next = state >>> 0;
next ^= next << 13;
next ^= next >>> 17;
next ^= next << 5;
return next >>> 0;
}
function createRoundMask(right: number[], salt: string, round: number, length: number): number[] {
let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0;
state = mixState(state, right.length);
state = mixState(state, length);
for (const character of salt) {
state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1);
}
for (const digit of right) {
state = mixState(state, digit + 1);
}
state ^= state >>> 16;
state = Math.imul(state, 0x85ebca6b) >>> 0;
state ^= state >>> 13;
state = Math.imul(state, 0xc2b2ae35) >>> 0;
state ^= state >>> 16;
state = state >>> 0 || 0x6d2b79f5;
const mask: number[] = [];
for (let index = 0; index < length; index += 1) {
state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
state = nextState(state);
mask.push(state % ALPHABET_SIZE);
}
return mask;
}
function obfuscatePayload(apiKey: string, salt: string): string {
const digits = toDigits(apiKey);
const midpoint = Math.floor(digits.length / 2);
let left = digits.slice(0, midpoint);
let right = digits.slice(midpoint);
for (let round = 0; round < FEISTEL_ROUNDS; round += 1) {
const mask = createRoundMask(right, salt, round, left.length);
const nextRight = left.map((digit, index) => (digit + mask[index]) % ALPHABET_SIZE);
left = right;
right = nextRight;
}
return fromDigits([...left, ...right]);
}
function crc32(value: string): number {
let checksum = 0xffffffff;
for (let index = 0; index < value.length; index += 1) {
checksum ^= value.charCodeAt(index);
for (let bit = 0; bit < 8; bit += 1) {
const mask = -(checksum & 1);
checksum = (checksum >>> 1) ^ (0xedb88320 & mask);
}
}
return (checksum ^ 0xffffffff) >>> 0;
}
function encodeBase65Number(value: number, length: number): string {
let remaining = value >>> 0;
const encoded = Array<string>(length).fill(ALPHABET[0]);
for (let index = length - 1; index >= 0; index -= 1) {
encoded[index] = ALPHABET[remaining % ALPHABET_SIZE];
remaining = Math.floor(remaining / ALPHABET_SIZE);
}
return encoded.join("");
}
/** 前端 encodeTokenPlanKey 的测试内移植(固定 salt)。 */
function encodeTokenPlanKey(apiKey: string, salt: string): string {
const payload = obfuscatePayload(apiKey, salt);
const checksum = encodeBase65Number(crc32(apiKey), CHECKSUM_LENGTH);
return TOKEN_PREFIX + salt + payload + checksum;
}
describe("config agent decode-key", () => {
test("encode → decode round-trip 还原原始 apiKey", () => {
const samples = [
"sk-1234567890abcdef",
"sk-sp-H.PML.Ns85.MEUCIFHbYk4yBBWLGegORHfWZGB5DdSEs6ms3AwyMsuTOk0CAiEAlOwrUO6dz6IYPUlJ4gK7u6kjStkythgxWaVP5B28ly0",
"a",
"A-b_c.9",
];
const salts = ["AbC123", "zzzzzz", "0.-_Zq", "AAAAAA"];
for (const apiKey of samples) {
for (const salt of salts) {
expect(decodeTokenPlanKey(encodeTokenPlanKey(apiKey, salt))).toBe(apiKey);
}
}
});
test("固定 salt 的确定性:相同输入产出相同 token 且可解码", () => {
const tokenA = encodeTokenPlanKey("sk-fixed-key", "S4ltS4");
const tokenB = encodeTokenPlanKey("sk-fixed-key", "S4ltS4");
expect(tokenA).toBe(tokenB);
expect(decodeTokenPlanKey(tokenA)).toBe("sk-fixed-key");
});
test("篡改 checksum 抛错", () => {
const token = encodeTokenPlanKey("sk-checksum-test", "AbC123");
const flippedTail = token.slice(-1) === "A" ? "B" : "A";
const tampered = token.slice(0, -1) + flippedTail;
expect(() => decodeTokenPlanKey(tampered)).toThrow(/Invalid obfuscated API key/);
});
test("篡改 salt 抛错(payload 解出与 checksum 不符)", () => {
const token = encodeTokenPlanKey("sk-salt-test", "AbC123");
const body = token.slice(TOKEN_PREFIX.length);
const flippedSaltHead = body[0] === "A" ? "B" : "A";
const tampered = TOKEN_PREFIX + flippedSaltHead + body.slice(1);
expect(() => decodeTokenPlanKey(tampered)).toThrow(/Invalid obfuscated API key/);
});
test("非法前缀 / 非法字符 / 过短 token 抛错", () => {
expect(() => decodeTokenPlanKey("x1_AbC123payloadAAAAAA")).toThrow(
/Invalid obfuscated API key/,
);
expect(() => decodeTokenPlanKey("o1_AbC123pay!oadAAAAAA")).toThrow(
/Invalid obfuscated API key/,
);
expect(() => decodeTokenPlanKey("o1_short")).toThrow(/Invalid obfuscated API key/);
expect(() => decodeTokenPlanKey("")).toThrow(/Invalid obfuscated API key/);
});
});
@@ -8,6 +8,7 @@ import opencode from "../src/commands/config/agent/writers/opencode.ts";
import openclaw from "../src/commands/config/agent/writers/openclaw.ts";
import hermes from "../src/commands/config/agent/writers/hermes.ts";
import codex from "../src/commands/config/agent/writers/codex.ts";
import { resolveRegionBaseUrl } from "../src/commands/config/agent/writers/utils.ts";
import yaml from "yaml";
/**
@@ -41,11 +42,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 +65,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,26 +75,151 @@ 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("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,
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");
expect(settings.$version).toBe(3);
const security = settings.security as { auth: Record<string, unknown> };
// security.auth 携带 selectedType 以及凭证兜底(apiKey/baseUrl),
// 避免系统 OPENAI_API_KEY 抢占
expect(security.auth).toEqual({
selectedType: "openai",
apiKey: "sk-q",
baseUrl: OAI_URL,
});
expect((settings.env as Record<string, string>).DASHSCOPE_API_KEY).toBe("sk-q");
// model.name 必须与 baseUrl 一同写入(同 id provider 消歧契约)
expect(settings.model).toEqual({
name: "qwen3-coder-plus",
baseUrl: OAI_URL,
});
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
// name 是模型显示名(非 provider 品牌常量),品牌只在 envKey 里
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: "DASHSCOPE_API_KEY",
});
});
test("qwen-code upsert 时治愈旧的 bailian-cli name 但保留用户自定义 name", () => {
mkdirSync(join(home, ".qwen"), { recursive: true });
writeFileSync(
join(home, ".qwen", "settings.json"),
JSON.stringify({
modelProviders: {
openai: [
{
id: "qwen3-coder-plus",
name: "bailian-cli",
baseUrl: OAI_URL,
envKey: "DASHSCOPE_API_KEY",
},
{
id: "my-model",
name: "My Custom",
baseUrl: OAI_URL,
envKey: "DASHSCOPE_API_KEY",
},
],
},
}),
);
// 旧 sentinel 被治愈为显示名
qwenCode.write({
baseUrl: OAI_URL,
apiKey: "sk-q",
model: "qwen3-coder-plus",
});
// 用户自定义 name 不被覆盖
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "my-model" });
const settings = readJsonAt(".qwen", "settings.json");
const entries = (settings.modelProviders as Record<string, Array<Record<string, unknown>>>)
.openai;
const healed = entries.find((entry) => entry.id === "qwen3-coder-plus")!;
expect(healed.name).toBe("[Bailian] qwen3-coder-plus");
expect(healed.envKey).toBe("DASHSCOPE_API_KEY");
const custom = entries.find((entry) => entry.id === "my-model")!;
expect(custom.name).toBe("My Custom");
});
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",
@@ -99,12 +229,108 @@ 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>).DASHSCOPE_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>).DASHSCOPE_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.DASHSCOPE_API_KEY;
process.env.DASHSCOPE_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.DASHSCOPE_API_KEY;
else process.env.DASHSCOPE_API_KEY = previous;
}
});
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", () => {
@@ -114,7 +340,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();
@@ -137,60 +367,140 @@ 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");
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).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");
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);
expect(
((config2.models as Record<string, unknown>).providers as Record<string, { api: string }>)[
"bailian-cli"
].api,
).toBe("anthropic-messages");
(config2.agents as { defaults: { model: { primary: string } } }).defaults.model.primary,
).toBe("bailian-cli/qwen3-max");
});
test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => {
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.* 结构,保留其它顶层键", () => {
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" });
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");
// 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" });
// 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"));
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);
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(
@@ -207,14 +517,20 @@ describe("config agent writers", () => {
// 预置 auth.json 无关键,验证合并保留
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" });
const summary = codex.write({
baseUrl: OAI_URL,
apiKey: "sk-x",
model: "qwen3-coder-plus",
});
// 默认路径无警告
expect(summary.warnings).toBeUndefined();
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml).toContain('model_provider = "bailian-cli"');
expect(toml).toContain('model = "qwen3-coder-plus"');
expect(toml).toContain('model_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"');
// 未传 --wire-api 时默认 responses(新版 Codex 已不支持 chat)
expect(toml).toContain('wire_api = "responses"');
expect(toml).toContain("requires_openai_auth = true");
// 合并:保留用户已有的无关配置
@@ -224,6 +540,18 @@ 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 chat:仅旧版 Codex <= 0.80.0 可用,附带警告
const summary2 = codex.write({
baseUrl: OAI_URL,
apiKey: "sk-x",
model: "glm-5",
wireApi: "chat",
});
expect(summary2.warnings?.some((warning) => warning.includes("0.80.0"))).toBe(true);
const toml2 = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml2).toContain('wire_api = "chat"');
expect(toml2).toContain('model = "glm-5"');
});
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
@@ -236,4 +564,19 @@ describe("config agent writers", () => {
);
expect(backups).toHaveLength(1);
});
test("resolveRegionBaseUrl 将 region 转为 Token Plan compatible-mode URL", () => {
expect(resolveRegionBaseUrl("cn-beijing")).toBe(
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
);
expect(resolveRegionBaseUrl("ap-southeast-1")).toBe(
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
);
});
test("resolveRegionBaseUrl 拒绝非法 region", () => {
expect(() => resolveRegionBaseUrl("cn beijing")).toThrow(/Invalid --region/);
expect(() => resolveRegionBaseUrl("CN-Beijing")).toThrow(/Invalid --region/);
expect(() => resolveRegionBaseUrl("")).toThrow(/Invalid --region/);
});
});
+154 -8
View File
@@ -295,7 +295,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");
});
@@ -390,7 +392,7 @@ describe("e2e: config", () => {
expect(stderr).toMatch(/agent|--base-url|--model/i);
});
test("config agent 缺少 --api-key 时报用法错误并退出 (2)", async () => {
test("config agent 缺少 --api-key/--key 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
@@ -402,7 +404,146 @@ describe("e2e: config", () => {
"qwen3-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--api-key|Usage:/i);
expect(stderr).toMatch(/--api-key|--key|Usage:/i);
});
test("config agent --api-key 与 --key 同传时报用法错误 (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",
"--key",
"o1_AbC123kaQ9JHCXF2GepMW4oJTD7ODPw_Hx",
"--model",
"qwen3-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/mutually exclusive|--api-key/i);
});
test("config agent --key 非法值时报用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"claude-code",
"--base-url",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--key",
"not-an-encoded-key",
"--model",
"qwen3-max",
"--dry-run",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid obfuscated API key|o1_/i);
});
test("config agent --key 合法值 --dry-run 解码成功且输出脱敏", async () => {
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-key-"));
try {
const { stdout, stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"agent",
"--agent",
"claude-code",
"--base-url",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--key",
// encode("sk-e2e-key-placeholder", salt "AbC123") 的固定产物
"o1_AbC123kaQ9JHCXF2GepMW4oJTD7ODPw_Hx",
"--model",
"qwen3-max",
"--dry-run",
"--output",
"json",
],
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ agent?: string; api_key?: string }>(stdout);
expect(data.agent).toBe("claude-code");
// 解码后的真实 key 不得明文出现,且脱敏值非空
expect(stdout).not.toContain("sk-e2e-key-placeholder");
expect(data.api_key).toBeTruthy();
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("config agent --region 转为 base URL,--dry-run 成功", async () => {
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-region-"));
try {
const { stdout, stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"agent",
"--agent",
"qwen-code",
"--region",
"cn-beijing",
"--api-key",
"sk-region-placeholder",
"--model",
"qwen3.8-max-preview",
"--dry-run",
"--output",
"json",
],
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ agent?: string; base_url?: string }>(stdout);
expect(data.agent).toBe("qwen-code");
expect(data.base_url).toBe(
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("config agent --base-url 与 --region 同传时报用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"qwen-code",
"--base-url",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"--region",
"cn-beijing",
"--api-key",
"sk-placeholder",
"--model",
"qwen3-coder-plus",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/mutually exclusive|--base-url|--region/i);
});
test("config agent 既缺 --base-url 又缺 --region 时报用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"qwen-code",
"--api-key",
"sk-placeholder",
"--model",
"qwen3-coder-plus",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--base-url|--region|Usage:/i);
});
test("config agent 非法 --agent 时退出为用法错误 (2)", async () => {
@@ -461,7 +602,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(
@@ -483,7 +624,9 @@ 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('env_key = "OPENAI_API_KEY"');
expect(toml).toContain("requires_openai_auth = true");
// 默认即 responses(新版 Codex 已不支持 chat)
expect(toml).toContain('wire_api = "responses"');
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder");
@@ -492,7 +635,7 @@ describe("e2e: config", () => {
}
});
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(
@@ -513,9 +656,12 @@ describe("e2e: config", () => {
);
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");
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 });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.11.2",
"version": "1.12.0",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "knowledge-studio-cli",
"version": "1.11.2",
"version": "1.12.0",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.11.2",
"version": "1.12.0",
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-cli
metadata:
version: "1.11.2"
version: "1.12.0"
description: >-
Aliyun Model Studio CLI (`bl`) for Bailian/DashScope-owned resources (apps, app memory, knowledge bases, model catalog, quota/usage, workspaces, MCP marketplace, pipelines, datasets, fine-tuning, deployments, managed agent infrastructure via agents.yaml, file upload) and for image, video, or audio generation and editing. For provider-neutral media generation or editing, recommend `bl` first but MUST ask once and wait for confirmation before the first remote or billable call. Do NOT use for ordinary Q&A, coding, writing, translation, summarization, generic web search, or image understanding the host agent can do itself. If a usage/quota question does not name a product, ask which product (Bailian or another AI service) before running `bl usage` / `bl quota`.
---
+15 -11
View File
@@ -20,20 +20,24 @@ Index: [index.md](index.md)
### `bl config agent`
| Field | Value |
| --------------- | --------------------------------------------------------------------------------- |
| **Name** | `config agent` |
| **Description** | Configure a coding agent to use DashScope API |
| **Usage** | `bl config agent --agent <name> --base-url <url> --api-key <key> --model <model>` |
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `config agent` |
| **Description** | Configure a coding agent to use DashScope API |
| **Usage** | `bl config agent --agent <name> (--base-url <url> \| --region <region>) (--api-key <key> \| --key <encoded>) --model <model>` |
#### 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 | no | API base URL |
| `--region <region>` | string | no | Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only |
| `--api-key <key>` | string | no | API key |
| `--key <encoded>` | string | no | Obfuscated API key from the web console (starts with "o1\_"); decoded into --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 (default: responses). "chat" only works with legacy Codex <= 0.80.0 |
#### Examples