feat(config-agent): add --key and --region, default codex wire_api to responses

- --key: decode the web console's obfuscated API key (o1_ prefix) into
  the real key; mutually exclusive with --api-key, exactly one required
- --region: convert a Model Studio region into the Token Plan base URL
  (token-plan.<region>.maas.aliyuncs.com/compatible-mode/v1); mutually
  exclusive with --base-url, exactly one required
- codex: default wire_api to "responses" (current Codex rejects "chat");
  --wire-api chat kept for legacy Codex <= 0.80.0 with a warning
- regenerate skills reference for the new flags
This commit is contained in:
lisheng.lisheng
2026-07-28 19:37:13 +08:00
parent 5a58f56b06
commit 96744e3328
11 changed files with 549 additions and 57 deletions
@@ -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: {
@@ -15,13 +17,23 @@ const FLAGS = {
type: "string",
valueHint: "<url>",
description: "API base URL",
required: true,
},
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",
required: true,
},
key: {
type: "string",
valueHint: "<encoded>",
description:
'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key',
},
model: {
type: "string",
@@ -46,17 +58,31 @@ const FLAGS = {
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, contextWindow, wireApi } = 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);
@@ -20,8 +20,7 @@ export default {
label: "Claude Code",
write({ baseUrl, apiKey, model }) {
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
const configDir =
process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
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[] = [];
@@ -2,12 +2,7 @@ import { homedir } from "os";
import { join } from "path";
import { existsSync, readFileSync } from "fs";
import yaml from "yaml";
import {
backup,
writeTextAtomic,
isAnthropicEndpoint,
type AgentDef,
} from "./utils.ts";
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
export default {
label: "Hermes Agent",
@@ -19,8 +14,7 @@ export default {
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = (yaml.parse(readFileSync(configPath, "utf-8")) ??
{}) as Record<string, unknown>;
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
} catch {
config = {};
}
@@ -1,12 +1,6 @@
import { homedir } from "os";
import { join } from "path";
import {
backup,
readJson,
writeJsonAtomic,
isAnthropicEndpoint,
type AgentDef,
} from "./utils.ts";
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
// Safe default when --context-window is not given: most Model Studio models
// offer ≥256K context; users can raise it per model via the flag.
@@ -1,12 +1,6 @@
import { homedir } from "os";
import { join } from "path";
import {
backup,
readJsonc,
writeJsonAtomic,
isAnthropicEndpoint,
type AgentDef,
} from "./utils.ts";
import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
export default {
label: "OpenCode",
@@ -20,9 +14,7 @@ export default {
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
const provider = (config.provider ?? {}) as Record<string, unknown>;
const npm = isAnthropicEndpoint(baseUrl)
? "@ai-sdk/anthropic"
: "@ai-sdk/openai-compatible";
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
provider["bailian-cli"] = {
npm,
name: "Alibaba Cloud Model Studio",
@@ -195,3 +195,21 @@ export function resolveClaudeCodeBaseUrl(baseUrl: string): {
"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";
/**
@@ -154,7 +155,7 @@ describe("config agent writers", () => {
apiKey: "sk-q",
baseUrl: OAI_URL,
});
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
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",
@@ -166,7 +167,7 @@ describe("config agent writers", () => {
id: "qwen3-coder-plus",
name: "[Bailian] qwen3-coder-plus",
baseUrl: OAI_URL,
envKey: "BAILIAN_CLI_API_KEY",
envKey: "DASHSCOPE_API_KEY",
});
});
@@ -181,13 +182,13 @@ describe("config agent writers", () => {
id: "qwen3-coder-plus",
name: "bailian-cli",
baseUrl: OAI_URL,
envKey: "BAILIAN_CLI_API_KEY",
envKey: "DASHSCOPE_API_KEY",
},
{
id: "my-model",
name: "My Custom",
baseUrl: OAI_URL,
envKey: "BAILIAN_CLI_API_KEY",
envKey: "DASHSCOPE_API_KEY",
},
],
},
@@ -208,7 +209,7 @@ describe("config agent writers", () => {
.openai;
const healed = entries.find((entry) => entry.id === "qwen3-coder-plus")!;
expect(healed.name).toBe("[Bailian] qwen3-coder-plus");
expect(healed.envKey).toBe("BAILIAN_CLI_API_KEY");
expect(healed.envKey).toBe("DASHSCOPE_API_KEY");
const custom = entries.find((entry) => entry.id === "my-model")!;
expect(custom.name).toBe("My Custom");
});
@@ -242,7 +243,7 @@ describe("config agent writers", () => {
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");
expect((settings.env as Record<string, string>).DASHSCOPE_API_KEY).toBe("sk-2");
});
test("qwen-code 不劫持已有 Token Plan 同 id 条目的 name/envKey", () => {
@@ -286,13 +287,13 @@ describe("config agent writers", () => {
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((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.BAILIAN_CLI_API_KEY;
process.env.BAILIAN_CLI_API_KEY = "sk-from-shell";
const previous = process.env.DASHSCOPE_API_KEY;
process.env.DASHSCOPE_API_KEY = "sk-from-shell";
try {
const summary = qwenCode.write({
baseUrl: OAI_URL,
@@ -303,8 +304,8 @@ describe("config agent writers", () => {
true,
);
} finally {
if (previous === undefined) delete process.env.BAILIAN_CLI_API_KEY;
else process.env.BAILIAN_CLI_API_KEY = previous;
if (previous === undefined) delete process.env.DASHSCOPE_API_KEY;
else process.env.DASHSCOPE_API_KEY = previous;
}
});
@@ -563,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/);
});
});
+141 -2
View File
@@ -392,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",
@@ -404,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 () => {
+15 -13
View File
@@ -20,22 +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 |
| `--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 |
| 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