Skip to content
Merged
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

### 变更
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/commands/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
153 changes: 153 additions & 0 deletions packages/commands/src/commands/config/agent/decode-key.ts
Original file line number Diff line number Diff line change
@@ -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;
}
67 changes: 62 additions & 5 deletions packages/commands/src/commands/config/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);

Expand All @@ -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`);
}
}
},
});
Loading