mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46b55b29ac | |||
| f953f428c5 | |||
| e03103364e | |||
| 64b1e72238 | |||
| fa3f5de728 | |||
| 02d859ee18 | |||
| 2220f932b5 | |||
| 5c390fad74 | |||
| 6218637f7c | |||
| fa1a8c7b84 | |||
| 0c7ba721a7 | |||
| 0dbf071368 | |||
| 16e30686f0 | |||
| 7803d91e3f | |||
| c2d17707c0 | |||
| 3e8df956fe | |||
| 1dfb4400cc | |||
| fd2077b373 | |||
| ceef514a50 | |||
| 0dde03c783 | |||
| f4e479ce80 | |||
| 2090293f85 | |||
| a245359792 | |||
| 19bd5a5ea8 | |||
| e262f2e574 | |||
| 180aac9f28 | |||
| 3477a08dad | |||
| aa1484264e | |||
| e1eadad184 | |||
| 2767491ce6 | |||
| efb832815a | |||
| 902516bbad | |||
| f44fb2e95f | |||
| c0fca869c2 | |||
| 332efb05b8 | |||
| c6b9931d6f | |||
| a92c58420b | |||
| bcf68a31f3 | |||
| 8e5dd127a9 | |||
| 888031562f | |||
| d017954ae8 | |||
| e53daf05eb | |||
| 9469556671 | |||
| b93e0d586d |
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
约定:
|
||||
|
||||
|
||||
@@ -6,6 +6,69 @@ 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
|
||||
|
||||
- **Project initialization** — `managed-agent project init` now creates `./managed-agent` by default. Use `--project .` to initialize in place. **(BREAKING)**
|
||||
- **Build confirmation** — `managed-agent project build` no longer requires confirmation and rejects `--yes`. Use `--dry-run` for a read-only preview; Publish still requires confirmation. **(BREAKING)**
|
||||
- **Managed Agent SDK** — upgrade to `0.7.1`. Build automatically associates active Agent-local resources while preserving explicit bindings, Skill versions, and File mount paths. Ambiguous Environment or Vault selections are rejected before writing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Project diagnostics** — provide actionable project-root guidance and surface the underlying Build validation error.
|
||||
- **YAML initialization paths** — show the absolute YAML path in creation messages and existing-file errors.
|
||||
|
||||
### Internal
|
||||
|
||||
- Expand project initialization and Build regression coverage, and remove the obsolete Build confirmation flag from the local lifecycle E2E test.
|
||||
|
||||
## [1.21.0] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Managed Agent directory projects** — initialize, validate, build, and publish local projects with Agent-scoped resource configuration and resource examples.
|
||||
- **Local project versions** — enable or disable snapshot versioning, list and preview history, and restore project files without Git.
|
||||
- **Project Workbench** — edit resources, review changes, publish updates, and manage local versions in a browser.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Playground version resolution** — check npm before launch, reuse matching local versions, and fetch the latest version when needed; retain explicit version and binary overrides.
|
||||
- **Managed Agent SDK** — upgrade to `0.7.0` and consume project workspace and version services through SDK subpath exports.
|
||||
|
||||
## [1.20.0] - 2026-09-03
|
||||
|
||||
> Managed Agents now combines YAML-first infrastructure management with direct Bailian AgentStudio resource and runtime operations.
|
||||
|
||||
@@ -6,6 +6,69 @@
|
||||
|
||||
[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
|
||||
|
||||
### 变更
|
||||
|
||||
- **项目初始化** —— `managed-agent project init` 默认创建 `./managed-agent` 子目录;如需原地初始化,请使用 `--project .`。**(BREAKING)**
|
||||
- **Build 确认机制** —— `managed-agent project build` 无需确认,并且不再接受 `--yes`。使用 `--dry-run` 可只读预览;Publish 仍需显式确认。**(BREAKING)**
|
||||
- **Managed Agent SDK** —— 升级至 `0.7.1`。Build 自动关联 Agent 目录下已启用的资源,保留显式引用、Skill 版本和 File 挂载路径;Environment 或 Vault 选择存在歧义时,在写入前报错。
|
||||
|
||||
### 修复
|
||||
|
||||
- **项目诊断** —— 提供可操作的项目根目录提示,并展示 Build 校验失败的具体原因。
|
||||
- **YAML 初始化路径** —— 创建成功及文件已存在的错误信息均展示 YAML 绝对路径。
|
||||
|
||||
### 内部
|
||||
|
||||
- 补充项目初始化和 Build 回归覆盖,移除本地闭环 E2E 测试中过时的 Build 确认参数。
|
||||
|
||||
## [1.21.0] - 2026-09-07
|
||||
|
||||
### 新增
|
||||
|
||||
- **Managed Agent 目录项目** —— 支持初始化、校验、构建和发布本地项目,按 Agent 组织资源配置,并提供资源示例。
|
||||
- **本地项目版本管理** —— 无需 Git 即可启停快照版本管理、查看和预览历史,以及恢复项目文件。
|
||||
- **项目 Workbench** —— 在浏览器中编辑资源、审阅变更、发布更新和管理本地版本。
|
||||
|
||||
### 变更
|
||||
|
||||
- **Playground 版本选择** —— 启动前查询 npm,复用版本一致的本地安装,否则按需下载最新版;保留显式版本和启动文件覆盖。
|
||||
- **Managed Agent SDK** —— 升级至 `0.7.0`,通过 SDK 子路径复用项目目录和版本服务。
|
||||
|
||||
## [1.20.0] - 2026-09-03
|
||||
|
||||
> Managed Agent 现在同时提供 YAML-first 基础设施管理与百炼 AgentStudio 资源、运行时 API 操作。
|
||||
|
||||
@@ -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." |
|
||||
|
||||
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
| ---------------- | ----------------------------------------------------------------------- |
|
||||
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
|
||||
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
|
||||
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
|
||||
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
|
||||
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
|
||||
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
- 类型由 `ParsedFlags<typeof FLAGS>` 推导;避免手写 `flags.x as number` 这类断言
|
||||
- 单 flag 必填用 `required: true`;跨 flag / 值相关校验放 `validate`
|
||||
- 默认值 fallback 写在命令实现或 `Settings` 解析层,不要重复解析 env/config
|
||||
- 需要在高风险确认前检查本地路径时,可用异步 `validate`;runtime 会在鉴权和确认前等待它完成。这里只允许本地只读检查,不写文件、不请求远端。非缺参的环境错误应抛出 `BailianError`,避免裸命令调用被当成缺参而仅显示 help。
|
||||
|
||||
### B. 鉴权 / 全局选项
|
||||
|
||||
|
||||
@@ -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) 一并核对
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识(AEM) |
|
||||
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
|
||||
| `auth: "apiKey"` | API Key;DashScope / OpenAI-compatible 模型域 | `Client.request/requestJson`、`McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent`、`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=apiKey` |
|
||||
| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 无 | 有:`pid=bailian-cli-node`、`authMethod=console` |
|
||||
| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 有:Console Gateway 后端直接标识 | 有:`pid=bailian-cli-node`、`authMethod=console` |
|
||||
| `auth: "openapi"` | AccessKey ID/Secret,可选 STS token;阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=openapi` |
|
||||
| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node`、`authMethod=none` |
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 仅对阿里云 host 注入
|
||||
- DashScope 上传策略 `getPolicy` 只有 `x-dashscope-source-config`,没有显式 CLI `User-Agent`
|
||||
- Console Gateway 用户数据由后端直接区分,不依赖 `x-dashscope-source-config`;AEM 的 `authMethod=console` 是独立的命令侧统计
|
||||
- OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product`、`action`、`api` 是鉴权或路由字段,不计为埋点标识
|
||||
|
||||
### 2. 后端渠道参数
|
||||
|
||||
@@ -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." |
|
||||
|
||||
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
| ---------------- | ----------------------------------------------------------------------- |
|
||||
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
|
||||
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
|
||||
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
|
||||
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
|
||||
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
|
||||
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.20.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",
|
||||
|
||||
@@ -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,
|
||||
@@ -140,6 +146,18 @@ import {
|
||||
managedAgentPlan,
|
||||
managedAgentApply,
|
||||
managedAgentDestroy,
|
||||
managedAgentPlayground,
|
||||
managedAgentProjectInit,
|
||||
managedAgentProjectValidate,
|
||||
managedAgentProjectBuild,
|
||||
managedAgentProjectPublish,
|
||||
managedAgentProjectWorkbench,
|
||||
managedAgentProjectVersionEnable,
|
||||
managedAgentProjectVersionDisable,
|
||||
managedAgentProjectVersionStatus,
|
||||
managedAgentProjectVersionList,
|
||||
managedAgentProjectVersionPreview,
|
||||
managedAgentProjectVersionRestore,
|
||||
managedAgentStateList,
|
||||
managedAgentStateShow,
|
||||
managedAgentStateRm,
|
||||
@@ -194,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.
|
||||
@@ -274,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,
|
||||
@@ -329,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,
|
||||
@@ -344,6 +383,18 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"managed-agent plan": managedAgentPlan,
|
||||
"managed-agent apply": managedAgentApply,
|
||||
"managed-agent destroy": managedAgentDestroy,
|
||||
"managed-agent playground": managedAgentPlayground,
|
||||
"managed-agent project init": managedAgentProjectInit,
|
||||
"managed-agent project validate": managedAgentProjectValidate,
|
||||
"managed-agent project build": managedAgentProjectBuild,
|
||||
"managed-agent project publish": managedAgentProjectPublish,
|
||||
"managed-agent project workbench": managedAgentProjectWorkbench,
|
||||
"managed-agent project version enable": managedAgentProjectVersionEnable,
|
||||
"managed-agent project version disable": managedAgentProjectVersionDisable,
|
||||
"managed-agent project version status": managedAgentProjectVersionStatus,
|
||||
"managed-agent project version list": managedAgentProjectVersionList,
|
||||
"managed-agent project version preview": managedAgentProjectVersionPreview,
|
||||
"managed-agent project version restore": managedAgentProjectVersionRestore,
|
||||
"managed-agent state list": managedAgentStateList,
|
||||
"managed-agent state show": managedAgentStateShow,
|
||||
"managed-agent state rm": managedAgentStateRm,
|
||||
@@ -399,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,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.20.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": {
|
||||
@@ -40,7 +40,7 @@
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openagentpack/sdk": "0.5.0",
|
||||
"@openagentpack/sdk": "0.7.1",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,9 +30,11 @@ export const BAILIAN_PROVIDER = "bailian";
|
||||
|
||||
interface AgentConfigOptions {
|
||||
resolveEnv?: boolean;
|
||||
environment?: Record<string, string>;
|
||||
projectName?: string;
|
||||
statePath?: string;
|
||||
credentials?: CredentialScope;
|
||||
overrideBailianBaseUrl?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +58,9 @@ export async function resolveAgentProjectConfig(
|
||||
prepareProviderEnv();
|
||||
const resolved = await resolveProjectConfig(filePath, options);
|
||||
normalizeInterpolatedProviderBlocks(resolved.config.providers);
|
||||
injectProviderCredentials(resolved.config.providers, host);
|
||||
injectProviderCredentials(resolved.config.providers, host, {
|
||||
overrideBaseUrl: options.overrideBailianBaseUrl,
|
||||
});
|
||||
scrubCredentialEnv();
|
||||
assertBailianOnlyProviders(resolved.config.providers);
|
||||
if ((options.credentials ?? "all") !== "none") {
|
||||
|
||||
@@ -99,9 +99,10 @@ export function prepareProviderEnv(): void {
|
||||
* Override the bailian provider block with bl's authStage-resolved credential, so
|
||||
* the bailian API key is authoritatively the CLI auth chain's — never a config
|
||||
* file bare-read or a stale env value. `api_key` is replaced unconditionally
|
||||
* when a credential resolved; `base_url` / `workspace_id` are filled only when
|
||||
* the block references them and the interpolated value is empty (a literal in
|
||||
* agents.yaml is respected).
|
||||
* when a credential resolved. `base_url` is normally filled only when empty so
|
||||
* a literal in agents.yaml remains supported; directory projects pass
|
||||
* `overrideBaseUrl` so their connection always follows the Bailian CLI auth
|
||||
* chain. `workspace_id` is filled only when empty.
|
||||
*
|
||||
* `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource
|
||||
* paths onto it verbatim; a value already ending in the suffix is left as-is.
|
||||
@@ -114,6 +115,7 @@ export function prepareProviderEnv(): void {
|
||||
export function injectProviderCredentials(
|
||||
providers: Record<string, unknown>,
|
||||
host: CredentialHost,
|
||||
options: { overrideBaseUrl?: boolean } = {},
|
||||
): void {
|
||||
const bailian = providers.bailian;
|
||||
if (!bailian || typeof bailian !== "object") return;
|
||||
@@ -121,7 +123,7 @@ export function injectProviderCredentials(
|
||||
|
||||
const cred = host.client.exportApiCredential();
|
||||
if (cred) block.api_key = cred.token;
|
||||
if ("base_url" in block && !block.base_url) {
|
||||
if ("base_url" in block && (options.overrideBaseUrl || !block.base_url)) {
|
||||
// Defensive normalization: the auth chain already normalizes base_url to
|
||||
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
|
||||
const origin = host.client.baseUrl.replace(/\/+$/, "");
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const PLAYGROUND_PACKAGE = "@openagentpack/playground";
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_PORT = 4848;
|
||||
const PLAYGROUND_URL_PATTERN = /running at http:\/\/localhost:(\d+)/i;
|
||||
|
||||
export interface PlaygroundLaunchOptions {
|
||||
port?: number;
|
||||
open: boolean;
|
||||
file?: string;
|
||||
project?: string;
|
||||
agent?: string;
|
||||
surface: "preview" | "workbench";
|
||||
client: Client;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
interface Launcher {
|
||||
command: string;
|
||||
args: string[];
|
||||
version?: string;
|
||||
fetched: boolean;
|
||||
}
|
||||
|
||||
interface ExistingPlayground {
|
||||
version: string;
|
||||
pid: number;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
interface PlaygroundProjectSummary {
|
||||
status?: string;
|
||||
agents?: Array<{ agent?: { id?: string } }>;
|
||||
}
|
||||
|
||||
export interface PlaygroundBrowserTarget {
|
||||
url: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export async function launchManagedAgentPlayground(
|
||||
options: PlaygroundLaunchOptions,
|
||||
): Promise<void> {
|
||||
assertSupportedNodeVersion();
|
||||
const port = options.port ?? DEFAULT_PORT;
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
|
||||
throw new BailianError(`Invalid --port '${port}'.`, ExitCode.USAGE);
|
||||
}
|
||||
const sourcePath = resolve(
|
||||
options.surface === "workbench" ? (options.project ?? ".") : (options.file ?? "agents.yaml"),
|
||||
);
|
||||
const projectId = createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
|
||||
const launcher = await resolveLauncher();
|
||||
const existing = await probeExistingPlayground(port);
|
||||
if (existing) {
|
||||
const reusable =
|
||||
existing.projectId === projectId &&
|
||||
(launcher.version === undefined || existing.version === launcher.version);
|
||||
if (reusable) {
|
||||
emitBare(`Workbench already running at http://localhost:${port} (pid ${existing.pid}).`);
|
||||
await openPlaygroundSurface(port, options.surface, options.agent, options.open);
|
||||
return;
|
||||
}
|
||||
const released = await replaceExistingPlayground(existing, port);
|
||||
if (!released) {
|
||||
throw new BailianError(
|
||||
`Could not stop the existing Workbench process (pid ${existing.pid}) on port ${port}.`,
|
||||
ExitCode.GENERAL,
|
||||
"Stop it manually or choose another --port.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const environment = buildPlaygroundEnvironment(options, port, sourcePath);
|
||||
if (launcher.fetched) {
|
||||
emitBare(`Fetching ${PLAYGROUND_PACKAGE} (first run may take a moment)...`);
|
||||
}
|
||||
const child = spawn(launcher.command, launcher.args, {
|
||||
env: environment,
|
||||
stdio: ["inherit", "pipe", "inherit"],
|
||||
});
|
||||
const removeSignalForwarding = forwardSignals(child);
|
||||
try {
|
||||
const readyPort = await waitForPlaygroundReady(child, port, 30_000, projectId);
|
||||
if (readyPort === null) {
|
||||
throw new BailianError(
|
||||
`Workbench did not become ready in time. Check the logs above, then open http://localhost:${port}.`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
emitBare(`Workbench ready at http://localhost:${readyPort}`);
|
||||
await openPlaygroundSurface(readyPort, options.surface, options.agent, options.open);
|
||||
const exitCode = await waitForChildExit(child);
|
||||
if (exitCode !== 0) {
|
||||
throw new BailianError(`Workbench exited with code ${exitCode}.`, ExitCode.GENERAL);
|
||||
}
|
||||
} finally {
|
||||
removeSignalForwarding();
|
||||
}
|
||||
}
|
||||
|
||||
export function playgroundBrowserTargetFromSummary(
|
||||
baseUrl: string,
|
||||
summary: PlaygroundProjectSummary,
|
||||
requestedAgent?: string,
|
||||
): PlaygroundBrowserTarget {
|
||||
if (summary.status !== "valid") return { url: baseUrl };
|
||||
const agentIds = (summary.agents ?? [])
|
||||
.map((entry) => entry.agent?.id?.trim())
|
||||
.filter((agentId): agentId is string => Boolean(agentId));
|
||||
const requested = requestedAgent?.trim();
|
||||
if (requested) {
|
||||
if (agentIds.includes(requested)) {
|
||||
return { url: `${baseUrl}/agents/${encodeURIComponent(requested)}/preview` };
|
||||
}
|
||||
return {
|
||||
url: baseUrl,
|
||||
warning: `Agent '${requested}' was not found. Opening the project Workbench instead.`,
|
||||
};
|
||||
}
|
||||
if (agentIds.length === 1) {
|
||||
return { url: `${baseUrl}/agents/${encodeURIComponent(agentIds[0]!)}/preview` };
|
||||
}
|
||||
if (agentIds.length > 1) {
|
||||
return {
|
||||
url: baseUrl,
|
||||
warning:
|
||||
"This project declares multiple Agents. Opening the Workbench; rerun with --agent <id> for Preview.",
|
||||
};
|
||||
}
|
||||
return { url: baseUrl };
|
||||
}
|
||||
|
||||
function assertSupportedNodeVersion(): void {
|
||||
const majorVersion = Number(process.versions.node.split(".")[0]);
|
||||
if (Number.isFinite(majorVersion) && majorVersion >= 22) return;
|
||||
throw new BailianError(
|
||||
"Managed Agent Workbench requires Node.js 22 or later.",
|
||||
ExitCode.USAGE,
|
||||
"Upgrade Node.js for Workbench; other Bailian CLI commands continue to support Node.js 18.17+.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveLauncher(): Promise<Launcher> {
|
||||
const explicit =
|
||||
process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN?.trim() ||
|
||||
process.env.AGENTS_PLAYGROUND_BIN?.trim();
|
||||
if (explicit) {
|
||||
if (!existsSync(explicit)) {
|
||||
throw new BailianError(
|
||||
`Configured Workbench binary does not exist: ${explicit}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return { command: process.execPath, args: [explicit], fetched: false };
|
||||
}
|
||||
|
||||
const requestedVersion = process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION?.trim() || "latest";
|
||||
const { stdout } = await execFileAsync(
|
||||
"npm",
|
||||
[
|
||||
"view",
|
||||
`${PLAYGROUND_PACKAGE}@${requestedVersion}`,
|
||||
"version",
|
||||
"--json",
|
||||
"--prefer-online",
|
||||
"--fetch-retries=0",
|
||||
"--fetch-timeout=10000",
|
||||
],
|
||||
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
let resolvedVersion: unknown;
|
||||
try {
|
||||
resolvedVersion = JSON.parse(stdout);
|
||||
} catch {
|
||||
resolvedVersion = undefined;
|
||||
}
|
||||
if (
|
||||
typeof resolvedVersion !== "string" ||
|
||||
!/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(resolvedVersion)
|
||||
) {
|
||||
throw new BailianError(
|
||||
"npm did not return a single valid Playground version. Specify an exact version or dist-tag. / npm 未返回唯一有效的 Playground 版本号。请指定精确版本或 dist-tag。",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
const installed = resolveInstalledPlayground();
|
||||
if (installed?.version === resolvedVersion) return installed;
|
||||
|
||||
const monorepoBinary = findLocalPlaygroundBin(process.cwd());
|
||||
if (
|
||||
monorepoBinary &&
|
||||
readPlaygroundVersion(resolve(dirname(monorepoBinary), "../../package.json")) ===
|
||||
resolvedVersion
|
||||
) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [monorepoBinary],
|
||||
version: resolvedVersion,
|
||||
fetched: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "npx",
|
||||
args: ["-y", `${PLAYGROUND_PACKAGE}@${resolvedVersion}`],
|
||||
version: resolvedVersion,
|
||||
fetched: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readPlaygroundVersion(manifestPath: string): string | undefined {
|
||||
try {
|
||||
return (JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: string }).version;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstalledPlayground(): Launcher | undefined {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJsonPath = require.resolve(`${PLAYGROUND_PACKAGE}/package.json`);
|
||||
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
version?: string;
|
||||
bin?: string | Record<string, string>;
|
||||
};
|
||||
const relativeBinary =
|
||||
typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["agents-playground"];
|
||||
if (!relativeBinary) return undefined;
|
||||
const binaryPath = resolve(dirname(packageJsonPath), relativeBinary);
|
||||
if (!existsSync(binaryPath)) return undefined;
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [binaryPath],
|
||||
version: manifest.version,
|
||||
fetched: false,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function findLocalPlaygroundBin(startDirectory: string): string | undefined {
|
||||
let directory = startDirectory;
|
||||
for (let depth = 0; depth < 10; depth += 1) {
|
||||
const candidate = resolve(directory, "packages/playground/dist/bin/playground.js");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = dirname(directory);
|
||||
if (parent === directory) break;
|
||||
directory = parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPlaygroundEnvironment(
|
||||
options: PlaygroundLaunchOptions,
|
||||
port: number,
|
||||
sourcePath: string,
|
||||
): NodeJS.ProcessEnv {
|
||||
const credential = options.client.exportApiCredential();
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
AGENTS_PLAYGROUND_TOKEN: randomBytes(32).toString("hex"),
|
||||
};
|
||||
if (options.surface === "workbench") {
|
||||
delete environment.AGENTS_CONFIG_PATH;
|
||||
environment.AGENTS_PROJECT_ROOT = sourcePath;
|
||||
} else {
|
||||
delete environment.AGENTS_PROJECT_ROOT;
|
||||
environment.AGENTS_CONFIG_PATH = sourcePath;
|
||||
}
|
||||
if (credential) environment.DASHSCOPE_API_KEY = credential.token;
|
||||
const baseUrl = options.client.baseUrl.replace(/\/+$/, "");
|
||||
environment.BAILIAN_BASE_URL = baseUrl.endsWith("/api/v1/agentstudio")
|
||||
? baseUrl
|
||||
: `${baseUrl}/api/v1/agentstudio`;
|
||||
if (options.settings.workspaceId) {
|
||||
environment.BAILIAN_WORKSPACE_ID = options.settings.workspaceId;
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
async function waitForPlaygroundReady(
|
||||
child: ChildProcess,
|
||||
fallbackPort: number,
|
||||
timeoutMs: number,
|
||||
expectedProjectId: string,
|
||||
): Promise<number | null> {
|
||||
let port = fallbackPort;
|
||||
let outputBuffer = "";
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
process.stdout.write(chunk);
|
||||
outputBuffer += chunk.toString();
|
||||
const match = outputBuffer.match(PLAYGROUND_URL_PATTERN);
|
||||
if (match?.[1]) port = Number(match[1]);
|
||||
});
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() <= deadline) {
|
||||
if (child.exitCode !== null) return null;
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
});
|
||||
const body = response.ok
|
||||
? ((await response.json()) as { playground?: { project_id?: string } })
|
||||
: undefined;
|
||||
if (body?.playground?.project_id === expectedProjectId) return port;
|
||||
} catch {
|
||||
// Not ready yet.
|
||||
}
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 300));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function probeExistingPlayground(port: number): Promise<ExistingPlayground | null> {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json()) as {
|
||||
playground?: { version?: string; pid?: number; project_id?: string };
|
||||
};
|
||||
if (!body.playground?.pid) return null;
|
||||
return {
|
||||
version: body.playground.version ?? "unknown",
|
||||
pid: body.playground.pid,
|
||||
projectId: body.playground.project_id,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceExistingPlayground(
|
||||
existing: ExistingPlayground,
|
||||
port: number,
|
||||
): Promise<boolean> {
|
||||
emitBare(`Replacing Workbench v${existing.version} (pid ${existing.pid}) on port ${port}...`);
|
||||
try {
|
||||
process.kill(existing.pid, "SIGTERM");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 100));
|
||||
if (!(await probeExistingPlayground(port))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function openPlaygroundSurface(
|
||||
port: number,
|
||||
surface: "preview" | "workbench",
|
||||
requestedAgent: string | undefined,
|
||||
shouldOpen: boolean,
|
||||
): Promise<void> {
|
||||
if (!shouldOpen) return;
|
||||
const target =
|
||||
surface === "workbench"
|
||||
? { url: `http://localhost:${port}` }
|
||||
: await resolvePlaygroundBrowserTarget(port, requestedAgent);
|
||||
if (target.warning) emitBare(`Warning: ${target.warning}`);
|
||||
openBrowser(target.url);
|
||||
}
|
||||
|
||||
async function resolvePlaygroundBrowserTarget(
|
||||
port: number,
|
||||
requestedAgent?: string,
|
||||
): Promise<PlaygroundBrowserTarget> {
|
||||
const baseUrl = `http://localhost:${port}`;
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/project`, {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
if (!response.ok) return { url: baseUrl };
|
||||
return playgroundBrowserTargetFromSummary(
|
||||
baseUrl,
|
||||
(await response.json()) as PlaygroundProjectSummary,
|
||||
requestedAgent,
|
||||
);
|
||||
} catch {
|
||||
return { url: baseUrl };
|
||||
}
|
||||
}
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
const command =
|
||||
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||
const args = process.platform === "win32" ? ["", url] : [url];
|
||||
try {
|
||||
spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
shell: process.platform === "win32",
|
||||
}).unref();
|
||||
} catch {
|
||||
emitBare(`Could not open a browser automatically. Visit ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
function forwardSignals(child: ChildProcess): () => void {
|
||||
const forwardInterrupt = () => child.kill("SIGINT");
|
||||
const forwardTerminate = () => child.kill("SIGTERM");
|
||||
process.on("SIGINT", forwardInterrupt);
|
||||
process.on("SIGTERM", forwardTerminate);
|
||||
return () => {
|
||||
process.off("SIGINT", forwardInterrupt);
|
||||
process.off("SIGTERM", forwardTerminate);
|
||||
};
|
||||
}
|
||||
|
||||
function waitForChildExit(child: ChildProcess): Promise<number> {
|
||||
if (child.exitCode !== null) return Promise.resolve(child.exitCode);
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
child.once("exit", (exitCode) => resolveExit(exitCode ?? 0));
|
||||
child.once("error", rejectExit);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
@@ -12,6 +13,7 @@ import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
const GITIGNORE_ADDITIONS = `
|
||||
# agents
|
||||
agents.state.json
|
||||
.openagentpack/versions/
|
||||
.env
|
||||
`;
|
||||
|
||||
@@ -71,8 +73,8 @@ const INIT_FLAGS = {
|
||||
|
||||
export default defineCommand({
|
||||
description: {
|
||||
"en-US": "Create a new agents.yaml template",
|
||||
"zh-CN": "创建新的 agents.yaml 模板",
|
||||
"en-US": "Create an agents.yaml template",
|
||||
"zh-CN": "创建 agents.yaml 模板",
|
||||
},
|
||||
auth: "none",
|
||||
usageArgs: "[--agent-name <name>] [--file <path>] [--force]",
|
||||
@@ -86,7 +88,7 @@ export default defineCommand({
|
||||
|
||||
if (existsSync(file) && !flags.force) {
|
||||
throw new BailianError(
|
||||
`${file} already exists.`,
|
||||
`${resolve(file)} already exists.`,
|
||||
ExitCode.USAGE,
|
||||
"Pass --force to overwrite.",
|
||||
);
|
||||
@@ -127,7 +129,7 @@ export default defineCommand({
|
||||
if (format === "json") {
|
||||
emitResult({ created: file, provider: "bailian", agent: agentName }, format);
|
||||
} else {
|
||||
emitBare(`Created ${file}`);
|
||||
emitBare(`Created ${resolve(file)}`);
|
||||
emitBare(
|
||||
"Credentials: run `bl auth login --api-key <key> --base-url <url>`, or set DASHSCOPE_API_KEY / BAILIAN_BASE_URL.",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import {
|
||||
commitProjectBuild,
|
||||
createDirectoryWorkspaceVersionService,
|
||||
executeProjectPublish,
|
||||
initializeDirectoryProject,
|
||||
planProjectPublish,
|
||||
previewProjectBuild,
|
||||
type ProjectBuildResolver,
|
||||
resolveDirectoryProjectRoot,
|
||||
validateDirectoryProject,
|
||||
} from "@openagentpack/sdk/project-workspace";
|
||||
import { CREDENTIALS_NOTE, resolveAgentProjectConfig } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
import { renderAgentFeedback } from "./_engine/feedback.ts";
|
||||
import { launchManagedAgentPlayground } from "./_engine/playground-launcher.ts";
|
||||
import { installSdkTransport } from "./_engine/transport.ts";
|
||||
import { formatResourceLabel } from "./_engine/address-utils.ts";
|
||||
|
||||
const PROJECT_FLAG = {
|
||||
project: {
|
||||
type: "string",
|
||||
valueHint: "<directory>",
|
||||
description: {
|
||||
"en-US": "Directory project root (default: current directory)",
|
||||
"zh-CN": "目录项目根路径(默认:当前目录)",
|
||||
},
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const JSON_FORMAT = "json" as const;
|
||||
|
||||
export const managedAgentProjectInit = defineCommand({
|
||||
description: {
|
||||
"en-US": "Create a directory project or convert the local agents.yaml",
|
||||
"zh-CN": "创建目录项目,或转换当前 agents.yaml",
|
||||
},
|
||||
auth: "none",
|
||||
usageArgs: "[--project <directory>]",
|
||||
flags: {
|
||||
project: {
|
||||
...PROJECT_FLAG.project,
|
||||
description: {
|
||||
"en-US": "Directory project root (default: ./managed-agent under the current directory)",
|
||||
"zh-CN": "目录项目根路径(默认:当前目录下的 ./managed-agent)",
|
||||
},
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--project ./my-agent", "--project ."],
|
||||
notes: [
|
||||
{
|
||||
"en-US":
|
||||
"Without --project, creates a managed-agent/ subdirectory. Enter it before running other project commands. Use --project . to initialize in place or convert the current agents.yaml; existing project files are not overwritten.",
|
||||
"zh-CN":
|
||||
"不传 --project 时创建 managed-agent/ 子目录;后续项目操作请先进入该目录。使用 --project . 可在当前目录初始化或转换 agents.yaml;不会覆盖已有项目文件。",
|
||||
},
|
||||
{
|
||||
"en-US":
|
||||
"New projects include Skill, File, Vault, and Environment examples under each resource directory's _examples/. They are not referenced by agent.json and are excluded from Build/Publish. Copy an example outside _examples/ to enable it, then configure its Agent reference.",
|
||||
"zh-CN":
|
||||
"新项目在四类资源目录的 _examples/ 下生成配置示例,不写入 agent.json 引用,也不参与 Build/Publish。需要使用时,将示例复制到 _examples/ 外,再配置 Agent 引用。",
|
||||
},
|
||||
],
|
||||
async run(ctx) {
|
||||
const projectRoot = ctx.flags.project ?? "./managed-agent";
|
||||
if (ctx.settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
would_initialize_project: projectRoot,
|
||||
},
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await initializeDirectoryProject({ projectRoot });
|
||||
emitResult(result, detectOutputFormat(ctx.settings.output));
|
||||
},
|
||||
});
|
||||
|
||||
export const managedAgentProjectValidate = defineCommand({
|
||||
description: { "en-US": "Validate a directory Agent project", "zh-CN": "校验目录式 Agent 项目" },
|
||||
auth: "none",
|
||||
usageArgs: "[--project <directory>]",
|
||||
flags: PROJECT_FLAG,
|
||||
exampleArgs: ["", "--project ./my-agent"],
|
||||
async run(ctx) {
|
||||
const result = await validateDirectoryProject(ctx.flags.project ?? ".");
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
if (format === JSON_FORMAT) emitResult(safeInspection(result), format);
|
||||
else {
|
||||
for (const diagnostic of [...result.diagnostics, ...result.warnings]) {
|
||||
emitBare(`${diagnostic.severity}: ${diagnostic.code}: ${diagnostic.message}`);
|
||||
}
|
||||
if (!result.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
||||
emitBare(`Project is valid (${result.project_revision.slice(0, 12)}).`);
|
||||
}
|
||||
}
|
||||
if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
||||
throw new BailianError("Directory project validation failed.", ExitCode.GENERAL);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const managedAgentProjectBuild = defineCommand({
|
||||
description: {
|
||||
"en-US": "Organize directory source and generate the immutable Publish Build",
|
||||
"zh-CN": "整理目录源文件并生成不可变的发布 Build",
|
||||
},
|
||||
auth: "none",
|
||||
notes: [
|
||||
{
|
||||
"en-US":
|
||||
"Build writes local project files without confirmation, including inferred resource associations and migration of plaintext Vault secrets into .env. Use --dry-run to preview without writing. Publish still requires explicit confirmation before remote changes.",
|
||||
"zh-CN":
|
||||
"Build 无需确认即可写入本地项目文件,包括推断的资源关联及将 Vault 明文密钥移入 .env。使用 --dry-run 可只预览不写入。Publish 变更远端资源前仍需显式确认。",
|
||||
},
|
||||
],
|
||||
usageArgs: "[--project <directory>]",
|
||||
flags: PROJECT_FLAG,
|
||||
exampleArgs: ["", "--dry-run", "--project ./my-agent"],
|
||||
async validate(flags) {
|
||||
await withAgentErrors(async () => {
|
||||
const root = await resolveDirectoryProjectRoot(flags.project ?? ".");
|
||||
const metadata = await stat(join(root, "project.json")).catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!metadata?.isFile()) {
|
||||
throw new BailianError(
|
||||
`Not a project root: ${root} (project.json is missing).`,
|
||||
ExitCode.USAGE,
|
||||
"Run from the directory containing project.json, or pass --project <directory>.",
|
||||
);
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const root = ctx.flags.project ?? ".";
|
||||
const preview = await previewProjectBuild(root);
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
if (ctx.settings.dryRun) {
|
||||
emitResult(safeInspection(preview), format);
|
||||
return;
|
||||
}
|
||||
if (!preview.can_build)
|
||||
throw new BailianError(
|
||||
preview.diagnostics.find((diagnostic) => diagnostic.severity === "error")?.message ??
|
||||
"Directory project is invalid and cannot be built.",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
const built = await commitProjectBuild({
|
||||
projectRoot: root,
|
||||
baseRevision: preview.project_revision,
|
||||
});
|
||||
emitResult(
|
||||
{ manifest: built.manifest, organization_moves: preview.organization_moves },
|
||||
format,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const managedAgentProjectPublish = defineCommand({
|
||||
description: {
|
||||
"en-US": "Publish the current directory-project Build and record a version",
|
||||
"zh-CN": "发布当前目录项目 Build 并记录版本",
|
||||
},
|
||||
auth: "apiKey",
|
||||
risk: {
|
||||
level: "high",
|
||||
message: {
|
||||
"en-US":
|
||||
"This publishes the current directory-project Build and may create, update, or delete remote managed Agent resources.",
|
||||
"zh-CN": "该操作会发布当前目录项目 Build,可能创建、更新或删除远端托管 Agent 资源。",
|
||||
},
|
||||
},
|
||||
usageArgs: "[--project <directory>] [--no-refresh] [--concurrency <n>]",
|
||||
flags: {
|
||||
...PROJECT_FLAG,
|
||||
noRefresh: {
|
||||
type: "switch",
|
||||
description: {
|
||||
"en-US": "Skip remote refresh before planning",
|
||||
"zh-CN": "规划前跳过远端刷新",
|
||||
},
|
||||
},
|
||||
concurrency: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: {
|
||||
"en-US": "Maximum parallel resource operations",
|
||||
"zh-CN": "最大并行资源操作数",
|
||||
},
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--yes", "--project ./my-agent --yes"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
installSdkTransport(ctx);
|
||||
const root = ctx.flags.project ?? ".";
|
||||
const resolveBuild: ProjectBuildResolver = async (buildPath, options) =>
|
||||
(await resolveAgentProjectConfig(ctx, buildPath, {
|
||||
environment: options?.environment,
|
||||
overrideBailianBaseUrl: true,
|
||||
})) as unknown as Awaited<ReturnType<ProjectBuildResolver>>;
|
||||
const planned = await withAgentErrors(() =>
|
||||
withStdoutProtected(() =>
|
||||
planProjectPublish(root, {
|
||||
provider: "bailian",
|
||||
refresh: !ctx.flags.noRefresh,
|
||||
quiet: true,
|
||||
onFeedback: renderAgentFeedback,
|
||||
resolveBuild,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const actions = planned.planned.plan.actions.filter((action) => action.action !== "no-op");
|
||||
if (ctx.settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
project_revision: planned.project_revision,
|
||||
build_manifest: planned.build_manifest,
|
||||
plan: planned.planned.plan,
|
||||
},
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const action of actions) {
|
||||
const marker = action.action === "create" ? "+" : action.action === "update" ? "~" : "-";
|
||||
emitBare(`${marker} ${formatResourceLabel(action.address)}`);
|
||||
}
|
||||
const result = await withAgentErrors(() =>
|
||||
withStdoutProtected(() =>
|
||||
executeProjectPublish({
|
||||
projectRoot: planned.project_root,
|
||||
expectedProjectRevision: planned.project_revision,
|
||||
expectedYamlHash: planned.build_manifest.yaml_hash,
|
||||
expectedPlanFingerprint: planned.plan_fingerprint,
|
||||
provider: "bailian",
|
||||
refresh: !ctx.flags.noRefresh,
|
||||
concurrency: ctx.flags.concurrency,
|
||||
policy: "force",
|
||||
onFeedback: renderAgentFeedback,
|
||||
resolveBuild,
|
||||
}),
|
||||
),
|
||||
);
|
||||
emitResult(result, detectOutputFormat(ctx.settings.output));
|
||||
},
|
||||
});
|
||||
|
||||
export const managedAgentProjectWorkbench = defineCommand({
|
||||
description: {
|
||||
"en-US": "Launch the directory project Workbench",
|
||||
"zh-CN": "启动目录项目 Workbench",
|
||||
},
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--project <directory>] [--port <n>] [--no-open]",
|
||||
flags: {
|
||||
...PROJECT_FLAG,
|
||||
port: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: { "en-US": "Local port (default: 4848)", "zh-CN": "本地端口(默认:4848)" },
|
||||
},
|
||||
noOpen: {
|
||||
type: "switch",
|
||||
description: { "en-US": "Do not open a browser", "zh-CN": "不自动打开浏览器" },
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--project ./my-agent --no-open"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const root = ctx.flags.project ?? ".";
|
||||
if (ctx.settings.dryRun) {
|
||||
emitResult(
|
||||
{ would_launch: "workbench", project_root: root, port: ctx.flags.port ?? 4848 },
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await launchManagedAgentPlayground({
|
||||
project: root,
|
||||
port: ctx.flags.port ?? 4848,
|
||||
open: !ctx.flags.noOpen,
|
||||
surface: "workbench",
|
||||
client: ctx.client,
|
||||
settings: ctx.settings,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const managedAgentProjectVersionEnable = versionToggleCommand(true);
|
||||
export const managedAgentProjectVersionDisable = versionToggleCommand(false);
|
||||
|
||||
function versionToggleCommand(enabled: boolean) {
|
||||
return defineCommand({
|
||||
description: enabled
|
||||
? { "en-US": "Enable directory project versions", "zh-CN": "启用目录项目版本管理" }
|
||||
: { "en-US": "Disable directory project versions", "zh-CN": "关闭目录项目版本管理" },
|
||||
auth: "none",
|
||||
usageArgs: "[--project <directory>]",
|
||||
flags: PROJECT_FLAG,
|
||||
exampleArgs: ["", "--project ./my-agent"],
|
||||
async run(ctx) {
|
||||
const service = createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".");
|
||||
if (ctx.settings.dryRun) {
|
||||
emitResult(
|
||||
{ would_set_enabled: enabled, status: await service.status() },
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = enabled
|
||||
? await service.enable("Enable project versions")
|
||||
: await service.disable();
|
||||
emitResult(result, detectOutputFormat(ctx.settings.output));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const managedAgentProjectVersionStatus = defineCommand({
|
||||
description: {
|
||||
"en-US": "Show directory project version status",
|
||||
"zh-CN": "显示目录项目版本状态",
|
||||
},
|
||||
auth: "none",
|
||||
usageArgs: "[--project <directory>]",
|
||||
flags: PROJECT_FLAG,
|
||||
exampleArgs: ["", "--project ./my-agent --output json"],
|
||||
async run(ctx) {
|
||||
emitResult(
|
||||
await createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".").status(),
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const managedAgentProjectVersionList = defineCommand({
|
||||
description: { "en-US": "List directory project versions", "zh-CN": "列出目录项目版本" },
|
||||
auth: "none",
|
||||
usageArgs: "[--project <directory>] [--limit <n>] [--cursor <cursor>]",
|
||||
flags: {
|
||||
...PROJECT_FLAG,
|
||||
limit: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: { "en-US": "Maximum versions to return", "zh-CN": "最多返回的版本数" },
|
||||
},
|
||||
cursor: {
|
||||
type: "string",
|
||||
valueHint: "<cursor>",
|
||||
description: { "en-US": "Pagination cursor", "zh-CN": "分页游标" },
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--limit 20 --output json"],
|
||||
async run(ctx) {
|
||||
emitResult(
|
||||
await createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".").listVersions({
|
||||
limit: ctx.flags.limit,
|
||||
cursor: ctx.flags.cursor,
|
||||
}),
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const VERSION_ID_FLAG = {
|
||||
versionId: {
|
||||
type: "string",
|
||||
valueHint: "<full-version>",
|
||||
required: true,
|
||||
description: { "en-US": "Full project version ID", "zh-CN": "完整的项目版本 ID" },
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export const managedAgentProjectVersionPreview = defineCommand({
|
||||
description: { "en-US": "Preview a directory project version", "zh-CN": "预览目录项目历史版本" },
|
||||
auth: "none",
|
||||
usageArgs: "--version-id <full-version> [--project <directory>]",
|
||||
flags: { ...PROJECT_FLAG, ...VERSION_ID_FLAG },
|
||||
exampleArgs: ["--version-id <full-version>"],
|
||||
async run(ctx) {
|
||||
emitResult(
|
||||
await createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".").previewVersion(
|
||||
ctx.flags.versionId,
|
||||
),
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const managedAgentProjectVersionRestore = defineCommand({
|
||||
description: {
|
||||
"en-US": "Restore a version to the project working directory",
|
||||
"zh-CN": "将历史版本恢复到项目工作目录",
|
||||
},
|
||||
auth: "none",
|
||||
risk: {
|
||||
level: "high",
|
||||
message: {
|
||||
"en-US":
|
||||
"This restores the full directory source to the working tree. Version history and remote State will not move.",
|
||||
"zh-CN": "该操作会将完整目录源文件恢复到工作区;版本历史和远端 State 不会移动。",
|
||||
},
|
||||
},
|
||||
usageArgs: "--version-id <full-version> [--project <directory>]",
|
||||
flags: { ...PROJECT_FLAG, ...VERSION_ID_FLAG },
|
||||
exampleArgs: ["--version-id <full-version>", "--version-id <full-version> --yes"],
|
||||
async run(ctx) {
|
||||
const service = createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".");
|
||||
const preview = await service.previewVersion(ctx.flags.versionId);
|
||||
if (!preview.can_restore)
|
||||
throw new BailianError(
|
||||
preview.blockers[0] ?? preview.diagnostics[0]?.message ?? "Version cannot be restored.",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
if (ctx.settings.dryRun) {
|
||||
emitResult(
|
||||
{ would_restore: ctx.flags.versionId, preview },
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const restored = await service.restoreVersion(ctx.flags.versionId, {
|
||||
headVersion: preview.base_head_version,
|
||||
projectRevision: preview.base_project_revision,
|
||||
});
|
||||
emitResult(restored, detectOutputFormat(ctx.settings.output));
|
||||
},
|
||||
});
|
||||
|
||||
function safeInspection(result: {
|
||||
project_root: string;
|
||||
project_revision: string;
|
||||
source_manifest_hash: string;
|
||||
yaml_hash: string;
|
||||
diagnostics: unknown[];
|
||||
warnings: unknown[];
|
||||
organization_moves: unknown[];
|
||||
canonical_yaml: string;
|
||||
before_yaml?: string;
|
||||
can_build?: boolean;
|
||||
}) {
|
||||
return {
|
||||
project_root: result.project_root,
|
||||
project_revision: result.project_revision,
|
||||
source_manifest_hash: result.source_manifest_hash,
|
||||
yaml_hash: result.yaml_hash,
|
||||
diagnostics: result.diagnostics,
|
||||
warnings: result.warnings,
|
||||
organization_moves: result.organization_moves,
|
||||
before_yaml: "before_yaml" in result ? result.before_yaml : undefined,
|
||||
after_yaml: result.canonical_yaml,
|
||||
can_build: "can_build" in result ? result.can_build : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { launchManagedAgentPlayground } from "./_engine/playground-launcher.ts";
|
||||
|
||||
const PLAYGROUND_BASE_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: {
|
||||
"en-US": "Config file path (default: agents.yaml)",
|
||||
"zh-CN": "配置文件路径(默认:agents.yaml)",
|
||||
},
|
||||
},
|
||||
port: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: {
|
||||
"en-US": "Local port (default: 4848)",
|
||||
"zh-CN": "本地端口(默认:4848)",
|
||||
},
|
||||
},
|
||||
noOpen: {
|
||||
type: "switch",
|
||||
description: {
|
||||
"en-US": "Do not open a browser automatically",
|
||||
"zh-CN": "不自动打开浏览器",
|
||||
},
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const PLAYGROUND_FLAGS = {
|
||||
...PLAYGROUND_BASE_FLAGS,
|
||||
agent: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: {
|
||||
"en-US": "Agent to preview (required when the project declares multiple Agents)",
|
||||
"zh-CN": "要预览的 Agent(项目包含多个 Agent 时需要指定)",
|
||||
},
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const PLAYGROUND_NOTES = [
|
||||
...CREDENTIALS_NOTE,
|
||||
{
|
||||
"en-US":
|
||||
"Session Preview requires Node.js 22+ and keeps using an agents.yaml source. Directory Workbench is available under managed-agent project workbench.",
|
||||
"zh-CN":
|
||||
"会话预览需要 Node.js 22+,并继续使用 agents.yaml;目录 Workbench 位于 managed-agent project workbench。",
|
||||
},
|
||||
];
|
||||
|
||||
export const managedAgentPlayground = defineCommand({
|
||||
description: {
|
||||
"en-US": "Launch a Session Preview for an agents.yaml Agent",
|
||||
"zh-CN": "为 agents.yaml 中的 Agent 启动会话预览",
|
||||
},
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--file <path>] [--agent <id>] [--port <n>] [--no-open]",
|
||||
flags: PLAYGROUND_FLAGS,
|
||||
exampleArgs: ["", "--agent assistant", "--file agents.yaml --no-open"],
|
||||
notes: PLAYGROUND_NOTES,
|
||||
async run(ctx) {
|
||||
const file = ctx.flags.file ?? "agents.yaml";
|
||||
const port = ctx.flags.port ?? 4848;
|
||||
if (ctx.settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
would_launch: "playground",
|
||||
config_file: file,
|
||||
agent: ctx.flags.agent,
|
||||
port,
|
||||
open_browser: !ctx.flags.noOpen,
|
||||
},
|
||||
detectOutputFormat(ctx.settings.output),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await launchManagedAgentPlayground({
|
||||
file,
|
||||
agent: ctx.flags.agent,
|
||||
port,
|
||||
open: !ctx.flags.noOpen,
|
||||
surface: "preview",
|
||||
client: ctx.client,
|
||||
settings: ctx.settings,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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": "最大返回数量(1–50)" },
|
||||
},
|
||||
} 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 存活时间(300–604800 秒)",
|
||||
},
|
||||
},
|
||||
} 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": "在 300–604800 秒后自动暂停",
|
||||
},
|
||||
},
|
||||
maxRunningTime: {
|
||||
type: "number",
|
||||
valueHint: "<seconds>",
|
||||
description: {
|
||||
"en-US": "Maximum running lifetime in seconds (300-604800)",
|
||||
"zh-CN": "最大运行时间(秒,300–604800)",
|
||||
},
|
||||
},
|
||||
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": "最大返回数量(1–100)" },
|
||||
},
|
||||
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/ru;Fun-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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,11 +137,26 @@ 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";
|
||||
export { default as managedAgentApply } from "./commands/managed-agent/apply.ts";
|
||||
export { default as managedAgentDestroy } from "./commands/managed-agent/destroy.ts";
|
||||
export { managedAgentPlayground } from "./commands/managed-agent/workbench.ts";
|
||||
export {
|
||||
managedAgentProjectBuild,
|
||||
managedAgentProjectInit,
|
||||
managedAgentProjectPublish,
|
||||
managedAgentProjectValidate,
|
||||
managedAgentProjectVersionDisable,
|
||||
managedAgentProjectVersionEnable,
|
||||
managedAgentProjectVersionList,
|
||||
managedAgentProjectVersionPreview,
|
||||
managedAgentProjectVersionRestore,
|
||||
managedAgentProjectVersionStatus,
|
||||
managedAgentProjectWorkbench,
|
||||
} from "./commands/managed-agent/project.ts";
|
||||
export { default as managedAgentStateList } from "./commands/managed-agent/state-list.ts";
|
||||
export { default as managedAgentStateShow } from "./commands/managed-agent/state-show.ts";
|
||||
export { default as managedAgentStateRm } from "./commands/managed-agent/state-rm.ts";
|
||||
@@ -209,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");
|
||||
|
||||
@@ -104,6 +104,20 @@ test("inject:base_url 已带后缀不重复拼;非空字面量 base_url 保留",
|
||||
expect(literal.bailian.base_url).toBe("https://custom.example.com/api/v1/agentstudio");
|
||||
});
|
||||
|
||||
test("inject:目录项目可强制使用 Bailian CLI 解析出的 base_url", () => {
|
||||
const providers = {
|
||||
bailian: {
|
||||
api_key: "from-project-env",
|
||||
base_url: "https://project-env.example.com/api/v1/agentstudio",
|
||||
},
|
||||
};
|
||||
injectProviderCredentials(providers, makeHost({ apiCred: bailianCred() }), {
|
||||
overrideBaseUrl: true,
|
||||
});
|
||||
expect(providers.bailian.api_key).toBe("sk-auth-chain");
|
||||
expect(providers.bailian.base_url).toBe("https://dashscope.aliyuncs.com/api/v1/agentstudio");
|
||||
});
|
||||
|
||||
test("inject:base_url 尾斜杠被规范化,不产生双斜杠", () => {
|
||||
const providers = { bailian: { api_key: "", base_url: "" } };
|
||||
injectProviderCredentials(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runNodeMain } from "e2e/runner";
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson } from "./helpers.ts";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
async function temporaryDirectory() {
|
||||
const directory = await realpath(await mkdtemp(join(tmpdir(), "bailian-yaml-init-")));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function runInit(directory: string, args: string[] = []) {
|
||||
return runNodeMain(
|
||||
fileURLToPath(new URL("./harness/main.ts", import.meta.url)),
|
||||
["managed-agent", "init", ...args],
|
||||
{
|
||||
cwd: directory,
|
||||
env: {
|
||||
BAILIAN_CONFIG_DIR: await temporaryDirectory(),
|
||||
BAILIAN_E2E_ROUTES: JSON.stringify([
|
||||
{ path: "managed-agent init", export: "managedAgentInit" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("e2e: managed-agent init output path", () => {
|
||||
test.each(["default", "relative", "absolute"] as const)(
|
||||
"reports the absolute YAML path for a %s output path",
|
||||
async (pathKind) => {
|
||||
const directory = await temporaryDirectory();
|
||||
const relativePath =
|
||||
pathKind === "default" ? "agents.yaml" : join("config files", "custom agents.yaml");
|
||||
const outputPath = join(directory, relativePath);
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
const args =
|
||||
pathKind === "default"
|
||||
? []
|
||||
: ["--file", pathKind === "absolute" ? outputPath : relativePath];
|
||||
|
||||
const result = await runInit(directory, args);
|
||||
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout.split(/\r?\n/)[0]).toBe(`Created ${outputPath}`);
|
||||
expect(await readFile(outputPath, "utf8")).toContain("agents:");
|
||||
},
|
||||
);
|
||||
|
||||
test("preserves the JSON output contract", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const result = await runInit(directory, ["--file", "custom.yaml", "--output", "json"]);
|
||||
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseStdoutJson(result.stdout)).toEqual({
|
||||
created: "custom.yaml",
|
||||
provider: "bailian",
|
||||
agent: "assistant",
|
||||
});
|
||||
expect((await stat(join(directory, "custom.yaml"))).isFile()).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps dry-run read-only without reporting a created file", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const result = await runInit(directory, ["--dry-run", "--output", "json"]);
|
||||
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseStdoutJson(result.stdout)).toEqual({
|
||||
would_create: "agents.yaml",
|
||||
provider: "bailian",
|
||||
agent: "assistant",
|
||||
would_update_gitignore: true,
|
||||
});
|
||||
expect(await stat(join(directory, "agents.yaml")).catch(() => null)).toBeNull();
|
||||
expect(await stat(join(directory, ".gitignore")).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
test.each(["default", "relative", "absolute"] as const)(
|
||||
"reports the absolute existing %s path without overwriting the YAML",
|
||||
async (pathKind) => {
|
||||
const directory = await temporaryDirectory();
|
||||
const relativePath =
|
||||
pathKind === "default" ? "agents.yaml" : join("config files", "custom agents.yaml");
|
||||
const outputPath = join(directory, relativePath);
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, "Keep user configuration.\n");
|
||||
const args =
|
||||
pathKind === "default"
|
||||
? []
|
||||
: ["--file", pathKind === "absolute" ? outputPath : relativePath];
|
||||
|
||||
const result = await runInit(directory, args);
|
||||
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toContain(`${outputPath} already exists.`);
|
||||
expect(result.stderr).toContain("Pass --force to overwrite.");
|
||||
expect(result.stdout).not.toContain("Created ");
|
||||
expect(await readFile(outputPath, "utf8")).toBe("Keep user configuration.\n");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
cp,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runNodeMain } from "e2e/runner";
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCommandHelp } from "./helpers.ts";
|
||||
|
||||
const routes = {
|
||||
"managed-agent project init": "managedAgentProjectInit",
|
||||
"managed-agent project build": "managedAgentProjectBuild",
|
||||
"managed-agent project publish": "managedAgentProjectPublish",
|
||||
"managed-agent project validate": "managedAgentProjectValidate",
|
||||
} as const;
|
||||
const directories: string[] = [];
|
||||
|
||||
async function temporaryDirectory() {
|
||||
const directory = await realpath(await mkdtemp(join(tmpdir(), "bailian-project-init-")));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function runInit(directory: string, args: string[] = []) {
|
||||
return runProject(directory, "init", args);
|
||||
}
|
||||
|
||||
async function runProject(directory: string, subcommand: string, args: string[] = [], json = true) {
|
||||
const configRoot = await temporaryDirectory();
|
||||
return runNodeMain(
|
||||
fileURLToPath(new URL("./harness/main.ts", import.meta.url)),
|
||||
["managed-agent", "project", subcommand, ...args, ...(json ? ["--output", "json"] : [])],
|
||||
{
|
||||
cwd: directory,
|
||||
env: {
|
||||
BAILIAN_CONFIG_DIR: configRoot,
|
||||
BAILIAN_E2E_ROUTES: JSON.stringify(
|
||||
Object.entries(routes).map(([path, exportName]) => ({ path, export: exportName })),
|
||||
),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("e2e: managed-agent project init directory defaults", () => {
|
||||
test("build links copied resources and reports ambiguous environment bindings without writing", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const initialized = await runInit(directory);
|
||||
expect(initialized.exitCode, initialized.stderr).toBe(0);
|
||||
const root = join(directory, "managed-agent");
|
||||
for (const [section, id] of [
|
||||
["skills", "example-skill"],
|
||||
["files", "example-file"],
|
||||
["environments", "example-env"],
|
||||
["vaults", "example-vault"],
|
||||
] as const) {
|
||||
await cp(
|
||||
join(root, "agents/assistant", section, "_examples", id),
|
||||
join(root, "agents/assistant", section, id),
|
||||
{ recursive: true },
|
||||
);
|
||||
}
|
||||
const agentPath = join(root, "agents/assistant/agent.json");
|
||||
const original = await readFile(agentPath, "utf8");
|
||||
const preview = await runProject(root, "build", ["--dry-run"]);
|
||||
expect(preview.exitCode, preview.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ can_build: boolean }>(preview.stdout).can_build).toBe(true);
|
||||
expect(await readFile(agentPath, "utf8")).toBe(original);
|
||||
const built = await runProject(root, "build");
|
||||
expect(built.exitCode, built.stderr).toBe(0);
|
||||
const agent = JSON.parse(await readFile(agentPath, "utf8"));
|
||||
expect(agent).toMatchObject({
|
||||
environment: "example-env",
|
||||
vault: "example-vault",
|
||||
skills: ["example-skill"],
|
||||
files: [{ file: "example-file", mount_path: "/mnt/example.md" }],
|
||||
});
|
||||
const alternatePath = join(root, "agents/assistant/environments/alternate");
|
||||
await mkdir(alternatePath);
|
||||
await writeFile(
|
||||
join(alternatePath, "environment.json"),
|
||||
JSON.stringify({ id: "alternate", config: { type: "cloud" } }),
|
||||
);
|
||||
delete agent.environment;
|
||||
await writeFile(agentPath, JSON.stringify(agent));
|
||||
const beforeConflict = await readFile(agentPath, "utf8");
|
||||
const buildPath = join(root, ".openagentpack/build/agents.yaml");
|
||||
const beforeBuild = await readFile(buildPath, "utf8");
|
||||
for (const json of [false, true]) {
|
||||
const conflict = await runProject(root, "build", [], json);
|
||||
expect(conflict.exitCode).toBe(1);
|
||||
expect(conflict.stderr).toContain("multiple local environment resources");
|
||||
expect(conflict.stderr).toContain("Set 'environment' explicitly");
|
||||
expect(conflict.stderr).not.toMatch(/\p{Script=Han}/u);
|
||||
}
|
||||
expect(await readFile(agentPath, "utf8")).toBe(beforeConflict);
|
||||
expect(await readFile(buildPath, "utf8")).toBe(beforeBuild);
|
||||
});
|
||||
|
||||
test("build checks directories and writes without confirmation while publish stays gated", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const initialized = await runInit(directory);
|
||||
expect(initialized.exitCode, initialized.stderr).toBe(0);
|
||||
const root = join(directory, "managed-agent");
|
||||
const nested = join(root, "agents/assistant/skills");
|
||||
for (const args of [[], ["--dry-run"]]) {
|
||||
const result = await runProject(nested, "build", args, false);
|
||||
expect(result.exitCode, result.stderr).toBe(2);
|
||||
expect(result.stderr).toContain("Not a project root:");
|
||||
expect(result.stderr).not.toMatch(/\p{Script=Han}/u);
|
||||
expect(result.stderr).toContain(`cd '${root}'`);
|
||||
expect(result.stderr).not.toContain("high-risk");
|
||||
expect(result.stderr).not.toContain("Usage:");
|
||||
}
|
||||
const explicit = await runProject(root, "build", ["--project", nested]);
|
||||
expect(explicit.exitCode).toBe(2);
|
||||
expect(explicit.stderr).toContain(`--project '${root}'`);
|
||||
const noMarker = await runProject(directory, "build", [], false);
|
||||
expect(noMarker.exitCode).toBe(2);
|
||||
expect(noMarker.stderr).toContain("project.json");
|
||||
expect(noMarker.stderr).not.toMatch(/\p{Script=Han}/u);
|
||||
expect(noMarker.stderr).not.toContain("high-risk");
|
||||
expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull();
|
||||
|
||||
const preview = await runProject(root, "build", ["--dry-run"]);
|
||||
expect(preview.exitCode, preview.stderr).toBe(0);
|
||||
expect(await stat(join(root, ".openagentpack/build")).catch(() => null)).toBeNull();
|
||||
const built = await runProject(root, "build");
|
||||
expect(built.exitCode, built.stderr).toBe(0);
|
||||
expect(built.stderr).not.toContain("requires_confirmation");
|
||||
expect((await stat(join(root, ".openagentpack/build/agents.yaml"))).isFile()).toBe(true);
|
||||
const explicitValid = await runProject(nested, "build", ["--project", root]);
|
||||
expect(explicitValid.exitCode, explicitValid.stderr).toBe(0);
|
||||
const storePath = join(root, ".openagentpack/versions/project/store.json");
|
||||
const beforePublish = await readFile(storePath, "utf8");
|
||||
const publish = await runProject(root, "publish");
|
||||
expect(publish.exitCode).toBe(7);
|
||||
expect(publish.stderr).toContain("requires_confirmation");
|
||||
expect(await readFile(storePath, "utf8")).toBe(beforePublish);
|
||||
});
|
||||
|
||||
test("only publish help includes confirmation; build keeps dry-run", async () => {
|
||||
const build = await runCommandHelp(routes, ["managed-agent", "project", "build", "--help"]);
|
||||
expect(build.stderr).not.toContain("--yes");
|
||||
expect(build.stderr).toContain("--dry-run");
|
||||
const publish = await runCommandHelp(routes, ["managed-agent", "project", "publish", "--help"]);
|
||||
expect(publish.stderr).toContain("--yes");
|
||||
const directory = await temporaryDirectory();
|
||||
const removedFlag = await runProject(directory, "build", ["--yes"]);
|
||||
expect(removedFlag.exitCode).toBe(2);
|
||||
expect(removedFlag.stderr).toMatch(/Unknown flag.*--yes/);
|
||||
});
|
||||
|
||||
test("nested build and validate explain the project root without changing directories", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const initialized = await runInit(directory);
|
||||
expect(initialized.exitCode, initialized.stderr).toBe(0);
|
||||
const root = join(directory, "managed-agent");
|
||||
const nested = join(root, "agents/assistant/skills");
|
||||
for (const command of ["build", "validate"]) {
|
||||
const result = await runProject(nested, command, ["--dry-run"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("Not a project root:");
|
||||
expect(result.stderr).not.toMatch(/\p{Script=Han}/u);
|
||||
expect(result.stderr).toContain(`cd '${root}'`);
|
||||
expect(result.stderr).toContain(`--project '${root}'`);
|
||||
expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND");
|
||||
}
|
||||
expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull();
|
||||
const corrected = await runProject(nested, "build", ["--project", root, "--dry-run"]);
|
||||
expect(corrected.exitCode, corrected.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("help describes the subdirectory default and explicit in-place initialization", async () => {
|
||||
const result = await runCommandHelp(routes, ["managed-agent", "project", "init", "--help"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toContain("./managed-agent");
|
||||
expect(result.stderr).toContain("--project .");
|
||||
});
|
||||
|
||||
test("default dry-run reports the child directory without creating it", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const result = await runInit(directory, ["--dry-run"]);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseStdoutJson(result.stdout)).toEqual({ would_initialize_project: "./managed-agent" });
|
||||
expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
test("initializes only the child, ignores parent source, and rejects repeated init without overwriting", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const parentYaml = "not a valid project declaration";
|
||||
await writeFile(join(directory, "agents.yaml"), parentYaml);
|
||||
if (process.platform !== "win32") {
|
||||
await symlink("missing-instructions.md", join(directory, "CLAUDE.md"));
|
||||
}
|
||||
const result = await runInit(directory);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const projectRoot = join(directory, "managed-agent");
|
||||
const initialized = parseStdoutJson<{
|
||||
project_root: string;
|
||||
baseline_version: string;
|
||||
converted_from_yaml: boolean;
|
||||
}>(result.stdout);
|
||||
expect(initialized.project_root).toBe(projectRoot);
|
||||
expect(initialized.baseline_version).toHaveLength(64);
|
||||
expect(initialized.converted_from_yaml).toBe(false);
|
||||
expect(await stat(join(directory, "project.json")).catch(() => null)).toBeNull();
|
||||
expect(await readFile(join(directory, "agents.yaml"), "utf8")).toBe(parentYaml);
|
||||
expect((await stat(join(projectRoot, "agents/assistant/agent.json"))).isFile()).toBe(true);
|
||||
expect(
|
||||
(await stat(join(projectRoot, ".openagentpack/versions/project/store.json"))).isFile(),
|
||||
).toBe(true);
|
||||
const instructions = join(projectRoot, "agents/assistant/instructions.md");
|
||||
await writeFile(instructions, "user changes");
|
||||
const repeated = await runInit(directory);
|
||||
expect(repeated.exitCode).not.toBe(0);
|
||||
expect(repeated.stderr).toContain("already exists");
|
||||
expect(await readFile(instructions, "utf8")).toBe("user changes");
|
||||
});
|
||||
|
||||
test("explicit paths, including dot, remain exact project roots", async () => {
|
||||
for (const target of ["custom-agent", "."]) {
|
||||
const directory = await temporaryDirectory();
|
||||
const result = await runInit(directory, ["--project", target]);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ project_root: string }>(result.stdout).project_root).toBe(
|
||||
join(directory, target),
|
||||
);
|
||||
expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
resolveProjectConfig,
|
||||
type ResourceAddress,
|
||||
} from "@openagentpack/sdk";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { parse } from "yaml";
|
||||
import { e2eFixturesDir, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts";
|
||||
import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
|
||||
@@ -29,6 +29,13 @@ const DEPLOYMENT_SAFETY_DIAGNOSTIC_CODES = [
|
||||
"bailian.deployment.file.mount_path.required",
|
||||
"bailian.deployment.file.mount_path.duplicate",
|
||||
];
|
||||
const projectDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const directory of projectDirectories.splice(0)) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function writeLocalSkillDirectory(parentDirectory: string): Promise<string> {
|
||||
const skillDirectory = join(parentDirectory, "local-report");
|
||||
@@ -98,8 +105,8 @@ async function seedTrackedResources(
|
||||
/**
|
||||
* managed-agent:help / 缺参不依赖密钥;所有 mutation 命令的 --dry-run
|
||||
* 必须在构建 SDK runtime(凭证注入 / 联网 / 写盘)之前短路,因此同样不需要密钥。
|
||||
* 鉴权分层:离线命令(init/validate/state list|show|rm)auth: "none";联网命令
|
||||
* 统一 auth: "apiKey" 硬门禁(见 managed-agent-auth-chain e2e)。
|
||||
* 鉴权分层:离线命令(init/project init/validate/state list|show|rm)auth: "none";
|
||||
* 联网命令统一 auth: "apiKey" 硬门禁(见 managed-agent-auth-chain e2e)。
|
||||
* 真实集成(apply/destroy/session 流程)依赖工作区内的 agents.yaml 与远端资源,
|
||||
* 属批量场景,暂仅覆盖 dry-run 契约。
|
||||
*/
|
||||
@@ -226,6 +233,215 @@ describe("e2e: managed-agent", () => {
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--file|--yes/i);
|
||||
expect(stderr).not.toContain("--provider");
|
||||
expect(stderr).not.toContain("--ci");
|
||||
expect(stderr).not.toContain("--refresh-only");
|
||||
});
|
||||
|
||||
test("managed-agent apply 不再接受 --refresh-only", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"apply",
|
||||
"--dry-run",
|
||||
"--refresh-only",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Unknown flag.*--refresh-only/i);
|
||||
});
|
||||
|
||||
test("managed-agent init 不再暴露 Git 仓库脚手架", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"init",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).not.toContain("--git");
|
||||
});
|
||||
|
||||
test("managed-agent project 暴露目录项目与共享版本管理子命令", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/init|validate|build|publish|workbench|version/i);
|
||||
});
|
||||
|
||||
test("managed-agent project 完成 init、validate、build 与 version status 本地闭环", async () => {
|
||||
const projectRoot = await mkdtemp(join(tmpdir(), "bailian-managed-agent-project-"));
|
||||
projectDirectories.push(projectRoot);
|
||||
const initialized = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"init",
|
||||
"--project",
|
||||
projectRoot,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(initialized.exitCode, initialized.stderr).toBe(0);
|
||||
expect(
|
||||
parseStdoutJson<{ baseline_version?: string }>(initialized.stdout).baseline_version,
|
||||
).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(JSON.parse(await readFile(join(projectRoot, "project.json"), "utf8"))).toEqual({
|
||||
version: "1",
|
||||
});
|
||||
expect(await stat(join(projectRoot, ".env")).catch(() => null)).toBeNull();
|
||||
expect(await stat(join(projectRoot, ".gitignore")).catch(() => null)).toBeNull();
|
||||
|
||||
expect(
|
||||
JSON.parse(await readFile(join(projectRoot, "agents/assistant/agent.json"), "utf8")),
|
||||
).toEqual({ name: "Assistant", model: "qwen3.7-max" });
|
||||
for (const relativePath of [
|
||||
"skills/_examples/example-skill/skill.json",
|
||||
"skills/_examples/example-skill/SKILL.md",
|
||||
"files/_examples/example-file/file.json",
|
||||
"files/_examples/example-file/example.md",
|
||||
"vaults/_examples/example-vault/vault.json",
|
||||
"environments/_examples/example-env/environment.json",
|
||||
]) {
|
||||
expect((await stat(join(projectRoot, "agents/assistant", relativePath))).isFile()).toBe(true);
|
||||
expect(
|
||||
await readFile(join(projectRoot, "agents/assistant", relativePath, "../README.md"), "utf8"),
|
||||
).toContain("_examples/");
|
||||
}
|
||||
|
||||
const skillDirectory = join(projectRoot, "agents/assistant/skills/writer");
|
||||
const filesDirectory = join(projectRoot, "agents/assistant/files");
|
||||
const nestedFileDirectory = join(filesDirectory, "mount");
|
||||
await mkdir(skillDirectory, { recursive: true });
|
||||
await mkdir(nestedFileDirectory, { recursive: true });
|
||||
await writeFile(join(skillDirectory, "SKILL.md"), "# Writer\n", "utf8");
|
||||
await writeFile(join(filesDirectory, "brief.txt"), "Build this brief.\n", "utf8");
|
||||
await writeFile(join(nestedFileDirectory, "mount.md"), "Mount this file.\n", "utf8");
|
||||
|
||||
const validated = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"validate",
|
||||
"--project",
|
||||
projectRoot,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(validated.exitCode, validated.stderr).toBe(0);
|
||||
expect(
|
||||
parseStdoutJson<{ diagnostics?: Array<{ severity?: string }> }>(validated.stdout).diagnostics,
|
||||
).not.toContainEqual(expect.objectContaining({ severity: "error" }));
|
||||
|
||||
const vaultDirectory = join(projectRoot, "agents/assistant/vaults/secrets");
|
||||
await mkdir(vaultDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
join(vaultDirectory, "vault.json"),
|
||||
JSON.stringify({
|
||||
id: "secrets",
|
||||
display_name: "Secrets",
|
||||
credentials: [
|
||||
{
|
||||
name: "service",
|
||||
type: "environment_variable",
|
||||
secret_name: "SERVICE_TOKEN",
|
||||
secret_value: "e2e-vault-private-value",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const dryBuild = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"build",
|
||||
"--project",
|
||||
projectRoot,
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(dryBuild.exitCode, dryBuild.stderr).toBe(0);
|
||||
expect(dryBuild.stdout + dryBuild.stderr).not.toContain("e2e-vault-private-value");
|
||||
expect(dryBuild.stdout).toContain("AGENTS_VAULT_");
|
||||
expect(await stat(join(projectRoot, ".env")).catch(() => null)).toBeNull();
|
||||
|
||||
const built = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"build",
|
||||
"--project",
|
||||
projectRoot,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(built.exitCode, built.stderr).toBe(0);
|
||||
const generatedYaml = await readFile(
|
||||
join(projectRoot, ".openagentpack/build/agents.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
expect(generatedYaml).not.toContain("example-");
|
||||
expect(generatedYaml).not.toContain("_examples");
|
||||
expect(generatedYaml).not.toContain("$" + "{SERVICE_TOKEN}");
|
||||
expect(built.stdout + built.stderr).not.toContain("e2e-vault-private-value");
|
||||
expect(await readFile(join(projectRoot, ".env"), "utf8")).toContain("e2e-vault-private-value");
|
||||
expect(await readFile(join(vaultDirectory, "vault.json"), "utf8")).not.toContain(
|
||||
"e2e-vault-private-value",
|
||||
);
|
||||
expect(
|
||||
await readFile(join(projectRoot, ".openagentpack/build/agents.yaml"), "utf8"),
|
||||
).not.toContain("e2e-vault-private-value");
|
||||
expect(await readFile(join(projectRoot, ".openagentpack/build/agents.yaml"), "utf8")).toContain(
|
||||
"assistant",
|
||||
);
|
||||
expect(await readFile(join(projectRoot, ".openagentpack/build/agents.yaml"), "utf8")).toContain(
|
||||
"provider: bailian",
|
||||
);
|
||||
expect(JSON.parse(await readFile(join(skillDirectory, "skill.json"), "utf8"))).toEqual({
|
||||
id: "writer",
|
||||
});
|
||||
expect(JSON.parse(await readFile(join(filesDirectory, "brief/file.json"), "utf8"))).toEqual({
|
||||
id: "brief",
|
||||
name: "brief.txt",
|
||||
source: "./brief.txt",
|
||||
});
|
||||
expect(JSON.parse(await readFile(join(nestedFileDirectory, "file.json"), "utf8"))).toEqual({
|
||||
id: "mount",
|
||||
name: "mount.md",
|
||||
source: "./mount.md",
|
||||
});
|
||||
const builtAgent = JSON.parse(
|
||||
await readFile(join(projectRoot, "agents/assistant/agent.json"), "utf8"),
|
||||
);
|
||||
expect(builtAgent.skills).toEqual(["writer"]);
|
||||
expect(builtAgent.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ file: "brief", mount_path: "/mnt/brief.txt" },
|
||||
{ file: "mount", mount_path: "/mnt/mount.md" },
|
||||
]),
|
||||
);
|
||||
|
||||
const status = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"version",
|
||||
"status",
|
||||
"--project",
|
||||
projectRoot,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(status.exitCode, status.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ enabled?: boolean }>(status.stdout).enabled).toBe(true);
|
||||
expect(await stat(join(projectRoot, ".openagentpack/state.json")).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
test("managed-agent project version preview 缺少 --version-id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"version",
|
||||
"preview",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--version-id|Missing required/i);
|
||||
});
|
||||
|
||||
test("managed-agent apply 计划失败时在最终错误中保留首条诊断和剩余数量", async () => {
|
||||
@@ -1289,6 +1505,29 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () =>
|
||||
expect(data.provider).toBe("bailian");
|
||||
});
|
||||
|
||||
test("project workbench --dry-run 仅输出目录项目启动计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"workbench",
|
||||
"--dry-run",
|
||||
"--project",
|
||||
"./agent-project",
|
||||
"--no-open",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
would_launch?: string;
|
||||
project_root?: string;
|
||||
port?: number;
|
||||
}>(stdout);
|
||||
expect(data.would_launch).toBe("workbench");
|
||||
expect(data.project_root).toBe("./agent-project");
|
||||
expect(data.port).toBe(4848);
|
||||
});
|
||||
|
||||
test("init 不再接受 --provider", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
@@ -1301,6 +1540,27 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () =>
|
||||
expect(stderr).toMatch(/Unknown flag.*--provider/i);
|
||||
});
|
||||
|
||||
test("project init --dry-run 不需要密钥且不创建目录", async () => {
|
||||
const parent = await mkdtemp(join(tmpdir(), "bailian-managed-agent-project-dry-run-"));
|
||||
projectDirectories.push(parent);
|
||||
const projectRoot = join(parent, "project");
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"project",
|
||||
"init",
|
||||
"--dry-run",
|
||||
"--project",
|
||||
projectRoot,
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(
|
||||
parseStdoutJson<{ would_initialize_project?: string }>(stdout).would_initialize_project,
|
||||
).toBe(projectRoot);
|
||||
expect(await stat(projectRoot).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
test("apply --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
|
||||
@@ -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 vocabulary:help / 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 vocabulary(DashScope 媒体)",
|
||||
() => {
|
||||
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-quota(Console)", () => {
|
||||
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,12 +188,42 @@ 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",
|
||||
"managed-agent plan": "managedAgentPlan",
|
||||
"managed-agent apply": "managedAgentApply",
|
||||
"managed-agent destroy": "managedAgentDestroy",
|
||||
"managed-agent playground": "managedAgentPlayground",
|
||||
"managed-agent project init": "managedAgentProjectInit",
|
||||
"managed-agent project validate": "managedAgentProjectValidate",
|
||||
"managed-agent project build": "managedAgentProjectBuild",
|
||||
"managed-agent project publish": "managedAgentProjectPublish",
|
||||
"managed-agent project workbench": "managedAgentProjectWorkbench",
|
||||
"managed-agent project version enable": "managedAgentProjectVersionEnable",
|
||||
"managed-agent project version disable": "managedAgentProjectVersionDisable",
|
||||
"managed-agent project version status": "managedAgentProjectVersionStatus",
|
||||
"managed-agent project version list": "managedAgentProjectVersionList",
|
||||
"managed-agent project version preview": "managedAgentProjectVersionPreview",
|
||||
"managed-agent project version restore": "managedAgentProjectVersionRestore",
|
||||
"managed-agent state list": "managedAgentStateList",
|
||||
"managed-agent state rm": "managedAgentStateRm",
|
||||
"managed-agent state import": "managedAgentStateImport",
|
||||
|
||||
@@ -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,139 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
commitPreparedProjectVersion,
|
||||
disableProjectVersioning,
|
||||
enableProjectVersioning,
|
||||
getProjectVersionStatus,
|
||||
listProjectVersions,
|
||||
prepareProjectVersion,
|
||||
previewProjectVersion,
|
||||
restoreProjectVersion,
|
||||
} from "@openagentpack/sdk/project-versions";
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { playgroundBrowserTargetFromSummary } from "../src/commands/managed-agent/_engine/playground-launcher.ts";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("managed-agent local snapshot versions", () => {
|
||||
test("uses the shared path-scoped switch and stores full YAML outside store.json", async () => {
|
||||
const root = await temporaryDirectory();
|
||||
const configPath = join(root, "agents.yaml");
|
||||
const siblingDirectory = join(root, "nested");
|
||||
const siblingConfigPath = join(siblingDirectory, "agents.yaml");
|
||||
await mkdir(siblingDirectory);
|
||||
await writeFile(configPath, projectYaml("First"));
|
||||
await writeFile(siblingConfigPath, projectYaml("Second"));
|
||||
|
||||
const enabled = await enableProjectVersioning(configPath, "Enable Bailian CLI versioning");
|
||||
|
||||
expect(enabled.versioning.enabled).toBe(true);
|
||||
expect(enabled.version?.message).toBe("Enable Bailian CLI versioning");
|
||||
expect((await getProjectVersionStatus(siblingConfigPath)).enabled).toBe(false);
|
||||
const storeSource = await readFile(join(root, ".openagentpack/versions/store.json"), "utf8");
|
||||
expect(storeSource).not.toContain("instructions: First");
|
||||
const snapshotSource = await readFile(
|
||||
join(root, ".openagentpack/versions/blobs", `${enabled.version!.source_hash}.yaml`),
|
||||
"utf8",
|
||||
);
|
||||
expect(snapshotSource).toContain("instructions: First");
|
||||
|
||||
await writeFile(configPath, projectYaml("First updated"));
|
||||
const repeated = await enableProjectVersioning(configPath, "Enable Bailian CLI versioning");
|
||||
expect(repeated.version?.message).toBe("Enable Bailian CLI versioning");
|
||||
expect((await listProjectVersions(configPath)).versions).toHaveLength(2);
|
||||
|
||||
const disabled = await disableProjectVersioning(configPath);
|
||||
expect(disabled.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("auto-snapshots after success and restores without changing history or permissions", async () => {
|
||||
const root = await temporaryDirectory();
|
||||
const configPath = join(root, "agents.yaml");
|
||||
await writeFile(configPath, projectYaml("Version one"));
|
||||
await chmod(configPath, 0o640);
|
||||
const enabled = await enableProjectVersioning(configPath, "Enable Bailian CLI versioning");
|
||||
const firstVersion = enabled.version!.version_id;
|
||||
const secondSource = projectYaml("Version two");
|
||||
await writeFile(configPath, secondSource);
|
||||
|
||||
const prepared = await prepareProjectVersion(configPath, secondSource);
|
||||
const version = await commitPreparedProjectVersion(prepared!);
|
||||
const currentVersionBeforeRestore = (await getProjectVersionStatus(configPath)).head_version;
|
||||
expect(version?.message).toBe("Apply agents.yaml");
|
||||
|
||||
const preview = await previewProjectVersion(configPath, firstVersion);
|
||||
expect(preview.can_restore).toBe(true);
|
||||
expect(preview.after_yaml).toContain("Version one");
|
||||
await restoreProjectVersion(configPath, firstVersion, {
|
||||
headVersion: preview.base_head_version,
|
||||
sourceRevision: preview.base_source_revision,
|
||||
});
|
||||
|
||||
expect(await readFile(configPath, "utf8")).toContain("Version one");
|
||||
expect((await getProjectVersionStatus(configPath)).head_version).toBe(
|
||||
currentVersionBeforeRestore,
|
||||
);
|
||||
expect((await stat(configPath)).mode & 0o777).toBe(0o640);
|
||||
});
|
||||
|
||||
test("rejects abbreviated version IDs and plaintext credentials", async () => {
|
||||
const root = await temporaryDirectory();
|
||||
const configPath = join(root, "agents.yaml");
|
||||
await writeFile(configPath, projectYaml("Safe"));
|
||||
const enabled = await enableProjectVersioning(configPath, "Enable Bailian CLI versioning");
|
||||
await expect(previewProjectVersion(configPath, enabled.version!.short_version)).rejects.toThrow(
|
||||
/full 64-character hexadecimal/i,
|
||||
);
|
||||
|
||||
await disableProjectVersioning(configPath);
|
||||
await writeFile(
|
||||
configPath,
|
||||
projectYaml("Unsafe").replace("qoder: {}", "qoder:\n api_key: plaintext-secret"),
|
||||
);
|
||||
await expect(
|
||||
enableProjectVersioning(configPath, "Enable Bailian CLI versioning"),
|
||||
).rejects.toThrow(/environment variable reference/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed-agent Workbench policy", () => {
|
||||
test("Session Preview opens the requested Agent or falls back to Workbench", () => {
|
||||
const summary = {
|
||||
status: "valid",
|
||||
agents: [{ agent: { id: "assistant" } }, { agent: { id: "reviewer" } }],
|
||||
};
|
||||
expect(
|
||||
playgroundBrowserTargetFromSummary("http://localhost:4848", summary, "reviewer"),
|
||||
).toEqual({ url: "http://localhost:4848/agents/reviewer/preview" });
|
||||
expect(playgroundBrowserTargetFromSummary("http://localhost:4848", summary)).toEqual(
|
||||
expect.objectContaining({ url: "http://localhost:4848", warning: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "bailian-cli-local-versions-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function projectYaml(instructions: string): string {
|
||||
return `version: "1"
|
||||
providers:
|
||||
qoder: {}
|
||||
defaults:
|
||||
provider: qoder
|
||||
agents:
|
||||
assistant:
|
||||
model: ultimate
|
||||
instructions: ${instructions}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const registry = vi.hoisted(() => ({
|
||||
version: '"0.7.0"',
|
||||
error: null as Error | null,
|
||||
manifest: "",
|
||||
query: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:child_process")>()),
|
||||
execFile: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: unknown,
|
||||
callback: (error: Error | null, output: { stdout: string }) => void,
|
||||
) => {
|
||||
registry.query(command, args, options);
|
||||
callback(registry.error, { stdout: registry.version });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node:module", () => ({
|
||||
createRequire: () => ({ resolve: () => registry.manifest }),
|
||||
}));
|
||||
|
||||
import { resolveLauncher } from "../src/commands/managed-agent/_engine/playground-launcher.ts";
|
||||
|
||||
let directory: string;
|
||||
let binary: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "playground-launcher-"));
|
||||
binary = join(directory, "playground.js");
|
||||
registry.manifest = join(directory, "package.json");
|
||||
registry.version = '"0.7.0"';
|
||||
registry.error = null;
|
||||
registry.query.mockClear();
|
||||
await writeFile(binary, "");
|
||||
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN", "");
|
||||
vi.stubEnv("AGENTS_PLAYGROUND_BIN", "");
|
||||
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION", "");
|
||||
vi.spyOn(process, "cwd").mockReturnValue(directory);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function install(version: string) {
|
||||
await writeFile(registry.manifest, JSON.stringify({ version, bin: "playground.js" }));
|
||||
}
|
||||
|
||||
test("checks latest on every resolution and reuses only the matching installed version", async () => {
|
||||
await install("0.7.0");
|
||||
expect(await resolveLauncher()).toMatchObject({
|
||||
args: [binary],
|
||||
version: "0.7.0",
|
||||
fetched: false,
|
||||
});
|
||||
registry.version = '"0.8.0"';
|
||||
expect(await resolveLauncher()).toMatchObject({
|
||||
command: "npx",
|
||||
args: ["-y", "@openagentpack/playground@0.8.0"],
|
||||
version: "0.8.0",
|
||||
fetched: true,
|
||||
});
|
||||
expect(registry.query).toHaveBeenCalledTimes(2);
|
||||
expect(registry.query).toHaveBeenCalledWith(
|
||||
"npm",
|
||||
expect.arrayContaining([
|
||||
"view",
|
||||
"@openagentpack/playground@latest",
|
||||
"--prefer-online",
|
||||
"--fetch-timeout=10000",
|
||||
]),
|
||||
expect.objectContaining({ timeout: 15_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
test("downloads the resolved exact version when local installation is old or missing", async () => {
|
||||
for (const version of ["0.6.0", undefined]) {
|
||||
if (version) await install(version);
|
||||
else await rm(registry.manifest);
|
||||
expect(await resolveLauncher()).toMatchObject({
|
||||
command: "npx",
|
||||
args: ["-y", "@openagentpack/playground@0.7.0"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit version overrides an incompatible installed version", async () => {
|
||||
await install("0.6.0");
|
||||
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION", "0.7.0");
|
||||
expect(await resolveLauncher()).toMatchObject({ fetched: true, version: "0.7.0" });
|
||||
expect(registry.query.mock.calls[0]?.[1]).toContain("@openagentpack/playground@0.7.0");
|
||||
});
|
||||
|
||||
test("explicit binary remains an offline development override", async () => {
|
||||
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN", binary);
|
||||
expect(await resolveLauncher()).toMatchObject({ args: [binary], fetched: false });
|
||||
expect(registry.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("registry failure does not silently reuse an old installation", async () => {
|
||||
await install("0.6.0");
|
||||
registry.error = new Error("registry unavailable");
|
||||
await expect(resolveLauncher()).rejects.toThrow("registry unavailable");
|
||||
});
|
||||
|
||||
test("rejects malformed or ambiguous registry metadata with localized diagnostics", async () => {
|
||||
for (const output of ["invalid", '["0.6.0","0.7.0"]', '"--unsafe"']) {
|
||||
registry.version = output;
|
||||
await expect(resolveLauncher()).rejects.toThrow("single valid Playground version");
|
||||
await expect(resolveLauncher()).rejects.toThrow("唯一有效的 Playground 版本号");
|
||||
}
|
||||
});
|
||||
|
||||
test("local source builds must also match the resolved version", async () => {
|
||||
const packageRoot = join(directory, "packages/playground");
|
||||
const sourceBinary = join(packageRoot, "dist/bin/playground.js");
|
||||
await mkdir(join(packageRoot, "dist/bin"), { recursive: true });
|
||||
await writeFile(sourceBinary, "");
|
||||
await writeFile(join(packageRoot, "package.json"), JSON.stringify({ version: "0.6.0" }));
|
||||
expect(await resolveLauncher()).toMatchObject({ fetched: true });
|
||||
await writeFile(join(packageRoot, "package.json"), JSON.stringify({ version: "0.7.0" }));
|
||||
expect(await resolveLauncher()).toMatchObject({
|
||||
args: [sourceBinary],
|
||||
version: "0.7.0",
|
||||
fetched: false,
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-core",
|
||||
"version": "1.20.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": {
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
resolveOpenApi,
|
||||
describeAuthState,
|
||||
resolveModelBaseUrl,
|
||||
resolveModelBaseUrlState,
|
||||
} from "./resolver.ts";
|
||||
export { makeAuthStore, type AuthStore, type AuthPersistPatch } from "./store.ts";
|
||||
export type {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 word→weight 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 word→weight 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -287,8 +287,10 @@ export interface Command<F extends FlagsDef = FlagsDef> {
|
||||
* Cross-flag validation, after parsing and before run. Return an error message
|
||||
* → UsageError; undefined to pass. Single-flag `required` is enforced by the
|
||||
* parser — use this for rules spanning flags or depending on a flag's *value*.
|
||||
* May be async for read-only local preflight. Runtime awaits it before auth
|
||||
* and confirmation. Do not perform remote requests or local writes here.
|
||||
*/
|
||||
validate?: (flags: ParsedFlags<F>) => string | undefined;
|
||||
validate?: (flags: ParsedFlags<F>) => string | undefined | Promise<string | undefined>;
|
||||
run: (ctx: CommandContext<F>) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export type {
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
ChatTool,
|
||||
AsrContextMessage,
|
||||
DashScopeASRRequest,
|
||||
DashScopeASRTaskResult,
|
||||
DashScopeASRTranscriptionItem,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeSettings(configName?: string): Settings {
|
||||
output: "json",
|
||||
outputExplicit: false,
|
||||
timeout: 30,
|
||||
watermark: true,
|
||||
verbose: false,
|
||||
quiet: true,
|
||||
dryRun: false,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "knowledge-studio-cli",
|
||||
"version": "1.20.0",
|
||||
"version": "1.25.0",
|
||||
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
|
||||
"keywords": [
|
||||
"alibaba-cloud",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-runtime",
|
||||
"version": "1.20.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": {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
buildSources,
|
||||
buildSettings,
|
||||
describeAuthState,
|
||||
resolveModelBaseUrl,
|
||||
resolveModelBaseUrlState,
|
||||
makeConfigStore,
|
||||
makeAuthStore,
|
||||
flushTelemetry,
|
||||
@@ -217,7 +217,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
parsedFlags,
|
||||
Object.keys(res.command.flags ?? {}),
|
||||
) as ParsedFlags<FlagsDef>;
|
||||
const invalid = res.command.validate?.(ownFlags);
|
||||
const invalid = await res.command.validate?.(ownFlags);
|
||||
if (invalid) throw new UsageError(invalid);
|
||||
|
||||
// 校验通过 → 建源、解析 settings、组 ctx,进中间件执行命令。
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user