Compare commits

..

2 Commits

Author SHA1 Message Date
rendianmeng 689b499a32 feat: qwenwork connector test 2026-08-10 14:12:11 +08:00
rendianmeng 5d4ead8d7d feat: qwenwork connector test 2026-08-10 14:09:01 +08:00
123 changed files with 2057 additions and 6071 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ packages/core/src/auth/ # apiKey / console credential 解析与落盘
packages/core/src/client/ # HTTP client / endpoints / console gateway
```
Skill / 命令手册随 `skills/bailian-*/``bl skill init` 安装(装齐 registry 中全部 `bailian-*`,含共享协议 `bailian-protocol`)。业务 skill`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent`)执行前读 `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-*/``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)。
约定:
-37
View File
@@ -6,43 +6,6 @@ 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
-37
View File
@@ -6,43 +6,6 @@
[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
### 新增
+31 -34
View File
@@ -1,38 +1,8 @@
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**在已有 Node.js**≥ 18.17.0**)的环境用 npm 安装;若没有可用 Node / npm再走二进制一键安装。不要臆造版本号或路径;以用户环境为准。
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**使用二进制一键安装(无需 Node若环境已有 Node / 需要 Command Pack再用 npm。不要臆造版本号或路径;以用户环境为准。
## 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`
安装 skillsCLI 内置,无需 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`
## 1. 推荐:二进制安装(无需 Node
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
@@ -67,9 +37,36 @@ bl --version
which bl # Windows: where.exe bl
```
若自动 skill 安装失败,再手动执行:`bl skill init`
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装
> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node回退到上方 npm 安装。
## 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 及用户可做什么。
---
+2 -18
View File
@@ -82,31 +82,15 @@ 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
```
**Install with NPM**
**Manual install (npm)**
```bash
npm install -g bailian-cli
bl skill init
npx skills add modelstudioai/cli --all -g
```
> 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.
+2 -18
View File
@@ -81,31 +81,15 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
请阅读https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
```
**NPM 安装**
**手动安装npm**
```bash
npm install -g bailian-cli
bl skill init
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 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 中描述你的任务,无需手动拼接命令。
+11 -12
View File
@@ -11,17 +11,16 @@
## 统一口径(安装)
1. **Supported install** `bl skill init`(装齐 registry 中全部 `bailian-*`,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它
1. **Supported install** `npx skills add modelstudioai/cli --all -g`(整包装齐,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它Agent Skills / `npx skills` **不会**按 frontmatter 自动拉依赖
3. **不要**在 frontmatter 写 `companions`也不要对外说「companions = 安装器硬依赖」
4. 子集安装`bl skill add --name bailian-protocol,<skill>`;漏装 protocol 会导致相对路径 Read 失败
5. **`bl skill add --all`** 安装 registry 全量(含 `spark-video` 等非 bailian 技能);一键安装 / `bl update``skill init`,不要用 `--all`
4. 子集安装`-s`)为 **advanced / 不推荐**skills CLI 不会自动带上 protocol;漏装会导致相对路径 Read 失败
## 概念图
```text
bailian-protocol ← 共享协议consent / 鉴权 / 版本 / 错误上报)
▲ 靠 `bl skill init` 与业务 skill 同装;非安装器强制 companions
▲ 靠 --all -g 与业务 skill 同装;非安装器强制 companions
┌───────┴────────┬────────────────┬──────────────────┐
bailian-gen bailian-finetune bailian-managed-agent
@@ -38,8 +37,8 @@ bailian-gen bailian-finetune bailian-managed-agent
### A. 分层边界
- [ ] **整包装齐**:安装/升级文案主推 `bl skill init`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init`
- [ ] **整包装齐**:安装/升级文案主推 `--all -g`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `npx skills add modelstudioai/cli --all -g`
- [ ] **软 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」指向句
@@ -47,9 +46,9 @@ bailian-gen bailian-finetune bailian-managed-agent
### B. 文案与落款一致性
- [ ] 领域 skillgen / finetune / managed-agent路由或命令表后有指向 `reference/` 的句;文末 `## references`protocol + reference与家族对齐
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `bl skill init`,不写 companions 必装
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `--all -g`,不写 companions 必装
- [ ] Quick examples 只演示本 skill 职责hub 不示范 `bl image` / `bl video` 等)
- [ ] 若改了安装方式:同步 `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) 同步静态页)
- [ ] 若改了安装方式:同步 `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) 同步静态页)
### C. 归属与生成
@@ -61,8 +60,8 @@ bailian-gen bailian-finetune bailian-managed-agent
```sh
pnpm run sync:skill-assets
# 已发布版本试装
bl skill init
# 本地试装(测本仓库改动,勿只拉远端)
npx skills add "$(pwd)" --all -g -y
```
抽查:打开 `skills/bailian-cli/SKILL.md` 确认无领域子命令明细表、无 `companions`;打开对应领域 skill 确认有「勿猜 flag」与 hand-off。
@@ -70,7 +69,7 @@ bl skill init
## 常见漏点
- ✗ hub 路由表再次抄回 image / video / finetune / managed-agent 明细 → token 膨胀且与领域 skill 双份漂移
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 `bl skill add` 合同不符
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 Agent Skills / `npx skills` 合同不符
- ✗ 软 hand-off 写成硬路径 `../bailian-*/SKILL.md` 当执行前提 → 子集安装断链
- ✗ 只改 SKILL、忘改 `GROUP_OWNER_SKILL` → reference 落错 skill
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖
+2 -18
View File
@@ -82,31 +82,15 @@ 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
```
**Install with NPM**
**Manual install (npm)**
```bash
npm install -g bailian-cli
bl skill init
npx skills add modelstudioai/cli --all -g
```
> 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.
+2 -18
View File
@@ -81,31 +81,15 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
请阅读https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
```
**NPM 安装**
**手动安装npm**
```bash
npm install -g bailian-cli
bl skill init
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.15.0",
"version": "1.14.2",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
-4
View File
@@ -45,8 +45,6 @@ import {
usageFreetier,
usageStats,
usageSummary,
usageTokenPlan,
usageCodingPlan,
pipelineRun,
pipelineValidate,
advisorRecommend,
@@ -166,8 +164,6 @@ 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,15 +7,10 @@ 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/);
@@ -23,24 +18,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.15.0",
"version": "1.14.2",
"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 `bl skill add`).
// - Agent skills installed under ~/.agents/skills (via `npx skills 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. `bl skill init` /
* `bl skill add` fans skills out into each installed agent, so the same skill can
* 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
* 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)">bl skill 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)">npx skills 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>bl skill init</code>'); return; }
if (!SKILLS.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills installed.', 'Install with <code>npx skills add modelstudioai/cli --all -g</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.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`--api zeldaEasy.broadscope-bailian.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, isStreamableHttpUnsupported } from "bailian-cli-core";
import { BailianError } 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,28 +26,14 @@ 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 (!(error instanceof BailianError) || error.hint) {
throw error;
}
if (isMcpNotActivated(error)) {
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
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;
}
+7 -11
View File
@@ -36,8 +36,7 @@ const CALL_FLAGS = {
url: {
type: "string",
valueHint: "<url>",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
},
} satisfies FlagsDef;
type CallFlags = ParsedFlags<typeof CALL_FLAGS>;
@@ -115,14 +114,14 @@ export default defineCommand({
const { serverCode, toolName } = parseTarget(flags.target);
const toolArgs = buildToolArgs(flags);
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const url = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
server: serverCode,
url: previewUrl,
url,
tool: toolName,
arguments: toolArgs,
},
@@ -131,14 +130,13 @@ export default defineCommand({
return;
}
let client: { close?(): void } | undefined;
const client = ctx.client.mcp(url);
try {
const connected = await ctx.client.connectBailianMcp(serverCode, flags.url);
client = connected.client;
const result = await connected.client.callTool(toolName, toolArgs);
await client.initialize();
const result = await client.callTool(toolName, toolArgs);
if (result.isError) {
const errText = result.content.map((contentItem) => contentItem.text || "").join("\n");
const errText = result.content.map((c) => c.text || "").join("\n");
throw new BailianError(`Tool error: ${errText}`);
}
@@ -148,8 +146,6 @@ export default defineCommand({
rethrowWithMcpActivateHint(error, serverCode);
}
throw error;
} finally {
client?.close?.();
}
},
});
+7 -11
View File
@@ -16,8 +16,7 @@ export default defineCommand({
url: {
type: "string",
valueHint: "<url>",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
},
},
exampleArgs: [
@@ -29,27 +28,24 @@ export default defineCommand({
const { settings, flags } = ctx;
const code = flags.server;
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(code));
const url = flags.url || ctx.client.url(bailianMcpPath(code));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ server: code, url: previewUrl, action: "tools/list" }, format);
emitResult({ server: code, url, action: "tools/list" }, format);
return;
}
let client: { close?(): void } | undefined;
const client = ctx.client.mcp(url);
try {
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);
await client.initialize();
const tools = await client.listTools();
emitResult({ server: code, url, tools }, format);
} catch (error) {
if (!flags.url) {
rethrowWithMcpActivateHint(error, code);
}
throw error;
} finally {
client?.close?.();
}
},
});
@@ -12,13 +12,6 @@ import {
stripUndefined,
taskPath,
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
type AsrApiRoute,
type AsrFlashFamily,
type OutputFormat,
type FlagsDef,
type ParsedFlags,
@@ -34,18 +27,8 @@ 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). 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",
},
model: { type: "string", valueHint: "<model>", description: "Model ID (default: fun-asr)" },
language: { type: "string", valueHint: "<lang>", description: "Language hint (e.g. zh, en, ja)" },
diarization: { type: "switch", description: "Enable automatic speaker diarization" },
speakerCount: {
type: "number",
@@ -72,33 +55,8 @@ 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 / Qwen-ASR Flash)",
description: "Recognize speech from audio files (FunAudio-ASR)",
auth: "apiKey",
usageArgs: "--url <audio-url> [flags]",
flags: RECOGNIZE_FLAGS,
@@ -110,7 +68,6 @@ 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;
@@ -133,70 +90,22 @@ 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((url) => ctx.client.uploadFile(url, model)));
if (route.kind === "sync-flash") {
await handleSyncFlashMode(
ctx.client,
settings,
flags,
format,
model,
route,
resolvedUrls[0]!,
);
return;
}
const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model)));
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:
route.asyncInputStyle === "file_url"
? { file_url: resolvedUrls[0]! }
: { file_urls: resolvedUrls },
input: {
file_urls: resolvedUrls,
},
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
...languageFields,
language_hints: language ? [language] : undefined,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
@@ -207,7 +116,7 @@ export default defineCommand({
stripUndefined(body.parameters as Record<string, unknown>);
if (settings.dryRun) {
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
emitResult({ request: body, mode: "async" }, format);
return;
}
@@ -219,55 +128,6 @@ 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,
@@ -300,16 +160,16 @@ async function handleAsyncMode(
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: settings.timeout,
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;
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;
},
});
const results = collectAsrTranscriptionItems(result.output);
const results = result.output.results ?? [];
if (results.length === 0) {
emitResult({ task_id: taskId, status: result.output.task_status }, format);
@@ -319,14 +179,12 @@ async function handleAsyncMode(
// Collect all transcription data for --out
const allTransData: Record<string, unknown>[] = [];
for (let index = 0; index < results.length; index++) {
const subResult = results[index]!;
for (let i = 0; i < results.length; i++) {
const subResult = results[i]!;
const isMulti = fileCount > 1;
if (isMulti) {
process.stdout.write(
`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`,
);
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
}
if (subResult.subtask_status === "FAILED") {
+26 -96
View File
@@ -1,35 +1,20 @@
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",
@@ -87,31 +72,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 parsedMessage of parsed) {
if (parsedMessage.role === "system") {
system = typeof parsedMessage.content === "string" ? parsedMessage.content : "";
for (const m of parsed) {
if (m.role === "system") {
system = typeof m.content === "string" ? m.content : "";
} else {
messages.push(parsedMessage as ChatMessage);
messages.push(m as ChatMessage);
}
}
}
if (flags.message) {
const validRoles = new Set(["system", "user", "assistant"]);
const messageValues = flags.message;
for (const messageValue of messageValues) {
const colonIndex = messageValue.indexOf(":");
const maybeRole = colonIndex !== -1 ? messageValue.slice(0, colonIndex) : "";
const msgs = flags.message;
for (const m of msgs) {
const colonIdx = m.indexOf(":");
const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : "";
if (validRoles.has(maybeRole)) {
const content = messageValue.slice(colonIndex + 1);
const content = m.slice(colonIdx + 1);
if (maybeRole === "system") {
system = content;
} else {
messages.push({ role: maybeRole as "user" | "assistant", content });
}
} else {
messages.push({ role: "user", content: messageValue });
messages.push({ role: "user", content: m });
}
}
}
@@ -120,33 +105,24 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
}
export default defineCommand({
description: "Send a text model request (OpenAI compatible, DashScope)",
description: "Send a chat completion (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: (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;
},
validate: (f) =>
!f.message && !f.messagesFile ? "Provide --message or --messages-file." : 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);
@@ -158,39 +134,29 @@ export default defineCommand({
}
allMessages.push(...messages);
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,
};
}
const body: ChatRequest = {
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 (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) {
if (flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget;
}
}
if (flags.tool) {
const tools = flags.tool.map((toolValue) => {
const tools = flags.tool.map((t) => {
try {
return JSON.parse(toolValue);
return JSON.parse(t);
} catch {
const raw = readFileSync(toolValue, "utf-8");
const raw = readFileSync(t, "utf-8");
return JSON.parse(raw);
}
});
@@ -203,8 +169,8 @@ export default defineCommand({
}
if (shouldStream) {
const responseStream = await ctx.client.request({
path: api === "responses" ? responsesPath() : chatPath(),
const res = await ctx.client.request({
path: chatPath(),
method: "POST",
body,
stream: true,
@@ -212,7 +178,6 @@ export default defineCommand({
let textContent = "";
let inThinking = false;
let responsesCompleted = false;
const writesStreamingStdout = format === "text";
const isTTY = process.stdout.isTTY;
const statusOut =
@@ -220,28 +185,8 @@ export default defineCommand({
const resultOut = process.stdout;
const statusColor = ansi(statusOut);
for await (const event of parseSSE(responseStream)) {
for await (const event of parseSSE(res)) {
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;
@@ -271,7 +216,6 @@ export default defineCommand({
// Skip unparseable chunks
}
}
if (api === "responses") assertResponsesStreamCompleted(responsesCompleted);
if (inThinking) statusOut.write(statusColor.reset);
if (format === "json") {
@@ -279,20 +223,6 @@ 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(),
@@ -1,76 +0,0 @@
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,
);
}
+2 -2
View File
@@ -20,7 +20,8 @@ import {
type AnsiStyles,
} from "bailian-cli-runtime";
const SKILL_INSTALL_CMD = "bl skill init";
const SKILL_SOURCE = "modelstudioai/cli";
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
function updateAgentSkill(color: AnsiStyles): void {
process.stderr.write("\nUpdating agent skill...\n");
@@ -142,7 +143,6 @@ 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 =
@@ -1,132 +0,0 @@
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,22 +1,95 @@
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, fetchModelList, type Client } 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.bailian-commerce.freeTrial.batchActivateFreeTierOnly";
const DEACTIVATE_API = "zeldaEasy.bailian-commerce.freeTrial.batchDeactivateFreeTierOnly";
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;
}
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",
@@ -88,7 +161,7 @@ export default defineCommand({
}
if (!modelFlag) {
models = (await fetchAllModels(ctx.client)).map((model) => model.name);
models = await fetchAllModelNames(ctx.client);
}
if (off) {
@@ -99,10 +172,12 @@ export default defineCommand({
}),
]);
const quotas = extractQuotas(quotaResult);
const quotaData = extractResponseData(quotaResult as Record<string, unknown>);
const quotas = (quotaData.freeTierQuotas ?? []) as FreeTierQuota[];
const quotaMap = new Map(quotas.map((quota) => [quota.model, quota]));
const stopStatuses = extractFreeTierOnlyStatuses(stopResult);
const stopData = extractResponseData(stopResult as Record<string, unknown>);
const stopStatuses = (stopData.freeTierOnlyStatuses ?? []) as FreeTierOnlyStatus[];
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
for (const name of models) {
@@ -117,7 +192,7 @@ export default defineCommand({
);
continue;
}
await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
await pollUntilDone(ctx.client, api, requestKey, [name]);
process.stdout.write(`Disabled auto-stop for "${name}".\n`);
}
return;
@@ -125,13 +200,13 @@ export default defineCommand({
const jsonResults: unknown[] = [];
for (const name of models) {
const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
const result = await pollUntilDone(ctx.client, api, requestKey, [name]);
if (format === "json") {
jsonResults.push(result);
continue;
}
if (result) {
const resultData = unwrapResponse(result as Record<string, unknown>);
const resultData = extractResponseData(result as Record<string, unknown>);
const failureModels = (resultData.failureModels as BatchResultFailure[]) ?? [];
if (failureModels.length > 0) {
process.stderr.write(
@@ -1,91 +0,0 @@
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`);
}
+12 -51
View File
@@ -24,14 +24,6 @@ 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;
@@ -110,9 +102,9 @@ export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
// Free-tier quota
// ---------------------------------------------------------------------------
export const FREE_TIER_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota";
export const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
export const FREE_TIER_ONLY_STATUS_API =
"zeldaEasy.bailian-commerce.freeTrial.queryFreeTierOnlyStatus";
"zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
export interface FreeTierQuota {
model: string;
@@ -265,27 +257,22 @@ export interface ListStatisticResponse {
}
const POLL_INTERVAL_MS = 500;
const DEFAULT_MAX_POLLS = 30;
const MAX_POLLS = 30;
/**
* 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(
export async function pollTelemetryApi(
client: Client,
api: string,
buildRequest: (taskId: string | undefined) => Record<string, unknown>,
maxPolls = DEFAULT_MAX_POLLS,
reqDTO: Record<string, unknown>,
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < maxPolls; attempt++) {
const raw = await client.console(api, buildRequest(nextTaskId));
const resp = unwrapResponse(raw as Record<string, unknown>);
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;
@@ -297,32 +284,6 @@ export async function pollConsoleUntilDone(
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) {
+165 -15
View File
@@ -1,26 +1,176 @@
import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core";
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
type Settings,
type Client,
} from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
import {
LIST_API,
OVERVIEW_API,
USAGE_KEY_LABELS,
extractListData,
extractOverviewData,
formatDate,
formatNumber,
pollTelemetryApi,
requireWorkspaceId,
resolveUsageMap,
type ModelStatisticItem,
type OverviewStatistic,
} from "./shared.ts";
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;
}
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}`;
@@ -1,77 +0,0 @@
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());
},
});
-2
View File
@@ -48,8 +48,6 @@ 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";
@@ -1,213 +0,0 @@
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,6 +1,4 @@
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 {
@@ -18,37 +16,6 @@ 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",
@@ -58,217 +25,6 @@ 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,37 +10,8 @@ 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 chatDashScope", () => {
@@ -74,42 +45,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chatDashScope", () => {
request?: { model?: string; messages?: Array<{ content?: string }> };
}>(stdout);
expect(data.request?.model).toBe("qwen3.8-max");
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");
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
});
test("【qwen3.8-max】文本对话", async () => {
@@ -109,8 +109,6 @@ 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 = {
@@ -1,80 +0,0 @@
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-planConsole", () => {
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");
});
});
@@ -1,76 +0,0 @@
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-planConsole", () => {
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,12 +26,6 @@ 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 广场深链", () => {
@@ -44,36 +38,6 @@ 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(
@@ -1,138 +0,0 @@
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.",
);
});
});
@@ -1,195 +0,0 @@
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 });
});
});
@@ -1,62 +0,0 @@
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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.15.0",
"version": "1.14.2",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
-327
View File
@@ -1,327 +0,0 @@
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 [];
}
+1 -26
View File
@@ -5,13 +5,7 @@ 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 {
bailianMcpPath,
bailianMcpSsePath,
connectBailianMcpWithFallback,
McpClient,
type McpConnectedClient,
} from "./mcp.ts";
import { McpClient } from "./mcp.ts";
import { callConsoleGateway } from "../console/gateway.ts";
import { refreshAccessToken } from "../auth/refresh-token.ts";
import { maskToken } from "../utils/token.ts";
@@ -170,25 +164,6 @@ 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);
-5
View File
@@ -6,11 +6,6 @@ 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 {
+2 -27
View File
@@ -14,7 +14,6 @@ export {
memorySearchPath,
mcpWebSearchPath,
profileSchemaPath,
responsesPath,
speechRecognizePath,
speechSynthesizePath,
taskPath,
@@ -35,18 +34,6 @@ 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";
@@ -70,19 +57,7 @@ export {
type AcsQueryParams,
type AcsSignConfig,
} from "./acs.ts";
export type {
McpTool,
McpToolResult,
McpConnectedClient,
ConnectBailianMcpOptions,
} from "./mcp.ts";
export {
McpClient,
bailianMcpPath,
bailianMcpSsePath,
isStreamableHttpUnsupported,
isUrlOverrideSseFallbackCandidate,
connectBailianMcpWithFallback,
} from "./mcp.ts";
export type { McpTool, McpToolResult } from "./mcp.ts";
export { McpClient, bailianMcpPath } from "./mcp.ts";
export type { ServerSentEvent } from "./stream.ts";
export { parseSSE } from "./stream.ts";
-474
View File
@@ -1,474 +0,0 @@
/**
* 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;
},
};
}
+2 -137
View File
@@ -15,8 +15,6 @@ 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 ----
@@ -29,7 +27,7 @@ interface JsonRpcRequest {
interface JsonRpcResponse {
jsonrpc: "2.0";
id?: number | string | null;
id: number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
@@ -63,101 +61,6 @@ 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 {
@@ -218,7 +121,7 @@ export class McpClient {
};
const response = await this.send(body);
const data = await this.readJsonRpcResponse(response, id);
const data = (await response.json()) as JsonRpcResponse;
if (data.error) {
throw new BailianError(
@@ -240,44 +143,6 @@ 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",
+46 -97
View File
@@ -7,71 +7,6 @@ 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;
@@ -79,55 +14,69 @@ 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) {
// 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;
}
if (done) 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, rest } = takeNormalizedSseLines(buffer);
buffer = rest;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let event: Partial<ServerSentEvent> = {};
for (const line of lines) {
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
event = applied.event;
if (applied.completed) {
yield applied.completed;
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;
}
}
}
// 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 };
// Flush remaining
if (buffer.trim() && buffer.includes("data:")) {
const colonIndex = buffer.indexOf(":");
if (colonIndex !== -1) {
yield { data: buffer.slice(colonIndex + 1).trimStart() };
}
}
} finally {
reader.releaseLock();
+1 -1
View File
@@ -58,7 +58,7 @@ export function effectiveConsoleGatewayConfig(
}
export interface ConsoleGatewayRequest {
/** Console API name, e.g. zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota */
/** Console API name, e.g. zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota */
api: string;
data: Record<string, unknown>;
}
+7 -58
View File
@@ -108,44 +108,6 @@ 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 {
@@ -571,46 +533,33 @@ export interface DashScopeTTSStreamChunk {
export interface DashScopeASRRequest {
model: string;
input: {
file_urls?: string[];
file_url?: string;
file_urls: 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";
/** Multi-file async results (fun-asr / paraformer / qwen-audio filetrans, etc.) */
results?: DashScopeASRTranscriptionItem[];
/** Singular result returned by qwen3-asr-flash-filetrans* on success */
result?: {
results?: Array<{
file_url?: string;
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;
-1
View File
@@ -48,7 +48,6 @@ export type {
ChatTool,
DashScopeASRRequest,
DashScopeASRTaskResult,
DashScopeASRTranscriptionItem,
DashScopeAsyncResponse,
DashScopeImageRequest,
DashScopeImageSyncResponse,
-213
View File
@@ -1,213 +0,0 @@
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([]);
});
-736
View File
@@ -1,736 +0,0 @@
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成功走 Streamable405 降级 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("connectBailianMcpWithFallbackWebSearch 不降级urlOverride 同 URL 降级 SSE404 不降级 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("McpSseClientstring 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("McpSseClientfetch 失败抛出原始 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("McpSseClientfetch 失败后同实例可重新 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("McpSseClientclose 可中止进行中的 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("McpSseClientPOST 非 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;
}
});
@@ -1,8 +0,0 @@
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");
});
});
-79
View File
@@ -1,79 +0,0 @@
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("parseSSECRLF 行尾可解析 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("parseSSEEOF without blank line keeps event type", async () => {
const events = await collectEvents(["event: endpoint\ndata: /message"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSEEOF data-only flush keeps prior behavior", async () => {
const events = await collectEvents(["data: plain"]);
expect(events).toEqual([{ data: "plain" }]);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "knowledge-studio-cli",
"version": "1.15.0",
"version": "1.14.2",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.15.0",
"version": "1.14.2",
"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": {
@@ -40,10 +40,12 @@
"check": "vp check"
},
"dependencies": {
"@modelcontextprotocol/server": "catalog:",
"bailian-cli-core": "workspace:*",
"boxen": "catalog:",
"chalk": "catalog:",
"undici": "catalog:"
"undici": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@clack/prompts": "^0.7.0",
+30 -1
View File
@@ -1,4 +1,4 @@
import { parseFlags } from "./args.ts";
import { parseFlags, parsePath } from "./args.ts";
import { CommandRegistry } from "./registry.ts";
import { resolve } from "./resolve.ts";
import {
@@ -32,6 +32,8 @@ import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { loadCommandPacks } from "./command-packs/load.ts";
import { createCommandPackManager } from "./command-packs/manager.ts";
import type { CommandPackPolicy } from "./command-packs/types.ts";
import { printMcpServeHelp } from "./mcp-server/help.ts";
import { serveMcpStdio } from "./mcp-server/serve.ts";
/** Per-product identity injected by each CLI entrypoint (bl / rag / …). */
export interface CliOptions {
@@ -112,6 +114,11 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
/** Render help for `path`; root ([]) doubles as the onboarding / login guide. */
function renderHelp(registry: CommandRegistry, path: string[], argv: string[]): void {
registry.printHelp(path, process.stderr);
if (path.length === 1 && path[0] === "mcp") {
process.stderr.write(
`\nAlso available (runtime built-in):\n mcp serve Start a local STDIO MCP server exposing all CLI commands as tools\n`,
);
}
if (path.length > 0) return;
let hasKey = false;
@@ -138,6 +145,28 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
}
async function dispatch(registry: CommandRegistry, argv: string[]): Promise<void> {
const parsed = parsePath(argv);
// Handle --version before path-specific dispatch (including mcp serve).
if (parsed.hasVersionFlag) {
process.stdout.write(`${binName} ${version}\n`);
return;
}
// Runtime built-in: local STDIO MCP host. Not a defineCommand leaf — needs the
// full registry to mount tools, so it lives here instead of packages/commands.
if (parsed.path[0] === "mcp" && parsed.path[1] === "serve") {
if (parsed.hasHelpFlag) {
printMcpServeHelp(binName);
return;
}
await serveMcpStdio({
identity,
leaves: registry.getLeafEntries(),
commandPacks: commandPackManager,
});
return;
}
const res = resolve(argv, registry);
switch (res.kind) {
+1 -2
View File
@@ -78,12 +78,11 @@ 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: cause ?? err },
{ cause: err },
);
}
+13 -7
View File
@@ -42,13 +42,7 @@ export {
// Output facilities consumed by commands
export { emitResult, emitBare, emitRequestId } from "./output/output.ts";
export { formatTable } from "./output/table.ts";
export {
renderBoxTable,
renderGauge,
type BoxTableOptions,
type BarColumn,
type GaugeCell,
} from "./output/box-table.ts";
export { renderBoxTable, type BoxTableOptions, type BarColumn } 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";
@@ -101,3 +95,15 @@ export { initPipelineSteps } from "./pipeline/init.ts";
export { executePipeline, streamPipelineEvents } from "./pipeline/executor.ts";
export { collectPipelineIssues, collectPipelineHints } from "./pipeline/validation.ts";
export type { PipelineDefinition, PipelineLifecycleEvent } from "./pipeline/types.ts";
// Local STDIO MCP server (bl mcp serve)
export { serveMcpStdio } from "./mcp-server/serve.ts";
export type { ServeMcpStdioOptions } from "./mcp-server/serve.ts";
export {
buildToolDescriptors,
flagsToInputSchema,
flagsToZodObject,
pathToToolName,
} from "./mcp-server/schema.ts";
export type { JsonSchemaObject, McpToolDescriptor } from "./mcp-server/schema.ts";
export { withCapturedOutput } from "./mcp-server/output-capture.ts";
+19
View File
@@ -0,0 +1,19 @@
/** Help text for the runtime built-in `mcp serve` path. */
export function printMcpServeHelp(binName: string): void {
process.stderr.write(
[
"Start a local STDIO MCP server exposing all CLI commands as tools (for connectors such as QwenWork)",
`Usage: ${binName} mcp serve`,
"",
"Notes:",
" Speaks MCP over stdin/stdout. Do not treat this process as a normal CLI that prints results to stdout.",
` Authenticate first with \`${binName} auth login\` (or env credentials); tools reuse the same local credential resolution as the CLI.`,
` Distinct from \`${binName} mcp list|tools|call\`, which call Bailian marketplace MCP servers.`,
" This path is a runtime built-in (not a commands-library leaf), so it can mount the full product command map.",
"",
"Examples:",
` ${binName} mcp serve`,
"",
].join("\n"),
);
}
+119
View File
@@ -0,0 +1,119 @@
import type {
AnyCommand,
CommandPackManager,
FlagDef,
FlagsDef,
Identity,
ParsedFlags,
} from "bailian-cli-core";
import {
BailianError,
Client,
ExitCode,
UsageError,
buildSettings,
buildSources,
makeAuthStore,
makeConfigStore,
resolveModelBaseUrl,
} from "bailian-cli-core";
import { camelToKebab } from "../args.ts";
import { compose, authStage, runCommandStage, type RunContext } from "../middleware.ts";
import { withCapturedOutput } from "./output-capture.ts";
export interface InvokeCommandOptions {
identity: Identity;
path: string[];
command: AnyCommand;
/** Tool arguments keyed by camelCase flag names. */
args: Record<string, unknown>;
commandPacks: CommandPackManager;
}
function coerceOwnFlags(command: AnyCommand, args: Record<string, unknown>): ParsedFlags<FlagsDef> {
const defs: FlagsDef = command.flags ?? {};
const ownFlags: Record<string, unknown> = {};
for (const key of Object.keys(defs)) {
const def: FlagDef = defs[key]!;
if (key in args) {
ownFlags[key] = args[key];
continue;
}
if (def.type === "switch") {
ownFlags[key] = false;
}
}
for (const key of Object.keys(defs)) {
const def: FlagDef = defs[key]!;
if (def.type !== "switch" && "required" in def && def.required && !(key in ownFlags)) {
throw new UsageError(`Missing required flag: --${camelToKebab(key)}`);
}
}
const invalid = command.validate?.(ownFlags as ParsedFlags<FlagsDef>);
if (invalid) throw new UsageError(invalid);
return ownFlags as ParsedFlags<FlagsDef>;
}
function formatInvokeError(error: unknown): string {
if (error instanceof BailianError) {
const parts = [error.message];
if (error.hint) parts.push(error.hint);
return parts.join("\n");
}
if (error instanceof Error) return error.message;
return String(error);
}
/**
* Run one leaf command under MCP: force JSON + quiet, capture emitResult/emitBare,
* reuse auth stage. Does not write to process.stdout.
*/
export async function invokeCommandForMcp(
options: InvokeCommandOptions,
): Promise<{ ok: true; text: string } | { ok: false; text: string }> {
try {
const ownFlags = coerceOwnFlags(options.command, options.args);
const sources = buildSources({});
const settings = {
...buildSettings(sources),
quiet: true,
output: "json" as const,
verbose: false,
};
const ctx: RunContext = {
identity: options.identity,
path: options.path,
command: options.command,
flags: ownFlags,
settings,
sources,
configStore: makeConfigStore(sources.configName),
authStore: makeAuthStore(sources),
commandPacks: options.commandPacks,
client: new Client({
identity: options.identity,
settings,
baseUrl: resolveModelBaseUrl(sources),
}),
};
const run = compose([authStage, runCommandStage]);
const { stdout } = await withCapturedOutput(() => run(ctx));
const text = stdout.trimEnd() || JSON.stringify({ ok: true });
return { ok: true, text };
} catch (error) {
const text = formatInvokeError(error);
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
return {
ok: false,
text: `${text}\n\nAuthenticate in a terminal first: ${options.identity.binName} auth login`,
};
}
return { ok: false, text };
}
}
@@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
interface CaptureState {
chunks: string[];
}
const captureStore = new AsyncLocalStorage<CaptureState>();
/** True when {@link emitResult} / {@link emitBare} should buffer instead of writing stdout. */
export function isCapturingOutput(): boolean {
return captureStore.getStore() !== undefined;
}
/** Append a line to the active capture buffer. No-op outside {@link withCapturedOutput}. */
export function appendCapturedOutput(chunk: string): void {
const state = captureStore.getStore();
if (state) state.chunks.push(chunk);
}
/**
* Run `fn` while diverting {@link emitResult} / {@link emitBare} into a buffer
* so MCP STDIO can keep exclusive ownership of process.stdout.
*/
export async function withCapturedOutput<T>(
fn: () => Promise<T>,
): Promise<{ value: T; stdout: string }> {
const state: CaptureState = { chunks: [] };
const value = await captureStore.run(state, fn);
return { value, stdout: state.chunks.join("") };
}
+129
View File
@@ -0,0 +1,129 @@
import type { AnyCommand, FlagDef, FlagsDef } from "bailian-cli-core";
import { z } from "zod";
/** JSON Schema object shape (used in unit tests / descriptor snapshots). */
export interface JsonSchemaObject {
type: "object";
properties: Record<string, Record<string, unknown>>;
required?: string[];
additionalProperties?: boolean;
}
/** Zod object schema for `McpServer.registerTool({ inputSchema })`. */
export function flagsToZodObject(flags: FlagsDef | undefined) {
const shape: Record<string, z.ZodTypeAny> = {};
for (const key of Object.keys(flags ?? {})) {
const def: FlagDef = flags![key]!;
let schema: z.ZodTypeAny;
if (def.type === "switch" || def.type === "boolean") {
schema = z.boolean();
} else if (def.type === "number") {
schema = z.number();
} else if (def.type === "array") {
const item =
def.choices && def.choices.length > 0
? z.enum(def.choices as [string, ...string[]])
: z.string();
schema = z.array(item);
} else if (def.choices && def.choices.length > 0) {
schema = z.enum(def.choices as [string, ...string[]]);
} else {
schema = z.string();
}
schema = schema.describe(def.description);
const required = def.type !== "switch" && "required" in def && !!def.required;
shape[key] = required ? schema : schema.optional();
}
return z.object(shape);
}
/** Stable MCP tool name from a space-separated command path, e.g. `text chat` → `bailian_text_chat`. */
export function pathToToolName(path: string, prefix = "bailian"): string {
const slug = path
.trim()
.split(/\s+/)
.join("_")
.replace(/[^a-zA-Z0-9_-]/g, "_");
return `${prefix}_${slug}`;
}
function flagToProperty(def: FlagDef): Record<string, unknown> {
if (def.type === "switch") {
return { type: "boolean", description: def.description };
}
if (def.type === "number") {
const property: Record<string, unknown> = { type: "number", description: def.description };
if (def.choices?.length) property.enum = def.choices.map((choice) => Number(choice));
return property;
}
if (def.type === "boolean") {
return { type: "boolean", description: def.description };
}
if (def.type === "array") {
const items: Record<string, unknown> = { type: "string" };
if (def.choices?.length) items.enum = [...def.choices];
return { type: "array", items, description: def.description };
}
const property: Record<string, unknown> = { type: "string", description: def.description };
if (def.choices?.length) property.enum = [...def.choices];
return property;
}
/** Build MCP `inputSchema` from a command's own flags (no global / credential flags). */
export function flagsToInputSchema(flags: FlagsDef | undefined): JsonSchemaObject {
const properties: Record<string, Record<string, unknown>> = {};
const required: string[] = [];
for (const [key, def] of Object.entries(flags ?? {})) {
properties[key] = flagToProperty(def);
if (def.type !== "switch" && "required" in def && def.required) {
required.push(key);
}
}
const schema: JsonSchemaObject = {
type: "object",
properties,
additionalProperties: false,
};
if (required.length > 0) schema.required = required;
return schema;
}
export interface McpToolDescriptor {
name: string;
description: string;
inputSchema: JsonSchemaObject;
/** Original CLI path, e.g. `text chat`. */
path: string;
command: AnyCommand;
}
/** Map leaf commands to MCP tool descriptors. */
export function buildToolDescriptors(
leaves: Array<{ path: string; command: AnyCommand }>,
options?: { toolNamePrefix?: string; skipPaths?: ReadonlySet<string> },
): McpToolDescriptor[] {
const prefix = options?.toolNamePrefix ?? "bailian";
const skipPaths = options?.skipPaths ?? new Set<string>();
const tools: McpToolDescriptor[] = [];
for (const leaf of leaves) {
if (skipPaths.has(leaf.path)) continue;
const name = pathToToolName(leaf.path, prefix);
const usage = leaf.command.usageArgs ? ` Usage: ${leaf.command.usageArgs}` : "";
tools.push({
name,
description: `${leaf.command.description} (bl ${leaf.path}).${usage}`,
inputSchema: flagsToInputSchema(leaf.command.flags),
path: leaf.path,
command: leaf.command,
});
}
return tools;
}
+55
View File
@@ -0,0 +1,55 @@
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import type { AnyCommand, CommandPackManager, Identity } from "bailian-cli-core";
import { buildToolDescriptors, flagsToZodObject } from "./schema.ts";
import { invokeCommandForMcp } from "./invoke.ts";
export interface ServeMcpStdioOptions {
identity: Identity;
/** Leaf command entries from the product command map / registry. */
leaves: Array<{ path: string; command: AnyCommand }>;
commandPacks: CommandPackManager;
}
/**
* Start an MCP server on stdin/stdout that exposes every leaf CLI command as a tool.
* Resolves when the transport closes (client disconnect / EOF).
*/
export async function serveMcpStdio(options: ServeMcpStdioOptions): Promise<void> {
const tools = buildToolDescriptors(options.leaves);
const mcpServer = new McpServer({
name: `${options.identity.clientName}-mcp`,
version: options.identity.version,
});
for (const tool of tools) {
mcpServer.registerTool(
tool.name,
{
description: tool.description,
inputSchema: flagsToZodObject(tool.command.flags),
},
async (args) => {
const result = await invokeCommandForMcp({
identity: options.identity,
path: tool.path.split(" "),
command: tool.command,
args: (args ?? {}) as Record<string, unknown>,
commandPacks: options.commandPacks,
});
return {
isError: !result.ok,
content: [{ type: "text" as const, text: result.text }],
};
},
);
}
const transport = new StdioServerTransport();
await mcpServer.connect(transport);
process.stderr.write(
`${options.identity.binName} mcp serve: STDIO MCP ready (${tools.length} tools)\n`,
);
}
-20
View File
@@ -136,26 +136,6 @@ 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,
+16 -2
View File
@@ -1,12 +1,21 @@
import { formatOutput, type OutputFormat } from "bailian-cli-core";
import { appendCapturedOutput, isCapturingOutput } from "../mcp-server/output-capture.ts";
/**
* Emit the primary result of a command.
* stdout result (text by default; JSON with --output json)
* stderr human info (progress, logs, tips) handled elsewhere
*
* When MCP STDIO capture is active, writes go to an in-memory buffer instead
* of process.stdout so the JSON-RPC stream stays intact.
*/
export function emitResult(data: unknown, format: OutputFormat): void {
process.stdout.write(formatOutput(data, format) + "\n");
const line = formatOutput(data, format) + "\n";
if (isCapturingOutput()) {
appendCapturedOutput(line);
return;
}
process.stdout.write(line);
}
/**
@@ -14,7 +23,12 @@ export function emitResult(data: unknown, format: OutputFormat): void {
* Used in --quiet mode or when the result is a single scalar.
*/
export function emitBare(value: string): void {
process.stdout.write(value + "\n");
const line = value + "\n";
if (isCapturingOutput()) {
appendCapturedOutput(line);
return;
}
process.stdout.write(line);
}
/**
+14 -180
View File
@@ -10,11 +10,6 @@ import {
taskPath,
speechSynthesizePath,
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
stripUndefined,
resolveBooleanFlag,
resolveWatermark,
@@ -28,7 +23,6 @@ import {
type DashScopeTTSRequest,
type DashScopeTTSResponse,
type DashScopeASRRequest,
type DashScopeASRTaskResult,
type ChatMessageContent,
isLocalFile,
} from "bailian-cli-core";
@@ -579,103 +573,27 @@ 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 audioUrl of rawUrls) {
if (isLocalFile(audioUrl)) {
for (const u of rawUrls) {
if (isLocalFile(u)) {
fileUrls.push(
await env.client.uploadFile(audioUrl, model, {
await env.client.uploadFile(u, input.model || "fun-asr", {
signal: ctx.signal,
}),
);
} else {
fileUrls.push(audioUrl);
fileUrls.push(u);
}
}
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 model = input.model || "fun-asr";
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url" ? { file_url: fileUrls[0]! } : { file_urls: fileUrls },
input: { file_urls: fileUrls },
parameters: {
channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined,
...languageFields,
language_hints: input.language ? [input.language] : undefined,
diarization_enabled: input.diarization,
speaker_count: input["speaker-count"],
vocabulary_id: input["vocabulary-id"],
@@ -683,8 +601,9 @@ export async function speechRecognize(
};
stripUndefined(body.parameters as Record<string, unknown>);
const url = speechRecognizePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
path: speechRecognizePath(),
path: url,
method: "POST",
body,
async: true,
@@ -695,65 +614,7 @@ export async function speechRecognize(
const pollIntervalMs = (input["poll-interval"] ?? 2) * 1000;
const timeoutMs = (ctx.timeoutSeconds ?? 300) * 1000;
// 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,
};
return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
}
// --- Shared: task polling ---
@@ -779,7 +640,7 @@ function flattenTaskResponse(resp: DashScopeTaskResponse): Record<string, unknow
if (urls.length > 0) flat.urls = urls;
}
if (output.results) {
const urls = output.results.map((item) => item.url).filter(Boolean);
const urls = output.results.map((r) => r.url).filter(Boolean);
if (urls.length > 0 && !flat.urls) flat.urls = urls;
}
if (output.task_metrics) flat.task_metrics = output.task_metrics;
@@ -797,13 +658,13 @@ async function pollTask(
return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
}
async function pollUntilSucceeded(
async function pollTaskWithOptions(
env: PipelineEnv,
taskId: string,
pollIntervalMs: number,
timeoutMs: number,
ctx?: StepContext,
): Promise<DashScopeTaskResponse> {
): Promise<Record<string, unknown>> {
const started = Date.now();
let attempt = 0;
@@ -826,7 +687,7 @@ async function pollUntilSucceeded(
const status = result.output.task_status;
if (status === "SUCCEEDED") {
return result;
return flattenTaskResponse(result);
}
if (status === "FAILED") {
@@ -857,33 +718,6 @@ async function pollUntilSucceeded(
}
}
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) => {
+32 -51
View File
@@ -25,13 +25,6 @@ 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`.
@@ -90,6 +83,24 @@ export class CommandRegistry {
return commands;
}
/** All executable leaf paths with their commands (space-separated path keys). */
getLeafEntries(): Array<{ path: string; command: AnyCommand }> {
const entries: Array<{ path: string; command: AnyCommand }> = [];
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, command: child.command });
}
if (child.children.size > 0) {
collect(child, fullPath);
}
}
};
collect(this.root, "");
return entries;
}
/** First registered command path, for the "Getting Help" example (e.g. "knowledge retrieve"). */
private helpExample(): string {
const walk = (node: CommandNode, path: string[]): string | null => {
@@ -164,37 +175,14 @@ export class CommandRegistry {
};
}
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 }> = [];
private buildResourceLines(a: (s: string) => string, d: (s: string) => string): string {
const entries: Array<{ path: string; 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,
auth: child.command.auth,
desc: child.command.description,
});
entries.push({ path: fullPath, desc: child.command.description });
}
if (child.children.size > 0) {
collect(child, fullPath);
@@ -203,7 +191,8 @@ export class CommandRegistry {
};
collect(this.root, "");
return this.buildCommandLines(entries, accent, dim);
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");
}
private buildFlagLines(
@@ -370,7 +359,6 @@ ${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)),
@@ -403,25 +391,18 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
}
private printChildren(node: CommandNode, prefix: string, out: NodeJS.WriteStream): void {
const entries: Array<{ path: string; auth: AuthRequirement; desc: string }> = [];
const collect = (currentNode: CommandNode, currentPath: string) => {
for (const [name, child] of currentNode.children) {
const entries: Array<{ fullName: string; description: string }> = [];
const collect = (n: CommandNode, p: string) => {
for (const [name, child] of n.children) {
if (child.command)
entries.push({
path: `${currentPath} ${name}`,
auth: child.command.auth,
desc: child.command.description,
});
if (child.children.size > 0) collect(child, `${currentPath} ${name}`);
entries.push({ fullName: `${p} ${name}`, description: child.command.description });
if (child.children.size > 0) collect(child, `${p} ${name}`);
}
};
collect(node, prefix);
out.write(
this.buildCommandLines(
entries,
(text) => this.accent(text, out),
(text) => this.dim(text, out),
) + "\n",
);
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`);
}
}
}
+10 -21
View File
@@ -209,25 +209,6 @@ 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.
@@ -275,7 +256,6 @@ 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) {
@@ -317,7 +297,16 @@ export async function performAutoUpdate(
);
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
await syncAgentSkillsAfterUpdate(dim, green, yellow, reset);
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`,
);
}
pendingNotification = null;
return true;
@@ -1,85 +0,0 @@
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,72 @@
import { describe, expect, test } from "vite-plus/test";
import { defineCommand, type Identity } from "bailian-cli-core";
import { emitResult } from "../src/output/output.ts";
import { invokeCommandForMcp } from "../src/mcp-server/invoke.ts";
import { createCommandPackManager } from "../src/command-packs/manager.ts";
const identity: Identity = {
binName: "bl",
version: "0.0.0-test",
clientName: "bailian-cli",
npmPackage: "bailian-cli",
};
const commandPacks = createCommandPackManager(identity, { supported: {} });
describe("mcp-server invoke", () => {
test("captures emitResult JSON without writing business output as protocol noise", async () => {
const command = defineCommand({
description: "Echo",
auth: "none",
flags: {
prompt: {
type: "string",
valueHint: "<text>",
description: "Prompt",
required: true,
},
},
async run(ctx) {
emitResult({ echoed: ctx.flags.prompt }, "json");
},
});
const result = await invokeCommandForMcp({
identity,
path: ["text", "chat"],
command,
args: { prompt: "hello" },
commandPacks,
});
expect(result.ok).toBe(true);
expect(JSON.parse(result.text)).toEqual({ echoed: "hello" });
});
test("returns usage error when required flag missing", async () => {
const command = defineCommand({
description: "Echo",
auth: "none",
flags: {
prompt: {
type: "string",
valueHint: "<text>",
description: "Prompt",
required: true,
},
},
async run() {},
});
const result = await invokeCommandForMcp({
identity,
path: ["text", "chat"],
command,
args: {},
commandPacks,
});
expect(result.ok).toBe(false);
expect(result.text).toMatch(/Missing required flag/);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vite-plus/test";
import { defineCommand } from "bailian-cli-core";
import {
buildToolDescriptors,
flagsToInputSchema,
flagsToZodObject,
pathToToolName,
} from "../src/mcp-server/schema.ts";
describe("mcp-server schema", () => {
test("pathToToolName maps space path to bailian_*", () => {
expect(pathToToolName("text chat")).toBe("bailian_text_chat");
expect(pathToToolName("memory profile get")).toBe("bailian_memory_profile_get");
});
test("flagsToInputSchema marks required strings and switch booleans", () => {
const schema = flagsToInputSchema({
prompt: {
type: "string",
valueHint: "<text>",
description: "User prompt",
required: true,
},
quiet: { type: "switch", description: "Quiet" },
n: { type: "number", valueHint: "<n>", description: "Count" },
tags: { type: "array", valueHint: "<tag>", description: "Tags" },
});
expect(schema.type).toBe("object");
expect(schema.required).toEqual(["prompt"]);
expect(schema.properties.prompt).toMatchObject({ type: "string" });
expect(schema.properties.quiet).toMatchObject({ type: "boolean" });
expect(schema.properties.n).toMatchObject({ type: "number" });
expect(schema.properties.tags).toMatchObject({
type: "array",
items: { type: "string" },
});
});
test("flagsToZodObject marks required fields", () => {
const schema = flagsToZodObject({
prompt: {
type: "string",
valueHint: "<text>",
description: "User prompt",
required: true,
},
n: { type: "number", valueHint: "<n>", description: "Count" },
});
expect(schema.parse({ prompt: "hi" })).toEqual({ prompt: "hi" });
expect(() => schema.parse({})).toThrow();
});
test("buildToolDescriptors maps leaf paths", () => {
const sample = defineCommand({
description: "Sample",
auth: "none",
flags: {
prompt: {
type: "string",
valueHint: "<text>",
description: "Prompt",
required: true,
},
},
async run() {},
});
const tools = buildToolDescriptors([{ path: "text chat", command: sample }]);
expect(tools).toHaveLength(1);
expect(tools[0]?.name).toBe("bailian_text_chat");
expect(tools[0]?.path).toBe("text chat");
expect(tools[0]?.inputSchema.required).toEqual(["prompt"]);
});
});
@@ -1,184 +0,0 @@
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 -54
View File
@@ -1,58 +1,13 @@
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 { expect, test } from "vite-plus/test";
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]);
@@ -169,11 +124,3 @@ 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" });
});
+29
View File
@@ -6,6 +6,9 @@ settings:
catalogs:
default:
'@modelcontextprotocol/server':
specifier: ^2.0.0
version: 2.0.0
'@types/node':
specifier: ^24
version: 24.12.2
@@ -45,6 +48,9 @@ catalogs:
yauzl:
specifier: ^3.4.0
version: 3.4.0
zod:
specifier: ^4.4.3
version: 4.4.3
overrides:
vite: npm:@voidzero-dev/vite-plus-core@latest
@@ -245,6 +251,9 @@ importers:
packages/runtime:
dependencies:
'@modelcontextprotocol/server':
specifier: 'catalog:'
version: 2.0.0
bailian-cli-core:
specifier: workspace:*
version: link:../core
@@ -257,6 +266,9 @@ importers:
undici:
specifier: 'catalog:'
version: 6.27.0
zod:
specifier: 'catalog:'
version: 4.4.3
devDependencies:
'@clack/prompts':
specifier: ^0.7.0
@@ -458,6 +470,14 @@ packages:
cpu: [x64]
os: [win32]
'@modelcontextprotocol/core@2.0.0':
resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==}
engines: {node: '>=20'}
'@modelcontextprotocol/server@2.0.0':
resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==}
engines: {node: '>=20'}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@@ -1667,6 +1687,15 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@modelcontextprotocol/core@2.0.0':
dependencies:
zod: 4.4.3
'@modelcontextprotocol/server@2.0.0':
dependencies:
'@modelcontextprotocol/core': 2.0.0
zod: 4.4.3
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
+3 -1
View File
@@ -3,14 +3,15 @@ packages:
- tools/*
catalog:
"@modelcontextprotocol/server": ^2.0.0
"@types/node": ^24
"@types/tar-stream": ^3.1.4
"@types/yauzl": ^3.4.0
ajv: ^8.20.0
boxen: ^8.0.1
chalk: ^5.6.2
tar-stream: ^3.2.0
smol-toml: ^1.4.2
tar-stream: ^3.2.0
tsx: ^4.23.0
typescript: ^5
undici: ^6.27.0
@@ -19,6 +20,7 @@ catalog:
vitest: npm:@voidzero-dev/vite-plus-test@latest
yaml: ^2.8.3
yauzl: ^3.4.0
zod: ^4.4.3
catalogMode: prefer
overrides:
+2 -2
View File
@@ -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 `bl skill init`)
- Shared protocol: `bailian-protocol` (install via `--all -g`)
- Soft hand-offs (optional skills): `bailian-gen` · `bailian-finetune` · `bailian-managed-agent`
```bash
bl skill init
npx skills add modelstudioai/cli --all -g
```
For CLI installation, authentication, and examples, see the [main README](../../README.md).
+2 -2
View File
@@ -4,11 +4,11 @@
**阿里云百炼 CLI**`bl`)的资源管理 Agent 技能 — 应用、记忆、RAG、用量/额度、MCP以及 hub `reference/`
- 共享协议:`bailian-protocol`(通过 `bl skill init` 与整家族同装)
- 共享协议:`bailian-protocol`(通过 `--all -g` 与整家族同装)
- 软 hand-off可选`bailian-gen` · `bailian-finetune` · `bailian-managed-agent`
```bash
bl skill init
npx skills add modelstudioai/cli --all -g
```
CLI 的安装、认证和使用示例请查看[主 README](../../README.zh.md)。
+7 -9
View File
@@ -1,7 +1,7 @@
---
name: bailian-cli
metadata:
version: "1.15.0"
version: "1.14.2"
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官方安装 `bl skill init`
共享协议consent / 版本预检 / 鉴权 / 错误上报)在 bailian-protocol官方安装 `npx skills add modelstudioai/cli --all -g`
家族路由:生图/生视频/配音/语音合成/转写 → 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 `bl skill init`; 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 `npx skills add modelstudioai/cli --all -g`; 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 `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).
> 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).
> Do not invoke it for ordinary reasoning, coding, writing, translation, summarization, generic research, or image understanding the host agent can complete directly.
>
> **Install (supported):** `bl skill init`
> **Install (supported):** `npx skills add modelstudioai/cli --all -g`
## Command reference (authoritative)
@@ -71,8 +71,6 @@ 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` |
@@ -115,7 +113,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 `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.
- 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.
- 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.
+8 -9
View File
@@ -7,20 +7,19 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ---------------------- | -------------- | ---------------------------------------------------------------------------------------------- |
| `bl advisor recommend` | API Key | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
| Command | Description |
| ---------------------- | ---------------------------------------------------------------------------------------------- |
| `bl advisor recommend` | 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) |
| **Authentication** | API Key |
| **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) |
| **Usage** | `bl advisor recommend --message <text> [flags]` |
#### Flags
+14 -16
View File
@@ -7,21 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ------------- | -------------- | ---------------------------------------------- |
| `bl app call` | API Key | Call a Bailian application (agent or workflow) |
| `bl app list` | Console | List Bailian applications |
| Command | Description |
| ------------- | ---------------------------------------------- |
| `bl app call` | Call a Bailian application (agent or workflow) |
| `bl app list` | List Bailian applications |
## Command details
### `bl app call`
| 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]` |
| Field | Value |
| --------------- | --------------------------------------------------- |
| **Name** | `app call` |
| **Description** | Call a Bailian application (agent or workflow) |
| **Usage** | `bl app call --app-id <id> --prompt <text> [flags]` |
#### Flags
@@ -68,12 +67,11 @@ bl app call --app-id abc123 --prompt "Start" --biz-params '{"key":"value"}'
### `bl app list`
| Field | Value |
| ------------------ | ------------------------- |
| **Name** | `app list` |
| **Description** | List Bailian applications |
| **Authentication** | Console |
| **Usage** | `bl app list [flags]` |
| Field | Value |
| --------------- | ------------------------- |
| **Name** | `app list` |
| **Description** | List Bailian applications |
| **Usage** | `bl app list [flags]` |
#### Flags
+26 -30
View File
@@ -7,23 +7,22 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 details
### `bl auth generate-access-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>` |
| 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>` |
#### Flags
@@ -41,12 +40,11 @@ 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) |
| **Authentication** | No Auth |
| **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) |
| **Usage** | `bl auth login --api-key <key> \| --console \| --open-api --access-key-id <id> --access-key-secret <secret>` |
#### Flags
@@ -80,12 +78,11 @@ 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 |
| **Authentication** | No Auth |
| **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 |
| **Usage** | `bl auth logout [--console \| --open-api] [--dry-run]` |
#### Flags
@@ -114,12 +111,11 @@ bl auth logout --dry-run
### `bl auth status`
| Field | Value |
| ------------------ | --------------------------------- |
| **Name** | `auth status` |
| **Description** | Show current authentication state |
| **Authentication** | No Auth |
| **Usage** | `bl auth status` |
| Field | Value |
| --------------- | --------------------------------- |
| **Name** | `auth status` |
| **Description** | Show current authentication state |
| **Usage** | `bl auth status` |
#### Flags
+38 -44
View File
@@ -7,25 +7,24 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 details
### `bl config agent`
| 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>` |
| 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>` |
#### Flags
@@ -56,12 +55,11 @@ 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 |
| **Authentication** | No Auth |
| **Usage** | `bl config list` |
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `config list` |
| **Description** | List config profiles and show the active profile |
| **Usage** | `bl config list` |
#### Flags
@@ -79,12 +77,11 @@ bl config list --output json
### `bl config set`
| Field | Value |
| ------------------ | ------------------------------------------- |
| **Name** | `config set` |
| **Description** | Set a config value |
| **Authentication** | No Auth |
| **Usage** | `bl config set --key <key> --value <value>` |
| Field | Value |
| --------------- | ------------------------------------------- |
| **Name** | `config set` |
| **Description** | Set a config value |
| **Usage** | `bl config set --key <key> --value <value>` |
#### Flags
@@ -109,12 +106,11 @@ bl config set --key base_url --value https://dashscope.aliyuncs.com
### `bl config show`
| Field | Value |
| ------------------ | ----------------------------- |
| **Name** | `config show` |
| **Description** | Display current configuration |
| **Authentication** | No Auth |
| **Usage** | `bl config show` |
| Field | Value |
| --------------- | ----------------------------- |
| **Name** | `config show` |
| **Description** | Display current configuration |
| **Usage** | `bl config show` |
#### Flags
@@ -132,12 +128,11 @@ bl config show --output json
### `bl config ui`
| 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]` |
| Field | Value |
| --------------- | --------------------------------------------- |
| **Name** | `config ui` |
| **Description** | Open a local web UI to manage config profiles |
| **Usage** | `bl config ui [--port <port>] [--no-open]` |
#### Flags
@@ -162,12 +157,11 @@ bl config ui --no-open
### `bl config use`
| Field | Value |
| ------------------ | ----------------------------- |
| **Name** | `config use` |
| **Description** | Set the active config profile |
| **Authentication** | No Auth |
| **Usage** | `bl config use --name <name>` |
| Field | Value |
| --------------- | ----------------------------- |
| **Name** | `config use` |
| **Description** | Set the active config profile |
| **Usage** | `bl config use --name <name>` |
#### Flags
+9 -10
View File
@@ -7,20 +7,19 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ----------------- | -------------- | ---------------------------------------------- |
| `bl console call` | Console | Call a Bailian console API via the CLI gateway |
| Command | Description |
| ----------------- | ---------------------------------------------- |
| `bl console call` | 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 |
| **Authentication** | Console |
| **Usage** | `bl console call --api <api> --data <json> [flags]` |
| Field | Value |
| --------------- | --------------------------------------------------- |
| **Name** | `console call` |
| **Description** | Call a Bailian console API via the CLI gateway |
| **Usage** | `bl console call --api <api> --data <json> [flags]` |
#### Flags
@@ -36,7 +35,7 @@ Index: [index.md](index.md)
#### Examples
```bash
bl console call --api zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'
bl console call --api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'
```
```bash
+8 -9
View File
@@ -7,20 +7,19 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ---------------- | -------------- | -------------------------------------------------------- |
| `bl file upload` | API Key | Upload a local file to DashScope temporary storage (48h) |
| Command | Description |
| ---------------- | -------------------------------------------------------- |
| `bl file upload` | 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) |
| **Authentication** | API Key |
| **Usage** | `bl file upload --file <path> --model <model>` |
| Field | Value |
| --------------- | -------------------------------------------------------- |
| **Name** | `file upload` |
| **Description** | Upload a local file to DashScope temporary storage (48h) |
| **Usage** | `bl file upload --file <path> --model <model>` |
#### Flags
+60 -62
View File
@@ -9,67 +9,65 @@ Use this index for the skill-scoped quick index and global flags.
## Quick index
| 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) |
| 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) |
## By group
@@ -93,7 +91,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` | `coding-plan`, `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) |
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
## Global flags
+20 -23
View File
@@ -7,22 +7,21 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 details
### `bl knowledge chat`
| 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]` |
| 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]` |
#### Flags
@@ -58,12 +57,11 @@ 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) |
| **Authentication** | API Key |
| **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) |
| **Usage** | `bl knowledge retrieve --index-id <id> --query <text> [flags]` |
#### Flags
@@ -94,12 +92,11 @@ 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) |
| **Authentication** | API Key |
| **Usage** | `bl knowledge search --query <text> --agent-id <id> [flags]` |
| Field | Value |
| --------------- | ------------------------------------------------------------ |
| **Name** | `knowledge search` |
| **Description** | Search a Bailian knowledge base (RAG semantic retrieval) |
| **Usage** | `bl knowledge search --query <text> --agent-id <id> [flags]` |
#### Flags
+35 -38
View File
@@ -7,34 +7,33 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 details
### `bl mcp call`
| 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>]` |
| 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>]` |
#### 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 (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 |
| 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 |
#### Examples
@@ -52,12 +51,11 @@ 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 |
| **Authentication** | Console |
| **Usage** | `bl mcp list [flags]` |
| Field | Value |
| --------------- | ----------------------------------------------------- |
| **Name** | `mcp list` |
| **Description** | List MCP servers activated under your Bailian account |
| **Usage** | `bl mcp list [flags]` |
#### Flags
@@ -88,21 +86,20 @@ bl mcp list --output json
### `bl mcp tools`
| 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>]` |
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `mcp tools` |
| **Description** | List tools exposed by an MCP server (tools/list) |
| **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 (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 |
| 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 |
#### Examples
+44 -51
View File
@@ -7,26 +7,25 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 details
### `bl memory add`
| 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]` |
| 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]` |
#### Flags
@@ -56,12 +55,11 @@ 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 |
| **Authentication** | API Key |
| **Usage** | `bl memory delete --node-id <id> --user-id <id>` |
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `memory delete` |
| **Description** | Delete a memory node |
| **Usage** | `bl memory delete --node-id <id> --user-id <id>` |
#### Flags
@@ -81,12 +79,11 @@ 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 |
| **Authentication** | API Key |
| **Usage** | `bl memory list --user-id <id> [flags]` |
| Field | Value |
| --------------- | --------------------------------------- |
| **Name** | `memory list` |
| **Description** | List memory nodes for a user |
| **Usage** | `bl memory list --user-id <id> [flags]` |
#### Flags
@@ -111,12 +108,11 @@ 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 |
| **Authentication** | API Key |
| **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 |
| **Usage** | `bl memory profile create --name <name> --attributes <json> [flags]` |
#### Flags
@@ -136,12 +132,11 @@ 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 |
| **Authentication** | API Key |
| **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 |
| **Usage** | `bl memory profile get --schema-id <id> --user-id <id>` |
#### Flags
@@ -160,12 +155,11 @@ 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 |
| **Authentication** | API Key |
| **Usage** | `bl memory search --user-id <id> [--query <text>] [flags]` |
| Field | Value |
| --------------- | ---------------------------------------------------------- |
| **Name** | `memory search` |
| **Description** | Search memory nodes by query or messages |
| **Usage** | `bl memory search --user-id <id> [--query <text>] [flags]` |
#### Flags
@@ -191,12 +185,11 @@ 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 |
| **Authentication** | API Key |
| **Usage** | `bl memory update --node-id <id> --user-id <id> --content <text>` |
| Field | Value |
| --------------- | ----------------------------------------------------------------- |
| **Name** | `memory update` |
| **Description** | Update a memory node content |
| **Usage** | `bl memory update --node-id <id> --user-id <id> --content <text>` |
#### Flags
+8 -9
View File
@@ -7,20 +7,19 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| --------------- | -------------- | ---------------------------------------------------------------------------------- |
| `bl model list` | Console | Browse model families or show detailed model info in the Bailian model marketplace |
| Command | Description |
| --------------- | ---------------------------------------------------------------------------------- |
| `bl model list` | 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 |
| **Authentication** | Console |
| **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 |
| **Usage** | `bl model list [--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]` |
#### Flags
+14 -16
View File
@@ -7,21 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | Description |
| ---------------------- | ------------------------------------------------ |
| `bl pipeline run` | Run a pipeline workflow definition |
| `bl pipeline validate` | Validate a pipeline definition without executing |
## Command details
### `bl pipeline run`
| Field | Value |
| ------------------ | --------------------------------------- |
| **Name** | `pipeline run` |
| **Description** | Run a pipeline workflow definition |
| **Authentication** | No Auth |
| **Usage** | `bl pipeline run --file <path> [flags]` |
| Field | Value |
| --------------- | --------------------------------------- |
| **Name** | `pipeline run` |
| **Description** | Run a pipeline workflow definition |
| **Usage** | `bl pipeline run --file <path> [flags]` |
#### Flags
@@ -58,12 +57,11 @@ bl pipeline run --file workflow.yaml --output json
### `bl pipeline validate`
| Field | Value |
| ------------------ | ------------------------------------------------ |
| **Name** | `pipeline validate` |
| **Description** | Validate a pipeline definition without executing |
| **Authentication** | No Auth |
| **Usage** | `bl pipeline validate --file <path>` |
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `pipeline validate` |
| **Description** | Validate a pipeline definition without executing |
| **Usage** | `bl pipeline validate --file <path>` |
#### Flags
+26 -30
View File
@@ -7,23 +7,22 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 details
### `bl plugin install`
| Field | Value |
| ------------------ | ---------------------------------------------- |
| **Name** | `plugin install` |
| **Description** | Install or upgrade an allowlisted Command Pack |
| **Authentication** | No Auth |
| **Usage** | `bl plugin install --package <name[@version]>` |
| Field | Value |
| --------------- | ---------------------------------------------- |
| **Name** | `plugin install` |
| **Description** | Install or upgrade an allowlisted Command Pack |
| **Usage** | `bl plugin install --package <name[@version]>` |
#### Flags
@@ -43,12 +42,11 @@ 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 |
| **Authentication** | No Auth |
| **Usage** | `bl plugin link --path <directory>` |
| Field | Value |
| --------------- | ------------------------------------------------------ |
| **Name** | `plugin link` |
| **Description** | Link an allowlisted local Command Pack for development |
| **Usage** | `bl plugin link --path <directory>` |
#### Flags
@@ -64,12 +62,11 @@ bl plugin link --path ../bailian-plugin-agent
### `bl plugin list`
| Field | Value |
| ------------------ | -------------------------------------------------- |
| **Name** | `plugin list` |
| **Description** | List installed Command Packs and their load status |
| **Authentication** | No Auth |
| **Usage** | `bl plugin list` |
| Field | Value |
| --------------- | -------------------------------------------------- |
| **Name** | `plugin list` |
| **Description** | List installed Command Packs and their load status |
| **Usage** | `bl plugin list` |
#### Flags
@@ -87,12 +84,11 @@ bl plugin list --output json
### `bl plugin remove`
| Field | Value |
| ------------------ | ----------------------------------- |
| **Name** | `plugin remove` |
| **Description** | Remove an installed Command Pack |
| **Authentication** | No Auth |
| **Usage** | `bl plugin remove --name <package>` |
| Field | Value |
| --------------- | ----------------------------------- |
| **Name** | `plugin remove` |
| **Description** | Remove an installed Command Pack |
| **Usage** | `bl plugin remove --name <package>` |
#### Flags
+26 -30
View File
@@ -7,23 +7,22 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 details
### `bl quota check`
| Field | Value |
| ------------------ | ------------------------------------------ |
| **Name** | `quota check` |
| **Description** | Check current usage against rate limits |
| **Authentication** | Console |
| **Usage** | `bl quota check [--model <model>] [flags]` |
| Field | Value |
| --------------- | ------------------------------------------ |
| **Name** | `quota check` |
| **Description** | Check current usage against rate limits |
| **Usage** | `bl quota check [--model <model>] [flags]` |
#### Flags
@@ -60,12 +59,11 @@ bl quota check --output json
### `bl quota history`
| Field | Value |
| ------------------ | -------------------------- |
| **Name** | `quota history` |
| **Description** | View quota change history |
| **Authentication** | Console |
| **Usage** | `bl quota history [flags]` |
| Field | Value |
| --------------- | -------------------------- |
| **Name** | `quota history` |
| **Description** | View quota change history |
| **Usage** | `bl quota history [flags]` |
#### Flags
@@ -103,12 +101,11 @@ bl quota history --output json
### `bl quota list`
| Field | Value |
| ------------------ | ----------------------------------------- |
| **Name** | `quota list` |
| **Description** | View model RPM/TPM rate limits |
| **Authentication** | Console |
| **Usage** | `bl quota list [--model <model>] [flags]` |
| Field | Value |
| --------------- | ----------------------------------------- |
| **Name** | `quota list` |
| **Description** | View model RPM/TPM rate limits |
| **Usage** | `bl quota list [--model <model>] [flags]` |
#### Flags
@@ -140,12 +137,11 @@ bl quota list --output json
### `bl quota request`
| Field | Value |
| ------------------ | -------------------------------------------------------- |
| **Name** | `quota request` |
| **Description** | Request a temporary quota increase |
| **Authentication** | Console |
| **Usage** | `bl quota request --model <model> --tpm <value> [flags]` |
| Field | Value |
| --------------- | -------------------------------------------------------- |
| **Name** | `quota request` |
| **Description** | Request a temporary quota increase |
| **Usage** | `bl quota request --model <model> --tpm <value> [flags]` |
#### Flags
+8 -9
View File
@@ -7,20 +7,19 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| --------------- | -------------- | ---------------------------------------------------- |
| `bl search web` | API Key | Search the web using DashScope MCP WebSearch service |
| Command | Description |
| --------------- | ---------------------------------------------------- |
| `bl search web` | 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 |
| **Authentication** | API Key |
| **Usage** | `bl search web --query <text> [flags]` |
| Field | Value |
| --------------- | ---------------------------------------------------- |
| **Name** | `search web` |
| **Description** | Search the web using DashScope MCP WebSearch service |
| **Usage** | `bl search web --query <text> [flags]` |
#### Flags
+32 -37
View File
@@ -7,24 +7,23 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 details
### `bl skill add`
| 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,...>` |
| Field | Value |
| --------------- | ---------------------------------------------------------------- |
| **Name** | `skill add` |
| **Description** | Install skills from the Bailian skill registry into local agents |
| **Usage** | `bl skill add --all \| --name <name,...>` |
#### Flags
@@ -45,12 +44,11 @@ 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) |
| **Authentication** | No Auth |
| **Usage** | `bl skill init` |
| Field | Value |
| --------------- | ----------------------------------------------------------------------- |
| **Name** | `skill init` |
| **Description** | Install all bailian-\* skills (one-shot bootstrap for new environments) |
| **Usage** | `bl skill init` |
#### Flags
@@ -69,12 +67,11 @@ bl skill init
### `bl skill list`
| Field | Value |
| ------------------ | ---------------------------------------------------- |
| **Name** | `skill list` |
| **Description** | List registry skills and diff against local installs |
| **Authentication** | No Auth |
| **Usage** | `bl skill list` |
| Field | Value |
| --------------- | ---------------------------------------------------- |
| **Name** | `skill list` |
| **Description** | List registry skills and diff against local installs |
| **Usage** | `bl skill list` |
#### Flags
@@ -96,12 +93,11 @@ bl skill list --output json
### `bl skill remove`
| Field | Value |
| ------------------ | ------------------------------------------------------- |
| **Name** | `skill remove` |
| **Description** | Remove locally installed skills (registry is untouched) |
| **Authentication** | No Auth |
| **Usage** | `bl skill remove --name <all\|name,...>` |
| Field | Value |
| --------------- | ------------------------------------------------------- |
| **Name** | `skill remove` |
| **Description** | Remove locally installed skills (registry is untouched) |
| **Usage** | `bl skill remove --name <all\|name,...>` |
#### Flags
@@ -121,12 +117,11 @@ bl skill remove --name all
### `bl skill update`
| Field | Value |
| ------------------ | ------------------------------------------------------- |
| **Name** | `skill update` |
| **Description** | Update installed skills to the latest registry versions |
| **Authentication** | No Auth |
| **Usage** | `bl skill update [--all] [--name <name,...>]` |
| Field | Value |
| --------------- | ------------------------------------------------------- |
| **Name** | `skill update` |
| **Description** | Update installed skills to the latest registry versions |
| **Usage** | `bl skill update [--all] [--name <name,...>]` |
#### Flags
+23 -29
View File
@@ -7,39 +7,37 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| -------------- | -------------- | -------------------------------------------------------- |
| `bl text chat` | API Key | Send a text model request (OpenAI compatible, DashScope) |
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) |
## Command details
### `bl text chat`
| Field | Value |
| ------------------ | -------------------------------------------------------- |
| **Name** | `text chat` |
| **Description** | Send a text model request (OpenAI compatible, DashScope) |
| **Authentication** | API Key |
| **Usage** | `bl text chat --message <text> [flags]` |
| Field | Value |
| --------------- | ----------------------------------------------------- |
| **Name** | `text chat` |
| **Description** | Send a chat completion (OpenAI compatible, DashScope) |
| **Usage** | `bl text chat --message <text> [flags]` |
#### Flags
| 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 |
| 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 |
#### Examples
@@ -47,10 +45,6 @@ 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"
```
+26 -30
View File
@@ -7,23 +7,22 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ---------------------------- | -------------- | ----------------------------------------- |
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization |
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members |
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat |
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details |
| Command | 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 details
### `bl token-plan add-member`
| 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]` |
| 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]` |
#### Flags
@@ -55,12 +54,11 @@ 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 |
| **Authentication** | AK/SK |
| **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 |
| **Usage** | `bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]` |
#### Flags
@@ -88,12 +86,11 @@ 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 |
| **Authentication** | AK/SK |
| **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 |
| **Usage** | `bl token-plan create-key --account-id <id> --workspace-id <id> [flags]` |
#### Flags
@@ -120,12 +117,11 @@ 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 |
| **Authentication** | AK/SK |
| **Usage** | `bl token-plan list-seats [flags]` |
| Field | Value |
| --------------- | ----------------------------------------- |
| **Name** | `token-plan list-seats` |
| **Description** | List Token Plan subscription seat details |
| **Usage** | `bl token-plan list-seats [flags]` |
#### Flags
+8 -9
View File
@@ -7,20 +7,19 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ----------- | -------------- | --------------------------------------------------- |
| `bl update` | No Auth | Update the CLI to the latest or a specified version |
| Command | Description |
| ----------- | --------------------------------------------------- |
| `bl update` | 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 |
| **Authentication** | No Auth |
| **Usage** | `bl update [--to <version>]` |
| Field | Value |
| --------------- | --------------------------------------------------- |
| **Name** | `update` |
| **Description** | Update the CLI to the latest or a specified version |
| **Usage** | `bl update [--to <version>]` |
#### Flags
+26 -88
View File
@@ -7,53 +7,22 @@ Index: [index.md](index.md)
## Commands in this group
| 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 | 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 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) |
| **Authentication** | Console |
| **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) |
| **Usage** | `bl usage free [--model <model>[,model2,...]] [flags]` |
#### Flags
@@ -104,12 +73,11 @@ 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 |
| **Authentication** | Console |
| **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 |
| **Usage** | `bl usage freetier <--model <model>[,model2,...] \| --all> [--off] [flags]` |
#### Flags
@@ -152,12 +120,11 @@ bl usage freetier --off --all
### `bl usage stats`
| Field | Value |
| ------------------ | ---------------------------------------------------------- |
| **Name** | `usage stats` |
| **Description** | Query model usage statistics |
| **Authentication** | Console |
| **Usage** | `bl usage stats [--model <model>] [--days <days>] [flags]` |
| Field | Value |
| --------------- | ---------------------------------------------------------- |
| **Name** | `usage stats` |
| **Description** | Query model usage statistics |
| **Usage** | `bl usage stats [--model <model>] [--days <days>] [flags]` |
#### Flags
@@ -203,12 +170,11 @@ 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 |
| **Authentication** | Console |
| **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 |
| **Usage** | `bl usage summary [--days <days>] [flags]` |
#### Flags
@@ -233,31 +199,3 @@ 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
```

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