mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7d32504ab | |||
| cdf94a8c89 | |||
| ce4d66b736 | |||
| 196a0aa506 | |||
| a9b0a752a8 | |||
| 2b7a0c742a | |||
| e5818e103c | |||
| 7797940626 | |||
| 5f1c97940d | |||
| 94ccab0898 | |||
| b895f88abb | |||
| 7eedc05b99 | |||
| f3c7b6fb10 | |||
| eb196cb4a6 | |||
| f1b6cacd7f | |||
| 9133b6bdd1 | |||
| 98ba3279fa | |||
| 3b7c4cfabc | |||
| d5d9fcb50f | |||
| 3ea2931152 | |||
| b402f3eacd | |||
| bedd59df27 | |||
| 39a488181e | |||
| 4ec0f6828b | |||
| 4dcec7d075 | |||
| daefc094ec | |||
| ae0c2c1213 | |||
| 01a62eb85b | |||
| 798ce596f6 | |||
| bd91e9d1c2 | |||
| e244771ee9 | |||
| 94f9dbbe9e | |||
| 8a0dd70206 | |||
| 9379da7a4c | |||
| ddcd564e61 | |||
| 0e4dd4b824 | |||
| 241de61866 | |||
| 61d9a74166 | |||
| 69eb759490 | |||
| 313966d7a9 | |||
| 4d84af614b | |||
| 9ae5dc924d | |||
| d6cb075629 | |||
| 0e33c70e65 | |||
| 24092b423c | |||
| 752a79e442 | |||
| 80bdcb83f6 |
@@ -35,7 +35,7 @@ packages/core/src/auth/ # apiKey / console credential 解析与落盘
|
||||
packages/core/src/client/ # HTTP client / endpoints / console gateway
|
||||
```
|
||||
|
||||
Skill / 命令手册随 `skills/bailian-*/` 经 `npx skills add modelstudioai/cli --all -g` 安装(整包装齐,含共享协议 `bailian-protocol`)。业务 skill(`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent`)执行前读 `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`)执行前读 `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,43 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.15.0] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Responses API for `bl text chat`** — Use `--api responses` to call the DashScope Responses API with streaming, tool definitions, and structured JSON output; Chat Completions remains the default.
|
||||
- **Subscription plan usage views** — `bl usage token-plan` displays 5-hour and weekly quota usage, while `bl usage coding-plan` displays 5-hour, weekly, and monthly usage; both support text and JSON output.
|
||||
- **Authentication requirements in command help** — Help output now states whether a command requires an API Key, Console login, or Alibaba Cloud OpenAPI credentials.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Broader speech-recognition model support** — `bl speech recognize` now routes asynchronous file-transcription and synchronous Flash ASR models to the appropriate DashScope APIs, with clear guidance for unsupported realtime models.
|
||||
- **MCP transport compatibility** — MCP commands now fall back from Streamable HTTP to classic SSE for compatible Bailian and custom endpoints.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Binary updates now refresh installed Agent Skills after a successful CLI upgrade.
|
||||
- Fixed unavailable Token Plan quota values and missing reset times.
|
||||
- Fixed Qwen3 file-transcription result handling so waiting mode and `--out` work correctly.
|
||||
- Fixed MCP SSE chunk parsing, header timeouts, abort cleanup, and fallback status matching.
|
||||
- Network failures in JSON output now preserve the errno value in `cause.code`.
|
||||
|
||||
## [1.14.3] - 2026-08-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Free-tier quota compatibility** — `bl usage free` and `bl usage freetier` now use the current Bailian Commerce console APIs for quota queries, activation, and deactivation, with consistent asynchronous-task polling.
|
||||
|
||||
## [1.14.2] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl skill init`** — Install all first-party `bailian-*` skills into detected local AI Agents in one step.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Skill command interface** — Skill management commands now default to JSON output for Agent workflows; `bl skill add` and `bl skill update` use explicit `--all` and `--name` selectors.
|
||||
|
||||
## [1.14.1] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,43 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.15.0] - 2026-08-14
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl text chat` 支持 Responses API** —— 可通过 `--api responses` 调用 DashScope Responses API,支持流式输出、工具定义和结构化 JSON 输出;默认仍使用 Chat Completions。
|
||||
- **订阅套餐用量视图** —— `bl usage token-plan` 支持查看 5 小时和每周额度,`bl usage coding-plan` 支持查看 5 小时、每周和每月额度;两者均提供文本与 JSON 输出。
|
||||
- **命令帮助展示鉴权要求** —— Help 输出现在会明确标注命令需要 API Key、控制台登录还是阿里云 OpenAPI 凭证。
|
||||
|
||||
### 变更
|
||||
|
||||
- **扩展语音识别模型支持** —— `bl speech recognize` 现在会将异步文件转写和同步 Flash ASR 模型路由至对应的 DashScope API,并为暂不支持的实时模型提供明确提示。
|
||||
- **增强 MCP 传输兼容性** —— MCP 命令现在可为兼容的百炼及自定义端点从 Streamable HTTP 自动回退至经典 SSE。
|
||||
|
||||
### 修复
|
||||
|
||||
- 二进制方式升级 CLI 成功后,现在会同步刷新已安装的 Agent Skills。
|
||||
- 修复 Token Plan 额度不可用或缺少重置时间时的展示问题。
|
||||
- 修复 Qwen3 文件转写结果处理,使等待模式和 `--out` 能够正常工作。
|
||||
- 修复 MCP SSE 分块解析、响应头超时、中止清理和回退状态匹配问题。
|
||||
- JSON 输出中的网络错误现在会在 `cause.code` 中保留 errno。
|
||||
|
||||
## [1.14.3] - 2026-08-12
|
||||
|
||||
### 修复
|
||||
|
||||
- **免费额度兼容性** —— `bl usage free` 和 `bl usage freetier` 现在使用最新的 Bailian Commerce 控制台 API 查询、开通和关闭免费额度,并统一处理异步任务轮询。
|
||||
|
||||
## [1.14.2] - 2026-08-07
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl skill init`** —— 一次性将全部官方 `bailian-*` Skill 安装到本机检测到的 AI Agent。
|
||||
|
||||
### 变更
|
||||
|
||||
- **Skill 命令接口** —— Skill 管理命令现在默认输出适合 Agent 工作流的 JSON;`bl skill add` 和 `bl skill update` 使用明确的 `--all` 与 `--name` 选择参数。
|
||||
|
||||
## [1.14.1] - 2026-08-05
|
||||
|
||||
### 新增
|
||||
|
||||
+34
-31
@@ -1,8 +1,38 @@
|
||||
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
|
||||
|
||||
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**使用二进制一键安装(无需 Node);若环境已有 Node / 需要 Command Pack,再用 npm。不要臆造版本号或路径;以用户环境为准。
|
||||
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**在已有 Node.js(**≥ 18.17.0**)的环境用 npm 安装;若没有可用 Node / npm,再走二进制一键安装。不要臆造版本号或路径;以用户环境为准。
|
||||
|
||||
## 1. 推荐:二进制安装(无需 Node)
|
||||
## 1. 推荐:npm 安装(要求 **≥ 18.17.0**)
|
||||
|
||||
1. `node -v` 确认版本 ≥ 18.17.0。
|
||||
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn)。
|
||||
3. 执行:
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
```
|
||||
|
||||
4. 校验:`bl --version`。
|
||||
|
||||
安装 skills(CLI 内置,无需 Git / npx skills):
|
||||
|
||||
```bash
|
||||
bl skill init
|
||||
```
|
||||
|
||||
**Supported:** `bl skill init` 一次装齐 registry 中全部 `bailian-*`(含共享协议 `bailian-protocol`)。
|
||||
|
||||
**Advanced / 按需子集:**
|
||||
|
||||
```bash
|
||||
bl skill add --name bailian-protocol,bailian-gen
|
||||
```
|
||||
|
||||
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
|
||||
|
||||
## 2. 备选:二进制安装(无需 Node)
|
||||
|
||||
当环境没有 Node / npm,或 Node 版本过低无法走 npm 时,使用二进制安装脚本。脚本安装 CLI 成功后会自动执行 `bl skill init`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
@@ -37,36 +67,9 @@ bl --version
|
||||
which bl # Windows: where.exe bl
|
||||
```
|
||||
|
||||
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装。
|
||||
若自动 skill 安装失败,再手动执行:`bl skill init`。
|
||||
|
||||
## 2. 备选:npm 安装(要求 **≥ 18.17.0**)
|
||||
|
||||
1. `node -v` 确认版本。
|
||||
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn)。
|
||||
3. 执行:
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
```
|
||||
|
||||
4. 校验:`bl --version`。
|
||||
|
||||
可选 skills(与 CLI 本体无关,按需):
|
||||
|
||||
```bash
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
**Supported:** 始终使用 `--all -g`,一次装齐整套 `bailian-*`(含共享协议 `bailian-protocol`)。Agent Skills / `npx skills` **不会**按 metadata 自动拉依赖。
|
||||
|
||||
**Advanced / 不推荐:** 子集 `-s` 时 skills CLI 不会自动带上 `bailian-protocol`;若坚持子集,必须手动同时指定,例如:
|
||||
|
||||
```bash
|
||||
# Advanced: you MUST include bailian-protocol yourself — installer does not pull it
|
||||
npx skills add modelstudioai/cli -g -s bailian-protocol -s bailian-gen
|
||||
```
|
||||
|
||||
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
|
||||
> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node,回退到上方 npm 安装。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -82,15 +82,31 @@ Send the following to your Agent — it will detect your environment, then insta
|
||||
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
|
||||
```
|
||||
|
||||
**Manual install (npm)**
|
||||
**Install with NPM**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
bl skill init
|
||||
```
|
||||
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
**Install on macOS/Linux**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
> No Node.js required. The installer automatically installs Bailian Skills.
|
||||
|
||||
**Install on Windows**
|
||||
|
||||
```powershell
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
> No Node.js required. The installer automatically installs Bailian Skills.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
|
||||
|
||||
+18
-2
@@ -81,15 +81,31 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
请阅读:https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
|
||||
```
|
||||
|
||||
**手动安装(npm)**
|
||||
**NPM 安装**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
bl skill init
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
**macOS/Linux 安装**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
> 无需预先安装 Node.js,安装脚本会自动安装 Bailian Skills。
|
||||
|
||||
**Windows 安装**
|
||||
|
||||
```powershell
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
> 无需预先安装 Node.js,安装脚本会自动安装 Bailian Skills。
|
||||
|
||||
## 快速开始
|
||||
|
||||
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
|
||||
|
||||
+12
-11
@@ -11,16 +11,17 @@
|
||||
|
||||
## 统一口径(安装)
|
||||
|
||||
1. **Supported install:** `npx skills add modelstudioai/cli --all -g`(整包装齐,含 `bailian-protocol`)
|
||||
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它;Agent Skills / `npx skills` **不会**按 frontmatter 自动拉依赖
|
||||
1. **Supported install:** `bl skill init`(装齐 registry 中全部 `bailian-*`,含 `bailian-protocol`)
|
||||
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它
|
||||
3. **不要**在 frontmatter 写 `companions`,也不要对外说「companions = 安装器硬依赖」
|
||||
4. 子集安装(`-s`)为 **advanced / 不推荐**:skills CLI 不会自动带上 protocol;漏装会导致相对路径 Read 失败
|
||||
4. 子集安装:`bl skill add --name bailian-protocol,<skill>`;漏装 protocol 会导致相对路径 Read 失败
|
||||
5. **`bl skill add --all`:** 安装 registry 全量(含 `spark-video` 等非 bailian 技能);一键安装 / `bl update` 用 `skill init`,不要用 `--all`
|
||||
|
||||
## 概念图
|
||||
|
||||
```text
|
||||
bailian-protocol ← 共享协议(consent / 鉴权 / 版本 / 错误上报)
|
||||
▲ 靠 --all -g 与业务 skill 同装;非安装器强制 companions
|
||||
▲ 靠 `bl skill init` 与业务 skill 同装;非安装器强制 companions
|
||||
│
|
||||
┌───────┴────────┬────────────────┬──────────────────┐
|
||||
bailian-gen bailian-finetune bailian-managed-agent
|
||||
@@ -37,8 +38,8 @@ bailian-gen bailian-finetune bailian-managed-agent
|
||||
|
||||
### A. 分层边界
|
||||
|
||||
- [ ] **整包装齐**:安装/升级文案主推 `--all -g`;业务 skill **不**声明 `companions`
|
||||
- [ ] **协议读取**:CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `npx skills add modelstudioai/cli --all -g`
|
||||
- [ ] **整包装齐**:安装/升级文案主推 `bl skill init`;业务 skill **不**声明 `companions`
|
||||
- [ ] **协议读取**:CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init`
|
||||
- [ ] **软 hand-off**:兄弟业务 skill **只写 skill 名**;已安装则 Read,未安装则 `bl … --help` 或提示整包安装;**不要**把 `../bailian-gen/…` 等写成执行前提
|
||||
- [ ] **Hub vs 领域**:`bailian-cli` 的「When to use which command」只列 hub 拥有的意图;媒体 / 精调 / managed-agent 各留 hand-off 行,**不抄**领域默认模型与子命令明细
|
||||
- [ ] **渐进披露**:SKILL 写意图路由与领域硬规则;flags / usage / examples 以 `reference/` 或 `bl <command> --help` 为准,表后保留「勿猜 flag」指向句
|
||||
@@ -46,9 +47,9 @@ bailian-gen bailian-finetune bailian-managed-agent
|
||||
### B. 文案与落款一致性
|
||||
|
||||
- [ ] 领域 skill(gen / finetune / managed-agent)路由或命令表后有指向 `reference/` 的句;文末 `## references`(protocol + reference)与家族对齐
|
||||
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `--all -g`,不写 companions 必装
|
||||
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `bl skill init`,不写 companions 必装
|
||||
- [ ] Quick examples 只演示本 skill 职责(hub 不示范 `bl image` / `bl video` 等)
|
||||
- [ ] 若改了安装方式:同步 `README.md` / `README.zh.md` / `INSTALL.md` / `skills/*/README*` / `skills/bailian-protocol/assets/setup.md` 中的 `npx skills add …` 示例(改 `INSTALL.md` 时按 [install-doc-change.md](install-doc-change.md) 同步静态页)
|
||||
- [ ] 若改了安装方式:同步 `README.md` / `README.zh.md` / `INSTALL.md` / `skills/*/README*` / `skills/bailian-protocol/assets/setup.md` 中的 `bl skill init` / `bl skill add …` 示例(改 `INSTALL.md` 时按 [install-doc-change.md](install-doc-change.md) 同步静态页)
|
||||
|
||||
### C. 归属与生成
|
||||
|
||||
@@ -60,8 +61,8 @@ bailian-gen bailian-finetune bailian-managed-agent
|
||||
|
||||
```sh
|
||||
pnpm run sync:skill-assets
|
||||
# 本地试装(测本仓库改动,勿只拉远端)
|
||||
npx skills add "$(pwd)" --all -g -y
|
||||
# 已发布版本试装
|
||||
bl skill init
|
||||
```
|
||||
|
||||
抽查:打开 `skills/bailian-cli/SKILL.md` 确认无领域子命令明细表、无 `companions`;打开对应领域 skill 确认有「勿猜 flag」与 hand-off。
|
||||
@@ -69,7 +70,7 @@ npx skills add "$(pwd)" --all -g -y
|
||||
## 常见漏点
|
||||
|
||||
- ✗ hub 路由表再次抄回 image / video / finetune / managed-agent 明细 → token 膨胀且与领域 skill 双份漂移
|
||||
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 Agent Skills / `npx skills` 合同不符
|
||||
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 `bl skill add` 合同不符
|
||||
- ✗ 软 hand-off 写成硬路径 `../bailian-*/SKILL.md` 当执行前提 → 子集安装断链
|
||||
- ✗ 只改 SKILL、忘改 `GROUP_OWNER_SKILL` → reference 落错 skill
|
||||
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖
|
||||
|
||||
+18
-2
@@ -82,15 +82,31 @@ Send the following to your Agent — it will detect your environment, then insta
|
||||
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
|
||||
```
|
||||
|
||||
**Manual install (npm)**
|
||||
**Install with NPM**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
bl skill init
|
||||
```
|
||||
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
**Install on macOS/Linux**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
> No Node.js required. The installer automatically installs Bailian Skills.
|
||||
|
||||
**Install on Windows**
|
||||
|
||||
```powershell
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
> No Node.js required. The installer automatically installs Bailian Skills.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
|
||||
|
||||
@@ -81,15 +81,31 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
请阅读:https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
|
||||
```
|
||||
|
||||
**手动安装(npm)**
|
||||
**NPM 安装**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
bl skill init
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
**macOS/Linux 安装**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
> 无需预先安装 Node.js,安装脚本会自动安装 Bailian Skills。
|
||||
|
||||
**Windows 安装**
|
||||
|
||||
```powershell
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
```
|
||||
|
||||
> 无需预先安装 Node.js,安装脚本会自动安装 Bailian Skills。
|
||||
|
||||
## 快速开始
|
||||
|
||||
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.14.2",
|
||||
"version": "1.15.0",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
usageFreetier,
|
||||
usageStats,
|
||||
usageSummary,
|
||||
usageTokenPlan,
|
||||
usageCodingPlan,
|
||||
pipelineRun,
|
||||
pipelineValidate,
|
||||
advisorRecommend,
|
||||
@@ -164,6 +166,8 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"usage freetier": usageFreetier,
|
||||
"usage stats": usageStats,
|
||||
"usage summary": usageSummary,
|
||||
"usage token-plan": usageTokenPlan,
|
||||
"usage coding-plan": usageCodingPlan,
|
||||
"pipeline run": pipelineRun,
|
||||
"pipeline validate": pipelineValidate,
|
||||
"advisor recommend": advisorRecommend,
|
||||
|
||||
@@ -7,10 +7,15 @@ const commandPaths = Object.keys(commands).sort();
|
||||
const groupPaths = deriveGroupPaths(commandPaths);
|
||||
|
||||
describe("e2e: bl registry smoke", () => {
|
||||
test("根帮助展示 bl 与全局 flag", async () => {
|
||||
test("根帮助展示 bl、逐命令鉴权域与全局 flag", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/\bbl\b/i);
|
||||
expect(stderr).not.toMatch(/COMMAND\s+AUTH\s+DESCRIPTION/);
|
||||
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
|
||||
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
|
||||
expect(stderr).toMatch(/token-plan create-key\s+\[AK\/SK\]\s+Create a Token Plan API key/);
|
||||
expect(stderr).toMatch(/config show\s+\[No Auth\]\s+Display current configuration/);
|
||||
expect(stderr).toMatch(/--base-url/);
|
||||
expect(stderr).toMatch(/--console-region/);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
@@ -18,6 +23,24 @@ describe("e2e: bl registry smoke", () => {
|
||||
expect(stderr).not.toMatch(/^\s*--region\s/m);
|
||||
});
|
||||
|
||||
test("分组帮助按叶子命令展示不同鉴权域", async () => {
|
||||
const { stderr, exitCode } = await runCli(["app", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
|
||||
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[["text", "chat"], "API Key"],
|
||||
[["app", "list"], "Console"],
|
||||
[["token-plan", "list-seats"], "AK/SK"],
|
||||
[["config", "show"], "No Auth"],
|
||||
] as const)("%s --help 明确展示鉴权域 %s", async (commandPath, authLabel) => {
|
||||
const { stderr, exitCode } = await runCli([...commandPath, "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain(`Authentication: ${authLabel}`);
|
||||
});
|
||||
|
||||
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.14.2",
|
||||
"version": "1.15.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": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Read-only discovery of locally installed AI tooling, surfaced by `config ui`:
|
||||
// - Agent skills installed under ~/.agents/skills (via `npx skills add`).
|
||||
// - Agent skills installed under ~/.agents/skills (via `bl skill add`).
|
||||
// - MCP servers declared in each coding agent's local config file.
|
||||
// - Coding agent frameworks and whether the bailian-cli provider is wired in.
|
||||
//
|
||||
@@ -128,8 +128,8 @@ function countFiles(dir: string, budget = 500): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill directories to scan, keyed by the module that owns them. `npx skills
|
||||
* add --all` fans skills out into each installed agent, so the same skill can
|
||||
* Skill directories to scan, keyed by the module that owns them. `bl skill init` /
|
||||
* `bl skill add` fans skills out into each installed agent, so the same skill can
|
||||
* live in several of these roots at once.
|
||||
*/
|
||||
function skillRoots(home: string): Array<{ source: string; dir: string }> {
|
||||
|
||||
@@ -548,7 +548,7 @@ export const PAGE_HTML = `<!doctype html>
|
||||
<section id="view-skills" class="view">
|
||||
<div class="view-head">
|
||||
<h2 class="view-title">Installed <span class="grad">Skills</span></h2>
|
||||
<p class="view-sub">Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via <code style="font-family:var(--mono)">npx skills add</code>.</p>
|
||||
<p class="view-sub">Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via <code style="font-family:var(--mono)">bl skill add</code>.</p>
|
||||
</div>
|
||||
<div class="toolbar"><input id="skillSearch" class="search" type="search" placeholder="Search skills…" autocomplete="off"><button id="addSkillBtn" class="btn-dark" type="button">+ Add skill</button></div>
|
||||
<div id="skillsBody"><div class="loading">Loading…</div></div>
|
||||
@@ -1444,7 +1444,7 @@ export const PAGE_HTML = `<!doctype html>
|
||||
function renderSkills() {
|
||||
var body = document.getElementById('skillsBody');
|
||||
var pager = document.getElementById('skillsPager');
|
||||
if (!SKILLS.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills installed.', 'Install with <code>npx skills add modelstudioai/cli --all -g</code>'); return; }
|
||||
if (!SKILLS.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills installed.', 'Install with <code>bl skill init</code>'); return; }
|
||||
var list = SKILLS.filter(function (s) { return skillMatches(s, SKILL_Q); });
|
||||
if (!list.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills match "' + SKILL_Q + '".', ''); return; }
|
||||
var info = pageSlice(list, SKILL_PAGE, getPageSize('skills')); SKILL_PAGE = info.page;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default defineCommand({
|
||||
},
|
||||
},
|
||||
exampleArgs: [
|
||||
`--api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
|
||||
`--api zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
|
||||
`--api some.api.name --data '{"key":"value"}' --console-region cn-beijing`,
|
||||
],
|
||||
async run(ctx) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BailianError } from "bailian-cli-core";
|
||||
import { BailianError, isStreamableHttpUnsupported } from "bailian-cli-core";
|
||||
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
|
||||
|
||||
/** Detect MCP-not-activated / invalid 404 errors (CLI-wrapped server message). */
|
||||
export function isMcpNotActivated(error: unknown): boolean {
|
||||
if (!(error instanceof BailianError)) return false;
|
||||
const message = error.message;
|
||||
if (!/MCP request failed:\s*404\b/i.test(message)) return false;
|
||||
if (!/^MCP request failed:\s*404\b/i.test(message)) return false;
|
||||
return /未开通|MCP不存在|MCP_IS_INVALID/i.test(message);
|
||||
}
|
||||
|
||||
@@ -26,14 +26,28 @@ export function mcpActivateHint(serverCode: string): string {
|
||||
/**
|
||||
* For not-activated errors, keep the original message / exitCode and append a hint only.
|
||||
* Do not replace the server error message.
|
||||
* WebSearch + 405 streamableHttp: do not fall back; attach a re-activate / upgrade hint.
|
||||
*/
|
||||
export function rethrowWithMcpActivateHint(error: unknown, serverCode: string): never {
|
||||
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
|
||||
if (!(error instanceof BailianError) || error.hint) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (isMcpNotActivated(error)) {
|
||||
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
|
||||
cause: error,
|
||||
api: error.api,
|
||||
rawResponse: error.rawResponse,
|
||||
});
|
||||
}
|
||||
|
||||
if (serverCode === "WebSearch" && isStreamableHttpUnsupported(error)) {
|
||||
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
|
||||
cause: error,
|
||||
api: error.api,
|
||||
rawResponse: error.rawResponse,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ const CALL_FLAGS = {
|
||||
url: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "Override the MCP endpoint URL (for non-Bailian servers)",
|
||||
description:
|
||||
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
type CallFlags = ParsedFlags<typeof CALL_FLAGS>;
|
||||
@@ -114,14 +115,14 @@ export default defineCommand({
|
||||
const { serverCode, toolName } = parseTarget(flags.target);
|
||||
const toolArgs = buildToolArgs(flags);
|
||||
|
||||
const url = flags.url || ctx.client.url(bailianMcpPath(serverCode));
|
||||
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(serverCode));
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
server: serverCode,
|
||||
url,
|
||||
url: previewUrl,
|
||||
tool: toolName,
|
||||
arguments: toolArgs,
|
||||
},
|
||||
@@ -130,13 +131,14 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ctx.client.mcp(url);
|
||||
let client: { close?(): void } | undefined;
|
||||
try {
|
||||
await client.initialize();
|
||||
const result = await client.callTool(toolName, toolArgs);
|
||||
const connected = await ctx.client.connectBailianMcp(serverCode, flags.url);
|
||||
client = connected.client;
|
||||
const result = await connected.client.callTool(toolName, toolArgs);
|
||||
|
||||
if (result.isError) {
|
||||
const errText = result.content.map((c) => c.text || "").join("\n");
|
||||
const errText = result.content.map((contentItem) => contentItem.text || "").join("\n");
|
||||
throw new BailianError(`Tool error: ${errText}`);
|
||||
}
|
||||
|
||||
@@ -146,6 +148,8 @@ export default defineCommand({
|
||||
rethrowWithMcpActivateHint(error, serverCode);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client?.close?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,7 +16,8 @@ export default defineCommand({
|
||||
url: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "Override the MCP endpoint URL (for non-Bailian servers)",
|
||||
description:
|
||||
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
|
||||
},
|
||||
},
|
||||
exampleArgs: [
|
||||
@@ -28,24 +29,27 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const code = flags.server;
|
||||
|
||||
const url = flags.url || ctx.client.url(bailianMcpPath(code));
|
||||
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(code));
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ server: code, url, action: "tools/list" }, format);
|
||||
emitResult({ server: code, url: previewUrl, action: "tools/list" }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ctx.client.mcp(url);
|
||||
let client: { close?(): void } | undefined;
|
||||
try {
|
||||
await client.initialize();
|
||||
const tools = await client.listTools();
|
||||
emitResult({ server: code, url, tools }, format);
|
||||
const connected = await ctx.client.connectBailianMcp(code, flags.url);
|
||||
client = connected.client;
|
||||
const tools = await connected.client.listTools();
|
||||
emitResult({ server: code, url: connected.url, tools }, format);
|
||||
} catch (error) {
|
||||
if (!flags.url) {
|
||||
rethrowWithMcpActivateHint(error, code);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client?.close?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
stripUndefined,
|
||||
taskPath,
|
||||
speechRecognizePath,
|
||||
resolveAsrApi,
|
||||
buildAsrFlashRequest,
|
||||
buildAsyncAsrLanguageFields,
|
||||
collectAsrTranscriptionItems,
|
||||
extractAsrFlashText,
|
||||
type AsrApiRoute,
|
||||
type AsrFlashFamily,
|
||||
type OutputFormat,
|
||||
type FlagsDef,
|
||||
type ParsedFlags,
|
||||
@@ -27,8 +34,18 @@ const RECOGNIZE_FLAGS = {
|
||||
description: "Audio file URL or local file path (repeatable, max 100)",
|
||||
required: true,
|
||||
},
|
||||
model: { type: "string", valueHint: "<model>", description: "Model ID (default: fun-asr)" },
|
||||
language: { type: "string", valueHint: "<lang>", description: "Language hint (e.g. zh, en, ja)" },
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description:
|
||||
"Model ID (default: fun-asr). Async: fun-asr / *-filetrans / paraformer-*; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash",
|
||||
},
|
||||
language: {
|
||||
type: "string",
|
||||
valueHint: "<lang>",
|
||||
description:
|
||||
"Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language",
|
||||
},
|
||||
diarization: { type: "switch", description: "Enable automatic speaker diarization" },
|
||||
speakerCount: {
|
||||
type: "number",
|
||||
@@ -55,8 +72,33 @@ const RECOGNIZE_FLAGS = {
|
||||
} satisfies FlagsDef;
|
||||
type RecognizeFlags = ParsedFlags<typeof RECOGNIZE_FLAGS>;
|
||||
|
||||
function assertSyncFlashFlagsAllowed(
|
||||
flags: RecognizeFlags,
|
||||
model: string,
|
||||
flashFamily: AsrFlashFamily,
|
||||
): void {
|
||||
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");
|
||||
}
|
||||
if (flags.channelId !== undefined) unsupported.push("--channel-id");
|
||||
if (flags.async === true) unsupported.push("--async");
|
||||
if (flags.pollInterval !== undefined) unsupported.push("--poll-interval");
|
||||
|
||||
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.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Recognize speech from audio files (FunAudio-ASR)",
|
||||
description: "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--url <audio-url> [flags]",
|
||||
flags: RECOGNIZE_FLAGS,
|
||||
@@ -68,6 +110,7 @@ export default defineCommand({
|
||||
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
|
||||
"--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",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
@@ -90,22 +133,70 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const model = flags.model || "fun-asr";
|
||||
const route = resolveAsrApi(model);
|
||||
if (route.kind === "unsupported") {
|
||||
throw new BailianError(
|
||||
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
if (route.kind === "sync-flash") {
|
||||
assertSyncFlashFlagsAllowed(flags, model, route.flashFamily!);
|
||||
if (rawUrls.length !== 1) {
|
||||
throw new BailianError(
|
||||
`Model "${model}" is a sync Flash ASR model and accepts exactly one --url (got ${rawUrls.length}).\n` +
|
||||
`Hint: Pass a single audio URL, or use an async filetrans model for batch files.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
route.kind === "async-filetrans" &&
|
||||
route.asyncInputStyle === "file_url" &&
|
||||
rawUrls.length !== 1
|
||||
) {
|
||||
throw new BailianError(
|
||||
`Model "${model}" accepts exactly one --url (got ${rawUrls.length}).\n` +
|
||||
"Hint: qwen3-asr-flash-filetrans* requires a single file_url.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Auto-upload local files in parallel
|
||||
const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model)));
|
||||
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
|
||||
|
||||
if (route.kind === "sync-flash") {
|
||||
await handleSyncFlashMode(
|
||||
ctx.client,
|
||||
settings,
|
||||
flags,
|
||||
format,
|
||||
model,
|
||||
route,
|
||||
resolvedUrls[0]!,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelId = flags.channelId;
|
||||
const language = flags.language;
|
||||
const vocabularyId = flags.vocabularyId;
|
||||
const languageFields = buildAsyncAsrLanguageFields(
|
||||
route.asyncLanguageStyle ?? "language_hints",
|
||||
flags.language,
|
||||
);
|
||||
|
||||
const body: DashScopeASRRequest = {
|
||||
model,
|
||||
input: {
|
||||
file_urls: resolvedUrls,
|
||||
},
|
||||
input:
|
||||
route.asyncInputStyle === "file_url"
|
||||
? { file_url: resolvedUrls[0]! }
|
||||
: { file_urls: resolvedUrls },
|
||||
parameters: {
|
||||
channel_id: channelId !== undefined ? [channelId] : [0],
|
||||
language_hints: language ? [language] : undefined,
|
||||
...languageFields,
|
||||
diarization_enabled: diarization ? true : undefined,
|
||||
speaker_count: speakerCount,
|
||||
vocabulary_id: vocabularyId,
|
||||
@@ -116,7 +207,7 @@ export default defineCommand({
|
||||
stripUndefined(body.parameters as Record<string, unknown>);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: "async" }, format);
|
||||
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,6 +219,55 @@ export default defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSyncFlashMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
flags: RecognizeFlags,
|
||||
format: OutputFormat,
|
||||
model: string,
|
||||
route: AsrApiRoute,
|
||||
audioUrl: string,
|
||||
): Promise<void> {
|
||||
const flashFamily = route.flashFamily as AsrFlashFamily;
|
||||
const body = buildAsrFlashRequest({
|
||||
model,
|
||||
audioUrl,
|
||||
language: flags.language,
|
||||
vocabularyId: flags.vocabularyId,
|
||||
flashFamily,
|
||||
});
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: "sync", path: route.path }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(`[Model: ${model}] [Mode: sync] [Files: 1]\n`);
|
||||
}
|
||||
|
||||
const response = await client.requestJson<Record<string, unknown>>({
|
||||
path: route.path,
|
||||
method: "POST",
|
||||
headers: { "X-DashScope-SSE": "disable" },
|
||||
body,
|
||||
});
|
||||
|
||||
const text = extractAsrFlashText(response, flashFamily);
|
||||
if (text) {
|
||||
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
} else {
|
||||
emitBare(JSON.stringify(response));
|
||||
}
|
||||
|
||||
if (flags.out) {
|
||||
writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n");
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(`Full result saved to: ${flags.out}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
@@ -160,16 +300,16 @@ async function handleAsyncMode(
|
||||
url: pollUrl,
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeASRTaskResult).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeASRTaskResult).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeASRTaskResult).output;
|
||||
return (o as unknown as Record<string, unknown>).message as string | undefined;
|
||||
isComplete: (data) => (data as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeASRTaskResult).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeASRTaskResult).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeASRTaskResult).output;
|
||||
return (output as unknown as Record<string, unknown>).message as string | undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const results = result.output.results ?? [];
|
||||
const results = collectAsrTranscriptionItems(result.output);
|
||||
|
||||
if (results.length === 0) {
|
||||
emitResult({ task_id: taskId, status: result.output.task_status }, format);
|
||||
@@ -179,12 +319,14 @@ async function handleAsyncMode(
|
||||
// Collect all transcription data for --out
|
||||
const allTransData: Record<string, unknown>[] = [];
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const subResult = results[i]!;
|
||||
for (let index = 0; index < results.length; index++) {
|
||||
const subResult = results[index]!;
|
||||
const isMulti = fileCount > 1;
|
||||
|
||||
if (isMulti) {
|
||||
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
|
||||
process.stdout.write(
|
||||
`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (subResult.subtask_status === "FAILED") {
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import {
|
||||
defineCommand,
|
||||
chatPath,
|
||||
responsesPath,
|
||||
parseSSE,
|
||||
detectOutputFormat,
|
||||
readTextFromPathOrStdin,
|
||||
type ChatMessage,
|
||||
type ChatRequest,
|
||||
type ChatResponse,
|
||||
type ResponsesRequest,
|
||||
type ResponsesResponse,
|
||||
type ResponsesStreamEvent,
|
||||
type StreamChunk,
|
||||
type FlagsDef,
|
||||
type ParsedFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { readFileSync } from "fs";
|
||||
import {
|
||||
assertResponsesStreamCompleted,
|
||||
inspectResponsesStreamEvent,
|
||||
extractResponsesText,
|
||||
} from "./responses.ts";
|
||||
|
||||
const CHAT_FLAGS = {
|
||||
api: {
|
||||
type: "string",
|
||||
valueHint: "<chat|responses>",
|
||||
choices: ["chat", "responses"] as const,
|
||||
description: "API to call (default: chat)",
|
||||
},
|
||||
model: { type: "string", valueHint: "<model>", description: "Model ID (default: qwen3.8-max)" },
|
||||
message: {
|
||||
type: "array",
|
||||
@@ -72,31 +87,31 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
|
||||
if (flags.messagesFile) {
|
||||
const raw = readTextFromPathOrStdin(flags.messagesFile);
|
||||
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
|
||||
for (const m of parsed) {
|
||||
if (m.role === "system") {
|
||||
system = typeof m.content === "string" ? m.content : "";
|
||||
for (const parsedMessage of parsed) {
|
||||
if (parsedMessage.role === "system") {
|
||||
system = typeof parsedMessage.content === "string" ? parsedMessage.content : "";
|
||||
} else {
|
||||
messages.push(m as ChatMessage);
|
||||
messages.push(parsedMessage as ChatMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.message) {
|
||||
const validRoles = new Set(["system", "user", "assistant"]);
|
||||
const msgs = flags.message;
|
||||
for (const m of msgs) {
|
||||
const colonIdx = m.indexOf(":");
|
||||
const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : "";
|
||||
const messageValues = flags.message;
|
||||
for (const messageValue of messageValues) {
|
||||
const colonIndex = messageValue.indexOf(":");
|
||||
const maybeRole = colonIndex !== -1 ? messageValue.slice(0, colonIndex) : "";
|
||||
|
||||
if (validRoles.has(maybeRole)) {
|
||||
const content = m.slice(colonIdx + 1);
|
||||
const content = messageValue.slice(colonIndex + 1);
|
||||
if (maybeRole === "system") {
|
||||
system = content;
|
||||
} else {
|
||||
messages.push({ role: maybeRole as "user" | "assistant", content });
|
||||
}
|
||||
} else {
|
||||
messages.push({ role: "user", content: m });
|
||||
messages.push({ role: "user", content: messageValue });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,24 +120,33 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Send a chat completion (OpenAI compatible, DashScope)",
|
||||
description: "Send a text model request (OpenAI compatible, DashScope)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--message <text> [flags]",
|
||||
flags: CHAT_FLAGS,
|
||||
exampleArgs: [
|
||||
'--message "What is Qwen?"',
|
||||
`--api responses --model qwen3.8-max --tool '{"type":"web_search"}' --message "Search for recent Alibaba Cloud news"`,
|
||||
'--model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python"',
|
||||
'--message "Hello" --message "assistant:Hi!" --message "How are you?"',
|
||||
"--messages-file - --stream",
|
||||
'--message "Hello" --output json',
|
||||
'--model qwq-plus --message "Solve 1+1" --enable-thinking',
|
||||
],
|
||||
validate: (f) =>
|
||||
!f.message && !f.messagesFile ? "Provide --message or --messages-file." : undefined,
|
||||
validate: (flags) => {
|
||||
if (!flags.message && !flags.messagesFile) {
|
||||
return "Provide --message or --messages-file.";
|
||||
}
|
||||
if (flags.api === "responses" && flags.thinkingBudget !== undefined) {
|
||||
return "--thinking-budget is not supported by the Responses API.";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const { system, messages } = parseMessages(flags);
|
||||
|
||||
const api = flags.api ?? "chat";
|
||||
const model = flags.model || settings.defaultTextModel || "qwen3.8-max";
|
||||
const shouldStream = flags.stream || process.stdout.isTTY;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
@@ -134,29 +158,39 @@ export default defineCommand({
|
||||
}
|
||||
allMessages.push(...messages);
|
||||
|
||||
const body: ChatRequest = {
|
||||
model,
|
||||
messages: allMessages,
|
||||
max_tokens: flags.maxTokens ?? 4096,
|
||||
stream: shouldStream,
|
||||
};
|
||||
let body: ChatRequest | ResponsesRequest;
|
||||
if (api === "responses") {
|
||||
body = {
|
||||
model,
|
||||
input: allMessages,
|
||||
max_output_tokens: flags.maxTokens ?? 4096,
|
||||
stream: shouldStream,
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
model,
|
||||
messages: allMessages,
|
||||
max_tokens: flags.maxTokens ?? 4096,
|
||||
stream: shouldStream,
|
||||
};
|
||||
}
|
||||
|
||||
if (flags.temperature !== undefined) body.temperature = flags.temperature;
|
||||
if (flags.topP !== undefined) body.top_p = flags.topP;
|
||||
|
||||
if (flags.enableThinking) {
|
||||
body.enable_thinking = true;
|
||||
if (flags.thinkingBudget !== undefined) {
|
||||
if (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) {
|
||||
body.thinking_budget = flags.thinkingBudget;
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.tool) {
|
||||
const tools = flags.tool.map((t) => {
|
||||
const tools = flags.tool.map((toolValue) => {
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
return JSON.parse(toolValue);
|
||||
} catch {
|
||||
const raw = readFileSync(t, "utf-8");
|
||||
const raw = readFileSync(toolValue, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
});
|
||||
@@ -169,8 +203,8 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
if (shouldStream) {
|
||||
const res = await ctx.client.request({
|
||||
path: chatPath(),
|
||||
const responseStream = await ctx.client.request({
|
||||
path: api === "responses" ? responsesPath() : chatPath(),
|
||||
method: "POST",
|
||||
body,
|
||||
stream: true,
|
||||
@@ -178,6 +212,7 @@ export default defineCommand({
|
||||
|
||||
let textContent = "";
|
||||
let inThinking = false;
|
||||
let responsesCompleted = false;
|
||||
const writesStreamingStdout = format === "text";
|
||||
const isTTY = process.stdout.isTTY;
|
||||
const statusOut =
|
||||
@@ -185,8 +220,28 @@ export default defineCommand({
|
||||
const resultOut = process.stdout;
|
||||
const statusColor = ansi(statusOut);
|
||||
|
||||
for await (const event of parseSSE(res)) {
|
||||
for await (const event of parseSSE(responseStream)) {
|
||||
if (event.data === "[DONE]") break;
|
||||
if (api === "responses") {
|
||||
let parsedEvent: ResponsesStreamEvent;
|
||||
try {
|
||||
parsedEvent = JSON.parse(event.data) as ResponsesStreamEvent;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const update = inspectResponsesStreamEvent(parsedEvent);
|
||||
if (update.delta) {
|
||||
textContent += update.delta;
|
||||
if (writesStreamingStdout) resultOut.write(update.delta);
|
||||
}
|
||||
if (update.completed) {
|
||||
responsesCompleted = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(event.data) as StreamChunk;
|
||||
|
||||
@@ -216,6 +271,7 @@ export default defineCommand({
|
||||
// Skip unparseable chunks
|
||||
}
|
||||
}
|
||||
if (api === "responses") assertResponsesStreamCompleted(responsesCompleted);
|
||||
if (inThinking) statusOut.write(statusColor.reset);
|
||||
|
||||
if (format === "json") {
|
||||
@@ -223,6 +279,20 @@ export default defineCommand({
|
||||
} else {
|
||||
resultOut.write("\n");
|
||||
}
|
||||
} else if (api === "responses") {
|
||||
const response = await ctx.client.requestJson<ResponsesResponse>({
|
||||
path: responsesPath(),
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
const text = extractResponsesText(response);
|
||||
|
||||
if (settings.quiet || format === "text") {
|
||||
emitBare(text);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
} else {
|
||||
const response = await ctx.client.requestJson<ChatResponse>({
|
||||
path: chatPath(),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type ResponsesResponse,
|
||||
type ResponsesStreamEvent,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
export interface ResponsesStreamUpdate {
|
||||
delta: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function extractResponsesText(response: ResponsesResponse): string {
|
||||
return response.output
|
||||
.filter((outputItem) => outputItem.type === "message")
|
||||
.flatMap((outputItem) => outputItem.content ?? [])
|
||||
.filter((contentItem) => contentItem.type === "output_text")
|
||||
.map((contentItem) => contentItem.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function extractResponsesStreamDelta(event: ResponsesStreamEvent): string {
|
||||
return event.type === "response.output_text.delta" ? (event.delta ?? "") : "";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function stringProperty(record: Record<string, unknown> | undefined, property: string) {
|
||||
const value = record?.[property];
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function responsesErrorMessage(event: ResponsesStreamEvent): string | undefined {
|
||||
const response = asRecord(event.response);
|
||||
const responseError = asRecord(response?.error);
|
||||
const eventError = asRecord(event.error);
|
||||
return (
|
||||
stringProperty(responseError, "message") ??
|
||||
stringProperty(eventError, "message") ??
|
||||
stringProperty(event, "message")
|
||||
);
|
||||
}
|
||||
|
||||
export function inspectResponsesStreamEvent(event: ResponsesStreamEvent): ResponsesStreamUpdate {
|
||||
if (event.type === "response.failed" || event.type === "error") {
|
||||
throw new BailianError(responsesErrorMessage(event) ?? "Response failed.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
if (event.type === "response.incomplete") {
|
||||
const response = asRecord(event.response);
|
||||
const incompleteDetails = asRecord(response?.incomplete_details);
|
||||
const reason = stringProperty(incompleteDetails, "reason");
|
||||
throw new BailianError(
|
||||
responsesErrorMessage(event) ??
|
||||
(reason ? `Response incomplete: ${reason}` : "Response incomplete."),
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
delta: extractResponsesStreamDelta(event),
|
||||
completed: event.type === "response.completed",
|
||||
};
|
||||
}
|
||||
|
||||
export function assertResponsesStreamCompleted(completed: boolean): void {
|
||||
if (completed) return;
|
||||
throw new BailianError(
|
||||
"Stream disconnected before completion: stream closed before response.completed.",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
type AnsiStyles,
|
||||
} from "bailian-cli-runtime";
|
||||
|
||||
const SKILL_SOURCE = "modelstudioai/cli";
|
||||
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
|
||||
const SKILL_INSTALL_CMD = "bl skill init";
|
||||
|
||||
function updateAgentSkill(color: AnsiStyles): void {
|
||||
process.stderr.write("\nUpdating agent skill...\n");
|
||||
@@ -143,6 +142,7 @@ export default defineCommand({
|
||||
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
|
||||
);
|
||||
writeUpdateState(newVer);
|
||||
updateAgentSkill(color);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const reinstall =
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts";
|
||||
import { formatNumber } from "./shared.ts";
|
||||
|
||||
const CODING_PLAN_USAGE_API =
|
||||
"zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2";
|
||||
|
||||
const COMMODITY_CODES: Record<string, string> = {
|
||||
domestic: "sfm_codingplan_public_cn",
|
||||
international: "sfm_codingplan_public_intl",
|
||||
};
|
||||
|
||||
interface CodingPlanWindow {
|
||||
usedQuota?: number;
|
||||
totalQuota?: number;
|
||||
/** Usage ratio in [0, 1]; absent when the window has no positive total or no used value. */
|
||||
percentage?: number;
|
||||
resetTime?: number;
|
||||
}
|
||||
|
||||
interface CodingPlanUsage {
|
||||
instanceType?: string;
|
||||
per5Hour: CodingPlanWindow;
|
||||
perWeek: CodingPlanWindow;
|
||||
perBillMonth: CodingPlanWindow;
|
||||
}
|
||||
|
||||
function readWindow(
|
||||
quotaInfo: Record<string, unknown> | undefined,
|
||||
fieldPrefix: string,
|
||||
): CodingPlanWindow {
|
||||
const window: CodingPlanWindow = {};
|
||||
if (!quotaInfo) return window;
|
||||
|
||||
const usedQuota = readNumber(quotaInfo[`${fieldPrefix}UsedQuota`]);
|
||||
if (usedQuota !== undefined) window.usedQuota = usedQuota;
|
||||
const totalQuota = readNumber(quotaInfo[`${fieldPrefix}TotalQuota`]);
|
||||
if (totalQuota !== undefined) window.totalQuota = totalQuota;
|
||||
const resetTime = readNumber(quotaInfo[`${fieldPrefix}QuotaNextRefreshTime`]);
|
||||
if (resetTime !== undefined) window.resetTime = resetTime;
|
||||
|
||||
// Console rule: the usage rate only exists with a positive total and a used value.
|
||||
if (usedQuota !== undefined && totalQuota !== undefined && totalQuota > 0) {
|
||||
window.percentage = usedQuota / totalQuota;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
/** Pick the first VALID instance's quota info, mirroring the Coding Plan console. */
|
||||
function readUsage(result: unknown): CodingPlanUsage | undefined {
|
||||
const response = unwrapResponse(result as Record<string, unknown>);
|
||||
const instances = Array.isArray(response.codingPlanInstanceInfos)
|
||||
? (response.codingPlanInstanceInfos as Record<string, unknown>[])
|
||||
: [];
|
||||
const validInstance = instances.find((instance) => instance.status === "VALID");
|
||||
if (!validInstance) return undefined;
|
||||
|
||||
const quotaInfo = validInstance.codingPlanQuotaInfo as Record<string, unknown> | undefined;
|
||||
const usage: CodingPlanUsage = {
|
||||
per5Hour: readWindow(quotaInfo, "per5Hour"),
|
||||
perWeek: readWindow(quotaInfo, "perWeek"),
|
||||
perBillMonth: readWindow(quotaInfo, "perBillMonth"),
|
||||
};
|
||||
if (typeof validInstance.instanceType === "string" && validInstance.instanceType) {
|
||||
usage.instanceType = validInstance.instanceType;
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
function toSection(label: string, window: CodingPlanWindow): QuotaSection {
|
||||
const section: QuotaSection = {
|
||||
label,
|
||||
emptyMessage: "No quota data for this window; verify in the Bailian Coding Plan console.",
|
||||
percentage: window.percentage,
|
||||
resetTime: window.resetTime,
|
||||
};
|
||||
if (window.usedQuota !== undefined && window.totalQuota !== undefined) {
|
||||
section.detail = `Used: ${formatNumber(window.usedQuota)} / ${formatNumber(window.totalQuota)}`;
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function printView(usage: CodingPlanUsage, generatedAt: number): void {
|
||||
const planSuffix = usage.instanceType ? ` (${usage.instanceType})` : "";
|
||||
printQuotaBox(
|
||||
`Coding Plan Usage${planSuffix}`,
|
||||
[
|
||||
toSection("5-hour quota", usage.per5Hour),
|
||||
toSection("1-week quota", usage.perWeek),
|
||||
toSection("Monthly quota", usage.perBillMonth),
|
||||
],
|
||||
generatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show Coding Plan quota usage",
|
||||
auth: "console",
|
||||
usageArgs: "[flags]",
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(ctx) {
|
||||
const { settings } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const requestData = {
|
||||
queryCodingPlanInstanceInfoRequest: {
|
||||
commodityCode: COMMODITY_CODES[settings.consoleSite ?? "domestic"],
|
||||
onlyLatestOne: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ api: CODING_PLAN_USAGE_API, data: requestData }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ctx.client.console(CODING_PLAN_USAGE_API, requestData);
|
||||
const usage = readUsage(result);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(usage ?? {}, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
process.stdout.write("No active Coding Plan subscription found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
printView(usage, Date.now());
|
||||
},
|
||||
});
|
||||
@@ -1,95 +1,22 @@
|
||||
import { defineCommand, detectOutputFormat, fetchModelList, type Client } from "bailian-cli-core";
|
||||
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import {
|
||||
FREE_TIER_API,
|
||||
FREE_TIER_ONLY_STATUS_API,
|
||||
extractFreeTierOnlyStatuses,
|
||||
extractQuotas,
|
||||
fetchAllModels,
|
||||
pollFreeTierBatch,
|
||||
} from "./shared.ts";
|
||||
|
||||
const ACTIVATE_API = "zeldaEasy.broadscope-bailian.freeTrial.batchActivateFreeTierOnly";
|
||||
const DEACTIVATE_API = "zeldaEasy.broadscope-bailian.freeTrial.batchDeactivateFreeTierOnly";
|
||||
const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
|
||||
const FREE_TIER_ONLY_STATUS_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
|
||||
|
||||
interface FreeTierQuota {
|
||||
model: string;
|
||||
quotaTotal: number;
|
||||
quotaInitTotal: number;
|
||||
}
|
||||
|
||||
interface FreeTierOnlyStatus {
|
||||
model: string;
|
||||
freeTierOnly: boolean;
|
||||
}
|
||||
const ACTIVATE_API = "zeldaEasy.bailian-commerce.freeTrial.batchActivateFreeTierOnly";
|
||||
const DEACTIVATE_API = "zeldaEasy.bailian-commerce.freeTrial.batchDeactivateFreeTierOnly";
|
||||
|
||||
interface BatchResultFailure {
|
||||
failureModelId: string;
|
||||
errorCode: string;
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
|
||||
const data = getNestedRecord(result, "data");
|
||||
if (!data) return result;
|
||||
|
||||
const dataV2 = getNestedRecord(data, "DataV2");
|
||||
if (dataV2) {
|
||||
const inner = getNestedRecord(dataV2, "data");
|
||||
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
|
||||
return innerData ?? inner ?? dataV2;
|
||||
}
|
||||
|
||||
const direct = getNestedRecord(data, "data");
|
||||
return direct ?? data;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const MAX_POLLS = 20;
|
||||
|
||||
async function pollUntilDone(
|
||||
client: Client,
|
||||
api: string,
|
||||
requestKey: string,
|
||||
models: string[],
|
||||
): Promise<unknown> {
|
||||
let nextTaskId: string | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
|
||||
const requestData = {
|
||||
[requestKey]: nextTaskId ? { taskId: nextTaskId } : { models },
|
||||
};
|
||||
|
||||
const raw = await client.console(api, requestData);
|
||||
|
||||
const resp = extractResponseData(raw as Record<string, unknown>);
|
||||
if (resp.taskId && Object.keys(resp).length === 1) {
|
||||
nextTaskId = resp.taskId as string;
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchAllModelNames(client: Client): Promise<string[]> {
|
||||
const allModels: Record<string, unknown>[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await fetchModelList((api, data) => client.console(api, data), {
|
||||
pageNo: page,
|
||||
pageSize: 50,
|
||||
});
|
||||
allModels.push(...result.models);
|
||||
if (allModels.length >= result.total) break;
|
||||
page++;
|
||||
}
|
||||
return allModels.map((item) => item.model as string).filter(Boolean);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description:
|
||||
"Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable",
|
||||
@@ -161,7 +88,7 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
if (!modelFlag) {
|
||||
models = await fetchAllModelNames(ctx.client);
|
||||
models = (await fetchAllModels(ctx.client)).map((model) => model.name);
|
||||
}
|
||||
|
||||
if (off) {
|
||||
@@ -172,12 +99,10 @@ export default defineCommand({
|
||||
}),
|
||||
]);
|
||||
|
||||
const quotaData = extractResponseData(quotaResult as Record<string, unknown>);
|
||||
const quotas = (quotaData.freeTierQuotas ?? []) as FreeTierQuota[];
|
||||
const quotas = extractQuotas(quotaResult);
|
||||
const quotaMap = new Map(quotas.map((quota) => [quota.model, quota]));
|
||||
|
||||
const stopData = extractResponseData(stopResult as Record<string, unknown>);
|
||||
const stopStatuses = (stopData.freeTierOnlyStatuses ?? []) as FreeTierOnlyStatus[];
|
||||
const stopStatuses = extractFreeTierOnlyStatuses(stopResult);
|
||||
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
|
||||
|
||||
for (const name of models) {
|
||||
@@ -192,7 +117,7 @@ export default defineCommand({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await pollUntilDone(ctx.client, api, requestKey, [name]);
|
||||
await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
|
||||
process.stdout.write(`Disabled auto-stop for "${name}".\n`);
|
||||
}
|
||||
return;
|
||||
@@ -200,13 +125,13 @@ export default defineCommand({
|
||||
|
||||
const jsonResults: unknown[] = [];
|
||||
for (const name of models) {
|
||||
const result = await pollUntilDone(ctx.client, api, requestKey, [name]);
|
||||
const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
|
||||
if (format === "json") {
|
||||
jsonResults.push(result);
|
||||
continue;
|
||||
}
|
||||
if (result) {
|
||||
const resultData = extractResponseData(result as Record<string, unknown>);
|
||||
const resultData = unwrapResponse(result as Record<string, unknown>);
|
||||
const failureModels = (resultData.failureModels as BatchResultFailure[]) ?? [];
|
||||
if (failureModels.length > 0) {
|
||||
process.stderr.write(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
ansi,
|
||||
displayWidth,
|
||||
renderGauge,
|
||||
type GaugeCell,
|
||||
type TextStyle,
|
||||
} from "bailian-cli-runtime";
|
||||
import { formatDateTime } from "./shared.ts";
|
||||
|
||||
const BOX_WIDTH = 76;
|
||||
|
||||
/** One quota window rendered inside the box: a label + usage ratio + reset time. */
|
||||
export interface QuotaSection {
|
||||
label: string;
|
||||
/** Shown instead of the gauge when the usage ratio is absent. */
|
||||
emptyMessage: string;
|
||||
/** Usage ratio in [0, 1]; absent means no data (possibly unlimited). */
|
||||
percentage?: number;
|
||||
resetTime?: number;
|
||||
/** Optional dim line under the gauge, e.g. "Used: 38 / 100". */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Accept only finite numbers; anything else counts as absent (possibly unlimited). */
|
||||
export function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/** Match the `usage free` gauge label style: 0.1% precision, no trailing zeros. */
|
||||
function formatPercentage(ratio: number): string {
|
||||
const percent = Math.round(ratio * 1000) / 10;
|
||||
return `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatRemainingTime(resetTime: number, now: number): string {
|
||||
const remainingMs = Math.max(0, resetTime - now);
|
||||
const totalMinutes = Math.floor(remainingMs / 60_000);
|
||||
if (totalMinutes === 0) return "now";
|
||||
|
||||
const days = Math.floor(totalMinutes / (24 * 60));
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Print a bordered quota box with a title line and one gauge per section. */
|
||||
export function printQuotaBox(title: string, sections: QuotaSection[], generatedAt: number): void {
|
||||
const color = ansi(process.stdout);
|
||||
const writeLine = (text = "", style?: TextStyle) => {
|
||||
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`));
|
||||
process.stdout.write(`│ ${style ? style(text) : text}${" ".repeat(padding)}│\n`);
|
||||
};
|
||||
// Pre-colored gauge cell: pad from the plain variant so ANSI escapes never shift the border.
|
||||
const writeGaugeLine = (cell: GaugeCell) => {
|
||||
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${cell.plain}`));
|
||||
process.stdout.write(`│ ${cell.colored}${" ".repeat(padding)}│\n`);
|
||||
};
|
||||
const writeQuota = (section: QuotaSection) => {
|
||||
writeLine(section.label, color.bold);
|
||||
if (section.percentage === undefined) {
|
||||
writeLine(section.emptyMessage, color.dim);
|
||||
return;
|
||||
}
|
||||
|
||||
const gaugeLabel = `${formatPercentage(section.percentage)} used`;
|
||||
writeGaugeLine(renderGauge(section.percentage * 100, gaugeLabel));
|
||||
if (section.detail) {
|
||||
writeLine(section.detail, color.dim);
|
||||
}
|
||||
if (section.resetTime === undefined) {
|
||||
writeLine("Resets: not applicable (no usage yet)", color.dim);
|
||||
return;
|
||||
}
|
||||
|
||||
const resetText = `Resets: ${formatDateTime(section.resetTime)} (in ${formatRemainingTime(section.resetTime, generatedAt)})`;
|
||||
writeLine(resetText, color.dim);
|
||||
};
|
||||
|
||||
process.stdout.write(`┌${"─".repeat(BOX_WIDTH)}┐\n`);
|
||||
writeLine(title, color.cyan);
|
||||
writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim);
|
||||
for (const section of sections) {
|
||||
process.stdout.write(`├${"─".repeat(BOX_WIDTH)}┤\n`);
|
||||
writeQuota(section);
|
||||
}
|
||||
process.stdout.write(`└${"─".repeat(BOX_WIDTH)}┘\n`);
|
||||
}
|
||||
@@ -24,6 +24,14 @@ export function formatDate(ts: number): string {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const hour = String(date.getHours()).padStart(2, "0");
|
||||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||
const second = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${formatDate(ts)} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
export function requireWorkspaceId(settings: Settings, binName: string): string {
|
||||
if (settings.workspaceId) return settings.workspaceId;
|
||||
|
||||
@@ -102,9 +110,9 @@ export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
|
||||
// Free-tier quota
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
|
||||
export const FREE_TIER_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota";
|
||||
export const FREE_TIER_ONLY_STATUS_API =
|
||||
"zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
|
||||
"zeldaEasy.bailian-commerce.freeTrial.queryFreeTierOnlyStatus";
|
||||
|
||||
export interface FreeTierQuota {
|
||||
model: string;
|
||||
@@ -257,22 +265,27 @@ export interface ListStatisticResponse {
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const MAX_POLLS = 30;
|
||||
const DEFAULT_MAX_POLLS = 30;
|
||||
|
||||
export async function pollTelemetryApi(
|
||||
/**
|
||||
* Poll a console API until it returns a terminal (non task-id) response.
|
||||
* The gateway answers an async request with a bare `{taskId}` envelope; the
|
||||
* caller re-issues with that id until real data arrives or the budget runs out.
|
||||
* `buildRequest` shapes each attempt (initial call vs. taskId follow-up) so the
|
||||
* same loop serves every request-wrapper convention (telemetry `reqDTO`,
|
||||
* free-tier batch `…Request`).
|
||||
*/
|
||||
export async function pollConsoleUntilDone(
|
||||
client: Client,
|
||||
api: string,
|
||||
reqDTO: Record<string, unknown>,
|
||||
buildRequest: (taskId: string | undefined) => Record<string, unknown>,
|
||||
maxPolls = DEFAULT_MAX_POLLS,
|
||||
): Promise<unknown> {
|
||||
let nextTaskId: string | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
|
||||
const requestData = nextTaskId
|
||||
? { reqDTO: { ...reqDTO, asyncTaskId: nextTaskId } }
|
||||
: { reqDTO };
|
||||
|
||||
const raw = await client.console(api, requestData);
|
||||
const resp = extractResponseData(raw as Record<string, unknown>);
|
||||
for (let attempt = 0; attempt < maxPolls; attempt++) {
|
||||
const raw = await client.console(api, buildRequest(nextTaskId));
|
||||
const resp = unwrapResponse(raw as Record<string, unknown>);
|
||||
|
||||
if (resp.taskId && Object.keys(resp).length === 1) {
|
||||
nextTaskId = resp.taskId as string;
|
||||
@@ -284,6 +297,32 @@ export async function pollTelemetryApi(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Telemetry APIs wrap the payload in `reqDTO` and echo the task id as `asyncTaskId`. */
|
||||
export async function pollTelemetryApi(
|
||||
client: Client,
|
||||
api: string,
|
||||
reqDTO: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return pollConsoleUntilDone(client, api, (taskId) =>
|
||||
taskId ? { reqDTO: { ...reqDTO, asyncTaskId: taskId } } : { reqDTO },
|
||||
);
|
||||
}
|
||||
|
||||
/** Free-tier batch activate/deactivate wrap the payload in `requestKey` and echo `taskId`. */
|
||||
export async function pollFreeTierBatch(
|
||||
client: Client,
|
||||
api: string,
|
||||
requestKey: string,
|
||||
models: string[],
|
||||
): Promise<unknown> {
|
||||
return pollConsoleUntilDone(
|
||||
client,
|
||||
api,
|
||||
(taskId) => ({ [requestKey]: taskId ? { taskId } : { models } }),
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
export function extractOverviewData(result: unknown): OverviewStatistic | undefined {
|
||||
const resp = extractResponseData(result as Record<string, unknown>);
|
||||
if (resp.callSuccessCount !== undefined || resp.usages !== undefined) {
|
||||
|
||||
@@ -1,176 +1,26 @@
|
||||
import {
|
||||
defineCommand,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
detectOutputFormat,
|
||||
type Settings,
|
||||
type Client,
|
||||
} from "bailian-cli-core";
|
||||
import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core";
|
||||
import { ansi, emitResult } from "bailian-cli-runtime";
|
||||
import { displayWidth, padEnd } from "bailian-cli-runtime";
|
||||
|
||||
const OVERVIEW_API = "zeldaEasy.bailian-telemetry.model.getModelUsageStatistic";
|
||||
const LIST_API = "zeldaEasy.bailian-telemetry.model.listModelUsageStatisticData";
|
||||
|
||||
interface UsageItem {
|
||||
key: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface OverviewStatistic {
|
||||
callCount: number;
|
||||
modelCount: number;
|
||||
callSuccessCount: number;
|
||||
usages: UsageItem[];
|
||||
}
|
||||
|
||||
interface ModelStatisticItem {
|
||||
model: string;
|
||||
callSuccessCount: number;
|
||||
usages?: UsageItem[];
|
||||
usage?: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
interface ListStatisticResponse {
|
||||
list: ModelStatisticItem[];
|
||||
totalCount: number;
|
||||
maxResults: number;
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
|
||||
const data = getNestedRecord(result, "data");
|
||||
if (!data) return result;
|
||||
|
||||
const dataV2 = getNestedRecord(data, "DataV2");
|
||||
if (dataV2) {
|
||||
const inner = getNestedRecord(dataV2, "data");
|
||||
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
|
||||
return innerData ?? inner ?? dataV2;
|
||||
}
|
||||
|
||||
const direct = getNestedRecord(data, "data");
|
||||
return direct ?? data;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const MAX_POLLS = 30;
|
||||
|
||||
async function pollTelemetryApi(
|
||||
client: Client,
|
||||
api: string,
|
||||
reqDTO: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
let nextTaskId: string | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
|
||||
const requestData = nextTaskId
|
||||
? { reqDTO: { ...reqDTO, asyncTaskId: nextTaskId } }
|
||||
: { reqDTO };
|
||||
|
||||
const raw = await client.console(api, requestData);
|
||||
|
||||
const resp = extractResponseData(raw as Record<string, unknown>);
|
||||
|
||||
if (resp.taskId && Object.keys(resp).length === 1) {
|
||||
nextTaskId = resp.taskId as string;
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireWorkspaceId(settings: Settings, binName: string): string {
|
||||
if (settings.workspaceId) return settings.workspaceId;
|
||||
|
||||
throw new BailianError(
|
||||
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id <id>\`.`,
|
||||
ExitCode.GENERAL,
|
||||
`Run \`${binName} workspace list\` to view available workspaces.`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function extractOverviewData(result: unknown): OverviewStatistic | undefined {
|
||||
const resp = extractResponseData(result as Record<string, unknown>);
|
||||
if (resp.callSuccessCount !== undefined || resp.usages !== undefined) {
|
||||
return resp as unknown as OverviewStatistic;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractListData(result: unknown): ListStatisticResponse {
|
||||
const resp = extractResponseData(result as Record<string, unknown>);
|
||||
const list = (resp.list as ModelStatisticItem[]) ?? [];
|
||||
const totalCount = (resp.totalCount as number) ?? 0;
|
||||
const maxResults = (resp.maxResults as number) ?? 0;
|
||||
return { list, totalCount, maxResults };
|
||||
}
|
||||
|
||||
function resolveUsageMap(item: ModelStatisticItem): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
if (item.usages && Array.isArray(item.usages)) {
|
||||
for (const entry of item.usages) {
|
||||
if (entry.key && entry.value != null) {
|
||||
out[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.usage && typeof item.usage === "object") {
|
||||
for (const [key, val] of Object.entries(item.usage)) {
|
||||
if (val != null) out[key] = val;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
import {
|
||||
LIST_API,
|
||||
OVERVIEW_API,
|
||||
USAGE_KEY_LABELS,
|
||||
extractListData,
|
||||
extractOverviewData,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
pollTelemetryApi,
|
||||
requireWorkspaceId,
|
||||
resolveUsageMap,
|
||||
type ModelStatisticItem,
|
||||
type OverviewStatistic,
|
||||
} from "./shared.ts";
|
||||
|
||||
interface UsageLabel {
|
||||
en: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const USAGE_KEY_LABELS: Record<string, UsageLabel> = {
|
||||
total_token: { en: "Total Tokens", unit: "tokens" },
|
||||
input_token: { en: "Input Tokens", unit: "tokens" },
|
||||
output_token: { en: "Output Tokens", unit: "tokens" },
|
||||
input_token_cache: { en: "Cached Tokens", unit: "tokens" },
|
||||
input_token_cache_read: { en: "Cache Read", unit: "tokens" },
|
||||
input_token_cache_creation: { en: "Cache Creation", unit: "tokens" },
|
||||
thinking_input_token: { en: "Thinking Input", unit: "tokens" },
|
||||
thinking_output_token: { en: "Thinking Output", unit: "tokens" },
|
||||
text_input_token: { en: "Text Input", unit: "tokens" },
|
||||
purein_text_output_token: { en: "Text Output", unit: "tokens" },
|
||||
embedding_token: { en: "Embedding", unit: "tokens" },
|
||||
image_number: { en: "Images", unit: "images" },
|
||||
video_duration: { en: "Video Duration", unit: "sec" },
|
||||
content_duration: { en: "Audio Duration", unit: "sec" },
|
||||
tts_text_number: { en: "TTS Chars", unit: "chars" },
|
||||
total_token_avg: { en: "Avg Tokens/Req" },
|
||||
};
|
||||
|
||||
function formatLabel(label: UsageLabel): string {
|
||||
const unitSuffix = label.unit ? ` [${label.unit}]` : "";
|
||||
return `${label.en}${unitSuffix}`;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { printQuotaBox, readNumber } from "./quota-box.ts";
|
||||
|
||||
const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
|
||||
|
||||
interface TokenPlanUsage {
|
||||
per5HourPercentage?: number;
|
||||
per5HourResetTime?: number;
|
||||
per1WeekPercentage?: number;
|
||||
per1WeekResetTime?: number;
|
||||
}
|
||||
|
||||
function readUsage(result: unknown): TokenPlanUsage {
|
||||
const response = unwrapResponse(result as Record<string, unknown>);
|
||||
const usage: TokenPlanUsage = {};
|
||||
|
||||
const per5HourPercentage = readNumber(response.per5HourPercentage);
|
||||
if (per5HourPercentage !== undefined) usage.per5HourPercentage = per5HourPercentage;
|
||||
const per5HourResetTime = readNumber(response.per5HourResetTime);
|
||||
if (per5HourResetTime !== undefined) usage.per5HourResetTime = per5HourResetTime;
|
||||
const per1WeekPercentage = readNumber(response.per1WeekPercentage);
|
||||
if (per1WeekPercentage !== undefined) usage.per1WeekPercentage = per1WeekPercentage;
|
||||
const per1WeekResetTime = readNumber(response.per1WeekResetTime);
|
||||
if (per1WeekResetTime !== undefined) usage.per1WeekResetTime = per1WeekResetTime;
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
function printView(usage: TokenPlanUsage, generatedAt: number): void {
|
||||
printQuotaBox(
|
||||
"Token Plan Usage",
|
||||
[
|
||||
{
|
||||
label: "5-hour quota",
|
||||
emptyMessage:
|
||||
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
percentage: usage.per5HourPercentage,
|
||||
resetTime: usage.per5HourResetTime,
|
||||
},
|
||||
{
|
||||
label: "1-week quota",
|
||||
emptyMessage:
|
||||
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
percentage: usage.per1WeekPercentage,
|
||||
resetTime: usage.per1WeekResetTime,
|
||||
},
|
||||
],
|
||||
generatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show Token Plan quota usage",
|
||||
auth: "console",
|
||||
usageArgs: "[flags]",
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(ctx) {
|
||||
const { settings } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
|
||||
const usage = readUsage(result);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(usage, format);
|
||||
return;
|
||||
}
|
||||
|
||||
printView(usage, Date.now());
|
||||
},
|
||||
});
|
||||
@@ -48,6 +48,8 @@ export { default as usageFree } from "./commands/usage/free.ts";
|
||||
export { default as usageFreetier } from "./commands/usage/freetier.ts";
|
||||
export { default as usageStats } from "./commands/usage/stats.ts";
|
||||
export { default as usageSummary } from "./commands/usage/summary.ts";
|
||||
export { default as usageTokenPlan } from "./commands/usage/token-plan.ts";
|
||||
export { default as usageCodingPlan } from "./commands/usage/coding-plan.ts";
|
||||
export { default as pipelineRun } from "./commands/pipeline/run.ts";
|
||||
export { default as pipelineValidate } from "./commands/pipeline/validate.ts";
|
||||
export { default as advisorRecommend } from "./commands/advisor/recommend.ts";
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import codingPlanUsage from "../src/commands/usage/coding-plan.ts";
|
||||
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
const originalForceColor = process.env.FORCE_COLOR;
|
||||
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNoColor === undefined) delete process.env.NO_COLOR;
|
||||
else process.env.NO_COLOR = originalNoColor;
|
||||
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
|
||||
else process.env.FORCE_COLOR = originalForceColor;
|
||||
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
|
||||
else delete (process.stdout as { isTTY?: boolean }).isTTY;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function captureStdout(): string[] {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
output.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
async function runCodingPlan(response: Record<string, unknown>, output?: string): Promise<void> {
|
||||
await codingPlanUsage.run({
|
||||
client: { console: vi.fn().mockResolvedValue(response) },
|
||||
flags: {},
|
||||
settings: { dryRun: false, output },
|
||||
} as never);
|
||||
}
|
||||
|
||||
function wrapResponse(data: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
data: {
|
||||
DataV2: {
|
||||
data: {
|
||||
data,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeInstanceResponse(
|
||||
quotaInfo: Record<string, unknown>,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return wrapResponse({
|
||||
codingPlanInstanceInfos: [
|
||||
{ status: "VALID", instanceType: "pro", codingPlanQuotaInfo: quotaInfo, ...overrides },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const FULL_QUOTA_INFO = {
|
||||
per5HourUsedQuota: 38,
|
||||
per5HourTotalQuota: 100,
|
||||
per5HourQuotaNextRefreshTime: 1_786_000_000_000,
|
||||
perWeekUsedQuota: 500,
|
||||
perWeekTotalQuota: 1000,
|
||||
perWeekQuotaNextRefreshTime: 1_786_100_000_000,
|
||||
perBillMonthUsedQuota: 950,
|
||||
perBillMonthTotalQuota: 1000,
|
||||
perBillMonthQuotaNextRefreshTime: 1_786_200_000_000,
|
||||
};
|
||||
|
||||
describe("usage coding-plan view", () => {
|
||||
test("renders the three quota windows with usage rates and used/total details", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
expect(renderedOutput).toContain("Coding Plan Usage (pro)");
|
||||
expect(renderedOutput).toContain("5-hour quota");
|
||||
expect(renderedOutput).toContain("1-week quota");
|
||||
expect(renderedOutput).toContain("Monthly quota");
|
||||
expect(renderedOutput).toContain("38% used");
|
||||
expect(renderedOutput).toContain("50% used");
|
||||
expect(renderedOutput).toContain("95% used");
|
||||
expect(renderedOutput).toContain("Used: 38 / 100");
|
||||
expect(renderedOutput).toContain("Used: 950 / 1,000");
|
||||
});
|
||||
|
||||
test("skips non-VALID instances when picking quota info", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(
|
||||
wrapResponse({
|
||||
codingPlanInstanceInfos: [
|
||||
{
|
||||
status: "EXPIRED",
|
||||
codingPlanQuotaInfo: { per5HourUsedQuota: 1, per5HourTotalQuota: 2 },
|
||||
},
|
||||
{ status: "VALID", codingPlanQuotaInfo: FULL_QUOTA_INFO },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output.join("")).toContain("38% used");
|
||||
});
|
||||
|
||||
test("renders windows without a positive total as missing quota data", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(
|
||||
makeInstanceResponse({
|
||||
per5HourUsedQuota: 38,
|
||||
per5HourTotalQuota: 0,
|
||||
perWeekUsedQuota: 500,
|
||||
perBillMonthUsedQuota: "not-a-number",
|
||||
perBillMonthTotalQuota: 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
const emptyMessageCount = renderedOutput.split(
|
||||
"No quota data for this window; verify in the Bailian Coding Plan console.",
|
||||
).length;
|
||||
expect(emptyMessageCount - 1).toBe(3);
|
||||
});
|
||||
|
||||
test("reports when there is no active subscription", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(wrapResponse({ codingPlanInstanceInfos: [] }));
|
||||
|
||||
expect(output.join("")).toContain("No active Coding Plan subscription found.");
|
||||
});
|
||||
|
||||
test("renders the gauge in the usage-free style: brand fill and proportional cells", async () => {
|
||||
delete process.env.NO_COLOR;
|
||||
process.env.FORCE_COLOR = "3";
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
|
||||
|
||||
// Brand-cyan fill cell, same as the `usage free` gauge column
|
||||
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
|
||||
});
|
||||
|
||||
test("fills gauge cells proportionally to the usage rate", async () => {
|
||||
process.env.NO_COLOR = "1";
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
// 95% of the default 20-cell gauge → 19 filled cells + 1 track space
|
||||
expect(renderedOutput).toContain(`${"\u2588".repeat(19)} `);
|
||||
expect(renderedOutput).not.toContain("\u2588".repeat(20));
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage coding-plan json", () => {
|
||||
test("outputs the three windows with used/total/percentage/resetTime", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO), "json");
|
||||
|
||||
expect(JSON.parse(output.join(""))).toEqual({
|
||||
instanceType: "pro",
|
||||
per5Hour: {
|
||||
usedQuota: 38,
|
||||
totalQuota: 100,
|
||||
percentage: 0.38,
|
||||
resetTime: 1_786_000_000_000,
|
||||
},
|
||||
perWeek: {
|
||||
usedQuota: 500,
|
||||
totalQuota: 1000,
|
||||
percentage: 0.5,
|
||||
resetTime: 1_786_100_000_000,
|
||||
},
|
||||
perBillMonth: {
|
||||
usedQuota: 950,
|
||||
totalQuota: 1000,
|
||||
percentage: 0.95,
|
||||
resetTime: 1_786_200_000_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty JSON object when no VALID instance exists", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(wrapResponse({}), "json");
|
||||
|
||||
expect(output.join("").trim()).toBe("{}");
|
||||
});
|
||||
|
||||
test("omits non-numeric quota fields from the JSON output", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runCodingPlan(
|
||||
makeInstanceResponse(
|
||||
{ per5HourUsedQuota: "not-a-number", per5HourTotalQuota: 100 },
|
||||
{ instanceType: undefined },
|
||||
),
|
||||
"json",
|
||||
);
|
||||
|
||||
expect(JSON.parse(output.join(""))).toEqual({
|
||||
per5Hour: { totalQuota: 100 },
|
||||
perWeek: {},
|
||||
perBillMonth: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
@@ -16,6 +18,37 @@ import { SPEECH_ROUTES } from "./topic-routes.ts";
|
||||
*/
|
||||
|
||||
describe("e2e: speech recognize", () => {
|
||||
async function runRecognizeDryRun(args: string[]) {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
|
||||
"speech",
|
||||
"recognize",
|
||||
...args,
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
return parseStdoutJson<{
|
||||
mode?: string;
|
||||
path?: string;
|
||||
request?: {
|
||||
model?: string;
|
||||
parameters?: {
|
||||
format?: string;
|
||||
language_hints?: string[];
|
||||
language?: string;
|
||||
vocabulary_id?: string;
|
||||
};
|
||||
input?: {
|
||||
file_url?: string;
|
||||
file_urls?: string[];
|
||||
messages?: Array<{ content?: Array<{ type?: string }> }>;
|
||||
};
|
||||
};
|
||||
}>(stdout);
|
||||
}
|
||||
|
||||
test("speech recognize --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
|
||||
"speech",
|
||||
@@ -25,6 +58,217 @@ describe("e2e: speech recognize", () => {
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/recognize|--url|model|audio/i);
|
||||
});
|
||||
|
||||
test("speech recognize sync-flash dry-run 走 multimodal-generation", async () => {
|
||||
const body = await runRecognizeDryRun([
|
||||
"--model",
|
||||
"qwen-audio-3.0-asr-flash",
|
||||
"--url",
|
||||
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
|
||||
"--language",
|
||||
"en",
|
||||
"--vocabulary-id",
|
||||
"vocab-e2e",
|
||||
]);
|
||||
expect(body.mode).toBe("sync");
|
||||
expect(body.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
|
||||
expect(body.request?.model).toBe("qwen-audio-3.0-asr-flash");
|
||||
expect(body.request?.parameters?.format).toBe("wav");
|
||||
expect(body.request?.parameters?.language_hints).toEqual(["en"]);
|
||||
expect(body.request?.parameters?.vocabulary_id).toBe("vocab-e2e");
|
||||
expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio");
|
||||
});
|
||||
|
||||
test("speech recognize qwen3 filetrans dry-run 使用 file_url 与 language", async () => {
|
||||
const body = await runRecognizeDryRun([
|
||||
"--model",
|
||||
"qwen3-asr-flash-filetrans",
|
||||
"--url",
|
||||
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
|
||||
"--language",
|
||||
"zh",
|
||||
]);
|
||||
expect(body.mode).toBe("async");
|
||||
expect(body.path).toBe("/api/v1/services/audio/asr/transcription");
|
||||
expect(body.request?.input?.file_url?.startsWith("https://")).toBe(true);
|
||||
expect(body.request?.input?.file_urls).toBeUndefined();
|
||||
expect(body.request?.parameters?.language).toBe("zh");
|
||||
expect(body.request?.parameters?.language_hints).toBeUndefined();
|
||||
});
|
||||
|
||||
test("speech recognize realtime 模型报用法错误", async () => {
|
||||
// Use --dry-run to skip auth so CI without API keys still hits USAGE(2)
|
||||
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
|
||||
"speech",
|
||||
"recognize",
|
||||
"--model",
|
||||
"qwen3-asr-flash-realtime",
|
||||
"--url",
|
||||
"https://example.com/a.wav",
|
||||
"--dry-run",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/realtime|WebSocket|unsupported/i);
|
||||
});
|
||||
|
||||
test("speech recognize flash 真实请求走 sync endpoint 并落盘 --out", async () => {
|
||||
let requestPath = "";
|
||||
let requestBody: Record<string, unknown> = {};
|
||||
let sseHeader: string | undefined;
|
||||
const server = http.createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
requestPath = request.url ?? "";
|
||||
requestBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
|
||||
sseHeader = request.headers["x-dashscope-sse"] as string | undefined;
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
output: { text: "flash recognition works" },
|
||||
request_id: "request-146",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
const outDir = makeE2eOutputDir("speech-recognize-flash-sync");
|
||||
const outPath = join(outDir, "result.json");
|
||||
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
|
||||
"speech",
|
||||
"recognize",
|
||||
"--model",
|
||||
"fun-asr-flash-2026-06-15",
|
||||
"--url",
|
||||
"https://example.com/sample.wav",
|
||||
"--api-key",
|
||||
"sk-e2e-placeholder",
|
||||
"--base-url",
|
||||
`http://127.0.0.1:${address.port}`,
|
||||
"--out",
|
||||
outPath,
|
||||
"--quiet",
|
||||
]);
|
||||
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("flash recognition works");
|
||||
expect(requestPath).toBe("/api/v1/services/aigc/multimodal-generation/generation");
|
||||
expect(sseHeader).toBe("disable");
|
||||
expect(requestBody).toMatchObject({
|
||||
model: "fun-asr-flash-2026-06-15",
|
||||
parameters: { format: "wav" },
|
||||
});
|
||||
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
|
||||
output: { text: "flash recognition works" },
|
||||
request_id: "request-146",
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("speech recognize flash 多 --url 在发请求前报用法错误", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
|
||||
"speech",
|
||||
"recognize",
|
||||
"--model",
|
||||
"qwen-audio-3.0-asr-flash",
|
||||
"--url",
|
||||
"https://example.com/a.wav",
|
||||
"--url",
|
||||
"https://example.com/b.wav",
|
||||
"--dry-run",
|
||||
"--quiet",
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/exactly one --url|sync Flash/i);
|
||||
});
|
||||
|
||||
test("speech recognize qwen3-filetrans 轮询成功后下载 result.transcription_url", async () => {
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = request.url ?? "";
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
if (url.startsWith("/api/v1/services/audio/asr/transcription")) {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
output: { task_id: "task-qwen3", task_status: "PENDING" },
|
||||
request_id: "req-submit",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.startsWith("/api/v1/tasks/")) {
|
||||
const address = server.address() as AddressInfo;
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
output: {
|
||||
task_id: "task-qwen3",
|
||||
task_status: "SUCCEEDED",
|
||||
result: {
|
||||
transcription_url: `http://127.0.0.1:${address.port}/transcription.json`,
|
||||
},
|
||||
},
|
||||
request_id: "req-poll",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.startsWith("/transcription.json")) {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
file_url: "https://example.com/a.wav",
|
||||
transcripts: [{ text: "你好世界", sentences: [{ text: "你好世界" }] }],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end(JSON.stringify({ message: `unexpected path: ${url}` }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
const outDir = makeE2eOutputDir("speech-recognize-qwen3-filetrans");
|
||||
const outPath = join(outDir, "result.json");
|
||||
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
|
||||
"speech",
|
||||
"recognize",
|
||||
"--model",
|
||||
"qwen3-asr-flash-filetrans",
|
||||
"--url",
|
||||
"https://example.com/a.wav",
|
||||
"--language",
|
||||
"zh",
|
||||
"--api-key",
|
||||
"sk-e2e-placeholder",
|
||||
"--base-url",
|
||||
`http://127.0.0.1:${address.port}`,
|
||||
"--poll-interval",
|
||||
"1",
|
||||
"--out",
|
||||
outPath,
|
||||
"--quiet",
|
||||
]);
|
||||
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("你好世界");
|
||||
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
|
||||
transcripts: [{ text: "你好世界" }],
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
|
||||
|
||||
@@ -10,8 +10,37 @@ describe("e2e: text chat", () => {
|
||||
test("text chat --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, ["text", "chat", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--api\s+<chat\|responses>/i);
|
||||
expect(stderr).toMatch(/chat|--message|model|stream/i);
|
||||
});
|
||||
|
||||
test("text chat 拒绝未知的 --api 值", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
|
||||
"text",
|
||||
"chat",
|
||||
"--api",
|
||||
"legacy",
|
||||
"--message",
|
||||
"hello",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--api|chat.*responses/i);
|
||||
});
|
||||
|
||||
test("text chat 在 Responses 模式拒绝 --thinking-budget", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
|
||||
"text",
|
||||
"chat",
|
||||
"--api",
|
||||
"responses",
|
||||
"--message",
|
||||
"hello",
|
||||
"--thinking-budget",
|
||||
"8",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/thinking-budget.*Responses/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
|
||||
@@ -45,7 +74,42 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
|
||||
request?: { model?: string; messages?: Array<{ content?: string }> };
|
||||
}>(stdout);
|
||||
expect(data.request?.model).toBe("qwen3.8-max");
|
||||
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
|
||||
expect(data.request?.messages?.some((message) => message.content === "干跑")).toBe(true);
|
||||
});
|
||||
|
||||
test("text chat --api responses --dry-run 生成 Responses 请求", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
|
||||
"text",
|
||||
"chat",
|
||||
"--dry-run",
|
||||
"--api",
|
||||
"responses",
|
||||
"--model",
|
||||
"qwen3.8-max",
|
||||
"--system",
|
||||
"system",
|
||||
"--message",
|
||||
"hello",
|
||||
"--max-tokens",
|
||||
"8",
|
||||
"--tool",
|
||||
'{"type":"web_search"}',
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ request?: Record<string, unknown> }>(stdout);
|
||||
expect(data.request).toMatchObject({
|
||||
model: "qwen3.8-max",
|
||||
input: [
|
||||
{ role: "system", content: "system" },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
max_output_tokens: 8,
|
||||
tools: [{ type: "web_search" }],
|
||||
});
|
||||
expect(data.request).not.toHaveProperty("messages");
|
||||
expect(data.request).not.toHaveProperty("max_tokens");
|
||||
});
|
||||
|
||||
test("【qwen3.8-max】文本对话", async () => {
|
||||
|
||||
@@ -109,6 +109,8 @@ export const USAGE_ROUTES: E2eRouteExports = {
|
||||
"usage free": "usageFree",
|
||||
"usage freetier": "usageFreetier",
|
||||
"usage stats": "usageStats",
|
||||
"usage token-plan": "usageTokenPlan",
|
||||
"usage coding-plan": "usageCodingPlan",
|
||||
};
|
||||
|
||||
export const DEPLOY_ROUTES: E2eRouteExports = {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
isConsoleAuthFailure,
|
||||
isConsoleE2EReady,
|
||||
parseStdoutJson,
|
||||
runCommandE2e,
|
||||
} from "./helpers.ts";
|
||||
import { USAGE_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: usage coding-plan", () => {
|
||||
test("usage coding-plan --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"coding-plan",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/Coding Plan|quota/i);
|
||||
});
|
||||
|
||||
test("usage coding-plan --help 包含 --output json 示例", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"coding-plan",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("bl usage coding-plan --output json");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-plan(Console)", () => {
|
||||
test("usage coding-plan --dry-run 输出网关请求计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"coding-plan",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
api?: string;
|
||||
data?: { queryCodingPlanInstanceInfoRequest?: Record<string, unknown> };
|
||||
}>(stdout);
|
||||
expect(data.api).toBe("zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2");
|
||||
expect(data.data?.queryCodingPlanInstanceInfoRequest).toEqual({
|
||||
commodityCode: "sfm_codingplan_public_cn",
|
||||
onlyLatestOne: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("usage coding-plan --output json 返回窗口结构", async () => {
|
||||
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
per5Hour?: { percentage?: number };
|
||||
perWeek?: { percentage?: number };
|
||||
perBillMonth?: { percentage?: number };
|
||||
}>(result.stdout);
|
||||
// 无有效订阅返回 {};有订阅时三个窗口必须存在
|
||||
if (Object.keys(data).length > 0) {
|
||||
expect(data.per5Hour).toBeTypeOf("object");
|
||||
expect(data.perWeek).toBeTypeOf("object");
|
||||
expect(data.perBillMonth).toBeTypeOf("object");
|
||||
}
|
||||
});
|
||||
|
||||
test("usage coding-plan 默认渲染生成时间与额度窗口或无订阅提示", async () => {
|
||||
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
if (result.stdout.includes("No active Coding Plan subscription found.")) return;
|
||||
expect(result.stdout).toContain("Generated at:");
|
||||
expect(result.stdout).toContain("5-hour quota");
|
||||
expect(result.stdout).toContain("1-week quota");
|
||||
expect(result.stdout).toContain("Monthly quota");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
isConsoleAuthFailure,
|
||||
isConsoleE2EReady,
|
||||
parseStdoutJson,
|
||||
runCommandE2e,
|
||||
} from "./helpers.ts";
|
||||
import { USAGE_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: usage token-plan", () => {
|
||||
test("usage token-plan --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"token-plan",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/Token Plan|quota/i);
|
||||
});
|
||||
|
||||
test("usage token-plan --help 包含 --output json 示例", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"token-plan",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("bl usage token-plan --output json");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () => {
|
||||
test("usage token-plan --dry-run 输出网关请求计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
|
||||
"usage",
|
||||
"token-plan",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ api?: string; data?: Record<string, unknown> }>(stdout);
|
||||
expect(data.api).toBe("zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage");
|
||||
expect(data.data).toEqual({});
|
||||
});
|
||||
|
||||
test("usage token-plan --output json 返回可用的额度字段", async () => {
|
||||
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
per5HourPercentage?: number;
|
||||
per5HourResetTime?: number;
|
||||
per1WeekPercentage?: number;
|
||||
per1WeekResetTime?: number;
|
||||
}>(result.stdout);
|
||||
const fields = [
|
||||
data.per5HourPercentage,
|
||||
data.per5HourResetTime,
|
||||
data.per1WeekPercentage,
|
||||
data.per1WeekResetTime,
|
||||
];
|
||||
for (const field of fields) {
|
||||
if (field !== undefined) expect(field).toBeTypeOf("number");
|
||||
}
|
||||
});
|
||||
|
||||
test("usage token-plan 默认渲染生成时间与两个额度窗口", async () => {
|
||||
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Generated at:");
|
||||
expect(result.stdout).toContain("5-hour quota");
|
||||
expect(result.stdout).toContain("1-week quota");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,12 @@ describe("mcp-activate-hint", () => {
|
||||
false,
|
||||
);
|
||||
expect(isMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false);
|
||||
// Nested wrapper phrase must not match (anchored at start).
|
||||
expect(
|
||||
isMcpNotActivated(
|
||||
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found - 未开通"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("hint 含对应 server 的 MCP 广场深链", () => {
|
||||
@@ -38,6 +44,36 @@ describe("mcp-activate-hint", () => {
|
||||
expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i);
|
||||
});
|
||||
|
||||
test("WebSearch + 405 streamableHttp 补重开通 hint", () => {
|
||||
const original = new BailianError(
|
||||
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
try {
|
||||
rethrowWithMcpActivateHint(original, "WebSearch");
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(BailianError);
|
||||
const wrapped = error as BailianError;
|
||||
expect(wrapped.message).toBe(original.message);
|
||||
expect(wrapped.hint).toMatch(/SSE|Streamable HTTP|Activate|re-activate/i);
|
||||
expect(wrapped.hint).toContain(mcpMarketplaceDetailPage("WebSearch"));
|
||||
}
|
||||
});
|
||||
|
||||
test("非 WebSearch 的 405 streamableHttp 不补 hint(由 fallback 处理)", () => {
|
||||
const original = new BailianError(
|
||||
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
try {
|
||||
rethrowWithMcpActivateHint(original, "WebParser");
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect(error).toBe(original);
|
||||
}
|
||||
});
|
||||
|
||||
test("rethrow 保留原 message,补 hint", () => {
|
||||
const serverCode = "market-cmapi00073529";
|
||||
const original = new BailianError(
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import chatCommand from "../src/commands/text/chat.ts";
|
||||
import {
|
||||
extractResponsesStreamDelta,
|
||||
extractResponsesText,
|
||||
} from "../src/commands/text/responses.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function responsesStreamResponse(events: unknown[]): Response {
|
||||
const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
|
||||
return new Response(body, { headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
async function runResponsesStream(events: unknown[]): Promise<BailianError | undefined> {
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
try {
|
||||
await chatCommand.run({
|
||||
settings: { output: "json" },
|
||||
flags: {
|
||||
api: "responses",
|
||||
message: ["hello"],
|
||||
stream: true,
|
||||
},
|
||||
client: {
|
||||
request: async () => responsesStreamResponse(events),
|
||||
},
|
||||
} as never);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(BailianError);
|
||||
return error as BailianError;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Responses output", () => {
|
||||
test("extracts only output_text from message items", () => {
|
||||
const text = extractResponsesText({
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
status: "completed",
|
||||
output: [
|
||||
{ type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }] },
|
||||
{ type: "web_search_call", status: "completed" },
|
||||
{ type: "message", content: [{ type: "output_text", text: "first" }] },
|
||||
{
|
||||
type: "message",
|
||||
content: [
|
||||
{ type: "refusal", text: "ignored" },
|
||||
{ type: "output_text", text: " second" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(text).toBe("first second");
|
||||
});
|
||||
|
||||
test("extracts only response.output_text.delta stream events", () => {
|
||||
expect(extractResponsesStreamDelta({ type: "response.output_text.delta", delta: "hi" })).toBe(
|
||||
"hi",
|
||||
);
|
||||
expect(extractResponsesStreamDelta({ type: "response.web_search_call.completed" })).toBe("");
|
||||
});
|
||||
|
||||
test("streaming response.completed finishes successfully", async () => {
|
||||
const error = await runResponsesStream([
|
||||
{ type: "response.output_text.delta", sequence_number: 1, delta: "done" },
|
||||
{
|
||||
type: "response.completed",
|
||||
sequence_number: 2,
|
||||
response: {
|
||||
id: "resp_completed",
|
||||
object: "response",
|
||||
status: "completed",
|
||||
output: [],
|
||||
error: null,
|
||||
incomplete_details: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("streaming response.failed throws the original service message", async () => {
|
||||
const error = await runResponsesStream([
|
||||
{
|
||||
type: "response.failed",
|
||||
sequence_number: 1,
|
||||
response: {
|
||||
id: "resp_failed",
|
||||
object: "response",
|
||||
status: "failed",
|
||||
output: [],
|
||||
error: { code: "quota_exceeded", message: "provider quota exceeded" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(error?.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(error?.message).toBe("provider quota exceeded");
|
||||
});
|
||||
|
||||
test("streaming response.incomplete exits non-zero with the service reason", async () => {
|
||||
const error = await runResponsesStream([
|
||||
{
|
||||
type: "response.incomplete",
|
||||
sequence_number: 1,
|
||||
response: {
|
||||
id: "resp_incomplete",
|
||||
object: "response",
|
||||
status: "incomplete",
|
||||
output: [],
|
||||
incomplete_details: { reason: "max_output_tokens" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(error?.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(error?.message).toBe("Response incomplete: max_output_tokens");
|
||||
});
|
||||
|
||||
test("stream ending before response.completed exits non-zero", async () => {
|
||||
const error = await runResponsesStream([
|
||||
{ type: "response.output_text.delta", sequence_number: 1, delta: "partial" },
|
||||
]);
|
||||
|
||||
expect(error?.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(error?.message).toBe(
|
||||
"Stream disconnected before completion: stream closed before response.completed.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import tokenPlanUsage from "../src/commands/usage/token-plan.ts";
|
||||
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
const originalForceColor = process.env.FORCE_COLOR;
|
||||
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNoColor === undefined) delete process.env.NO_COLOR;
|
||||
else process.env.NO_COLOR = originalNoColor;
|
||||
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
|
||||
else process.env.FORCE_COLOR = originalForceColor;
|
||||
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
|
||||
else delete (process.stdout as { isTTY?: boolean }).isTTY;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function captureStdout(): string[] {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
output.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
async function runTokenPlan(response: Record<string, unknown>, output?: string): Promise<void> {
|
||||
await tokenPlanUsage.run({
|
||||
client: { console: vi.fn().mockResolvedValue(response) },
|
||||
flags: {},
|
||||
settings: { dryRun: false, output },
|
||||
} as never);
|
||||
}
|
||||
|
||||
function makeUsageResponse(
|
||||
per5HourPercentage?: number,
|
||||
per1WeekPercentage = per5HourPercentage,
|
||||
): Record<string, unknown> {
|
||||
const usage: Record<string, number> = {};
|
||||
if (per5HourPercentage !== undefined) {
|
||||
usage.per5HourPercentage = per5HourPercentage;
|
||||
if (per5HourPercentage !== 0) usage.per5HourResetTime = 1_786_000_000_000;
|
||||
}
|
||||
if (per1WeekPercentage !== undefined) {
|
||||
usage.per1WeekPercentage = per1WeekPercentage;
|
||||
if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000;
|
||||
}
|
||||
|
||||
return wrapResponse(usage);
|
||||
}
|
||||
|
||||
function wrapResponse(usage: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
data: {
|
||||
DataV2: {
|
||||
data: {
|
||||
data: usage,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("usage token-plan view", () => {
|
||||
test("renders the gauge with the usage-free brand fill color", async () => {
|
||||
delete process.env.NO_COLOR;
|
||||
process.env.FORCE_COLOR = "3";
|
||||
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(0.5));
|
||||
|
||||
// Brand-cyan fill cell, same as the `usage free` gauge column
|
||||
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
|
||||
});
|
||||
|
||||
test("renders proportional gauge cells with a transparent track", async () => {
|
||||
process.env.NO_COLOR = "1";
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(0.5));
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
// 50% of the default 20-cell gauge → 10 filled cells + 10 track spaces
|
||||
expect(renderedOutput).toContain(`${"\u2588".repeat(10)}${" ".repeat(10)}`);
|
||||
expect(renderedOutput).not.toContain("\u2588".repeat(11));
|
||||
});
|
||||
|
||||
test("accepts missing reset times when the quota usage is zero", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(0));
|
||||
|
||||
expect(output.join("")).toContain("Resets: not applicable (no usage yet)");
|
||||
});
|
||||
|
||||
test("allows one unused quota window without masking another reset time", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(0, 0.5));
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
expect(renderedOutput).toContain("Resets: not applicable (no usage yet)");
|
||||
expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test("renders missing quota windows as possibly unlimited", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse());
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
expect(renderedOutput).toContain(
|
||||
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
);
|
||||
expect(renderedOutput).toContain(
|
||||
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders only the missing quota window as possibly unlimited", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(undefined, 0.5));
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
expect(renderedOutput).toContain(
|
||||
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
);
|
||||
expect(renderedOutput).not.toContain(
|
||||
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
);
|
||||
expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
test("renders a window with a missing percentage as possibly unlimited even when its reset time is present", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(wrapResponse({ per5HourResetTime: 1_786_000_000_000 }));
|
||||
|
||||
expect(output.join("")).toContain(
|
||||
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats non-numeric quota fields as absent instead of failing", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(
|
||||
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: Number.NaN }),
|
||||
);
|
||||
|
||||
const renderedOutput = output.join("");
|
||||
expect(renderedOutput).toContain(
|
||||
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
);
|
||||
expect(renderedOutput).toContain(
|
||||
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage token-plan json", () => {
|
||||
test("outputs the four core usage fields with --output json", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(0.5, 0.25), "json");
|
||||
|
||||
expect(JSON.parse(output.join(""))).toEqual({
|
||||
per5HourPercentage: 0.5,
|
||||
per5HourResetTime: 1_786_000_000_000,
|
||||
per1WeekPercentage: 0.25,
|
||||
per1WeekResetTime: 1_786_100_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty JSON object when no quota fields are available", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(makeUsageResponse(), "json");
|
||||
|
||||
expect(output.join("").trim()).toBe("{}");
|
||||
});
|
||||
|
||||
test("omits non-numeric quota fields from the JSON output", async () => {
|
||||
const output = captureStdout();
|
||||
|
||||
await runTokenPlan(
|
||||
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: 0 }),
|
||||
"json",
|
||||
);
|
||||
|
||||
expect(JSON.parse(output.join(""))).toEqual({ per1WeekPercentage: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
performBinaryUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
const childProcessMocks = vi.hoisted(() => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("bailian-cli-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("bailian-cli-runtime")>();
|
||||
return { ...actual, performBinaryUpdate: runtimeMocks.performBinaryUpdate };
|
||||
});
|
||||
|
||||
vi.mock("child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("child_process")>();
|
||||
return { ...actual, execSync: childProcessMocks.execSync };
|
||||
});
|
||||
|
||||
import updateCommand from "../src/commands/update.ts";
|
||||
|
||||
let configDir: string;
|
||||
let previousConfigDir: string | undefined;
|
||||
let previousInstallMethod: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
configDir = mkdtempSync(join(tmpdir(), "bl-update-binary-"));
|
||||
previousConfigDir = process.env.BAILIAN_CONFIG_DIR;
|
||||
previousInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
|
||||
process.env.BAILIAN_CONFIG_DIR = configDir;
|
||||
process.env.BAILIAN_INSTALL_METHOD = "binary";
|
||||
runtimeMocks.performBinaryUpdate.mockResolvedValue("1.15.0");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = previousConfigDir;
|
||||
if (previousInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = previousInstallMethod;
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("binary bl update syncs bailian skills after the CLI update succeeds", async () => {
|
||||
await updateCommand.run({
|
||||
identity: {
|
||||
binName: "bl",
|
||||
clientName: "bailian-cli",
|
||||
npmPackage: "bailian-cli",
|
||||
version: "1.14.3",
|
||||
},
|
||||
flags: { to: "1.15.0" },
|
||||
settings: {},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMocks.performBinaryUpdate).toHaveBeenCalledWith("1.15.0");
|
||||
expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill init", { stdio: "inherit" });
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-core",
|
||||
"version": "1.14.2",
|
||||
"version": "1.15.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": {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { imageSyncPath, speechRecognizePath } from "./endpoints.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`
|
||||
* - sync multimodal (`.../aigc/multimodal-generation/generation`):
|
||||
* - qwen3: `{ content: [{ audio }] }` + optional `asr_options.language`
|
||||
* (qwen3-asr-flash*)
|
||||
* - input-audio: `{ type: input_audio, input_audio.data }` +
|
||||
* `format`/`sample_rate` + optional `language_hints`
|
||||
* (fun-asr-flash*, qwen-audio-*-asr-flash*)
|
||||
* - realtime / streaming: WebSocket — not supported by `speech recognize`
|
||||
*/
|
||||
|
||||
export type AsrApiKind = "async-filetrans" | "sync-flash" | "unsupported";
|
||||
|
||||
/** Sync-flash request body shape differs by Flash protocol family. */
|
||||
export type AsrFlashFamily = "qwen3" | "input-audio";
|
||||
|
||||
export interface AsrApiRoute {
|
||||
kind: AsrApiKind;
|
||||
path: string;
|
||||
/** True when the call is synchronous (no X-DashScope-Async / task poll). */
|
||||
useSync: boolean;
|
||||
/**
|
||||
* Async transcription request input style.
|
||||
* - `file_urls`: classic async models (fun-asr / paraformer / qwen-audio filetrans...)
|
||||
* - `file_url`: qwen3-asr-flash-filetrans family
|
||||
*/
|
||||
asyncInputStyle?: "file_urls" | "file_url";
|
||||
/**
|
||||
* Async transcription language field style.
|
||||
* - `language_hints`: fun-asr / paraformer / qwen-audio filetrans...
|
||||
* - `language`: qwen3-asr-flash-filetrans*
|
||||
*/
|
||||
asyncLanguageStyle?: "language_hints" | "language";
|
||||
flashFamily?: AsrFlashFamily;
|
||||
/** Human-readable reason when kind is unsupported. */
|
||||
unsupportedReason?: string;
|
||||
}
|
||||
|
||||
function isRealtimeOrStreaming(model: string): boolean {
|
||||
return /realtime|streaming/i.test(model);
|
||||
}
|
||||
|
||||
function isFiletransModel(model: string): boolean {
|
||||
return /filetrans/i.test(model);
|
||||
}
|
||||
|
||||
function isQwen3FiletransModel(model: string): boolean {
|
||||
return /^qwen3-asr-flash-filetrans(?:-|$)/i.test(model);
|
||||
}
|
||||
|
||||
const INPUT_AUDIO_FLASH_PREFIXES = ["fun-asr-flash", "qwen-audio"] as const;
|
||||
|
||||
/**
|
||||
* Fun-ASR-Flash / Qwen-Audio-*-ASR-Flash share the input_audio + format protocol.
|
||||
* Examples: fun-asr-flash-2026-06-15, qwen-audio-3.0-asr-flash
|
||||
*/
|
||||
function isInputAudioFlashModel(model: string): boolean {
|
||||
if (isRealtimeOrStreaming(model) || isFiletransModel(model)) return false;
|
||||
if (model.startsWith(INPUT_AUDIO_FLASH_PREFIXES[0])) return true;
|
||||
if (model.startsWith(INPUT_AUDIO_FLASH_PREFIXES[1]) && /asr-flash/i.test(model)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen3-ASR-Flash sync models use content.audio + asr_options.
|
||||
* Examples: qwen3-asr-flash, qwen3-asr-flash-2025-09-08, qwen3-asr-flash-us
|
||||
*/
|
||||
function isQwen3AsrFlashModel(model: string): boolean {
|
||||
if (!/^qwen3-asr-flash(?:-|$)/i.test(model)) return false;
|
||||
if (isFiletransModel(model) || isRealtimeOrStreaming(model)) return false;
|
||||
if (isInputAudioFlashModel(model)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which DashScope ASR API a model should use for file recognition.
|
||||
* Unknown models default to async-filetrans (preserves existing CLI behavior).
|
||||
*/
|
||||
export function resolveAsrApi(model: string): AsrApiRoute {
|
||||
if (isRealtimeOrStreaming(model)) {
|
||||
return {
|
||||
kind: "unsupported",
|
||||
path: "",
|
||||
useSync: false,
|
||||
unsupportedReason:
|
||||
`Model "${model}" is a realtime/streaming ASR model and requires a WebSocket API. ` +
|
||||
`Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) or a sync flash model ` +
|
||||
`(e.g. qwen3-asr-flash, qwen-audio-3.0-asr-flash) with this command.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isFiletransModel(model)) {
|
||||
const isQwen3Filetrans = isQwen3FiletransModel(model);
|
||||
return {
|
||||
kind: "async-filetrans",
|
||||
path: speechRecognizePath(),
|
||||
useSync: false,
|
||||
asyncInputStyle: isQwen3Filetrans ? "file_url" : "file_urls",
|
||||
asyncLanguageStyle: isQwen3Filetrans ? "language" : "language_hints",
|
||||
};
|
||||
}
|
||||
|
||||
if (isInputAudioFlashModel(model)) {
|
||||
return {
|
||||
kind: "sync-flash",
|
||||
path: imageSyncPath(),
|
||||
useSync: true,
|
||||
flashFamily: "input-audio",
|
||||
};
|
||||
}
|
||||
|
||||
if (isQwen3AsrFlashModel(model)) {
|
||||
return {
|
||||
kind: "sync-flash",
|
||||
path: imageSyncPath(),
|
||||
useSync: true,
|
||||
flashFamily: "qwen3",
|
||||
};
|
||||
}
|
||||
|
||||
// fun-asr / paraformer / sensevoice / unknown → keep legacy async path
|
||||
return {
|
||||
kind: "async-filetrans",
|
||||
path: speechRecognizePath(),
|
||||
useSync: false,
|
||||
asyncInputStyle: "file_urls",
|
||||
asyncLanguageStyle: "language_hints",
|
||||
};
|
||||
}
|
||||
|
||||
/** Infer audio container hint for input-audio Flash `parameters.format`. */
|
||||
export function inferAudioFormatHint(audioUrl: string): string {
|
||||
// data URI: data:audio/mpeg;base64,... → mp3; data:audio/x-wav;... → wav
|
||||
const dataType = /^data:audio\/([^;,]+)/i.exec(audioUrl)?.[1]?.toLowerCase();
|
||||
if (dataType) {
|
||||
if (dataType === "mpeg") return "mp3";
|
||||
if (dataType === "x-wav" || dataType === "wave") return "wav";
|
||||
return dataType;
|
||||
}
|
||||
|
||||
const pathPart = audioUrl.split(/[?#]/, 1)[0] ?? audioUrl;
|
||||
const match = pathPart.match(/\.([a-zA-Z0-9]+)$/);
|
||||
const extension = match?.[1]?.toLowerCase();
|
||||
if (!extension) return "wav";
|
||||
if (extension === "mpeg") return "mp3";
|
||||
return extension;
|
||||
}
|
||||
|
||||
export interface BuildAsrFlashRequestOpts {
|
||||
model: string;
|
||||
audioUrl: string;
|
||||
language?: string;
|
||||
/** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */
|
||||
vocabularyId?: string;
|
||||
flashFamily: AsrFlashFamily;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build language fields for async ASR routes.
|
||||
* qwen3-asr-flash-filetrans* → `language`; other async models → `language_hints`.
|
||||
*/
|
||||
export function buildAsyncAsrLanguageFields(
|
||||
languageStyle: "language_hints" | "language",
|
||||
language?: string,
|
||||
): { language_hints?: string[]; language?: string } {
|
||||
if (!language) return {};
|
||||
if (languageStyle === "language") {
|
||||
return { language };
|
||||
}
|
||||
return { language_hints: [language] };
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
if (flashFamily === "input-audio") {
|
||||
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id
|
||||
const parameters: Record<string, unknown> = {
|
||||
format: inferAudioFormatHint(audioUrl),
|
||||
sample_rate: "16000",
|
||||
};
|
||||
if (language) {
|
||||
parameters.language_hints = [language];
|
||||
}
|
||||
if (vocabularyId) {
|
||||
parameters.vocabulary_id = vocabularyId;
|
||||
}
|
||||
return {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_audio",
|
||||
input_audio: { data: audioUrl },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
const asrOptions: Record<string, unknown> = {};
|
||||
if (language) {
|
||||
asrOptions.language = language;
|
||||
}
|
||||
|
||||
const parameters: Record<string, unknown> = {};
|
||||
if (Object.keys(asrOptions).length > 0) {
|
||||
parameters.asr_options = asrOptions;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ audio: audioUrl }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
if (Object.keys(parameters).length > 0) {
|
||||
body.parameters = parameters;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract recognition text from a sync Flash ASR response.
|
||||
* Qwen3 uses choices[].message.content; input-audio Flash uses output.text /
|
||||
* output.sentence.text / output.output.sentence.text.
|
||||
*/
|
||||
export function extractAsrFlashText(
|
||||
response: Record<string, unknown>,
|
||||
flashFamily: AsrFlashFamily,
|
||||
): string {
|
||||
const output = response.output as Record<string, unknown> | undefined;
|
||||
if (!output) return "";
|
||||
|
||||
if (flashFamily === "input-audio") {
|
||||
if (typeof output.text === "string" && output.text.length > 0) {
|
||||
return output.text;
|
||||
}
|
||||
const topSentence = output.sentence as Record<string, unknown> | undefined;
|
||||
if (typeof topSentence?.text === "string" && topSentence.text.length > 0) {
|
||||
return topSentence.text;
|
||||
}
|
||||
const nested = output.output as Record<string, unknown> | undefined;
|
||||
const nestedSentence = nested?.sentence as Record<string, unknown> | undefined;
|
||||
if (typeof nestedSentence?.text === "string") {
|
||||
return nestedSentence.text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const choices = output.choices as Array<Record<string, unknown>> | undefined;
|
||||
if (!choices?.length) return "";
|
||||
|
||||
const texts: string[] = [];
|
||||
for (const choice of choices) {
|
||||
const message = choice.message as Record<string, unknown> | undefined;
|
||||
if (!message) continue;
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
texts.push(content);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const item of content) {
|
||||
if (typeof item === "string") {
|
||||
texts.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.text === "string") {
|
||||
texts.push(record.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return texts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize async ASR task transcription items:
|
||||
* - classic models: `output.results[]`
|
||||
* - qwen3-asr-flash-filetrans*: `output.result.transcription_url`
|
||||
*/
|
||||
export function collectAsrTranscriptionItems(output: {
|
||||
results?: Array<{
|
||||
file_url?: string;
|
||||
transcription_url?: string;
|
||||
subtask_status?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}>;
|
||||
result?: { transcription_url?: string };
|
||||
}): Array<{
|
||||
file_url?: string;
|
||||
transcription_url?: string;
|
||||
subtask_status?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}> {
|
||||
if (output.results && output.results.length > 0) {
|
||||
return output.results;
|
||||
}
|
||||
const transcriptionUrl = output.result?.transcription_url;
|
||||
if (typeof transcriptionUrl === "string" && transcriptionUrl.length > 0) {
|
||||
return [{ transcription_url: transcriptionUrl, subtask_status: "SUCCEEDED" }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -5,7 +5,13 @@ import { ExitCode } from "../errors/codes.ts";
|
||||
import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts";
|
||||
import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts";
|
||||
import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts";
|
||||
import { McpClient } from "./mcp.ts";
|
||||
import {
|
||||
bailianMcpPath,
|
||||
bailianMcpSsePath,
|
||||
connectBailianMcpWithFallback,
|
||||
McpClient,
|
||||
type McpConnectedClient,
|
||||
} from "./mcp.ts";
|
||||
import { callConsoleGateway } from "../console/gateway.ts";
|
||||
import { refreshAccessToken } from "../auth/refresh-token.ts";
|
||||
import { maskToken } from "../utils/token.ts";
|
||||
@@ -164,6 +170,25 @@ export class Client {
|
||||
return new McpClient(this.http, url, this.deps.apiCred?.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a Bailian MCP: try Streamable HTTP, then SSE on 405 (except WebSearch).
|
||||
* `urlOverride` maps to `--url`: Streamable first, then classic SSE on the same URL (405/404).
|
||||
*/
|
||||
connectBailianMcp(
|
||||
serverCode: string,
|
||||
urlOverride?: string,
|
||||
): Promise<{ client: McpConnectedClient; url: string }> {
|
||||
this.requireApi();
|
||||
return connectBailianMcpWithFallback({
|
||||
deps: this.http,
|
||||
authToken: this.deps.apiCred?.token,
|
||||
httpUrl: this.url(bailianMcpPath(serverCode)),
|
||||
sseUrl: this.url(bailianMcpSsePath(serverCode)),
|
||||
serverCode,
|
||||
urlOverride,
|
||||
});
|
||||
}
|
||||
|
||||
async console<T>(api: string, data: Record<string, unknown>): Promise<T> {
|
||||
if (!this.deps.consoleCred) {
|
||||
throw new BailianError("This command needs a console access token.", ExitCode.AUTH);
|
||||
|
||||
@@ -6,6 +6,11 @@ export function chatPath(): string {
|
||||
return "/compatible-mode/v1/chat/completions";
|
||||
}
|
||||
|
||||
// ---- Responses (OpenAI Compatible) ----
|
||||
export function responsesPath(): string {
|
||||
return "/compatible-mode/v1/responses";
|
||||
}
|
||||
|
||||
// ---- Image Generation (DashScope) ----
|
||||
/** Async image API used by wan2.6-t2i / wan2.6-image (T2I) and similar message-format models. */
|
||||
export function imagePath(): string {
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
memorySearchPath,
|
||||
mcpWebSearchPath,
|
||||
profileSchemaPath,
|
||||
responsesPath,
|
||||
speechRecognizePath,
|
||||
speechSynthesizePath,
|
||||
taskPath,
|
||||
@@ -34,6 +35,18 @@ export {
|
||||
type ImageInputStyle,
|
||||
type ImageSizeProfile,
|
||||
} from "./image-routes.ts";
|
||||
export {
|
||||
buildAsrFlashRequest,
|
||||
buildAsyncAsrLanguageFields,
|
||||
collectAsrTranscriptionItems,
|
||||
extractAsrFlashText,
|
||||
inferAudioFormatHint,
|
||||
resolveAsrApi,
|
||||
type AsrApiKind,
|
||||
type AsrApiRoute,
|
||||
type AsrFlashFamily,
|
||||
type BuildAsrFlashRequestOpts,
|
||||
} from "./asr-routes.ts";
|
||||
export { CHANNEL, sourceConfig, trackingHeaders, type TrackingIdentity } from "./headers.ts";
|
||||
export type { HttpDeps, RequestOpts } from "./http.ts";
|
||||
export { request, requestJson } from "./http.ts";
|
||||
@@ -57,7 +70,19 @@ export {
|
||||
type AcsQueryParams,
|
||||
type AcsSignConfig,
|
||||
} from "./acs.ts";
|
||||
export type { McpTool, McpToolResult } from "./mcp.ts";
|
||||
export { McpClient, bailianMcpPath } from "./mcp.ts";
|
||||
export type {
|
||||
McpTool,
|
||||
McpToolResult,
|
||||
McpConnectedClient,
|
||||
ConnectBailianMcpOptions,
|
||||
} from "./mcp.ts";
|
||||
export {
|
||||
McpClient,
|
||||
bailianMcpPath,
|
||||
bailianMcpSsePath,
|
||||
isStreamableHttpUnsupported,
|
||||
isUrlOverrideSseFallbackCandidate,
|
||||
connectBailianMcpWithFallback,
|
||||
} from "./mcp.ts";
|
||||
export type { ServerSentEvent } from "./stream.ts";
|
||||
export { parseSSE } from "./stream.ts";
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* MCP classic HTTP+SSE client (protocol 2024-11-05 transport).
|
||||
*
|
||||
* Flow: GET /sse → endpoint event → POST JSON-RPC to message URL;
|
||||
* responses arrive as SSE `message` events matched by JSON-RPC id.
|
||||
*/
|
||||
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import type { HttpDeps } from "./http.ts";
|
||||
import { trackingHeaders } from "./headers.ts";
|
||||
import type { McpTool, McpToolResult } from "./mcp.ts";
|
||||
import { parseSSE } from "./stream.ts";
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id?: number | string | null;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
type PendingResolver = {
|
||||
resolve: (value: JsonRpcResponse) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
};
|
||||
|
||||
/** Match JSON-RPC ids with string keys (number or string echo from server). */
|
||||
function pendingKey(id: number | string): string {
|
||||
return String(id);
|
||||
}
|
||||
|
||||
export class McpSseClient {
|
||||
private sseUrl: string;
|
||||
private messageUrl: string | undefined;
|
||||
private nextId = 1;
|
||||
private deps: HttpDeps;
|
||||
private authToken: string | undefined;
|
||||
private abortController: AbortController | undefined;
|
||||
private pending = new Map<string, PendingResolver>();
|
||||
private endpointReady: Promise<void>;
|
||||
private resolveEndpoint: (() => void) | undefined;
|
||||
private rejectEndpoint: ((reason: unknown) => void) | undefined;
|
||||
private closed = false;
|
||||
/** Set when the SSE GET ends without an intentional close(); later RPCs fail fast. */
|
||||
private streamEnded = false;
|
||||
|
||||
constructor(deps: HttpDeps, sseUrl: string, authToken?: string) {
|
||||
this.deps = deps;
|
||||
this.sseUrl = sseUrl;
|
||||
this.authToken = authToken;
|
||||
this.endpointReady = new Promise<void>((resolve, reject) => {
|
||||
this.resolveEndpoint = resolve;
|
||||
this.rejectEndpoint = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the SSE session and run initialize / notifications/initialized. */
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.authToken) {
|
||||
throw new BailianError("This command needs a model-domain API key.", ExitCode.AUTH);
|
||||
}
|
||||
|
||||
await this.openSse();
|
||||
|
||||
const result = await this.rpc("initialize", {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: this.deps.identity.clientName,
|
||||
version: this.deps.identity.version,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.deps.settings.verbose) {
|
||||
console.error(`[MCP SSE] Session initialized`);
|
||||
console.error(`[MCP SSE] Server: ${JSON.stringify(result)}`);
|
||||
}
|
||||
|
||||
await this.notify("notifications/initialized");
|
||||
}
|
||||
|
||||
async listTools(): Promise<McpTool[]> {
|
||||
const result = (await this.rpc("tools/list")) as { tools: McpTool[] };
|
||||
return result.tools || [];
|
||||
}
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
||||
const result = (await this.rpc("tools/call", { name, arguments: args })) as McpToolResult;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Abort the hanging GET /sse so the CLI process can exit. */
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.abortController?.abort();
|
||||
this.failPending(new BailianError("MCP SSE session closed.", ExitCode.GENERAL));
|
||||
this.messageUrl = undefined;
|
||||
}
|
||||
|
||||
private failPending(reason: unknown): void {
|
||||
for (const [, waiter] of this.pending) {
|
||||
waiter.reject(reason);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private markStreamEnded(reason: BailianError): void {
|
||||
this.streamEnded = true;
|
||||
this.messageUrl = undefined;
|
||||
this.failPending(reason);
|
||||
}
|
||||
|
||||
private async openSse(): Promise<void> {
|
||||
if (this.abortController) return;
|
||||
|
||||
// One abortController for header/error-body wait; clear timer before the long-lived stream.
|
||||
this.abortController = new AbortController();
|
||||
const timeoutMs = this.deps.settings.timeout * 1000;
|
||||
let headerTimedOut = false;
|
||||
const headerTimer = setTimeout(() => {
|
||||
headerTimedOut = true;
|
||||
this.abortController?.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/event-stream",
|
||||
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
|
||||
...trackingHeaders(this.deps.identity),
|
||||
};
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
if (this.deps.settings.verbose) {
|
||||
console.error(`> GET ${this.sseUrl}`);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(this.sseUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(headerTimer);
|
||||
// Allow a later initialize() to openSse again on this instance.
|
||||
this.abortController = undefined;
|
||||
if (this.closed) {
|
||||
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
|
||||
}
|
||||
if (headerTimedOut) {
|
||||
throw new BailianError("MCP SSE timed out waiting for response headers.", ExitCode.TIMEOUT);
|
||||
}
|
||||
// Rethrow fetch failures so runtime can surface errno (e.g. ENOTFOUND) in JSON/text.
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (this.deps.settings.verbose) {
|
||||
console.error(`< ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Keep headerTimer until error body is read (or times out).
|
||||
let errMsg = `MCP request failed: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errBody = await response.text();
|
||||
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
|
||||
} catch (error) {
|
||||
clearTimeout(headerTimer);
|
||||
this.abortController = undefined;
|
||||
if (this.closed) {
|
||||
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
|
||||
}
|
||||
if (headerTimedOut) {
|
||||
throw new BailianError(
|
||||
"MCP SSE timed out reading error response body.",
|
||||
ExitCode.TIMEOUT,
|
||||
);
|
||||
}
|
||||
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
|
||||
}
|
||||
clearTimeout(headerTimer);
|
||||
this.abortController = undefined;
|
||||
// Do not rejectEndpoint — openSse never awaits endpointReady on this path.
|
||||
throw new BailianError(errMsg, ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
clearTimeout(headerTimer);
|
||||
|
||||
void this.consumeSse(response).catch((error) => {
|
||||
if (this.closed) return;
|
||||
const reason =
|
||||
error instanceof BailianError
|
||||
? error
|
||||
: new BailianError(
|
||||
`MCP SSE stream failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
this.rejectEndpoint?.(reason);
|
||||
// consumeSse already markStreamEnded on a clean end; cover parse/read failures here.
|
||||
if (!this.streamEnded) {
|
||||
this.markStreamEnded(reason);
|
||||
}
|
||||
});
|
||||
|
||||
const endpointTimeout = cancellableTimeoutReject(
|
||||
timeoutMs,
|
||||
"MCP SSE timed out waiting for endpoint event.",
|
||||
);
|
||||
try {
|
||||
await Promise.race([this.endpointReady, endpointTimeout.promise]);
|
||||
} finally {
|
||||
endpointTimeout.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private async consumeSse(response: Response): Promise<void> {
|
||||
for await (const event of parseSSE(response)) {
|
||||
if (this.closed) break;
|
||||
|
||||
// Spec requires event: endpoint; ignore unnamed events so JSON is not treated as a URL.
|
||||
if (event.event === "endpoint") {
|
||||
const raw = event.data.trim();
|
||||
if (!raw) continue;
|
||||
// Only accept same-origin message URLs so we never forward the Bearer token cross-origin.
|
||||
this.messageUrl = resolveSameOriginMessageUrl(this.sseUrl, raw);
|
||||
this.resolveEndpoint?.();
|
||||
this.resolveEndpoint = undefined;
|
||||
this.rejectEndpoint = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Omitted SSE event type defaults to "message".
|
||||
if (event.event === "message" || event.event === undefined) {
|
||||
let payload: JsonRpcResponse;
|
||||
try {
|
||||
payload = JSON.parse(event.data) as JsonRpcResponse;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof payload.id !== "number" && typeof payload.id !== "string") continue;
|
||||
const key = pendingKey(payload.id);
|
||||
const waiter = this.pending.get(key);
|
||||
if (!waiter) continue;
|
||||
this.pending.delete(key);
|
||||
waiter.resolve(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.closed) return;
|
||||
|
||||
if (!this.messageUrl) {
|
||||
const error = new BailianError(
|
||||
"MCP SSE stream ended before endpoint event.",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
this.rejectEndpoint?.(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// After endpoint: mark dead and wake pending; don't throw (avoid unhandledRejection).
|
||||
this.markStreamEnded(new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL));
|
||||
}
|
||||
|
||||
private async rpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||
if (this.closed || this.streamEnded) {
|
||||
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
const key = pendingKey(id);
|
||||
const body = {
|
||||
jsonrpc: "2.0" as const,
|
||||
id,
|
||||
method,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
|
||||
const timeoutMs = this.deps.settings.timeout * 1000;
|
||||
const responsePromise = new Promise<JsonRpcResponse>((resolve, reject) => {
|
||||
this.pending.set(key, { resolve, reject });
|
||||
});
|
||||
// Stream may end and reject pending before Promise.race; attach catch to avoid unhandledRejection.
|
||||
void responsePromise.catch(() => undefined);
|
||||
const responseTimeout = cancellableTimeoutReject(
|
||||
timeoutMs,
|
||||
`MCP SSE timed out waiting for response to ${method}.`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.postMessage(body);
|
||||
if (this.closed || this.streamEnded) {
|
||||
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
|
||||
}
|
||||
const data = await Promise.race([responsePromise, responseTimeout.promise]);
|
||||
if (data.error) {
|
||||
throw new BailianError(
|
||||
`MCP error (${data.error.code}): ${data.error.message}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
return data.result;
|
||||
} catch (error) {
|
||||
this.pending.delete(key);
|
||||
throw error;
|
||||
} finally {
|
||||
responseTimeout.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private async notify(method: string, params?: Record<string, unknown>): Promise<void> {
|
||||
const body = {
|
||||
jsonrpc: "2.0" as const,
|
||||
method,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
await this.postMessage(body);
|
||||
}
|
||||
|
||||
private async postMessage(body: unknown): Promise<void> {
|
||||
if (this.closed || this.streamEnded) {
|
||||
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
|
||||
}
|
||||
if (!this.messageUrl) {
|
||||
throw new BailianError("MCP SSE message endpoint is not ready.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
|
||||
...trackingHeaders(this.deps.identity),
|
||||
};
|
||||
// Bearer is only sent to a messageUrl that already passed the same-origin check.
|
||||
if (this.authToken) {
|
||||
headers["Authorization"] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
if (this.deps.settings.verbose) {
|
||||
console.error(`> POST ${this.messageUrl}`);
|
||||
console.error(`> Method: ${(body as { method?: string }).method}`);
|
||||
}
|
||||
|
||||
const timeoutMs = this.deps.settings.timeout * 1000;
|
||||
// Combine per-RPC timeout with session abort so close() cancels in-flight POSTs.
|
||||
const requestSignal = createLinkedAbortSignal(timeoutMs, this.abortController?.signal);
|
||||
let res: Response;
|
||||
try {
|
||||
try {
|
||||
res = await fetch(this.messageUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: requestSignal.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.closed) {
|
||||
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (this.deps.settings.verbose) {
|
||||
console.error(`< ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// Keep signal until error body is read (same class of bug as GET openSse).
|
||||
let errMsg = `MCP request failed: ${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const errBody = await res.text();
|
||||
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
|
||||
} catch (error) {
|
||||
if (this.closed) {
|
||||
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
|
||||
}
|
||||
if (requestSignal.timedOut) {
|
||||
throw new BailianError(
|
||||
"MCP SSE timed out reading error response body.",
|
||||
ExitCode.TIMEOUT,
|
||||
);
|
||||
}
|
||||
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
|
||||
}
|
||||
throw new BailianError(errMsg, ExitCode.GENERAL);
|
||||
}
|
||||
} finally {
|
||||
requestSignal.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the SSE endpoint data to an absolute URL and require same origin as sseUrl. */
|
||||
export function resolveSameOriginMessageUrl(sseUrl: string, endpointData: string): string {
|
||||
let resolved: URL;
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(sseUrl);
|
||||
resolved = new URL(endpointData, sseUrl);
|
||||
} catch {
|
||||
throw new BailianError(
|
||||
`MCP SSE endpoint is not a valid URL: ${endpointData}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
if (resolved.origin !== base.origin) {
|
||||
throw new BailianError(
|
||||
`MCP SSE endpoint origin mismatch: expected ${base.origin}, got ${resolved.origin}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
return resolved.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable timeout rejection: after Promise.race settles, call cancel()
|
||||
* to clear the timer and avoid unhandledRejection.
|
||||
*/
|
||||
function cancellableTimeoutReject(
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): { promise: Promise<never>; cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const promise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
reject(new BailianError(message, ExitCode.TIMEOUT));
|
||||
}, timeoutMs);
|
||||
});
|
||||
// Swallow late rejects after cancel to avoid unhandledRejection.
|
||||
void promise.catch(() => undefined);
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Timeout + optional parent abort without AbortSignal.any (Node 18). */
|
||||
function createLinkedAbortSignal(
|
||||
timeoutMs: number,
|
||||
parentSignal?: AbortSignal,
|
||||
): { signal: AbortSignal; cleanup: () => void; timedOut: boolean } {
|
||||
const controller = new AbortController();
|
||||
const state = { timedOut: false };
|
||||
const timeout = setTimeout(() => {
|
||||
state.timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
const abortFromParent = () => controller.abort(parentSignal?.reason);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
parentSignal?.removeEventListener("abort", abortFromParent);
|
||||
};
|
||||
|
||||
if (parentSignal?.aborted) abortFromParent();
|
||||
else parentSignal?.addEventListener("abort", abortFromParent, { once: true });
|
||||
controller.signal.addEventListener("abort", cleanup, { once: true });
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup,
|
||||
get timedOut() {
|
||||
return state.timedOut;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import type { HttpDeps } from "./http.ts";
|
||||
import { trackingHeaders } from "./headers.ts";
|
||||
import { McpSseClient } from "./mcp-sse.ts";
|
||||
import { parseSSE } from "./stream.ts";
|
||||
|
||||
// ---- JSON-RPC 2.0 Types ----
|
||||
|
||||
@@ -27,7 +29,7 @@ interface JsonRpcRequest {
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: number;
|
||||
id?: number | string | null;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
@@ -61,6 +63,101 @@ export function bailianMcpPath(serverCode: string): string {
|
||||
return `/api/v1/mcps/${serverCode}/mcp`;
|
||||
}
|
||||
|
||||
/** Classic SSE path: `/api/v1/mcps/<serverCode>/sse`. */
|
||||
export function bailianMcpSsePath(serverCode: string): string {
|
||||
return `/api/v1/mcps/${serverCode}/sse`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when Streamable HTTP is unsupported and classic SSE fallback should be tried.
|
||||
* Anchored to HTTP wrapper text only (not JSON-RPC / nested copies). Bailian 404 excluded.
|
||||
*/
|
||||
export function isStreamableHttpUnsupported(error: unknown): boolean {
|
||||
if (!(error instanceof BailianError)) return false;
|
||||
return /^MCP request failed:\s*405\b/i.test(error.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE fallback for `--url` (official backwards-compat: same URL, HTTP 405/404 then GET SSE).
|
||||
*/
|
||||
export function isUrlOverrideSseFallbackCandidate(error: unknown): boolean {
|
||||
if (!(error instanceof BailianError)) return false;
|
||||
return /^MCP request failed:\s*(405|404)\b/i.test(error.message);
|
||||
}
|
||||
|
||||
export type McpConnectedClient = {
|
||||
initialize(): Promise<void>;
|
||||
listTools(): Promise<McpTool[]>;
|
||||
callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult>;
|
||||
close?(): void;
|
||||
};
|
||||
|
||||
export type ConnectBailianMcpOptions = {
|
||||
deps: HttpDeps;
|
||||
authToken: string | undefined;
|
||||
/** Full Streamable HTTP URL (/mcp). */
|
||||
httpUrl: string;
|
||||
/** Full classic SSE URL (/sse). */
|
||||
sseUrl: string;
|
||||
serverCode: string;
|
||||
/**
|
||||
* Explicit `--url` override: try Streamable on that URL first;
|
||||
* on 405/404 fall back to classic SSE on the same URL.
|
||||
*/
|
||||
urlOverride?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Connect via Streamable HTTP first; on 405 (except WebSearch), fall back to SSE.
|
||||
* `--url` uses the same URL for Streamable then classic SSE (official backwards-compat).
|
||||
* For WebSearch, rethrow the original error so commands can attach a re-activate hint.
|
||||
*/
|
||||
export async function connectBailianMcpWithFallback(
|
||||
options: ConnectBailianMcpOptions,
|
||||
): Promise<{ client: McpConnectedClient; url: string }> {
|
||||
const { deps, authToken, httpUrl, sseUrl, serverCode, urlOverride } = options;
|
||||
|
||||
if (urlOverride) {
|
||||
const httpClient = new McpClient(deps, urlOverride, authToken);
|
||||
try {
|
||||
await httpClient.initialize();
|
||||
return { client: httpClient, url: urlOverride };
|
||||
} catch (error) {
|
||||
if (!isUrlOverrideSseFallbackCandidate(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const sseClient = new McpSseClient(deps, urlOverride, authToken);
|
||||
try {
|
||||
await sseClient.initialize();
|
||||
return { client: sseClient, url: urlOverride };
|
||||
} catch (error) {
|
||||
sseClient.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const httpClient = new McpClient(deps, httpUrl, authToken);
|
||||
try {
|
||||
await httpClient.initialize();
|
||||
return { client: httpClient, url: httpUrl };
|
||||
} catch (error) {
|
||||
if (!isStreamableHttpUnsupported(error) || serverCode === "WebSearch") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const sseClient = new McpSseClient(deps, sseUrl, authToken);
|
||||
try {
|
||||
await sseClient.initialize();
|
||||
return { client: sseClient, url: sseUrl };
|
||||
} catch (error) {
|
||||
sseClient.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MCP Client ----
|
||||
|
||||
export class McpClient {
|
||||
@@ -121,7 +218,7 @@ export class McpClient {
|
||||
};
|
||||
|
||||
const response = await this.send(body);
|
||||
const data = (await response.json()) as JsonRpcResponse;
|
||||
const data = await this.readJsonRpcResponse(response, id);
|
||||
|
||||
if (data.error) {
|
||||
throw new BailianError(
|
||||
@@ -143,6 +240,44 @@ export class McpClient {
|
||||
await this.send(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSON-RPC response by Content-Type: application/json or text/event-stream.
|
||||
*/
|
||||
private async readJsonRpcResponse(
|
||||
response: Response,
|
||||
expectedId: number,
|
||||
): Promise<JsonRpcResponse> {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
return await this.readJsonRpcFromSse(response, expectedId);
|
||||
}
|
||||
|
||||
return (await response.json()) as JsonRpcResponse;
|
||||
}
|
||||
|
||||
private async readJsonRpcFromSse(
|
||||
response: Response,
|
||||
expectedId: number,
|
||||
): Promise<JsonRpcResponse> {
|
||||
const expectedKey = String(expectedId);
|
||||
for await (const event of parseSSE(response)) {
|
||||
if (event.event && event.event !== "message") continue;
|
||||
let payload: JsonRpcResponse;
|
||||
try {
|
||||
payload = JSON.parse(event.data) as JsonRpcResponse;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (payload.id == null) continue;
|
||||
if (String(payload.id) !== expectedKey) continue;
|
||||
return payload;
|
||||
}
|
||||
throw new BailianError(
|
||||
"MCP SSE response stream ended without a matching JSON-RPC response.",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
|
||||
private async send(body: unknown): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -7,6 +7,71 @@ export interface ServerSentEvent {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Normalize CRLF/CR to LF; hold a trailing `\r` so a split CRLF is not double-broken. */
|
||||
function takeNormalizedSseLines(buffer: string): { lines: string[]; rest: string } {
|
||||
let text = buffer;
|
||||
let holdTrailingCr = false;
|
||||
if (text.endsWith("\r")) {
|
||||
holdTrailingCr = true;
|
||||
text = text.slice(0, -1);
|
||||
}
|
||||
|
||||
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const parts = text.split("\n");
|
||||
const incomplete = parts.pop() ?? "";
|
||||
return {
|
||||
lines: parts,
|
||||
rest: holdTrailingCr ? `${incomplete}\r` : incomplete,
|
||||
};
|
||||
}
|
||||
|
||||
function applySseLine(
|
||||
line: string,
|
||||
event: Partial<ServerSentEvent>,
|
||||
maxBuffer: number,
|
||||
): { event: Partial<ServerSentEvent>; completed?: ServerSentEvent } {
|
||||
if (line === "") {
|
||||
if (event.data === undefined) {
|
||||
return { event: {} };
|
||||
}
|
||||
return {
|
||||
event: {},
|
||||
completed: { data: event.data, event: event.event, id: event.id },
|
||||
};
|
||||
}
|
||||
|
||||
if (line.startsWith(":")) {
|
||||
return { event };
|
||||
}
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return { event };
|
||||
}
|
||||
|
||||
const field = line.slice(0, colonIndex);
|
||||
const fieldValue = line.slice(colonIndex + 1).trimStart();
|
||||
const nextEvent: Partial<ServerSentEvent> = { ...event };
|
||||
|
||||
switch (field) {
|
||||
case "data":
|
||||
nextEvent.data =
|
||||
nextEvent.data !== undefined ? `${nextEvent.data}\n${fieldValue}` : fieldValue;
|
||||
if (nextEvent.data.length > maxBuffer) {
|
||||
throw new BailianError("SSE event exceeded the maximum buffer size.", ExitCode.GENERAL);
|
||||
}
|
||||
break;
|
||||
case "event":
|
||||
nextEvent.event = fieldValue;
|
||||
break;
|
||||
case "id":
|
||||
nextEvent.id = fieldValue;
|
||||
break;
|
||||
}
|
||||
|
||||
return { event: nextEvent };
|
||||
}
|
||||
|
||||
export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEvent> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return;
|
||||
@@ -14,69 +79,55 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
// Guard against a hostile or malfunctioning stream that never emits a newline
|
||||
// (or builds a single absurdly large event): bound the in-memory buffer so the
|
||||
// parser cannot be driven to exhaust process memory.
|
||||
const MAX_SSE_BUFFER = 16 * 1024 * 1024; // 16 MiB
|
||||
|
||||
try {
|
||||
// Keep partial event fields across chunks.
|
||||
let event: Partial<ServerSentEvent> = {};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (done) {
|
||||
// EOF: treat any held `\r` as a line ending.
|
||||
if (buffer.length > 0) {
|
||||
const finalText = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const parts = finalText.split("\n");
|
||||
buffer = parts.pop() ?? "";
|
||||
for (const line of parts) {
|
||||
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
|
||||
event = applied.event;
|
||||
if (applied.completed) {
|
||||
yield applied.completed;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
if (buffer.length > MAX_SSE_BUFFER) {
|
||||
throw new BailianError("SSE stream exceeded the maximum buffer size.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let event: Partial<ServerSentEvent> = {};
|
||||
const { lines, rest } = takeNormalizedSseLines(buffer);
|
||||
buffer = rest;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === "") {
|
||||
if (event.data !== undefined) {
|
||||
yield { data: event.data, event: event.event, id: event.id };
|
||||
}
|
||||
event = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(":")) continue; // comment
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const field = line.slice(0, colonIndex);
|
||||
const value = line.slice(colonIndex + 1).trimStart();
|
||||
|
||||
switch (field) {
|
||||
case "data":
|
||||
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
|
||||
if (event.data.length > MAX_SSE_BUFFER) {
|
||||
throw new BailianError(
|
||||
"SSE event exceeded the maximum buffer size.",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "event":
|
||||
event.event = value;
|
||||
break;
|
||||
case "id":
|
||||
event.id = value;
|
||||
break;
|
||||
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
|
||||
event = applied.event;
|
||||
if (applied.completed) {
|
||||
yield applied.completed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
if (buffer.trim() && buffer.includes("data:")) {
|
||||
const colonIndex = buffer.indexOf(":");
|
||||
if (colonIndex !== -1) {
|
||||
yield { data: buffer.slice(colonIndex + 1).trimStart() };
|
||||
}
|
||||
// Legacy EOF flush: apply trailing field line and dispatch with event/id intact.
|
||||
if (buffer.length > 0) {
|
||||
const applied = applySseLine(buffer, event, MAX_SSE_BUFFER);
|
||||
event = applied.event;
|
||||
}
|
||||
if (event.data !== undefined) {
|
||||
yield { data: event.data, event: event.event, id: event.id };
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
|
||||
@@ -58,7 +58,7 @@ export function effectiveConsoleGatewayConfig(
|
||||
}
|
||||
|
||||
export interface ConsoleGatewayRequest {
|
||||
/** Console API name, e.g. zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota */
|
||||
/** Console API name, e.g. zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota */
|
||||
api: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,44 @@ export interface StreamChunk {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Responses (OpenAI Compatible) ----
|
||||
|
||||
export interface ResponsesRequest {
|
||||
model: string;
|
||||
input: ChatMessage[];
|
||||
max_output_tokens?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stream?: boolean;
|
||||
tools?: Array<Record<string, unknown>>;
|
||||
enable_thinking?: boolean;
|
||||
}
|
||||
|
||||
export interface ResponsesOutputContent {
|
||||
type: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface ResponsesOutputItem {
|
||||
type: string;
|
||||
content?: ResponsesOutputContent[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ResponsesResponse {
|
||||
id: string;
|
||||
object: "response";
|
||||
status: string;
|
||||
output: ResponsesOutputItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ResponsesStreamEvent {
|
||||
type: string;
|
||||
delta?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ---- Image (DashScope) ----
|
||||
|
||||
export interface DashScopeImageRequest {
|
||||
@@ -533,33 +571,46 @@ export interface DashScopeTTSStreamChunk {
|
||||
export interface DashScopeASRRequest {
|
||||
model: string;
|
||||
input: {
|
||||
file_urls: string[];
|
||||
file_urls?: string[];
|
||||
file_url?: string;
|
||||
};
|
||||
parameters?: {
|
||||
channel_id?: number[];
|
||||
/** Classic async models (fun-asr / paraformer / qwen-audio filetrans, etc.) */
|
||||
language_hints?: string[];
|
||||
/** qwen3-asr-flash-filetrans* uses singular `language` */
|
||||
language?: string;
|
||||
diarization_enabled?: boolean;
|
||||
speaker_count?: number;
|
||||
vocabulary_id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashScopeASRTranscriptionItem {
|
||||
file_url?: string;
|
||||
transcription_url?: string;
|
||||
subtask_status?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DashScopeASRTaskResult {
|
||||
output: {
|
||||
task_id: string;
|
||||
task_status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "UNKNOWN";
|
||||
results?: Array<{
|
||||
file_url?: string;
|
||||
/** Multi-file async results (fun-asr / paraformer / qwen-audio filetrans, etc.) */
|
||||
results?: DashScopeASRTranscriptionItem[];
|
||||
/** Singular result returned by qwen3-asr-flash-filetrans* on success */
|
||||
result?: {
|
||||
transcription_url?: string;
|
||||
subtask_status?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}>;
|
||||
};
|
||||
task_metrics?: {
|
||||
TOTAL: number;
|
||||
SUCCEEDED: number;
|
||||
FAILED: number;
|
||||
};
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
usage?: Record<string, unknown>;
|
||||
request_id: string;
|
||||
|
||||
@@ -48,6 +48,7 @@ export type {
|
||||
ChatTool,
|
||||
DashScopeASRRequest,
|
||||
DashScopeASRTaskResult,
|
||||
DashScopeASRTranscriptionItem,
|
||||
DashScopeAsyncResponse,
|
||||
DashScopeImageRequest,
|
||||
DashScopeImageSyncResponse,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
buildAsrFlashRequest,
|
||||
buildAsyncAsrLanguageFields,
|
||||
collectAsrTranscriptionItems,
|
||||
extractAsrFlashText,
|
||||
inferAudioFormatHint,
|
||||
resolveAsrApi,
|
||||
} from "../src/client/asr-routes.ts";
|
||||
|
||||
test("resolveAsrApi routes model families correctly", () => {
|
||||
const cases = [
|
||||
{
|
||||
model: "fun-asr",
|
||||
expected: {
|
||||
kind: "async-filetrans",
|
||||
useSync: false,
|
||||
path: "/api/v1/services/audio/asr/transcription",
|
||||
asyncInputStyle: "file_urls",
|
||||
},
|
||||
},
|
||||
{
|
||||
model: "qwen3-asr-flash-filetrans-2025-11-17",
|
||||
expected: {
|
||||
kind: "async-filetrans",
|
||||
useSync: false,
|
||||
path: "/api/v1/services/audio/asr/transcription",
|
||||
asyncInputStyle: "file_url",
|
||||
asyncLanguageStyle: "language",
|
||||
},
|
||||
},
|
||||
{
|
||||
model: "qwen-audio-3.0-asr-flash-filetrans",
|
||||
expected: {
|
||||
kind: "async-filetrans",
|
||||
useSync: false,
|
||||
asyncInputStyle: "file_urls",
|
||||
asyncLanguageStyle: "language_hints",
|
||||
},
|
||||
},
|
||||
{
|
||||
model: "qwen3-asr-flash-us",
|
||||
expected: {
|
||||
kind: "sync-flash",
|
||||
useSync: true,
|
||||
flashFamily: "qwen3",
|
||||
path: "/api/v1/services/aigc/multimodal-generation/generation",
|
||||
},
|
||||
},
|
||||
{
|
||||
model: "qwen-audio-3.0-asr-flash",
|
||||
expected: {
|
||||
kind: "sync-flash",
|
||||
useSync: true,
|
||||
flashFamily: "input-audio",
|
||||
},
|
||||
},
|
||||
{
|
||||
model: "qwen3-asr-flash-realtime",
|
||||
expected: {
|
||||
kind: "unsupported",
|
||||
},
|
||||
},
|
||||
{
|
||||
model: "foo-asr-flash",
|
||||
expected: {
|
||||
kind: "async-filetrans",
|
||||
useSync: false,
|
||||
path: "/api/v1/services/audio/asr/transcription",
|
||||
asyncInputStyle: "file_urls",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const { model, expected } of cases) {
|
||||
const route = resolveAsrApi(model);
|
||||
expect(route, model).toMatchObject(expected);
|
||||
if (expected.kind === "unsupported") {
|
||||
expect(route.unsupportedReason, model).toMatch(/realtime|streaming|WebSocket/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown models default to async-filetrans for backward compatibility", () => {
|
||||
expect(resolveAsrApi("custom-asr-model")).toMatchObject({
|
||||
kind: "async-filetrans",
|
||||
useSync: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("inferAudioFormatHint reads extension from url", () => {
|
||||
expect(inferAudioFormatHint("https://example.com/a.mp3")).toBe("mp3");
|
||||
expect(inferAudioFormatHint("oss://bucket/path/file.WAV")).toBe("wav");
|
||||
expect(inferAudioFormatHint("https://example.com/a.mpeg?x=1")).toBe("mp3");
|
||||
expect(inferAudioFormatHint("https://example.com/noext")).toBe("wav");
|
||||
expect(inferAudioFormatHint("data:audio/mpeg;base64,AAA")).toBe("mp3");
|
||||
expect(inferAudioFormatHint("data:audio/x-wav;base64,AAA")).toBe("wav");
|
||||
expect(inferAudioFormatHint("data:audio/ogg;codecs=opus;base64,AAA")).toBe("ogg");
|
||||
});
|
||||
|
||||
test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => {
|
||||
expect(
|
||||
buildAsrFlashRequest({
|
||||
model: "qwen3-asr-flash",
|
||||
audioUrl: "https://example.com/a.mp3",
|
||||
language: "en",
|
||||
flashFamily: "qwen3",
|
||||
}),
|
||||
).toEqual({
|
||||
model: "qwen3-asr-flash",
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ audio: "https://example.com/a.mp3" }] }],
|
||||
},
|
||||
parameters: { asr_options: { language: "en" } },
|
||||
});
|
||||
|
||||
expect(
|
||||
buildAsrFlashRequest({
|
||||
model: "qwen-audio-3.0-asr-flash",
|
||||
audioUrl: "https://example.com/a.wav",
|
||||
language: "en",
|
||||
vocabularyId: "vocab-abc",
|
||||
flashFamily: "input-audio",
|
||||
}),
|
||||
).toEqual({
|
||||
model: "qwen-audio-3.0-asr-flash",
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
format: "wav",
|
||||
sample_rate: "16000",
|
||||
language_hints: ["en"],
|
||||
vocabulary_id: "vocab-abc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("buildAsyncAsrLanguageFields maps language by async style", () => {
|
||||
expect(buildAsyncAsrLanguageFields("language_hints", "zh")).toEqual({
|
||||
language_hints: ["zh"],
|
||||
});
|
||||
expect(buildAsyncAsrLanguageFields("language", "zh")).toEqual({ language: "zh" });
|
||||
expect(buildAsyncAsrLanguageFields("language", undefined)).toEqual({});
|
||||
});
|
||||
|
||||
test("extractAsrFlashText reads qwen3 choices and input-audio text fields", () => {
|
||||
expect(
|
||||
extractAsrFlashText(
|
||||
{
|
||||
output: {
|
||||
choices: [{ message: { content: [{ text: "你好" }] } }],
|
||||
},
|
||||
},
|
||||
"qwen3",
|
||||
),
|
||||
).toBe("你好");
|
||||
|
||||
expect(
|
||||
extractAsrFlashText(
|
||||
{
|
||||
output: {
|
||||
text: "Hello World",
|
||||
output: { sentence: { text: "ignored when text present" } },
|
||||
},
|
||||
},
|
||||
"input-audio",
|
||||
),
|
||||
).toBe("Hello World");
|
||||
|
||||
expect(
|
||||
extractAsrFlashText(
|
||||
{
|
||||
output: {
|
||||
sentence: { text: "top-level sentence" },
|
||||
},
|
||||
},
|
||||
"input-audio",
|
||||
),
|
||||
).toBe("top-level sentence");
|
||||
|
||||
expect(
|
||||
extractAsrFlashText(
|
||||
{
|
||||
output: {
|
||||
output: { sentence: { text: "nested sentence" } },
|
||||
},
|
||||
},
|
||||
"input-audio",
|
||||
),
|
||||
).toBe("nested sentence");
|
||||
});
|
||||
|
||||
test("collectAsrTranscriptionItems prefers results[] then singular result", () => {
|
||||
expect(
|
||||
collectAsrTranscriptionItems({
|
||||
results: [{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }],
|
||||
}),
|
||||
).toEqual([{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }]);
|
||||
|
||||
expect(
|
||||
collectAsrTranscriptionItems({
|
||||
result: { transcription_url: "https://example.com/qwen3.json" },
|
||||
}),
|
||||
).toEqual([{ transcription_url: "https://example.com/qwen3.json", subtask_status: "SUCCEEDED" }]);
|
||||
|
||||
expect(collectAsrTranscriptionItems({})).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,736 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import type { Identity, Settings } from "../src/index.ts";
|
||||
import {
|
||||
BailianError,
|
||||
bailianMcpPath,
|
||||
bailianMcpSsePath,
|
||||
connectBailianMcpWithFallback,
|
||||
isStreamableHttpUnsupported,
|
||||
isUrlOverrideSseFallbackCandidate,
|
||||
McpClient,
|
||||
} from "../src/index.ts";
|
||||
import { McpSseClient, resolveSameOriginMessageUrl } from "../src/client/mcp-sse.ts";
|
||||
|
||||
function testDeps(overrides?: Partial<Settings>): { identity: Identity; settings: Settings } {
|
||||
return {
|
||||
identity: {
|
||||
binName: "bl",
|
||||
version: "0.0.0-test",
|
||||
npmPackage: "bailian-cli",
|
||||
clientName: "bailian-cli",
|
||||
},
|
||||
settings: {
|
||||
output: "json",
|
||||
outputExplicit: true,
|
||||
timeout: 5,
|
||||
verbose: false,
|
||||
quiet: true,
|
||||
dryRun: false,
|
||||
telemetry: true,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRpcResult(id: number | string, result: unknown): string {
|
||||
return `event:message\ndata:${JSON.stringify({ jsonrpc: "2.0", id, result })}\n\n`;
|
||||
}
|
||||
|
||||
function requestUrl(input: string | URL | Request): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.href;
|
||||
return input.url;
|
||||
}
|
||||
|
||||
test("bailianMcp 路径与 isStreamableHttpUnsupported", () => {
|
||||
expect(bailianMcpPath("WebParser")).toBe("/api/v1/mcps/WebParser/mcp");
|
||||
expect(bailianMcpSsePath("WebParser")).toBe("/api/v1/mcps/WebParser/sse");
|
||||
|
||||
expect(
|
||||
isStreamableHttpUnsupported(
|
||||
new BailianError(
|
||||
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStreamableHttpUnsupported(new BailianError("MCP request failed: 405 Method Not Allowed")),
|
||||
).toBe(true);
|
||||
expect(isStreamableHttpUnsupported(new BailianError("MCP request failed: 404 Not Found"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isStreamableHttpUnsupported(new Error("405 streamableHttp"))).toBe(false);
|
||||
// JSON-RPC business 405 must not trigger HTTP transport fallback
|
||||
expect(isStreamableHttpUnsupported(new BailianError("MCP error (405): Method Not Allowed"))).toBe(
|
||||
false,
|
||||
);
|
||||
// Nested wrapper phrase in a JSON-RPC message must not trigger fallback.
|
||||
expect(
|
||||
isStreamableHttpUnsupported(
|
||||
new BailianError("MCP error (-32000): MCP request failed: 405 Method Not Allowed"),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isUrlOverrideSseFallbackCandidate(new BailianError("MCP request failed: 404 Not Found")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isUrlOverrideSseFallbackCandidate(
|
||||
new BailianError("MCP request failed: 405 Method Not Allowed"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isUrlOverrideSseFallbackCandidate(new BailianError("MCP error (404): not found"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isUrlOverrideSseFallbackCandidate(
|
||||
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("resolveSameOriginMessageUrl:同源通过、跨域拒绝", () => {
|
||||
expect(
|
||||
resolveSameOriginMessageUrl(
|
||||
"https://example.test/api/v1/mcps/WebParser/sse",
|
||||
"/api/v1/mcps/WebParser/message?sessionId=x",
|
||||
),
|
||||
).toBe("https://example.test/api/v1/mcps/WebParser/message?sessionId=x");
|
||||
|
||||
expect(() =>
|
||||
resolveSameOriginMessageUrl(
|
||||
"https://example.test/api/v1/mcps/WebParser/sse",
|
||||
"https://evil.example/steal",
|
||||
),
|
||||
).toThrow(/origin mismatch/i);
|
||||
});
|
||||
|
||||
test("connectBailianMcpWithFallback:成功走 Streamable;405 降级 SSE", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
// Streamable success path
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
if (requestUrl(input).includes("/sse")) {
|
||||
return new Response("should not hit sse", { status: 500 });
|
||||
}
|
||||
if (body.method === "notifications/initialized") {
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: {} }), {
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const connected = await connectBailianMcpWithFallback({
|
||||
deps: testDeps(),
|
||||
authToken: "sk-test",
|
||||
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
|
||||
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
|
||||
serverCode: "WebParser",
|
||||
});
|
||||
expect(connected.url).toContain("/mcp");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
// Bare HTTP 405 (no streamableHttp body text) → SSE
|
||||
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
const urls: string[] = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
urls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
|
||||
if (url.endsWith("/mcp")) {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
statusText: "Method Not Allowed",
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith("/sse") && (init?.method ?? "GET") === "GET") {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
sseController = controller;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
"event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=test-session\n\n",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/message")) {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
queueMicrotask(() => {
|
||||
if (body.id != null && sseController) {
|
||||
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
|
||||
}
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
try {
|
||||
const connected = await connectBailianMcpWithFallback({
|
||||
deps: testDeps(),
|
||||
authToken: "sk-test",
|
||||
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
|
||||
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
|
||||
serverCode: "WebParser",
|
||||
});
|
||||
expect(connected.url).toContain("/sse");
|
||||
expect(urls.some((entry) => entry.includes("GET ") && entry.includes("/sse"))).toBe(true);
|
||||
connected.client.close?.();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("connectBailianMcpWithFallback:WebSearch 不降级;urlOverride 同 URL 降级 SSE;404 不降级 Bailian 路径", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const urls: string[] = [];
|
||||
|
||||
globalThis.fetch = async (input) => {
|
||||
urls.push(requestUrl(input));
|
||||
return new Response("current mcp not support streamableHttp", {
|
||||
status: 405,
|
||||
statusText: "Method Not Allowed",
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(
|
||||
connectBailianMcpWithFallback({
|
||||
deps: testDeps(),
|
||||
authToken: "sk-test",
|
||||
httpUrl: "https://example.test/api/v1/mcps/WebSearch/mcp",
|
||||
sseUrl: "https://example.test/api/v1/mcps/WebSearch/sse",
|
||||
serverCode: "WebSearch",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BailianError);
|
||||
expect(urls.some((url) => url.includes("/sse"))).toBe(false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
// urlOverride: after POST 405, fall back with GET SSE on the same URL
|
||||
urls.length = 0;
|
||||
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
const overrideUrl = "https://custom.example/mcp";
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
const method = init?.method ?? "GET";
|
||||
urls.push(`${method} ${url}`);
|
||||
|
||||
if (method === "POST" && url === overrideUrl) {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
statusText: "Method Not Allowed",
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "GET" && url === overrideUrl) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
sseController = controller;
|
||||
controller.enqueue(encoder.encode("event:endpoint\ndata:/message?sessionId=x\n\n"));
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/message")) {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
queueMicrotask(() => {
|
||||
if (body.id != null && sseController) {
|
||||
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
|
||||
}
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
try {
|
||||
const connected = await connectBailianMcpWithFallback({
|
||||
deps: testDeps(),
|
||||
authToken: "sk-test",
|
||||
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
|
||||
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
|
||||
serverCode: "WebParser",
|
||||
urlOverride: overrideUrl,
|
||||
});
|
||||
expect(connected.url).toBe(overrideUrl);
|
||||
expect(urls.some((entry) => entry.startsWith(`GET ${overrideUrl}`))).toBe(true);
|
||||
connected.client.close?.();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response("MCP不存在或未开通", { status: 404, statusText: "Not Found" });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
connectBailianMcpWithFallback({
|
||||
deps: testDeps(),
|
||||
authToken: "sk-test",
|
||||
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
|
||||
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
|
||||
serverCode: "WebParser",
|
||||
}),
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("404") });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:流结束后立刻失败 pending(不干等到 timeout)", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
if ((init?.method ?? "GET") === "GET" || url.endsWith("/sse")) {
|
||||
// Close the stream immediately after the endpoint event
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode("event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n"),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(
|
||||
testDeps({ timeout: 5 }),
|
||||
"https://example.test/sse",
|
||||
"sk-test",
|
||||
);
|
||||
const started = Date.now();
|
||||
await expect(client.initialize()).rejects.toThrow(/stream ended unexpectedly/i);
|
||||
expect(Date.now() - started).toBeLessThan(2000);
|
||||
client.close();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:string JSON-RPC id 可匹配;仅认 event:endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
if ((init?.method ?? "GET") === "GET" || url.endsWith("/sse")) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
sseController = controller;
|
||||
// Untyped events must not be treated as endpoint
|
||||
controller.enqueue(
|
||||
encoder.encode(`data:${JSON.stringify({ jsonrpc: "2.0", id: 99, result: {} })}\n\n`),
|
||||
);
|
||||
controller.enqueue(
|
||||
encoder.encode("event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n"),
|
||||
);
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/message")) {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
queueMicrotask(() => {
|
||||
if (body.id != null && sseController) {
|
||||
// Echo id as a string
|
||||
sseController.enqueue(encoder.encode(jsonRpcResult(String(body.id), {})));
|
||||
}
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
|
||||
await client.initialize();
|
||||
client.close();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpClient:支持 text/event-stream 响应体", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
if (body.method === "notifications/initialized") {
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
const sse = `event: message\ndata: ${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: body.id,
|
||||
result: {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
serverInfo: { name: "x", version: "0" },
|
||||
},
|
||||
})}\n\n`;
|
||||
return new Response(sse, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpClient(testDeps(), "https://example.test/mcp", "sk-test");
|
||||
await client.initialize();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient.close 可中止挂起 GET", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let aborted = false;
|
||||
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const signal = init?.signal;
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
});
|
||||
}
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
"event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
|
||||
const initPromise = client.initialize().catch(() => undefined);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
client.close();
|
||||
await initPromise;
|
||||
expect(aborted).toBe(true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:等待响应头受 --timeout 约束", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const signal = init?.signal;
|
||||
return new Promise((_resolve, reject) => {
|
||||
if (!signal) {
|
||||
reject(new Error("missing signal"));
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException("This operation was aborted.", "AbortError"));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("This operation was aborted.", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(
|
||||
testDeps({ timeout: 1 }),
|
||||
"https://example.test/sse",
|
||||
"sk-test",
|
||||
);
|
||||
const started = Date.now();
|
||||
await expect(client.initialize()).rejects.toThrow(/timed out waiting for response headers/i);
|
||||
expect(Date.now() - started).toBeLessThan(2500);
|
||||
client.close();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:非 2xx 不产生 unhandledRejection", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response("boom", { status: 500, statusText: "Internal Server Error" });
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
|
||||
await expect(client.initialize()).rejects.toThrow(/MCP request failed:\s*500/i);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(unhandled).toEqual([]);
|
||||
client.close();
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:非 2xx 读 body 仍受 --timeout 约束", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const signal = init?.signal;
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
async text() {
|
||||
return new Promise<string>((_resolve, reject) => {
|
||||
if (!signal) {
|
||||
reject(new Error("missing signal"));
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException("This operation was aborted.", "AbortError"));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("This operation was aborted.", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
} as Response;
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(
|
||||
testDeps({ timeout: 1 }),
|
||||
"https://example.test/sse",
|
||||
"sk-test",
|
||||
);
|
||||
const started = Date.now();
|
||||
await expect(client.initialize()).rejects.toThrow(/timed out reading error response body/i);
|
||||
expect(Date.now() - started).toBeLessThan(2500);
|
||||
client.close();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:fetch 失败抛出原始 TypeError(保留 ENOTFOUND)", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.test"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const fetchFailed = new TypeError("fetch failed", { cause: root });
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
throw fetchFailed;
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
|
||||
const error = await client.initialize().catch((reason: unknown) => reason);
|
||||
expect(error).toBe(fetchFailed);
|
||||
expect((error as TypeError & { cause?: NodeJS.ErrnoException }).cause?.code).toBe("ENOTFOUND");
|
||||
client.close();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:fetch 失败后同实例可重新 openSse", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let attempt = 0;
|
||||
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
const method = init?.method ?? "GET";
|
||||
if (method === "GET" || url.endsWith("/sse")) {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
throw new TypeError("fetch failed");
|
||||
}
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
sseController = controller;
|
||||
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
queueMicrotask(() => {
|
||||
if (body.id != null && sseController) {
|
||||
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
|
||||
}
|
||||
});
|
||||
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
|
||||
await expect(client.initialize()).rejects.toThrow(/fetch failed/i);
|
||||
await client.initialize();
|
||||
client.close();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:close 可中止进行中的 POST", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let postAborted = false;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
const method = init?.method ?? "GET";
|
||||
if (method === "GET" || url.endsWith("/sse")) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
const signal = init?.signal;
|
||||
return new Promise((_resolve, reject) => {
|
||||
if (!signal) {
|
||||
reject(new Error("missing signal"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
postAborted = true;
|
||||
reject(new DOMException("This operation was aborted.", "AbortError"));
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(
|
||||
testDeps({ timeout: 5 }),
|
||||
"https://example.test/sse",
|
||||
"sk-test",
|
||||
);
|
||||
const initPromise = client.initialize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
client.close();
|
||||
await expect(initPromise).rejects.toThrow(/session closed|aborted/i);
|
||||
expect(postAborted).toBe(true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("McpSseClient:POST 非 2xx 读 body 仍受 --timeout 约束", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
const method = init?.method ?? "GET";
|
||||
if (method === "GET" || url.endsWith("/sse")) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
const signal = init?.signal;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
if (!signal) return;
|
||||
const onAbort = () => {
|
||||
try {
|
||||
controller.error(new DOMException("This operation was aborted.", "AbortError"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 500, statusText: "Internal Server Error" });
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new McpSseClient(
|
||||
testDeps({ timeout: 1 }),
|
||||
"https://example.test/sse",
|
||||
"sk-test",
|
||||
);
|
||||
const started = Date.now();
|
||||
await expect(client.initialize()).rejects.toThrow(/timed out reading error response body/i);
|
||||
expect(Date.now() - started).toBeLessThan(2500);
|
||||
client.close();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { responsesPath } from "../src/index.ts";
|
||||
|
||||
describe("Responses API", () => {
|
||||
test("uses the OpenAI-compatible Responses endpoint", () => {
|
||||
expect(responsesPath()).toBe("/compatible-mode/v1/responses");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { parseSSE } from "../src/client/stream.ts";
|
||||
|
||||
async function collectEvents(
|
||||
chunks: string[],
|
||||
): Promise<Array<{ data: string; event?: string; id?: string }>> {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const response = new Response(stream, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
const events: Array<{ data: string; event?: string; id?: string }> = [];
|
||||
for await (const event of parseSSE(response)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
test("parseSSE:单 chunk 完整事件保持原行为", async () => {
|
||||
const events = await collectEvents([
|
||||
'event: message\ndata: {"ok":true}\nid: 1\n\ndata: plain\n\n',
|
||||
]);
|
||||
expect(events).toEqual([{ data: '{"ok":true}', event: "message", id: "1" }, { data: "plain" }]);
|
||||
});
|
||||
|
||||
test("parseSSE:多行 data 与注释保持原行为", async () => {
|
||||
const events = await collectEvents([": keep-alive\ndata: line1\ndata: line2\n\n"]);
|
||||
expect(events).toEqual([{ data: "line1\nline2" }]);
|
||||
});
|
||||
|
||||
test("parseSSE:跨 chunk 保留 event 类型", async () => {
|
||||
const events = await collectEvents(["event: endpoint\n", "data: /message?sessionId=abc\n\n"]);
|
||||
expect(events).toEqual([{ data: "/message?sessionId=abc", event: "endpoint" }]);
|
||||
});
|
||||
|
||||
test("parseSSE:跨 chunk 保留 id,且多事件连续正确", async () => {
|
||||
const events = await collectEvents([
|
||||
"id: a\nevent: message\n",
|
||||
'data: {"n":1}\n\n',
|
||||
"event: message\ndata: ",
|
||||
'{"n":2}\n\n',
|
||||
]);
|
||||
expect(events).toEqual([
|
||||
{ data: '{"n":1}', event: "message", id: "a" },
|
||||
{ data: '{"n":2}', event: "message" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSSE:CRLF 行尾可解析 endpoint", async () => {
|
||||
const events = await collectEvents(["event: endpoint\r\ndata: /message\r\n\r\n"]);
|
||||
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
|
||||
});
|
||||
|
||||
test("parseSSE:纯 CR 行尾可解析 endpoint", async () => {
|
||||
const events = await collectEvents(["event: endpoint\rdata: /message\r\r"]);
|
||||
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
|
||||
});
|
||||
|
||||
test("parseSSE:跨 chunk 的 CRLF(\\r|\\n)不丢事件", async () => {
|
||||
const events = await collectEvents(["event: endpoint\r", "\ndata: /message\r\n\r\n"]);
|
||||
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
|
||||
});
|
||||
|
||||
test("parseSSE:EOF without blank line keeps event type", async () => {
|
||||
const events = await collectEvents(["event: endpoint\ndata: /message"]);
|
||||
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
|
||||
});
|
||||
|
||||
test("parseSSE:EOF data-only flush keeps prior behavior", async () => {
|
||||
const events = await collectEvents(["data: plain"]);
|
||||
expect(events).toEqual([{ data: "plain" }]);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "knowledge-studio-cli",
|
||||
"version": "1.14.2",
|
||||
"version": "1.15.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.14.2",
|
||||
"version": "1.15.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": {
|
||||
|
||||
@@ -78,11 +78,12 @@ function fromFetchFailed(err: TypeError): BailianError {
|
||||
if (causeMsg && causeMsg !== code) detailParts.push(causeMsg);
|
||||
const detail = detailParts.length > 0 ? detailParts.join(": ") : "unknown cause";
|
||||
|
||||
// Prefer the errno (ENOTFOUND, …) so JSON toJSON() exposes cause.code for agents.
|
||||
return new BailianError(
|
||||
`Network request failed: ${detail}`,
|
||||
ExitCode.NETWORK,
|
||||
pickNetworkHint(code),
|
||||
{ cause: err },
|
||||
{ cause: cause ?? err },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,13 @@ export {
|
||||
// Output facilities consumed by commands
|
||||
export { emitResult, emitBare, emitRequestId } from "./output/output.ts";
|
||||
export { formatTable } from "./output/table.ts";
|
||||
export { renderBoxTable, type BoxTableOptions, type BarColumn } from "./output/box-table.ts";
|
||||
export {
|
||||
renderBoxTable,
|
||||
renderGauge,
|
||||
type BoxTableOptions,
|
||||
type BarColumn,
|
||||
type GaugeCell,
|
||||
} from "./output/box-table.ts";
|
||||
export { createSpinner, createProgressBar } from "./output/progress.ts";
|
||||
export { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
|
||||
export { maybeShowStatusBar } from "./output/status-bar.ts";
|
||||
|
||||
@@ -136,6 +136,26 @@ interface RenderedCell {
|
||||
colored: string;
|
||||
}
|
||||
|
||||
/** A standalone gauge cell (bar + label) for non-table layouts, e.g. the usage quota box. */
|
||||
export interface GaugeCell {
|
||||
plain: string;
|
||||
colored: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single gauge cell in the `usage free` table style: brand-cyan fill,
|
||||
* transparent track, and a light-blue label after the bar. `percent` is 0-100;
|
||||
* null renders an empty gauge with a neutral label.
|
||||
*/
|
||||
export function renderGauge(
|
||||
percent: number | null,
|
||||
label: string,
|
||||
width: number = DEFAULT_BAR_WIDTH,
|
||||
out: NodeJS.WriteStream = process.stdout,
|
||||
): GaugeCell {
|
||||
return buildBarCell(percent, label, width, colorLevel(out));
|
||||
}
|
||||
|
||||
function buildBarCell(
|
||||
percent: number | null,
|
||||
label: string,
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
taskPath,
|
||||
speechSynthesizePath,
|
||||
speechRecognizePath,
|
||||
resolveAsrApi,
|
||||
buildAsrFlashRequest,
|
||||
buildAsyncAsrLanguageFields,
|
||||
collectAsrTranscriptionItems,
|
||||
extractAsrFlashText,
|
||||
stripUndefined,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
type DashScopeTTSRequest,
|
||||
type DashScopeTTSResponse,
|
||||
type DashScopeASRRequest,
|
||||
type DashScopeASRTaskResult,
|
||||
type ChatMessageContent,
|
||||
isLocalFile,
|
||||
} from "bailian-cli-core";
|
||||
@@ -573,27 +579,103 @@ export async function speechRecognize(
|
||||
});
|
||||
}
|
||||
|
||||
const model = input.model || "fun-asr";
|
||||
const route = resolveAsrApi(model);
|
||||
if (route.kind === "unsupported") {
|
||||
throw new PipelineError(
|
||||
"invalid_input",
|
||||
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
|
||||
{
|
||||
step: "speech/recognize",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (route.kind === "sync-flash") {
|
||||
if (rawUrls.length !== 1) {
|
||||
throw new PipelineError(
|
||||
"invalid_input",
|
||||
`Model "${model}" is a sync Flash ASR model and accepts exactly one url (got ${rawUrls.length})`,
|
||||
{ step: "speech/recognize" },
|
||||
);
|
||||
}
|
||||
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");
|
||||
}
|
||||
if (input["channel-id"] !== undefined) unsupportedFlags.push("channel-id");
|
||||
if (unsupportedFlags.length > 0) {
|
||||
throw new PipelineError(
|
||||
"invalid_input",
|
||||
`Model "${model}" uses sync Flash ASR and does not support: ${unsupportedFlags.join(", ")}`,
|
||||
{ step: "speech/recognize" },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
route.kind === "async-filetrans" &&
|
||||
route.asyncInputStyle === "file_url" &&
|
||||
rawUrls.length !== 1
|
||||
) {
|
||||
throw new PipelineError(
|
||||
"invalid_input",
|
||||
`Model "${model}" accepts exactly one url (got ${rawUrls.length})`,
|
||||
{ step: "speech/recognize" },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve local files to upload URLs
|
||||
const fileUrls: string[] = [];
|
||||
for (const u of rawUrls) {
|
||||
if (isLocalFile(u)) {
|
||||
for (const audioUrl of rawUrls) {
|
||||
if (isLocalFile(audioUrl)) {
|
||||
fileUrls.push(
|
||||
await env.client.uploadFile(u, input.model || "fun-asr", {
|
||||
await env.client.uploadFile(audioUrl, model, {
|
||||
signal: ctx.signal,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
fileUrls.push(u);
|
||||
fileUrls.push(audioUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const model = input.model || "fun-asr";
|
||||
if (route.kind === "sync-flash") {
|
||||
const flashFamily = route.flashFamily!;
|
||||
const body = buildAsrFlashRequest({
|
||||
model,
|
||||
audioUrl: fileUrls[0]!,
|
||||
language: input.language,
|
||||
vocabularyId: input["vocabulary-id"],
|
||||
flashFamily,
|
||||
});
|
||||
const response = await env.client.requestJson<Record<string, unknown>>({
|
||||
path: route.path,
|
||||
method: "POST",
|
||||
headers: { "X-DashScope-SSE": "disable" },
|
||||
body,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
return {
|
||||
text: extractAsrFlashText(response, flashFamily),
|
||||
model,
|
||||
mode: "sync",
|
||||
raw: response,
|
||||
};
|
||||
}
|
||||
|
||||
const languageFields = buildAsyncAsrLanguageFields(
|
||||
route.asyncLanguageStyle ?? "language_hints",
|
||||
input.language,
|
||||
);
|
||||
const body: DashScopeASRRequest = {
|
||||
model,
|
||||
input: { file_urls: fileUrls },
|
||||
input:
|
||||
route.asyncInputStyle === "file_url" ? { file_url: fileUrls[0]! } : { file_urls: fileUrls },
|
||||
parameters: {
|
||||
channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined,
|
||||
language_hints: input.language ? [input.language] : undefined,
|
||||
...languageFields,
|
||||
diarization_enabled: input.diarization,
|
||||
speaker_count: input["speaker-count"],
|
||||
vocabulary_id: input["vocabulary-id"],
|
||||
@@ -601,9 +683,8 @@ export async function speechRecognize(
|
||||
};
|
||||
stripUndefined(body.parameters as Record<string, unknown>);
|
||||
|
||||
const url = speechRecognizePath();
|
||||
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
|
||||
path: url,
|
||||
path: speechRecognizePath(),
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
@@ -614,7 +695,65 @@ export async function speechRecognize(
|
||||
const pollIntervalMs = (input["poll-interval"] ?? 2) * 1000;
|
||||
const timeoutMs = (ctx.timeoutSeconds ?? 300) * 1000;
|
||||
|
||||
return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
|
||||
// ASR polling reads original output, avoids generic flatten (avoids transcription_url polluting media urls)
|
||||
const asrTask = await pollAsrTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
|
||||
const transcriptionItems = collectAsrTranscriptionItems(asrTask.output);
|
||||
|
||||
const base: Record<string, unknown> = {
|
||||
task_id: asrTask.output.task_id,
|
||||
task_status: asrTask.output.task_status,
|
||||
request_id: asrTask.request_id,
|
||||
mode: "async",
|
||||
model,
|
||||
};
|
||||
if (asrTask.output.results) base.results = asrTask.output.results;
|
||||
if (asrTask.output.result) {
|
||||
base.result = asrTask.output.result;
|
||||
if (typeof asrTask.output.result.transcription_url === "string") {
|
||||
base.transcription_url = asrTask.output.result.transcription_url;
|
||||
}
|
||||
}
|
||||
if (asrTask.output.task_metrics) base.task_metrics = asrTask.output.task_metrics;
|
||||
if (asrTask.usage) base.usage = asrTask.usage;
|
||||
|
||||
if (transcriptionItems.length === 0) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const texts: string[] = [];
|
||||
const transcripts: Record<string, unknown>[] = [];
|
||||
for (const item of transcriptionItems) {
|
||||
if (!item.transcription_url) continue;
|
||||
const transRes = await fetch(item.transcription_url, { signal: ctx.signal });
|
||||
if (!transRes.ok) {
|
||||
throw new PipelineError(
|
||||
"async_task_failed",
|
||||
`Failed to download transcription: HTTP ${transRes.status}`,
|
||||
{ step: "speech/recognize", details: { taskId, url: item.transcription_url } },
|
||||
);
|
||||
}
|
||||
const transData = (await transRes.json()) as Record<string, unknown>;
|
||||
transcripts.push(transData);
|
||||
const transcriptList = transData.transcripts as
|
||||
| Array<{ text?: string; sentences?: Array<{ text?: string }> }>
|
||||
| undefined;
|
||||
if (!transcriptList?.length) continue;
|
||||
for (const transcript of transcriptList) {
|
||||
if (transcript.sentences?.length) {
|
||||
for (const sentence of transcript.sentences) {
|
||||
if (sentence.text) texts.push(sentence.text);
|
||||
}
|
||||
} else if (transcript.text) {
|
||||
texts.push(transcript.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
text: texts.join("\n"),
|
||||
transcripts,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Shared: task polling ---
|
||||
@@ -640,7 +779,7 @@ function flattenTaskResponse(resp: DashScopeTaskResponse): Record<string, unknow
|
||||
if (urls.length > 0) flat.urls = urls;
|
||||
}
|
||||
if (output.results) {
|
||||
const urls = output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && !flat.urls) flat.urls = urls;
|
||||
}
|
||||
if (output.task_metrics) flat.task_metrics = output.task_metrics;
|
||||
@@ -658,13 +797,13 @@ async function pollTask(
|
||||
return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
|
||||
}
|
||||
|
||||
async function pollTaskWithOptions(
|
||||
async function pollUntilSucceeded(
|
||||
env: PipelineEnv,
|
||||
taskId: string,
|
||||
pollIntervalMs: number,
|
||||
timeoutMs: number,
|
||||
ctx?: StepContext,
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<DashScopeTaskResponse> {
|
||||
const started = Date.now();
|
||||
let attempt = 0;
|
||||
|
||||
@@ -687,7 +826,7 @@ async function pollTaskWithOptions(
|
||||
const status = result.output.task_status;
|
||||
|
||||
if (status === "SUCCEEDED") {
|
||||
return flattenTaskResponse(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (status === "FAILED") {
|
||||
@@ -718,6 +857,33 @@ async function pollTaskWithOptions(
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTaskWithOptions(
|
||||
env: PipelineEnv,
|
||||
taskId: string,
|
||||
pollIntervalMs: number,
|
||||
timeoutMs: number,
|
||||
ctx?: StepContext,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return flattenTaskResponse(await pollUntilSucceeded(env, taskId, pollIntervalMs, timeoutMs, ctx));
|
||||
}
|
||||
|
||||
/** ASR task polling: preserve original output (includes results[] / result.transcription_url). */
|
||||
async function pollAsrTaskWithOptions(
|
||||
env: PipelineEnv,
|
||||
taskId: string,
|
||||
pollIntervalMs: number,
|
||||
timeoutMs: number,
|
||||
ctx?: StepContext,
|
||||
): Promise<DashScopeASRTaskResult> {
|
||||
return (await pollUntilSucceeded(
|
||||
env,
|
||||
taskId,
|
||||
pollIntervalMs,
|
||||
timeoutMs,
|
||||
ctx,
|
||||
)) as DashScopeASRTaskResult;
|
||||
}
|
||||
|
||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -25,6 +25,13 @@ interface CommandNode {
|
||||
children: Map<string, CommandNode>;
|
||||
}
|
||||
|
||||
const AUTH_LABELS = {
|
||||
apiKey: "API Key",
|
||||
console: "Console",
|
||||
openapi: "AK/SK",
|
||||
none: "No Auth",
|
||||
} satisfies Record<AuthRequirement, string>;
|
||||
|
||||
/**
|
||||
* What a command path resolves to in the registry. The single judgement that
|
||||
* feeds `resolve()` — no scattered `isGroupPath` + throwing `resolve`.
|
||||
@@ -157,14 +164,37 @@ export class CommandRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
private buildResourceLines(a: (s: string) => string, d: (s: string) => string): string {
|
||||
const entries: Array<{ path: string; desc: string }> = [];
|
||||
private buildCommandLines(
|
||||
entries: Array<{ path: string; auth: AuthRequirement; desc: string }>,
|
||||
accent: (text: string) => string,
|
||||
dim: (text: string) => string,
|
||||
): string {
|
||||
const maxPathLength = Math.max(...entries.map((entry) => entry.path.length));
|
||||
const maxAuthLength = Math.max(
|
||||
...entries.map((entry) => `[${AUTH_LABELS[entry.auth]}]`.length),
|
||||
);
|
||||
const rows = entries.map((entry) => {
|
||||
const authLabel = `[${AUTH_LABELS[entry.auth]}]`;
|
||||
return ` ${accent(entry.path.padEnd(maxPathLength + 2))} ${accent(authLabel.padEnd(maxAuthLength + 2))} ${dim(entry.desc)}`;
|
||||
});
|
||||
return rows.join("\n");
|
||||
}
|
||||
|
||||
private buildResourceLines(
|
||||
accent: (text: string) => string,
|
||||
dim: (text: string) => string,
|
||||
): string {
|
||||
const entries: Array<{ path: string; auth: AuthRequirement; desc: string }> = [];
|
||||
|
||||
const collect = (node: CommandNode, prefix: string) => {
|
||||
for (const [name, child] of node.children) {
|
||||
const fullPath = prefix ? `${prefix} ${name}` : name;
|
||||
if (child.command) {
|
||||
entries.push({ path: fullPath, desc: child.command.description });
|
||||
entries.push({
|
||||
path: fullPath,
|
||||
auth: child.command.auth,
|
||||
desc: child.command.description,
|
||||
});
|
||||
}
|
||||
if (child.children.size > 0) {
|
||||
collect(child, fullPath);
|
||||
@@ -173,8 +203,7 @@ export class CommandRegistry {
|
||||
};
|
||||
collect(this.root, "");
|
||||
|
||||
const maxLen = Math.max(...entries.map((e) => e.path.length));
|
||||
return entries.map((e) => ` ${a(e.path.padEnd(maxLen + 2))} ${d(e.desc)}`).join("\n");
|
||||
return this.buildCommandLines(entries, accent, dim);
|
||||
}
|
||||
|
||||
private buildFlagLines(
|
||||
@@ -341,6 +370,7 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
|
||||
|
||||
out.write(`\n${cmd.description}\n`);
|
||||
out.write(`${b("Usage:")} ${prefix}${cmd.usageArgs ? ` ${cmd.usageArgs}` : ""}\n`);
|
||||
out.write(`${b("Authentication:")} ${a(AUTH_LABELS[cmd.auth])}\n`);
|
||||
const flagEntries = [
|
||||
...Object.entries(cmd.flags ?? {}),
|
||||
...Object.entries(credentialFlagDefs(cmd)),
|
||||
@@ -373,18 +403,25 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
|
||||
}
|
||||
|
||||
private printChildren(node: CommandNode, prefix: string, out: NodeJS.WriteStream): void {
|
||||
const entries: Array<{ fullName: string; description: string }> = [];
|
||||
const collect = (n: CommandNode, p: string) => {
|
||||
for (const [name, child] of n.children) {
|
||||
const entries: Array<{ path: string; auth: AuthRequirement; desc: string }> = [];
|
||||
const collect = (currentNode: CommandNode, currentPath: string) => {
|
||||
for (const [name, child] of currentNode.children) {
|
||||
if (child.command)
|
||||
entries.push({ fullName: `${p} ${name}`, description: child.command.description });
|
||||
if (child.children.size > 0) collect(child, `${p} ${name}`);
|
||||
entries.push({
|
||||
path: `${currentPath} ${name}`,
|
||||
auth: child.command.auth,
|
||||
desc: child.command.description,
|
||||
});
|
||||
if (child.children.size > 0) collect(child, `${currentPath} ${name}`);
|
||||
}
|
||||
};
|
||||
collect(node, prefix);
|
||||
const maxLen = Math.max(...entries.map((e) => e.fullName.length));
|
||||
for (const { fullName, description } of entries) {
|
||||
out.write(` ${this.accent(fullName.padEnd(maxLen), out)} ${this.dim(description, out)}\n`);
|
||||
}
|
||||
out.write(
|
||||
this.buildCommandLines(
|
||||
entries,
|
||||
(text) => this.accent(text, out),
|
||||
(text) => this.dim(text, out),
|
||||
) + "\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,25 @@ function errorMessage(err: unknown): string {
|
||||
return String(err);
|
||||
}
|
||||
|
||||
async function syncAgentSkillsAfterUpdate(
|
||||
dim: string,
|
||||
green: string,
|
||||
yellow: string,
|
||||
reset: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("bl skill init", { stdio: "inherit" });
|
||||
process.stderr.write(` ${green}\u2713 Agent skill updated.${reset}\n\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
` ${yellow}\u26a0 Agent skill sync failed: ${errorMessage(error)}${reset}\n`,
|
||||
);
|
||||
process.stderr.write(` ${yellow} Run manually: bl skill init${reset}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform auto-update for npm or binary installs.
|
||||
* Returns true if update succeeded, false otherwise.
|
||||
@@ -256,6 +275,7 @@ export async function performAutoUpdate(
|
||||
writeState({ lastChecked: Date.now(), latestVersion: newVer });
|
||||
process.stderr.write(` ${green}✓ Update complete: ${currentVersion} → ${newVer}${reset}\n`);
|
||||
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
|
||||
await syncAgentSkillsAfterUpdate(dim, green, yellow, reset);
|
||||
pendingNotification = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -297,16 +317,7 @@ export async function performAutoUpdate(
|
||||
);
|
||||
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
|
||||
|
||||
try {
|
||||
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
|
||||
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
|
||||
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
|
||||
} catch (err) {
|
||||
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
|
||||
process.stderr.write(
|
||||
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
|
||||
);
|
||||
}
|
||||
await syncAgentSkillsAfterUpdate(dim, green, yellow, reset);
|
||||
|
||||
pendingNotification = null;
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ExitCode } from "bailian-cli-core";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { handleError } from "../src/error-handler.ts";
|
||||
|
||||
test("handleError: fetch failed JSON includes cause.code from errno", () => {
|
||||
const previousOutput = process.env.DASHSCOPE_OUTPUT;
|
||||
process.env.DASHSCOPE_OUTPUT = "json";
|
||||
|
||||
let stderr = "";
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
const originalExit = process.exit;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as typeof process.exit;
|
||||
|
||||
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const fetchFailed = new TypeError("fetch failed", { cause: root });
|
||||
|
||||
try {
|
||||
expect(() => handleError(fetchFailed, "bl")).toThrow(
|
||||
new RegExp(`process\\.exit:${ExitCode.NETWORK}`),
|
||||
);
|
||||
const payload = JSON.parse(stderr.trim()) as {
|
||||
error: { code: number; message: string; cause?: { message: string; code?: string } };
|
||||
};
|
||||
expect(payload.error.code).toBe(ExitCode.NETWORK);
|
||||
expect(payload.error.message).toMatch(/ENOTFOUND/);
|
||||
expect(payload.error.cause).toEqual({
|
||||
message: root.message,
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
process.exit = originalExit;
|
||||
if (previousOutput === undefined) {
|
||||
delete process.env.DASHSCOPE_OUTPUT;
|
||||
} else {
|
||||
process.env.DASHSCOPE_OUTPUT = previousOutput;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("handleError: fetch failed without nested cause still maps to NETWORK", () => {
|
||||
const previousOutput = process.env.DASHSCOPE_OUTPUT;
|
||||
process.env.DASHSCOPE_OUTPUT = "json";
|
||||
|
||||
let stderr = "";
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
const originalExit = process.exit;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as typeof process.exit;
|
||||
|
||||
const fetchFailed = new TypeError("fetch failed");
|
||||
|
||||
try {
|
||||
expect(() => handleError(fetchFailed, "bl")).toThrow(
|
||||
new RegExp(`process\\.exit:${ExitCode.NETWORK}`),
|
||||
);
|
||||
const payload = JSON.parse(stderr.trim()) as {
|
||||
error: { code: number; message: string; cause?: { message: string; code?: string } };
|
||||
};
|
||||
expect(payload.error.code).toBe(ExitCode.NETWORK);
|
||||
expect(payload.error.message).toMatch(/unknown cause/);
|
||||
expect(payload.error.cause).toEqual({ message: "fetch failed" });
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
process.exit = originalExit;
|
||||
if (previousOutput === undefined) {
|
||||
delete process.env.DASHSCOPE_OUTPUT;
|
||||
} else {
|
||||
process.env.DASHSCOPE_OUTPUT = previousOutput;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import type { Client } from "bailian-cli-core";
|
||||
import { PipelineError } from "../src/pipeline/errors.ts";
|
||||
import type { PipelineEnv } from "../src/pipeline/bl-config.ts";
|
||||
import { speechRecognize } from "../src/pipeline/steps/bl-api.ts";
|
||||
import type { StepContext } from "../src/pipeline/types.ts";
|
||||
|
||||
type CapturedRequest = {
|
||||
path?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
async?: boolean;
|
||||
};
|
||||
|
||||
function makeEnv(requestJsonImpl?: (opts: CapturedRequest) => Promise<unknown>): {
|
||||
env: PipelineEnv;
|
||||
captured: CapturedRequest[];
|
||||
} {
|
||||
const captured: CapturedRequest[] = [];
|
||||
const client = {
|
||||
uploadFile: async (source: string) => source,
|
||||
requestJson: async (opts: CapturedRequest) => {
|
||||
captured.push(opts);
|
||||
if (requestJsonImpl) return requestJsonImpl(opts);
|
||||
return { output: { text: "ok" } };
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
return {
|
||||
env: {
|
||||
client,
|
||||
settings: { quiet: true, output: "json" } as PipelineEnv["settings"],
|
||||
},
|
||||
captured,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(): StepContext {
|
||||
return { dryRun: false, signal: new AbortController().signal };
|
||||
}
|
||||
|
||||
test("pipeline speechRecognize routes input-audio flash to sync multimodal endpoint", async () => {
|
||||
const { env, captured } = makeEnv();
|
||||
const result = (await speechRecognize(
|
||||
env,
|
||||
{
|
||||
url: "https://example.com/a.wav",
|
||||
model: "qwen-audio-3.0-asr-flash",
|
||||
language: "en",
|
||||
"vocabulary-id": "vocab-1",
|
||||
},
|
||||
makeCtx(),
|
||||
)) as { mode?: string; text?: string };
|
||||
|
||||
expect(result.mode).toBe("sync");
|
||||
expect(result.text).toBe("ok");
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0]?.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
|
||||
expect(captured[0]?.headers?.["X-DashScope-SSE"]).toBe("disable");
|
||||
expect(captured[0]?.body).toMatchObject({
|
||||
model: "qwen-audio-3.0-asr-flash",
|
||||
parameters: {
|
||||
format: "wav",
|
||||
language_hints: ["en"],
|
||||
vocabulary_id: "vocab-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", 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: "qwen3-asr-flash-filetrans",
|
||||
language: "zh",
|
||||
"poll-interval": 0,
|
||||
},
|
||||
makeCtx(),
|
||||
);
|
||||
|
||||
expect(captured[0]?.path).toBe("/api/v1/services/audio/asr/transcription");
|
||||
expect(captured[0]?.async).toBe(true);
|
||||
expect(captured[0]?.body).toMatchObject({
|
||||
model: "qwen3-asr-flash-filetrans",
|
||||
input: { file_url: "https://example.com/a.wav" },
|
||||
parameters: { language: "zh" },
|
||||
});
|
||||
expect(
|
||||
(captured[0]?.body?.parameters as Record<string, unknown> | undefined)?.language_hints,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("pipeline speechRecognize rejects realtime models before requesting", async () => {
|
||||
const { env, captured } = makeEnv();
|
||||
await expect(
|
||||
speechRecognize(
|
||||
env,
|
||||
{ url: "https://example.com/a.wav", model: "qwen3-asr-flash-realtime" },
|
||||
makeCtx(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(PipelineError);
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("pipeline speechRecognize rejects multiple urls for sync flash", async () => {
|
||||
const { env, captured } = makeEnv();
|
||||
await expect(
|
||||
speechRecognize(
|
||||
env,
|
||||
{
|
||||
url: ["https://example.com/a.wav", "https://example.com/b.wav"],
|
||||
model: "fun-asr-flash-2026-06-15",
|
||||
},
|
||||
makeCtx(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(PipelineError);
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("pipeline speechRecognize downloads qwen3 singular result.transcription_url", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const transcriptionUrl = "https://example.com/transcription.json";
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
expect(url).toBe(transcriptionUrl);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
transcripts: [{ text: "pipeline hello", sentences: [{ text: "pipeline hello" }] }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
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",
|
||||
result: { transcription_url: transcriptionUrl },
|
||||
},
|
||||
request_id: "r1",
|
||||
};
|
||||
});
|
||||
|
||||
const result = (await speechRecognize(
|
||||
env,
|
||||
{
|
||||
url: "https://example.com/a.wav",
|
||||
model: "qwen3-asr-flash-filetrans",
|
||||
"poll-interval": 0,
|
||||
},
|
||||
makeCtx(),
|
||||
)) as {
|
||||
mode?: string;
|
||||
text?: string;
|
||||
transcription_url?: string;
|
||||
result?: { transcription_url?: string };
|
||||
};
|
||||
|
||||
expect(captured[0]?.async).toBe(true);
|
||||
expect(result.mode).toBe("async");
|
||||
expect(result.text).toBe("pipeline hello");
|
||||
expect(result.transcription_url).toBe(transcriptionUrl);
|
||||
expect(result.result?.transcription_url).toBe(transcriptionUrl);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -1,13 +1,58 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const binaryUpdateMocks = vi.hoisted(() => ({
|
||||
performBinaryUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
const childProcessMocks = vi.hoisted(() => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/binary-update.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/utils/binary-update.ts")>();
|
||||
return { ...actual, performBinaryUpdate: binaryUpdateMocks.performBinaryUpdate };
|
||||
});
|
||||
|
||||
vi.mock("child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("child_process")>();
|
||||
return { ...actual, execSync: childProcessMocks.execSync };
|
||||
});
|
||||
|
||||
import {
|
||||
compareVersion,
|
||||
isMajorUpgrade,
|
||||
isNewerVersion,
|
||||
isPrerelease,
|
||||
parseVersion,
|
||||
performAutoUpdate,
|
||||
shouldAutoUpdate,
|
||||
} from "../src/utils/update-checker.ts";
|
||||
|
||||
let configDir: string;
|
||||
let previousConfigDir: string | undefined;
|
||||
let previousInstallMethod: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
configDir = mkdtempSync(join(tmpdir(), "bl-auto-update-binary-"));
|
||||
previousConfigDir = process.env.BAILIAN_CONFIG_DIR;
|
||||
previousInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
|
||||
process.env.BAILIAN_CONFIG_DIR = configDir;
|
||||
process.env.BAILIAN_INSTALL_METHOD = "binary";
|
||||
binaryUpdateMocks.performBinaryUpdate.mockResolvedValue("2.0.0");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = previousConfigDir;
|
||||
if (previousInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = previousInstallMethod;
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("parseVersion strips pre-release and build metadata", () => {
|
||||
expect(parseVersion("1.4.2")).toEqual([1, 4, 2]);
|
||||
expect(parseVersion("2.0.0-beta.1")).toEqual([2, 0, 0]);
|
||||
@@ -124,3 +169,11 @@ test("shouldAutoUpdate only targets stable releases with a significant gap", ()
|
||||
// Same core, release over its pre-release: notify only (no major gap).
|
||||
expect(shouldAutoUpdate("1.4.2", "1.4.2-beta.1")).toBe(false);
|
||||
});
|
||||
|
||||
test("binary auto-update syncs bailian skills after the CLI update succeeds", async () => {
|
||||
const updated = await performAutoUpdate("1.14.3", "2.0.0");
|
||||
|
||||
expect(updated).toBe(true);
|
||||
expect(binaryUpdateMocks.performBinaryUpdate).toHaveBeenCalledWith("2.0.0");
|
||||
expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill init", { stdio: "inherit" });
|
||||
});
|
||||
|
||||
Generated
+3
-3
@@ -24,12 +24,12 @@ catalogs:
|
||||
chalk:
|
||||
specifier: ^5.6.2
|
||||
version: 5.6.2
|
||||
tar-stream:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
smol-toml:
|
||||
specifier: ^1.4.2
|
||||
version: 1.7.0
|
||||
tar-stream:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
tsx:
|
||||
specifier: ^4.23.0
|
||||
version: 4.23.0
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
Agent skill for **Alibaba Cloud Model Studio CLI** (`bl`) resource hub — apps, memory, RAG, usage/quota, MCP, and hub `reference/`.
|
||||
|
||||
- Shared protocol: `bailian-protocol` (install via `--all -g`)
|
||||
- Shared protocol: `bailian-protocol` (install via `bl skill init`)
|
||||
- Soft hand-offs (optional skills): `bailian-gen` · `bailian-finetune` · `bailian-managed-agent`
|
||||
|
||||
```bash
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
bl skill init
|
||||
```
|
||||
|
||||
For CLI installation, authentication, and examples, see the [main README](../../README.md).
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
**阿里云百炼 CLI**(`bl`)的资源管理 Agent 技能 — 应用、记忆、RAG、用量/额度、MCP,以及 hub `reference/`。
|
||||
|
||||
- 共享协议:`bailian-protocol`(通过 `--all -g` 与整家族同装)
|
||||
- 共享协议:`bailian-protocol`(通过 `bl skill init` 与整家族同装)
|
||||
- 软 hand-off(可选):`bailian-gen` · `bailian-finetune` · `bailian-managed-agent`
|
||||
|
||||
```bash
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
bl skill init
|
||||
```
|
||||
|
||||
CLI 的安装、认证和使用示例请查看[主 README](../../README.zh.md)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-cli
|
||||
metadata:
|
||||
version: "1.14.2"
|
||||
version: "1.15.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
@@ -10,7 +10,7 @@ description: >-
|
||||
工作空间、MCP 市场、pipeline、文件上传、console API、登录鉴权与配置、
|
||||
Agent skill 安装/列表/更新/卸载(bl skill add|list|update|remove,百炼 skill registry)。
|
||||
用户点名百炼 / DashScope / `bl`,或继续既有 `bl` 工作流时直接使用。
|
||||
共享协议(consent / 版本预检 / 鉴权 / 错误上报)在 bailian-protocol;官方安装 `npx skills add modelstudioai/cli --all -g`。
|
||||
共享协议(consent / 版本预检 / 鉴权 / 错误上报)在 bailian-protocol;官方安装 `bl skill init`。
|
||||
家族路由:生图/生视频/配音/语音合成/转写 → bailian-gen;精调/微调/训练/数据集 → bailian-finetune;
|
||||
agents.yaml 托管 Agent → bailian-managed-agent。
|
||||
不要用于普通问答、编程、写作、翻译、摘要、泛搜索,或图片理解等宿主自己能做的任务(普通问答、编程、写作、翻译、摘要、泛搜索不触发)。
|
||||
@@ -19,14 +19,14 @@ description: >-
|
||||
|
||||
# Aliyun Model Studio CLI (`bl`)
|
||||
|
||||
**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Provider selection and consent, Version & updates (pre-flight checklist), Setup & auth, and CLI errors: report an issue. If that protocol file is missing, stop and run `npx skills add modelstudioai/cli --all -g`; do not guess auth/consent.**
|
||||
**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Provider selection and consent, Version & updates (pre-flight checklist), Setup & auth, and CLI errors: report an issue. If that protocol file is missing, stop and run `bl skill init`; do not guess auth/consent.**
|
||||
|
||||
> **Family hub** — This skill owns Bailian resource commands and the hub `reference/` (apps, knowledge, usage, auth, config, …).
|
||||
> Shared protocol → [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md) (install the full family with `--all -g`).
|
||||
> Soft hand-offs by skill name (Read if installed; else `bl … --help` / prompt `npx skills add modelstudioai/cli --all -g`): `bailian-gen` (media) · `bailian-finetune` (training) · `bailian-managed-agent` (agents.yaml IaC).
|
||||
> Shared protocol → [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md) (install the full family with `bl skill init`).
|
||||
> Soft hand-offs by skill name (Read if installed; else `bl … --help` / prompt `bl skill init`): `bailian-gen` (media) · `bailian-finetune` (training) · `bailian-managed-agent` (agents.yaml IaC).
|
||||
> Do not invoke it for ordinary reasoning, coding, writing, translation, summarization, generic research, or image understanding the host agent can complete directly.
|
||||
>
|
||||
> **Install (supported):** `npx skills add modelstudioai/cli --all -g`
|
||||
> **Install (supported):** `bl skill init`
|
||||
|
||||
## Command reference (authoritative)
|
||||
|
||||
@@ -71,6 +71,8 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian-
|
||||
| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions |
|
||||
| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed |
|
||||
| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed |
|
||||
| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed |
|
||||
| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed |
|
||||
| Console API (advanced) | `bl console call` | Console auth |
|
||||
| Bailian workspace listing | `bl workspace list` | Console auth |
|
||||
| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` |
|
||||
@@ -113,7 +115,7 @@ schema-export commands.
|
||||
|
||||
## Routing reminders
|
||||
|
||||
- Image/video/audio generation or editing → skill `bailian-gen` (class 3 consent from `bailian-protocol`). Fine-tuning / datasets / deployments → `bailian-finetune`. agents.yaml IaC → `bailian-managed-agent`. Soft hand-off: Read sibling skill if installed; else `bl … --help` or prompt `npx skills add modelstudioai/cli --all -g`. Image understanding the host agent can do → host-first; use `bl vision` / `bl omni` only when the user names a Bailian model or the media (video/audio files) exceeds host capability.
|
||||
- Image/video/audio generation or editing → skill `bailian-gen` (class 3 consent from `bailian-protocol`). Fine-tuning / datasets / deployments → `bailian-finetune`. agents.yaml IaC → `bailian-managed-agent`. Soft hand-off: Read sibling skill if installed; else `bl … --help` or prompt `bl skill init`. Image understanding the host agent can do → host-first; use `bl vision` / `bl omni` only when the user names a Bailian model or the media (video/audio files) exceeds host capability.
|
||||
- Answer ordinary reasoning, coding, writing, translation, summarization, and generic research with the host agent's native capabilities; do not bounce them through `bl text chat` or `bl search web`.
|
||||
- Usage / quota / credits questions that do not name a product → ask which product (Bailian or another AI service) first; run `bl usage` / `bl quota` only after the user picks Bailian or Bailian context is already established.
|
||||
- "Remember this" and memory requests default to the host agent's own memory; `bl memory *` is only for Bailian app memory resources.
|
||||
|
||||
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
|
||||
| Command | Authentication | Description |
|
||||
| ---------------------- | -------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `bl advisor recommend` | API Key | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl advisor recommend`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `advisor recommend` |
|
||||
| **Description** | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
|
||||
| **Usage** | `bl advisor recommend --message <text> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `advisor recommend` |
|
||||
| **Description** | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl advisor recommend --message <text> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,20 +7,21 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ------------- | ---------------------------------------------- |
|
||||
| `bl app call` | Call a Bailian application (agent or workflow) |
|
||||
| `bl app list` | List Bailian applications |
|
||||
| Command | Authentication | Description |
|
||||
| ------------- | -------------- | ---------------------------------------------- |
|
||||
| `bl app call` | API Key | Call a Bailian application (agent or workflow) |
|
||||
| `bl app list` | Console | List Bailian applications |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl app call`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------------------------- |
|
||||
| **Name** | `app call` |
|
||||
| **Description** | Call a Bailian application (agent or workflow) |
|
||||
| **Usage** | `bl app call --app-id <id> --prompt <text> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| **Name** | `app call` |
|
||||
| **Description** | Call a Bailian application (agent or workflow) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl app call --app-id <id> --prompt <text> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -67,11 +68,12 @@ bl app call --app-id abc123 --prompt "Start" --biz-params '{"key":"value"}'
|
||||
|
||||
### `bl app list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------- |
|
||||
| **Name** | `app list` |
|
||||
| **Description** | List Bailian applications |
|
||||
| **Usage** | `bl app list [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------- |
|
||||
| **Name** | `app list` |
|
||||
| **Description** | List Bailian applications |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl app list [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,22 +7,23 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK |
|
||||
| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) |
|
||||
| `bl auth logout` | Clear stored credentials; full logout also clears the model Base URL |
|
||||
| `bl auth status` | Show current authentication state |
|
||||
| Command | Authentication | Description |
|
||||
| ------------------------------- | -------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `bl auth generate-access-token` | No Auth | Generate a CLI access token using OpenAPI AK/SK |
|
||||
| `bl auth login` | No Auth | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) |
|
||||
| `bl auth logout` | No Auth | Clear stored credentials; full logout also clears the model Base URL |
|
||||
| `bl auth status` | No Auth | Show current authentication state |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl auth generate-access-token`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `auth generate-access-token` |
|
||||
| **Description** | Generate a CLI access token using OpenAPI AK/SK |
|
||||
| **Usage** | `bl auth generate-access-token --access-key-id <id> --access-key-secret <secret> --security-token <token>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `auth generate-access-token` |
|
||||
| **Description** | Generate a CLI access token using OpenAPI AK/SK |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl auth generate-access-token --access-key-id <id> --access-key-secret <secret> --security-token <token>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -40,11 +41,12 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx
|
||||
|
||||
### `bl auth login`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Name** | `auth login` |
|
||||
| **Description** | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) |
|
||||
| **Usage** | `bl auth login --api-key <key> \| --console \| --open-api --access-key-id <id> --access-key-secret <secret>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Name** | `auth login` |
|
||||
| **Description** | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl auth login --api-key <key> \| --console \| --open-api --access-key-id <id> --access-key-secret <secret>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -78,11 +80,12 @@ bl auth login --open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx
|
||||
|
||||
### `bl auth logout`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------------------------- |
|
||||
| **Name** | `auth logout` |
|
||||
| **Description** | Clear stored credentials; full logout also clears the model Base URL |
|
||||
| **Usage** | `bl auth logout [--console \| --open-api] [--dry-run]` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------------------- |
|
||||
| **Name** | `auth logout` |
|
||||
| **Description** | Clear stored credentials; full logout also clears the model Base URL |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl auth logout [--console \| --open-api] [--dry-run]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -111,11 +114,12 @@ bl auth logout --dry-run
|
||||
|
||||
### `bl auth status`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------- |
|
||||
| **Name** | `auth status` |
|
||||
| **Description** | Show current authentication state |
|
||||
| **Usage** | `bl auth status` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------- |
|
||||
| **Name** | `auth status` |
|
||||
| **Description** | Show current authentication state |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl auth status` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,24 +7,25 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ------------------------------------------------ |
|
||||
| `bl config agent` | Configure a coding agent to use DashScope API |
|
||||
| `bl config list` | List config profiles and show the active profile |
|
||||
| `bl config set` | Set a config value |
|
||||
| `bl config show` | Display current configuration |
|
||||
| `bl config ui` | Open a local web UI to manage config profiles |
|
||||
| `bl config use` | Set the active config profile |
|
||||
| Command | Authentication | Description |
|
||||
| ----------------- | -------------- | ------------------------------------------------ |
|
||||
| `bl config agent` | No Auth | Configure a coding agent to use DashScope API |
|
||||
| `bl config list` | No Auth | List config profiles and show the active profile |
|
||||
| `bl config set` | No Auth | Set a config value |
|
||||
| `bl config show` | No Auth | Display current configuration |
|
||||
| `bl config ui` | No Auth | Open a local web UI to manage config profiles |
|
||||
| `bl config use` | No Auth | Set the active config profile |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl config agent`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `config agent` |
|
||||
| **Description** | Configure a coding agent to use DashScope API |
|
||||
| **Usage** | `bl config agent --agent <name> (--base-url <url> \| --region <region>) (--api-key <key> \| --key <encoded>) --model <model>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `config agent` |
|
||||
| **Description** | Configure a coding agent to use DashScope API |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl config agent --agent <name> (--base-url <url> \| --region <region>) (--api-key <key> \| --key <encoded>) --model <model>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -55,11 +56,12 @@ bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatib
|
||||
|
||||
### `bl config list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------ |
|
||||
| **Name** | `config list` |
|
||||
| **Description** | List config profiles and show the active profile |
|
||||
| **Usage** | `bl config list` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------ |
|
||||
| **Name** | `config list` |
|
||||
| **Description** | List config profiles and show the active profile |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl config list` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -77,11 +79,12 @@ bl config list --output json
|
||||
|
||||
### `bl config set`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------- |
|
||||
| **Name** | `config set` |
|
||||
| **Description** | Set a config value |
|
||||
| **Usage** | `bl config set --key <key> --value <value>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------- |
|
||||
| **Name** | `config set` |
|
||||
| **Description** | Set a config value |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl config set --key <key> --value <value>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -106,11 +109,12 @@ bl config set --key base_url --value https://dashscope.aliyuncs.com
|
||||
|
||||
### `bl config show`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------- |
|
||||
| **Name** | `config show` |
|
||||
| **Description** | Display current configuration |
|
||||
| **Usage** | `bl config show` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------- |
|
||||
| **Name** | `config show` |
|
||||
| **Description** | Display current configuration |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl config show` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -128,11 +132,12 @@ bl config show --output json
|
||||
|
||||
### `bl config ui`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| **Name** | `config ui` |
|
||||
| **Description** | Open a local web UI to manage config profiles |
|
||||
| **Usage** | `bl config ui [--port <port>] [--no-open]` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------------------- |
|
||||
| **Name** | `config ui` |
|
||||
| **Description** | Open a local web UI to manage config profiles |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl config ui [--port <port>] [--no-open]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -157,11 +162,12 @@ bl config ui --no-open
|
||||
|
||||
### `bl config use`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------- |
|
||||
| **Name** | `config use` |
|
||||
| **Description** | Set the active config profile |
|
||||
| **Usage** | `bl config use --name <name>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------- |
|
||||
| **Name** | `config use` |
|
||||
| **Description** | Set the active config profile |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl config use --name <name>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ---------------------------------------------- |
|
||||
| `bl console call` | Call a Bailian console API via the CLI gateway |
|
||||
| Command | Authentication | Description |
|
||||
| ----------------- | -------------- | ---------------------------------------------- |
|
||||
| `bl console call` | Console | Call a Bailian console API via the CLI gateway |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl console call`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------------------------- |
|
||||
| **Name** | `console call` |
|
||||
| **Description** | Call a Bailian console API via the CLI gateway |
|
||||
| **Usage** | `bl console call --api <api> --data <json> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| **Name** | `console call` |
|
||||
| **Description** | Call a Bailian console API via the CLI gateway |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl console call --api <api> --data <json> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -35,7 +36,7 @@ Index: [index.md](index.md)
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl console call --api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'
|
||||
bl console call --api zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------------- |
|
||||
| `bl file upload` | Upload a local file to DashScope temporary storage (48h) |
|
||||
| Command | Authentication | Description |
|
||||
| ---------------- | -------------- | -------------------------------------------------------- |
|
||||
| `bl file upload` | API Key | Upload a local file to DashScope temporary storage (48h) |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl file upload`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| **Name** | `file upload` |
|
||||
| **Description** | Upload a local file to DashScope temporary storage (48h) |
|
||||
| **Usage** | `bl file upload --file <path> --model <model>` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------- |
|
||||
| **Name** | `file upload` |
|
||||
| **Description** | Upload a local file to DashScope temporary storage (48h) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl file upload --file <path> --model <model>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -9,65 +9,67 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
|
||||
## Quick index
|
||||
|
||||
| Command | Description | Detail |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) |
|
||||
| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) |
|
||||
| `bl app list` | List Bailian applications | [app.md](app.md) |
|
||||
| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) |
|
||||
| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) |
|
||||
| `bl auth logout` | Clear stored credentials; full logout also clears the model Base URL | [auth.md](auth.md) |
|
||||
| `bl auth status` | Show current authentication state | [auth.md](auth.md) |
|
||||
| `bl config agent` | Configure a coding agent to use DashScope API | [config.md](config.md) |
|
||||
| `bl config list` | List config profiles and show the active profile | [config.md](config.md) |
|
||||
| `bl config set` | Set a config value | [config.md](config.md) |
|
||||
| `bl config show` | Display current configuration | [config.md](config.md) |
|
||||
| `bl config ui` | Open a local web UI to manage config profiles | [config.md](config.md) |
|
||||
| `bl config use` | Set the active config profile | [config.md](config.md) |
|
||||
| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) |
|
||||
| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) |
|
||||
| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) |
|
||||
| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
|
||||
| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
|
||||
| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
|
||||
| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) |
|
||||
| `bl memory delete` | Delete a memory node | [memory.md](memory.md) |
|
||||
| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) |
|
||||
| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) |
|
||||
| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) |
|
||||
| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) |
|
||||
| `bl memory update` | Update a memory node content | [memory.md](memory.md) |
|
||||
| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
|
||||
| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
|
||||
| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
|
||||
| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
|
||||
| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
|
||||
| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) |
|
||||
| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) |
|
||||
| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) |
|
||||
| `bl quota history` | View quota change history | [quota.md](quota.md) |
|
||||
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
|
||||
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
|
||||
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
|
||||
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
|
||||
| `bl skill init` | Install all bailian-\* skills (one-shot bootstrap for new environments) | [skill.md](skill.md) |
|
||||
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
|
||||
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
|
||||
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
|
||||
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
|
||||
| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
|
||||
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
|
||||
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
|
||||
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
|
||||
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
|
||||
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
|
||||
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
|
||||
| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) |
|
||||
| Command | Authentication | Description | Detail |
|
||||
| ------------------------------- | -------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `bl advisor recommend` | API Key | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) |
|
||||
| `bl app call` | API Key | Call a Bailian application (agent or workflow) | [app.md](app.md) |
|
||||
| `bl app list` | Console | List Bailian applications | [app.md](app.md) |
|
||||
| `bl auth generate-access-token` | No Auth | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) |
|
||||
| `bl auth login` | No Auth | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) |
|
||||
| `bl auth logout` | No Auth | Clear stored credentials; full logout also clears the model Base URL | [auth.md](auth.md) |
|
||||
| `bl auth status` | No Auth | Show current authentication state | [auth.md](auth.md) |
|
||||
| `bl config agent` | No Auth | Configure a coding agent to use DashScope API | [config.md](config.md) |
|
||||
| `bl config list` | No Auth | List config profiles and show the active profile | [config.md](config.md) |
|
||||
| `bl config set` | No Auth | Set a config value | [config.md](config.md) |
|
||||
| `bl config show` | No Auth | Display current configuration | [config.md](config.md) |
|
||||
| `bl config ui` | No Auth | Open a local web UI to manage config profiles | [config.md](config.md) |
|
||||
| `bl config use` | No Auth | Set the active config profile | [config.md](config.md) |
|
||||
| `bl console call` | Console | Call a Bailian console API via the CLI gateway | [console.md](console.md) |
|
||||
| `bl file upload` | API Key | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) |
|
||||
| `bl knowledge chat` | API Key | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge retrieve` | API Key | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |
|
||||
| `bl knowledge search` | API Key | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) |
|
||||
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
|
||||
| `bl mcp list` | Console | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
|
||||
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
|
||||
| `bl memory add` | API Key | Add memory from messages or custom content | [memory.md](memory.md) |
|
||||
| `bl memory delete` | API Key | Delete a memory node | [memory.md](memory.md) |
|
||||
| `bl memory list` | API Key | List memory nodes for a user | [memory.md](memory.md) |
|
||||
| `bl memory profile create` | API Key | Create a user profile schema for memory profiling | [memory.md](memory.md) |
|
||||
| `bl memory profile get` | API Key | Get user profile by schema ID and user ID | [memory.md](memory.md) |
|
||||
| `bl memory search` | API Key | Search memory nodes by query or messages | [memory.md](memory.md) |
|
||||
| `bl memory update` | API Key | Update a memory node content | [memory.md](memory.md) |
|
||||
| `bl model list` | Console | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
|
||||
| `bl pipeline run` | No Auth | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
|
||||
| `bl pipeline validate` | No Auth | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
|
||||
| `bl plugin install` | No Auth | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
|
||||
| `bl plugin link` | No Auth | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
|
||||
| `bl plugin list` | No Auth | List installed Command Packs and their load status | [plugin.md](plugin.md) |
|
||||
| `bl plugin remove` | No Auth | Remove an installed Command Pack | [plugin.md](plugin.md) |
|
||||
| `bl quota check` | Console | Check current usage against rate limits | [quota.md](quota.md) |
|
||||
| `bl quota history` | Console | View quota change history | [quota.md](quota.md) |
|
||||
| `bl quota list` | Console | View model RPM/TPM rate limits | [quota.md](quota.md) |
|
||||
| `bl quota request` | Console | Request a temporary quota increase | [quota.md](quota.md) |
|
||||
| `bl search web` | API Key | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
|
||||
| `bl skill add` | No Auth | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
|
||||
| `bl skill init` | No Auth | Install all bailian-\* skills (one-shot bootstrap for new environments) | [skill.md](skill.md) |
|
||||
| `bl skill list` | No Auth | List registry skills and diff against local installs | [skill.md](skill.md) |
|
||||
| `bl skill remove` | No Auth | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
|
||||
| `bl skill update` | No Auth | Update installed skills to the latest registry versions | [skill.md](skill.md) |
|
||||
| `bl text chat` | API Key | Send a text model request (OpenAI compatible, DashScope) | [text.md](text.md) |
|
||||
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
|
||||
| `bl update` | No Auth | Update the CLI to the latest or a specified version | [update.md](update.md) |
|
||||
| `bl usage coding-plan` | Console | Show Coding Plan quota usage | [usage.md](usage.md) |
|
||||
| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
|
||||
| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
|
||||
| `bl usage stats` | Console | Query model usage statistics | [usage.md](usage.md) |
|
||||
| `bl usage summary` | Console | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
|
||||
| `bl usage token-plan` | Console | Show Token Plan quota usage | [usage.md](usage.md) |
|
||||
| `bl workspace init` | No Auth | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
|
||||
| `bl workspace list` | Console | List all workspaces | [workspace.md](workspace.md) |
|
||||
|
||||
## By group
|
||||
|
||||
@@ -91,7 +93,7 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
| `text` | `chat` | [text.md](text.md) |
|
||||
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
|
||||
| `update` | `(root)` | [update.md](update.md) |
|
||||
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
|
||||
| `usage` | `coding-plan`, `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) |
|
||||
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
|
||||
|
||||
## Global flags
|
||||
|
||||
@@ -7,21 +7,22 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------------- |
|
||||
| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) |
|
||||
| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) |
|
||||
| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) |
|
||||
| Command | Authentication | Description |
|
||||
| ----------------------- | -------------- | ------------------------------------------------------------------------- |
|
||||
| `bl knowledge chat` | API Key | Chat with a Bailian knowledge base (RAG Q&A with streaming) |
|
||||
| `bl knowledge retrieve` | API Key | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) |
|
||||
| `bl knowledge search` | API Key | Search a Bailian knowledge base (RAG semantic retrieval) |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl knowledge chat`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------ |
|
||||
| **Name** | `knowledge chat` |
|
||||
| **Description** | Chat with a Bailian knowledge base (RAG Q&A with streaming) |
|
||||
| **Usage** | `bl knowledge chat --message <text> --agent-id <id> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------ |
|
||||
| **Name** | `knowledge chat` |
|
||||
| **Description** | Chat with a Bailian knowledge base (RAG Q&A with streaming) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl knowledge chat --message <text> --agent-id <id> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -57,11 +58,12 @@ bl knowledge chat --message "Describe these images" --image https://example.com/
|
||||
|
||||
### `bl knowledge retrieve`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------- |
|
||||
| **Name** | `knowledge retrieve` |
|
||||
| **Description** | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) |
|
||||
| **Usage** | `bl knowledge retrieve --index-id <id> --query <text> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------- |
|
||||
| **Name** | `knowledge retrieve` |
|
||||
| **Description** | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl knowledge retrieve --index-id <id> --query <text> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -92,11 +94,12 @@ bl knowledge retrieve --index-id idx_xxx --query "RAG retrieval" --rerank --rera
|
||||
|
||||
### `bl knowledge search`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------ |
|
||||
| **Name** | `knowledge search` |
|
||||
| **Description** | Search a Bailian knowledge base (RAG semantic retrieval) |
|
||||
| **Usage** | `bl knowledge search --query <text> --agent-id <id> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------ |
|
||||
| **Name** | `knowledge search` |
|
||||
| **Description** | Search a Bailian knowledge base (RAG semantic retrieval) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl knowledge search --query <text> --agent-id <id> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,33 +7,34 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| -------------- | ----------------------------------------------------- |
|
||||
| `bl mcp call` | Call a tool on an MCP server (tools/call) |
|
||||
| `bl mcp list` | List MCP servers activated under your Bailian account |
|
||||
| `bl mcp tools` | List tools exposed by an MCP server (tools/list) |
|
||||
| Command | Authentication | Description |
|
||||
| -------------- | -------------- | ----------------------------------------------------- |
|
||||
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) |
|
||||
| `bl mcp list` | Console | List MCP servers activated under your Bailian account |
|
||||
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl mcp call`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------------------- |
|
||||
| **Name** | `mcp call` |
|
||||
| **Description** | Call a tool on an MCP server (tools/call) |
|
||||
| **Usage** | `bl mcp call --target <server.tool> [--arg k=v ...] [--json '{...}'] [--url <url>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------------------- |
|
||||
| **Name** | `mcp call` |
|
||||
| **Description** | Call a tool on an MCP server (tools/call) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl mcp call --target <server.tool> [--arg k=v ...] [--json '{...}'] [--url <url>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `--target <server.tool>` | string | yes | Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection |
|
||||
| `--arg <kv>` | array | no | Tool argument (repeatable). Values parsed as JSON if possible, else string. |
|
||||
| `--json <obj>` | string | no | Full arguments object as JSON; merged with --arg (arg wins). |
|
||||
| `--query <text>` | string | no | Shortcut for --arg query=<text> (mirrors many DashScope MCP tools). |
|
||||
| `--url <url>` | string | no | Override the MCP endpoint URL (for non-Bailian servers) |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <url>` | string | no | API base URL |
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `--target <server.tool>` | string | yes | Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection |
|
||||
| `--arg <kv>` | array | no | Tool argument (repeatable). Values parsed as JSON if possible, else string. |
|
||||
| `--json <obj>` | string | no | Full arguments object as JSON; merged with --arg (arg wins). |
|
||||
| `--query <text>` | string | no | Shortcut for --arg query=<text> (mirrors many DashScope MCP tools). |
|
||||
| `--url <url>` | string | no | Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL. |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <url>` | string | no | API base URL |
|
||||
|
||||
#### Examples
|
||||
|
||||
@@ -51,11 +52,12 @@ bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3
|
||||
|
||||
### `bl mcp list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| **Name** | `mcp list` |
|
||||
| **Description** | List MCP servers activated under your Bailian account |
|
||||
| **Usage** | `bl mcp list [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------- |
|
||||
| **Name** | `mcp list` |
|
||||
| **Description** | List MCP servers activated under your Bailian account |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl mcp list [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -86,20 +88,21 @@ bl mcp list --output json
|
||||
|
||||
### `bl mcp tools`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------ |
|
||||
| **Name** | `mcp tools` |
|
||||
| **Description** | List tools exposed by an MCP server (tools/list) |
|
||||
| **Usage** | `bl mcp tools --server <code> [--url <url>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------ |
|
||||
| **Name** | `mcp tools` |
|
||||
| **Description** | List tools exposed by an MCP server (tools/list) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl mcp tools --server <code> [--url <url>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------ | ------ | -------- | ------------------------------------------------------- |
|
||||
| `--server <code>` | string | yes | Server code from `mcp list` (e.g. market-cmapi00073529) |
|
||||
| `--url <url>` | string | no | Override the MCP endpoint URL (for non-Bailian servers) |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <url>` | string | no | API base URL |
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `--server <code>` | string | yes | Server code from `mcp list` (e.g. market-cmapi00073529) |
|
||||
| `--url <url>` | string | no | Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL. |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <url>` | string | no | API base URL |
|
||||
|
||||
#### Examples
|
||||
|
||||
|
||||
@@ -7,25 +7,26 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| -------------------------- | ------------------------------------------------- |
|
||||
| `bl memory add` | Add memory from messages or custom content |
|
||||
| `bl memory delete` | Delete a memory node |
|
||||
| `bl memory list` | List memory nodes for a user |
|
||||
| `bl memory profile create` | Create a user profile schema for memory profiling |
|
||||
| `bl memory profile get` | Get user profile by schema ID and user ID |
|
||||
| `bl memory search` | Search memory nodes by query or messages |
|
||||
| `bl memory update` | Update a memory node content |
|
||||
| Command | Authentication | Description |
|
||||
| -------------------------- | -------------- | ------------------------------------------------- |
|
||||
| `bl memory add` | API Key | Add memory from messages or custom content |
|
||||
| `bl memory delete` | API Key | Delete a memory node |
|
||||
| `bl memory list` | API Key | List memory nodes for a user |
|
||||
| `bl memory profile create` | API Key | Create a user profile schema for memory profiling |
|
||||
| `bl memory profile get` | API Key | Get user profile by schema ID and user ID |
|
||||
| `bl memory search` | API Key | Search memory nodes by query or messages |
|
||||
| `bl memory update` | API Key | Update a memory node content |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl memory add`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------------- |
|
||||
| **Name** | `memory add` |
|
||||
| **Description** | Add memory from messages or custom content |
|
||||
| **Usage** | `bl memory add --user-id <id> [--messages <json>] [--content <text>] [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------------- |
|
||||
| **Name** | `memory add` |
|
||||
| **Description** | Add memory from messages or custom content |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl memory add --user-id <id> [--messages <json>] [--content <text>] [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -55,11 +56,12 @@ bl memory add --user-id user1 --content "Lives in Beijing" --profile-schema sche
|
||||
|
||||
### `bl memory delete`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------ |
|
||||
| **Name** | `memory delete` |
|
||||
| **Description** | Delete a memory node |
|
||||
| **Usage** | `bl memory delete --node-id <id> --user-id <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------ |
|
||||
| **Name** | `memory delete` |
|
||||
| **Description** | Delete a memory node |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl memory delete --node-id <id> --user-id <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -79,11 +81,12 @@ bl memory delete --node-id node_xxx --user-id user1
|
||||
|
||||
### `bl memory list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------------- |
|
||||
| **Name** | `memory list` |
|
||||
| **Description** | List memory nodes for a user |
|
||||
| **Usage** | `bl memory list --user-id <id> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| **Name** | `memory list` |
|
||||
| **Description** | List memory nodes for a user |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl memory list --user-id <id> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -108,11 +111,12 @@ bl memory list --user-id user1 --page-size 20 --page 2
|
||||
|
||||
### `bl memory profile create`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------------------------- |
|
||||
| **Name** | `memory profile create` |
|
||||
| **Description** | Create a user profile schema for memory profiling |
|
||||
| **Usage** | `bl memory profile create --name <name> --attributes <json> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------------------- |
|
||||
| **Name** | `memory profile create` |
|
||||
| **Description** | Create a user profile schema for memory profiling |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl memory profile create --name <name> --attributes <json> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -132,11 +136,12 @@ bl memory profile create --name "user_basic" --attributes '[{"name":"age","descr
|
||||
|
||||
### `bl memory profile get`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------- |
|
||||
| **Name** | `memory profile get` |
|
||||
| **Description** | Get user profile by schema ID and user ID |
|
||||
| **Usage** | `bl memory profile get --schema-id <id> --user-id <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| **Name** | `memory profile get` |
|
||||
| **Description** | Get user profile by schema ID and user ID |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl memory profile get --schema-id <id> --user-id <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -155,11 +160,12 @@ bl memory profile get --schema-id schema_xxx --user-id user1
|
||||
|
||||
### `bl memory search`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| **Name** | `memory search` |
|
||||
| **Description** | Search memory nodes by query or messages |
|
||||
| **Usage** | `bl memory search --user-id <id> [--query <text>] [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| **Name** | `memory search` |
|
||||
| **Description** | Search memory nodes by query or messages |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl memory search --user-id <id> [--query <text>] [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -185,11 +191,12 @@ bl memory search --user-id user1 --messages '[{"role":"user","content":"recommen
|
||||
|
||||
### `bl memory update`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------- |
|
||||
| **Name** | `memory update` |
|
||||
| **Description** | Update a memory node content |
|
||||
| **Usage** | `bl memory update --node-id <id> --user-id <id> --content <text>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------- |
|
||||
| **Name** | `memory update` |
|
||||
| **Description** | Update a memory node content |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl memory update --node-id <id> --user-id <id> --content <text>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| --------------- | ---------------------------------------------------------------------------------- |
|
||||
| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace |
|
||||
| Command | Authentication | Description |
|
||||
| --------------- | -------------- | ---------------------------------------------------------------------------------- |
|
||||
| `bl model list` | Console | Browse model families or show detailed model info in the Bailian model marketplace |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl model list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `model list` |
|
||||
| **Description** | Browse model families or show detailed model info in the Bailian model marketplace |
|
||||
| **Usage** | `bl model list [--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `model list` |
|
||||
| **Description** | Browse model families or show detailed model info in the Bailian model marketplace |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl model list [--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,20 +7,21 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------- | ------------------------------------------------ |
|
||||
| `bl pipeline run` | Run a pipeline workflow definition |
|
||||
| `bl pipeline validate` | Validate a pipeline definition without executing |
|
||||
| Command | Authentication | Description |
|
||||
| ---------------------- | -------------- | ------------------------------------------------ |
|
||||
| `bl pipeline run` | No Auth | Run a pipeline workflow definition |
|
||||
| `bl pipeline validate` | No Auth | Validate a pipeline definition without executing |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl pipeline run`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------------- |
|
||||
| **Name** | `pipeline run` |
|
||||
| **Description** | Run a pipeline workflow definition |
|
||||
| **Usage** | `bl pipeline run --file <path> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| **Name** | `pipeline run` |
|
||||
| **Description** | Run a pipeline workflow definition |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl pipeline run --file <path> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -57,11 +58,12 @@ bl pipeline run --file workflow.yaml --output json
|
||||
|
||||
### `bl pipeline validate`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------ |
|
||||
| **Name** | `pipeline validate` |
|
||||
| **Description** | Validate a pipeline definition without executing |
|
||||
| **Usage** | `bl pipeline validate --file <path>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------ |
|
||||
| **Name** | `pipeline validate` |
|
||||
| **Description** | Validate a pipeline definition without executing |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl pipeline validate --file <path>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,22 +7,23 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | ------------------------------------------------------ |
|
||||
| `bl plugin install` | Install or upgrade an allowlisted Command Pack |
|
||||
| `bl plugin link` | Link an allowlisted local Command Pack for development |
|
||||
| `bl plugin list` | List installed Command Packs and their load status |
|
||||
| `bl plugin remove` | Remove an installed Command Pack |
|
||||
| Command | Authentication | Description |
|
||||
| ------------------- | -------------- | ------------------------------------------------------ |
|
||||
| `bl plugin install` | No Auth | Install or upgrade an allowlisted Command Pack |
|
||||
| `bl plugin link` | No Auth | Link an allowlisted local Command Pack for development |
|
||||
| `bl plugin list` | No Auth | List installed Command Packs and their load status |
|
||||
| `bl plugin remove` | No Auth | Remove an installed Command Pack |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl plugin install`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------- |
|
||||
| **Name** | `plugin install` |
|
||||
| **Description** | Install or upgrade an allowlisted Command Pack |
|
||||
| **Usage** | `bl plugin install --package <name[@version]>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------- |
|
||||
| **Name** | `plugin install` |
|
||||
| **Description** | Install or upgrade an allowlisted Command Pack |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl plugin install --package <name[@version]>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -42,11 +43,12 @@ bl plugin install --package @ali/bailian-plugin-agent@beta
|
||||
|
||||
### `bl plugin link`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| **Name** | `plugin link` |
|
||||
| **Description** | Link an allowlisted local Command Pack for development |
|
||||
| **Usage** | `bl plugin link --path <directory>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------ |
|
||||
| **Name** | `plugin link` |
|
||||
| **Description** | Link an allowlisted local Command Pack for development |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl plugin link --path <directory>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -62,11 +64,12 @@ bl plugin link --path ../bailian-plugin-agent
|
||||
|
||||
### `bl plugin list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| **Name** | `plugin list` |
|
||||
| **Description** | List installed Command Packs and their load status |
|
||||
| **Usage** | `bl plugin list` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------- |
|
||||
| **Name** | `plugin list` |
|
||||
| **Description** | List installed Command Packs and their load status |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl plugin list` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -84,11 +87,12 @@ bl plugin list --output json
|
||||
|
||||
### `bl plugin remove`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------- |
|
||||
| **Name** | `plugin remove` |
|
||||
| **Description** | Remove an installed Command Pack |
|
||||
| **Usage** | `bl plugin remove --name <package>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------- |
|
||||
| **Name** | `plugin remove` |
|
||||
| **Description** | Remove an installed Command Pack |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl plugin remove --name <package>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,22 +7,23 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `bl quota check` | Check current usage against rate limits |
|
||||
| `bl quota history` | View quota change history |
|
||||
| `bl quota list` | View model RPM/TPM rate limits |
|
||||
| `bl quota request` | Request a temporary quota increase |
|
||||
| Command | Authentication | Description |
|
||||
| ------------------ | -------------- | --------------------------------------- |
|
||||
| `bl quota check` | Console | Check current usage against rate limits |
|
||||
| `bl quota history` | Console | View quota change history |
|
||||
| `bl quota list` | Console | View model RPM/TPM rate limits |
|
||||
| `bl quota request` | Console | Request a temporary quota increase |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl quota check`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| **Name** | `quota check` |
|
||||
| **Description** | Check current usage against rate limits |
|
||||
| **Usage** | `bl quota check [--model <model>] [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------ |
|
||||
| **Name** | `quota check` |
|
||||
| **Description** | Check current usage against rate limits |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl quota check [--model <model>] [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -59,11 +60,12 @@ bl quota check --output json
|
||||
|
||||
### `bl quota history`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------- |
|
||||
| **Name** | `quota history` |
|
||||
| **Description** | View quota change history |
|
||||
| **Usage** | `bl quota history [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------- |
|
||||
| **Name** | `quota history` |
|
||||
| **Description** | View quota change history |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl quota history [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -101,11 +103,12 @@ bl quota history --output json
|
||||
|
||||
### `bl quota list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------- |
|
||||
| **Name** | `quota list` |
|
||||
| **Description** | View model RPM/TPM rate limits |
|
||||
| **Usage** | `bl quota list [--model <model>] [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------- |
|
||||
| **Name** | `quota list` |
|
||||
| **Description** | View model RPM/TPM rate limits |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl quota list [--model <model>] [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -137,11 +140,12 @@ bl quota list --output json
|
||||
|
||||
### `bl quota request`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| **Name** | `quota request` |
|
||||
| **Description** | Request a temporary quota increase |
|
||||
| **Usage** | `bl quota request --model <model> --tpm <value> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------- |
|
||||
| **Name** | `quota request` |
|
||||
| **Description** | Request a temporary quota increase |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl quota request --model <model> --tpm <value> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| `bl search web` | Search the web using DashScope MCP WebSearch service |
|
||||
| Command | Authentication | Description |
|
||||
| --------------- | -------------- | ---------------------------------------------------- |
|
||||
| `bl search web` | API Key | Search the web using DashScope MCP WebSearch service |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl search web`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| **Name** | `search web` |
|
||||
| **Description** | Search the web using DashScope MCP WebSearch service |
|
||||
| **Usage** | `bl search web --query <text> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------- |
|
||||
| **Name** | `search web` |
|
||||
| **Description** | Search the web using DashScope MCP WebSearch service |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl search web --query <text> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,23 +7,24 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ----------------------------------------------------------------------- |
|
||||
| `bl skill add` | Install skills from the Bailian skill registry into local agents |
|
||||
| `bl skill init` | Install all bailian-\* skills (one-shot bootstrap for new environments) |
|
||||
| `bl skill list` | List registry skills and diff against local installs |
|
||||
| `bl skill remove` | Remove locally installed skills (registry is untouched) |
|
||||
| `bl skill update` | Update installed skills to the latest registry versions |
|
||||
| Command | Authentication | Description |
|
||||
| ----------------- | -------------- | ----------------------------------------------------------------------- |
|
||||
| `bl skill add` | No Auth | Install skills from the Bailian skill registry into local agents |
|
||||
| `bl skill init` | No Auth | Install all bailian-\* skills (one-shot bootstrap for new environments) |
|
||||
| `bl skill list` | No Auth | List registry skills and diff against local installs |
|
||||
| `bl skill remove` | No Auth | Remove locally installed skills (registry is untouched) |
|
||||
| `bl skill update` | No Auth | Update installed skills to the latest registry versions |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl skill add`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| **Name** | `skill add` |
|
||||
| **Description** | Install skills from the Bailian skill registry into local agents |
|
||||
| **Usage** | `bl skill add --all \| --name <name,...>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------- |
|
||||
| **Name** | `skill add` |
|
||||
| **Description** | Install skills from the Bailian skill registry into local agents |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl skill add --all \| --name <name,...>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -44,11 +45,12 @@ bl skill add --name spark-video,bailian-model-recommend
|
||||
|
||||
### `bl skill init`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------- |
|
||||
| **Name** | `skill init` |
|
||||
| **Description** | Install all bailian-\* skills (one-shot bootstrap for new environments) |
|
||||
| **Usage** | `bl skill init` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------- |
|
||||
| **Name** | `skill init` |
|
||||
| **Description** | Install all bailian-\* skills (one-shot bootstrap for new environments) |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl skill init` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -67,11 +69,12 @@ bl skill init
|
||||
|
||||
### `bl skill list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| **Name** | `skill list` |
|
||||
| **Description** | List registry skills and diff against local installs |
|
||||
| **Usage** | `bl skill list` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------- |
|
||||
| **Name** | `skill list` |
|
||||
| **Description** | List registry skills and diff against local installs |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl skill list` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -93,11 +96,12 @@ bl skill list --output json
|
||||
|
||||
### `bl skill remove`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------- |
|
||||
| **Name** | `skill remove` |
|
||||
| **Description** | Remove locally installed skills (registry is untouched) |
|
||||
| **Usage** | `bl skill remove --name <all\|name,...>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| **Name** | `skill remove` |
|
||||
| **Description** | Remove locally installed skills (registry is untouched) |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl skill remove --name <all\|name,...>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -117,11 +121,12 @@ bl skill remove --name all
|
||||
|
||||
### `bl skill update`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------- |
|
||||
| **Name** | `skill update` |
|
||||
| **Description** | Update installed skills to the latest registry versions |
|
||||
| **Usage** | `bl skill update [--all] [--name <name,...>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| **Name** | `skill update` |
|
||||
| **Description** | Update installed skills to the latest registry versions |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl skill update [--all] [--name <name,...>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,37 +7,39 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| -------------- | ----------------------------------------------------- |
|
||||
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) |
|
||||
| Command | Authentication | Description |
|
||||
| -------------- | -------------- | -------------------------------------------------------- |
|
||||
| `bl text chat` | API Key | Send a text model request (OpenAI compatible, DashScope) |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl text chat`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------- |
|
||||
| **Name** | `text chat` |
|
||||
| **Description** | Send a chat completion (OpenAI compatible, DashScope) |
|
||||
| **Usage** | `bl text chat --message <text> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------- |
|
||||
| **Name** | `text chat` |
|
||||
| **Description** | Send a text model request (OpenAI compatible, DashScope) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl text chat --message <text> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | --------------------------------------------------------------------------- |
|
||||
| `--model <model>` | string | no | Model ID (default: qwen3.8-max) |
|
||||
| `--message <text>` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file |
|
||||
| `--messages-file <path>` | string | no | JSON file with messages array (use - for stdin) |
|
||||
| `--system <text>` | string | no | System prompt |
|
||||
| `--max-tokens <n>` | number | no | Maximum tokens to generate (default: 4096) |
|
||||
| `--temperature <n>` | number | no | Sampling temperature (0.0, 2.0] |
|
||||
| `--top-p <n>` | number | no | Nucleus sampling threshold |
|
||||
| `--stream` | switch | no | Stream response tokens (default: on in TTY) |
|
||||
| `--tool <json-or-path>` | array | no | Tool definition as JSON or file path (repeatable) |
|
||||
| `--enable-thinking` | switch | no | Enable thinking/reasoning mode (for qwen3/qwq models) |
|
||||
| `--thinking-budget <n>` | number | no | Max tokens for thinking (default: 4096) |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <url>` | string | no | API base URL |
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------- | ------ | -------- | --------------------------------------------------------------------------- |
|
||||
| `--api <chat\|responses>` | string | no | API to call (default: chat) |
|
||||
| `--model <model>` | string | no | Model ID (default: qwen3.8-max) |
|
||||
| `--message <text>` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file |
|
||||
| `--messages-file <path>` | string | no | JSON file with messages array (use - for stdin) |
|
||||
| `--system <text>` | string | no | System prompt |
|
||||
| `--max-tokens <n>` | number | no | Maximum tokens to generate (default: 4096) |
|
||||
| `--temperature <n>` | number | no | Sampling temperature (0.0, 2.0] |
|
||||
| `--top-p <n>` | number | no | Nucleus sampling threshold |
|
||||
| `--stream` | switch | no | Stream response tokens (default: on in TTY) |
|
||||
| `--tool <json-or-path>` | array | no | Tool definition as JSON or file path (repeatable) |
|
||||
| `--enable-thinking` | switch | no | Enable thinking/reasoning mode (for qwen3/qwq models) |
|
||||
| `--thinking-budget <n>` | number | no | Max tokens for thinking (default: 4096) |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <url>` | string | no | API base URL |
|
||||
|
||||
#### Examples
|
||||
|
||||
@@ -45,6 +47,10 @@ Index: [index.md](index.md)
|
||||
bl text chat --message "What is Qwen?"
|
||||
```
|
||||
|
||||
```bash
|
||||
bl text chat --api responses --model qwen3.8-max --tool '{"type":"web_search"}' --message "Search for recent Alibaba Cloud news"
|
||||
```
|
||||
|
||||
```bash
|
||||
bl text chat --model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python"
|
||||
```
|
||||
|
||||
@@ -7,22 +7,23 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------------- | ----------------------------------------- |
|
||||
| `bl token-plan add-member` | Add a member to a Token Plan organization |
|
||||
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members |
|
||||
| `bl token-plan create-key` | Create a Token Plan API key for a seat |
|
||||
| `bl token-plan list-seats` | List Token Plan subscription seat details |
|
||||
| Command | Authentication | Description |
|
||||
| ---------------------------- | -------------- | ----------------------------------------- |
|
||||
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization |
|
||||
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members |
|
||||
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat |
|
||||
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl token-plan add-member`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------------- |
|
||||
| **Name** | `token-plan add-member` |
|
||||
| **Description** | Add a member to a Token Plan organization |
|
||||
| **Usage** | `bl token-plan add-member --account-name <name> --org-id <id> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------------- |
|
||||
| **Name** | `token-plan add-member` |
|
||||
| **Description** | Add a member to a Token Plan organization |
|
||||
| **Authentication** | AK/SK |
|
||||
| **Usage** | `bl token-plan add-member --account-name <name> --org-id <id> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -54,11 +55,12 @@ bl token-plan add-member --account-name member1 --org-id org_123 --spec-type sta
|
||||
|
||||
### `bl token-plan assign-seats`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `token-plan assign-seats` |
|
||||
| **Description** | Batch assign Token Plan seats to members |
|
||||
| **Usage** | `bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `token-plan assign-seats` |
|
||||
| **Description** | Batch assign Token Plan seats to members |
|
||||
| **Authentication** | AK/SK |
|
||||
| **Usage** | `bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -86,11 +88,12 @@ bl token-plan assign-seats --workspace-id ws_456 --seat-type pro --account-id ac
|
||||
|
||||
### `bl token-plan create-key`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------ |
|
||||
| **Name** | `token-plan create-key` |
|
||||
| **Description** | Create a Token Plan API key for a seat |
|
||||
| **Usage** | `bl token-plan create-key --account-id <id> --workspace-id <id> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------ |
|
||||
| **Name** | `token-plan create-key` |
|
||||
| **Description** | Create a Token Plan API key for a seat |
|
||||
| **Authentication** | AK/SK |
|
||||
| **Usage** | `bl token-plan create-key --account-id <id> --workspace-id <id> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -117,11 +120,12 @@ bl token-plan create-key --account-id acc_123 --workspace-id ws_456 --descriptio
|
||||
|
||||
### `bl token-plan list-seats`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------- |
|
||||
| **Name** | `token-plan list-seats` |
|
||||
| **Description** | List Token Plan subscription seat details |
|
||||
| **Usage** | `bl token-plan list-seats [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------- |
|
||||
| **Name** | `token-plan list-seats` |
|
||||
| **Description** | List Token Plan subscription seat details |
|
||||
| **Authentication** | AK/SK |
|
||||
| **Usage** | `bl token-plan list-seats [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ----------- | --------------------------------------------------- |
|
||||
| `bl update` | Update the CLI to the latest or a specified version |
|
||||
| Command | Authentication | Description |
|
||||
| ----------- | -------------- | --------------------------------------------------- |
|
||||
| `bl update` | No Auth | Update the CLI to the latest or a specified version |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl update`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------------------------- |
|
||||
| **Name** | `update` |
|
||||
| **Description** | Update the CLI to the latest or a specified version |
|
||||
| **Usage** | `bl update [--to <version>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| **Name** | `update` |
|
||||
| **Description** | Update the CLI to the latest or a specified version |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl update [--to <version>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,22 +7,53 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) |
|
||||
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
|
||||
| `bl usage stats` | Query model usage statistics |
|
||||
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview |
|
||||
| Command | Authentication | Description |
|
||||
| ---------------------- | -------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `bl usage coding-plan` | Console | Show Coding Plan quota usage |
|
||||
| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) |
|
||||
| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
|
||||
| `bl usage stats` | Console | Query model usage statistics |
|
||||
| `bl usage summary` | Console | Show a unified usage summary: free-tier quota and recent usage overview |
|
||||
| `bl usage token-plan` | Console | Show Token Plan quota usage |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl usage coding-plan`
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------ |
|
||||
| **Name** | `usage coding-plan` |
|
||||
| **Description** | Show Coding Plan quota usage |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl usage coding-plan [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
|
||||
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
|
||||
| `--console-site <site>` | string | no | Console site: domestic, international |
|
||||
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
|
||||
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl usage coding-plan
|
||||
```
|
||||
|
||||
```bash
|
||||
bl usage coding-plan --output json
|
||||
```
|
||||
|
||||
### `bl usage free`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------- |
|
||||
| **Name** | `usage free` |
|
||||
| **Description** | Query free-tier quota for models (all models if --model is omitted) |
|
||||
| **Usage** | `bl usage free [--model <model>[,model2,...]] [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------- |
|
||||
| **Name** | `usage free` |
|
||||
| **Description** | Query free-tier quota for models (all models if --model is omitted) |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl usage free [--model <model>[,model2,...]] [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -73,11 +104,12 @@ bl usage free --model qwen3-max --console-region cn-beijing
|
||||
|
||||
### `bl usage freetier`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **Name** | `usage freetier` |
|
||||
| **Description** | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
|
||||
| **Usage** | `bl usage freetier <--model <model>[,model2,...] \| --all> [--off] [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Name** | `usage freetier` |
|
||||
| **Description** | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl usage freetier <--model <model>[,model2,...] \| --all> [--off] [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -120,11 +152,12 @@ bl usage freetier --off --all
|
||||
|
||||
### `bl usage stats`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| **Name** | `usage stats` |
|
||||
| **Description** | Query model usage statistics |
|
||||
| **Usage** | `bl usage stats [--model <model>] [--days <days>] [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| **Name** | `usage stats` |
|
||||
| **Description** | Query model usage statistics |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl usage stats [--model <model>] [--days <days>] [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -170,11 +203,12 @@ bl usage stats --output json
|
||||
|
||||
### `bl usage summary`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------- |
|
||||
| **Name** | `usage summary` |
|
||||
| **Description** | Show a unified usage summary: free-tier quota and recent usage overview |
|
||||
| **Usage** | `bl usage summary [--days <days>] [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------- |
|
||||
| **Name** | `usage summary` |
|
||||
| **Description** | Show a unified usage summary: free-tier quota and recent usage overview |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl usage summary [--days <days>] [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -199,3 +233,31 @@ bl usage summary --days 30
|
||||
```bash
|
||||
bl usage summary --output json
|
||||
```
|
||||
|
||||
### `bl usage token-plan`
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------- |
|
||||
| **Name** | `usage token-plan` |
|
||||
| **Description** | Show Token Plan quota usage |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl usage token-plan [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
|
||||
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
|
||||
| `--console-site <site>` | string | no | Console site: domestic, international |
|
||||
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
|
||||
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl usage token-plan
|
||||
```
|
||||
|
||||
```bash
|
||||
bl usage token-plan --output json
|
||||
```
|
||||
|
||||
@@ -7,20 +7,21 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | ----------------------------------------------------------- |
|
||||
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services |
|
||||
| `bl workspace list` | List all workspaces |
|
||||
| Command | Authentication | Description |
|
||||
| ------------------- | -------------- | ----------------------------------------------------------- |
|
||||
| `bl workspace init` | No Auth | Initialize Bailian workspace and activate postpaid services |
|
||||
| `bl workspace list` | Console | List all workspaces |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl workspace init`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| **Name** | `workspace init` |
|
||||
| **Description** | Initialize Bailian workspace and activate postpaid services |
|
||||
| **Usage** | `bl workspace init --access-key-id <id> --access-key-secret <secret> [--security-token <token>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| **Name** | `workspace init` |
|
||||
| **Description** | Initialize Bailian workspace and activate postpaid services |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl workspace init --access-key-id <id> --access-key-secret <secret> [--security-token <token>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -38,11 +39,12 @@ bl workspace init --access-key-id LTAIxxxxx --access-key-secret xxxxx
|
||||
|
||||
### `bl workspace list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------- |
|
||||
| **Name** | `workspace list` |
|
||||
| **Description** | List all workspaces |
|
||||
| **Usage** | `bl workspace list [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------- |
|
||||
| **Name** | `workspace list` |
|
||||
| **Description** | List all workspaces |
|
||||
| **Authentication** | Console |
|
||||
| **Usage** | `bl workspace list [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-finetune
|
||||
metadata:
|
||||
version: "1.14.2"
|
||||
version: "1.15.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
@@ -11,12 +11,12 @@ description: >-
|
||||
upload 拿 file-id → finetune create 建任务 → watch 看进度 → export 导出 → deploy 上线,需要 API key;
|
||||
写操作先用 `--dry-run` 预览。反触发:用户点名火山方舟/ark 的精调不走本 skill;只是要选哪个模型走
|
||||
bailian-model-recommend;用现成模型生图生视频走 bailian-gen;百炼其他资源管理走 bailian-cli。
|
||||
官方安装:`npx skills add modelstudioai/cli --all -g`(与共享协议 bailian-protocol 同装)。
|
||||
官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。
|
||||
---
|
||||
|
||||
# Bailian fine-tuning pipeline (`bl dataset` / `bl finetune` / `bl deploy`)
|
||||
|
||||
**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Version & updates (pre-flight checklist), Setup & auth, and CLI errors: report an issue. Command details are authoritative in [`reference/`](reference/index.md) (dataset / finetune / deploy) and `bl <command> --help` — do not guess flags. The whole pipeline requires an API key. If that protocol file is missing, stop and run `npx skills add modelstudioai/cli --all -g`; do not guess auth/consent.**
|
||||
**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Version & updates (pre-flight checklist), Setup & auth, and CLI errors: report an issue. Command details are authoritative in [`reference/`](reference/index.md) (dataset / finetune / deploy) and `bl <command> --help` — do not guess flags. The whole pipeline requires an API key. If that protocol file is missing, stop and run `bl skill init`; do not guess auth/consent.**
|
||||
|
||||
## End-to-end workflow (follow in order)
|
||||
|
||||
@@ -63,7 +63,7 @@ bl deploy text create --model my-qwen-sft --name my-svc
|
||||
|
||||
## Common hand-offs
|
||||
|
||||
软 hand-off(按 skill **名**;已安装则 Read,否则 `--help` / 提示 `npx skills add modelstudioai/cli --all -g`):
|
||||
软 hand-off(按 skill **名**;已安装则 Read,否则 `--help` / 提示 `bl skill init`):
|
||||
|
||||
- After deployment, try the model or generate content → skill `bailian-gen` (media) or `bl text chat` (fallback: `bl image\|video\|text --help`).
|
||||
- Unsure which base model to pick → `bailian-model-recommend` / `bl advisor recommend`.
|
||||
@@ -71,5 +71,5 @@ bl deploy text create --model my-qwen-sft --name my-svc
|
||||
|
||||
## references
|
||||
|
||||
- [bailian-protocol](../bailian-protocol/SKILL.md) — shared protocol (install via `--all -g`)
|
||||
- [bailian-protocol](../bailian-protocol/SKILL.md) — shared protocol (install via `bl skill init`)
|
||||
- [reference/](reference/index.md) — command details
|
||||
|
||||
@@ -7,23 +7,24 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| --------------------- | ------------------------------------------------------------------ |
|
||||
| `bl dataset delete` | Delete a dataset file by ID |
|
||||
| `bl dataset get` | Get details of a single dataset file |
|
||||
| `bl dataset list` | List uploaded dataset files |
|
||||
| `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian |
|
||||
| `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading |
|
||||
| Command | Authentication | Description |
|
||||
| --------------------- | -------------- | ------------------------------------------------------------------ |
|
||||
| `bl dataset delete` | API Key | Delete a dataset file by ID |
|
||||
| `bl dataset get` | API Key | Get details of a single dataset file |
|
||||
| `bl dataset list` | API Key | List uploaded dataset files |
|
||||
| `bl dataset upload` | API Key | Upload a dataset file (.jsonl or .zip) to Bailian |
|
||||
| `bl dataset validate` | No Auth | Locally validate a dataset file (.jsonl or .zip) without uploading |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl dataset delete`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------- |
|
||||
| **Name** | `dataset delete` |
|
||||
| **Description** | Delete a dataset file by ID |
|
||||
| **Usage** | `bl dataset delete --file-id <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------- |
|
||||
| **Name** | `dataset delete` |
|
||||
| **Description** | Delete a dataset file by ID |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl dataset delete --file-id <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -45,11 +46,12 @@ bl dataset delete --file-id file-id-xxx --dry-run
|
||||
|
||||
### `bl dataset get`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------ |
|
||||
| **Name** | `dataset get` |
|
||||
| **Description** | Get details of a single dataset file |
|
||||
| **Usage** | `bl dataset get --file-id <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------ |
|
||||
| **Name** | `dataset get` |
|
||||
| **Description** | Get details of a single dataset file |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl dataset get --file-id <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -71,11 +73,12 @@ bl dataset get --file-id file-xxx --output json
|
||||
|
||||
### `bl dataset list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------- |
|
||||
| **Name** | `dataset list` |
|
||||
| **Description** | List uploaded dataset files |
|
||||
| **Usage** | `bl dataset list [--page <n>] [--page-size <n>] [--purpose <name>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------- |
|
||||
| **Name** | `dataset list` |
|
||||
| **Description** | List uploaded dataset files |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl dataset list [--page <n>] [--page-size <n>] [--purpose <name>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -107,11 +110,12 @@ bl dataset list --output json
|
||||
|
||||
### `bl dataset upload`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `dataset upload` |
|
||||
| **Description** | Upload a dataset file (.jsonl or .zip) to Bailian |
|
||||
| **Usage** | `bl dataset upload --file <path> [--purpose <name>] [--schema <chatml\|dpo\|cpt\|tts\|image>] [--no-validate] [--full-validate]` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `dataset upload` |
|
||||
| **Description** | Upload a dataset file (.jsonl or .zip) to Bailian |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl dataset upload --file <path> [--purpose <name>] [--schema <chatml\|dpo\|cpt\|tts\|image>] [--no-validate] [--full-validate]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -170,11 +174,12 @@ bl dataset upload --file train.jsonl --no-validate
|
||||
|
||||
### `bl dataset validate`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `dataset validate` |
|
||||
| **Description** | Locally validate a dataset file (.jsonl or .zip) without uploading |
|
||||
| **Usage** | `bl dataset validate --file <path> [--full-validate] [--schema <chatml\|dpo\|cpt\|tts\|image>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `dataset validate` |
|
||||
| **Description** | Locally validate a dataset file (.jsonl or .zip) without uploading |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl dataset validate --file <path> [--full-validate] [--schema <chatml\|dpo\|cpt\|tts\|image>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,27 +7,28 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------ | --------------------------------------------------------- |
|
||||
| `bl deploy audio create` | Create an audio (TTS) model deployment |
|
||||
| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) |
|
||||
| `bl deploy get` | Get details of a single model deployment |
|
||||
| `bl deploy image create` | Create an image generation model deployment |
|
||||
| `bl deploy list` | List model deployments |
|
||||
| `bl deploy models` | List models available for deployment |
|
||||
| `bl deploy scale` | Scale a deployment's capacity |
|
||||
| `bl deploy text create` | Create a text model deployment |
|
||||
| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) |
|
||||
| Command | Authentication | Description |
|
||||
| ------------------------ | -------------- | --------------------------------------------------------- |
|
||||
| `bl deploy audio create` | API Key | Create an audio (TTS) model deployment |
|
||||
| `bl deploy delete` | API Key | Delete a model deployment (must be STOPPED or FAILED) |
|
||||
| `bl deploy get` | API Key | Get details of a single model deployment |
|
||||
| `bl deploy image create` | API Key | Create an image generation model deployment |
|
||||
| `bl deploy list` | API Key | List model deployments |
|
||||
| `bl deploy models` | API Key | List models available for deployment |
|
||||
| `bl deploy scale` | API Key | Scale a deployment's capacity |
|
||||
| `bl deploy text create` | API Key | Create a text model deployment |
|
||||
| `bl deploy update` | API Key | Update a deployment's rate limits (rpm_limit / tpm_limit) |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl deploy audio create`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy audio create` |
|
||||
| **Description** | Create an audio (TTS) model deployment |
|
||||
| **Usage** | `bl deploy audio create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy audio create` |
|
||||
| **Description** | Create an audio (TTS) model deployment |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy audio create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -83,11 +84,12 @@ bl deploy audio create --model my-cosyvoice-ft --name my-tts --dry-run
|
||||
|
||||
### `bl deploy delete`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| **Name** | `deploy delete` |
|
||||
| **Description** | Delete a model deployment (must be STOPPED or FAILED) |
|
||||
| **Usage** | `bl deploy delete --deployed-model <id> [--skip-precheck]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| **Name** | `deploy delete` |
|
||||
| **Description** | Delete a model deployment (must be STOPPED or FAILED) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy delete --deployed-model <id> [--skip-precheck]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -110,11 +112,12 @@ bl deploy delete --deployed-model dep-... --dry-run
|
||||
|
||||
### `bl deploy get`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| **Name** | `deploy get` |
|
||||
| **Description** | Get details of a single model deployment |
|
||||
| **Usage** | `bl deploy get --deployed-model <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------- |
|
||||
| **Name** | `deploy get` |
|
||||
| **Description** | Get details of a single model deployment |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy get --deployed-model <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -136,11 +139,12 @@ bl deploy get --deployed-model qwen-plus-2025-12-01-b6d61c71 --output json
|
||||
|
||||
### `bl deploy image create`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy image create` |
|
||||
| **Description** | Create an image generation model deployment |
|
||||
| **Usage** | `bl deploy image create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy image create` |
|
||||
| **Description** | Create an image generation model deployment |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy image create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -196,11 +200,12 @@ bl deploy image create --model my-wan-ft --name my-wan --dry-run
|
||||
|
||||
### `bl deploy list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------------------- |
|
||||
| **Name** | `deploy list` |
|
||||
| **Description** | List model deployments |
|
||||
| **Usage** | `bl deploy list [--page <n>] [--page-size <n>] [--status <s>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------------- |
|
||||
| **Name** | `deploy list` |
|
||||
| **Description** | List model deployments |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy list [--page <n>] [--page-size <n>] [--status <s>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -228,11 +233,12 @@ bl deploy list --page-size 20 --output json
|
||||
|
||||
### `bl deploy models`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy models` |
|
||||
| **Description** | List models available for deployment |
|
||||
| **Usage** | `bl deploy models [--page <n>] [--page-size <n>] [--catalog-version <v>] [--source <custom\|public>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy models` |
|
||||
| **Description** | List models available for deployment |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy models [--page <n>] [--page-size <n>] [--catalog-version <v>] [--source <custom\|public>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -265,11 +271,12 @@ bl deploy models --catalog-version v1.0 --output json
|
||||
|
||||
### `bl deploy scale`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy scale` |
|
||||
| **Description** | Scale a deployment's capacity |
|
||||
| **Usage** | `bl deploy scale --deployed-model <id> --capacity <n> [--input-tpm <n>] [--output-tpm <n>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy scale` |
|
||||
| **Description** | Scale a deployment's capacity |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy scale --deployed-model <id> --capacity <n> [--input-tpm <n>] [--output-tpm <n>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -294,11 +301,12 @@ bl deploy scale --deployed-model dep-... --capacity 2
|
||||
|
||||
### `bl deploy text create`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy text create` |
|
||||
| **Description** | Create a text model deployment |
|
||||
| **Usage** | `bl deploy text create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy text create` |
|
||||
| **Description** | Create a text model deployment |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy text create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -358,11 +366,12 @@ bl deploy text create --model qwen3-8b --name my-qwen3 --plan mu --deploy-spec M
|
||||
|
||||
### `bl deploy update`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy update` |
|
||||
| **Description** | Update a deployment's rate limits (rpm_limit / tpm_limit) |
|
||||
| **Usage** | `bl deploy update --deployed-model <id> [--rpm-limit <n>] [--tpm-limit <n>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------------------- |
|
||||
| **Name** | `deploy update` |
|
||||
| **Description** | Update a deployment's rate limits (rpm_limit / tpm_limit) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl deploy update --deployed-model <id> [--rpm-limit <n>] [--tpm-limit <n>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -7,30 +7,31 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `bl finetune audio create` | Create an audio TTS model fine-tune job (sft-lora) |
|
||||
| `bl finetune cancel` | Cancel a running fine-tune job |
|
||||
| `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
|
||||
| `bl finetune checkpoints` | List checkpoints produced by a fine-tune job |
|
||||
| `bl finetune delete` | Delete a fine-tune job record |
|
||||
| `bl finetune export` | Publish a checkpoint as a deployable model |
|
||||
| `bl finetune get` | Get details of a single fine-tune job |
|
||||
| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) |
|
||||
| `bl finetune list` | List fine-tune jobs |
|
||||
| `bl finetune logs` | Fetch training logs for a fine-tune job |
|
||||
| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
|
||||
| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
|
||||
| Command | Authentication | Description |
|
||||
| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `bl finetune audio create` | API Key | Create an audio TTS model fine-tune job (sft-lora) |
|
||||
| `bl finetune cancel` | API Key | Cancel a running fine-tune job |
|
||||
| `bl finetune capability` | No Auth | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
|
||||
| `bl finetune checkpoints` | API Key | List checkpoints produced by a fine-tune job |
|
||||
| `bl finetune delete` | API Key | Delete a fine-tune job record |
|
||||
| `bl finetune export` | API Key | Publish a checkpoint as a deployable model |
|
||||
| `bl finetune get` | API Key | Get details of a single fine-tune job |
|
||||
| `bl finetune image create` | API Key | Create an image generation model fine-tune job (sft-lora) |
|
||||
| `bl finetune list` | API Key | List fine-tune jobs |
|
||||
| `bl finetune logs` | API Key | Fetch training logs for a fine-tune job |
|
||||
| `bl finetune text create` | API Key | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
|
||||
| `bl finetune watch` | API Key | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl finetune audio create`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune audio create` |
|
||||
| **Description** | Create an audio TTS model fine-tune job (sft-lora) |
|
||||
| **Usage** | `bl finetune audio create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune audio create` |
|
||||
| **Description** | Create an audio TTS model fine-tune job (sft-lora) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune audio create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -79,11 +80,12 @@ bl finetune audio create --model cosyvoice-v3-flash --datasets ./audio.zip --dry
|
||||
|
||||
### `bl finetune cancel`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------- |
|
||||
| **Name** | `finetune cancel` |
|
||||
| **Description** | Cancel a running fine-tune job |
|
||||
| **Usage** | `bl finetune cancel --job-id <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------- |
|
||||
| **Name** | `finetune cancel` |
|
||||
| **Description** | Cancel a running fine-tune job |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune cancel --job-id <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -110,11 +112,12 @@ bl finetune cancel --job-id ft-xxx --dry-run
|
||||
|
||||
### `bl finetune capability`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune capability` |
|
||||
| **Description** | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
|
||||
| **Usage** | `bl finetune capability --model <m> \| --training-type <t>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune capability` |
|
||||
| **Description** | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
|
||||
| **Authentication** | No Auth |
|
||||
| **Usage** | `bl finetune capability --model <m> \| --training-type <t>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -150,11 +153,12 @@ bl finetune capability --training-type sft --quiet
|
||||
|
||||
### `bl finetune checkpoints`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------- |
|
||||
| **Name** | `finetune checkpoints` |
|
||||
| **Description** | List checkpoints produced by a fine-tune job |
|
||||
| **Usage** | `bl finetune checkpoints --job-id <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------- |
|
||||
| **Name** | `finetune checkpoints` |
|
||||
| **Description** | List checkpoints produced by a fine-tune job |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune checkpoints --job-id <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -181,11 +185,12 @@ bl finetune checkpoints --job-id ft-xxx --output json
|
||||
|
||||
### `bl finetune delete`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------- |
|
||||
| **Name** | `finetune delete` |
|
||||
| **Description** | Delete a fine-tune job record |
|
||||
| **Usage** | `bl finetune delete --job-id <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------- |
|
||||
| **Name** | `finetune delete` |
|
||||
| **Description** | Delete a fine-tune job record |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune delete --job-id <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -212,11 +217,12 @@ bl finetune delete --job-id ft-xxx --dry-run
|
||||
|
||||
### `bl finetune export`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune export` |
|
||||
| **Description** | Publish a checkpoint as a deployable model |
|
||||
| **Usage** | `bl finetune export --job-id <id> --checkpoint <name> --model-name <name>` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune export` |
|
||||
| **Description** | Publish a checkpoint as a deployable model |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune export --job-id <id> --checkpoint <name> --model-name <name>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -242,11 +248,12 @@ bl finetune export --job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft
|
||||
|
||||
### `bl finetune get`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------- |
|
||||
| **Name** | `finetune get` |
|
||||
| **Description** | Get details of a single fine-tune job |
|
||||
| **Usage** | `bl finetune get --job-id <id>` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------- |
|
||||
| **Name** | `finetune get` |
|
||||
| **Description** | Get details of a single fine-tune job |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune get --job-id <id>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -268,11 +275,12 @@ bl finetune get --job-id ft-xxx --output json
|
||||
|
||||
### `bl finetune image create`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Name** | `finetune image create` |
|
||||
| **Description** | Create an image generation model fine-tune job (sft-lora) |
|
||||
| **Usage** | `bl finetune image create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i\|i2i>] [--learning-rate <str>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Name** | `finetune image create` |
|
||||
| **Description** | Create an image generation model fine-tune job (sft-lora) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune image create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i\|i2i>] [--learning-rate <str>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -329,11 +337,12 @@ bl finetune image create --model wan2.7-image-pro --datasets ./images.zip --dry-
|
||||
|
||||
### `bl finetune list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| **Name** | `finetune list` |
|
||||
| **Description** | List fine-tune jobs |
|
||||
| **Usage** | `bl finetune list [--page <n>] [--page-size <n>] [--status <s>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------- |
|
||||
| **Name** | `finetune list` |
|
||||
| **Description** | List fine-tune jobs |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune list [--page <n>] [--page-size <n>] [--status <s>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -361,11 +370,12 @@ bl finetune list --page-size 20 --output json
|
||||
|
||||
### `bl finetune logs`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune logs` |
|
||||
| **Description** | Fetch training logs for a fine-tune job |
|
||||
| **Usage** | `bl finetune logs --job-id <id> [--page <n>] [--page-size <n>] [--search <keyword>] [--tail <n>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune logs` |
|
||||
| **Description** | Fetch training logs for a fine-tune job |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune logs --job-id <id> [--page <n>] [--page-size <n>] [--search <keyword>] [--tail <n>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -407,11 +417,12 @@ bl finetune logs --job-id ft-xxx --search checkpoint --tail 5
|
||||
|
||||
### `bl finetune text create`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune text create` |
|
||||
| **Description** | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
|
||||
| **Usage** | `bl finetune text create --model <model> --datasets <id\|path,...> [--validations <id\|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft\|sft-lora\|dpo\|dpo-lora\|cpt>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune text create` |
|
||||
| **Description** | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune text create --model <model> --datasets <id\|path,...> [--validations <id\|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft\|sft-lora\|dpo\|dpo-lora\|cpt>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -486,11 +497,12 @@ bl finetune text create --model qwen3-8b --datasets file-xxx --dry-run
|
||||
|
||||
### `bl finetune watch`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune watch` |
|
||||
| **Description** | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
|
||||
| **Usage** | `bl finetune watch --job-id <id> [--follow] [--interval <sec>] [--poll-timeout <sec>]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | `finetune watch` |
|
||||
| **Description** | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl finetune watch --job-id <id> [--follow] [--interval <sec>] [--poll-timeout <sec>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -9,34 +9,34 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
|
||||
## Quick index
|
||||
|
||||
| Command | Description | Detail |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| `bl dataset delete` | Delete a dataset file by ID | [dataset.md](dataset.md) |
|
||||
| `bl dataset get` | Get details of a single dataset file | [dataset.md](dataset.md) |
|
||||
| `bl dataset list` | List uploaded dataset files | [dataset.md](dataset.md) |
|
||||
| `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) |
|
||||
| `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) |
|
||||
| `bl deploy audio create` | Create an audio (TTS) model deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) |
|
||||
| `bl deploy get` | Get details of a single model deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy image create` | Create an image generation model deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy list` | List model deployments | [deploy.md](deploy.md) |
|
||||
| `bl deploy models` | List models available for deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy scale` | Scale a deployment's capacity | [deploy.md](deploy.md) |
|
||||
| `bl deploy text create` | Create a text model deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) |
|
||||
| `bl finetune audio create` | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
|
||||
| `bl finetune cancel` | Cancel a running fine-tune job | [finetune.md](finetune.md) |
|
||||
| `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) |
|
||||
| `bl finetune checkpoints` | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) |
|
||||
| `bl finetune delete` | Delete a fine-tune job record | [finetune.md](finetune.md) |
|
||||
| `bl finetune export` | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) |
|
||||
| `bl finetune get` | Get details of a single fine-tune job | [finetune.md](finetune.md) |
|
||||
| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
|
||||
| `bl finetune list` | List fine-tune jobs | [finetune.md](finetune.md) |
|
||||
| `bl finetune logs` | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) |
|
||||
| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) |
|
||||
| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) |
|
||||
| Command | Authentication | Description | Detail |
|
||||
| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| `bl dataset delete` | API Key | Delete a dataset file by ID | [dataset.md](dataset.md) |
|
||||
| `bl dataset get` | API Key | Get details of a single dataset file | [dataset.md](dataset.md) |
|
||||
| `bl dataset list` | API Key | List uploaded dataset files | [dataset.md](dataset.md) |
|
||||
| `bl dataset upload` | API Key | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) |
|
||||
| `bl dataset validate` | No Auth | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) |
|
||||
| `bl deploy audio create` | API Key | Create an audio (TTS) model deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy delete` | API Key | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) |
|
||||
| `bl deploy get` | API Key | Get details of a single model deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy image create` | API Key | Create an image generation model deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy list` | API Key | List model deployments | [deploy.md](deploy.md) |
|
||||
| `bl deploy models` | API Key | List models available for deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy scale` | API Key | Scale a deployment's capacity | [deploy.md](deploy.md) |
|
||||
| `bl deploy text create` | API Key | Create a text model deployment | [deploy.md](deploy.md) |
|
||||
| `bl deploy update` | API Key | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) |
|
||||
| `bl finetune audio create` | API Key | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
|
||||
| `bl finetune cancel` | API Key | Cancel a running fine-tune job | [finetune.md](finetune.md) |
|
||||
| `bl finetune capability` | No Auth | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) |
|
||||
| `bl finetune checkpoints` | API Key | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) |
|
||||
| `bl finetune delete` | API Key | Delete a fine-tune job record | [finetune.md](finetune.md) |
|
||||
| `bl finetune export` | API Key | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) |
|
||||
| `bl finetune get` | API Key | Get details of a single fine-tune job | [finetune.md](finetune.md) |
|
||||
| `bl finetune image create` | API Key | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
|
||||
| `bl finetune list` | API Key | List fine-tune jobs | [finetune.md](finetune.md) |
|
||||
| `bl finetune logs` | API Key | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) |
|
||||
| `bl finetune text create` | API Key | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) |
|
||||
| `bl finetune watch` | API Key | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) |
|
||||
|
||||
## By group
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-gen
|
||||
metadata:
|
||||
version: "1.14.2"
|
||||
version: "1.15.0"
|
||||
requires:
|
||||
bins: ["bl"]
|
||||
description: >-
|
||||
@@ -13,12 +13,12 @@ description: >-
|
||||
图片和语音同步返回并落地本地文件,视频是异步任务、用 `--download` 或轮询取回;本地文件直接传路径,CLI 自动上传。
|
||||
反触发:宿主自己能做的图片理解、普通问答、编程、写作、翻译不走本技能;百炼应用/知识库/用量/额度走 bailian-cli;
|
||||
精调训练走 bailian-finetune。
|
||||
官方安装:`npx skills add modelstudioai/cli --all -g`(与共享协议 bailian-protocol 同装)。
|
||||
官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。
|
||||
---
|
||||
|
||||
# Bailian media generation (`bl image` / `bl video` / `bl speech` / `bl omni`)
|
||||
|
||||
**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Provider selection and consent (one-time ask templates), Version & updates (pre-flight checklist), and CLI errors: report an issue. Command details are authoritative in [`reference/`](reference/index.md) and `bl <command> --help` — do not guess flags. If that protocol file is missing, stop and run `npx skills add modelstudioai/cli --all -g`; do not guess auth/consent.**
|
||||
**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Provider selection and consent (one-time ask templates), Version & updates (pre-flight checklist), and CLI errors: report an issue. Command details are authoritative in [`reference/`](reference/index.md) and `bl <command> --help` — do not guess flags. If that protocol file is missing, stop and run `bl skill init`; do not guess auth/consent.**
|
||||
|
||||
## Consent (short version; full rules in bailian-protocol)
|
||||
|
||||
@@ -39,6 +39,8 @@ description: >-
|
||||
| A/V understanding (files the host can't play) | `bl omni --video` / `--audio` | `qwen3.5-omni-plus` |
|
||||
| Image/video describe (user names Bailian) | `bl vision describe` | `qwen-vl-max`; host-first for plain image Q&A |
|
||||
|
||||
For ASR model selection, keep `fun-asr` (or other `*-filetrans`) for long recordings, repeated files, speaker diarization, or asynchronous task IDs. For one local or remote audio file up to about five minutes when the user asks for low-latency Flash models, use `--model fun-asr-flash-2026-06-15`, `--model qwen-audio-3.0-asr-flash`, or `--model qwen3-asr-flash`. Flash recognition is synchronous and accepts exactly one file per call.
|
||||
|
||||
Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl <command> --help` — do not guess flags.
|
||||
|
||||
## Local files (mandatory)
|
||||
@@ -76,7 +78,7 @@ If one or more `bl` commands actually ran, proactively add a one-line summary in
|
||||
|
||||
## Common hand-offs
|
||||
|
||||
软 hand-off(按 skill **名**;已安装则 Read,否则 `--help` / 提示 `npx skills add modelstudioai/cli --all -g`):
|
||||
软 hand-off(按 skill **名**;已安装则 Read,否则 `--help` / 提示 `bl skill init`):
|
||||
|
||||
- Generation failed and it is not a usage/auth/content-filter issue → follow the issue-reporting flow in `bailian-protocol` ([`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md#cli-errors-report-an-issue)) and ask once whether to report.
|
||||
- Managing Bailian apps / knowledge bases / usage → skill `bailian-cli` (fallback: `bl app\|knowledge\|usage --help`).
|
||||
@@ -84,5 +86,5 @@ If one or more `bl` commands actually ran, proactively add a one-line summary in
|
||||
|
||||
## references
|
||||
|
||||
- [bailian-protocol](../bailian-protocol/SKILL.md) — shared protocol (install via `--all -g`)
|
||||
- [bailian-protocol](../bailian-protocol/SKILL.md) — shared protocol (install via `bl skill init`)
|
||||
- [reference/](reference/index.md) — command details
|
||||
|
||||
@@ -7,20 +7,21 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| `bl image edit` | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) |
|
||||
| `bl image generate` | Generate images (Qwen-Image / wan2.x) |
|
||||
| Command | Authentication | Description |
|
||||
| ------------------- | -------------- | -------------------------------------------------------------------- |
|
||||
| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) |
|
||||
| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl image edit`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | -------------------------------------------------------------------- |
|
||||
| **Name** | `image edit` |
|
||||
| **Description** | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) |
|
||||
| **Usage** | `bl image edit --image <url> --prompt <text> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | -------------------------------------------------------------------- |
|
||||
| **Name** | `image edit` |
|
||||
| **Description** | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl image edit --image <url> --prompt <text> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
@@ -80,11 +81,12 @@ bl image edit --image ./photo.png --prompt "Replace the background with a beach"
|
||||
|
||||
### `bl image generate`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------- |
|
||||
| **Name** | `image generate` |
|
||||
| **Description** | Generate images (Qwen-Image / wan2.x) |
|
||||
| **Usage** | `bl image generate --prompt <text> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ------------------------------------------- |
|
||||
| **Name** | `image generate` |
|
||||
| **Description** | Generate images (Qwen-Image / wan2.x) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl image generate --prompt <text> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
@@ -9,19 +9,19 @@ Use this index for the skill-scoped quick index and global flags.
|
||||
|
||||
## Quick index
|
||||
|
||||
| Command | Description | Detail |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| `bl image edit` | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) |
|
||||
| `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) |
|
||||
| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
|
||||
| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) |
|
||||
| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) |
|
||||
| `bl video download` | Download a completed video by task ID | [video.md](video.md) |
|
||||
| `bl video edit` | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) |
|
||||
| `bl video generate` | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) |
|
||||
| `bl video ref` | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) |
|
||||
| `bl video task get` | Query async task status | [video.md](video.md) |
|
||||
| `bl vision describe` | Describe an image or video using Qwen-VL | [vision.md](vision.md) |
|
||||
| Command | Authentication | Description | Detail |
|
||||
| ---------------------- | -------------- | ----------------------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) |
|
||||
| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) |
|
||||
| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
|
||||
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) |
|
||||
| `bl speech synthesize` | API Key | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) |
|
||||
| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) |
|
||||
| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) |
|
||||
| `bl video generate` | API Key | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) |
|
||||
| `bl video ref` | API Key | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) |
|
||||
| `bl video task get` | API Key | Query async task status | [video.md](video.md) |
|
||||
| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) |
|
||||
|
||||
## By group
|
||||
|
||||
|
||||
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) |
|
||||
| Command | Authentication | Description |
|
||||
| --------- | -------------- | ---------------------------------------------------- |
|
||||
| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl omni`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| **Name** | `omni` |
|
||||
| **Description** | Multimodal chat with text + audio output (Qwen-Omni) |
|
||||
| **Usage** | `bl omni --message <text> [flags]` |
|
||||
| Field | Value |
|
||||
| ------------------ | ---------------------------------------------------- |
|
||||
| **Name** | `omni` |
|
||||
| **Description** | Multimodal chat with text + audio output (Qwen-Omni) |
|
||||
| **Authentication** | API Key |
|
||||
| **Usage** | `bl omni --message <text> [flags]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user