mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user