diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a495b13..f49e9a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index fd0a8378..b3caa7b3 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -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 ### 变更 diff --git a/packages/cli/package.json b/packages/cli/package.json index 511d3f51..8ddd130d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/commands/package.json b/packages/commands/package.json index a5950b4c..af856b27 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -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": { diff --git a/packages/commands/src/commands/config/agent/decode-key.ts b/packages/commands/src/commands/config/agent/decode-key.ts new file mode 100644 index 00000000..53b6d0d8 --- /dev/null +++ b/packages/commands/src/commands/config/agent/decode-key.ts @@ -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(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; +} diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts index b83e11a0..54bb55cb 100644 --- a/packages/commands/src/commands/config/agent/index.ts +++ b/packages/commands/src/commands/config/agent/index.ts @@ -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: "", description: "API base URL", required: true }, - apiKey: { type: "string", valueHint: "", description: "API key", required: true }, + baseUrl: { + type: "string", + valueHint: "", + description: "API base URL", + }, + region: { + type: "string", + valueHint: "", + description: + "Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only", + }, + apiKey: { + type: "string", + valueHint: "", + description: "API key", + }, + key: { + type: "string", + valueHint: "", + description: + 'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key', + }, model: { type: "string", valueHint: "", description: "Default model name", required: true, }, + contextWindow: { + type: "number", + valueHint: "", + description: "OpenClaw only: model context window in tokens (default: 256000)", + }, + wireApi: { + type: "string", + valueHint: "", + 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 --base-url --api-key --model ", + usageArgs: + "--agent (--base-url | --region ) (--api-key | --key ) --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`); + } } }, }); diff --git a/packages/commands/src/commands/config/agent/writers/claude-code.ts b/packages/commands/src/commands/config/agent/writers/claude-code.ts index 938f84b3..cd4a9900 100644 --- a/packages/commands/src/commands/config/agent/writers/claude-code.ts +++ b/packages/commands/src/commands/config/agent/writers/claude-code.ts @@ -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, 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; - 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; diff --git a/packages/commands/src/commands/config/agent/writers/codex.ts b/packages/commands/src/commands/config/agent/writers/codex.ts index 5353d99d..bb5c106d 100644 --- a/packages/commands/src/commands/config/agent/writers/codex.ts +++ b/packages/commands/src/commands/config/agent/writers/codex.ts @@ -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; const existing = (providers[PROVIDER_KEY] ?? {}) as Record; @@ -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; diff --git a/packages/commands/src/commands/config/agent/writers/hermes.ts b/packages/commands/src/commands/config/agent/writers/hermes.ts index ae2de7af..73ae5194 100644 --- a/packages/commands/src/commands/config/agent/writers/hermes.ts +++ b/packages/commands/src/commands/config/agent/writers/hermes.ts @@ -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 = { + 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>) - : []; - 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)); diff --git a/packages/commands/src/commands/config/agent/writers/openclaw.ts b/packages/commands/src/commands/config/agent/writers/openclaw.ts index 71ec8c57..57d44c32 100644 --- a/packages/commands/src/commands/config/agent/writers/openclaw.ts +++ b/packages/commands/src/commands/config/agent/writers/openclaw.ts @@ -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 | undefined { + const model = defaults.model; + if (!model || typeof model !== "object") return undefined; + const primary = (model as Record).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; models.mode = "merge"; const providers = (models.providers ?? {}) as Record; 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; const defaults = (agents.defaults ?? {}) as Record; - defaults.model = { primary: `bailian-cli/${model}` }; + const allowedModels = (defaults.models ?? {}) as Record; + 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; diff --git a/packages/commands/src/commands/config/agent/writers/opencode.ts b/packages/commands/src/commands/config/agent/writers/opencode.ts index 87b729ce..cc64b494 100644 --- a/packages/commands/src/commands/config/agent/writers/opencode.ts +++ b/packages/commands/src/commands/config/agent/writers/opencode.ts @@ -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"; diff --git a/packages/commands/src/commands/config/agent/writers/qwen-code.ts b/packages/commands/src/commands/config/agent/writers/qwen-code.ts index 437f19ff..42e6ae78 100644 --- a/packages/commands/src/commands/config/agent/writers/qwen-code.ts +++ b/packages/commands/src/commands/config/agent/writers/qwen-code.ts @@ -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): 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; env[ENV_KEY] = apiKey; settings.env = env; - // modelProviders[] — 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[] — upsert only bailian-cli-owned entries. const providers = (settings.modelProviders ?? {}) as Record< string, Array> >; const entries = (providers[protocol] ?? []) as Array>; - 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; 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; diff --git a/packages/commands/src/commands/config/agent/writers/utils.ts b/packages/commands/src/commands/config/agent/writers/utils.ts index bbc6a377..32f3d04b 100644 --- a/packages/commands/src/commands/config/agent/writers/utils.ts +++ b/packages/commands/src/commands/config/agent/writers/utils.ts @@ -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 { if (!existsSync(path)) return {}; @@ -30,6 +114,16 @@ export function readJson(path: string): Record { } } +/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */ +export function readJsonc(path: string): Record { + if (!existsSync(path)) return {}; + try { + return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record; + } 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`; +} diff --git a/packages/commands/tests/config-agent-decode-key.test.ts b/packages/commands/tests/config-agent-decode-key.test.ts new file mode 100644 index 00000000..71027f05 --- /dev/null +++ b/packages/commands/tests/config-agent-decode-key.test.ts @@ -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(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/); + }); +}); diff --git a/packages/commands/tests/config-agent-writers.test.ts b/packages/commands/tests/config-agent-writers.test.ts index 1537c204..a08becd4 100644 --- a/packages/commands/tests/config-agent-writers.test.ts +++ b/packages/commands/tests/config-agent-writers.test.ts @@ -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 { 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).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; + 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; + 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 }; - 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).BAILIAN_CLI_API_KEY).toBe("sk-q"); - expect((settings.model as Record).name).toBe("qwen3-coder-plus"); + expect(settings.$version).toBe(3); + const security = settings.security as { auth: Record }; + // 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).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>>; + // name 是模型显示名(非 provider 品牌常量),品牌只在 envKey 里 expect(providers.openai[0]).toMatchObject({ id: "qwen3-coder-plus", - name: "bailian-cli", + name: "[Bailian] qwen3-coder-plus", + baseUrl: OAI_URL, + 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, - envKey: "BAILIAN_CLI_API_KEY", + 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>>) + .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).openai; expect(openaiEntries).toHaveLength(1); + expect((settings.env as Record).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>> + ).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).BAILIAN_TOKEN_PLAN_API_KEY).toBe( + "sk-token-plan", + ); + expect((settings.env as Record).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; + 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>; 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; expect(models.mode).toBe("merge"); const bailian = (models.providers as Record>)["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>)[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 }; + }; 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).providers as Record< + string, + { api: string; models: Array> } + >; + expect(providers2["bailian-cli"].api).toBe("anthropic-messages"); + expect(providers2["bailian-cli"].models[0].contextWindow).toBe(1000000); + expect( + (config2.agents as { defaults: { model: { primary: string } } }).defaults.model.primary, + ).toBe("bailian-cli/qwen3-max"); + }); + + 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 }; + }; + 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 }).providers["bailian-token-plan"], + ).toBeDefined(); expect( - ((config2.models as Record).providers as Record)[ - "bailian-cli" - ].api, - ).toBe("anthropic-messages"); + (config.models as { providers: Record }).providers["bailian-cli"], + ).toBeDefined(); + expect(summary.warnings?.some((warning) => warning.includes("Left existing primary"))).toBe( + true, + ); }); - test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => { + 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>).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"); - - // anthropic 端点用 anthropic_messages - hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" }); + // 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 端点:必须带 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>).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.", () => { @@ -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/); + }); }); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index a0a48f9f..e9b1bdba 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -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 }); } diff --git a/packages/core/package.json b/packages/core/package.json index 0aa5f5cb..0817e12d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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": { diff --git a/packages/kscli/package.json b/packages/kscli/package.json index 4a197b5b..b3fb15d2 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -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", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index e8dfb6c3..2193d3a0 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -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": { diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 57f278b5..faf1eea5 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -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`. --- diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 32f6eda4..50403b99 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -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 --base-url --api-key --model ` | +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `config agent` | +| **Description** | Configure a coding agent to use DashScope API | +| **Usage** | `bl config agent --agent (--base-url \| --region ) (--api-key \| --key ) --model ` | #### Flags -| Flag | Type | Required | Description | -| --------------------------------------------------------------------- | ------ | -------- | ----------------------------------------------------------------------- | -| `--agent ` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex | -| `--base-url ` | string | yes | API base URL | -| `--api-key ` | string | yes | API key | -| `--model ` | string | yes | Default model name | +| Flag | Type | Required | Description | +| --------------------------------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------- | +| `--agent ` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex | +| `--base-url ` | string | no | API base URL | +| `--region ` | string | no | Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only | +| `--api-key ` | string | no | API key | +| `--key ` | string | no | Obfuscated API key from the web console (starts with "o1\_"); decoded into --api-key | +| `--model ` | string | yes | Default model name | +| `--context-window ` | number | no | OpenClaw only: model context window in tokens (default: 256000) | +| `--wire-api ` | string | no | Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0 | #### Examples