Compare commits

...

27 Commits

Author SHA1 Message Date
ls 241345d7f1 Merge pull request #203 from modelstudioai/feat/tokenplan-harness
feat(token-plan): add harness-quota command and prepare 1.25.0
2026-09-14 19:21:05 +08:00
lisheng.lisheng 46b55b29ac chore(release): prepare 1.25.0 2026-09-14 19:07:20 +08:00
ls f953f428c5 Merge pull request #201 from VIISEVEN27/feat/tokenplan-harness
feat(token-plan): 新增 Token Plan harness 权益额度用量命令
2026-09-14 11:22:10 +08:00
wangmingxiang e03103364e feat(token-plan): 新增 Token Plan harness 权益额度用量命令
- 新增 token-plan harness-quota 命令用于显示 Harness 权益额度使用情况
- 实现权限基于 Console 认证,支持通过 --type 参数筛选 harness 类型
- 集成权益与 Harness 列表数据,展示已发放与待发放额度明细
- 支持标准输出和 JSON 格式输出展示额度及使用比例
- 添加单元测试覆盖多种输出及边界情况
- 更新 CLI 命令注册及相关文档和 Help 信息
- 添加 E2E 测试验证新命令行为及请求参数传递正确性
2026-09-14 10:04:31 +08:00
chenanran555 64b1e72238 Merge pull request #193 from modelstudioai/feat/sandbox
Feat/sandbox
2026-09-11 15:49:02 +08:00
chenanran555 fa3f5de728 chore(release): prepare 1.24.0 2026-09-11 14:55:08 +08:00
chenanran555 02d859ee18 Merge origin/main into feat/sandbox 2026-09-11 11:14:11 +08:00
Gong Shiqi 2220f932b5 Merge pull request #198 from modelstudioai/codex/release-1.23.0
chore(release): prepare 1.23.0
2026-09-10 17:37:40 +08:00
若麒 5c390fad74 chore(release): prepare 1.23.0 2026-09-10 16:57:58 +08:00
Gong Shiqi 6218637f7c Merge pull request #194 from modelstudioai/feat/global-watermark-config
Feat/global watermark config
2026-09-10 16:49:33 +08:00
chenanran555 fa1a8c7b84 docs(sandbox): add connection workflows and official references to skill 2026-09-10 16:07:41 +08:00
若麒 0c7ba721a7 Merge remote-tracking branch 'origin/main' into feat/global-watermark-config 2026-09-10 15:39:12 +08:00
Gong Shiqi 0dbf071368 Merge pull request #197 from modelstudioai/feat/speech-vocabulary
Add ASR instant hotwords, context, and vocabulary CRUD
2026-09-10 15:11:35 +08:00
clh02467605 16e30686f0 fix(speech): validate --words JSON before auth
Invalid vocabulary JSON was parsed in run(), so CI without an API key failed with AUTH(3) instead of USAGE(2).
2026-09-10 14:28:16 +08:00
clh02467605 7803d91e3f chore: fixed Formatting issues 2026-09-10 14:07:37 +08:00
clh02467605 c2d17707c0 feat(speech): add instant hotwords, context, and vocabulary CRUD 2026-09-10 14:02:05 +08:00
chenanran555 3e8df956fe fix(sandbox): retain submitted build IDs on polling failure 2026-09-10 11:21:05 +08:00
rendianmeng 1dfb4400cc Merge branch 'main' of github.com:modelstudioai/cli into feat/global-watermark-config 2026-09-10 10:33:23 +08:00
rendianmeng fd2077b373 feat: pipeline watermark 处理完善 2026-09-09 17:40:49 +08:00
chenanran555 ceef514a50 feat(sandbox): add template file upload with sandbox_template source 2026-09-09 17:35:23 +08:00
chenanran555 0dde03c783 Merge remote-tracking branch 'origin/main' into feat/sandbox 2026-09-09 16:53:57 +08:00
chenanran555 f4e479ce80 feat(sandbox): add official image presets and template image selection 2026-09-09 16:45:37 +08:00
chenanran555 2090293f85 Merge pull request #192 from modelstudioai/feat/managed-agent-ci
fix(managed-agent): improve project initialization and build validation
2026-09-08 19:39:03 +08:00
若麒 3477a08dad feat(config): add profile-level watermark setting 2026-09-08 16:33:40 +08:00
chenanran555 e1eadad184 Merge remote-tracking branch 'origin/main' into feat/sandbox 2026-09-08 14:23:30 +08:00
chenanran555 c0fca869c2 fix(sandbox): support shared --base-url configuration with workspace fallback 2026-09-07 16:37:56 +08:00
chenanran555 332efb05b8 feat(sandbox): add Bailian Sandbox lifecycle commands 2026-09-07 15:11:51 +08:00
105 changed files with 7923 additions and 161 deletions
+2
View File
@@ -15,6 +15,8 @@ git add \
skills/bailian-finetune/reference \
skills/bailian-managed-agent/SKILL.md \
skills/bailian-managed-agent/reference \
skills/bailian-sandbox/SKILL.md \
skills/bailian-sandbox/reference \
skills/bailian-web-search/SKILL.md
vp staged
+1 -1
View File
@@ -36,7 +36,7 @@ packages/core/src/auth/ # apiKey / console credential 解析与落盘
packages/core/src/client/ # HTTP client / endpoints / console gateway
```
Skill / 命令手册随 `skills/bailian-*/``bl skill init` 安装(装齐 registry 中全部 `bailian-*`,含共享协议 `bailian-protocol`)。业务 skill`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent` / `bailian-web-search`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts`**`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills/<skill>/reference/`(纳入 git);`tools/sync-skill-metadata.ts``packages/cli/package.json` 同步各 `skills/*/SKILL.md``metadata.version`。两者由根脚本 `pnpm run sync:skill-assets``.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。
Skill / 命令手册随 `skills/bailian-*/``bl skill init` 安装(装齐 registry 中全部 `bailian-*`,含共享协议 `bailian-protocol`)。业务 skill`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent` / `bailian-sandbox` / `bailian-web-search`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts`**`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills/<skill>/reference/`(纳入 git);`tools/sync-skill-metadata.ts``packages/cli/package.json` 同步各 `skills/*/SKILL.md``metadata.version`。两者由根脚本 `pnpm run sync:skill-assets``.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。
约定:
+33
View File
@@ -6,6 +6,39 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.25.0] - 2026-09-14
### Added
- **Token Plan harness quota** — `bl token-plan harness-quota` shows Token Plan harness entitlement quota usage (Console auth), joining the harness list with issued entitlements to display used/total quota, usage ratio, and reset time; harnesses with a pending entitlement are listed as issuing.
- Filter the harness list with `--type official_tool|infrastructure`; render as a quota box or `--output json`.
## [1.24.0] - 2026-09-11
### Added
- Sandbox instance and template lifecycle commands, with automatic build polling and `--async` submission.
- `bl sandbox official-images` and template `--image` presets for Code Interpreter, Browser, and All-in-One.
- `bl sandbox file upload` uploads template mount files with `source=sandbox_template`; use the returned File ID in `mntConfig`.
- Sandbox supports shared `--base-url`, environment and Profile configuration, with workspace-based endpoint fallback.
- Dedicated `bailian-sandbox` Skill with command references and instance connection guidance.
### Fixed
- Preserve submitted `templateID` and `buildID` when template-build polling fails, so users can check the existing build before resubmitting.
### Security
- Sandbox REST calls use Bailian Bearer authentication without an E2B SDK or E2B API key. Connection credentials and dry-run environment values are redacted by default.
## [1.23.0] - 2026-09-10
### Added
- **Profile-level watermark control** — configure `watermark` with `bl config set --key watermark --value true|false` to control the default watermark behavior for image generation and editing, video generation and editing, and reference-to-video commands.
- **ASR accuracy controls** — `bl speech recognize` now supports instant hot words with `--vocabulary`, contextual word enhancement with `--context`, and reusable pre-built vocabularies with `--vocabulary-id` for supported ASR models.
- **Speech vocabulary management** — added `bl speech vocabulary create|list|get|update|delete` to manage reusable pre-built hot-word vocabularies.
## [1.22.0] - 2026-09-08
### Changed
+33
View File
@@ -6,6 +6,39 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.25.0] - 2026-09-14
### 新增
- **Token Plan harness 权益额度** —— `bl token-plan harness-quota` 查看 Token Plan harness 权益额度用量Console 认证),联合 harness 列表与已发放权益,展示已用/总额度、使用比例和重置时间;待发放权益的 harness 以「发放中」状态列出。
- 通过 `--type official_tool|infrastructure` 筛选 harness 列表;支持额度框输出或 `--output json`
## [1.24.0] - 2026-09-11
### 新增
- Sandbox 实例与模版生命周期命令,支持自动轮询构建状态和 `--async` 异步提交。
- `bl sandbox official-images` 与模版 `--image` 参数,内置代码解释器、浏览器和全能型镜像预设。
- `bl sandbox file upload` 使用 `source=sandbox_template` 上传模版挂载文件,返回的 File ID 可用于 `mntConfig`
- Sandbox 复用 `--base-url`、环境变量和 Profile 配置,未配置时通过工作空间拼接接入地址。
- 独立的 `bailian-sandbox` Skill提供命令参考和实例连接指南。
### 修复
- 模版构建轮询失败时保留已提交的 `templateID``buildID`,便于先查询已有构建,避免重复提交。
### 安全
- Sandbox REST 调用使用百炼 Bearer 鉴权,不依赖 E2B SDK 或 E2B API Key连接凭据和 dry-run 环境变量默认脱敏。
## [1.23.0] - 2026-09-10
### 新增
- **Profile 级水印控制** —— 可通过 `bl config set --key watermark --value true|false` 设置图片生成与编辑、视频生成与编辑以及参考生视频命令的默认水印行为。
- **ASR 准确率增强** —— `bl speech recognize` 现支持通过 `--vocabulary` 传入即时热词、通过 `--context` 增强上下文词表,以及通过 `--vocabulary-id` 使用适用于对应 ASR 模型的预编译热词表。
- **语音热词表管理** —— 新增 `bl speech vocabulary create|list|get|update|delete`,用于管理可复用的预编译热词表。
## [1.22.0] - 2026-09-08
### 变更
+1
View File
@@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
+1
View File
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
+1
View File
@@ -56,6 +56,7 @@ bailian-gen bailian-finetune bailian-managed-agent bailian-web-search
### C. 归属与生成
- [ ] 新一级命令组归属领域时:改 `tools/generate-reference.ts``GROUP_OWNER_SKILL`,并更新**拥有方** skill 的路由表hub 最多加一行 hand-off
- [ ] 新增领域目录时:补齐 `SKILL.md`(含 `metadata.version`),同步 `packages/cli/package.json` 的 reference 格式化路径、`.vite-hooks/pre-commit` 的生成物暂存清单、`tools/release/check.mjs` 的生成物校验清单;同步 hub 与共享协议的领域路由
- [ ]`pnpm run sync:skill-assets`(或 commit 走 pre-commit提交生成的 `reference/` 与 version 同步结果
- [ ] 高风险命令生成的 reference 必须包含 `Risk` / `Risk message` 和简短 Agent safety 提示;带 `--yes` 的示例必须标注只能在确认后执行,不要手改生成物
- [ ] 默认模型若写在领域路由表(如 `bailian-gen`):与命令 default / [model-add-remove.md](model-add-remove.md) 一并核对
+1
View File
@@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
+1
View File
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.22.0",
"version": "1.25.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
@@ -41,7 +41,7 @@
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference skills/bailian-gen/reference skills/bailian-finetune/reference skills/bailian-managed-agent/reference'",
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference skills/bailian-gen/reference skills/bailian-finetune/reference skills/bailian-managed-agent/reference skills/bailian-sandbox/reference'",
"sync:skill-version": "tsx ../../tools/sync-skill-metadata.ts",
"build": "vp pack",
"dev": "tsx src/main.ts",
+42
View File
@@ -70,6 +70,11 @@ import {
searchWeb,
speechSynthesize,
speechRecognize,
speechVocabularyCreate,
speechVocabularyList,
speechVocabularyGet,
speechVocabularyUpdate,
speechVocabularyDelete,
fileUpload,
consoleCall,
usageFree,
@@ -125,6 +130,7 @@ import {
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
tokenPlanHarnessQuota,
workspaceInit,
pluginInstall,
pluginLink,
@@ -206,6 +212,21 @@ import {
managedAgentFileSearch,
managedAgentFileDownload,
managedAgentFileDelete,
sandboxCreate,
sandboxOfficialImages,
sandboxFileUpload,
sandboxList,
sandboxGet,
sandboxConnect,
sandboxPause,
sandboxResume,
sandboxDelete,
sandboxTemplateCreate,
sandboxTemplateList,
sandboxTemplateGet,
sandboxTemplateUpdate,
sandboxTemplateBuildStatus,
sandboxTemplateDelete,
} from "bailian-cli-commands";
// Full bailian-cli product: every command, exposed under the `bl` binary.
@@ -286,6 +307,11 @@ export const commands: Record<string, AnyCommand> = {
"search web": searchWeb,
"speech synthesize": speechSynthesize,
"speech recognize": speechRecognize,
"speech vocabulary create": speechVocabularyCreate,
"speech vocabulary list": speechVocabularyList,
"speech vocabulary get": speechVocabularyGet,
"speech vocabulary update": speechVocabularyUpdate,
"speech vocabulary delete": speechVocabularyDelete,
"file upload": fileUpload,
"console call": consoleCall,
"usage free": usageFree,
@@ -341,6 +367,7 @@ export const commands: Record<string, AnyCommand> = {
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
"token-plan add-member": tokenPlanAddMember,
"token-plan harness-quota": tokenPlanHarnessQuota,
"workspace init": workspaceInit,
"plugin install": pluginInstall,
"plugin link": pluginLink,
@@ -423,6 +450,21 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent file search": managedAgentFileSearch,
"managed-agent file download": managedAgentFileDownload,
"managed-agent file delete": managedAgentFileDelete,
"sandbox create": sandboxCreate,
"sandbox official-images": sandboxOfficialImages,
"sandbox file upload": sandboxFileUpload,
"sandbox list": sandboxList,
"sandbox get": sandboxGet,
"sandbox connect": sandboxConnect,
"sandbox pause": sandboxPause,
"sandbox resume": sandboxResume,
"sandbox delete": sandboxDelete,
"sandbox template create": sandboxTemplateCreate,
"sandbox template list": sandboxTemplateList,
"sandbox template get": sandboxTemplateGet,
"sandbox template update": sandboxTemplateUpdate,
"sandbox template build-status": sandboxTemplateBuildStatus,
"sandbox template delete": sandboxTemplateDelete,
};
/**
@@ -0,0 +1,53 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { validateSkillDir } from "bailian-cli-core";
import { expect, test } from "vite-plus/test";
import { parse } from "yaml";
import { commands } from "../src/commands.ts";
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), "../../..");
const sandboxSkillDirectory = join(repositoryRoot, "skills/bailian-sandbox");
test("Sandbox is a valid standalone skill aligned with the CLI version", () => {
const metadata = validateSkillDir(sandboxSkillDirectory, "bailian-sandbox");
expect(metadata.name).toBe("bailian-sandbox");
const skillMarkdown = readFileSync(join(sandboxSkillDirectory, "SKILL.md"), "utf8");
const frontmatter = parse(skillMarkdown.split(/^---\s*$/m)[1]) as {
metadata: { version: string };
};
const cliPackage = JSON.parse(
readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"),
) as { version: string };
expect(frontmatter.metadata.version).toBe(cliPackage.version);
for (const link of skillMarkdown.matchAll(/\]\(([^)]+)\)/g)) {
expect(existsSync(join(sandboxSkillDirectory, link[1]))).toBe(true);
}
});
test("Sandbox reference contains every registered Sandbox command only in its owning skill", () => {
const expectedCommands = Object.keys(commands)
.filter((commandPath) => commandPath.startsWith("sandbox "))
.map((commandPath) => `bl ${commandPath}`)
.sort();
expect(expectedCommands.length).toBeGreaterThan(0);
const reference = readFileSync(join(sandboxSkillDirectory, "reference/sandbox.md"), "utf8");
const documentedCommands = [...reference.matchAll(/^### `([^`]+)`/gm)]
.map((match) => match[1])
.sort();
expect(documentedCommands).toEqual(expectedCommands);
const index = readFileSync(join(sandboxSkillDirectory, "reference/index.md"), "utf8");
for (const commandPath of expectedCommands) {
expect(index).toContain(`\`${commandPath}\``);
}
expect(existsSync(join(repositoryRoot, "skills/bailian-cli/reference/sandbox.md"))).toBe(false);
const hubIndex = readFileSync(
join(repositoryRoot, "skills/bailian-cli/reference/index.md"),
"utf8",
);
expect(hubIndex).not.toContain("`bl sandbox ");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.22.0",
"version": "1.25.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": {
+3 -2
View File
@@ -12,9 +12,9 @@ export default defineCommand({
valueHint: "<key>",
description: {
"en-US":
"Config key (language, base_url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"Config key (language, base_url, output, output_dir, timeout, watermark, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"zh-CN":
"配置项名称language、base_url、output、output_dir、timeout、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id",
"配置项名称language、base_url、output、output_dir、timeout、watermark、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id",
},
required: true,
},
@@ -29,6 +29,7 @@ export default defineCommand({
"--key language --value zh-CN",
"--key output --value json",
"--key timeout --value 600",
"--key watermark --value false",
"--key base_url --value https://dashscope.aliyuncs.com",
"--config company-plan --key api-key-capabilities --value text.chat,image.generate",
],
@@ -3,6 +3,7 @@ import {
ExitCode,
isApiKeyCapability,
normalizeModelBaseUrl,
parseBooleanValue,
SUPPORTED_LANGUAGES,
} from "bailian-cli-core";
@@ -13,6 +14,7 @@ export const VALID_KEYS = [
"output",
"output_dir",
"timeout",
"watermark",
"api_key",
"access_token",
"access_key_id",
@@ -62,7 +64,7 @@ export const UI_ENUM_KEYS: Record<string, string[]> = {
};
// Keys the UI renders as a true/false dropdown and stores as a boolean.
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry", "watermark"]);
// Default model each `default_*_model` key falls back to when left unset. These
// mirror the inline `|| "<model>"` fallbacks in the generation commands
@@ -164,7 +166,10 @@ export function resolveKey(key: string): string {
* Validate a single config entry and coerce its value to the stored type.
* Throws BailianError(USAGE) for unknown keys or invalid values.
*/
export function validateAndCoerce(key: string, value: string): string | number | string[] {
export function validateAndCoerce(
key: string,
value: string,
): string | number | boolean | string[] {
const resolvedKey = resolveKey(key);
if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) {
@@ -201,6 +206,8 @@ export function validateAndCoerce(key: string, value: string): string | number |
if (resolvedKey === "base_url") return normalizeModelBaseUrl(value);
if (resolvedKey === "watermark") return parseBooleanValue(value, "watermark");
if (resolvedKey === "api_key_capabilities") {
let rawCapabilities: unknown;
if (value.trim().startsWith("[")) {
@@ -18,6 +18,7 @@ export default defineCommand({
base_url: client.baseUrl,
output: settings.output,
timeout: settings.timeout,
watermark: settings.watermark,
config: settings.configName ?? "default",
config_file: store.path,
};
+1 -1
View File
@@ -207,7 +207,7 @@ export default defineCommand({
"prompt-extend",
);
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size: resolveImageSize(flags.size, route.sizeProfile),
@@ -185,7 +185,7 @@ export default defineCommand({
"prompt-extend",
);
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size,
@@ -0,0 +1,113 @@
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import {
agentStudioFilesPath,
BailianError,
defineCommand,
detectOutputFormat,
ExitCode,
sandboxBaseUrl,
} from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
import { redactConnectionCredentials, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const UPLOAD_SOURCE = "sandbox_template";
export const sandboxFileUpload = defineCommand({
description: {
"en-US": "Upload a workspace file for Sandbox template mounts",
"zh-CN": "上传工作空间文件,供 Sandbox 模版挂载使用",
},
auth: "apiKey",
usageArgs: "--path <path> [--filename <name>] [--mime-type <type>]",
flags: {
...WORKSPACE_FLAG,
path: {
type: "string",
valueHint: "<path>",
required: true,
description: { "en-US": "Local file path", "zh-CN": "本地文件路径" },
},
filename: {
type: "string",
valueHint: "<name>",
description: { "en-US": "Remote filename override", "zh-CN": "覆盖远端文件名" },
},
mimeType: {
type: "string",
valueHint: "<type>",
description: {
"en-US": "Multipart file MIME type (default: application/octet-stream)",
"zh-CN": "Multipart 文件部分的 MIME 类型默认application/octet-stream",
},
},
},
exampleArgs: [
"--path ./config.json --output json",
"--path ./config.json --quiet",
"--path ./notes.txt --filename notes.txt --mime-type text/plain --dry-run --output json",
],
notes: [
{
"en-US":
"POST /api/v1/agentstudio/files with multipart fields file and source=sandbox_template. Uses a Bailian Bearer API Key, not Console authentication or an E2B key; no agents.yaml is needed.",
"zh-CN":
"向 /api/v1/agentstudio/files 发送 multipart 字段 file 和 source=sandbox_template。使用百炼 Bearer API Key不使用 Console 鉴权或 E2B Key无需 agents.yaml。",
},
{
"en-US":
"Base URL follows Sandbox: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. Without one, --workspace-id > BAILIAN_WORKSPACE_ID > config workspace_id selects the cn-beijing origin. The upload path has no /sandbox prefix.",
"zh-CN":
"Base URL 沿用 Sandbox--base-url > DASHSCOPE_BASE_URL > 登录/Profile 的 base_url。未配置时按 --workspace-id > BAILIAN_WORKSPACE_ID > 配置项 workspace_id 选择 cn-beijing 地址。上传路径不带 /sandbox 前缀。",
},
{
"en-US":
"Returns the upload response immediately; --quiet prints only its id. Upload does not wait for security review: status=checking is not ready to mount. Use an available file's id as mntConfig[].originFileId in template create/update --body, together with mountPath and optional originFileName, in the same workspace. This does not transfer files into a running instance.",
"zh-CN":
"上传响应返回后立即输出,--quiet 仅输出 id。不会等待安全审核status=checking 不代表已可挂载。在同一工作空间的 template create/update --body 中,将可用文件的 id 填入 mntConfig[].originFileId同时传入 mountPath 和可选的 originFileName。此命令不向运行中的实例传文件。",
},
{
"en-US":
"--dry-run previews the endpoint, source, and local path without reading or uploading the file. The service detects the MIME type and enforces upload limits.",
"zh-CN":
"--dry-run 仅预览 Endpoint、source 和本地路径不读取或上传文件。MIME 类型检测和上传限制由服务端执行。",
},
],
async run(ctx) {
const endpoint = ctx.client.url(agentStudioFilesPath(), () =>
sandboxBaseUrl(resolveWorkspaceId(ctx)),
);
const filename = ctx.flags.filename ?? basename(ctx.flags.path);
const mimeType = ctx.flags.mimeType ?? "application/octet-stream";
const format = detectOutputFormat(ctx.settings.output);
if (ctx.settings.dryRun) {
emitResult(
{
method: "POST",
endpoint,
request: { source: UPLOAD_SOURCE, file: { path: ctx.flags.path, filename, mimeType } },
},
format,
);
return;
}
const content = new Uint8Array(await readFile(ctx.flags.path));
const form = new FormData();
form.append("source", UPLOAD_SOURCE);
form.append("file", new Blob([content], { type: mimeType }), filename);
const file = await ctx.client.requestJson<Record<string, unknown>>({
method: "POST",
path: endpoint,
body: form,
});
if (!file || typeof file.id !== "string" || !file.id.trim()) {
throw new BailianError(
"Upload response did not contain a File ID. / 上传响应未包含 File ID。",
ExitCode.GENERAL,
);
}
if (ctx.settings.quiet) emitBare(file.id);
else emitResult(redactConnectionCredentials(file), format);
},
});
@@ -0,0 +1,88 @@
import { defineCommand, detectOutputFormat, UsageError } from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
/** Pinned CLI presets, not a live catalog or a guarantee of regional availability. */
export const SANDBOX_IMAGES = [
{
id: "code-interpreter",
imageName: "代码解释器",
imageUrl: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/code-interpreter-v1:v0.0.44",
icon: "https://img.alicdn.com/imgextra/i2/O1CN01iX8RH9ckFvC093x2_!!6000000000905-2-tps-72-72.png",
description: {
"en-US": "Python / Node.js runtimes with common data-processing libraries",
"zh-CN": "Python / Node.js 运行时+常用数据处理库",
},
},
{
id: "browser",
imageName: "浏览器",
imageUrl: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44",
icon: "https://img.alicdn.com/imgextra/i4/O1CN01ShCbPEunrRI093x2_!!6000000000155-2-tps-72-72.png",
description: {
"en-US": "Chromium and a visual desktop for clicking, filling forms, and screenshots",
"zh-CN": "Chromium +可视化桌面,支持点击/ 填表/截图",
},
},
{
id: "all-in-one",
imageName: "全能型",
imageUrl: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/all-in-one:v0.0.44",
icon: "https://img.alicdn.com/imgextra/i1/O1CN01smhklUahsyE093x2_!!6000000006657-2-tps-72-72.png",
description: {
"en-US": "Code execution and browser capabilities together",
"zh-CN": "代码执行+浏览器双能力",
},
},
] as const;
export const SANDBOX_IMAGE_CHOICES = SANDBOX_IMAGES.flatMap((image) => [image.id, image.imageName]);
export const SANDBOX_IMAGE_NOTES = SANDBOX_IMAGES.map((image) => ({
"en-US": `${image.id} (${image.imageName}): ${image.description["en-US"]}. ${image.imageUrl}`,
"zh-CN": `${image.id}${image.imageName}${image.description["zh-CN"]}${image.imageUrl}`,
}));
export function resolveSandboxImage(selector: string) {
const image = SANDBOX_IMAGES.find(
(candidate) => candidate.id === selector || candidate.imageName === selector,
);
if (!image) {
throw new UsageError(
`Unknown built-in image / 未知内置镜像: ${selector}. ${SANDBOX_IMAGE_CHOICES.join(", ")}`,
);
}
return image;
}
export const sandboxOfficialImages = defineCommand({
description: {
"en-US": "List the built-in Sandbox base images (offline)",
"zh-CN": "列出 CLI 内置的 Sandbox 基础镜像(离线)",
},
auth: "none",
exampleArgs: ["", "--output json", "--quiet"],
notes: [
{
"en-US":
"Use a preset ID or Chinese name with template create/update --image. These are pinned cn-beijing images; other environments may differ. Use --from-image for a custom image.",
"zh-CN":
"在 template create/update 中通过 --image 传入预设 ID 或中文名。这些是固定版本的 cn-beijing 镜像,其他环境可能不同;自定义镜像使用 --from-image。",
},
...SANDBOX_IMAGE_NOTES,
],
async run(ctx) {
if (ctx.settings.quiet) {
for (const image of SANDBOX_IMAGES) emitBare(image.id);
return;
}
if (detectOutputFormat(ctx.settings.output) === "json") {
emitResult(SANDBOX_IMAGES, "json");
return;
}
for (const image of SANDBOX_IMAGES) {
emitBare(`${image.id} (${image.imageName})`);
emitBare(` ${image.imageUrl}`);
emitBare(` ${image.description["en-US"]} / ${image.description["zh-CN"]}`);
}
},
});
@@ -0,0 +1,417 @@
import {
BailianError,
detectOutputFormat,
defineCommand,
ExitCode,
SANDBOX_PATHS,
sandboxInstanceActionPath,
sandboxInstancePath,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
import {
BODY_FLAG,
displayValue,
INSTANCE_TIMEOUT_FLAG,
mergeObjectField,
parseKeyValueEntries,
readRequestBody,
redactConnectionCredentials,
redactRequestSecrets,
resolveSandboxEndpoint,
SANDBOX_NOTES,
setDefined,
SHOW_CREDENTIALS_FLAG,
validateIntegerRange,
WORKSPACE_FLAG,
type JsonObject,
} from "./shared.ts";
interface SandboxInfo extends JsonObject {
sandboxID?: string;
templateID?: string;
state?: string;
cpuCount?: number;
memoryMB?: number;
startedAt?: string;
endAt?: string;
}
const SANDBOX_ID_FLAG = {
sandboxId: {
type: "string",
valueHint: "<id>",
required: true,
description: { "en-US": "Sandbox instance ID", "zh-CN": "Sandbox 实例 ID" },
},
} satisfies FlagsDef;
const CREATE_FLAGS = {
...WORKSPACE_FLAG,
...BODY_FLAG,
templateId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Ready template ID; may alternatively be supplied as templateID in --body",
"zh-CN": "已就绪的模版 ID也可通过 --body 中的 templateID 提供",
},
},
...INSTANCE_TIMEOUT_FLAG,
allowInternetAccess: {
type: "boolean",
valueHint: "<bool>",
description: { "en-US": "Allow public internet access", "zh-CN": "允许访问公网" },
},
metadata: {
type: "array",
valueHint: "<key=value>",
description: {
"en-US": "Instance metadata entry; repeat for multiple values",
"zh-CN": "实例 Metadata 键值;可重复传入",
},
},
env: {
type: "array",
valueHint: "<key=value>",
description: {
"en-US": "Instance environment variable; repeat for multiple values",
"zh-CN": "实例环境变量;可重复传入",
},
},
autoPause: {
type: "boolean",
valueHint: "<bool>",
description: {
"en-US": "Pause the instance when its timeout expires",
"zh-CN": "实例超时后自动暂停",
},
},
autoResume: {
type: "boolean",
valueHint: "<bool>",
description: {
"en-US": "Automatically resume a paused instance when connecting",
"zh-CN": "连接已暂停实例时自动恢复",
},
},
allowOut: {
type: "array",
valueHint: "<address>",
description: {
"en-US": "Outbound allow-list entry; repeat for multiple values",
"zh-CN": "出站白名单条目;可重复传入",
},
},
denyOut: {
type: "array",
valueHint: "<address>",
description: {
"en-US": "Outbound deny-list entry; repeat for multiple values",
"zh-CN": "出站黑名单条目;可重复传入",
},
},
maskRequestHost: {
type: "string",
valueHint: "<host>",
description: {
"en-US": "Override the outbound request Host header",
"zh-CN": "覆盖出站请求的 Host Header",
},
},
...SHOW_CREDENTIALS_FLAG,
} satisfies FlagsDef;
type CreateFlags = ParsedFlags<typeof CREATE_FLAGS>;
const LIST_FLAGS = {
...WORKSPACE_FLAG,
templateId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "Filter by template ID", "zh-CN": "按模版 ID 筛选" },
},
sandboxId: {
type: "string",
valueHint: "<id>",
description: { "en-US": "Filter by sandbox ID", "zh-CN": "按 Sandbox ID 筛选" },
},
state: {
type: "string",
valueHint: "<state>",
description: {
"en-US": "Filter by state, for example running or paused",
"zh-CN": "按状态筛选,例如 running 或 paused",
},
},
limit: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Maximum results (1-50)", "zh-CN": "最大返回数量150" },
},
} satisfies FlagsDef;
const GET_FLAGS = {
...WORKSPACE_FLAG,
...SANDBOX_ID_FLAG,
...SHOW_CREDENTIALS_FLAG,
} satisfies FlagsDef;
const ACTION_FLAGS = { ...WORKSPACE_FLAG, ...SANDBOX_ID_FLAG } satisfies FlagsDef;
const CONNECTION_ACTION_FLAGS = {
...WORKSPACE_FLAG,
...SANDBOX_ID_FLAG,
...BODY_FLAG,
...INSTANCE_TIMEOUT_FLAG,
...SHOW_CREDENTIALS_FLAG,
} satisfies FlagsDef;
export async function buildSandboxCreateBody(flags: CreateFlags): Promise<JsonObject> {
const body = await readRequestBody(flags.body);
setDefined(body, "templateID", flags.templateId);
setDefined(body, "timeout", flags.instanceTimeout);
setDefined(body, "allow_internet_access", flags.allowInternetAccess);
setDefined(body, "autoPause", flags.autoPause);
setDefined(body, "autoResume", flags.autoResume);
mergeObjectField(body, "metadata", parseKeyValueEntries(flags.metadata, "--metadata"));
mergeObjectField(body, "envVars", parseKeyValueEntries(flags.env, "--env"));
const networkOverrides: JsonObject = {};
setDefined(networkOverrides, "allowOut", flags.allowOut);
setDefined(networkOverrides, "denyOut", flags.denyOut);
setDefined(networkOverrides, "maskRequestHost", flags.maskRequestHost);
mergeObjectField(body, "network", networkOverrides);
if (typeof body.templateID !== "string" || body.templateID.trim().length === 0) {
throw new BailianError(
"Sandbox create requires --template-id or templateID in --body.",
ExitCode.USAGE,
);
}
validateIntegerRange(body.timeout, "Sandbox timeout", 300, 604800);
return body;
}
async function buildConnectionBody(flags: {
body?: string;
instanceTimeout?: number;
}): Promise<JsonObject> {
const body = await readRequestBody(flags.body);
setDefined(body, "timeout", flags.instanceTimeout);
validateIntegerRange(body.timeout, "Sandbox timeout", 300, 604800);
return body;
}
function emitSandboxObject(
sandbox: SandboxInfo,
options: { format: "text" | "json"; quiet: boolean; showCredentials: boolean },
): void {
const output = options.showCredentials ? sandbox : redactConnectionCredentials(sandbox);
if (options.quiet) {
emitBare(displayValue(sandbox.sandboxID));
return;
}
emitResult(output, options.format);
}
export const sandboxCreate = defineCommand({
description: { "en-US": "Create a Sandbox instance", "zh-CN": "创建 Sandbox 实例" },
auth: "apiKey",
usageArgs: "(--template-id <id> | --body <json|@path>) [flags]",
flags: CREATE_FLAGS,
exampleArgs: [
"--template-id tpl-xxx --instance-timeout 3600",
"--template-id tpl-xxx --base-url https://workspace.cn-beijing.maas.aliyuncs.com",
"--body @sandbox.json --dry-run --output json",
"--template-id tpl-xxx --show-credentials --output json",
],
notes: SANDBOX_NOTES,
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const endpoint = resolveSandboxEndpoint(ctx, SANDBOX_PATHS.sandboxes);
const body = await buildSandboxCreateBody(ctx.flags);
if (ctx.settings.dryRun) {
emitResult({ method: "POST", endpoint, request: redactRequestSecrets(body) }, format);
return;
}
const response = await ctx.client.requestJson<SandboxInfo>({
path: endpoint,
method: "POST",
body,
});
emitSandboxObject(response, {
format,
quiet: ctx.settings.quiet,
showCredentials: ctx.flags.showCredentials,
});
},
});
export const sandboxList = defineCommand({
description: { "en-US": "List Sandbox instances", "zh-CN": "列出 Sandbox 实例" },
auth: "apiKey",
usageArgs: "[--template-id <id>] [--sandbox-id <id>] [--state <state>] [--limit <n>]",
flags: LIST_FLAGS,
exampleArgs: ["", "--state running --limit 20", "--template-id tpl-xxx --output json"],
notes: SANDBOX_NOTES,
validate(flags) {
if (
flags.limit !== undefined &&
(!Number.isInteger(flags.limit) || flags.limit < 1 || flags.limit > 50)
) {
return "--limit must be an integer between 1 and 50.";
}
return undefined;
},
async run(ctx) {
const url = new URL(resolveSandboxEndpoint(ctx, SANDBOX_PATHS.sandboxList));
if (ctx.flags.templateId) url.searchParams.set("templateID", ctx.flags.templateId);
if (ctx.flags.sandboxId) url.searchParams.set("sandboxID", ctx.flags.sandboxId);
if (ctx.flags.state) url.searchParams.set("state", ctx.flags.state);
if (ctx.flags.limit !== undefined) url.searchParams.set("limit", String(ctx.flags.limit));
const response = await ctx.client.requestJson<SandboxInfo[]>({
path: url.toString(),
method: "GET",
});
if (ctx.settings.quiet) {
for (const sandbox of response) emitBare(displayValue(sandbox.sandboxID));
return;
}
const format = detectOutputFormat(ctx.settings.output);
if (format === "json") {
emitResult(response, format);
return;
}
if (response.length === 0) {
emitBare("No Sandbox instances found.");
return;
}
const rows = response.map((sandbox) => [
displayValue(sandbox.sandboxID),
displayValue(sandbox.templateID),
displayValue(sandbox.state),
displayValue(sandbox.cpuCount),
displayValue(sandbox.memoryMB),
displayValue(sandbox.startedAt),
displayValue(sandbox.endAt),
]);
for (const line of formatTable(
["ID", "TEMPLATE", "STATE", "CPU", "MEMORY_MB", "STARTED_AT", "END_AT"],
rows,
)) {
emitBare(line);
}
},
});
export const sandboxGet = defineCommand({
description: { "en-US": "Get Sandbox instance details", "zh-CN": "获取 Sandbox 实例详情" },
auth: "apiKey",
usageArgs: "--sandbox-id <id> [--show-credentials]",
flags: GET_FLAGS,
exampleArgs: ["--sandbox-id sbx-xxx", "--sandbox-id sbx-xxx --show-credentials --output json"],
notes: SANDBOX_NOTES,
async run(ctx) {
const endpoint = resolveSandboxEndpoint(ctx, sandboxInstancePath(ctx.flags.sandboxId));
const response = await ctx.client.requestJson<SandboxInfo>({ path: endpoint, method: "GET" });
emitSandboxObject(response, {
format: detectOutputFormat(ctx.settings.output),
quiet: ctx.settings.quiet,
showCredentials: ctx.flags.showCredentials,
});
},
});
function connectionCommand(action: "connect" | "resume") {
const actionText = action === "connect" ? "Connect to" : "Resume";
const actionTextZh = action === "connect" ? "连接" : "恢复";
return defineCommand({
description: {
"en-US": `${actionText} a Sandbox instance and return connection information`,
"zh-CN": `${actionTextZh} Sandbox 实例并返回连接信息`,
},
auth: "apiKey",
usageArgs: "--sandbox-id <id> [--instance-timeout <seconds>] [--show-credentials]",
flags: CONNECTION_ACTION_FLAGS,
exampleArgs: [
`--sandbox-id sbx-xxx --instance-timeout 3600`,
`--sandbox-id sbx-xxx --show-credentials --output json`,
],
notes: SANDBOX_NOTES,
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const endpoint = resolveSandboxEndpoint(
ctx,
sandboxInstanceActionPath(ctx.flags.sandboxId, action),
);
const body = await buildConnectionBody(ctx.flags);
if (ctx.settings.dryRun) {
emitResult({ method: "POST", endpoint, request: redactRequestSecrets(body) }, format);
return;
}
const response = await ctx.client.requestJson<SandboxInfo>({
path: endpoint,
method: "POST",
body: Object.keys(body).length > 0 ? body : undefined,
});
emitSandboxObject(response, {
format,
quiet: ctx.settings.quiet,
showCredentials: ctx.flags.showCredentials,
});
},
});
}
export const sandboxConnect = connectionCommand("connect");
export const sandboxResume = connectionCommand("resume");
export const sandboxPause = defineCommand({
description: { "en-US": "Pause a Sandbox instance", "zh-CN": "暂停 Sandbox 实例" },
auth: "apiKey",
usageArgs: "--sandbox-id <id>",
flags: ACTION_FLAGS,
exampleArgs: ["--sandbox-id sbx-xxx", "--sandbox-id sbx-xxx --dry-run --output json"],
notes: SANDBOX_NOTES,
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const endpoint = resolveSandboxEndpoint(
ctx,
sandboxInstanceActionPath(ctx.flags.sandboxId, "pause"),
);
if (ctx.settings.dryRun) {
emitResult({ method: "POST", endpoint, request: null }, format);
return;
}
await ctx.client.request({ path: endpoint, method: "POST" });
if (ctx.settings.quiet) emitBare(ctx.flags.sandboxId);
else emitResult({ sandboxID: ctx.flags.sandboxId, paused: true }, format);
},
});
export const sandboxDelete = defineCommand({
description: { "en-US": "Release a Sandbox instance", "zh-CN": "释放 Sandbox 实例" },
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US": "This permanently releases the Sandbox instance and cannot be undone.",
"zh-CN": "该操作会永久释放 Sandbox 实例,且无法撤销。",
},
},
usageArgs: "--sandbox-id <id>",
flags: ACTION_FLAGS,
exampleArgs: ["--sandbox-id sbx-xxx --dry-run", "--sandbox-id sbx-xxx --yes"],
notes: SANDBOX_NOTES,
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const endpoint = resolveSandboxEndpoint(ctx, sandboxInstancePath(ctx.flags.sandboxId));
if (ctx.settings.dryRun) {
emitResult({ method: "DELETE", endpoint, request: null }, format);
return;
}
await ctx.client.request({ path: endpoint, method: "DELETE" });
if (ctx.settings.quiet) emitBare(ctx.flags.sandboxId);
else emitResult({ sandboxID: ctx.flags.sandboxId, deleted: true }, format);
},
});
@@ -0,0 +1,252 @@
import { readFile } from "node:fs/promises";
import {
BailianError,
ExitCode,
sandboxApiPath,
sandboxBaseUrl,
type Client,
type FlagsDef,
type LocalizedText,
} from "bailian-cli-core";
export const WORKSPACE_FLAG = {
workspaceId: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Workspace ID for the default Sandbox endpoint; optional with a configured base URL",
"zh-CN": "默认 Sandbox Endpoint 的 Workspace ID已配置 Base URL 时可省略",
},
},
} satisfies FlagsDef;
export const BODY_FLAG = {
body: {
type: "string",
valueHint: "<json|@path>",
description: {
"en-US": "JSON request body, inline or loaded from an @file path; explicit flags override it",
"zh-CN": "JSON 请求体,可内联或从 @文件路径读取;显式 Flag 优先",
},
},
} satisfies FlagsDef;
export const INSTANCE_TIMEOUT_FLAG = {
instanceTimeout: {
type: "number",
valueHint: "<seconds>",
description: {
"en-US": "Sandbox lifetime after this operation (300-604800 seconds)",
"zh-CN": "本次操作后的 Sandbox 存活时间300604800 秒)",
},
},
} satisfies FlagsDef;
export const SHOW_CREDENTIALS_FLAG = {
showCredentials: {
type: "switch",
description: {
"en-US": "Print envd and traffic access tokens instead of redacting them",
"zh-CN": "输出 envd 与流量访问 Token 原值,不进行脱敏",
},
},
} satisfies FlagsDef;
export const POLL_INTERVAL_FLAG = {
pollInterval: {
type: "number",
valueHint: "<seconds>",
description: {
"en-US": "Template build polling interval (default: 5 seconds)",
"zh-CN": "模版构建轮询间隔默认5 秒)",
},
},
} satisfies FlagsDef;
export const SANDBOX_NOTES: LocalizedText[] = [
{
"en-US": "Auth: uses a Bailian API Key as an Authorization Bearer token; no E2B key is sent.",
"zh-CN": "鉴权:使用百炼 API Key 作为 Authorization Bearer Token不会发送 E2B Key。",
},
{
"en-US":
"Base URL: --base-url > DASHSCOPE_BASE_URL > login/profile base_url. The CLI uses its origin and appends /api/v1/agentstudio/sandbox; otherwise it uses the workspace-scoped cn-beijing endpoint.",
"zh-CN":
"Base URL 优先级:--base-url > DASHSCOPE_BASE_URL > 登录/Profile 的 base_url。CLI 取其 origin 并追加 /api/v1/agentstudio/sandbox未配置时使用工作空间的 cn-beijing Endpoint。",
},
{
"en-US":
"Without a configured base URL, workspace is required: --workspace-id > BAILIAN_WORKSPACE_ID > config workspace_id.",
"zh-CN":
"未配置 Base URL 时必须提供 Workspace--workspace-id > BAILIAN_WORKSPACE_ID > 配置项 workspace_id。",
},
{
"en-US":
"Sandbox is currently available in cn-beijing only and requires prior SLR authorization.",
"zh-CN": "Sandbox 当前仅支持 cn-beijing首次使用前需完成 SLR 授权。",
},
{
"en-US":
"Global --timeout limits HTTP requests and total template-build polling; --instance-timeout maps to the Sandbox API lifetime field.",
"zh-CN":
"全局 --timeout 限制 HTTP 请求和模版构建总轮询时间;--instance-timeout 映射到 Sandbox API 的实例存活时间字段。",
},
];
export type JsonObject = Record<string, unknown>;
interface SandboxEndpointContext {
flags: { workspaceId?: string };
settings: { workspaceId?: string };
client: Pick<Client, "url">;
}
export function resolveSandboxEndpoint(ctx: SandboxEndpointContext, path: string): string {
return ctx.client.url(sandboxApiPath(path), () => sandboxBaseUrl(resolveWorkspaceId(ctx)));
}
export function resolveWorkspaceId(ctx: Omit<SandboxEndpointContext, "client">): string {
const workspaceId = ctx.flags.workspaceId || ctx.settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required when no base URL is configured. / 未配置 Base URL 时必须提供 Workspace ID。",
ExitCode.USAGE,
"Pass --workspace-id, set BAILIAN_WORKSPACE_ID, configure workspace_id, or set --base-url. / 请传入 --workspace-id、设置 BAILIAN_WORKSPACE_ID 或 workspace_id 配置,或通过 --base-url 指定地址。",
);
}
return workspaceId;
}
function asJsonObject(value: unknown, source: string): JsonObject {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new BailianError(`${source} must contain a JSON object.`, ExitCode.USAGE);
}
return value as JsonObject;
}
export async function readRequestBody(argument?: string): Promise<JsonObject> {
if (!argument) return {};
const source = argument.startsWith("@") ? argument.slice(1) : undefined;
if (source !== undefined && source.length === 0) {
throw new BailianError("--body @path must include a file path.", ExitCode.USAGE);
}
const raw = source === undefined ? argument : await readFile(source, "utf8");
try {
return asJsonObject(JSON.parse(raw) as unknown, source ? `Body file ${source}` : "--body");
} catch (error) {
if (error instanceof BailianError) throw error;
if (error instanceof SyntaxError) {
throw new BailianError(
source ? `Body file ${source} contains invalid JSON.` : "--body contains invalid JSON.",
ExitCode.USAGE,
);
}
throw error;
}
}
export function setDefined(body: JsonObject, key: string, value: unknown): void {
if (value !== undefined) body[key] = value;
}
export function parseKeyValueEntries(entries: string[] | undefined, flag: string): JsonObject {
const values: JsonObject = {};
for (const entry of entries ?? []) {
const separator = entry.indexOf("=");
if (separator <= 0) {
throw new BailianError(`${flag} values must use KEY=VALUE format.`, ExitCode.USAGE);
}
const key = entry.slice(0, separator).trim();
if (!key) {
throw new BailianError(`${flag} values must include a non-empty key.`, ExitCode.USAGE);
}
values[key] = entry.slice(separator + 1);
}
return values;
}
export function mergeObjectField(body: JsonObject, key: string, overrides: JsonObject): void {
if (Object.keys(overrides).length === 0) return;
const current = body[key];
const base =
current !== null && typeof current === "object" && !Array.isArray(current)
? (current as JsonObject)
: {};
body[key] = { ...base, ...overrides };
}
export function validateIntegerRange(
value: unknown,
label: string,
minimum: number,
maximum: number,
): void {
if (
value !== undefined &&
(typeof value !== "number" || !Number.isInteger(value) || value < minimum || value > maximum)
) {
throw new BailianError(
`${label} must be an integer between ${minimum} and ${maximum}.`,
ExitCode.USAGE,
);
}
}
export function validatePositiveInteger(value: unknown, label: string): void {
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
throw new BailianError(`${label} must be a positive integer.`, ExitCode.USAGE);
}
}
const CONNECTION_CREDENTIAL_KEYS = new Set(["envdAccessToken", "trafficAccessToken"]);
const SENSITIVE_KEY = /(?:token|secret|password|credential|api[_-]?key)/i;
function redactValue(value: unknown, redactEnvironmentMaps: boolean): unknown {
if (Array.isArray(value)) return value.map((entry) => redactValue(entry, redactEnvironmentMaps));
if (value === null || typeof value !== "object") return value;
const output: JsonObject = {};
for (const [key, entry] of Object.entries(value as JsonObject)) {
if (
CONNECTION_CREDENTIAL_KEYS.has(key) ||
SENSITIVE_KEY.test(key) ||
(redactEnvironmentMaps && (key === "envVars" || key === "envConfig"))
) {
if (entry !== undefined && entry !== null) {
output[key] =
(key === "envVars" || key === "envConfig") &&
typeof entry === "object" &&
!Array.isArray(entry)
? Object.fromEntries(
Object.keys(entry as JsonObject).map((environmentKey) => [
environmentKey,
"[REDACTED]",
]),
)
: "[REDACTED]";
} else {
output[key] = entry;
}
continue;
}
output[key] = redactValue(entry, redactEnvironmentMaps);
}
return output;
}
export function redactConnectionCredentials<T>(value: T): T {
return redactValue(value, false) as T;
}
export function redactRequestSecrets<T>(value: T): T {
return redactValue(value, true) as T;
}
export function displayValue(value: unknown): string {
if (value === undefined || value === null || value === "") return "-";
if (Array.isArray(value)) return value.join(",");
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return `${value}`;
}
return JSON.stringify(value) ?? "-";
}
@@ -0,0 +1,651 @@
import {
BailianError,
detectOutputFormat,
defineCommand,
ExitCode,
sandboxApiPath,
SANDBOX_PATHS,
sandboxTemplateBuildStatusPath,
sandboxTemplatePath,
type Client,
type FlagsDef,
type ParsedFlags,
type Settings,
} from "bailian-cli-core";
import { createSpinner, emitBare, emitResult, formatTable } from "bailian-cli-runtime";
import { resolveSandboxImage, SANDBOX_IMAGE_CHOICES, SANDBOX_IMAGE_NOTES } from "./images.ts";
import {
BODY_FLAG,
displayValue,
mergeObjectField,
parseKeyValueEntries,
POLL_INTERVAL_FLAG,
readRequestBody,
redactRequestSecrets,
resolveSandboxEndpoint,
SANDBOX_NOTES,
setDefined,
validateIntegerRange,
validatePositiveInteger,
WORKSPACE_FLAG,
type JsonObject,
} from "./shared.ts";
const TEMPLATE_ASYNC_FLAG = {
async: {
type: "switch",
description: {
"en-US": "Return the submitted templateID/buildID immediately without polling",
"zh-CN": "提交后立即返回 templateID/buildID不轮询构建状态",
},
},
} satisfies FlagsDef;
interface TemplateInfo extends JsonObject {
templateID?: string;
templateName?: string;
cpuCount?: number;
memoryMB?: number;
spawnCount?: number;
buildCount?: number;
updatedAt?: string;
buildID?: string;
buildStatus?: string;
}
interface TemplateBuildStatus extends JsonObject {
templateID?: string;
buildID?: string;
status?: "building" | "ready" | "error";
reason?: { code?: number | string; message?: string };
}
const TEMPLATE_ID_FLAG = {
templateId: {
type: "string",
valueHint: "<id>",
required: true,
description: { "en-US": "Sandbox template ID", "zh-CN": "Sandbox 模版 ID" },
},
} satisfies FlagsDef;
const TEMPLATE_MUTATION_FIELDS = {
image: {
type: "string",
valueHint: "<preset>",
choices: SANDBOX_IMAGE_CHOICES,
description: {
"en-US": "Built-in image ID or Chinese name; fills fromImage and imageName",
"zh-CN": "内置镜像 ID 或中文名;自动填写 fromImage 和 imageName",
},
},
name: {
type: "string",
valueHint: "<name>",
description: { "en-US": "Template name", "zh-CN": "模版名称" },
},
cpuCount: {
type: "number",
valueHint: "<cores>",
description: { "en-US": "vCPU count", "zh-CN": "vCPU 核数" },
},
memoryMb: {
type: "number",
valueHint: "<mb>",
description: { "en-US": "Memory in MB", "zh-CN": "内存大小MB" },
},
fromImage: {
type: "string",
valueHint: "<image>",
description: { "en-US": "Base image identifier", "zh-CN": "基础镜像标识" },
},
imageName: {
type: "string",
valueHint: "<name>",
description: { "en-US": "Base image display name", "zh-CN": "基础镜像展示名称" },
},
env: {
type: "array",
valueHint: "<key=value>",
description: {
"en-US": "Template environment variable; repeat for multiple values",
"zh-CN": "模版环境变量;可重复传入",
},
},
allowOut: {
type: "array",
valueHint: "<address>",
description: {
"en-US": "Outbound allow-list entry; repeat for multiple values",
"zh-CN": "出站白名单条目;可重复传入",
},
},
denyOut: {
type: "array",
valueHint: "<address>",
description: {
"en-US": "Outbound deny-list entry; repeat for multiple values",
"zh-CN": "出站黑名单条目;可重复传入",
},
},
autoPauseTime: {
type: "number",
valueHint: "<seconds>",
description: {
"en-US": "Automatically pause after 300-604800 seconds",
"zh-CN": "在 300604800 秒后自动暂停",
},
},
maxRunningTime: {
type: "number",
valueHint: "<seconds>",
description: {
"en-US": "Maximum running lifetime in seconds (300-604800)",
"zh-CN": "最大运行时间300604800",
},
},
description: {
type: "string",
valueHint: "<text>",
description: { "en-US": "Template description", "zh-CN": "模版描述" },
},
} satisfies FlagsDef;
const CREATE_FLAGS = {
...WORKSPACE_FLAG,
...BODY_FLAG,
...TEMPLATE_MUTATION_FIELDS,
tag: {
type: "array",
valueHint: "<tag>",
description: { "en-US": "E2B tag; repeat for multiple values", "zh-CN": "E2B Tag可重复传入" },
},
alias: {
type: "string",
valueHint: "<alias>",
description: { "en-US": "E2B template alias", "zh-CN": "E2B 模版别名" },
},
...TEMPLATE_ASYNC_FLAG,
...POLL_INTERVAL_FLAG,
} satisfies FlagsDef;
const UPDATE_FLAGS = {
...WORKSPACE_FLAG,
...TEMPLATE_ID_FLAG,
...BODY_FLAG,
...TEMPLATE_MUTATION_FIELDS,
...TEMPLATE_ASYNC_FLAG,
...POLL_INTERVAL_FLAG,
} satisfies FlagsDef;
type CreateFlags = ParsedFlags<typeof CREATE_FLAGS>;
type UpdateFlags = ParsedFlags<typeof UPDATE_FLAGS>;
const LIST_FLAGS = {
...WORKSPACE_FLAG,
limit: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Maximum results (1-100)", "zh-CN": "最大返回数量1100" },
},
cursor: {
type: "string",
valueHint: "<cursor>",
description: { "en-US": "Server-side pagination cursor", "zh-CN": "服务端分页 Cursor" },
},
} satisfies FlagsDef;
const GET_FLAGS = { ...WORKSPACE_FLAG, ...TEMPLATE_ID_FLAG } satisfies FlagsDef;
const BUILD_STATUS_FLAGS = {
...WORKSPACE_FLAG,
...TEMPLATE_ID_FLAG,
buildId: {
type: "string",
valueHint: "<id>",
required: true,
description: { "en-US": "Template build ID", "zh-CN": "模版构建 ID" },
},
} satisfies FlagsDef;
function applyTemplateMutationFlags(body: JsonObject, flags: CreateFlags | UpdateFlags): void {
if (flags.image !== undefined) {
const image = resolveSandboxImage(flags.image);
body.fromImage = image.imageUrl;
body.imageName = image.imageName;
}
setDefined(body, "name", flags.name);
setDefined(body, "cpuCount", flags.cpuCount);
setDefined(body, "memoryMB", flags.memoryMb);
setDefined(body, "fromImage", flags.fromImage);
setDefined(body, "imageName", flags.imageName);
setDefined(body, "autoPauseTime", flags.autoPauseTime);
setDefined(body, "maxRunningTimeout", flags.maxRunningTime);
setDefined(body, "description", flags.description);
mergeObjectField(body, "envConfig", parseKeyValueEntries(flags.env, "--env"));
const networkOverrides: JsonObject = {};
setDefined(networkOverrides, "allowOut", flags.allowOut);
setDefined(networkOverrides, "denyOut", flags.denyOut);
mergeObjectField(body, "networkConfig", networkOverrides);
}
function validateTemplateMutationBody(body: JsonObject, create: boolean): void {
if (create && (typeof body.name !== "string" || body.name.trim().length === 0)) {
throw new BailianError("Template create requires --name or name in --body.", ExitCode.USAGE);
}
const hasCpu = body.cpuCount !== undefined;
const hasMemory = body.memoryMB !== undefined;
if (create && (!hasCpu || !hasMemory)) {
throw new BailianError(
"Template create requires both --cpu-count and --memory-mb, or their --body fields.",
ExitCode.USAGE,
);
}
if (hasCpu !== hasMemory) {
throw new BailianError("cpuCount and memoryMB must be provided together.", ExitCode.USAGE);
}
if (hasCpu) validatePositiveInteger(body.cpuCount, "cpuCount");
if (hasMemory) validatePositiveInteger(body.memoryMB, "memoryMB");
validateIntegerRange(body.autoPauseTime, "autoPauseTime", 300, 604800);
validateIntegerRange(body.maxRunningTimeout, "maxRunningTimeout", 300, 604800);
if (!create && Object.keys(body).length === 0) {
throw new BailianError("Template update requires at least one field.", ExitCode.USAGE);
}
}
export async function buildTemplateCreateBody(flags: CreateFlags): Promise<JsonObject> {
const body = await readRequestBody(flags.body);
applyTemplateMutationFlags(body, flags);
setDefined(body, "tags", flags.tag);
setDefined(body, "alias", flags.alias);
validateTemplateMutationBody(body, true);
return body;
}
export async function buildTemplateUpdateBody(flags: UpdateFlags): Promise<JsonObject> {
const body = await readRequestBody(flags.body);
applyTemplateMutationFlags(body, flags);
validateTemplateMutationBody(body, false);
return body;
}
export async function waitForTemplateBuild(
client: Client,
settings: Settings,
endpoint: string,
intervalSec: number,
): Promise<TemplateBuildStatus> {
const deadline = Date.now() + settings.timeout * 1000;
const spinner = createSpinner("Waiting for template build...");
if (!settings.quiet) spinner.start();
try {
while (true) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) break;
const status = await client.requestJson<TemplateBuildStatus>({
path: endpoint,
method: "GET",
timeout: remainingMs / 1000,
});
if (!settings.quiet) spinner.update(`Build status: ${status.status ?? "unknown"}`);
if (status.status === "ready") {
spinner.stop("Template build ready.");
return status;
}
if (status.status === "error") {
spinner.stop("Template build failed.");
const reason =
status.reason?.message ??
(status.reason?.code !== undefined ? `${status.reason.code}` : "Template build failed.");
throw new BailianError(reason, ExitCode.GENERAL);
}
const sleepMs = Math.min(intervalSec * 1000, deadline - Date.now());
if (sleepMs > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, sleepMs));
}
}
} finally {
spinner.stop();
}
throw new BailianError(
"Template build polling timed out.",
ExitCode.TIMEOUT,
"Increase --timeout or query the build status command with the returned IDs.",
);
}
function validatePollInterval(flags: { pollInterval?: number }): string | undefined {
if (
flags.pollInterval !== undefined &&
(!Number.isInteger(flags.pollInterval) || flags.pollInterval <= 0)
) {
return "--poll-interval must be a positive integer.";
}
return undefined;
}
async function emitTemplateMutationResult(options: {
response: TemplateInfo;
endpoint: string;
client: Client;
settings: Settings;
async: boolean;
pollInterval?: number;
}): Promise<void> {
const format = detectOutputFormat(options.settings.output);
if (!options.response.templateID || !options.response.buildID) {
throw new BailianError(
"Template mutation response is missing templateID or buildID.",
ExitCode.GENERAL,
);
}
if (options.async) {
if (options.settings.quiet) {
emitBare(`${options.response.templateID}\t${options.response.buildID}`);
} else {
emitResult(options.response, format);
}
return;
}
// Poll the same origin that accepted the build, including custom gateways.
const buildEndpoint = new URL(
sandboxApiPath(
sandboxTemplateBuildStatusPath(options.response.templateID, options.response.buildID),
),
options.endpoint,
).toString();
let build: TemplateBuildStatus;
try {
build = await waitForTemplateBuild(
options.client,
options.settings,
buildEndpoint,
options.pollInterval ?? 5,
);
} catch (error) {
const hint =
`Submitted build / 已提交的构建: templateID=${options.response.templateID}, buildID=${options.response.buildID}.\n` +
"Check this build with sandbox template build-status before submitting another build. / 请先通过 sandbox template build-status 查询本次构建,再决定是否重新提交。";
if (error instanceof BailianError) {
throw new BailianError(
error.message,
error.exitCode,
[error.hint, hint].filter(Boolean).join("\n"),
{
api: error.api,
rawResponse: error.rawResponse,
cause: error.cause,
},
);
}
// Leave transport errors intact so runtime retains timeout/network classification.
const recovery = {
templateID: options.response.templateID,
buildID: options.response.buildID,
hint,
};
process.stderr.write(
format === "json" ? `${JSON.stringify(recovery, null, 2)}\n\n` : `${hint}\n`,
);
throw error;
}
if (options.settings.quiet) emitBare(displayValue(options.response.templateID));
else emitResult({ template: options.response, build }, format);
}
export const sandboxTemplateCreate = defineCommand({
description: { "en-US": "Create a Sandbox template", "zh-CN": "创建 Sandbox 模版" },
auth: "apiKey",
usageArgs: "(--name <name> --cpu-count <cores> --memory-mb <mb> | --body <json|@path>) [flags]",
flags: CREATE_FLAGS,
exampleArgs: [
"--name browser --image browser --cpu-count 1 --memory-mb 2048",
"--name python --cpu-count 1 --memory-mb 2048",
"--body @template.json --async --output json",
"--name browser --cpu-count 4 --memory-mb 8192 --dry-run --output json",
],
notes: [
...SANDBOX_NOTES,
...SANDBOX_IMAGE_NOTES,
{
"en-US":
"--image overrides body fromImage/imageName; explicit --from-image/--image-name override the corresponding preset fields. Without --image, image behavior is unchanged.",
"zh-CN":
"--image 覆盖 body 中的 fromImage/imageName显式 --from-image/--image-name 再覆盖对应预设字段。不传 --image 时保持原有镜像行为。",
},
{
"en-US":
"For local file mounts, use sandbox file upload first; put the available file's id into --body mntConfig[].originFileId with mountPath and optional originFileName. Upload and template must use the same workspace. Other complete nested structures can also be supplied through --body.",
"zh-CN":
"挂载本地文件前先调用 sandbox file upload将可用文件的 id 填入 --body 的 mntConfig[].originFileId并传入 mountPath 和可选的 originFileName。上传和模版须位于同一工作空间。其他完整嵌套结构也可通过 --body 提供。",
},
{
"en-US":
"By default the command waits for build status ready; --async returns the submitted build immediately.",
"zh-CN": "默认等待构建状态变为 ready--async 会立即返回已提交的构建信息。",
},
],
validate: validatePollInterval,
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const endpoint = resolveSandboxEndpoint(ctx, SANDBOX_PATHS.templateCreate);
const body = await buildTemplateCreateBody(ctx.flags);
if (ctx.settings.dryRun) {
emitResult({ method: "POST", endpoint, request: redactRequestSecrets(body) }, format);
return;
}
const response = await ctx.client.requestJson<TemplateInfo>({
path: endpoint,
method: "POST",
body,
});
await emitTemplateMutationResult({
response,
endpoint,
client: ctx.client,
settings: ctx.settings,
async: ctx.flags.async,
pollInterval: ctx.flags.pollInterval,
});
},
});
export const sandboxTemplateList = defineCommand({
description: { "en-US": "List Sandbox templates", "zh-CN": "列出 Sandbox 模版" },
auth: "apiKey",
usageArgs: "[--limit <n>] [--cursor <cursor>]",
flags: LIST_FLAGS,
exampleArgs: ["", "--limit 100 --output json"],
notes: [
...SANDBOX_NOTES,
{
"en-US":
"The API response does not expose a next cursor, so automatic --all pagination is unavailable.",
"zh-CN": "API 响应未提供下一页 Cursor因此不支持自动 --all 翻页。",
},
],
validate(flags) {
if (
flags.limit !== undefined &&
(!Number.isInteger(flags.limit) || flags.limit < 1 || flags.limit > 100)
) {
return "--limit must be an integer between 1 and 100.";
}
return undefined;
},
async run(ctx) {
const url = new URL(resolveSandboxEndpoint(ctx, SANDBOX_PATHS.templateList));
if (ctx.flags.limit !== undefined) url.searchParams.set("limit", String(ctx.flags.limit));
if (ctx.flags.cursor) url.searchParams.set("cursor", ctx.flags.cursor);
const response = await ctx.client.requestJson<TemplateInfo[]>({
path: url.toString(),
method: "GET",
});
if (ctx.settings.quiet) {
for (const template of response) emitBare(displayValue(template.templateID));
return;
}
const format = detectOutputFormat(ctx.settings.output);
if (format === "json") {
emitResult(response, format);
return;
}
if (response.length === 0) {
emitBare("No Sandbox templates found.");
return;
}
const rows = response.map((template) => [
displayValue(template.templateID),
displayValue(template.templateName),
displayValue(template.cpuCount),
displayValue(template.memoryMB),
displayValue(template.spawnCount),
displayValue(template.buildCount),
displayValue(template.updatedAt),
]);
for (const line of formatTable(
["ID", "NAME", "CPU", "MEMORY_MB", "INSTANCES", "BUILDS", "UPDATED_AT"],
rows,
)) {
emitBare(line);
}
},
});
export const sandboxTemplateGet = defineCommand({
description: { "en-US": "Get Sandbox template details", "zh-CN": "获取 Sandbox 模版详情" },
auth: "apiKey",
usageArgs: "--template-id <id>",
flags: GET_FLAGS,
exampleArgs: ["--template-id tpl-xxx", "--template-id tpl-xxx --output json"],
notes: SANDBOX_NOTES,
async run(ctx) {
const endpoint = resolveSandboxEndpoint(ctx, sandboxTemplatePath(ctx.flags.templateId));
const response = await ctx.client.requestJson<TemplateInfo>({ path: endpoint, method: "GET" });
if (ctx.settings.quiet) emitBare(displayValue(response.templateID));
else emitResult(response, detectOutputFormat(ctx.settings.output));
},
});
export const sandboxTemplateUpdate = defineCommand({
description: { "en-US": "Update a Sandbox template", "zh-CN": "更新 Sandbox 模版" },
auth: "apiKey",
usageArgs: "--template-id <id> (--body <json|@path> | [fields])",
flags: UPDATE_FLAGS,
exampleArgs: [
"--template-id tpl-xxx --image all-in-one",
"--template-id tpl-xxx --cpu-count 4 --memory-mb 8192",
"--template-id tpl-xxx --body @template-update.json --async --output json",
"--template-id tpl-xxx --description updated --dry-run --output json",
],
notes: [
...SANDBOX_NOTES,
...SANDBOX_IMAGE_NOTES,
{
"en-US":
"--image overrides body fromImage/imageName; explicit --from-image/--image-name override the corresponding preset fields. Without --image, image behavior is unchanged.",
"zh-CN":
"--image 覆盖 body 中的 fromImage/imageName显式 --from-image/--image-name 再覆盖对应预设字段。不传 --image 时保持原有镜像行为。",
},
{
"en-US": "Supplying envConfig or --env replaces the template's complete environment map.",
"zh-CN": "传入 envConfig 或 --env 会整体替换模版的环境变量 Map。",
},
{
"en-US":
"Use sandbox file upload for local mount files. In --body mntConfig[], set originFileId to the available file's id and supply mountPath; upload and template must use the same workspace.",
"zh-CN":
"本地挂载文件先通过 sandbox file upload 上传。在 --body 的 mntConfig[] 中,将 originFileId 设为可用文件的 id 并传入 mountPath上传和模版须位于同一工作空间。",
},
{
"en-US":
"By default the command waits for build status ready; --async returns the submitted build immediately.",
"zh-CN": "默认等待构建状态变为 ready--async 会立即返回已提交的构建信息。",
},
],
validate: validatePollInterval,
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const endpoint = resolveSandboxEndpoint(ctx, sandboxTemplatePath(ctx.flags.templateId));
const body = await buildTemplateUpdateBody(ctx.flags);
if (ctx.settings.dryRun) {
emitResult({ method: "PUT", endpoint, request: redactRequestSecrets(body) }, format);
return;
}
const response = await ctx.client.requestJson<TemplateInfo>({
path: endpoint,
method: "PUT",
body,
});
await emitTemplateMutationResult({
response,
endpoint,
client: ctx.client,
settings: ctx.settings,
async: ctx.flags.async,
pollInterval: ctx.flags.pollInterval,
});
},
});
export const sandboxTemplateBuildStatus = defineCommand({
description: {
"en-US": "Get Sandbox template build status",
"zh-CN": "获取 Sandbox 模版构建状态",
},
auth: "apiKey",
usageArgs: "--template-id <id> --build-id <id>",
flags: BUILD_STATUS_FLAGS,
exampleArgs: [
"--template-id tpl-xxx --build-id build-xxx",
"--template-id tpl-xxx --build-id build-xxx --output json",
],
notes: SANDBOX_NOTES,
async run(ctx) {
const endpoint = resolveSandboxEndpoint(
ctx,
sandboxTemplateBuildStatusPath(ctx.flags.templateId, ctx.flags.buildId),
);
const response = await ctx.client.requestJson<TemplateBuildStatus>({
path: endpoint,
method: "GET",
});
if (ctx.settings.quiet) emitBare(displayValue(response.status));
else emitResult(response, detectOutputFormat(ctx.settings.output));
},
});
export const sandboxTemplateDelete = defineCommand({
description: { "en-US": "Delete a Sandbox template", "zh-CN": "删除 Sandbox 模版" },
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US": "This permanently deletes the Sandbox template and cannot be undone.",
"zh-CN": "该操作会永久删除 Sandbox 模版,且无法撤销。",
},
},
usageArgs: "--template-id <id>",
flags: GET_FLAGS,
exampleArgs: ["--template-id tpl-xxx --dry-run", "--template-id tpl-xxx --yes"],
notes: [
...SANDBOX_NOTES,
{
"en-US":
"The server rejects deletion while running or paused instances still use the template.",
"zh-CN": "仍有运行中或已暂停实例使用该模版时,服务端会拒绝删除。",
},
],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const endpoint = resolveSandboxEndpoint(ctx, sandboxTemplatePath(ctx.flags.templateId));
if (ctx.settings.dryRun) {
emitResult({ method: "DELETE", endpoint, request: null }, format);
return;
}
await ctx.client.request({ path: endpoint, method: "DELETE" });
if (ctx.settings.quiet) emitBare(ctx.flags.templateId);
else emitResult({ templateID: ctx.flags.templateId, deleted: true }, format);
},
});
@@ -14,9 +14,11 @@ import {
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsrContextMessages,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
parseInstantVocabulary,
type AsrApiRoute,
type AsrFlashFamily,
type OutputFormat,
@@ -73,8 +75,30 @@ const RECOGNIZE_FLAGS = {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Hot-word vocabulary ID for improved accuracy",
"zh-CN": "用于提升识别准确率的热词表 ID",
"en-US":
"Pre-built hot-word vocabulary ID (create it via `speech vocabulary create`). Its target_model must exactly match --model, otherwise it is silently ignored. Wider model support than --vocabulary, including Fun-ASR and Paraformer",
"zh-CN":
"预编译热词列表 ID可用 `speech vocabulary create` 创建)。其 target_model 必须与 --model 完全一致,否则静默失效且无报错。支持模型比 --vocabulary 更广,含 Fun-ASR 与 Paraformer 系列",
},
},
vocabulary: {
type: "string",
valueHint: "<json>",
description: {
"en-US":
"Instant hot words as JSON object of word→weight, e.g. '{\"Fendouzhe\":4}'. Weight 1-5 (4 recommended; higher values can hurt other words), 50 for super hot word. No pre-built vocabulary needed. Takes effect only on Qwen-Audio-3.0-ASR-Flash models",
"zh-CN":
"即时热词JSON 对象「热词→权重」,例如 '{\"奋斗者\":4}'。权重 1-5推荐 4过高会拖累其他词50 表示超级热词。无需预先创建热词表。仅 Qwen-Audio-3.0-ASR-Flash 系列模型生效",
},
},
context: {
type: "string",
valueHint: "<text>",
description: {
"en-US":
"Context enhancement word list to improve accuracy on proper nouns; must contain the target words themselves (a topic description alone has little effect); max 400 chars. Takes effect only on Qwen-Audio-3.0-ASR-Flash and Fun-ASR-Flash models",
"zh-CN":
"上下文增强词表,提升专有名词准确率;须包含待识别的原词本身(只写主题描述效果有限),最长 400 字符。仅 Qwen-Audio-3.0-ASR-Flash 系列与 Fun-ASR-Flash 模型生效",
},
},
channelId: {
@@ -110,9 +134,11 @@ function assertSyncFlashFlagsAllowed(
const unsupported: string[] = [];
if (flags.diarization === true) unsupported.push("--diarization");
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
// qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does
if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) {
unsupported.push("--vocabulary-id");
// qwen3 sync Flash has no place for vocabulary_id / vocabulary / context in its body shape
if (flashFamily === "qwen3") {
if (flags.vocabularyId !== undefined) unsupported.push("--vocabulary-id");
if (flags.vocabulary !== undefined) unsupported.push("--vocabulary");
if (flags.context !== undefined) unsupported.push("--context");
}
if (flags.channelId !== undefined) unsupported.push("--channel-id");
if (flags.async === true) unsupported.push("--async");
@@ -121,12 +147,34 @@ function assertSyncFlashFlagsAllowed(
if (unsupported.length > 0) {
throw new BailianError(
`Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` +
`Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`,
syncFlashUnsupportedHint(unsupported),
ExitCode.USAGE,
);
}
}
/** Pick a hint that matches the rejected flags (vocab/context vs diarization/async/…). */
function syncFlashUnsupportedHint(unsupported: string[]): string {
const vocabularyRelated = new Set(["--vocabulary", "--vocabulary-id", "--context"]);
const hasVocabularyRelated = unsupported.some((flag) => vocabularyRelated.has(flag));
const hasOtherFlags = unsupported.some((flag) => !vocabularyRelated.has(flag));
if (hasVocabularyRelated && !hasOtherFlags) {
return (
"Hint: Use qwen-audio-3.0-asr-flash (or an async filetrans model such as " +
"qwen-audio-3.0-asr-flash-filetrans) for vocabulary/context flags."
);
}
if (hasVocabularyRelated && hasOtherFlags) {
return (
"Hint: For vocabulary/context flags use qwen-audio-3.0-asr-flash or " +
"qwen-audio-3.0-asr-flash-filetrans; for the other flags use an async filetrans model " +
"(e.g. fun-asr)."
);
}
return "Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.";
}
export default defineCommand({
description: {
"en-US": "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
@@ -141,6 +189,8 @@ export default defineCommand({
"--url https://example.com/meeting.wav --diarization --speaker-count 3",
"--url https://example.com/audio.mp3 --language zh",
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
'--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --vocabulary \'{"奋斗者":4,"鲸落":4}\'',
'--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --context "奋斗者号 鲸落 深海勇士"',
"--url https://example.com/audio.mp3 --out result.json",
"--url https://example.com/audio.mp3 --async --quiet",
"--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en",
@@ -198,6 +248,9 @@ export default defineCommand({
const format = detectOutputFormat(settings.output);
const vocabulary =
flags.vocabulary !== undefined ? parseInstantVocabulary(flags.vocabulary) : undefined;
// Auto-upload local files in parallel
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
@@ -210,6 +263,7 @@ export default defineCommand({
model,
route,
resolvedUrls[0]!,
vocabulary,
);
return;
}
@@ -223,16 +277,19 @@ export default defineCommand({
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url"
input: {
...(route.asyncInputStyle === "file_url"
? { file_url: resolvedUrls[0]! }
: { file_urls: resolvedUrls },
: { file_urls: resolvedUrls }),
...(flags.context !== undefined ? { context: buildAsrContextMessages(flags.context) } : {}),
},
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
...languageFields,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
vocabulary,
},
};
@@ -260,6 +317,7 @@ async function handleSyncFlashMode(
model: string,
route: AsrApiRoute,
audioUrl: string,
vocabulary: Record<string, number> | undefined,
): Promise<void> {
const flashFamily = route.flashFamily as AsrFlashFamily;
const body = buildAsrFlashRequest({
@@ -267,6 +325,8 @@ async function handleSyncFlashMode(
audioUrl,
language: flags.language,
vocabularyId: flags.vocabularyId,
vocabulary,
context: flags.context,
flashFamily,
});
@@ -0,0 +1,117 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
createVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
VOCABULARY_BODY_FLAGS,
VOCABULARY_LIMIT_NOTES,
validateVocabularySource,
readVocabularyEntries,
} from "./shared.ts";
const CREATE_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: {
"en-US":
"ASR model this vocabulary is built for (required). Must exactly match the --model passed to `speech recognize` later, otherwise the vocabulary is silently ignored",
"zh-CN":
"该热词表服务的 ASR 模型(必填)。必须与后续 `speech recognize` 的 --model 完全一致,否则热词表静默失效",
},
required: true,
},
prefix: {
type: "string",
valueHint: "<prefix>",
description: {
"en-US":
"Custom vocabulary prefix (required). Digits and lowercase letters only, max 10 chars",
"zh-CN": "热词表自定义前缀(必填)。仅允许数字和小写字母,最长 10 个字符",
},
required: true,
},
...VOCABULARY_BODY_FLAGS,
} satisfies FlagsDef;
type CreateFlags = ParsedFlags<typeof CREATE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Create a precompiled hot-word vocabulary for ASR",
"zh-CN": "创建用于语音识别的预编译热词表",
},
auth: "apiKey",
usageArgs: "--model <model> --prefix <prefix> (--words <json> | --words-file <path>) [flags]",
flags: CREATE_FLAGS,
notes: [
{
"en-US":
"The --model must exactly match the --model used later with `speech recognize --vocabulary-id`; a mismatch causes silent failure with no error.",
"zh-CN":
"--model 必须与后续 `speech recognize --vocabulary-id` 使用的 --model 完全一致;不一致时热词表会静默失效且无报错。",
},
...VOCABULARY_LIMIT_NOTES,
],
exampleArgs: [
{
"en-US": '--model fun-asr --prefix demo --words \'{"Fendouzhe":4,"Jingluo":4}\'',
"zh-CN": '--model fun-asr --prefix demo --words \'{"奋斗者":4,"鲸落":4}\'',
},
{
"en-US":
'--model paraformer-v2 --prefix demo --words \'[{"text":"Fendouzhe","weight":4,"lang":"zh"}]\'',
"zh-CN":
'--model paraformer-v2 --prefix demo --words \'[{"text":"奋斗者","weight":4,"lang":"zh"}]\'',
},
{
"en-US": "--model fun-asr --prefix demo --words '{\"Fendouzhe\":4}' --lang zh",
"zh-CN": "--model fun-asr --prefix demo --words '{\"奋斗者\":4}' --lang zh",
},
"--model fun-asr --prefix demo --words-file ./hotwords.json",
{
"en-US": "--model fun-asr --prefix demo --words '{\"Fendouzhe\":4}' --quiet",
"zh-CN": "--model fun-asr --prefix demo --words '{\"奋斗者\":4}' --quiet",
},
],
validate: (flags: CreateFlags) => validateVocabularySource(flags),
async run(ctx) {
const { settings, flags } = ctx;
const vocabulary = readVocabularyEntries(flags);
const format = detectOutputFormat(settings.output);
const request = buildVocabularyRequest("create_vocabulary", {
target_model: flags.model,
prefix: flags.prefix,
vocabulary,
});
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request,
},
format,
);
return;
}
const response = await createVocabulary(ctx.client, {
targetModel: flags.model,
prefix: flags.prefix,
vocabulary,
});
if (settings.quiet || format === "text") {
emitBare(response.output?.vocabulary_id ?? "");
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,60 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
deleteVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { VOCABULARY_ID_FLAG } from "./shared.ts";
const DELETE_FLAGS = {
...VOCABULARY_ID_FLAG,
} satisfies FlagsDef;
type DeleteFlags = ParsedFlags<typeof DELETE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Delete a precompiled hot-word vocabulary",
"zh-CN": "删除预编译热词表",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US": "This permanently deletes the specified hot-word vocabulary and cannot be undone.",
"zh-CN": "该操作会永久删除指定的热词表,且无法撤销。",
},
},
usageArgs: "--id <id>",
flags: DELETE_FLAGS,
exampleArgs: ["--id vocab-demo-xxx --dry-run", "--id vocab-demo-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = (flags as DeleteFlags).id;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("delete_vocabulary", {
vocabulary_id: vocabularyId,
}),
},
format,
);
return;
}
const response = await deleteVocabulary(ctx.client, vocabularyId);
if (settings.quiet || format === "text") {
emitBare(vocabularyId);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,79 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
queryVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { VOCABULARY_ID_FLAG } from "./shared.ts";
const GET_FLAGS = {
...VOCABULARY_ID_FLAG,
} satisfies FlagsDef;
type GetFlags = ParsedFlags<typeof GET_FLAGS>;
export default defineCommand({
description: {
"en-US": "Get details of a precompiled hot-word vocabulary",
"zh-CN": "查看预编译热词表详情",
},
auth: "apiKey",
usageArgs: "--id <id>",
flags: GET_FLAGS,
notes: [
{
"en-US":
"Use this command to confirm target_model before calling `speech recognize --vocabulary-id`; a model mismatch causes silent failure.",
"zh-CN":
"调用 `speech recognize --vocabulary-id` 前请用本命令确认 target_model模型不一致会导致静默失效。",
},
],
exampleArgs: ["--id vocab-demo-xxx", "--id vocab-demo-xxx --quiet"],
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = (flags as GetFlags).id;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("query_vocabulary", {
vocabulary_id: vocabularyId,
}),
},
format,
);
return;
}
const response = await queryVocabulary(ctx.client, vocabularyId);
const output = response.output;
if (settings.quiet) {
emitBare(output?.target_model ?? "");
return;
}
if (format === "text") {
emitBare(`vocabulary_id: ${vocabularyId}`);
emitBare(`status: ${output?.status ?? ""}`);
emitBare(`target_model: ${output?.target_model ?? ""}`);
emitBare(`gmt_create: ${output?.gmt_create ?? ""}`);
emitBare(`gmt_modified: ${output?.gmt_modified ?? ""}`);
const entries = output?.vocabulary ?? [];
if (entries.length > 0) {
emitBare("vocabulary:");
for (const entry of entries) {
const langPart = entry.lang ? ` lang=${entry.lang}` : "";
emitBare(` ${entry.text} weight=${entry.weight}${langPart}`);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
listVocabularies,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const LIST_FLAGS = {
prefix: {
type: "string",
valueHint: "<prefix>",
description: {
"en-US": "Filter by vocabulary prefix",
"zh-CN": "按热词表前缀过滤",
},
},
page: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Page number, 1-based (default: 1). Mapped to API page_index (0-based) as page - 1",
"zh-CN": "页码,从 1 开始默认1。映射为 API 的 page_index从 0 开始page - 1",
},
},
pageSize: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Results per page (default: 10)",
"zh-CN": "每页结果数默认10",
},
},
} satisfies FlagsDef;
type ListFlags = ParsedFlags<typeof LIST_FLAGS>;
export default defineCommand({
description: {
"en-US": "List precompiled hot-word vocabularies",
"zh-CN": "列出预编译热词表",
},
auth: "apiKey",
usageArgs: "[--prefix <prefix>] [--page <n>] [--page-size <n>]",
flags: LIST_FLAGS,
notes: [
{
"en-US":
"List responses do not include target_model; use `speech vocabulary get` to inspect the model a vocabulary was built for.",
"zh-CN": "list 响应不含 target_model要对齐模型请使用 `speech vocabulary get`。",
},
{
"en-US": "Vocabularies with status UNDEPLOYED are silently ignored by ASR.",
"zh-CN": "status 为 UNDEPLOYED 的热词表会被 ASR 静默忽略。",
},
],
exampleArgs: ["", "--prefix demo", "--page 2 --page-size 20"],
validate: (flags: ListFlags) => {
if (flags.page !== undefined && flags.page < 1) {
return "--page must be >= 1.";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const pageIndex = flags.page !== undefined ? flags.page - 1 : undefined;
const input: Record<string, unknown> = {};
if (flags.prefix !== undefined) input.prefix = flags.prefix;
if (pageIndex !== undefined) input.page_index = pageIndex;
if (flags.pageSize !== undefined) input.page_size = flags.pageSize;
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("list_vocabulary", input),
},
format,
);
return;
}
const response = await listVocabularies(ctx.client, {
prefix: flags.prefix,
pageIndex,
pageSize: flags.pageSize,
});
if (settings.quiet || format === "text") {
const items = response.output?.vocabulary_list ?? [];
if (items.length === 0) {
emitBare("No vocabularies found.");
} else {
let hasUndeployed = false;
for (const item of items) {
const id = item.vocabulary_id ?? "";
const status = item.status ?? "";
const modified = item.gmt_modified ?? "";
if (status === "UNDEPLOYED") hasUndeployed = true;
emitBare(`[${id}] ${status} ${modified}`.trimEnd());
}
if (hasUndeployed) {
emitBare(
"Note: UNDEPLOYED vocabularies are silently ignored by ASR. Use `speech vocabulary get` to inspect them.",
);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,103 @@
import {
readTextFromPathOrStdin,
parseVocabularyEntries,
UsageError,
type FlagsDef,
type ParsedFlags,
type VocabularyEntry,
} from "bailian-cli-core";
/** Shared --id flag for get / update / delete. */
export const VOCABULARY_ID_FLAG = {
id: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Hot-word vocabulary ID (required)",
"zh-CN": "热词表 ID必填",
},
required: true,
},
} satisfies FlagsDef;
/** Shared hot-word body flags for create / update. */
export const VOCABULARY_BODY_FLAGS = {
words: {
type: "string",
valueHint: "<json>",
description: {
"en-US":
"Hot words as JSON object of word→weight, e.g. '{\"Fendouzhe\":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file",
"zh-CN":
"热词JSON 对象「热词→权重」,例如 '{\"奋斗者\":4}';需要逐条指定语言时可传 API 的条目数组。权重 1-5推荐 4当热词表的 target_model 为 Qwen-Audio-3.0-ASR-Flash 系列时还可使用 50超级热词。也可使用 --words-file",
},
},
wordsFile: {
type: "string",
valueHint: "<path>",
description: {
"en-US": "JSON file with the hot words (use - for stdin)",
"zh-CN": "包含热词的 JSON 文件(使用 - 从 stdin 读取)",
},
},
lang: {
type: "string",
valueHint: "<code>",
description: {
"en-US":
"Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja",
"zh-CN":
"对象形态时应用到所有热词的语言代码选填数组形态忽略。Paraformer 支持 zh/en/ja/yue/ko/de/fr/ruFun-ASR 支持 zh/en/ja",
},
},
} satisfies FlagsDef;
type VocabularySourceFlags = ParsedFlags<typeof VOCABULARY_BODY_FLAGS>;
/** Cross-flag validation for --words / --words-file. */
export function validateVocabularySource(flags: VocabularySourceFlags): string | undefined {
if (!flags.words && !flags.wordsFile) {
return "Provide --words or --words-file.";
}
if (flags.words && flags.wordsFile) {
return "Use either --words or --words-file, not both.";
}
if (!flags.words) {
return undefined;
}
try {
parseVocabularyEntries(flags.words, flags.lang);
} catch (error) {
if (error instanceof UsageError) {
return error.message;
}
throw error;
}
return undefined;
}
/** Read and parse vocabulary entries from flag or file. */
export function readVocabularyEntries(flags: VocabularySourceFlags): VocabularyEntry[] {
const raw = flags.wordsFile ? readTextFromPathOrStdin(flags.wordsFile) : (flags.words as string);
return parseVocabularyEntries(raw, flags.lang);
}
/** Shared notes covering account limits and silent-failure pitfalls. */
export const VOCABULARY_LIMIT_NOTES = [
{
"en-US":
"Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits.",
"zh-CN": "每个账号最多 10 个热词表;两次更新间隔至少 5 分钟。完整限制见 improve-asr-accuracy。",
},
{
"en-US":
"Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is.",
"zh-CN": "新加坡子业务空间不支持热词表;服务端错误会原样透传。",
},
{
"en-US":
"Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word.",
"zh-CN":
"权重 1-5推荐 4当热词表的 target_model 为 Qwen-Audio-3.0-ASR-Flash 系列时还可使用 50超级热词。",
},
] as const;
@@ -0,0 +1,90 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
updateVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
VOCABULARY_ID_FLAG,
VOCABULARY_BODY_FLAGS,
VOCABULARY_LIMIT_NOTES,
validateVocabularySource,
readVocabularyEntries,
} from "./shared.ts";
const UPDATE_FLAGS = {
...VOCABULARY_ID_FLAG,
...VOCABULARY_BODY_FLAGS,
} satisfies FlagsDef;
type UpdateFlags = ParsedFlags<typeof UPDATE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Replace the contents of a precompiled hot-word vocabulary",
"zh-CN": "完全替换预编译热词表的内容",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This fully replaces all hot words in the vocabulary. Entries not listed will be discarded and cannot be undone.",
"zh-CN": "该操作会完全替换热词表中的全部词条。未列出的词将被丢弃,且无法撤销。",
},
},
usageArgs: "--id <id> (--words <json> | --words-file <path>) [flags]",
flags: UPDATE_FLAGS,
notes: [
{
"en-US":
"update is a full replace, not an append. Prefer --dry-run first to preview the complete vocabulary that will be written.",
"zh-CN": "update 是完全替换,不是增量追加。建议先用 --dry-run 预览将要写入的完整词表。",
},
...VOCABULARY_LIMIT_NOTES,
],
exampleArgs: [
{
"en-US": "--id vocab-demo-xxx --words '{\"Fendouzhe\":4}' --dry-run",
"zh-CN": "--id vocab-demo-xxx --words '{\"奋斗者\":4}' --dry-run",
},
{
"en-US": '--id vocab-demo-xxx --words \'{"Fendouzhe":4,"Jingluo":4}\' --yes',
"zh-CN": '--id vocab-demo-xxx --words \'{"奋斗者":4,"鲸落":4}\' --yes',
},
],
validate: (flags: UpdateFlags) => validateVocabularySource(flags),
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = flags.id;
const vocabulary = readVocabularyEntries(flags);
const format = detectOutputFormat(settings.output);
const request = buildVocabularyRequest("update_vocabulary", {
vocabulary_id: vocabularyId,
vocabulary,
});
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request,
},
format,
);
return;
}
const response = await updateVocabulary(ctx.client, vocabularyId, vocabulary);
if (settings.quiet || format === "text") {
emitBare(vocabularyId);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,169 @@
import { randomUUID } from "node:crypto";
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { printQuotaBox, readNumber, type QuotaSection } from "../usage/quota-box.ts";
import { formatNumber } from "../shared/format.ts";
import type { HarnessBenefitItem, TokenPlanEquityInfo } from "./types.ts";
const HARNESS_LIST_API = "zeldaEasy.broadscope-bailian.token-plan.detail";
const EQUITY_INFO_API = "zeldaEasy.bailian-commerce.tokenPlan.queryTokenPlanEquityInfo";
/** One harness that carries an entitlement quota, issued or still pending. */
interface HarnessQuotaRow {
planCode: string;
title: string;
type: string;
/** `issuing` means the harness carries a quota but no entitlement is issued yet. */
status: "issued" | "issuing";
quotaUnit: string;
instanceId?: string;
totalQuota?: number;
availableQuota?: number;
usedQuota?: number;
/** Used ratio in percent with 0.1 precision, matching the console display. */
usedPercent?: number;
instanceStartTime?: number;
instanceEndTime?: number;
}
function readArray<T>(result: unknown, field: string): T[] {
const response = unwrapResponse(result as Record<string, unknown>);
const value = response[field];
return Array.isArray(value) ? (value as T[]) : [];
}
/**
* Join the harness list with the issued entitlements, keeping the server order.
* A harness carrying a resource pack but no matching entitlement is still
* listed as `issuing` — issuance lags the purchase by a few minutes.
*/
function buildRows(
items: HarnessBenefitItem[],
equityInfos: TokenPlanEquityInfo[],
): HarnessQuotaRow[] {
const rows: HarnessQuotaRow[] = [];
for (const item of items) {
// Only harnesses that carry a resource pack have an entitlement quota at all.
if (item.hasResourcePack !== true) continue;
const planCodes = item.planCodes ?? [];
const equity = equityInfos.find(
(equityInfo) => equityInfo.equityType && planCodes.includes(equityInfo.equityType),
);
const planCode = equity?.equityType ?? planCodes[0] ?? "";
const row: HarnessQuotaRow = {
planCode,
title: item.title ?? planCode,
type: item.type ?? "",
status: equity ? "issued" : "issuing",
quotaUnit: item.quotaUnit ?? item.priceInfo?.unit ?? "",
};
if (!equity) {
rows.push(row);
continue;
}
if (equity.instanceId) row.instanceId = equity.instanceId;
const totalQuota = readNumber(equity.totalQuota);
if (totalQuota !== undefined) row.totalQuota = totalQuota;
const availableQuota = readNumber(equity.availableQuota);
if (availableQuota !== undefined) row.availableQuota = availableQuota;
if (totalQuota !== undefined && availableQuota !== undefined) {
row.usedQuota = Math.max(totalQuota - availableQuota, 0);
if (totalQuota > 0) {
row.usedPercent = Math.round((row.usedQuota / totalQuota) * 1000) / 10;
}
}
const instanceStartTime = readNumber(equity.instanceStartTime);
if (instanceStartTime !== undefined) row.instanceStartTime = instanceStartTime;
const instanceEndTime = readNumber(equity.instanceEndTime);
if (instanceEndTime !== undefined) row.instanceEndTime = instanceEndTime;
rows.push(row);
}
return rows;
}
function toSection(row: HarnessQuotaRow): QuotaSection {
const section: QuotaSection = {
label: `${row.title} (${row.planCode})`,
emptyMessage:
row.status === "issuing"
? "Quota is being issued; issuance can take up to 5 minutes."
: "No positive quota total reported; check the Bailian Token Plan console.",
};
if (row.status === "issuing") return section;
if (row.totalQuota !== undefined && row.usedQuota !== undefined) {
const unitSuffix = row.quotaUnit ? ` ${row.quotaUnit}` : "";
section.detail = `Used: ${formatNumber(row.usedQuota)} / ${formatNumber(row.totalQuota)}${unitSuffix}`;
if (row.totalQuota > 0) section.percentage = row.usedQuota / row.totalQuota;
}
if (row.instanceEndTime !== undefined) section.resetTime = row.instanceEndTime;
return section;
}
export default defineCommand({
description: {
"en-US": "Show Token Plan harness entitlement quota usage",
"zh-CN": "查看 Token Plan harness 权益额度用量",
},
auth: "console",
usageArgs: "[flags]",
flags: {
type: {
type: "string",
valueHint: "<type>",
choices: ["official_tool", "infrastructure"] as const,
description: {
"en-US": "Filter harness list by type: official_tool, infrastructure",
"zh-CN": "按类型筛选 harness 列表official_tool、infrastructure",
},
},
},
exampleArgs: ["", "--type official_tool", "--output json"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const benefitsData = flags.type ? { type: flags.type } : {};
const equityData = { queryTokenPlanEquityInfoRequest: { requestId: randomUUID() } };
if (settings.dryRun) {
emitResult(
{
requests: [
{ api: HARNESS_LIST_API, data: benefitsData },
{ api: EQUITY_INFO_API, data: equityData },
],
},
format,
);
return;
}
const [benefitsResult, equityResult] = await Promise.all([
ctx.client.console(HARNESS_LIST_API, benefitsData),
ctx.client.console(EQUITY_INFO_API, equityData),
]);
const rows = buildRows(
readArray<HarnessBenefitItem>(benefitsResult, "items"),
readArray<TokenPlanEquityInfo>(equityResult, "tokenPlanEquityInfos"),
);
if (format === "json") {
emitResult({ generatedAt: Date.now(), items: rows }, format);
return;
}
if (rows.length === 0) {
process.stdout.write("No harness with entitlement quota found.\n");
return;
}
printQuotaBox("Token Plan Harness Quota", rows.map(toSection), Date.now());
},
});
@@ -65,3 +65,25 @@ export interface AddOrganizationMemberResponse {
SeatAssigned?: boolean;
};
}
// Console gateway payloads keep the server's camelCase keys, unlike the
// PascalCase OpenAPI shapes above.
export interface HarnessBenefitItem {
type?: string;
hasResourcePack?: boolean;
planCodes?: string[];
title?: string;
quotaUnit?: string;
priceInfo?: { unit?: string } | null;
}
export interface TokenPlanEquityInfo {
instanceId?: string;
templateCode?: string;
equityType?: string;
totalQuota?: number;
availableQuota?: number;
instanceStartTime?: number;
instanceEndTime?: number;
}
+1 -1
View File
@@ -197,7 +197,7 @@ export default defineCommand({
// --- Build request body ---
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const body: DashScopeVideoEditRequest = {
model,
@@ -208,7 +208,7 @@ export default defineCommand({
resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model);
}
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const body: DashScopeVideoRequest = {
+1 -1
View File
@@ -249,7 +249,7 @@ export default defineCommand({
// --- Build request body ---
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const body: DashScopeVideoRefRequest = {
model,
+25
View File
@@ -73,6 +73,11 @@ export { default as mcpTools } from "./commands/mcp/tools.ts";
export { default as searchWeb } from "./commands/search/web.ts";
export { default as speechSynthesize } from "./commands/speech/synthesize.ts";
export { default as speechRecognize } from "./commands/speech/recognize.ts";
export { default as speechVocabularyCreate } from "./commands/speech/vocabulary/create.ts";
export { default as speechVocabularyList } from "./commands/speech/vocabulary/list.ts";
export { default as speechVocabularyGet } from "./commands/speech/vocabulary/get.ts";
export { default as speechVocabularyUpdate } from "./commands/speech/vocabulary/update.ts";
export { default as speechVocabularyDelete } from "./commands/speech/vocabulary/delete.ts";
export { default as fileUpload } from "./commands/file/upload.ts";
export { default as consoleCall } from "./commands/console/call.ts";
export { default as usageFree } from "./commands/usage/free.ts";
@@ -132,6 +137,7 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as tokenPlanHarnessQuota } from "./commands/token-plan/harness-quota.ts";
export { default as managedAgentInit } from "./commands/managed-agent/init.ts";
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
@@ -223,3 +229,22 @@ export { default as skillUpdate } from "./commands/skill/update.ts";
export { default as skillRemove } from "./commands/skill/remove.ts";
export { default as skillList } from "./commands/skill/list.ts";
export { default as skillInit } from "./commands/skill/init.ts";
export { sandboxOfficialImages } from "./commands/sandbox/images.ts";
export { sandboxFileUpload } from "./commands/sandbox/file.ts";
export {
sandboxConnect,
sandboxCreate,
sandboxDelete,
sandboxGet,
sandboxList,
sandboxPause,
sandboxResume,
} from "./commands/sandbox/instance.ts";
export {
sandboxTemplateBuildStatus,
sandboxTemplateCreate,
sandboxTemplateDelete,
sandboxTemplateGet,
sandboxTemplateList,
sandboxTemplateUpdate,
} from "./commands/sandbox/template.ts";
@@ -33,3 +33,9 @@ test("default-speech-recognition-model alias accepts an ASR model ID", () => {
"qwen-audio-3.0-asr-flash",
);
});
test("watermark config accepts only boolean text and stores a boolean", () => {
expect(validateAndCoerce("watermark", "false")).toBe(false);
expect(validateAndCoerce("watermark", "TRUE")).toBe(true);
expect(() => validateAndCoerce("watermark", "yes")).toThrow(/true or false/i);
});
@@ -200,8 +200,10 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
expect(res.json.keys).toContain("console_site");
expect(res.json.keys).toContain("telemetry");
expect(res.json.keys).toContain("default_speech_recognition_model");
expect(res.json.keys).toContain("watermark");
expect(res.json.enums.console_site).toEqual(["domestic", "international"]);
expect(res.json.booleanKeys).toContain("telemetry");
expect(res.json.booleanKeys).toContain("watermark");
// Default field hints are surfaced as prefilled values in the UI.
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-3.0");
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max");
@@ -64,10 +64,53 @@ describe("e2e: config", () => {
config_file?: string;
base_url?: string;
timeout?: number;
watermark?: boolean;
}>(stdout);
expect(data.config_file).toBeDefined();
expect(data.base_url).toBeDefined();
expect(data.timeout).toBeDefined();
expect(data.watermark).toBe(true);
});
test("config set 将 watermark 作为 boolean 写入并由 config show 读回", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-watermark-"));
try {
const env = { BAILIAN_CONFIG_DIR: configDir };
const setResult = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"set",
"--config",
"media",
"--key",
"watermark",
"--value",
"false",
"--output",
"json",
],
env,
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
expect(parseStdoutJson<{ watermark?: boolean }>(setResult.stdout).watermark).toBe(false);
const persisted = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
Record<string, unknown>
>;
expect(persisted.media?.watermark).toBe(false);
const showResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "show", "--config", "media", "--output", "json"],
env,
);
expect(showResult.exitCode, showResult.stderr).toBe(0);
expect(parseStdoutJson<{ watermark?: boolean }>(showResult.stdout).watermark).toBe(false);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config show --output text", async () => {
@@ -240,4 +240,75 @@ describe("e2e: pipeline", () => {
expect(stdout).toBe("");
expect(stderr).toMatch(/--events must be one of: jsonl/i);
});
test("pipeline image/generate dry-run 继承 Profile watermark=false", async () => {
const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-"));
const workflowPath = join(configDir, "image-generate.json");
try {
await writeFile(
join(configDir, "config.json"),
JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n",
);
await writeFile(
workflowPath,
JSON.stringify({
version: "workflow/v1",
steps: [{ id: "gen", type: "image/generate", input: { prompt: "A cat" } }],
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
PIPELINE_ROUTES,
["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" },
);
expect(exitCode, stderr).toBe(0);
const report = parseStdoutJson<{
status?: string;
steps?: Array<{ type?: string; input?: { watermark?: boolean; prompt?: string } }>;
}>(stdout);
expect(report.status).toBe("planned");
expect(report.steps?.[0]).toMatchObject({
type: "image/generate",
input: { prompt: "A cat", watermark: false },
});
} finally {
await rm(configDir, { recursive: true, force: true });
}
});
test("pipeline 步骤显式 watermark=true 覆盖 Profile false", async () => {
const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-ov-"));
const workflowPath = join(configDir, "image-generate.json");
try {
await writeFile(
join(configDir, "config.json"),
JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n",
);
await writeFile(
workflowPath,
JSON.stringify({
version: "workflow/v1",
steps: [
{
id: "gen",
type: "image/generate",
input: { prompt: "A cat", watermark: true },
},
],
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
PIPELINE_ROUTES,
["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" },
);
expect(exitCode, stderr).toBe(0);
const report = parseStdoutJson<{
steps?: Array<{ input?: { watermark?: boolean } }>;
}>(stdout);
expect(report.steps?.[0]?.input?.watermark).toBe(true);
} finally {
await rm(configDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,318 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { SANDBOX_ROUTES } from "./topic-routes.ts";
const ROUTES = { ...SANDBOX_ROUTES, "auth login": "authLogin" } as const;
const tempDirectories: string[] = [];
const servers: Server[] = [];
const API_PATH = "/api/v1/agentstudio/sandbox";
const PROFILE_CONFIG = {
api_key: "sk-default-test",
base_url: "https://default.example.test",
active_config: "sandbox-test",
"sandbox-test": {
api_key: "sk-profile-test",
base_url: "https://profile.example.test",
},
};
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
),
);
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeConfigEnv(config: Record<string, unknown> = {}): NodeJS.ProcessEnv {
const directory = mkdtempSync(join(tmpdir(), "bl-sandbox-base-url-"));
tempDirectories.push(directory);
writeFileSync(join(directory, "config.json"), JSON.stringify(config));
return {
BAILIAN_CONFIG_DIR: directory,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
BAILIAN_WORKSPACE_ID: "",
};
}
describe("e2e: Sandbox shared base URL resolution", () => {
test.each([
{
name: "flag overrides env and profile, normalizing paths and preserving the port",
args: ["--base-url", "https://flag.example.test:8443/api/v1/agentstudio/?ignored=1#fragment"],
envBaseUrl: "https://env.example.test",
config: PROFILE_CONFIG,
expectedOrigin: "https://flag.example.test:8443",
},
{
name: "env overrides the active profile",
args: [],
envBaseUrl: "https://env.example.test/compatible-mode/v1/",
config: PROFILE_CONFIG,
expectedOrigin: "https://env.example.test",
},
{
name: "the active profile supplies its configured origin",
args: [],
envBaseUrl: "",
config: PROFILE_CONFIG,
expectedOrigin: "https://profile.example.test",
},
{
name: "an explicit profile selection uses that profile",
args: ["--config", "default"],
envBaseUrl: "",
config: PROFILE_CONFIG,
expectedOrigin: "https://default.example.test",
},
{
name: "an explicit default model origin still overrides workspace inference",
args: ["--base-url", "https://dashscope.aliyuncs.com", "--workspace-id", "unused"],
envBaseUrl: "",
config: {},
expectedOrigin: "https://dashscope.aliyuncs.com",
},
{
name: "without any base URL the workspace endpoint is preserved",
args: ["--workspace-id", "ws-test"],
envBaseUrl: "",
config: {},
expectedOrigin: "https://ws-test.cn-beijing.maas.aliyuncs.com",
},
])("$name", async ({ args, envBaseUrl, config, expectedOrigin }) => {
const result = await runCommandE2e(
ROUTES,
["sandbox", "create", "--template-id", "tpl-test", ...args, "--dry-run", "--output", "json"],
{ ...makeConfigEnv(config), DASHSCOPE_BASE_URL: envBaseUrl },
);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toMatchObject({
endpoint: `${expectedOrigin}${API_PATH}/sandboxes`,
});
});
test("an unconfigured URL still requires a workspace, while malformed URLs fail validation", async () => {
const env = makeConfigEnv();
for (const args of [[], ["--base-url", "invalid"], ["--base-url", "file:///tmp/gateway"]]) {
const result = await runCommandE2e(
ROUTES,
[
"sandbox",
"create",
"--template-id",
"tpl-test",
...args,
"--dry-run",
"--output",
"json",
],
env,
);
expect(result.exitCode).toBe(2);
expect(JSON.parse(result.stderr).error.message).toMatch(
/Workspace ID|Invalid model base URL/,
);
}
});
test("auth login persists a base URL that Sandbox uses without a workspace flag", async () => {
const env = makeConfigEnv();
const login = await runCommandE2e(
ROUTES,
[
"auth",
"login",
"--config",
"sandbox-test",
"--api-key",
"sk-login-test",
"--base-url",
"https://login.example.test/api/v1/agentstudio/sandbox/",
],
env,
);
expect(login.exitCode, login.stderr).toBe(0);
const stored = JSON.parse(readFileSync(join(env.BAILIAN_CONFIG_DIR!, "config.json"), "utf8"));
expect(stored.active_config).toBe("sandbox-test");
expect(stored["sandbox-test"].base_url).toBe("https://login.example.test");
const result = await runCommandE2e(
ROUTES,
["sandbox", "pause", "--sandbox-id", "sbx-test", "--dry-run", "--output", "json"],
env,
);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toMatchObject({
endpoint: `https://login.example.test${API_PATH}/sandboxes/sbx-test/pause`,
});
});
});
describe("e2e: Sandbox custom gateway transport", () => {
test.each([
{
action: "create",
args: ["--name", "python", "--cpu-count", "1", "--memory-mb", "2048"],
method: "POST",
suffix: "/v3/templates",
},
{
action: "update",
args: ["--template-id", "template/a", "--description", "updated"],
method: "PUT",
suffix: "/templates/template%2Fa",
},
])(
"template $action and build polling use the same gateway and Bearer key",
async ({ action, args, method, suffix }) => {
const received: {
method?: string;
path?: string;
authorization?: string;
e2bKey?: string | string[];
}[] = [];
const server = createServer((request, response) => {
request.resume();
received.push({
method: request.method,
path: request.url,
authorization: request.headers.authorization,
e2bKey: request.headers["x-api-key"],
});
response.setHeader("content-type", "application/json");
response.end(
JSON.stringify({
templateID: "template/a",
buildID: "build a",
status: request.method === "GET" ? "ready" : "building",
}),
);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
servers.push(server);
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected a local TCP server.");
const origin = `http://127.0.0.1:${address.port}`;
const result = await runCommandE2e(
ROUTES,
["sandbox", "template", action, ...args, "--poll-interval", "1", "--output", "json"],
makeConfigEnv({ api_key: "sk-gateway-test", base_url: origin }),
);
expect(result.exitCode, result.stderr).toBe(0);
expect(received).toEqual([
{
method,
path: `${API_PATH}${suffix}`,
authorization: "Bearer sk-gateway-test",
e2bKey: undefined,
},
{
method: "GET",
path: `${API_PATH}/templates/template%2Fa/builds/build%20a/status`,
authorization: "Bearer sk-gateway-test",
e2bKey: undefined,
},
]);
expect(parseStdoutJson(result.stdout)).toMatchObject({ build: { status: "ready" } });
},
);
});
describe("e2e: Sandbox submitted build recovery", () => {
test.each([
{ action: "create", output: "json", failure: "timeout", exitCode: 5 },
{ action: "update", output: "text", failure: "service", exitCode: 1 },
{ action: "create", output: "json", failure: "network", exitCode: 6 },
])(
"$action retains IDs after a $failure in $output output",
async ({ action, output, failure, exitCode }) => {
let submissionCount = 0;
const server = createServer((request, response) => {
request.resume();
response.setHeader("content-type", "application/json");
if (request.method !== "GET") {
submissionCount += 1;
response.end(
JSON.stringify({
templateID: "template-recovery",
buildID: "build-recovery",
buildStatus: "building",
}),
);
} else if (failure === "network") {
request.socket.destroy();
} else if (failure === "service") {
response.writeHead(503);
response.end(
JSON.stringify({
code: 100005,
message: "original service failure",
requestID: "request-recovery",
}),
);
} else {
response.end(JSON.stringify({ status: "building" }));
}
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
servers.push(server);
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected a local TCP server.");
const args =
action === "create"
? ["--name", "recovery", "--cpu-count", "1", "--memory-mb", "2048"]
: ["--template-id", "template-recovery", "--description", "updated"];
const result = await runCommandE2e(
ROUTES,
["sandbox", "template", action, ...args, "--timeout", "1", "--quiet", "--output", output],
makeConfigEnv({
api_key: "sk-recovery-test",
base_url: `http://127.0.0.1:${address.port}`,
}),
);
expect(result.exitCode, result.stderr).toBe(exitCode);
expect(result.stdout).toBe("");
expect(submissionCount).toBe(1);
expect(result.stderr).toContain("templateID=template-recovery, buildID=build-recovery");
expect(result.stderr).toContain("sandbox template build-status");
if (output === "json") {
const diagnostics = result.stderr
.trim()
.split(/\n\s*\n/)
.map((diagnostic) => JSON.parse(diagnostic));
expect(diagnostics.at(-1)).toMatchObject({ error: { code: exitCode } });
if (failure === "timeout") {
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].error.message).toBe("Template build polling timed out.");
} else {
expect(diagnostics[0]).toMatchObject({
templateID: "template-recovery",
buildID: "build-recovery",
});
expect(diagnostics.at(-1).error.message).toContain("Network request failed");
}
} else {
expect(result.stderr).toContain("original service failure");
expect(result.stderr).toContain("HTTP 503 (100005)");
expect(result.stderr).toContain("request-recovery");
}
},
);
});
@@ -0,0 +1,312 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type IncomingHttpHeaders, type Server } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e, runCommandHelp } from "./helpers.ts";
import { SANDBOX_ROUTES } from "./topic-routes.ts";
const directories: string[] = [];
const servers: Server[] = [];
const FILE_PATH = "/api/v1/agentstudio/files";
const UPLOAD_COMMAND = ["sandbox", "file", "upload"];
const RESPONSE = {
id: "file_sandbox_test",
filename: "config.json",
type: "file",
status: "available",
mime_type: "application/json",
size_bytes: 12,
requestId: "request-test",
};
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
),
);
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function setup(config: Record<string, unknown> = {}) {
const directory = mkdtempSync(join(tmpdir(), "bl-sandbox-file-"));
directories.push(directory);
writeFileSync(join(directory, "config.json"), JSON.stringify(config));
return {
directory,
env: {
BAILIAN_CONFIG_DIR: directory,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
BAILIAN_WORKSPACE_ID: "",
},
};
}
async function gateway(responseBody: unknown = RESPONSE, status = 200) {
const received: {
method?: string;
path?: string;
headers: IncomingHttpHeaders;
body: Buffer;
}[] = [];
const server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
received.push({
method: request.method,
path: request.url,
headers: request.headers,
body: Buffer.concat(chunks),
});
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(responseBody));
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
servers.push(server);
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected a local TCP server.");
return { origin: `http://127.0.0.1:${address.port}`, received };
}
describe("e2e: Sandbox template file upload", () => {
test("help documents upload auth, source, mount mapping, and the runtime boundary", async () => {
const result = await runCommandHelp(SANDBOX_ROUTES, [...UPLOAD_COMMAND, "--help"]);
expect(result.exitCode).toBe(0);
for (const text of [
"bl sandbox file upload",
"--path",
"--filename",
"--mime-type",
"--base-url",
"--workspace-id",
"source=sandbox_template",
FILE_PATH,
"mntConfig[].originFileId",
"status=checking",
"running instance",
]) {
expect(result.stderr).toContain(text);
}
expect(result.stderr).not.toContain("--console-site");
expect(result.stderr).not.toContain("--yes");
});
test("requires --path and does not expose a source override", async () => {
const { env } = setup();
for (const args of [[], ["--path", "unused", "--source", "other"]]) {
const result = await runCommandE2e(
SANDBOX_ROUTES,
[...UPLOAD_COMMAND, ...args, "--output", "json"],
env,
);
expect(result.exitCode).toBe(2);
}
});
test.each(["flag", "env", "profile", "workspace"])(
"%s determines the upload origin; dry-run does not read the local file or need credentials",
async (source) => {
const selectedOrigin = `https://${source}.example.test:8443`;
const { directory, env } = setup(
source === "profile"
? { active_config: "sandbox", sandbox: { base_url: selectedOrigin } }
: {},
);
const path = join(directory, "does-not-exist.json");
const args =
source === "flag"
? ["--base-url", `${selectedOrigin}/api/v1/agentstudio/sandbox?ignored=1#fragment`]
: source === "workspace"
? ["--workspace-id", "ws-files"]
: [];
const result = await runCommandE2e(
SANDBOX_ROUTES,
[...UPLOAD_COMMAND, "--path", path, ...args, "--dry-run", "--output", "json"],
{ ...env, DASHSCOPE_BASE_URL: source === "env" ? selectedOrigin : "" },
);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toEqual({
method: "POST",
endpoint:
(source === "workspace"
? "https://ws-files.cn-beijing.maas.aliyuncs.com"
: selectedOrigin) + FILE_PATH,
request: {
source: "sandbox_template",
file: { path, filename: "does-not-exist.json", mimeType: "application/octet-stream" },
},
});
},
);
test("uploads exact multipart bytes and source with saved Bearer credentials, then reuses the ID in a template mount", async () => {
const { origin, received } = await gateway();
const { directory, env } = setup({
active_config: "sandbox",
sandbox: { api_key: "sk-upload-test-only", base_url: origin },
});
const path = join(directory, "local data.bin");
const content = Buffer.from([0, 1, 255, 13, 10, 65]);
writeFileSync(path, content);
const result = await runCommandE2e(
SANDBOX_ROUTES,
[
...UPLOAD_COMMAND,
"--path",
path,
"--filename",
"config.json",
"--mime-type",
"application/json",
"--output",
"json",
"--verbose",
],
env,
);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toEqual(RESPONSE);
expect(result.stdout + result.stderr).not.toContain("sk-upload-test-only");
expect(received).toHaveLength(1);
const request = received[0];
expect(request.method).toBe("POST");
expect(request.path).toBe(FILE_PATH);
expect(request.headers.authorization).toBe("Bearer sk-upload-test-only");
expect(request.headers["x-api-key"]).toBeUndefined();
expect(request.headers["content-type"]).toMatch(/^multipart\/form-data; boundary=/);
const form = await new Request(`${origin}${FILE_PATH}`, {
method: "POST",
headers: { "content-type": request.headers["content-type"]! },
body: new Uint8Array(request.body),
}).formData();
expect([...form.keys()].sort()).toEqual(["file", "source"]);
expect(form.get("source")).toBe("sandbox_template");
const file = form.get("file");
if (!file || typeof file === "string") throw new Error("Expected a multipart file.");
expect(file.name).toBe("config.json");
expect(file.type).toBe("application/json");
expect(Buffer.from(await file.arrayBuffer())).toEqual(content);
const mount = {
originFileId: RESPONSE.id,
originFileName: RESPONSE.filename,
mountPath: "/home/user/config.json",
};
const template = await runCommandE2e(
SANDBOX_ROUTES,
[
"sandbox",
"template",
"create",
"--name",
"files",
"--cpu-count",
"1",
"--memory-mb",
"2048",
"--body",
JSON.stringify({ mntConfig: [mount] }),
"--dry-run",
"--output",
"json",
],
env,
);
expect(template.exitCode, template.stderr).toBe(0);
expect(parseStdoutJson(template.stdout)).toMatchObject({ request: { mntConfig: [mount] } });
expect(received).toHaveLength(1);
});
test.each(["json", "text", "quiet"])(
"%s output returns checking status or the bare ID without polling or creating a template",
async (output) => {
const pending = { ...RESPONSE, status: "checking" };
const { origin, received } = await gateway(pending);
const { directory, env } = setup({ api_key: "sk-upload-test-only", base_url: origin });
const path = join(directory, "notes.txt");
writeFileSync(path, "Only synthetic test data.");
const result = await runCommandE2e(
SANDBOX_ROUTES,
[
...UPLOAD_COMMAND,
"--path",
path,
...(output === "quiet" ? ["--quiet"] : ["--output", output]),
],
env,
);
expect(result.exitCode, result.stderr).toBe(0);
if (output === "quiet") expect(result.stdout.trim()).toBe(RESPONSE.id);
else {
expect(result.stdout).toContain("checking");
expect(result.stdout).toContain(RESPONSE.id);
}
expect(received).toHaveLength(1);
},
);
test("missing files fail locally without an upload request", async () => {
const { origin, received } = await gateway();
const { directory, env } = setup({ api_key: "sk-upload-test-only", base_url: origin });
const result = await runCommandE2e(
SANDBOX_ROUTES,
[...UPLOAD_COMMAND, "--path", join(directory, "missing.txt"), "--output", "json"],
env,
);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("ENOENT");
expect(received).toHaveLength(0);
});
test("server errors are passed through without translating them", async () => {
const { origin, received } = await gateway(
{ code: "InvalidSource", message: "source rejected by server", request_id: "req-error" },
400,
);
const { directory, env } = setup({ api_key: "sk-upload-test-only", base_url: origin });
const path = join(directory, "notes.txt");
writeFileSync(path, "test");
const result = await runCommandE2e(
SANDBOX_ROUTES,
[...UPLOAD_COMMAND, "--path", path, "--output", "json"],
env,
);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toMatchObject({
error: {
message: "source rejected by server",
http_status: 400,
api_code: "InvalidSource",
request_id: "req-error",
},
});
expect(received).toHaveLength(1);
});
test("a response without an ID is not reported as a successful upload", async () => {
const { origin } = await gateway({ status: "checking" });
const { directory, env } = setup({ api_key: "sk-upload-test-only", base_url: origin });
const path = join(directory, "notes.txt");
writeFileSync(path, "test");
const result = await runCommandE2e(
SANDBOX_ROUTES,
[...UPLOAD_COMMAND, "--path", path, "--quiet", "--output", "json"],
env,
);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("File ID");
});
});
@@ -0,0 +1,334 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e as runBaseCommandE2e, runCommandHelp } from "./helpers.ts";
import { SANDBOX_ROUTES } from "./topic-routes.ts";
const AUTH_ARGS = ["--api-key", "sk-sandbox-e2e", "--workspace-id", "ws-e2e"];
let configDirectory: string;
beforeEach(() => {
configDirectory = mkdtempSync(join(tmpdir(), "bl-sandbox-e2e-"));
writeFileSync(join(configDirectory, "config.json"), "{}");
});
afterEach(() => {
rmSync(configDirectory, { recursive: true, force: true });
});
function runCommandE2e(routes: typeof SANDBOX_ROUTES, args: string[]) {
return runBaseCommandE2e(routes, args, {
BAILIAN_CONFIG_DIR: configDirectory,
DASHSCOPE_BASE_URL: "",
DASHSCOPE_API_KEY: "",
BAILIAN_WORKSPACE_ID: "",
});
}
describe("e2e: Sandbox command discovery", () => {
test("built-in images are discoverable without API Key authentication", async () => {
const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [
"sandbox",
"official-images",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("Usage: bl sandbox official-images");
expect(stderr).not.toContain("Authentication: API Key");
expect(stderr).toContain("code-interpreter");
expect(stderr).toContain("all-in-one");
});
test.each([
["sandbox", "create"],
["sandbox", "list"],
["sandbox", "get"],
["sandbox", "connect"],
["sandbox", "pause"],
["sandbox", "resume"],
["sandbox", "delete"],
["sandbox", "template", "create"],
["sandbox", "template", "list"],
["sandbox", "template", "get"],
["sandbox", "template", "update"],
["sandbox", "template", "build-status"],
["sandbox", "template", "delete"],
])("%s help resolves", async (...commandPath) => {
const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [...commandPath, "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain(`Usage: bl ${commandPath.join(" ")}`);
expect(stderr).toContain("Authentication: API Key");
});
test("instance timeout is separate from the global request/poll timeout", async () => {
const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [
"sandbox",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--instance-timeout <seconds>/);
expect(stderr).toMatch(/--timeout <seconds>/);
expect(stderr).toMatch(/no E2B key is sent/i);
});
test("template create exposes max-running-time and default polling controls", async () => {
const { stderr, exitCode } = await runCommandHelp(SANDBOX_ROUTES, [
"sandbox",
"template",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--max-running-time <seconds>/);
expect(stderr).toMatch(/--image <[^>]*code-interpreter[^>]*browser[^>]*all-in-one[^>]*>/);
expect(stderr).toContain("browser:v0.0.44");
expect(stderr).toMatch(/--async/);
expect(stderr).toMatch(/--poll-interval <seconds>/);
expect(stderr).toMatch(/waits for build status ready/i);
});
});
describe("e2e: Sandbox offline validation and dry-run", () => {
test("image catalog is available offline in JSON, text, and quiet output", async () => {
const jsonResult = await runCommandE2e(SANDBOX_ROUTES, [
"sandbox",
"official-images",
"--output",
"json",
]);
expect(jsonResult.exitCode, jsonResult.stderr).toBe(0);
const images = JSON.parse(jsonResult.stdout) as {
id: string;
imageName: string;
imageUrl: string;
}[];
expect(images.map((image) => image.id)).toEqual(["code-interpreter", "browser", "all-in-one"]);
expect(images[1]).toMatchObject({
imageName: "浏览器",
imageUrl: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44",
});
const textResult = await runCommandE2e(SANDBOX_ROUTES, [
"sandbox",
"official-images",
"--output",
"text",
]);
expect(textResult.exitCode, textResult.stderr).toBe(0);
expect(textResult.stdout).toContain("代码解释器");
expect(textResult.stdout).toContain("browser:v0.0.44");
const quietResult = await runCommandE2e(SANDBOX_ROUTES, [
"sandbox",
"official-images",
"--quiet",
]);
expect(quietResult.exitCode, quietResult.stderr).toBe(0);
expect(quietResult.stdout.trim().split("\n")).toEqual([
"code-interpreter",
"browser",
"all-in-one",
]);
});
test.each([
{
operation: "create",
args: ["--name", "test", "--cpu-count", "1", "--memory-mb", "2048"],
selector: "browser",
method: "POST",
},
{
operation: "update",
args: ["--template-id", "template-test"],
selector: "浏览器",
method: "PUT",
},
])(
"template $operation resolves --image before dry-run output",
async ({ operation, args, selector, method }) => {
const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [
"sandbox",
"template",
operation,
...args,
"--image",
selector,
...AUTH_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
expect(parseStdoutJson(stdout)).toMatchObject({
method,
request: {
fromImage: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44",
imageName: "浏览器",
},
});
},
);
test("unknown image selector fails before a request", async () => {
const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [
"sandbox",
"template",
"create",
"--name",
"test",
"--cpu-count",
"1",
"--memory-mb",
"2048",
"--image",
"unknown",
...AUTH_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode).toBe(2);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 2, message: expect.stringContaining("--image") },
});
});
test("create requires a template in flags or body", async () => {
const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [
"sandbox",
"create",
...AUTH_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode).toBe(2);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 2, message: expect.stringMatching(/template-id|templateID/) },
});
});
test("create body flags win and request environment values are redacted", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [
"sandbox",
"create",
"--body",
'{"templateID":"template-body","timeout":600,"envVars":{"BODY":"secret"}}',
"--template-id",
"template-flag",
"--instance-timeout",
"900",
"--env",
"FLAG=secret",
...AUTH_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
expect(parseStdoutJson(stdout)).toMatchObject({
method: "POST",
endpoint: "https://ws-e2e.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes",
request: {
templateID: "template-flag",
timeout: 900,
envVars: { BODY: "[REDACTED]", FLAG: "[REDACTED]" },
},
});
});
test.each([
{
name: "connect instance",
args: ["sandbox", "connect", "--sandbox-id", "sandbox-test", "--instance-timeout", "900"],
method: "POST",
suffix: "/sandboxes/sandbox-test/connect",
},
{
name: "pause instance",
args: ["sandbox", "pause", "--sandbox-id", "sandbox-test"],
method: "POST",
suffix: "/sandboxes/sandbox-test/pause",
},
{
name: "resume instance",
args: ["sandbox", "resume", "--sandbox-id", "sandbox-test"],
method: "POST",
suffix: "/sandboxes/sandbox-test/resume",
},
{
name: "delete instance",
args: ["sandbox", "delete", "--sandbox-id", "sandbox-test"],
method: "DELETE",
suffix: "/sandboxes/sandbox-test",
},
{
name: "create template",
args: [
"sandbox",
"template",
"create",
"--name",
"python",
"--cpu-count",
"1",
"--memory-mb",
"2048",
],
method: "POST",
suffix: "/v3/templates",
},
{
name: "update template",
args: [
"sandbox",
"template",
"update",
"--template-id",
"template-test",
"--description",
"updated",
],
method: "PUT",
suffix: "/templates/template-test",
},
{
name: "delete template",
args: ["sandbox", "template", "delete", "--template-id", "template-test"],
method: "DELETE",
suffix: "/templates/template-test",
},
])("$name dry-run stays offline", async ({ args, method, suffix }) => {
const { stdout, stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [
...args,
...AUTH_ARGS,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const result = parseStdoutJson<{ method: string; endpoint: string }>(stdout);
expect(result.method).toBe(method);
expect(result.endpoint).toBe(
`https://ws-e2e.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox${suffix}`,
);
});
});
describe("e2e: Sandbox high-risk confirmation", () => {
test.each([
["sandbox", "delete", "--sandbox-id", "sandbox-test"],
["sandbox", "template", "delete", "--template-id", "template-test"],
])("%s requires --yes before a remote delete", async (...commandArgs) => {
const { stderr, exitCode } = await runCommandE2e(SANDBOX_ROUTES, [
...commandArgs,
...AUTH_ARGS,
"--output",
"json",
]);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
});
@@ -40,11 +40,18 @@ describe("e2e: speech recognize", () => {
language_hints?: string[];
language?: string;
vocabulary_id?: string;
vocabulary?: Record<string, number>;
};
input?: {
file_url?: string;
file_urls?: string[];
messages?: Array<{ content?: Array<{ type?: string }> }>;
context?: Array<{
role?: string;
content?: Array<{ type?: string; text?: string }>;
}>;
messages?: Array<{
content?: Array<{ type?: string; text?: string; input_audio?: { data?: string } }>;
}>;
};
};
}>(stdout);
@@ -120,6 +127,116 @@ describe("e2e: speech recognize", () => {
expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize async dry-run 注入 input.context 与 parameters.vocabulary", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/audio.mp3",
"--vocabulary",
'{"奋斗者":4,"鲸落":4}',
"--context",
"奋斗者号 鲸落 深海勇士",
]);
expect(body.mode).toBe("async");
expect(body.request?.parameters?.vocabulary).toEqual({ 奋斗者: 4, 鲸落: 4 });
expect(body.request?.input?.context).toEqual([
{
role: "user",
content: [{ type: "input_text", text: "奋斗者号 鲸落 深海勇士" }],
},
]);
});
test("speech recognize sync input-audio dry-run 将 context 前置且 input_audio 在最后", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://example.com/audio.wav",
"--vocabulary",
'{"奋斗者":4}',
"--context",
"奋斗者号",
]);
expect(body.mode).toBe("sync");
expect(body.request?.parameters?.vocabulary).toEqual({ 奋斗者: 4 });
const messages = body.request?.input?.messages ?? [];
expect(messages).toHaveLength(2);
expect(messages[0]?.content?.[0]).toMatchObject({ type: "input_text", text: "奋斗者号" });
expect(messages[1]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize 非法 --vocabulary JSON 返回用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--vocabulary",
"{bad json",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|--vocabulary/i);
});
test("speech recognize 空 --vocabulary 返回用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--vocabulary",
"",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|--vocabulary/i);
});
test("speech recognize qwen3 sync 拒绝 --vocabulary", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash",
"--url",
"https://example.com/a.wav",
"--vocabulary",
'{"奋斗者":4}',
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--vocabulary|does not support/i);
expect(stderr).toMatch(/qwen-audio-3\.0-asr-flash|vocabulary\/context/i);
});
test("speech recognize sync Flash 拒绝 --diarization 时提示 async filetrans", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"fun-asr-flash",
"--url",
"https://example.com/a.wav",
"--diarization",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--diarization|does not support/i);
expect(stderr).toMatch(/async filetrans|fun-asr/i);
expect(stderr).not.toMatch(/vocabulary\/context/i);
});
test("speech recognize qwen3 filetrans dry-run 使用 file_url 与 language", async () => {
const body = await runRecognizeDryRun([
"--model",
@@ -366,5 +483,88 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
const raw = readFileSync(asrJson, "utf8");
expect(raw.length).toBeGreaterThan(2);
}, 300_000);
test("【qwen-audio】synthesize → recognize 即时热词/上下文", async () => {
// 生造专名:无热词时常被听错;带 --vocabulary/--context 后应能正确召回。
const hotwordScript =
"请把录音同步到听悟匣,并启动澜舟芯做摘要。听悟匣负责转写,澜舟芯负责归档。最后确认玄甲协议是否已开启。";
const hotwords = ["听悟匣", "澜舟芯", "玄甲协议"] as const;
const vocabularyJson = '{"听悟匣":4,"澜舟芯":4,"玄甲协议":4}';
const contextText = "听悟匣 澜舟芯 玄甲协议";
const missingHotwords = (text: string): string[] => {
const normalized = text.replace(/\s+/g, "");
return hotwords.filter((word) => !normalized.includes(word));
};
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const outMp3 = join(outDir, "hotword-tts.mp3");
const syn = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--model",
"cosyvoice-v3-flash",
"--voice",
"longxiaochun_v3",
"--text",
hotwordScript,
"--out",
outMp3,
"--output",
"json",
]);
expect(syn.exitCode, syn.stderr).toBe(0);
const synBody = parseStdoutJson<{ audio_url?: string }>(syn.stdout);
const audioUrl = synBody.audio_url;
expect(audioUrl?.startsWith("http")).toBe(true);
const baselineOut = join(outDir, "asr-baseline.json");
const baseline = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
audioUrl!,
"--language",
"zh",
"--out",
baselineOut,
"--quiet",
]);
expect(baseline.exitCode, baseline.stderr).toBe(0);
writeFileSync(join(outDir, "asr-baseline.txt"), baseline.stdout);
const baselineMissing = missingHotwords(baseline.stdout);
// soft仅落盘对照不 fail无热词偶发也能认出专名
writeFileSync(
join(outDir, "asr-baseline-missing.txt"),
baselineMissing.length > 0 ? baselineMissing.join("\n") + "\n" : "(none)\n",
);
const hotOut = join(outDir, "asr-hot.json");
const hot = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
audioUrl!,
"--language",
"zh",
"--vocabulary",
vocabularyJson,
"--context",
contextText,
"--out",
hotOut,
"--quiet",
]);
expect(hot.exitCode, hot.stderr).toBe(0);
writeFileSync(join(outDir, "asr-hot.txt"), hot.stdout);
expect(
missingHotwords(hot.stdout),
`expected hotwords in ASR text, got: ${hot.stdout.trim()}`,
).toEqual([]);
}, 420_000);
},
);
@@ -0,0 +1,551 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vite-plus/test";
import {
e2eLabelFromMetaUrl,
isBailianE2EMediaEnabled,
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCommandHelp,
runCommandE2e,
} from "./helpers.ts";
import { SPEECH_ROUTES } from "./topic-routes.ts";
/**
* Speech vocabularyhelp / dry-run / CRUD E2E + DashScope
*/
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function makeTempJson(content: string): string {
const tempDir = mkdtempSync(join(tmpdir(), "bl-vocab-e2e-"));
tempDirs.push(tempDir);
const filePath = join(tempDir, "hotwords.json");
writeFileSync(filePath, content);
return filePath;
}
describe("e2e: speech vocabulary", () => {
test("speech vocabulary --help 列出 5 个子命令", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/create/i);
expect(stderr).toMatch(/list/i);
expect(stderr).toMatch(/get/i);
expect(stderr).toMatch(/update/i);
expect(stderr).toMatch(/delete/i);
});
test("create --help 展示关键 flags 与静默失效 / weight 50 文案", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model/i);
expect(stderr).toMatch(/--prefix/i);
expect(stderr).toMatch(/--words/i);
expect(stderr).toMatch(/--words-file/i);
expect(stderr).toMatch(/silently ignored|静默失效/i);
expect(stderr).toMatch(/50/);
});
test("delete --help 展示 Risk 与 --yes", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--yes/i);
expect(stderr).toMatch(/Risk|风险/i);
});
test("update --help 展示 Risk、replace 语义与 --yes", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--yes/i);
expect(stderr).toMatch(/Risk|风险/i);
expect(stderr).toMatch(/replaces|替换/i);
});
test("create 缺少 --model 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--model|Missing required/i);
});
test("create 缺少 --prefix 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--words",
'{"x":4}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prefix|Missing required/i);
});
test("create 两个词表 flag 都不传时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--words|--words-file/i);
});
test("create 同时传 --words 与 --words-file 时退出为用法错误 (2)", async () => {
const filePath = makeTempJson('{"x":4}');
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--words-file",
filePath,
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/either|--words|--words-file/i);
});
test("create 非法 JSON 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
"{bad json",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|JSON/i);
});
test("create 字符串权重时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":"4"}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/must be a number|number/i);
});
test("create 空词表 {} 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
"{}",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/at least one|hot word/i);
});
test("get 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required/i);
});
test("delete 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required/i);
});
test("create object 形态 --dry-run 输出 speech-biasing 信封", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"奋斗者":4,"鲸落":4}',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
model?: string;
input?: {
action?: string;
target_model?: string;
prefix?: string;
vocabulary?: Array<{ text?: string; weight?: number; lang?: string }>;
};
};
}>(stdout);
expect(body.request?.model).toBe("speech-biasing");
expect(body.request?.input?.action).toBe("create_vocabulary");
expect(body.request?.input?.target_model).toBe("fun-asr");
expect(body.request?.input?.prefix).toBe("demo");
expect(body.request?.input?.vocabulary).toEqual([
{ text: "奋斗者", weight: 4 },
{ text: "鲸落", weight: 4 },
]);
});
test("create array 形态 --dry-run 原样透传 lang", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'[{"text":"x","weight":4,"lang":"zh"}]',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number; lang?: string }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4, lang: "zh" }]);
});
test("create object + --lang --dry-run 下发到每一条", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--lang",
"zh",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number; lang?: string }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4, lang: "zh" }]);
});
test("create --words-file --dry-run 读取文件", async () => {
const filePath = makeTempJson('{"from-file":4}');
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words-file",
filePath,
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "from-file", weight: 4 }]);
});
test("list --page 2 --dry-run 将 page_index 转为 1", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"list",
"--page",
"2",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; page_index?: number } };
}>(stdout);
expect(body.request?.input?.action).toBe("list_vocabulary");
expect(body.request?.input?.page_index).toBe(1);
});
test("get --dry-run 使用 query_vocabulary", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--id",
"vocab-x",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; vocabulary_id?: string } };
}>(stdout);
expect(body.request?.input?.action).toBe("query_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
});
test("update --dry-run 无 --yes 也能预览", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--id",
"vocab-x",
"--words",
'{"x":4}',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: {
action?: string;
vocabulary_id?: string;
vocabulary?: Array<{ text?: string; weight?: number }>;
};
};
}>(stdout);
expect(body.request?.input?.action).toBe("update_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4 }]);
});
test("delete --dry-run 无 --yes 也能预览", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
"vocab-x",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; vocabulary_id?: string } };
}>(stdout);
expect(body.request?.input?.action).toBe("delete_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
});
});
describe("e2e: speech vocabulary high-risk confirmation", () => {
test("delete 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
"vocab-x",
"--api-key",
"e2e-dummy-key",
"--output",
"json",
]);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
test("update 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--id",
"vocab-x",
"--words",
'{"x":4}',
"--api-key",
"e2e-dummy-key",
"--output",
"json",
]);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: speech vocabularyDashScope 媒体)",
() => {
test("create → list/get → delete 完整链路", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
let vocabularyId = "";
try {
const created = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"blcli",
"--words",
'{"奋斗者":4}',
"--quiet",
]);
expect(created.exitCode, created.stderr).toBe(0);
vocabularyId = created.stdout.trim();
expect(vocabularyId.length).toBeGreaterThan(0);
writeFileSync(join(outDir, "vocabulary-id.txt"), vocabularyId + "\n");
const listed = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"list",
"--prefix",
"blcli",
"--output",
"json",
]);
expect(listed.exitCode, listed.stderr).toBe(0);
const listBody = parseStdoutJson<{
output?: { vocabulary_list?: Array<{ vocabulary_id?: string; status?: string }> };
}>(listed.stdout);
const listedItem = listBody.output?.vocabulary_list?.find(
(item) => item.vocabulary_id === vocabularyId,
);
expect(listedItem).toBeTruthy();
expect(listedItem?.status).toBe("OK");
const got = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--id",
vocabularyId,
"--output",
"json",
]);
expect(got.exitCode, got.stderr).toBe(0);
const getBody = parseStdoutJson<{
output?: { status?: string; target_model?: string };
}>(got.stdout);
expect(getBody.output?.status).toBe("OK");
expect(getBody.output?.target_model).toBe("fun-asr");
} finally {
if (vocabularyId) {
const deleted = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
vocabularyId,
"--yes",
"--quiet",
]);
expect(deleted.exitCode, deleted.stderr).toBe(0);
}
}
}, 120_000);
},
);
@@ -1,5 +1,12 @@
import { describe, expect, test } from "vite-plus/test";
import { makeE2eOutputDir, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts";
import {
isConsoleAuthFailure,
isConsoleE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCommandHelp,
runCommandE2e,
} from "./helpers.ts";
import { TOKEN_PLAN_ROUTES } from "./topic-routes.ts";
describe("e2e: token-plan", () => {
@@ -60,4 +67,92 @@ describe("e2e: token-plan", () => {
expect(stderr).toMatch(/ALIBABA_CLOUD_ACCESS_KEY_ID/);
expect(stderr).not.toMatch(/auth login --api-key/);
});
test("token-plan harness-quota help 展示 --type 与 console 鉴权域 flags", async () => {
const { stderr, exitCode } = await runCommandHelp(TOKEN_PLAN_ROUTES, [
"token-plan",
"harness-quota",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--type <official_tool\|infrastructure>/);
expect(stderr).toMatch(/--console-region/);
expect(stderr).not.toMatch(/--access-key-id/);
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: token-plan harness-quotaConsole", () => {
test("harness-quota --dry-run 输出两个网关请求计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(TOKEN_PLAN_ROUTES, [
"token-plan",
"harness-quota",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
requests?: Array<{ api?: string; data?: Record<string, unknown> }>;
}>(stdout);
expect(data.requests?.[0]?.api).toBe("zeldaEasy.broadscope-bailian.token-plan.detail");
expect(data.requests?.[1]?.api).toBe(
"zeldaEasy.bailian-commerce.tokenPlan.queryTokenPlanEquityInfo",
);
expect(data.requests?.[0]?.data).toEqual({});
});
test("harness-quota --type 透传给 harness 列表接口", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(TOKEN_PLAN_ROUTES, [
"token-plan",
"harness-quota",
"--type",
"official_tool",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ requests?: Array<{ data?: Record<string, unknown> }> }>(stdout);
expect(data.requests?.[0]?.data).toEqual({ type: "official_tool" });
});
test("harness-quota --output json 返回权益额度条目", async () => {
const result = await runCommandE2e(TOKEN_PLAN_ROUTES, [
"token-plan",
"harness-quota",
"--output",
"json",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
const data = parseStdoutJson<{
generatedAt?: number;
items?: Array<{
planCode?: string;
status?: string;
totalQuota?: number;
availableQuota?: number;
usedQuota?: number;
usedPercent?: number;
}>;
}>(result.stdout);
expect(Array.isArray(data.items)).toBe(true);
for (const item of data.items ?? []) {
expect(item.planCode).toBeTypeOf("string");
expect(["issued", "issuing"]).toContain(item.status);
const numbers = [item.totalQuota, item.availableQuota, item.usedQuota, item.usedPercent];
for (const value of numbers) {
if (value !== undefined) expect(value).toBeTypeOf("number");
}
}
});
test("harness-quota 默认渲染额度框或空态文案", async () => {
const result = await runCommandE2e(TOKEN_PLAN_ROUTES, ["token-plan", "harness-quota"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toMatch(
/Token Plan Harness Quota|No harness with entitlement quota found/,
);
});
});
@@ -72,6 +72,11 @@ export const VISION_ROUTES: E2eRouteExports = {
export const SPEECH_ROUTES: E2eRouteExports = {
"speech synthesize": "speechSynthesize",
"speech recognize": "speechRecognize",
"speech vocabulary create": "speechVocabularyCreate",
"speech vocabulary list": "speechVocabularyList",
"speech vocabulary get": "speechVocabularyGet",
"speech vocabulary update": "speechVocabularyUpdate",
"speech vocabulary delete": "speechVocabularyDelete",
};
export const MCP_ROUTES: E2eRouteExports = {
@@ -172,6 +177,7 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = {
"token-plan create-key": "tokenPlanCreateKey",
"token-plan assign-seats": "tokenPlanAssignSeats",
"token-plan add-member": "tokenPlanAddMember",
"token-plan harness-quota": "tokenPlanHarnessQuota",
};
export const SKILL_ROUTES: E2eRouteExports = {
@@ -182,6 +188,24 @@ export const SKILL_ROUTES: E2eRouteExports = {
"skill init": "skillInit",
};
export const SANDBOX_ROUTES: E2eRouteExports = {
"sandbox create": "sandboxCreate",
"sandbox official-images": "sandboxOfficialImages",
"sandbox file upload": "sandboxFileUpload",
"sandbox list": "sandboxList",
"sandbox get": "sandboxGet",
"sandbox connect": "sandboxConnect",
"sandbox pause": "sandboxPause",
"sandbox resume": "sandboxResume",
"sandbox delete": "sandboxDelete",
"sandbox template create": "sandboxTemplateCreate",
"sandbox template list": "sandboxTemplateList",
"sandbox template get": "sandboxTemplateGet",
"sandbox template update": "sandboxTemplateUpdate",
"sandbox template build-status": "sandboxTemplateBuildStatus",
"sandbox template delete": "sandboxTemplateDelete",
};
export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
"managed-agent init": "managedAgentInit",
"managed-agent validate": "managedAgentValidate",
@@ -0,0 +1,80 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { IMAGE_ROUTES, VIDEO_ROUTES, type E2eRouteExports } from "./topic-routes.ts";
interface WatermarkScenario {
name: string;
routes: E2eRouteExports;
args: string[];
}
const scenarios: WatermarkScenario[] = [
{
name: "image generate",
routes: IMAGE_ROUTES,
args: ["image", "generate", "--prompt", "A cat"],
},
{
name: "image edit",
routes: IMAGE_ROUTES,
args: [
"image",
"edit",
"--image",
"https://example.com/input.png",
"--prompt",
"Blue background",
],
},
{
name: "video generate",
routes: VIDEO_ROUTES,
args: ["video", "generate", "--prompt", "A cat waves"],
},
{
name: "video edit",
routes: VIDEO_ROUTES,
args: ["video", "edit", "--video", "https://example.com/input.mp4", "--prompt", "Warm colors"],
},
{
name: "video ref",
routes: VIDEO_ROUTES,
args: [
"video",
"ref",
"--image",
"https://example.com/person.png",
"--prompt",
"Image 1 waves",
],
},
];
describe("e2e: global watermark config", () => {
for (const scenario of scenarios) {
test(`${scenario.name} uses watermark=false from the selected Profile`, async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-watermark-profile-"));
try {
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({ media: { watermark: false } }, null, 2) + "\n",
);
const { stdout, stderr, exitCode } = await runCommandE2e(
scenario.routes,
[...scenario.args, "--config", "media", "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { parameters?: { watermark?: boolean } };
}>(stdout);
expect(data.request?.parameters?.watermark).toBe(false);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
}
});
@@ -0,0 +1,134 @@
import { ExitCode } from "bailian-cli-core";
import { describe, expect, test } from "vite-plus/test";
import { resolveSandboxImage, SANDBOX_IMAGES } from "../src/commands/sandbox/images.ts";
import {
buildTemplateCreateBody,
buildTemplateUpdateBody,
} from "../src/commands/sandbox/template.ts";
const EXPECTED_IMAGES = [
["code-interpreter", "代码解释器", "code-interpreter-v1"],
["browser", "浏览器", "browser"],
["all-in-one", "全能型", "all-in-one"],
] as const;
describe("Sandbox built-in image presets", () => {
test("contains exactly the three pinned images with icons and bilingual descriptions", () => {
expect(SANDBOX_IMAGES.map((image) => image.id)).toEqual(EXPECTED_IMAGES.map(([id]) => id));
for (const image of SANDBOX_IMAGES) {
expect(image.icon).toMatch(/^https:\/\/img\.alicdn\.com\/.*\.png$/);
expect(image.description["en-US"]).not.toBe("");
expect(image.description["zh-CN"]).not.toBe("");
}
});
test.each(EXPECTED_IMAGES)(
"%s maps both selectors to the exact create/update payload",
async (id, imageName, repositoryName) => {
const fromImage = `fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/${repositoryName}:v0.0.44`;
for (const selector of [id, imageName]) {
expect(resolveSandboxImage(selector)).toMatchObject({ id, imageName, imageUrl: fromImage });
expect(
await buildTemplateCreateBody({
image: selector,
name: "test",
cpuCount: 1,
memoryMb: 2048,
async: false,
}),
).toEqual({ name: "test", cpuCount: 1, memoryMB: 2048, fromImage, imageName });
expect(
await buildTemplateUpdateBody({
image: selector,
templateId: "template-test",
async: false,
}),
).toEqual({ fromImage, imageName });
}
},
);
test("preset fields override body fields without sending catalog metadata", async () => {
expect(
await buildTemplateUpdateBody({
image: "browser",
templateId: "template-test",
async: false,
body: JSON.stringify({ fromImage: "body:latest", imageName: "body", description: "kept" }),
}),
).toEqual({
fromImage: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44",
imageName: "浏览器",
description: "kept",
});
});
test("explicit custom image fields override the corresponding preset fields", async () => {
expect(
await buildTemplateUpdateBody({
image: "browser",
fromImage: "custom:v2",
imageName: "custom name",
templateId: "template-test",
async: false,
}),
).toEqual({ fromImage: "custom:v2", imageName: "custom name" });
expect(
await buildTemplateUpdateBody({
image: "browser",
imageName: "renamed",
templateId: "template-test",
async: false,
}),
).toEqual({
fromImage: "fc-e2b-registry.cn-beijing.cr.aliyuncs.com/runtime/browser:v0.0.44",
imageName: "renamed",
});
});
test("without --image, display names and custom URLs retain their previous behavior", async () => {
expect(
await buildTemplateUpdateBody({
imageName: "浏览器",
templateId: "template-test",
async: false,
}),
).toEqual({ imageName: "浏览器" });
expect(
await buildTemplateCreateBody({
name: "test",
cpuCount: 4,
memoryMb: 8192,
async: false,
body: '{"fromImage":"custom:latest","imageName":"custom"}',
}),
).toEqual({
name: "test",
cpuCount: 4,
memoryMB: 8192,
fromImage: "custom:latest",
imageName: "custom",
});
expect(
await buildTemplateCreateBody({
name: "test",
cpuCount: 1,
memoryMb: 2048,
async: false,
}),
).not.toHaveProperty("fromImage");
expect(
await buildTemplateUpdateBody({
description: "only description",
templateId: "template-test",
async: false,
}),
).toEqual({ description: "only description" });
});
test("rejects unknown presets locally", () => {
expect(() => resolveSandboxImage("unknown")).toThrowError(
expect.objectContaining({ exitCode: ExitCode.USAGE }),
);
});
});
+642
View File
@@ -0,0 +1,642 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BailianError, Client, ExitCode, type AnyCommand, type Settings } from "bailian-cli-core";
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import {
buildSandboxCreateBody,
sandboxConnect,
sandboxCreate,
sandboxDelete,
sandboxGet,
sandboxList,
sandboxPause,
sandboxResume,
} from "../src/commands/sandbox/instance.ts";
import {
buildTemplateCreateBody,
buildTemplateUpdateBody,
sandboxTemplateBuildStatus,
sandboxTemplateCreate,
sandboxTemplateDelete,
sandboxTemplateGet,
sandboxTemplateList,
sandboxTemplateUpdate,
waitForTemplateBuild,
} from "../src/commands/sandbox/template.ts";
import {
readRequestBody,
redactConnectionCredentials,
redactRequestSecrets,
} from "../src/commands/sandbox/shared.ts";
afterEach(() => {
vi.restoreAllMocks();
});
const SETTINGS: Settings = {
output: "json",
outputExplicit: true,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
telemetry: false,
};
interface RecordedRequest {
path: string;
method?: string;
body?: unknown;
timeout?: number;
}
function createUrlResolver(baseUrl?: string): Client["url"] {
const client = new Client({
identity: {
binName: "bl",
version: "test",
npmPackage: "bailian-cli",
clientName: "bailian-cli",
},
settings: SETTINGS,
baseUrl: baseUrl ?? "https://dashscope.aliyuncs.com",
baseUrlIsDefault: baseUrl === undefined,
});
return client.url.bind(client);
}
async function runCommand(
command: AnyCommand,
flags: Record<string, unknown>,
response: unknown,
baseUrl?: string,
): Promise<RecordedRequest> {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const requestJson = vi.fn(async (_request: RecordedRequest) => response);
const request = vi.fn(async (requestOptions: RecordedRequest) => {
void requestOptions;
return new Response(null, { status: 204 });
});
await command.run({
identity: {
binName: "bl",
version: "test",
npmPackage: "bailian-cli",
clientName: "bailian-cli",
},
settings: SETTINGS,
flags,
client: { requestJson, request, url: createUrlResolver(baseUrl) },
} as never);
const recorded = requestJson.mock.calls[0]?.[0] ?? request.mock.calls[0]?.[0];
if (!recorded) throw new Error("Expected the command to issue one request.");
expect(requestJson.mock.calls.length + request.mock.calls.length).toBe(1);
return recorded;
}
describe("Sandbox request input", () => {
test("--body accepts inline and @file JSON objects", async () => {
expect(await readRequestBody('{"name":"inline"}')).toEqual({ name: "inline" });
const directory = mkdtempSync(join(tmpdir(), "bl-sandbox-body-"));
const bodyPath = join(directory, "body.json");
writeFileSync(bodyPath, '{"name":"file"}', "utf8");
try {
expect(await readRequestBody(`@${bodyPath}`)).toEqual({ name: "file" });
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("instance flags override body scalars and merge key-value maps", async () => {
const body = await buildSandboxCreateBody({
body: JSON.stringify({
templateID: "template-body",
timeout: 600,
metadata: { body: "kept", override: "body" },
envVars: { BODY: "kept", OVERRIDE: "body" },
network: { allowOut: ["body.example"], maskRequestHost: "body.example" },
}),
templateId: "template-flag",
instanceTimeout: 900,
metadata: ["override=flag"],
env: ["OVERRIDE=flag"],
allowOut: ["flag.example"],
maskRequestHost: "flag.example",
showCredentials: false,
});
expect(body).toEqual({
templateID: "template-flag",
timeout: 900,
metadata: { body: "kept", override: "flag" },
envVars: { BODY: "kept", OVERRIDE: "flag" },
network: { allowOut: ["flag.example"], maskRequestHost: "flag.example" },
});
});
test("validates instance timeout and paired template CPU/memory fields", async () => {
await expect(
buildSandboxCreateBody({
templateId: "template-test",
instanceTimeout: 299,
showCredentials: false,
}),
).rejects.toMatchObject({ exitCode: ExitCode.USAGE });
await expect(
buildTemplateUpdateBody({
templateId: "template-test",
cpuCount: 2,
async: false,
}),
).rejects.toMatchObject({ exitCode: ExitCode.USAGE });
});
test("template create flags map max-running-time and nested overrides", async () => {
const body = await buildTemplateCreateBody({
body: JSON.stringify({ networkConfig: { allowOut: ["body.example"] } }),
name: "python",
cpuCount: 2,
memoryMb: 4096,
maxRunningTime: 3600,
allowOut: ["flag.example"],
tag: ["latest"],
async: false,
});
expect(body).toEqual({
name: "python",
cpuCount: 2,
memoryMB: 4096,
maxRunningTimeout: 3600,
tags: ["latest"],
networkConfig: { allowOut: ["flag.example"] },
});
});
test("redacts returned connection credentials and request environment secrets recursively", () => {
expect(
redactConnectionCredentials({
sandboxID: "sandbox-test",
envdAccessToken: "envd-secret",
connection: { trafficAccessToken: "traffic-secret", hostname: "example.test" },
}),
).toEqual({
sandboxID: "sandbox-test",
envdAccessToken: "[REDACTED]",
connection: { trafficAccessToken: "[REDACTED]", hostname: "example.test" },
});
expect(
redactRequestSecrets({ envVars: { PUBLIC_NAME: "secret" }, nested: { apiKey: "secret" } }),
).toEqual({
envVars: { PUBLIC_NAME: "[REDACTED]" },
nested: { apiKey: "[REDACTED]" },
});
});
});
describe("Sandbox command transport", () => {
test.each([
{
name: "create instance",
command: sandboxCreate,
flags: { workspaceId: "ws-test", templateId: "template-test", showCredentials: false },
response: { sandboxID: "sandbox-test" },
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes",
body: { templateID: "template-test" },
},
},
{
name: "list instances",
command: sandboxList,
flags: { workspaceId: "ws-test", templateId: "template-test", state: "running", limit: 10 },
response: [],
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v2/sandboxes?templateID=template-test&state=running&limit=10",
},
},
{
name: "get instance",
command: sandboxGet,
flags: { workspaceId: "ws-test", sandboxId: "sandbox/a", showCredentials: false },
response: { sandboxID: "sandbox/a" },
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox%2Fa",
},
},
{
name: "connect instance",
command: sandboxConnect,
flags: {
workspaceId: "ws-test",
sandboxId: "sandbox-test",
instanceTimeout: 900,
showCredentials: false,
},
response: { sandboxID: "sandbox-test" },
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/connect",
body: { timeout: 900 },
},
},
{
name: "pause instance",
command: sandboxPause,
flags: { workspaceId: "ws-test", sandboxId: "sandbox-test" },
response: undefined,
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/pause",
},
},
{
name: "resume instance",
command: sandboxResume,
flags: { workspaceId: "ws-test", sandboxId: "sandbox-test", showCredentials: false },
response: { sandboxID: "sandbox-test" },
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/resume",
},
},
{
name: "delete instance",
command: sandboxDelete,
flags: { workspaceId: "ws-test", sandboxId: "sandbox-test" },
response: undefined,
request: {
method: "DELETE",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test",
},
},
{
name: "create template",
command: sandboxTemplateCreate,
flags: {
workspaceId: "ws-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: true,
},
response: { templateID: "template-test", buildID: "build-test" },
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v3/templates",
body: { name: "python", cpuCount: 1, memoryMB: 2048 },
},
},
{
name: "list templates",
command: sandboxTemplateList,
flags: { workspaceId: "ws-test", limit: 20, cursor: "cursor/a" },
response: [],
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v2/templates?limit=20&cursor=cursor%2Fa",
},
},
{
name: "get template",
command: sandboxTemplateGet,
flags: { workspaceId: "ws-test", templateId: "template/a" },
response: { templateID: "template/a" },
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template%2Fa",
},
},
{
name: "update template",
command: sandboxTemplateUpdate,
flags: {
workspaceId: "ws-test",
templateId: "template-test",
description: "updated",
async: true,
},
response: { templateID: "template-test", buildID: "build-test" },
request: {
method: "PUT",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test",
body: { description: "updated" },
},
},
{
name: "get template build status",
command: sandboxTemplateBuildStatus,
flags: { workspaceId: "ws-test", templateId: "template-test", buildId: "build/a" },
response: { status: "ready" },
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test/builds/build%2Fa/status",
},
},
{
name: "delete template",
command: sandboxTemplateDelete,
flags: { workspaceId: "ws-test", templateId: "template-test" },
response: undefined,
request: {
method: "DELETE",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test",
},
},
])(
"maps $name to its documented method and path",
async ({ command, flags, response, request }) => {
expect(await runCommand(command, flags, response)).toEqual(request);
expect(
await runCommand(
command,
{ ...flags, workspaceId: undefined },
response,
"https://gateway.example.test",
),
).toEqual({
...request,
path: request.path.replace(
"https://ws-test.cn-beijing.maas.aliyuncs.com",
"https://gateway.example.test",
),
});
},
);
test.each([
{ showCredentials: false, expectedToken: "[REDACTED]" },
{ showCredentials: true, expectedToken: "envd-secret" },
])("show-credentials=$showCredentials controls connection token output", async (testCase) => {
let stdout = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
await sandboxGet.run({
identity: { binName: "bl" },
settings: { ...SETTINGS, quiet: false },
flags: {
workspaceId: "ws-test",
sandboxId: "sandbox-test",
showCredentials: testCase.showCredentials,
},
client: {
url: createUrlResolver(),
requestJson: async () => ({
sandboxID: "sandbox-test",
envdAccessToken: "envd-secret",
}),
},
} as never);
expect(JSON.parse(stdout)).toMatchObject({ envdAccessToken: testCase.expectedToken });
});
});
describe("Sandbox template build polling", () => {
test("polls building until ready", async () => {
const requestJson = vi
.fn()
.mockResolvedValueOnce({ status: "building" })
.mockResolvedValueOnce({ status: "ready", buildID: "build-test" });
const result = await waitForTemplateBuild(
{ requestJson } as never,
SETTINGS,
"https://example.test/build/status",
0,
);
expect(result).toMatchObject({ status: "ready", buildID: "build-test" });
expect(requestJson).toHaveBeenCalledTimes(2);
});
test("preserves the service build error message", async () => {
const requestJson = vi.fn().mockResolvedValue({
status: "error",
reason: { code: "BuildFailed", message: "image download failed" },
});
await expect(
waitForTemplateBuild(
{ requestJson } as never,
SETTINGS,
"https://example.test/build/status",
0,
),
).rejects.toMatchObject({
message: "image download failed",
exitCode: ExitCode.GENERAL,
});
});
test("returns a timeout error when the global timeout expires", async () => {
const requestJson = vi.fn();
await expect(
waitForTemplateBuild(
{ requestJson } as never,
{ ...SETTINGS, timeout: 0 },
"https://example.test/build/status",
1,
),
).rejects.toEqual(expect.objectContaining({ exitCode: ExitCode.TIMEOUT }));
expect(requestJson).not.toHaveBeenCalled();
});
test.each([
{ name: "create", command: sandboxTemplateCreate },
{ name: "update", command: sandboxTemplateUpdate },
])("$name preserves submitted IDs and the original polling failure", async ({ command }) => {
const submission = { templateID: "template-test", buildID: "build-test" };
const serviceCause = new Error("original cause");
const serviceError = new BailianError("service message", ExitCode.GENERAL, "original hint", {
api: { httpStatus: 503, apiCode: "Unavailable", requestId: "request-test" },
rawResponse: "original response",
cause: serviceCause,
});
const scenarios = [
{
timeout: 0,
response: { status: "building" },
exitCode: ExitCode.TIMEOUT,
message: "Template build polling timed out.",
},
{
timeout: 30,
response: { status: "error", reason: { message: "image download failed" } },
exitCode: ExitCode.GENERAL,
message: "image download failed",
},
{
timeout: 30,
error: serviceError,
exitCode: ExitCode.GENERAL,
message: serviceError.message,
},
];
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
for (const scenario of scenarios) {
const requestJson = vi.fn().mockResolvedValueOnce(submission);
if (scenario.error) requestJson.mockRejectedValue(scenario.error);
else requestJson.mockResolvedValue(scenario.response);
const operation = command.run({
identity: { binName: "bl" },
settings: { ...SETTINGS, timeout: scenario.timeout },
flags: {
workspaceId: "ws-test",
templateId: "template-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: false,
},
client: { requestJson, url: createUrlResolver() },
} as never);
await expect(operation).rejects.toMatchObject({
message: scenario.message,
exitCode: scenario.exitCode,
hint: expect.stringContaining("templateID=template-test, buildID=build-test"),
});
if (scenario.error) {
await expect(operation).rejects.toMatchObject({
api: serviceError.api,
rawResponse: serviceError.rawResponse,
cause: serviceCause,
hint: expect.stringContaining("original hint"),
});
}
expect(requestJson.mock.calls.filter(([request]) => request.method !== "GET")).toHaveLength(
1,
);
}
expect(stdout).not.toHaveBeenCalled();
});
test.each(["json", "text"] as const)(
"transport failures retain their identity and emit build recovery in %s diagnostics",
async (output) => {
const failure = new TypeError("fetch failed", { cause: { code: "ECONNRESET" } });
const requestJson = vi
.fn()
.mockResolvedValueOnce({ templateID: "template-test", buildID: "build-test" })
.mockRejectedValue(failure);
let stderr = "";
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
await expect(
sandboxTemplateCreate.run({
identity: { binName: "bl" },
settings: { ...SETTINGS, output },
flags: {
workspaceId: "ws-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: false,
},
client: { requestJson, url: createUrlResolver() },
} as never),
).rejects.toBe(failure);
if (output === "json") {
expect(JSON.parse(stderr)).toMatchObject({
templateID: "template-test",
buildID: "build-test",
});
} else {
expect(stderr).toContain("templateID=template-test, buildID=build-test");
}
},
);
test("async template creation returns after the submit request", async () => {
let stdout = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const requestJson = vi.fn().mockResolvedValue({
templateID: "template-test",
buildID: "build-test",
buildStatus: "building",
});
await sandboxTemplateCreate.run({
identity: { binName: "bl" },
settings: SETTINGS,
flags: {
workspaceId: "ws-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: true,
},
client: { requestJson, url: createUrlResolver() },
} as never);
expect(requestJson).toHaveBeenCalledTimes(1);
expect(stdout).toBe("template-test\tbuild-test\n");
});
test.each([undefined, "https://gateway.example.test"])(
"template creation polls the selected origin %s and emits the final envelope",
async (baseUrl) => {
let stdout = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const requestJson = vi
.fn()
.mockResolvedValueOnce({
templateID: "template-test",
buildID: "build-test",
buildStatus: "building",
})
.mockResolvedValueOnce({
templateID: "template-test",
buildID: "build-test",
status: "ready",
});
await sandboxTemplateCreate.run({
identity: { binName: "bl" },
settings: { ...SETTINGS, quiet: false },
flags: {
workspaceId: "ws-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: false,
pollInterval: 1,
},
client: { requestJson, url: createUrlResolver(baseUrl) },
} as never);
expect(requestJson).toHaveBeenCalledTimes(2);
expect(requestJson.mock.calls[1]?.[0]).toMatchObject({
method: "GET",
path: `${baseUrl ?? "https://ws-test.cn-beijing.maas.aliyuncs.com"}/api/v1/agentstudio/sandbox/templates/template-test/builds/build-test/status`,
});
expect(JSON.parse(stdout)).toEqual({
template: {
templateID: "template-test",
buildID: "build-test",
buildStatus: "building",
},
build: {
templateID: "template-test",
buildID: "build-test",
status: "ready",
},
});
},
);
});
test("Sandbox validation errors use CLI usage exit codes", async () => {
await expect(buildSandboxCreateBody({ showCredentials: false })).rejects.toBeInstanceOf(
BailianError,
);
});
@@ -0,0 +1,247 @@
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import harnessQuota from "../src/commands/token-plan/harness-quota.ts";
afterEach(() => {
vi.restoreAllMocks();
});
function captureStdout(): string[] {
const output: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
return output;
}
/** Gateway envelope produced by `callConsoleGateway` (DataV2 wrapper included). */
function wrapResponse(payload: Record<string, unknown>): Record<string, unknown> {
return { data: { DataV2: { data: { data: payload } } } };
}
const IMAGE_HARNESS = {
type: "official_tool",
hasResourcePack: true,
title: "图像生成 MCP",
quotaUnit: "张",
planCodes: ["tokenplan_harnesstool_image_generation", "tokenplan_harnesstool_image_monthly"],
};
const SEARCH_HARNESS = {
type: "official_tool",
hasResourcePack: true,
title: "联网搜索 MCP",
priceInfo: { unit: "次" },
planCodes: ["tokenplan_harnesstool_web_search"],
};
/** Harness without an entitlement quota — never shown, same as the console card. */
const NO_QUOTA_HARNESS = {
type: "infrastructure",
hasResourcePack: false,
title: "无额度 Harness",
planCodes: ["tokenplan_harnesstool_no_quota"],
};
async function runHarnessQuota(
items: Record<string, unknown>[],
equityInfos: Record<string, unknown>[],
options: { output?: string; type?: string } = {},
): Promise<Record<string, unknown>[]> {
const calls: Record<string, unknown>[] = [];
await harnessQuota.run({
client: {
console: vi.fn().mockImplementation((api: string, data: Record<string, unknown>) => {
calls.push({ api, data });
return Promise.resolve(
api.endsWith("token-plan.detail")
? wrapResponse({ items })
: wrapResponse({ userId: "1256099523640572", tokenPlanEquityInfos: equityInfos }),
);
}),
},
flags: options.type ? { type: options.type } : {},
settings: { dryRun: false, output: options.output },
} as never);
return calls;
}
describe("token-plan harness-quota view", () => {
test("renders one gauge per harness with issued quota", async () => {
const output = captureStdout();
await runHarnessQuota(
[IMAGE_HARNESS, NO_QUOTA_HARNESS],
[
{
instanceId: "instance-1",
equityType: "tokenplan_harnesstool_image_generation",
totalQuota: 100,
availableQuota: 60,
instanceStartTime: 1_786_000_000_000,
instanceEndTime: 1_788_000_000_000,
},
],
);
const rendered = output.join("");
expect(rendered).toContain("Token Plan Harness Quota");
expect(rendered).toContain("图像生成 MCP (tokenplan_harnesstool_image_generation)");
expect(rendered).toContain("40% used");
expect(rendered).toContain("Used: 40 / 100 张");
expect(rendered).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
// Harness without a resource pack has no entitlement quota at all.
expect(rendered).not.toContain("无额度 Harness");
});
test("marks a harness whose entitlement is not issued yet as issuing", async () => {
const output = captureStdout();
await runHarnessQuota(
[IMAGE_HARNESS, SEARCH_HARNESS],
[
{
equityType: "tokenplan_harnesstool_image_generation",
totalQuota: 100,
availableQuota: 60,
instanceEndTime: 1_788_000_000_000,
},
],
);
const rendered = output.join("");
// Pending harness keeps its first plan code and shows no gauge or reset line.
expect(rendered).toContain("联网搜索 MCP (tokenplan_harnesstool_web_search)");
expect(rendered).toContain("Quota is being issued; issuance can take up to 5 minutes.");
expect(rendered).not.toContain("Resets: not applicable");
});
test("falls back to the price unit when quotaUnit is absent", async () => {
const output = captureStdout();
await runHarnessQuota(
[SEARCH_HARNESS],
[
{
equityType: "tokenplan_harnesstool_web_search",
totalQuota: 2000,
availableQuota: 500,
instanceEndTime: 1_788_000_000_000,
},
],
);
expect(output.join("")).toContain("Used: 1,500 / 2,000 次");
});
test("reports a non-positive quota total instead of a gauge", async () => {
const output = captureStdout();
await runHarnessQuota(
[IMAGE_HARNESS],
[
{
equityType: "tokenplan_harnesstool_image_monthly",
totalQuota: 0,
availableQuota: 0,
instanceEndTime: 1_788_000_000_000,
},
],
);
expect(output.join("")).toContain(
"No positive quota total reported; check the Bailian Token Plan console.",
);
});
test("prints the empty state when no harness carries an entitlement quota", async () => {
const output = captureStdout();
await runHarnessQuota([NO_QUOTA_HARNESS], []);
expect(output.join("")).toBe("No harness with entitlement quota found.\n");
});
test("passes --type through to the harness list API only", async () => {
captureStdout();
const calls = await runHarnessQuota([], [], { type: "infrastructure" });
expect(calls[0]).toEqual({
api: "zeldaEasy.broadscope-bailian.token-plan.detail",
data: { type: "infrastructure" },
});
expect(calls[1]?.api).toBe("zeldaEasy.bailian-commerce.tokenPlan.queryTokenPlanEquityInfo");
});
});
describe("token-plan harness-quota json", () => {
test("emits the joined quota fields with --output json", async () => {
const output = captureStdout();
await runHarnessQuota(
[IMAGE_HARNESS, SEARCH_HARNESS],
[
{
instanceId: "instance-1",
equityType: "tokenplan_harnesstool_image_generation",
totalQuota: 100,
availableQuota: 60,
instanceStartTime: 1_786_000_000_000,
instanceEndTime: 1_788_000_000_000,
},
],
{ output: "json" },
);
const parsed = JSON.parse(output.join("")) as { items: Record<string, unknown>[] };
expect(parsed.items).toEqual([
{
planCode: "tokenplan_harnesstool_image_generation",
title: "图像生成 MCP",
type: "official_tool",
status: "issued",
quotaUnit: "张",
instanceId: "instance-1",
totalQuota: 100,
availableQuota: 60,
usedQuota: 40,
usedPercent: 40,
instanceStartTime: 1_786_000_000_000,
instanceEndTime: 1_788_000_000_000,
},
{
planCode: "tokenplan_harnesstool_web_search",
title: "联网搜索 MCP",
type: "official_tool",
status: "issuing",
quotaUnit: "次",
},
]);
});
test("treats non-numeric quota fields as absent instead of failing", async () => {
const output = captureStdout();
await runHarnessQuota(
[IMAGE_HARNESS],
[
{
equityType: "tokenplan_harnesstool_image_generation",
totalQuota: "not-a-number",
availableQuota: Number.NaN,
},
],
{ output: "json" },
);
const parsed = JSON.parse(output.join("")) as { items: Record<string, unknown>[] };
expect(parsed.items[0]).toEqual({
planCode: "tokenplan_harnesstool_image_generation",
title: "图像生成 MCP",
type: "official_tool",
status: "issued",
quotaUnit: "张",
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.22.0",
"version": "1.25.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": {
+1
View File
@@ -4,6 +4,7 @@ export {
resolveOpenApi,
describeAuthState,
resolveModelBaseUrl,
resolveModelBaseUrlState,
} from "./resolver.ts";
export { makeAuthStore, type AuthStore, type AuthPersistPatch } from "./store.ts";
export type {
+14 -3
View File
@@ -8,11 +8,22 @@ import { ExitCode } from "../errors/codes.ts";
// Resolve the credential for a command's declared domain (model = api-key,
// console = access-token), by priority, or throw. Read only from sources.
/** Preserve whether the shared base URL chain selected a configured origin or its default. */
export function resolveModelBaseUrlState(
sources: ResolutionSources,
fallback: string = REGIONS.cn,
): { baseUrl: string; baseUrlIsDefault: boolean } {
const configuredBaseUrl =
sources.flags.baseUrl || sources.env.DASHSCOPE_BASE_URL || sources.file.base_url;
return {
baseUrl: normalizeModelBaseUrl(configuredBaseUrl || fallback),
baseUrlIsDefault: !configuredBaseUrl,
};
}
/** Model-domain baseUrl(flag > env > config file > fallback);无需 key 也可解析。 */
export function resolveModelBaseUrl(s: ResolutionSources, fallback: string = REGIONS.cn): string {
return normalizeModelBaseUrl(
s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback,
);
return resolveModelBaseUrlState(s, fallback).baseUrl;
}
/**
+38 -18
View File
@@ -1,16 +1,20 @@
import { imageSyncPath, speechRecognizePath } from "./endpoints.ts";
import type { AsrContextMessage } from "../types/api.ts";
/**
* DashScope ASR APIs differ by model family:
*
* - async file transcription (`.../audio/asr/transcription`):
* fun-asr*, paraformer* (non-realtime), *-filetrans, sensevoice*
* language via `parameters.language_hints`
* language via `parameters.language_hints`; optional `input.context` /
* `parameters.vocabulary` (model-dependent effective range)
* - sync multimodal (`.../aigc/multimodal-generation/generation`):
* - qwen3: `{ content: [{ audio }] }` + optional `asr_options.language`
* (qwen3-asr-flash*)
* (qwen3-asr-flash*) no vocabulary / context fields in this body shape
* - input-audio: `{ type: input_audio, input_audio.data }` +
* `format`/`sample_rate` + optional `language_hints`
* `format`/`sample_rate` + optional `language_hints` /
* `vocabulary_id` / `vocabulary`; optional leading `input_text` for context
* (fun-asr-flash*, qwen-audio-*-asr-flash*)
* - realtime / streaming: WebSocket not supported by `speech recognize`
*/
@@ -158,9 +162,18 @@ export interface BuildAsrFlashRequestOpts {
language?: string;
/** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */
vocabularyId?: string;
/** Instant hot words (word → weight); input-audio Flash only (command layer rejects qwen3). */
vocabulary?: Record<string, number>;
/** Context enhancement text; prepended as input_text before input_audio. */
context?: string;
flashFamily: AsrFlashFamily;
}
/** Wrap plain text as a single user context message for ASR. */
export function buildAsrContextMessages(text: string): AsrContextMessage[] {
return [{ role: "user", content: [{ type: "input_text", text }] }];
}
/**
* Build language fields for async ASR routes.
* qwen3-asr-flash-filetrans* `language`; other async models `language_hints`.
@@ -178,10 +191,10 @@ export function buildAsyncAsrLanguageFields(
/** Build a sync multimodal ASR request body for Flash models. */
export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<string, unknown> {
const { model, audioUrl, language, vocabularyId, flashFamily } = opts;
const { model, audioUrl, language, vocabularyId, vocabulary, context, flashFamily } = opts;
if (flashFamily === "input-audio") {
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary(_id)
const parameters: Record<string, unknown> = {
format: inferAudioFormatHint(audioUrl),
sample_rate: "16000",
@@ -192,21 +205,28 @@ export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<str
if (vocabularyId) {
parameters.vocabulary_id = vocabularyId;
}
if (vocabulary) {
parameters.vocabulary = vocabulary;
}
// input_audio must be the last message; prepend context as input_text when present
const messages: Array<Record<string, unknown>> = [];
if (context) {
for (const message of buildAsrContextMessages(context)) {
messages.push(message as unknown as Record<string, unknown>);
}
}
messages.push({
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
});
return {
model,
input: {
messages: [
{
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
},
],
},
input: { messages },
parameters,
};
}
+9 -3
View File
@@ -23,6 +23,8 @@ export interface ClientDeps {
settings: Settings;
/** Model 域 base URL(凭证无关链解析,resolveModelBaseUrl;有 apiCred 时两者一致)。 */
baseUrl: string;
/** True only when the shared URL chain used its default; explicit origins win over service defaults. */
baseUrlIsDefault?: boolean;
apiCred?: ApiKeyCredential;
consoleCred?: ConsoleCredential;
openApiCred?: OpenApiCredential;
@@ -108,9 +110,13 @@ export class Client {
return this.deps.apiCred;
}
/** Full URL for a model-domain {@link path}; build request/display URLs only through this. */
url(path: string): string {
return this.baseUrl + path;
/**
* Full URL for a model-domain path. Services may supply a lazy default origin,
* evaluated only when no flag/env/profile base URL was configured.
*/
url(path: string, defaultBaseUrl?: () => string): string {
const baseUrl = this.deps.baseUrlIsDefault && defaultBaseUrl ? defaultBaseUrl() : this.baseUrl;
return baseUrl + path;
}
private toOpts({ path, ...rest }: ClientRequestOpts): RequestOpts {
+54
View File
@@ -89,6 +89,11 @@ export function speechRecognizePath(): string {
return "/api/v1/services/audio/asr/transcription";
}
// ---- Hot-word Vocabulary (ASR customization) ----
export function speechVocabularyPath(): string {
return "/api/v1/services/audio/asr/customization";
}
// ---- Memory Profile (DashScope v2) ----
export function profileSchemaPath(): string {
return "/api/v2/apps/memory/profile_schemas";
@@ -226,6 +231,55 @@ export function ragEndpoint(workspaceId: string, path: string): string {
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com${path}`;
}
// ---- Sandbox control plane (workspace-based host, cn-beijing only) ----
/** AgentStudio workspace files; template uploads send source=sandbox_template. */
export function agentStudioFilesPath(): string {
return "/api/v1/agentstudio/files";
}
/** Default Sandbox origin when no shared base URL was configured (cn-beijing only). */
export function sandboxBaseUrl(workspaceId: string): string {
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com`;
}
/** Sandbox service prefix, appended to the selected origin just like AgentStudio SDK paths. */
export function sandboxApiPath(path: string): string {
return `/api/v1/agentstudio/sandbox${path}`;
}
/** Build the default workspace-scoped absolute Sandbox control-plane URL. */
export function sandboxEndpoint(workspaceId: string, path: string): string {
return sandboxBaseUrl(workspaceId) + sandboxApiPath(path);
}
export const SANDBOX_PATHS = {
sandboxes: "/sandboxes",
sandboxList: "/v2/sandboxes",
templates: "/templates",
templateCreate: "/v3/templates",
templateList: "/v2/templates",
} as const;
export function sandboxInstancePath(sandboxId: string): string {
return `${SANDBOX_PATHS.sandboxes}/${encodeURIComponent(sandboxId)}`;
}
export function sandboxInstanceActionPath(
sandboxId: string,
action: "connect" | "pause" | "resume",
): string {
return `${sandboxInstancePath(sandboxId)}/${action}`;
}
export function sandboxTemplatePath(templateId: string): string {
return `${SANDBOX_PATHS.templates}/${encodeURIComponent(templateId)}`;
}
export function sandboxTemplateBuildStatusPath(templateId: string, buildId: string): string {
return `${sandboxTemplatePath(templateId)}/builds/${encodeURIComponent(buildId)}/status`;
}
export const RAG_PATHS = {
// indices domain — knowledge bases / documents / chunks / import jobs.
// Note: parameter naming is inconsistent across endpoints; see per-path comments.
+11
View File
@@ -18,9 +18,19 @@ export {
profileSchemaPath,
ragEndpoint,
RAG_PATHS,
agentStudioFilesPath,
sandboxEndpoint,
sandboxBaseUrl,
sandboxApiPath,
SANDBOX_PATHS,
sandboxInstancePath,
sandboxInstanceActionPath,
sandboxTemplatePath,
sandboxTemplateBuildStatusPath,
responsesPath,
speechRecognizePath,
speechSynthesizePath,
speechVocabularyPath,
taskPath,
userProfilePath,
videoGeneratePath,
@@ -41,6 +51,7 @@ export {
type ImageSizeProfile,
} from "./image-routes.ts";
export {
buildAsrContextMessages,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
+1
View File
@@ -272,6 +272,7 @@ export function buildSettings(s: ResolutionSources): Settings {
outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output),
outputDir: file.output_dir || undefined,
timeout,
watermark: file.watermark ?? true,
defaultTextModel: file.default_text_model,
defaultVideoModel: file.default_video_model,
defaultImageToVideoModel: file.default_image_to_video_model,
+4
View File
@@ -35,6 +35,7 @@ export interface ConfigFile {
output?: "text" | "json";
output_dir?: string;
timeout?: number;
watermark?: boolean;
default_text_model?: string;
default_video_model?: string;
default_image_to_video_model?: string;
@@ -63,6 +64,7 @@ export const CONFIG_FILE_KEYS = [
"output",
"output_dir",
"timeout",
"watermark",
"default_text_model",
"default_video_model",
"default_image_to_video_model",
@@ -156,6 +158,7 @@ export function parseConfigFile(raw: unknown): ConfigFile {
if (typeof obj.output_dir === "string" && obj.output_dir.length > 0)
out.output_dir = obj.output_dir;
if (typeof obj.timeout === "number" && obj.timeout > 0) out.timeout = obj.timeout;
if (typeof obj.watermark === "boolean") out.watermark = obj.watermark;
if (typeof obj.default_text_model === "string" && obj.default_text_model.length > 0)
out.default_text_model = obj.default_text_model;
if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0)
@@ -224,6 +227,7 @@ export interface Settings {
outputExplicit: boolean;
outputDir?: string;
timeout: number;
watermark: boolean;
defaultTextModel?: string;
defaultVideoModel?: string;
defaultImageToVideoModel?: string;
+3 -2
View File
@@ -7,9 +7,10 @@ export interface ApiErrorBody {
type?: string;
code?: number | string;
};
code?: string;
code?: number | string;
message?: string;
request_id?: string;
requestID?: string;
}
export function mapApiError(status: number, body: ApiErrorBody, _url?: string): BailianError {
@@ -26,7 +27,7 @@ export function mapApiError(status: number, body: ApiErrorBody, _url?: string):
api: {
httpStatus: status,
apiCode,
requestId: body.request_id,
requestId: body.request_id ?? body.requestID,
},
});
}
+1
View File
@@ -13,6 +13,7 @@ export * from "./files/index.ts";
export * from "./dataset/index.ts";
export * from "./finetune/index.ts";
export * from "./deploy/index.ts";
export * from "./speech/index.ts";
export * from "./types/index.ts";
export * from "./utils/index.ts";
export * from "./telemetry/index.ts";
+14
View File
@@ -0,0 +1,14 @@
export {
SPEECH_BIASING_MODEL,
buildVocabularyRequest,
createVocabulary,
listVocabularies,
queryVocabulary,
updateVocabulary,
deleteVocabulary,
type VocabularyEntry,
type VocabularyListItem,
type VocabularyEnvelope,
type VocabularyRequest,
} from "./vocabulary.ts";
export { parseInstantVocabulary, parseVocabularyEntries } from "./vocabulary-input.ts";
@@ -0,0 +1,76 @@
import { UsageError } from "../errors/base.ts";
import type { VocabularyEntry } from "./vocabulary.ts";
/** Shared JSON decode + top-level shape guard for both hot-word flags. */
function decodeVocabularyJson(raw: string, flagName: string): unknown {
try {
return JSON.parse(raw);
} catch (error) {
throw new UsageError(`${flagName} is not valid JSON — ${(error as Error).message}`);
}
}
/**
* Instant hot words for `recognize --vocabulary`.
* The API field is a flat wordweight object, so an array is a usage error here.
*/
export function parseInstantVocabulary(raw: string): Record<string, number> {
const parsed = decodeVocabularyJson(raw, "--vocabulary");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new UsageError("--vocabulary must decode to a JSON object of word→weight.");
}
for (const [word, weight] of Object.entries(parsed)) {
if (typeof weight !== "number" || !Number.isFinite(weight)) {
throw new UsageError(`--vocabulary weight for "${word}" must be a number.`);
}
}
return parsed as Record<string, number>;
}
/**
* Vocabulary create/update --words.
* Accepts the same wordweight object as recognize, or the API entry array
* when per-entry lang is needed. Array form ignores the optional lang param.
*/
export function parseVocabularyEntries(raw: string, lang?: string): VocabularyEntry[] {
const flagName = "--words";
const parsed = decodeVocabularyJson(raw, flagName);
let entries: VocabularyEntry[];
if (Array.isArray(parsed)) {
entries = parsed.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new UsageError(
`${flagName} entry #${index} must be an object with string "text" and number "weight".`,
);
}
const entry = item as Partial<VocabularyEntry>;
if (typeof entry.text !== "string" || typeof entry.weight !== "number") {
throw new UsageError(
`${flagName} entry #${index} must have a string "text" and a number "weight".`,
);
}
if (!Number.isFinite(entry.weight)) {
throw new UsageError(`${flagName} entry #${index} weight must be a finite number.`);
}
return {
text: entry.text,
weight: entry.weight,
...(typeof entry.lang === "string" ? { lang: entry.lang } : {}),
};
});
} else if (!parsed || typeof parsed !== "object") {
throw new UsageError(`${flagName} must decode to a JSON object or array.`);
} else {
entries = [];
for (const [text, weight] of Object.entries(parsed)) {
if (typeof weight !== "number" || !Number.isFinite(weight)) {
throw new UsageError(`${flagName} weight for "${text}" must be a number.`);
}
entries.push({ text, weight, ...(lang ? { lang } : {}) });
}
}
if (entries.length === 0) {
throw new UsageError(`${flagName} must contain at least one hot word.`);
}
return entries;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Hot-word vocabulary HTTP API wrappers.
*
* Thin functions over `requestJson`. They return the parsed body verbatim
* (snake_case) so callers can decide how to surface fields.
*/
import { speechVocabularyPath } from "../client/endpoints.ts";
import type { Client } from "../client/client.ts";
/** Fixed model id for the hot-word customization endpoint. */
export const SPEECH_BIASING_MODEL = "speech-biasing";
export interface VocabularyEntry {
text: string;
weight: number;
lang?: string;
}
export interface VocabularyListItem {
vocabulary_id?: string;
gmt_create?: string;
gmt_modified?: string;
/** OK | UNDEPLOYED — UNDEPLOYED vocabularies are silently ignored by ASR. */
status?: string;
}
export interface VocabularyEnvelope<T> {
request_id?: string;
output?: T;
usage?: { count?: number };
}
export interface VocabularyRequest {
model: string;
input: Record<string, unknown>;
}
/** Shared request body builder for dry-run and live calls. */
export function buildVocabularyRequest(
action: string,
input: Record<string, unknown>,
): VocabularyRequest {
return {
model: SPEECH_BIASING_MODEL,
input: { action, ...input },
};
}
async function callVocabularyApi<T>(
client: Client,
action: string,
input: Record<string, unknown>,
signal?: AbortSignal,
): Promise<VocabularyEnvelope<T>> {
return client.requestJson<VocabularyEnvelope<T>>({
path: speechVocabularyPath(),
method: "POST",
body: buildVocabularyRequest(action, input),
signal,
});
}
export function createVocabulary(
client: Client,
params: { targetModel: string; prefix: string; vocabulary: VocabularyEntry[] },
signal?: AbortSignal,
): Promise<VocabularyEnvelope<{ vocabulary_id?: string }>> {
return callVocabularyApi(
client,
"create_vocabulary",
{
target_model: params.targetModel,
prefix: params.prefix,
vocabulary: params.vocabulary,
},
signal,
);
}
export function listVocabularies(
client: Client,
params: { prefix?: string; pageIndex?: number; pageSize?: number } = {},
signal?: AbortSignal,
): Promise<VocabularyEnvelope<{ vocabulary_list?: VocabularyListItem[] }>> {
const input: Record<string, unknown> = {};
if (params.prefix !== undefined) input.prefix = params.prefix;
if (params.pageIndex !== undefined) input.page_index = params.pageIndex;
if (params.pageSize !== undefined) input.page_size = params.pageSize;
return callVocabularyApi(client, "list_vocabulary", input, signal);
}
export function queryVocabulary(
client: Client,
vocabularyId: string,
signal?: AbortSignal,
): Promise<
VocabularyEnvelope<{
gmt_create?: string;
gmt_modified?: string;
status?: string;
target_model?: string;
vocabulary?: VocabularyEntry[];
}>
> {
return callVocabularyApi(client, "query_vocabulary", { vocabulary_id: vocabularyId }, signal);
}
export function updateVocabulary(
client: Client,
vocabularyId: string,
vocabulary: VocabularyEntry[],
signal?: AbortSignal,
): Promise<VocabularyEnvelope<Record<string, never>>> {
return callVocabularyApi(
client,
"update_vocabulary",
{ vocabulary_id: vocabularyId, vocabulary },
signal,
);
}
export function deleteVocabulary(
client: Client,
vocabularyId: string,
signal?: AbortSignal,
): Promise<VocabularyEnvelope<Record<string, never>>> {
return callVocabularyApi(client, "delete_vocabulary", { vocabulary_id: vocabularyId }, signal);
}
+10
View File
@@ -580,11 +580,19 @@ export interface DashScopeTTSStreamChunk {
// ---- Speech Recognition / ASR (DashScope) ----
/** Context-enhancement message for async ASR `input.context` / sync Flash `input.messages`. */
export interface AsrContextMessage {
role: "user" | "assistant";
content: Array<{ type: "input_text" | "text"; text: string }>;
}
export interface DashScopeASRRequest {
model: string;
input: {
file_urls?: string[];
file_url?: string;
/** Context enhancement for async filetrans (array of chat-style messages). */
context?: AsrContextMessage[];
};
parameters?: {
channel_id?: number[];
@@ -595,6 +603,8 @@ export interface DashScopeASRRequest {
diarization_enabled?: boolean;
speaker_count?: number;
vocabulary_id?: string;
/** Instant hot words (word → weight); takes effect on Qwen-Audio-3.0-ASR-Flash series. */
vocabulary?: Record<string, number>;
};
}
+1
View File
@@ -49,6 +49,7 @@ export type {
ChatRequest,
ChatResponse,
ChatTool,
AsrContextMessage,
DashScopeASRRequest,
DashScopeASRTaskResult,
DashScopeASRTranscriptionItem,
+3 -3
View File
@@ -34,7 +34,7 @@ export function resolveBooleanFlag(
return defaultWhenUnset;
}
/** Resolve `--watermark` flag; default true when unset. */
export function resolveWatermark(flagValue: unknown): boolean {
return parseOptionalBooleanValue(flagValue, "watermark") ?? true;
/** Resolve `--watermark`; command flag overrides config, then defaults to true. */
export function resolveWatermark(flagValue: unknown, configuredValue?: boolean): boolean {
return parseOptionalBooleanValue(flagValue, "watermark") ?? configuredValue ?? true;
}
+42
View File
@@ -1,5 +1,6 @@
import { expect, test } from "vite-plus/test";
import {
buildAsrContextMessages,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
@@ -141,6 +142,47 @@ test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => {
});
});
test("buildAsrContextMessages wraps plain text as a single user input_text message", () => {
expect(buildAsrContextMessages("奋斗者号 鲸落")).toEqual([
{ role: "user", content: [{ type: "input_text", text: "奋斗者号 鲸落" }] },
]);
});
test("buildAsrFlashRequest injects instant vocabulary and prepends context before input_audio", () => {
const body = buildAsrFlashRequest({
model: "qwen-audio-3.0-asr-flash",
audioUrl: "https://example.com/a.wav",
vocabulary: { 奋斗者: 4, 鲸落: 4 },
context: "奋斗者号 鲸落",
flashFamily: "input-audio",
});
expect(body).toEqual({
model: "qwen-audio-3.0-asr-flash",
input: {
messages: [
{
role: "user",
content: [{ type: "input_text", text: "奋斗者号 鲸落" }],
},
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
parameters: {
format: "wav",
sample_rate: "16000",
vocabulary: { 奋斗者: 4, 鲸落: 4 },
},
});
const messages = (body.input as { messages: Array<{ content: Array<{ type?: string }> }> })
.messages;
expect(messages[messages.length - 1]?.content?.[0]?.type).toBe("input_audio");
});
test("buildAsyncAsrLanguageFields maps language by async style", () => {
expect(buildAsyncAsrLanguageFields("language_hints", "zh")).toEqual({
language_hints: ["zh"],
@@ -5,6 +5,7 @@ import {
resolveApiKey,
resolveConsole,
resolveModelBaseUrl,
resolveModelBaseUrlState,
resolveOpenApi,
} from "../src/auth/resolver.ts";
import { getModelProfilePreset } from "../src/config/profile-presets.ts";
@@ -79,6 +80,14 @@ test("default_speech_recognition_model 从配置文件进入运行时 Settings",
expect(resolve({ file }).defaultSpeechRecognitionModel).toBe("qwen-audio-3.0-asr-flash");
});
test("watermark 从配置文件进入 Settings缺省时保持合规默认值 true", () => {
expect(parseConfigFile({ watermark: false }).watermark).toBe(false);
expect(parseConfigFile({ watermark: true }).watermark).toBe(true);
expect(parseConfigFile({ watermark: "false" }).watermark).toBeUndefined();
expect(resolve({ file: { watermark: false } }).watermark).toBe(false);
expect(resolve({}).watermark).toBe(true);
});
test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => {
const flags = { baseUrl: "https://flag.example.com/compatible-mode/v1?source=flag" };
const env = { DASHSCOPE_BASE_URL: "https://env.example.com/apps/anthropic#env" };
@@ -98,6 +107,24 @@ test("baseUrl:非法 flag/env 在 resolver 边界报 usage error", () => {
).toThrow(/Invalid model base URL/);
});
test("baseUrl state distinguishes an implicit default from an explicitly configured default origin", () => {
const defaultOrigin = "https://dashscope.aliyuncs.com";
expect(resolveModelBaseUrlState(src({}))).toEqual({
baseUrl: defaultOrigin,
baseUrlIsDefault: true,
});
for (const sources of [
src({ flags: { baseUrl: defaultOrigin } }),
src({ env: { DASHSCOPE_BASE_URL: defaultOrigin } }),
src({ file: { base_url: defaultOrigin } }),
]) {
expect(resolveModelBaseUrlState(sources)).toEqual({
baseUrl: defaultOrigin,
baseUrlIsDefault: false,
});
}
});
test("命名 config 仍保持 flag > env > selected file", () => {
const env = {
DASHSCOPE_BASE_URL: "https://env.example.com",
+1
View File
@@ -28,6 +28,7 @@ function makeSettings(configName?: string): Settings {
output: "json",
outputExplicit: false,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
+96
View File
@@ -2,11 +2,17 @@ import { expect, test } from "vite-plus/test";
import type { Identity, Settings } from "../src/index.ts";
import {
BailianError,
Client,
ExitCode,
McpClient,
callConsoleGateway,
mapApiError,
request,
sandboxEndpoint,
sandboxInstanceActionPath,
sandboxInstancePath,
sandboxTemplateBuildStatusPath,
sandboxTemplatePath,
} from "../src/index.ts";
import { parseConfigFile } from "../src/config/schema.ts";
import {
@@ -31,6 +37,7 @@ function testDeps(identity: Partial<Identity> = {}): {
output: "json",
outputExplicit: true,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
@@ -73,6 +80,92 @@ test("mapApiError captures apiCode and request_id when present", () => {
});
});
test("mapApiError captures Sandbox numeric code and requestID", () => {
const err = mapApiError(400, {
code: 100004,
message: "参数缺失",
requestID: "sandbox-request-id",
});
expect(err.message).toBe("参数缺失");
expect(err.api).toEqual({
httpStatus: 400,
apiCode: "100004",
requestId: "sandbox-request-id",
});
});
test("Sandbox endpoint builders fix cn-beijing and encode path identifiers", () => {
expect(sandboxEndpoint("ws-test", "/sandboxes")).toBe(
"https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes",
);
expect(sandboxInstancePath("sandbox/a b")).toBe("/sandboxes/sandbox%2Fa%20b");
expect(sandboxInstanceActionPath("sandbox/a b", "connect")).toBe(
"/sandboxes/sandbox%2Fa%20b/connect",
);
expect(sandboxTemplatePath("template/a b")).toBe("/templates/template%2Fa%20b");
expect(sandboxTemplateBuildStatusPath("template/a", "build b")).toBe(
"/templates/template%2Fa/builds/build%20b/status",
);
});
test("Client sends Sandbox REST requests with Bearer auth and no E2B API key", async () => {
const originalFetch = globalThis.fetch;
let requestHeaders: RequestInit["headers"];
globalThis.fetch = async (_input, init) => {
requestHeaders = init?.headers;
return new Response(JSON.stringify({ sandboxID: "sandbox-test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
try {
const deps = testDeps();
const client = new Client({
...deps,
baseUrl: "https://dashscope.aliyuncs.com",
apiCred: {
token: "bailian-api-key",
baseUrl: "https://dashscope.aliyuncs.com",
source: "flag",
},
});
await client.requestJson({
path: sandboxEndpoint("ws-test", "/sandboxes/sandbox-test"),
method: "GET",
});
const headers = new Headers(requestHeaders);
expect(headers.get("Authorization")).toBe("Bearer bailian-api-key");
expect(headers.has("X-API-Key")).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
test("Client.url evaluates a service default only when the shared origin was not configured", () => {
const deps = testDeps();
const defaultClient = new Client({
...deps,
baseUrl: "https://dashscope.aliyuncs.com",
baseUrlIsDefault: true,
});
const configuredClient = new Client({
...deps,
baseUrl: "https://dashscope.aliyuncs.com",
baseUrlIsDefault: false,
});
expect(defaultClient.url("/service")).toBe("https://dashscope.aliyuncs.com/service");
expect(defaultClient.url("/service", () => "https://workspace.example.test")).toBe(
"https://workspace.example.test/service",
);
expect(
configuredClient.url("/service", () => {
throw new Error("A configured origin must not require a workspace default.");
}),
).toBe("https://dashscope.aliyuncs.com/service");
});
test("BailianError propagates cause via options-bag and exposes it in toJSON", () => {
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), {
code: "ENOTFOUND",
@@ -290,6 +383,9 @@ test("resolveWatermark uses flag or defaults to true", () => {
expect(resolveWatermark("false")).toBe(false);
expect(resolveWatermark("true")).toBe(true);
expect(resolveWatermark(undefined)).toBe(true);
expect(resolveWatermark(undefined, false)).toBe(false);
expect(resolveWatermark("true", false)).toBe(true);
expect(resolveWatermark("false", true)).toBe(false);
});
test("resolveBooleanFlag uses flag or defaultWhenUnset", () => {
+1
View File
@@ -23,6 +23,7 @@ function testDeps(overrides?: Partial<Settings>): { identity: Identity; settings
output: "json",
outputExplicit: true,
timeout: 5,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vite-plus/test";
import {
parseInstantVocabulary,
parseVocabularyEntries,
buildVocabularyRequest,
SPEECH_BIASING_MODEL,
UsageError,
} from "../src/index.ts";
describe("parseInstantVocabulary", () => {
test("接受 word→weight 对象", () => {
expect(parseInstantVocabulary('{"奋斗者":4,"鲸落":5}')).toEqual({
奋斗者: 4,
鲸落: 5,
});
});
test("拒绝数组", () => {
expect(() => parseInstantVocabulary('[{"text":"x","weight":4}]')).toThrow(UsageError);
expect(() => parseInstantVocabulary('[{"text":"x","weight":4}]')).toThrow(/object/i);
});
test("拒绝字符串权重", () => {
expect(() => parseInstantVocabulary('{"x":"4"}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":"4"}')).toThrow(/must be a number/);
});
test("拒绝非有限数字权重null", () => {
expect(() => parseInstantVocabulary('{"x":null}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":null}')).toThrow(/must be a number/);
});
test("拒绝 NaN / Infinity 字面量(非法 JSON", () => {
expect(() => parseInstantVocabulary('{"x":NaN}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":Infinity}')).toThrow(UsageError);
});
test("拒绝顶层 null", () => {
expect(() => parseInstantVocabulary("null")).toThrow(UsageError);
});
test("非法 JSON 抛 UsageError", () => {
expect(() => parseInstantVocabulary("{bad json")).toThrow(UsageError);
expect(() => parseInstantVocabulary("{bad json")).toThrow(/not valid JSON/);
});
});
describe("parseVocabularyEntries", () => {
test("对象形态转为条目数组", () => {
expect(parseVocabularyEntries('{"奋斗者":4,"鲸落":5}')).toEqual([
{ text: "奋斗者", weight: 4 },
{ text: "鲸落", weight: 5 },
]);
});
test("对象形态叠加 --lang", () => {
expect(parseVocabularyEntries('{"奋斗者":4}', "zh")).toEqual([
{ text: "奋斗者", weight: 4, lang: "zh" },
]);
});
test("数组形态透传 lang", () => {
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4,"lang":"zh"}]')).toEqual([
{ text: "奋斗者", weight: 4, lang: "zh" },
]);
});
test("数组形态忽略第二参 lang", () => {
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4}]', "zh")).toEqual([
{ text: "奋斗者", weight: 4 },
]);
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4,"lang":"en"}]', "zh")).toEqual([
{ text: "奋斗者", weight: 4, lang: "en" },
]);
});
test("数组缺 text 或 weight 拒绝", () => {
expect(() => parseVocabularyEntries('[{"weight":4}]')).toThrow(UsageError);
expect(() => parseVocabularyEntries('[{"text":"x"}]')).toThrow(UsageError);
});
test("非 object 数组元素拒绝", () => {
expect(() => parseVocabularyEntries('["x"]')).toThrow(UsageError);
expect(() => parseVocabularyEntries("[null]")).toThrow(UsageError);
});
test("空对象 / 空数组拒绝", () => {
expect(() => parseVocabularyEntries("{}")).toThrow(UsageError);
expect(() => parseVocabularyEntries("{}")).toThrow(/at least one/);
expect(() => parseVocabularyEntries("[]")).toThrow(UsageError);
expect(() => parseVocabularyEntries("[]")).toThrow(/at least one/);
});
});
describe("buildVocabularyRequest", () => {
test("固定 model 为 speech-biasing", () => {
const body = buildVocabularyRequest("create_vocabulary", {
target_model: "fun-asr",
prefix: "demo",
vocabulary: [{ text: "奋斗者", weight: 4 }],
});
expect(body.model).toBe(SPEECH_BIASING_MODEL);
expect(body.model).toBe("speech-biasing");
expect(body.input.action).toBe("create_vocabulary");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "knowledge-studio-cli",
"version": "1.22.0",
"version": "1.25.0",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.22.0",
"version": "1.25.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": {
+2 -2
View File
@@ -29,7 +29,7 @@ import {
buildSources,
buildSettings,
describeAuthState,
resolveModelBaseUrl,
resolveModelBaseUrlState,
makeConfigStore,
makeAuthStore,
flushTelemetry,
@@ -238,7 +238,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
client: new Client({
identity,
settings,
baseUrl: resolveModelBaseUrl(sources),
...resolveModelBaseUrlState(sources),
}),
};
await runMiddleware(ctx);
+3 -3
View File
@@ -19,7 +19,7 @@ import {
resolveApiKey,
resolveConsole,
resolveOpenApi,
resolveModelBaseUrl,
resolveModelBaseUrlState,
selectApiKeyResolutionSources,
trackCommandExecution,
} from "bailian-cli-core";
@@ -128,7 +128,7 @@ export const authStage: Middleware = async (ctx, next) => {
const base = {
identity: ctx.identity,
settings,
baseUrl: resolveModelBaseUrl(sources),
...resolveModelBaseUrlState(sources),
};
if (command.auth === "apiKey") {
const capability = ctx.path.join(".");
@@ -145,7 +145,7 @@ export const authStage: Middleware = async (ctx, next) => {
}
ctx.client = new Client({
...base,
baseUrl: resolveModelBaseUrl(apiSources),
...resolveModelBaseUrlState(apiSources),
apiCred: cred,
});
if (cred) maybeShowStatusBar(settings, cred.token, cred);
@@ -16,6 +16,22 @@ export interface PipelineEnv {
settings: Settings;
}
/** Media steps that inherit Profile `watermark` when the YAML omits the field. */
export const PIPELINE_WATERMARK_STEPS = new Set(["image/generate", "image/edit", "video/generate"]);
/**
* Fill Profile watermark into a planned/executed step input.
* Explicit step `watermark` wins; otherwise use Settings (file default true).
*/
export function applyProfileWatermarkToStepInput(
stepType: string,
input: Record<string, unknown>,
settings: Settings,
): Record<string, unknown> {
if (!PIPELINE_WATERMARK_STEPS.has(stepType) || input.watermark !== undefined) return input;
return { ...input, watermark: settings.watermark };
}
/**
* Build the in-process env for pipeline steps. Uses the same source resolution
* as the CLI itself (env vars, config file; no CLI flags), but forces JSON
+9 -4
View File
@@ -1,5 +1,5 @@
import { PipelineError, toPipelineError } from "./errors.ts";
import { buildPipelineEnv } from "./bl-config.ts";
import { applyProfileWatermarkToStepInput, buildPipelineEnv } from "./bl-config.ts";
import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts";
import {
evaluateCondition,
@@ -156,12 +156,17 @@ async function executePipelineInternal(
if (options.dryRun) {
for (const planStep of topologicalOrder(plan)) {
const resolved = resolvePlannedStepInput(planStep.step, pipeline, normalizedRuntimeInput);
const plannedInput = applyProfileWatermarkToStepInput(
planStep.step.type,
resolved.redacted,
blEnv.settings,
);
const report: PipelineStepReport = {
id: planStep.step.id,
type: planStep.step.type,
status: "planned",
dependencies: planStep.dependencies,
input: resolved.redacted,
input: plannedInput,
...(planStep.step.when !== undefined ? { condition: "pending" } : {}),
};
reports.push(report);
@@ -170,14 +175,14 @@ async function executePipelineInternal(
timestamp: now(),
status: "planned",
step: stepEvent(planStep),
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
input: inputSummary(plannedInput, resolved.sensitiveKeys),
});
await emit({
type: "step.planned",
timestamp: now(),
status: "planned",
step: stepEvent(planStep),
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
input: inputSummary(plannedInput, resolved.sensitiveKeys),
...(planStep.step.when !== undefined ? { condition: "pending" as const } : {}),
});
}
+25 -8
View File
@@ -12,6 +12,7 @@ import {
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsrContextMessages,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
@@ -192,7 +193,8 @@ export async function imageGenerate(
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
};
const body: DashScopeImageRequest =
@@ -299,7 +301,8 @@ export async function imageEdit(
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
};
let body: DashScopeImageRequest;
@@ -460,7 +463,8 @@ export async function videoGenerate(
ratio: input.ratio || undefined,
duration: input.duration,
prompt_extend: resolveBooleanFlag(input["prompt-extend"], undefined, "prompt-extend"),
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
seed: input.seed,
},
};
@@ -563,6 +567,10 @@ export interface SpeechRecognizeInput {
diarization?: boolean;
"speaker-count"?: number;
"vocabulary-id"?: string;
/** Instant hot words (already structured; no JSON string parse needed). */
vocabulary?: Record<string, number>;
/** Context enhancement plain text. */
context?: string;
"channel-id"?: number;
"poll-interval"?: number;
}
@@ -602,9 +610,11 @@ export async function speechRecognize(
const unsupportedFlags: string[] = [];
if (input.diarization) unsupportedFlags.push("diarization");
if (input["speaker-count"] !== undefined) unsupportedFlags.push("speaker-count");
// input-audio Flash supports vocabulary_id; qwen3 sync Flash does not
if (route.flashFamily === "qwen3" && input["vocabulary-id"] !== undefined) {
unsupportedFlags.push("vocabulary-id");
// qwen3 sync Flash has no place for vocabulary_id / vocabulary / context in its body shape
if (route.flashFamily === "qwen3") {
if (input["vocabulary-id"] !== undefined) unsupportedFlags.push("vocabulary-id");
if (input.vocabulary !== undefined) unsupportedFlags.push("vocabulary");
if (input.context !== undefined) unsupportedFlags.push("context");
}
if (input["channel-id"] !== undefined) unsupportedFlags.push("channel-id");
if (unsupportedFlags.length > 0) {
@@ -648,6 +658,8 @@ export async function speechRecognize(
audioUrl: fileUrls[0]!,
language: input.language,
vocabularyId: input["vocabulary-id"],
vocabulary: input.vocabulary,
context: input.context,
flashFamily,
});
const response = await env.client.requestJson<Record<string, unknown>>({
@@ -671,14 +683,19 @@ export async function speechRecognize(
);
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url" ? { file_url: fileUrls[0]! } : { file_urls: fileUrls },
input: {
...(route.asyncInputStyle === "file_url"
? { file_url: fileUrls[0]! }
: { file_urls: fileUrls }),
...(input.context ? { context: buildAsrContextMessages(input.context) } : {}),
},
parameters: {
channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined,
...languageFields,
diarization_enabled: input.diarization,
speaker_count: input["speaker-count"],
vocabulary_id: input["vocabulary-id"],
vocabulary: input.vocabulary,
},
};
stripUndefined(body.parameters as Record<string, unknown>);
@@ -1,5 +1,9 @@
import { registerStep } from "../dispatcher.ts";
import { buildPipelineEnv, type PipelineEnv } from "../bl-config.ts";
import {
applyProfileWatermarkToStepInput,
buildPipelineEnv,
type PipelineEnv,
} from "../bl-config.ts";
import { isRecord } from "../utils.ts";
import {
textChat,
@@ -153,9 +157,13 @@ async function executeDirectBlStep(
input: Record<string, unknown>,
ctx: StepContext,
): Promise<StepResult> {
const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv();
if (ctx.dryRun) {
// Surface the Profile-effective watermark in dry-run so plans match runtime.
const plannedInput = applyProfileWatermarkToStepInput(id, input, env.settings);
return {
metadata: { dryRun: true, step: id, plannedInput: input },
metadata: { dryRun: true, step: id, plannedInput },
warnings: [
{ code: "dry_run_skipped", message: `Step ${id} was not executed in dry-run mode` },
],
@@ -167,7 +175,6 @@ async function executeDirectBlStep(
throw new Error(`No direct API handler registered for step: ${id}`);
}
const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv();
const data = await handler(env, input, ctx);
const builder = RESULT_BUILDERS[id];
if (builder) {
@@ -1,8 +1,9 @@
/** Shared --foo <bool> help text; keep wording consistent with actual CLI/request behavior. */
export const BOOL_FLAG_WATERMARK = {
"en-US": "Enable watermark (true/false). Omit flag to use CLI default (true).",
"zh-CN": "是否启用水印true/false。不传时使用 CLI 默认值 true",
"en-US":
"Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true).",
"zh-CN": "是否启用水印true/false。不传时使用 Profile 配置或 CLI 默认值 true。",
};
/** CLI sends prompt_extend=true when flag omitted (qwen-image edit, etc.). */
@@ -81,6 +81,28 @@ async function runAuth(context: RunContext): Promise<void> {
await authStage(context, async () => {});
}
test.each([undefined, "https://default.example.com", "https://dashscope.aliyuncs.com"])(
"service defaults follow the effective profile base URL after capability fallback: %s",
async (defaultBaseUrl) => {
useTempConfigDir();
await writeConfigFile({ api_key: "sk-default", base_url: defaultBaseUrl });
await writeConfigFile(
{
api_key: "sk-plan",
base_url: "https://plan.example.com",
api_key_capabilities: ["text.chat"],
},
"company-plan",
);
const context = makeContext(["sandbox", "list"]);
await runAuth(context);
expect(context.client.url("/service", () => "https://workspace.example.test")).toBe(
`${defaultBaseUrl ?? "https://workspace.example.test"}/service`,
);
expect(context.client.exportApiCredential()?.token).toBe("sk-default");
},
);
async function captureStderr(operation: () => Promise<void>): Promise<{
output: string;
error?: unknown;
@@ -68,6 +68,81 @@ test("pipeline speechRecognize routes input-audio flash to sync multimodal endpo
});
});
test("pipeline speechRecognize injects vocabulary and context on sync input-audio", async () => {
const { env, captured } = makeEnv();
await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen-audio-3.0-asr-flash",
vocabulary: { 奋斗者: 4 },
context: "奋斗者号",
},
makeCtx(),
);
expect(captured[0]?.body).toMatchObject({
parameters: { vocabulary: { 奋斗者: 4 } },
input: {
messages: [
{ role: "user", content: [{ type: "input_text", text: "奋斗者号" }] },
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
});
});
test("pipeline speechRecognize injects vocabulary and context on async filetrans", async () => {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
return { output: { task_id: "task-1", task_status: "PENDING" } };
}
return {
output: { task_id: "task-1", task_status: "SUCCEEDED", results: [] },
request_id: "r1",
};
});
await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen-audio-3.0-asr-flash-filetrans",
vocabulary: { 鲸落: 4 },
context: "鲸落 深海勇士",
"poll-interval": 0,
},
makeCtx(),
);
expect(captured[0]?.body).toMatchObject({
input: {
file_urls: ["https://example.com/a.wav"],
context: [{ role: "user", content: [{ type: "input_text", text: "鲸落 深海勇士" }] }],
},
parameters: { vocabulary: { 鲸落: 4 } },
});
});
test("pipeline speechRecognize rejects vocabulary on qwen3 sync flash", async () => {
const { env, captured } = makeEnv();
await expect(
speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen3-asr-flash",
vocabulary: { 奋斗者: 4 },
},
makeCtx(),
),
).rejects.toBeInstanceOf(PipelineError);
expect(captured).toHaveLength(0);
});
test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", async () => {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
@@ -0,0 +1,129 @@
import { expect, test } from "vite-plus/test";
import type { Client } from "bailian-cli-core";
import { applyProfileWatermarkToStepInput, type PipelineEnv } from "../src/pipeline/bl-config.ts";
import { imageEdit, imageGenerate, videoGenerate } from "../src/pipeline/steps/bl-api.ts";
import type { StepContext } from "../src/pipeline/types.ts";
type CapturedRequest = {
path?: string;
method?: string;
body?: {
parameters?: { watermark?: boolean };
};
async?: boolean;
};
function makeEnv(watermark: boolean): {
env: PipelineEnv;
captured: CapturedRequest[];
} {
const captured: CapturedRequest[] = [];
const client = {
uploadFile: async (source: string) => source,
requestJson: async (opts: CapturedRequest) => {
captured.push(opts);
if (opts.async) {
return { output: { task_id: "task-wm", task_status: "PENDING" } };
}
// Sync image response shape (qwen-image / wan2.7-image).
return {
request_id: "req-wm",
output: {
choices: [{ message: { content: [{ image: "https://example.com/out.png" }] } }],
},
};
},
} as unknown as Client;
return {
env: {
client,
settings: {
quiet: true,
output: "json",
outputExplicit: true,
timeout: 300,
watermark,
verbose: false,
dryRun: false,
telemetry: false,
} as PipelineEnv["settings"],
},
captured,
};
}
function makeCtx(): StepContext {
return { dryRun: false, signal: new AbortController().signal, timeoutSeconds: 1 };
}
test("pipeline imageGenerate inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
await imageGenerate(env, { prompt: "A cat" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline imageGenerate keeps step watermark over Profile", async () => {
const { env, captured } = makeEnv(false);
await imageGenerate(env, { prompt: "A cat", watermark: true }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(true);
});
test("pipeline imageEdit inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
await imageEdit(env, { prompt: "Blue sky", image: "https://example.com/in.png" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline videoGenerate inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
// First call submits async task; pollTaskWithOptions will keep polling — mock SUCCEEDED quickly.
const client = env.client as unknown as {
requestJson: (opts: CapturedRequest) => Promise<unknown>;
};
let calls = 0;
client.requestJson = async (opts: CapturedRequest) => {
captured.push(opts);
calls += 1;
if (calls === 1) {
return { output: { task_id: "task-wm", task_status: "PENDING" } };
}
return {
output: {
task_id: "task-wm",
task_status: "SUCCEEDED",
video_url: "https://example.com/out.mp4",
},
};
};
await videoGenerate(
env,
{ prompt: "A cat walks", "poll-interval": 0 },
{ ...makeCtx(), timeoutSeconds: 5 },
);
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline defaults watermark to true when Profile leaves the compliance default", async () => {
const { env, captured } = makeEnv(true);
await imageGenerate(env, { prompt: "A cat" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(true);
});
test("applyProfileWatermarkToStepInput fills omitted watermark from Settings", () => {
const settings = { watermark: false } as PipelineEnv["settings"];
expect(
applyProfileWatermarkToStepInput("image/generate", { prompt: "A cat" }, settings).watermark,
).toBe(false);
expect(
applyProfileWatermarkToStepInput(
"image/generate",
{ prompt: "A cat", watermark: true },
settings,
).watermark,
).toBe(true);
expect(
applyProfileWatermarkToStepInput("text/chat", { message: "hi" }, settings).watermark,
).toBeUndefined();
});
+8 -4
View File
@@ -1,7 +1,7 @@
---
name: bailian-cli
metadata:
version: "1.22.0"
version: "1.25.0"
requires:
bins: ["bl"]
description: >-
@@ -12,7 +12,8 @@ description: >-
用户点名百炼 / DashScope / `bl`,或继续既有 `bl` 工作流时直接使用。
共享协议consent / 版本预检 / 鉴权 / 错误上报)在 bailian-protocol官方安装 `bl skill init`
家族路由:生图/生视频/配音/语音合成/转写 → bailian-gen精调/微调/训练/数据集 → bailian-finetune
agents.yaml 托管 Agent → bailian-managed-agent联网搜索的模型路由Token Plan 自带搜索 vs MCP 搜索 + 兜底)→ bailian-web-search。
agents.yaml 托管 Agent → bailian-managed-agentSandbox 实例与模版 → bailian-sandbox
联网搜索的模型路由Token Plan 自带搜索 vs MCP 搜索 + 兜底)→ bailian-web-search。
不要用于普通问答、编程、写作、翻译、摘要、泛搜索,或图片理解等宿主自己能做的任务(普通问答、编程、写作、翻译、摘要、泛搜索不触发)。
未命名用量/额度问题:先问用户使用哪个产品,再运行 `bl usage` / `bl quota` 查询。
---
@@ -23,7 +24,7 @@ description: >-
> **Family hub** — This skill owns Bailian resource commands and the hub `reference/` (apps, knowledge, usage, auth, config, …).
> Shared protocol → [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md) (install the full family with `bl skill init`).
> Soft hand-offs by skill name (Read if installed; else `bl … --help` / prompt `bl skill init`): `bailian-gen` (media) · `bailian-finetune` (training) · `bailian-managed-agent` (agents.yaml IaC) · `bailian-web-search` (web search routing).
> Soft hand-offs by skill name (Read if installed; else `bl … --help` / prompt `bl skill init`): `bailian-gen` (media) · `bailian-finetune` (training) · `bailian-managed-agent` (agents.yaml IaC) · `bailian-sandbox` (Sandbox lifecycle) · `bailian-web-search` (web search routing).
> Do not invoke it for ordinary reasoning, coding, writing, translation, summarization, generic research, or image understanding the host agent can complete directly.
>
> **Install (supported):** `bl skill init`
@@ -40,6 +41,7 @@ Domain skills own their own generated reference trees (soft hand-off — do not
- `bailian-gen``image` / `video` / `speech` / `omni` / `vision` (fallback: `bl image\|video\|speech\|omni\|vision --help`)
- `bailian-finetune``dataset` / `finetune` / `deploy` (fallback: `bl dataset\|finetune\|deploy --help`)
- `bailian-managed-agent``managed-agent` (fallback: `bl managed-agent --help`)
- `bailian-sandbox``sandbox` (fallback: `bl sandbox --help`)
- `bailian-web-search` → web search **routing** (hub still owns `reference/search.md` flags; **must** route via this skill before `bl search web`)
Auto-generated from the CLI source at build time (`pnpm --filter bailian-cli run generate:reference`). Before running an unfamiliar command:
@@ -54,7 +56,7 @@ Do not guess flags — use the reference files or `--help`.
## When to use which command
Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml, soft hand-off to the domain skill.
Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml / Sandbox, soft hand-off to the domain skill.
| User intent | Command | Notes |
| ------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------- |
@@ -72,6 +74,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian-
| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed |
| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed |
| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed |
| Token Plan harness entitlement quota | `bl token-plan harness-quota` | Console auth; issued quota usage + still-issuing harnesses |
| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed |
| Console API (advanced) | `bl console call` | Console auth |
| Bailian workspace listing | `bl workspace list` | Console auth |
@@ -79,6 +82,7 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian-
| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` |
| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` |
| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` also require `plan` |
| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` |
| Web search (model-aware routing) | → skill `bailian-web-search` | Token Plan vs MCP path + fallback; fallback: `bl search web --help` |
Flags, usage, and examples: see hub [`reference/`](reference/index.md) or `bl <command> --help` — do not guess flags. Domain command details live in the owning skill's `reference/`.
+8 -4
View File
@@ -88,10 +88,10 @@ bl config list --output json
#### Flags
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--key <key>` | string | yes | Config key (language, base*url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) |
| `--value <value>` | string | yes | Value to set |
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--key <key>` | string | yes | Config key (language, base*url, output, output_dir, timeout, watermark, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) |
| `--value <value>` | string | yes | Value to set |
#### Examples
@@ -107,6 +107,10 @@ bl config set --key output --value json
bl config set --key timeout --value 600
```
```bash
bl config set --key watermark --value false
```
```bash
bl config set --key base_url --value https://dashscope.aliyuncs.com
```
+2 -1
View File
@@ -95,6 +95,7 @@ Use this index for the skill-scoped quick index and global flags.
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
| `bl token-plan harness-quota` | Console | Show Token Plan harness entitlement quota usage | [token-plan.md](token-plan.md) |
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
| `bl update` | No Auth | Update the CLI to the latest or a specified version | [update.md](update.md) |
| `bl usage coding-plan` | Console | Show Coding Plan quota usage | [usage.md](usage.md) |
@@ -127,7 +128,7 @@ Use this index for the skill-scoped quick index and global flags.
| `search` | `web` | [search.md](search.md) |
| `skill` | `add`, `init`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `text` | `chat` | [text.md](text.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `harness-quota`, `list-seats` | [token-plan.md](token-plan.md) |
| `update` | `(root)` | [update.md](update.md) |
| `usage` | `coding-plan`, `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) |
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
+40 -6
View File
@@ -7,12 +7,13 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ---------------------------- | -------------- | ----------------------------------------- |
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization |
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members |
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat |
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details |
| Command | Authentication | Description |
| ----------------------------- | -------------- | ----------------------------------------------- |
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization |
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members |
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat |
| `bl token-plan harness-quota` | Console | Show Token Plan harness entitlement quota usage |
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details |
## Command details
@@ -118,6 +119,39 @@ bl token-plan create-key --account-id acc_123 --workspace-id ws_456
bl token-plan create-key --account-id acc_123 --workspace-id ws_456 --description 'Dev key'
```
### `bl token-plan harness-quota`
| Field | Value |
| ------------------ | ----------------------------------------------- |
| **Name** | `token-plan harness-quota` |
| **Description** | Show Token Plan harness entitlement quota usage |
| **Authentication** | Console |
| **Usage** | `bl token-plan harness-quota [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ---------------------------------------- | ------ | -------- | ---------------------------------------------------------- |
| `--type <official_tool\|infrastructure>` | string | no | Filter harness list by type: official_tool, infrastructure |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Examples
```bash
bl token-plan harness-quota
```
```bash
bl token-plan harness-quota --type official_tool
```
```bash
bl token-plan harness-quota --output json
```
### `bl token-plan list-seats`
| Field | Value |
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-finetune
metadata:
version: "1.22.0"
version: "1.25.0"
requires:
bins: ["bl"]
description: >-
+25 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-gen
metadata:
version: "1.22.0"
version: "1.25.0"
requires:
bins: ["bl"]
description: >-
@@ -45,8 +45,27 @@ Unless the user explicitly specifies a model, omit `--model` and let the CLI use
For ASR model selection, keep `fun-asr` (or other `*-filetrans`) for long recordings, repeated files, speaker diarization, or asynchronous task IDs. For one local or remote audio file up to about five minutes when the user asks for low-latency Flash models, use `--model fun-asr-flash-2026-06-15`, `--model qwen-audio-3.0-asr-flash`, or `--model qwen3-asr-flash`. Flash recognition is synchronous and accepts exactly one file per call.
To improve ASR accuracy with domain terms:
- Prefer instant `--vocabulary` / `--context` on `bl speech recognize` when the model is Qwen-Audio-3.0-ASR-Flash series (and Fun-ASR-Flash for `--context` only) — no pre-built vocabulary needed. Start weights at 4 (do not default everything to 5). `--context` must list the target words themselves; a topic description alone has little effect.
- Use `bl speech vocabulary create` + `--vocabulary-id` for Fun-ASR / Paraformer, or whenever the same hot words must be reused across requests. The vocabulary `--model` must exactly match recognize `--model` (otherwise the vocabulary is silently ignored). Each account may have at most 10 vocabularies; delete unused ones.
Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl <command> --help` — do not guess flags.
## Watermark configuration
Image generation and editing, video generation and editing, and reference-to-video enable watermarks by default. Change the default for the active Profile with:
```bash
bl config set --key watermark --value false
```
Set it to `true` to enable watermarks again. To update a named Profile without switching Profiles, add `--config <name>`:
```bash
bl config set --config media --key watermark --value false
```
## Local files (mandatory)
Any command that accepts a **file URL** also accepts a **local path**; the CLI uploads to DashScope temporary storage (`oss://`, 48h) automatically. If the user gives a local file, pass the path directly — never ask them to upload or host a URL first.
@@ -68,6 +87,11 @@ bl vision describe --image ./photo.jpg --prompt "图里有什么?"
bl vision describe --video ./clip.mp4 --prompt "总结视频内容"
bl omni --message "Describe the video content" --video ./demo.mp4 --text-only
bl speech synthesize --text "Hello, welcome to Bailian" --out hello.mp3
bl speech recognize --url ./meeting.wav --model qwen-audio-3.0-asr-flash-filetrans \
--vocabulary '{"奋斗者":4}' --context "奋斗者号"
VOCAB=$(bl speech vocabulary create --model fun-asr --prefix demo --words '{"奋斗者":4}' --quiet)
bl speech recognize --url ./meeting.wav --model fun-asr --vocabulary-id "$VOCAB"
bl speech vocabulary delete --id "$VOCAB" --yes
```
## Output language
+2 -2
View File
@@ -36,7 +36,7 @@ Index: [index.md](index.md)
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--function <name>` | string | no | wanx\*-imageedit function (default: description_edit). Examples: stylization_all, description_edit |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--out-dir <dir>` | string | no | Download images to directory |
| `--out-prefix <prefix>` | string | no | Filename prefix (default: edited) |
| `--async` | switch | no | Return async task id without waiting |
@@ -99,7 +99,7 @@ bl image edit --image ./photo.png --prompt "Replace the background with a beach"
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag: true for qwen-image sync; parameter omitted on async models (API default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--async` | switch | no | Return async task id without waiting |
| `--concurrent <n>` | number | no | Run N parallel requests (default: 1) |
| `--out-dir <dir>` | string | no | Download images to directory |
+25 -20
View File
@@ -9,29 +9,34 @@ Use this index for the skill-scoped quick index and global flags.
## Quick index
| Command | Authentication | Description | Detail |
| ---------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) |
| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) |
| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) |
| `bl speech synthesize` | API Key | Synthesize speech from text | [speech.md](speech.md) |
| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) |
| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) |
| `bl video generate` | API Key | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | [video.md](video.md) |
| `bl video ref` | API Key | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) |
| `bl video task get` | API Key | Query async task status | [video.md](video.md) |
| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) |
| Command | Authentication | Description | Detail |
| ----------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) |
| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) |
| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) |
| `bl speech synthesize` | API Key | Synthesize speech from text | [speech.md](speech.md) |
| `bl speech vocabulary create` | API Key | Create a precompiled hot-word vocabulary for ASR | [speech.md](speech.md) |
| `bl speech vocabulary delete` | API Key | Delete a precompiled hot-word vocabulary | [speech.md](speech.md) |
| `bl speech vocabulary get` | API Key | Get details of a precompiled hot-word vocabulary | [speech.md](speech.md) |
| `bl speech vocabulary list` | API Key | List precompiled hot-word vocabularies | [speech.md](speech.md) |
| `bl speech vocabulary update` | API Key | Replace the contents of a precompiled hot-word vocabulary | [speech.md](speech.md) |
| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) |
| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) |
| `bl video generate` | API Key | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | [video.md](video.md) |
| `bl video ref` | API Key | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) |
| `bl video task get` | API Key | Query async task status | [video.md](video.md) |
| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) |
## By group
| Group | Commands | Reference |
| -------- | ------------------------------------------------- | ---------------------- |
| `image` | `edit`, `generate` | [image.md](image.md) |
| `omni` | `(root)` | [omni.md](omni.md) |
| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) |
| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) |
| `vision` | `describe` | [vision.md](vision.md) |
| Group | Commands | Reference |
| -------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `image` | `edit`, `generate` | [image.md](image.md) |
| `omni` | `(root)` | [omni.md](omni.md) |
| `speech` | `recognize`, `synthesize`, `vocabulary create`, `vocabulary delete`, `vocabulary get`, `vocabulary list`, `vocabulary update` | [speech.md](speech.md) |
| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) |
| `vision` | `describe` | [vision.md](vision.md) |
## Global flags
+228 -18
View File
@@ -7,10 +7,15 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ---------------------- | -------------- | ----------------------------------------------------------------- |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) |
| `bl speech synthesize` | API Key | Synthesize speech from text |
| Command | Authentication | Description |
| ----------------------------- | -------------- | ----------------------------------------------------------------- |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) |
| `bl speech synthesize` | API Key | Synthesize speech from text |
| `bl speech vocabulary create` | API Key | Create a precompiled hot-word vocabulary for ASR |
| `bl speech vocabulary delete` | API Key | Delete a precompiled hot-word vocabulary |
| `bl speech vocabulary get` | API Key | Get details of a precompiled hot-word vocabulary |
| `bl speech vocabulary list` | API Key | List precompiled hot-word vocabularies |
| `bl speech vocabulary update` | API Key | Replace the contents of a precompiled hot-word vocabulary |
## Command details
@@ -25,20 +30,22 @@ Index: [index.md](index.md)
#### Flags
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | array | yes | Audio file URL or local file path (repeatable, max 100) |
| `--model <model>` | string | no | Model ID (default: configured Profile ASR model, otherwise fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash |
| `--language <lang>` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language |
| `--diarization` | switch | no | Enable automatic speaker diarization |
| `--speaker-count <n>` | number | no | Expected number of speakers (requires --diarization) |
| `--vocabulary-id <id>` | string | no | Hot-word vocabulary ID for improved accuracy |
| `--channel-id <n>` | number | no | Audio channel ID (default: 0) |
| `--out <path>` | string | no | Save full transcription result to JSON file |
| `--async` | switch | no | Return async task id without waiting |
| `--poll-interval <seconds>` | number | no | Polling interval in seconds (default: 2) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | array | yes | Audio file URL or local file path (repeatable, max 100) |
| `--model <model>` | string | no | Model ID (default: configured Profile ASR model, otherwise fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash |
| `--language <lang>` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language |
| `--diarization` | switch | no | Enable automatic speaker diarization |
| `--speaker-count <n>` | number | no | Expected number of speakers (requires --diarization) |
| `--vocabulary-id <id>` | string | no | Pre-built hot-word vocabulary ID (create it via `speech vocabulary create`). Its target_model must exactly match --model, otherwise it is silently ignored. Wider model support than --vocabulary, including Fun-ASR and Paraformer |
| `--vocabulary <json>` | string | no | Instant hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'. Weight 1-5 (4 recommended; higher values can hurt other words), 50 for super hot word. No pre-built vocabulary needed. Takes effect only on Qwen-Audio-3.0-ASR-Flash models |
| `--context <text>` | string | no | Context enhancement word list to improve accuracy on proper nouns; must contain the target words themselves (a topic description alone has little effect); max 400 chars. Takes effect only on Qwen-Audio-3.0-ASR-Flash and Fun-ASR-Flash models |
| `--channel-id <n>` | number | no | Audio channel ID (default: 0) |
| `--out <path>` | string | no | Save full transcription result to JSON file |
| `--async` | switch | no | Return async task id without waiting |
| `--poll-interval <seconds>` | number | no | Polling interval in seconds (default: 2) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
@@ -62,6 +69,14 @@ bl speech recognize --url https://example.com/audio.mp3 --language zh
bl speech recognize --url https://example.com/audio.mp3 --vocabulary-id vocab-abc123
```
```bash
bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --vocabulary '{"奋斗者":4,"鲸落":4}'
```
```bash
bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --context "奋斗者号 鲸落 深海勇士"
```
```bash
bl speech recognize --url https://example.com/audio.mp3 --out result.json
```
@@ -148,3 +163,198 @@ bl speech synthesize --text "Hello" --voice <voice_id> --stream | afplay -
```bash
bl speech synthesize --text "Hello" --voice <voice_id> --stream | ffplay -nodisp -autoexit -f s16le -ar 24000 -ac 1 -
```
### `bl speech vocabulary create`
| Field | Value |
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
| **Name** | `speech vocabulary create` |
| **Description** | Create a precompiled hot-word vocabulary for ASR |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary create --model <model> --prefix <prefix> (--words <json> \| --words-file <path>) [flags]` |
#### Flags
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--model <model>` | string | yes | ASR model this vocabulary is built for (required). Must exactly match the --model passed to `speech recognize` later, otherwise the vocabulary is silently ignored |
| `--prefix <prefix>` | string | yes | Custom vocabulary prefix (required). Digits and lowercase letters only, max 10 chars |
| `--words <json>` | string | no | Hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file |
| `--words-file <path>` | string | no | JSON file with the hot words (use - for stdin) |
| `--lang <code>` | string | no | Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- The --model must exactly match the --model used later with `speech recognize --vocabulary-id`; a mismatch causes silent failure with no error.
- Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits.
- Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is.
- Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word.
#### Examples
```bash
bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4,"Jingluo":4}'
```
```bash
bl speech vocabulary create --model paraformer-v2 --prefix demo --words '[{"text":"Fendouzhe","weight":4,"lang":"zh"}]'
```
```bash
bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4}' --lang zh
```
```bash
bl speech vocabulary create --model fun-asr --prefix demo --words-file ./hotwords.json
```
```bash
bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4}' --quiet
```
### `bl speech vocabulary delete`
| Field | Value |
| ------------------ | -------------------------------------------------------------------------------- |
| **Name** | `speech vocabulary delete` |
| **Description** | Delete a precompiled hot-word vocabulary |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary delete --id <id>` |
| **Risk** | `high` |
| **Risk message** | This permanently deletes the specified hot-word vocabulary and cannot be undone. |
> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope.
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | --------------------------------- |
| `--id <id>` | string | yes | Hot-word vocabulary ID (required) |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
```bash
bl speech vocabulary delete --id vocab-demo-xxx --dry-run
```
```bash
# Only after explicit user confirmation:
bl speech vocabulary delete --id vocab-demo-xxx --yes
```
### `bl speech vocabulary get`
| Field | Value |
| ------------------ | ------------------------------------------------ |
| **Name** | `speech vocabulary get` |
| **Description** | Get details of a precompiled hot-word vocabulary |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary get --id <id>` |
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | --------------------------------- |
| `--id <id>` | string | yes | Hot-word vocabulary ID (required) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- Use this command to confirm target_model before calling `speech recognize --vocabulary-id`; a model mismatch causes silent failure.
#### Examples
```bash
bl speech vocabulary get --id vocab-demo-xxx
```
```bash
bl speech vocabulary get --id vocab-demo-xxx --quiet
```
### `bl speech vocabulary list`
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------ |
| **Name** | `speech vocabulary list` |
| **Description** | List precompiled hot-word vocabularies |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary list [--prefix <prefix>] [--page <n>] [--page-size <n>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------- | ------ | -------- | --------------------------------------------------------------------------------- |
| `--prefix <prefix>` | string | no | Filter by vocabulary prefix |
| `--page <n>` | number | no | Page number, 1-based (default: 1). Mapped to API page_index (0-based) as page - 1 |
| `--page-size <n>` | number | no | Results per page (default: 10) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- List responses do not include target_model; use `speech vocabulary get` to inspect the model a vocabulary was built for.
- Vocabularies with status UNDEPLOYED are silently ignored by ASR.
#### Examples
```bash
bl speech vocabulary list
```
```bash
bl speech vocabulary list --prefix demo
```
```bash
bl speech vocabulary list --page 2 --page-size 20
```
### `bl speech vocabulary update`
| Field | Value |
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
| **Name** | `speech vocabulary update` |
| **Description** | Replace the contents of a precompiled hot-word vocabulary |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary update --id <id> (--words <json> \| --words-file <path>) [flags]` |
| **Risk** | `high` |
| **Risk message** | This fully replaces all hot words in the vocabulary. Entries not listed will be discarded and cannot be undone. |
> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope.
#### Flags
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--id <id>` | string | yes | Hot-word vocabulary ID (required) |
| `--words <json>` | string | no | Hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file |
| `--words-file <path>` | string | no | JSON file with the hot words (use - for stdin) |
| `--lang <code>` | string | no | Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- update is a full replace, not an append. Prefer --dry-run first to preview the complete vocabulary that will be written.
- Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits.
- Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is.
- Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word.
#### Examples
```bash
bl speech vocabulary update --id vocab-demo-xxx --words '{"Fendouzhe":4}' --dry-run
```
```bash
# Only after explicit user confirmation:
bl speech vocabulary update --id vocab-demo-xxx --words '{"Fendouzhe":4,"Jingluo":4}' --yes
```
+22 -22
View File
@@ -56,26 +56,26 @@ bl video download --task-id 3b256896-xxxx --out video.mp4 --quiet
#### Flags
| Flag | Type | Required | Description |
| -------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- |
| `--model <model>` | string | no | Model ID (default: happyhorse-1.0-video-edit) |
| `--video <url>` | string | yes | Input video URL or local file (mp4/mov, 2-10s) |
| `--prompt <text>` | string | no | Edit instruction (e.g. "Convert the scene to a claymation style") |
| `--ref-image <url>` | string | no | Reference image URL (up to 4, comma-separated) |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--resolution <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) |
| `--duration <seconds>` | number | no | Output video duration in seconds (2-10) |
| `--audio-setting <auto\|origin>` | string | no | Audio: auto (default) or origin (keep original) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |
| `--concurrent <n>` | number | no | Run N parallel requests (default: 1) |
| `--poll-interval <seconds>` | number | no | Polling interval when waiting (default: 15) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| -------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------ |
| `--model <model>` | string | no | Model ID (default: happyhorse-1.0-video-edit) |
| `--video <url>` | string | yes | Input video URL or local file (mp4/mov, 2-10s) |
| `--prompt <text>` | string | no | Edit instruction (e.g. "Convert the scene to a claymation style") |
| `--ref-image <url>` | string | no | Reference image URL (up to 4, comma-separated) |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--resolution <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) |
| `--duration <seconds>` | number | no | Output video duration in seconds (2-10) |
| `--audio-setting <auto\|origin>` | string | no | Audio: auto (default) or origin (keep original) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |
| `--concurrent <n>` | number | no | Run N parallel requests (default: 1) |
| `--poll-interval <seconds>` | number | no | Polling interval when waiting (default: 15) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
@@ -117,7 +117,7 @@ bl video edit --video https://example.com/input.mp4 --prompt "Put clothes on the
| `--ratio <ratio>` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) |
| `--duration <seconds>` | number | no | Video duration in seconds (default: 5) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--file <url-or-path>` | string | no | Reference file URL or local path for file-to-video (wan3.0-video only; mutually exclusive with --image/--last-frame) |
@@ -172,7 +172,7 @@ bl video generate --prompt "A cat playing with a ball" --watermark false
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1) |
| `--duration <seconds>` | number | no | Video duration in seconds (default: 5) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-managed-agent
metadata:
version: "1.22.0"
version: "1.25.0"
requires:
bins: ["bl"]
description: >-
+3 -2
View File
@@ -1,13 +1,13 @@
---
name: bailian-protocol
metadata:
version: "1.22.0"
version: "1.25.0"
requires:
bins: ["bl"]
description: >-
阿里云百炼 `bl` 家族共享执行协议consent 确认、版本预检、鉴权/安装、错误上报、本地文件与输出约定)。
不是面向用户意图的业务入口;当任一 bailian-* 业务 skillbailian-cli / bailian-gen /
bailian-finetune / bailian-managed-agent / bailian-web-search执行前需要公共上下文或用户首次安装/鉴权/`bl` 报错需上报时读取本 skill。
bailian-finetune / bailian-managed-agent / bailian-sandbox / bailian-web-search执行前需要公共上下文或用户首次安装/鉴权/`bl` 报错需上报时读取本 skill。
官方安装为整包:`bl skill init`(与业务 skill 同装)。
---
@@ -60,6 +60,7 @@ After approval, treat Bailian as selected for the current task. Do not ask again
| 生图 / 生视频 / 语音 / 图片理解 / 视频理解 / omni / vision | `bailian-gen` | `bl image\|video\|speech\|omni\|vision --help` |
| 精调 / 数据集 / 部署 | `bailian-finetune` | `bl dataset\|finetune\|deploy --help` |
| agents.yaml IaC | `bailian-managed-agent` | `bl managed-agent --help` |
| 百炼 Sandbox 实例 / 模版生命周期 | `bailian-sandbox` | `bl sandbox --help` |
| 联网搜索 / web search模型路由 + 兜底) | `bailian-web-search` | `bl search web --help` |
| 应用 / 知识库 / 用量 / 鉴权配置等资源 | `bailian-cli` | `bl app\|knowledge\|usage\|auth\|config --help` |
+75
View File
@@ -0,0 +1,75 @@
---
name: bailian-sandbox
metadata:
version: "1.25.0"
requires:
bins: ["bl"]
description: >-
阿里云百炼 Sandbox 沙箱实例与模版生命周期管理入口:用户要创建、查询、连接、暂停、恢复或释放百炼沙箱,
或查看内置基础镜像、上传模版挂载文件、创建、更新、查询、删除沙箱模版、查看模版构建状态时,使用 `bl sandbox`
用户要访问已创建实例、打开 WebShell、连接 browser-use 浏览器自动化或 VNC 实时画面时,先获取连接信息,再连接数据面。
不用于宿主执行沙箱设置、E2B 官方云资源或通用文件传输。
agents.yaml 托管 Agent / Session / Environment 管理交给 bailian-managed-agent。
官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。
---
# Bailian Sandbox (`bl sandbox`)
Before running `bl`, read the shared [bailian-protocol](../bailian-protocol/SKILL.md) for consent, high-risk confirmation, version checks, authentication, and error handling. If it is missing, stop execution and prompt the user to install the full family with `bl skill init`.
## Scope and setup
- Manage Sandbox instances and templates through Bailian's E2B-compatible REST control plane. No E2B SDK or E2B API key is required; authentication uses the Bailian API Key as an Authorization Bearer token.
- Resolve Base URL through the same CLI chain as Managed Agent: `--base-url` > `DASHSCOPE_BASE_URL` > login/profile `base_url`. Use an origin such as `https://workspace.cn-beijing.maas.aliyuncs.com`; the CLI strips URL paths/query/fragment and appends `/api/v1/agentstudio/sandbox` for lifecycle operations, or `/api/v1/agentstudio/files` for template file uploads. The saved API Key is reused. Profile capability fallback follows the shared protocol for both the key and Base URL.
- If no Base URL is configured, resolve the workspace from `--workspace-id`, then `BAILIAN_WORKSPACE_ID`, then configured `workspace_id`, and use `https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox`. With a configured Base URL, the workspace flag is optional. The service currently supports `cn-beijing` and requires prior Sandbox SLR authorization.
- No `agents.yaml` or local IaC state is required. `get` / `connect` return instance connection information; neither opens an interactive shell or browser. For WebShell, CDP, or VNC, use that information with the runtime APIs described below. Do not invent `bl sandbox exec`, `webshell`, or browser subcommands.
## Choose the operation
| User intent | Command family |
| --------------------------------------- | --------------------------------------------------------- |
| Discover built-in base image presets | `bl sandbox official-images` (offline, no authentication) |
| Upload a local file for template mounts | `bl sandbox file upload` |
| Inspect or create instances | `bl sandbox list` / `get` / `create` |
| Connect, pause, or resume an instance | `bl sandbox connect` / `pause` / `resume` |
| Release an instance | `bl sandbox delete` |
| Inspect or build templates | `bl sandbox template list` / `get` / `create` / `update` |
| Check a submitted template build | `bl sandbox template build-status` |
| Delete a template | `bl sandbox template delete` |
Read [reference/index.md](reference/index.md) and the relevant section of [reference/sandbox.md](reference/sandbox.md) for exact flags, usage, and examples, or run the matching command with `--help`. Do not guess flags.
To save the origin in an isolated Profile, use `bl auth login --config sandbox --api-key <key> --base-url <origin>`. For a one-command override, use `bl sandbox list --base-url <origin>`. Custom gateways also apply to template build-status polling.
For built-in template images, discover the preset ID or Chinese name through `sandbox official-images`, then pass it to template create/update with `--image`. This fills both `fromImage` and `imageName`; `--image-name` alone remains a display name, not an image selector. Presets override the body's image fields; explicit `--from-image` / `--image-name` override the corresponding preset fields. Presets are pinned cn-beijing image URLs, not a live catalog or an availability guarantee for other environments. Omitting `--image` does not change existing defaults or update a template's image implicitly. See the generated reference for the catalog and exact flags.
## Connect to an instance
For requests to access a running instance, open WebShell, control a browser, or show its live desktop, read [assets/instance-connections.md](assets/instance-connections.md). It contains the connection workflow, API payloads, verification steps, and official documentation links.
- Obtain fresh JSON with `bl sandbox get --sandbox-id <id> --show-credentials --output json`. Use `envdAccessToken` as `X-Access-Token`; the control-plane Bailian API Key is a different credential.
- WebShell uses the returned `envdUrl` and envd PTY RPCs. The official browser template uses port 3000 on `{port}-{sandboxID}.{domain}` for CDP and VNC. These ports and paths come from the template contract, not fields returned by `get`; do not apply them to other images without checking their documentation.
- When the user asks to open or access the instance, complete the connection and verify a terminal command or rendered browser page. Returning connection JSON or a successful health check alone does not complete that request. A local proxy URL is a host-side access page, not a URL returned by the Sandbox API.
## Operational boundaries
- Mutating commands act on remote resources. Only perform the requested operation and scope; read-only discovery does not authorize creating, pausing, resuming, or deleting resources.
- Instance and template deletion are high-risk. Follow the shared protocol: show the exact target and risk, then wait for explicit confirmation before adding `--yes`. Treat `requires_confirmation` as a stop signal, not a reason to retry automatically.
- Connection credentials are redacted by default. A request to connect to or access the instance needs the tokens: use `--show-credentials` for that operation, capture the result privately, and keep tokens out of chat summaries, logs, URLs, and committed files.
- Template create/update wait by polling the build-status endpoint, not template details. `--async` returns after the submission response with `templateID` / `buildID`; it does not mean the build is ready. Use those IDs with `template build-status` to check completion.
- Global `--timeout` limits HTTP requests and total template-build polling. `--instance-timeout` sets instance lifetime; these are different limits. A polling timeout does not prove the remote build failed or stopped; check its status before submitting another build.
- `--body` accepts a JSON object inline or through `@path`; explicit flags override body fields. For local template mounts, first use `bl sandbox file upload`: it sends multipart `file` and fixed `source=sandbox_template` directly to `/api/v1/agentstudio/files` with the Bailian API Key. Use the returned `id` as `mntConfig[].originFileId`, with `mountPath` and optional `originFileName`, in template create/update `--body`. The upload and template must use the same workspace. This is not the temporary OSS upload from `bl file upload` or a transfer into a running instance.
- File upload returns immediately after the upload response and does not poll security review. `status=checking` is not ready to mount; only use files whose status is `available`. `--quiet` returns the File ID only; inspect the normal/JSON response for status. Upload alone does not authorize creating a template or instance.
## Common hand-offs
Refer to sibling skills by name: read them if installed; otherwise use the command's `--help` or prompt `bl skill init`.
- Managed Agent / Session / Environment resources or `agents.yaml` IaC → `bailian-managed-agent` (`bl managed-agent --help`).
- Workspace discovery or CLI login/configuration → `bailian-cli` (`bl workspace --help` / `bl auth --help` / `bl config --help`).
## references
- [bailian-protocol](../bailian-protocol/SKILL.md) — shared execution protocol, installed with `bl skill init`
- [reference/](reference/index.md) — generated command reference
- [Instance connections](assets/instance-connections.md) — WebShell, browser CDP, VNC, and official runtime documentation
@@ -0,0 +1,152 @@
# Connect to a Sandbox instance
Use this guide when the user asks to access an instance, open WebShell, automate its browser, or view the live desktop. Lifecycle operations stay in `bl sandbox`; runtime access uses the returned connection information with HTTP, CDP, or VNC clients.
## Official documentation and endpoint ownership
- [Bailian Sandbox SDK](https://docs.agent.bailian.aliyun.com/zh/sandbox/sdk) ([Markdown](https://docs.agent.bailian.aliyun.com/zh/sandbox/sdk.md)) — separates control-plane operations from runtime access; documents command execution through `POST /process.Process/Start`.
- [Browser template](https://help.aliyun.com/en/functioncompute/browser-template) — the **WebSocket endpoints** section defines port 3000, `/health`, `/ws/automation`, `/ws/livestream`, and `X-Access-Token` authentication.
- [Use Browser Use Sandbox](https://help.aliyun.com/en/functioncompute/use-browser-use-sandbox) — browser readiness, Puppeteer / BrowserUse integration, page navigation, and screenshots.
- [Connect protocol](https://connectrpc.com/docs/protocol/) — streaming envelopes, unary JSON requests, and end-of-stream errors used by envd.
The browser paths below belong to the official browser image, not to every Sandbox template. Custom images or gateways may have different ports and paths; check their contract before constructing a URL. The envd PTY payloads below were verified with Bailian `envdVersion=0.2.4`; use the returned version when diagnosing compatibility changes.
## 1. Obtain connection information
Reuse the requested instance. Create one only when creation is part of the user's request. Follow the shared protocol before running `bl`; use the generated command reference for lifecycle flags.
Capture the credential-bearing result in a private temporary directory:
```bash
umask 077
sandbox_connection_dir="$(mktemp -d)"
bl sandbox get --sandbox-id <sandbox-id> --show-credentials --output json > "$sandbox_connection_dir/connection.json"
```
Check the command exit code before reading the file. `create` and `connect` can also return connection fields with `--show-credentials`, but neither command opens a terminal or browser. Without that flag, tokens are `[REDACTED]` and cannot authenticate a runtime request.
| Returned field | Use |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `envdUrl` | Runtime base URL for WebShell / process RPCs; use it directly. |
| `envdAccessToken` | Value for the runtime `X-Access-Token` header; not the Bailian API Key. |
| `sandboxID`, `domain` | Construct a documented service host as `{port}-{sandboxID}.{domain}`. Use `sandboxID`, not `bailianSandboxId` or `templateID`. |
| `state`, `endAt`, `envdVersion` | Check readiness, expiry, and the runtime version. |
| `trafficAccessToken` | Separate optional credential; do not substitute it for `envdAccessToken` in the flows below. |
Require a running instance and a usable token. If it is paused, resume only within the requested scope, then fetch fresh connection information. If it has expired or no longer exists, report that state rather than retrying an old token or silently creating a replacement. Do not hardcode a previously observed domain, instance ID, or token.
## 2. Check the intended service
For the official browser template, set `browserHost = "3000-" + sandboxID + "." + domain`.
| Capability | Address | Transport |
| ------------------------- | ----------------------------------- | -------------------------- |
| envd readiness | `{envdUrl}/health` | HTTPS GET |
| WebShell / shell commands | `{envdUrl}/process.Process/Start` | HTTPS POST, Connect stream |
| Browser readiness | `https://{browserHost}/health` | HTTPS GET |
| Browser automation | `wss://{browserHost}/ws/automation` | CDP over WebSocket |
| Live browser desktop | `wss://{browserHost}/ws/livestream` | VNC / RFB over WebSocket |
Send `X-Access-Token: <envdAccessToken>` on each request. The control-plane `Authorization: Bearer <Bailian API Key>` is not this data-plane header. Do not send the Bailian API Key to runtime service hosts.
This Python example uses only the standard library and keeps the token out of command arguments and output. It checks envd and, for a browser instance, the browser service:
```bash
python3 - "$sandbox_connection_dir/connection.json" <<'PYTHON'
import json
import sys
import urllib.request
from pathlib import Path
connection = json.loads(Path(sys.argv[1]).read_text())
token = connection.get("envdAccessToken")
if not token or token == "[REDACTED]":
raise SystemExit("Fetch connection information with --show-credentials first")
browser_host = f"3000-{connection['sandboxID']}.{connection['domain']}"
for base_url in [connection["envdUrl"], f"https://{browser_host}"]:
request = urllib.request.Request(
base_url.rstrip("/") + "/health",
headers={"X-Access-Token": token},
)
with urllib.request.urlopen(request, timeout=15) as response:
print(base_url, response.status, response.read(1024).decode())
PYTHON
```
An authenticated `/health` response of 200 establishes service readiness only. A root-path `/` response of 404 does not prove that the runtime is unavailable; these endpoints need not serve an HTML homepage. For a newly starting browser service, retry health checks with a bounded deadline before opening CDP or VNC.
## 3. Open WebShell through envd PTY
Use a terminal client or a host-side page with a terminal emulator such as xterm.js. Reuse an available envd client instead of recreating the protocol when possible.
1. POST to `{envdUrl}/process.Process/Start` with `X-Access-Token`, `Connect-Protocol-Version: 1`, `Content-Type: application/connect+json`, and `connect-content-encoding: identity`.
2. Encode the JSON payload below in a Connect envelope: one zero flags byte, a four-byte big-endian payload length, then the UTF-8 JSON bytes. This is not a plain JSON POST.
```json
{
"process": {
"cmd": "/bin/bash",
"args": ["--noprofile", "--norc", "-i"],
"envs": { "TERM": "xterm-256color" }
},
"pty": { "size": { "cols": 100, "rows": 30 } }
}
```
Choose a shell available in the image; the browser image used here has `/bin/bash`. Consume the response incrementally instead of waiting for the interactive process to finish:
- Save `event.start.pid` as the process handle.
- Decode Base64 `event.data.pty` into bytes and write them to the terminal; preserve multibyte characters across chunks. For non-PTY processes, output may use `stdout` / `stderr` instead.
- Handle `event.end.exitCode`. Frames whose flags contain `0x02` are end-of-stream frames; report their `error` even when HTTP status is 200. Buffer incomplete frames across network chunks.
Subsequent requests use `Content-Type: application/json` and the same token and protocol-version headers, without a streaming envelope:
| Action | POST path under `envdUrl` | JSON body |
| ------------------------------------------- | ----------------------------- | --------------------------------------------------------------- |
| Send keyboard input | `/process.Process/SendInput` | `{"process":{"pid":123},"input":{"pty":"cHdkDQ=="}}` |
| Resize the terminal | `/process.Process/Update` | `{"process":{"pid":123},"pty":{"size":{"cols":100,"rows":30}}}` |
| Terminate this shell on explicit disconnect | `/process.Process/SendSignal` | `{"process":{"pid":123},"signal":"SIGNAL_SIGKILL"}` |
Replace `123` with the returned PID. `cHdkDQ==` is Base64 for `pwd` followed by a carriage return. Serialize keyboard input requests to preserve their order. A WebShell frontend maps terminal input to `SendInput`, terminal resize to `Update`, and streamed output back to the terminal. Abort streaming requests and clean up only the shell process created by that client when disconnecting; do not kill the sandbox or the browser stack.
Verify the interactive path by typing `pwd` in the terminal and observing the result and a new prompt. A successful `Start` response alone does not verify keyboard input. In a page that wraps global `fetch`, use an unwrapped streaming client or a local proxy if the wrapper buffers the stream.
## 4. Connect browser automation through CDP
Use a WebSocket client that supports custom headers. With `puppeteer-core` available in a scratch project, save the following as `connect-browser.mjs` there and run `node connect-browser.mjs <connection.json> <target-url> <screenshot.png>`. The browser runs in the sandbox; no local Chromium installation or E2B SDK is needed for this direct connection.
```javascript
import { readFile } from "node:fs/promises";
import puppeteer from "puppeteer-core";
const [connectionPath, targetUrl, screenshotPath] = process.argv.slice(2);
if (!connectionPath || !targetUrl || !screenshotPath) {
throw new Error("Expected connection.json, target URL, and screenshot path");
}
const connection = JSON.parse(await readFile(connectionPath, "utf8"));
const browserHost = `3000-${connection.sandboxID}.${connection.domain}`;
const browser = await puppeteer.connect({
browserWSEndpoint: `wss://${browserHost}/ws/automation`,
headers: { "X-Access-Token": connection.envdAccessToken },
});
try {
const page = await browser.newPage();
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
await page.screenshot({ path: screenshotPath });
console.log(JSON.stringify({ url: page.url(), title: await page.title(), screenshotPath }));
} finally {
browser.disconnect();
}
```
Run the health/token checks first. Use the user's requested target; if they only ask for an access check, `https://example.com` is a suitable sample. Verify the page title and screenshot. Disconnecting the client leaves the remote browser available; do not copy tutorial cleanup that closes the browser or kills a sandbox the user wants to keep using.
## 5. Show the live browser desktop through VNC
Connect a noVNC client to `wss://{browserHost}/ws/livestream`. This endpoint carries VNC frames, not CDP messages or an HTML page. A WebSocket handshake alone does not establish a working desktop; verify that the client finishes the RFB handshake and renders the remote browser.
Browser-native WebSocket cannot attach `X-Access-Token` headers. For a user-facing page, use a header-capable bridge: browser noVNC → local WebSocket proxy → sandbox VNC endpoint. Keep the token on the proxy side and attach it only to the sandbox request. Do not put it in page source or query parameters to work around header support.
For a temporary local viewer, bind to `127.0.0.1`, check the WebSocket Origin, and forward only the selected instance's service. Serve the needed noVNC assets from an available dependency or a scratch install. The same local approach can bridge an xterm.js WebShell page to envd. Keep the proxy running while the user is using the page; stop its connections when the instance expires or the user finishes.
Report the actual local page URL separately from the remote service URL. Open the page when requested, verify the connected state and visible desktop or terminal output, and retain the usable tab for the user. State that a loopback URL works only on this machine and report the instance's actual expiry from `endAt`. Do not treat `127.0.0.1` pages as addresses returned by `sandbox get`.

Some files were not shown because too many files have changed in this diff Show More