Skip to content

Commit 96744e3

Browse files
committed
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
1 parent 5a58f56 commit 96744e3

11 files changed

Lines changed: 549 additions & 57 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { BailianError, ExitCode } from "bailian-cli-core";
2+
3+
/**
4+
* Decoder for the obfuscated API key ("o1_…") produced by the Model Studio web
5+
* console. Ported verbatim from the frontend `encodeTokenPlanKey` counterpart:
6+
* token = "o1_" + salt(6) + feistel-obfuscated payload + crc32 checksum(6),
7+
* all over a 65-character alphabet. Pure logic, no dependencies; the CLI only
8+
* ever needs the decode direction.
9+
*/
10+
11+
const TOKEN_PREFIX = "o1_";
12+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
13+
const ALPHABET_SIZE = ALPHABET.length;
14+
const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index]));
15+
const KEY_PATTERN = /^[A-Za-z0-9._-]+$/;
16+
const SALT_LENGTH = 6;
17+
const CHECKSUM_LENGTH = 6;
18+
const FEISTEL_ROUNDS = 8;
19+
20+
function invalidCredential(): BailianError {
21+
return new BailianError(
22+
"Invalid obfuscated API key.",
23+
ExitCode.USAGE,
24+
'--key expects the obfuscated key copied from the web console (starts with "o1_").',
25+
);
26+
}
27+
28+
function toDigits(value: string): number[] {
29+
const digits: number[] = [];
30+
for (const character of value) {
31+
const digit = ALPHABET_INDEX.get(character);
32+
if (digit === undefined) throw invalidCredential();
33+
digits.push(digit);
34+
}
35+
return digits;
36+
}
37+
38+
function fromDigits(digits: number[]): string {
39+
return digits.map((digit) => ALPHABET[digit]).join("");
40+
}
41+
42+
function mixState(state: number, value: number): number {
43+
return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0;
44+
}
45+
46+
function nextState(state: number): number {
47+
let next = state >>> 0;
48+
next ^= next << 13;
49+
next ^= next >>> 17;
50+
next ^= next << 5;
51+
return next >>> 0;
52+
}
53+
54+
function createRoundMask(right: number[], salt: string, round: number, length: number): number[] {
55+
let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0;
56+
57+
state = mixState(state, right.length);
58+
state = mixState(state, length);
59+
for (const character of salt) {
60+
state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1);
61+
}
62+
for (const digit of right) {
63+
state = mixState(state, digit + 1);
64+
}
65+
66+
state ^= state >>> 16;
67+
state = Math.imul(state, 0x85ebca6b) >>> 0;
68+
state ^= state >>> 13;
69+
state = Math.imul(state, 0xc2b2ae35) >>> 0;
70+
state ^= state >>> 16;
71+
state = state >>> 0 || 0x6d2b79f5;
72+
73+
const mask: number[] = [];
74+
for (let index = 0; index < length; index += 1) {
75+
state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
76+
state = nextState(state);
77+
mask.push(state % ALPHABET_SIZE);
78+
}
79+
return mask;
80+
}
81+
82+
function deobfuscatePayload(payload: string, salt: string): string {
83+
const digits = toDigits(payload);
84+
const midpoint = Math.floor(digits.length / 2);
85+
let left = digits.slice(0, midpoint);
86+
let right = digits.slice(midpoint);
87+
88+
for (let round = FEISTEL_ROUNDS - 1; round >= 0; round -= 1) {
89+
const previousRight = left;
90+
const mask = createRoundMask(previousRight, salt, round, right.length);
91+
const previousLeft = right.map(
92+
(digit, index) => (digit - mask[index] + ALPHABET_SIZE) % ALPHABET_SIZE,
93+
);
94+
left = previousLeft;
95+
right = previousRight;
96+
}
97+
98+
return fromDigits([...left, ...right]);
99+
}
100+
101+
function crc32(value: string): number {
102+
let checksum = 0xffffffff;
103+
for (let index = 0; index < value.length; index += 1) {
104+
checksum ^= value.charCodeAt(index);
105+
for (let bit = 0; bit < 8; bit += 1) {
106+
const mask = -(checksum & 1);
107+
checksum = (checksum >>> 1) ^ (0xedb88320 & mask);
108+
}
109+
}
110+
return (checksum ^ 0xffffffff) >>> 0;
111+
}
112+
113+
function encodeBase65Number(value: number, length: number): string {
114+
let remaining = value >>> 0;
115+
const encoded = Array<string>(length).fill(ALPHABET[0]);
116+
117+
for (let index = length - 1; index >= 0; index -= 1) {
118+
encoded[index] = ALPHABET[remaining % ALPHABET_SIZE];
119+
remaining = Math.floor(remaining / ALPHABET_SIZE);
120+
}
121+
if (remaining !== 0) throw invalidCredential();
122+
return encoded.join("");
123+
}
124+
125+
function validateSalt(salt: string): void {
126+
if (salt.length !== SALT_LENGTH || !KEY_PATTERN.test(salt)) {
127+
throw invalidCredential();
128+
}
129+
}
130+
131+
/** Decode an "o1_…" obfuscated token back into the plain API key. */
132+
export function decodeTokenPlanKey(token: string): string {
133+
const minimumLength = TOKEN_PREFIX.length + SALT_LENGTH + CHECKSUM_LENGTH + 1;
134+
if (token.length < minimumLength || !token.startsWith(TOKEN_PREFIX)) {
135+
throw invalidCredential();
136+
}
137+
138+
const body = token.slice(TOKEN_PREFIX.length);
139+
if (!KEY_PATTERN.test(body)) throw invalidCredential();
140+
141+
const salt = body.slice(0, SALT_LENGTH);
142+
const payload = body.slice(SALT_LENGTH, -CHECKSUM_LENGTH);
143+
const checksum = body.slice(-CHECKSUM_LENGTH);
144+
validateSalt(salt);
145+
if (!payload) throw invalidCredential();
146+
147+
const apiKey = deobfuscatePayload(payload, salt);
148+
if (!KEY_PATTERN.test(apiKey)) throw invalidCredential();
149+
150+
const expectedChecksum = encodeBase65Number(crc32(apiKey), CHECKSUM_LENGTH);
151+
if (checksum !== expectedChecksum) throw invalidCredential();
152+
return apiKey;
153+
}

packages/commands/src/commands/config/agent/index.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { platform } from "os";
22
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
33
import { emitResult, emitBare } from "bailian-cli-runtime";
44
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
5+
import { decodeTokenPlanKey } from "./decode-key.ts";
6+
import { resolveRegionBaseUrl } from "./writers/utils.ts";
57

68
const FLAGS = {
79
agent: {
@@ -15,13 +17,23 @@ const FLAGS = {
1517
type: "string",
1618
valueHint: "<url>",
1719
description: "API base URL",
18-
required: true,
20+
},
21+
region: {
22+
type: "string",
23+
valueHint: "<region>",
24+
description:
25+
"Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only",
1926
},
2027
apiKey: {
2128
type: "string",
2229
valueHint: "<key>",
2330
description: "API key",
24-
required: true,
31+
},
32+
key: {
33+
type: "string",
34+
valueHint: "<encoded>",
35+
description:
36+
'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key',
2537
},
2638
model: {
2739
type: "string",
@@ -46,17 +58,31 @@ const FLAGS = {
4658
export default defineCommand({
4759
description: "Configure a coding agent to use DashScope API",
4860
auth: "none",
49-
usageArgs: "--agent <name> --base-url <url> --api-key <key> --model <model>",
61+
usageArgs:
62+
"--agent <name> (--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model>",
5063
flags: FLAGS,
5164
exampleArgs: [
5265
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
5366
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
5467
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
5568
],
69+
validate(flags) {
70+
if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required";
71+
if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive";
72+
if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required";
73+
if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive";
74+
return undefined;
75+
},
5676
async run(ctx) {
5777
const { settings, flags } = ctx;
5878
const agentName = flags.agent;
59-
const { baseUrl, apiKey, model, contextWindow, wireApi } = flags;
79+
const { model, contextWindow, wireApi } = flags;
80+
// --region is a Token Plan convenience: convert it into a base URL and use
81+
// it exactly as --base-url would be.
82+
const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!;
83+
// --key carries the web console's obfuscated form; decode it up front so
84+
// even --dry-run validates the token.
85+
const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!;
6086
const agentDef = AGENTS[agentName];
6187
const format = detectOutputFormat(settings.output);
6288

packages/commands/src/commands/config/agent/writers/claude-code.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ export default {
2020
label: "Claude Code",
2121
write({ baseUrl, apiKey, model }) {
2222
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
23-
const configDir =
24-
process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
23+
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
2524
const settingsPath = join(configDir, "settings.json");
2625
const onboardingPath = join(homedir(), ".claude.json");
2726
const warnings: string[] = [];

packages/commands/src/commands/config/agent/writers/hermes.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ import { homedir } from "os";
22
import { join } from "path";
33
import { existsSync, readFileSync } from "fs";
44
import yaml from "yaml";
5-
import {
6-
backup,
7-
writeTextAtomic,
8-
isAnthropicEndpoint,
9-
type AgentDef,
10-
} from "./utils.ts";
5+
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
116

127
export default {
138
label: "Hermes Agent",
@@ -19,8 +14,7 @@ export default {
1914
let config: Record<string, unknown> = {};
2015
if (existsSync(configPath)) {
2116
try {
22-
config = (yaml.parse(readFileSync(configPath, "utf-8")) ??
23-
{}) as Record<string, unknown>;
17+
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
2418
} catch {
2519
config = {};
2620
}

packages/commands/src/commands/config/agent/writers/openclaw.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { homedir } from "os";
22
import { join } from "path";
3-
import {
4-
backup,
5-
readJson,
6-
writeJsonAtomic,
7-
isAnthropicEndpoint,
8-
type AgentDef,
9-
} from "./utils.ts";
3+
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
104

115
// Safe default when --context-window is not given: most Model Studio models
126
// offer ≥256K context; users can raise it per model via the flag.

packages/commands/src/commands/config/agent/writers/opencode.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { homedir } from "os";
22
import { join } from "path";
3-
import {
4-
backup,
5-
readJsonc,
6-
writeJsonAtomic,
7-
isAnthropicEndpoint,
8-
type AgentDef,
9-
} from "./utils.ts";
3+
import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
104

115
export default {
126
label: "OpenCode",
@@ -20,9 +14,7 @@ export default {
2014
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
2115

2216
const provider = (config.provider ?? {}) as Record<string, unknown>;
23-
const npm = isAnthropicEndpoint(baseUrl)
24-
? "@ai-sdk/anthropic"
25-
: "@ai-sdk/openai-compatible";
17+
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
2618
provider["bailian-cli"] = {
2719
npm,
2820
name: "Alibaba Cloud Model Studio",

packages/commands/src/commands/config/agent/writers/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,21 @@ export function resolveClaudeCodeBaseUrl(baseUrl: string): {
195195
"Use a URL ending in /apps/anthropic (not /compatible-mode/v1). Example: https://dashscope.aliyuncs.com/apps/anthropic",
196196
);
197197
}
198+
199+
/**
200+
* Convert a Model Studio region id into a Token Plan base URL, used in place of
201+
* --base-url. Produces the OpenAI-compatible endpoint; the claude-code writer
202+
* rewrites it to /apps/anthropic on its own, and the other writers consume the
203+
* compatible-mode URL directly.
204+
*/
205+
export function resolveRegionBaseUrl(region: string): string {
206+
const normalized = region.trim();
207+
if (!/^[a-z0-9-]+$/.test(normalized)) {
208+
throw new BailianError(
209+
`Invalid --region "${region}".`,
210+
ExitCode.USAGE,
211+
"Use a Model Studio region id, e.g. cn-beijing or ap-southeast-1.",
212+
);
213+
}
214+
return `https://token-plan.${normalized}.maas.aliyuncs.com/compatible-mode/v1`;
215+
}

0 commit comments

Comments
 (0)