Compare commits

..

30 Commits

Author SHA1 Message Date
gujieye 5f1c97940d Merge branch 'main' into feat/coding-plan-usage 2026-08-14 17:05:53 +08:00
Gong Shiqi f1b6cacd7f Merge pull request #153 from modelstudioai/feat/mcp-support-sse
Add MCP classic SSE auto-fallback for Bailian and --url
2026-08-14 16:07:15 +08:00
故璃 9133b6bdd1 feat: add coding plan usage 2026-08-14 15:56:16 +08:00
clh02467605 98ba3279fa fix(runtime): expose errno in fetch-failed JSON cause.code 2026-08-14 15:27:26 +08:00
clh02467605 3b7c4cfabc Merge remote-tracking branch 'refs/remotes/origin/main' into feat/mcp-support-sse 2026-08-14 14:42:21 +08:00
clh02467605 d5d9fcb50f fix: fixed sse error 2026-08-14 14:41:37 +08:00
clh02467605 3ea2931152 fix(mcp): harden SSE parsing, abort, and fallback matching 2026-08-14 11:11:47 +08:00
gujieye b402f3eacd Merge pull request #141 from sonicg83/codex/usage-token-plan-reset-times
fix(usage): handle missing Token Plan quota fields
2026-08-14 11:10:56 +08:00
gujieye bedd59df27 Merge branch 'main' into codex/usage-token-plan-reset-times 2026-08-13 19:54:06 +08:00
故璃 39a488181e refactor(usage): align token-plan with --output convention and tolerant quota reading 2026-08-13 19:43:11 +08:00
clh02467605 4dcec7d075 fix(mcp): fix SSE header timeout, 405 fallback matching, and parseSSE chunking 2026-08-13 18:32:54 +08:00
Gong Shiqi daefc094ec Merge pull request #149 from modelstudioai/fix/fixed_issue_146
fix: support sync-flash and qwen3-filetrans ASR models in speech recognize
2026-08-13 16:26:32 +08:00
clh02467605 ae0c2c1213 fix(speech): handle qwen3-filetrans singular result.transcription_url
Normalize async ASR transcription items so waiting mode downloads text and --out works without changing shared media task types.
2026-08-13 15:52:34 +08:00
clh02467605 01a62eb85b Merge remote-tracking branch 'refs/remotes/origin/main' into feat/mcp-support-sse 2026-08-13 15:40:04 +08:00
clh02467605 798ce596f6 fix(mcp): harden SSE fallback for Bailian and --url overrides 2026-08-13 15:37:32 +08:00
clh02467605 bd91e9d1c2 Merge remote-tracking branch 'refs/remotes/origin/main' into fix/fixed_issue_146
# Conflicts:
#	skills/bailian-gen/reference/index.md
#	skills/bailian-gen/reference/speech.md
2026-08-13 14:36:16 +08:00
clh02467605 e244771ee9 test(speech): harden flash ASR contract coverage and docs
Add SSE disable header, data-URI format inference, broader response text
parsing, HTTP contract e2e, pipeline routing tests, and ASR model selection
guidance in bailian-gen.
2026-08-13 14:25:47 +08:00
Gong Shiqi 94f9dbbe9e Merge pull request #151 from modelstudioai/feat/command-auth-help
feat(cli): show command authentication requirements in help
2026-08-13 13:38:28 +08:00
若麒 8a0dd70206 feat(cli): show command authentication requirements in help 2026-08-13 12:01:41 +08:00
clh02467605 9379da7a4c fix(speech): align flash vocabulary_id and qwen3-filetrans language params 2026-08-13 09:47:09 +08:00
clh02467605 ddcd564e61 test: dry-run realtime ASR usage-error e2e to skip auth in CI 2026-08-12 17:22:25 +08:00
gujieye 0e4dd4b824 Merge pull request #148 from modelstudioai/feat/usage_free_api
refactor(usage): consolidate shared poll logic; migrate freeTrial API…
2026-08-12 17:13:59 +08:00
clh02467605 241de61866 fix: support sync-flash and qwen3-filetrans ASR models in speech recognize
- Add asr-routes.ts with resolveAsrApi() to route models to the correct
  DashScope endpoint instead of always hitting asr/transcription
- Async filetrans: fun-asr / paraformer / *-filetrans → file_urls (plural)
- Async filetrans (qwen3): qwen3-asr-flash-filetrans* → file_url (singular)
- Sync flash (input-audio): fun-asr-flash* / qwen-audio-*-asr-flash → multimodal-generation
- Sync flash (qwen3): qwen3-asr-flash* → multimodal-generation + asr_options
- Realtime/streaming models now give a clear USAGE error instead of a
  confusing server-side "url error"
- Propagate same routing logic to pipeline speechRecognize step
- Add table-driven unit tests and dry-run e2e assertions
Fixes #146
2026-08-12 17:12:23 +08:00
故璃 61d9a74166 fix: 1.14.3 2026-08-12 17:05:18 +08:00
故璃 69eb759490 refactor(usage): consolidate shared poll logic; migrate freeTrial APIs to bailian-commerce
Dedup:
- shared.ts: extract generic pollConsoleUntilDone (request-builder callback
  absorbs each wrapper convention); pollTelemetryApi becomes a thin wrapper;
  add pollFreeTierBatch
- freetier.ts / stats.ts: drop inline duplicates of extractResponseData,
  polling, model-list paging, free-tier extractors and usage label maps;
  import from shared.ts (behaviour unchanged: freetier keeps its 20-poll
  budget, telemetry keeps 30)

Endpoint migration (broadscope-bailian.freeTrial -> bailian-commerce.freeTrial):
- queryFreeTierQuota, queryFreeTierOnlyStatus, batchActivateFreeTierOnly,
  batchDeactivateFreeTierOnly
- update the console call example and the gateway doc comment to match

Note: verified statically and via dry-run; live calls pending a fresh
console login (session expired).
2026-08-12 16:30:08 +08:00
clh02467605 313966d7a9 feat(mcp): add SSE support with fallback mechanism for MCP connections
- Add McpSseClient implementation for classic HTTP+SSE MCP protocol
- Implement connectBailianMcpWithFallback with Streamable HTTP to SSE fallback
- Add isStreamableHttpUnsupported helper to detect 405 streamableHttp errors
- Update activate-hint logic to handle WebSearch 405 streamableHttp cases
- Replace direct MCP client usage with connection manager in call/tools commands
- Add proper client cleanup with close() calls in finally blocks
- Export new MCP connection utilities and types from core client module
- Add comprehensive tests for SSE client and fallback behavior
2026-08-12 15:44:51 +08:00
sonicg83 4d84af614b Merge branch 'modelstudioai:main' into codex/usage-token-plan-reset-times 2026-08-07 23:29:01 +08:00
sonicg 24092b423c fix(usage): handle unavailable token plan quotas 2026-08-06 22:47:26 +08:00
sonicg 752a79e442 fix(usage): handle missing token plan reset times 2026-08-06 09:12:41 +08:00
sonicg 80bdcb83f6 feat(usage): add token plan usage view 2026-08-05 23:28:39 +08:00
110 changed files with 5240 additions and 5827 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### Console Login (OAuth)
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, asset center, direct console calls). Opens the Bailian console in your browser to sign in.
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
+1 -1
View File
@@ -122,7 +122,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### 控制台登录OAuth
控制台能力命令模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、资产中心、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
-574
View File
@@ -1,574 +0,0 @@
# bailian-cli 快速上手指南
> 本文档面向新加入项目的开发者,帮助你理解 monorepo 的整体架构、代码组织方式和日常开发流程。
> AI Agent 维护契约见根目录 [`AGENTS.md`](../AGENTS.md);各场景的详细清单见 [`docs/agents/`](agents/)。
---
## 1. 项目是什么
**bailian-cli** 是阿里云百炼DashScope / Model Studio平台的命令行工具让用户和 AI Agent 通过终端调用平台的全部 AI 能力:
- 文本/全模态对话、图像/视频生成与编辑、语音合成与识别
- 知识库检索、记忆管理、应用调用、MCP 集成
- 微调与部署、数据集管理、配额与业务空间
- 控制台能力(用量统计、限流提额、资产中心等)
仓库以 **pnpm monorepo** 组织,产出两个 npm 产品:
| 产品 | 包名 | 二进制 | 定位 |
| -------------- | ---------------------- | ---------------- | ------------------------------------ |
| 百炼全量 CLI | `bailian-cli` | `bl` / `bailian` | 暴露全部命令 |
| 知识库轻量 CLI | `knowledge-studio-cli` | `kscli` | 仅 config + knowledge 命令,路径拍平 |
---
## 2. 技术栈
| 类别 | 选型 |
| --------- | -------------------------------------------------------------------------------------------- |
| 语言 | TypeScriptstrict |
| 运行时 | Node.js ≥ 22.12 |
| 包管理 | pnpm 10 + workspace catalog |
| 构建/测试 | [vite-plus](https://github.com/voidzero-dev/vite-plus)`vp check` / `vp test` / `vp pack` |
| HTTP | undici经 core client 封装) |
| 模块 | ESM`"type": "module"` |
---
## 3. 核心架构:四层分层
项目按 **「纯逻辑 → 运行时框架 → 命令库 → 产品入口」** 严格分层,职责边界清晰:
```
┌─────────────────────────────────────────────────────────────────┐
│ 产品入口层 │
│ packages/cli (bl) packages/kscli (kscli) │
│ 决定命令路径 map、产品 identity、README、技能 reference │
└────────────────────────────┬────────────────────────────────────┘
│ createCli(commands, identity)
┌────────────────────────────▼────────────────────────────────────┐
│ 运行时框架层 packages/runtime (bailian-cli-runtime) │
│ 参数解析、命令树/registry、help、middleware、错误处理、输出 │
└────────────────────────────┬────────────────────────────────────┘
│ 调用 defineCommand 的 run()
┌────────────────────────────▼────────────────────────────────────┐
│ 命令库层 packages/commands (bailian-cli-commands) │
│ 96+ 命令实现;只导出 command不决定产品路径 │
└────────────────────────────┬────────────────────────────────────┘
│ client / settings / auth
┌────────────────────────────▼────────────────────────────────────┐
│ 纯逻辑层 packages/core (bailian-cli-core) │
│ 鉴权、配置、HTTP client、错误、类型、文件工具、领域 API │
└─────────────────────────────────────────────────────────────────┘
```
### 分层边界(必须遵守)
| 层 | 可以做 | 不能做 |
| --------------- | --------------------------- | ------------------------------------------------------------- |
| **core** | 纯库逻辑、HTTP、鉴权解析 | 依赖 runtime/commands硬编码 `bl`/`kscli`;调 `process.exit` |
| **runtime** | TTY、help、middleware、输出 | 写具体业务命令逻辑 |
| **commands** | 命令元数据 + `run` 实现 | 决定产品路径;在 usage 里写 bin 前缀 |
| **cli / kscli** | 命令路径 map、产品 identity | 不写命令业务逻辑 |
---
## 4. 包详解
### 4.1 `packages/core` — `bailian-cli-core`
纯逻辑层,被所有上层依赖。主要模块:
```
packages/core/src/
├── auth/ # API Key / Console token 解析与落盘
├── client/ # HTTP client、endpoints、MCP、流式解析
├── config/ # ~/.bailian/config.json、Settings、来源优先级
├── console/ # Console Gateway 调用
├── dataset/ # 数据集校验ChatML/DPO/CPT schema
├── finetune/ # 微调 API 与能力探测
├── deploy/ # 部署 API
├── advisor/ # 模型推荐(意图识别 + 召回)
├── errors/ # BailianError、UsageError、退出码
├── output/ # JSON/text 格式化(命令层也可用 runtime 的 emit
├── files/ # 本地文件上传、URL 解析
├── telemetry/ # 命令执行遥测
└── types/ # Command、FlagsDef、defineCommand
```
**关键类型** — 每个命令通过 `defineCommand` 声明:
```typescript
defineCommand({
description: "…",
auth: "apiKey" | "console" | "none",
flags: {
/* camelCase key → kebab-case CLI flag */
},
usageArgs: "--prompt <text> [flags]", // 不含 bl/kscli 前缀
exampleArgs: ['--prompt "hello"'],
validate: (flags) => string | undefined, // 跨 flag 校验
run: async (ctx) => {
/* ctx.client / ctx.flags / ctx.settings */
},
});
```
**Client** 是命令的网络入口,凭证已注入,命令层不碰 token
```typescript
ctx.client.requestJson({ path: "/…", method: "POST", body });
ctx.client.console({ product: "…", action: "…", params });
ctx.client.uploadFile(localPath);
ctx.client.mcp();
```
### 4.2 `packages/runtime` — `bailian-cli-runtime`
通用 CLI 框架,与具体业务无关。核心文件:
| 文件 | 职责 |
| ------------------ | ----------------------------------------------- |
| `create-cli.ts` | 入口工厂:`createCli(commands, identity).run()` |
| `registry.ts` | 从 `Record<string, AnyCommand>` 建树,动态 help |
| `args.ts` | 路径 + flag 解析 |
| `middleware.ts` | auth → telemetry → versionCheck → runCommand |
| `error-handler.ts` | 统一错误输出与退出码 |
| `urls.ts` | 用户面控制台 URL非 API endpoint |
| `output/` | 颜色、表格、进度条、banner |
| `pipeline/` | 多步 pipeline 编排(`bl pipeline run` |
**Middleware 流水线**(洋葱模型):
```
argv 解析
→ authStage 按 command.auth 注入 apiKey / console 凭证到 ctx.client
→ telemetryStage 记录命令执行
→ versionCheckStage 检查 npm 更新
→ runCommandStage 调用 command.run(ctx)
```
### 4.3 `packages/commands` — `bailian-cli-commands`
命令实现库,按**能力域**组织目录(≠ 最终 CLI 路径):
```
packages/commands/src/commands/
├── text/ # 文本对话
├── omni/ # 全模态对话
├── image/ # 图像生成/编辑
├── video/ # 视频生成/编辑/下载
├── speech/ # 语音合成/识别
├── vision/ # 图像/视频理解
├── knowledge/ # 知识库检索/搜索/对话
├── memory/ # 记忆管理
├── app/ # 应用调用
├── mcp/ # MCP 服务
├── auth/ # 登录/登出/状态
├── config/ # 配置读写
├── console/ # 通用 Console Gateway 调用
├── dataset/ # 数据集上传/校验
├── finetune/ # 微调任务
├── deploy/ # 模型部署
├── quota/ # 限流与提额
├── workspace/ # 业务空间
├── usage/ # 用量统计
├── advisor/ # 模型推荐
├── asset-center/ # 资产中心(新)
├── pipeline/ # Pipeline 编排
├── search/ # 联网搜索
├── file/ # 文件上传
├── token-plan/ # Token 计划
└── update.ts # 自更新
```
每个命令文件 `export default defineCommand(…)`,并在 `packages/commands/src/index.ts` 具名 re-export。
### 4.4 `packages/cli` — `bailian-cli``bl`
产品入口,极薄:
```typescript
// packages/cli/src/main.ts
createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
}).run();
```
**命令路径由 `packages/cli/src/commands.ts` 决定**,例如:
```typescript
export const commands: Record<string, AnyCommand> = {
"text chat": textChat,
"asset-center list": assetList,
"finetune create": finetuneCreate,
update, // 单级命令 key 即路径
};
```
此文件还被 `tools/generate-reference.ts` 读取,生成 Agent Skill 参考文档。
### 4.5 `packages/kscli` — `knowledge-studio-cli``kscli`
轻量 RAG 产品,**复用同一套 commands**,但路径拍平:
```typescript
const commands = {
retrieve: knowledgeRetrieve, // ↔ bl knowledge retrieve
search: knowledgeSearch, // ↔ bl knowledge search
chat: knowledgeChat, // ↔ bl knowledge chat
"config show": configShow,
update,
};
```
同一个 `knowledgeRetrieve` 实现,在 `bl` 显示 `bl knowledge retrieve`,在 `kscli` 显示 `kscli retrieve`——路径完全由产品入口 map 的 key 决定。
---
## 5. 一次命令执行的完整链路
`bl text chat --message "hi"` 为例:
```mermaid
sequenceDiagram
participant User
participant main as cli/main.ts
participant createCli as runtime/create-cli.ts
participant registry as runtime/registry.ts
participant mw as middleware
participant cmd as commands/text/chat.ts
participant client as core/client
User->>main: bl text chat --message "hi"
main->>createCli: createCli(commands, identity).run(argv)
createCli->>registry: 解析路径 ["text","chat"]
registry-->>createCli: 匹配 textChat command
createCli->>mw: authStage → 注入 apiKey 到 client
mw->>cmd: run(ctx)
cmd->>client: requestJson / parseSSE
client-->>User: stdout 输出
```
**配置与凭证解析优先级**core 统一处理,命令不介入):
| 来源 | API Key | Console Token |
| ---- | ----------------------- | ------------------------------------- |
| 1 | `--api-key` flag | `~/.bailian/config.json` access_token |
| 2 | `DASHSCOPE_API_KEY` env | — |
| 3 | config.json `api_key` | — |
Console 命令额外有 `--console-region``--workspace-id` 等 flag由 runtime 按 `auth: "console"` 自动展示)。
---
## 6. 鉴权域
每个命令声明 `auth` 字段runtime 自动处理:
| auth 值 | 适用场景 | 凭证来源 | 网络方法 |
| ----------- | ------------------------------ | -------------------- | -------------------------------- |
| `"apiKey"` | DashScope API模型推理等 | API Key | `client.request` / `requestJson` |
| `"console"` | Console Gateway控制台能力 | Console access token | `client.console` |
| `"none"` | 纯本地config、update、help | 无 | 可选 credential-less client |
**规则**:调用 Console Gateway 的命令必须 `auth: "console"`,且**不要**重复声明 console 凭证域 flags。
---
## 7. 错误处理约定
CLI **只翻译自己能权威解释的错误**,服务端错误原样透传:
| 错误来源 | 处理 |
| ---------------------- | -------------------------------- |
| 缺参、flag 校验 | `UsageError` → 退出码 2 |
| 本地无凭证 | `BailianError(AUTH)` |
| 网络/DNS/TLS | `BailianError(NETWORK)` |
| HTTP 4xx/5xx、业务错码 | message **原样透传**,不二次包装 |
---
## 8. 开发工作流
### 8.1 环境准备
```bash
# 要求 Node >= 22.12, pnpm >= 10
pnpm install
# 格式化 + lint + 类型检查
pnpm run check # 或 vp check
# 本地跑 bltsx 直跑,无需 build
pnpm run bl -- text chat --help
pnpm run kscli -- search --help
# 全量测试
pnpm test # 或 vp test
# 构建所有包
pnpm run ready # check + test + build
```
### 8.2 新增一个 `bl` 命令(最小路径)
假设新增 `bl widget do`
**Step 1** — 实现命令(`packages/commands`
```bash
# 新建
packages/commands/src/commands/widget/do.ts
```
```typescript
import { defineCommand, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const FLAGS = {
name: { type: "string", valueHint: "<name>", description: "Widget name", required: true },
} satisfies FlagsDef;
export default defineCommand({
description: "Do something with a widget",
auth: "apiKey", // 或 "console" / "none"
flags: FLAGS,
usageArgs: "--name <name>",
exampleArgs: ['--name "demo"'],
async run(ctx) {
const data = await ctx.client.requestJson({ path: "/…", method: "POST", body: { } });
emitResult(ctx, data);
},
});
```
**Step 2** — 导出(`packages/commands/src/index.ts`
```typescript
export { default as widgetDo } from "./commands/widget/do.ts";
```
**Step 3** — 注册产品路径(`packages/cli/src/commands.ts`
```typescript
import { widgetDo } from "bailian-cli-commands";
// …
"widget do": widgetDo,
```
**Step 4** — E2E 测试(`packages/cli/tests/e2e/widget.e2e.test.ts`
见 [docs/agents/cli-e2e-tests.md](agents/cli-e2e-tests.md):至少覆盖分组 help、`--help`、缺参用例。
**Step 5** — 验证
```bash
vp check
vp test
pnpm run bl -- widget do --help
```
> 若 `kscli` 也需要暴露:在 `packages/kscli/src/main.ts` 的 map 里加 key。
> 技能 reference 会在 pre-commit 时由 `generate-reference.ts` 自动从 `commands.ts` 生成。
详细清单 → [docs/agents/command-add-remove.md](agents/command-add-remove.md)
### 8.3 给已有命令加 flag
→ [docs/agents/command-flag-change.md](agents/command-flag-change.md)
---
## 9. 测试体系
```
packages/cli/tests/
├── e2e/ # 33 个 e2e 测试文件
│ ├── helpers.ts # runCli、环境变量 readiness 判断
│ ├── global-setup.ts
│ └── <topic>.e2e.test.ts
└── stress/ # 多能力并发压测
├── run.mjs
└── targets/
```
**E2E 双层结构**(固定模式):
```typescript
// 层 1永远跑 — help / 分组,无需 API Key
describe("e2e: asset-center", () => {
test("asset-center 分组展示子命令帮助且成功退出", );
test("asset-center list --help 正常退出", );
});
// 层 2skipIf 缺凭证 — dry-run / 真实集成
describe.skipIf(!isConsoleE2EReady())("e2e: asset-centerConsole …)", () => {
test("缺少 --asset-id 时退出为用法错误 (2)", );
test("真实 list 流程", );
});
```
环境变量(常用):
| 变量 | 用途 |
| --------------------------------------------- | ------------------------- |
| `DASHSCOPE_API_KEY` | 模型 API 集成测试 |
| Console token`bl auth login --console` | 控制台命令测试 |
| `BAILIAN_E2E_*` | 各能力开关(视频/媒体等) |
压测:`pnpm run test:stress`
---
## 10. 命令能力地图(`bl` 全量)
当前 `packages/cli/src/commands.ts` 注册的命令组:
| 命令组 | 子命令示例 | auth 域 |
| -------------- | ------------------------------------------------------------------------------- | ---------------- |
| `auth` | login, status, logout | none / console |
| `text` | chat | apiKey |
| `omni` | (全模态对话) | apiKey |
| `image` | generate, edit | apiKey |
| `video` | generate, edit, ref, task get, download | apiKey |
| `vision` | describe | apiKey |
| `speech` | synthesize, recognize | apiKey |
| `knowledge` | retrieve, search, chat | apiKey |
| `memory` | add, search, list, update, delete, profile create/get | apiKey |
| `app` | call, list | apiKey / console |
| `mcp` | call, list, tools | apiKey |
| `search` | web | apiKey |
| `file` | upload | apiKey |
| `config` | show, set | none |
| `console` | call | console |
| `usage` | free, freetier, stats | console |
| `workspace` | list | console |
| `quota` | list, request, history, check | console |
| `dataset` | upload, list, get, delete, validate | console |
| `finetune` | create, list, get, cancel, delete, logs, checkpoints, export, watch, capability | console |
| `deploy` | create, list, get, models, scale, update, delete | console |
| `token-plan` | list-seats, create-key, assign-seats, add-member | console |
| `asset-center` | list, get, favorite, unfavorite, delete, download, stats, storage | console |
| `pipeline` | run, validate | apiKey |
| `advisor` | recommend | apiKey |
| `update` | (自更新) | none |
---
## 11. 非代码资产
```
tools/
├── generate-reference.ts # 从 cli/commands.ts → skills/bailian-cli/reference/
├── sync-skill-metadata.ts # 同步 SKILL.md 版本号
└── release/ # CI 发版自动化
skills/bailian-cli/ # Agent Skillnpx skills add modelstudioai/cli
.github/workflows/ # CI/CDpublish.yml 等)
docs/agents/ # 各维护场景的 AI 清单
```
根脚本:
```bash
pnpm run sync:skill-assets # build + 生成 reference + 同步版本
pnpm run release:check # 发版前校验
```
---
## 12. 发布
- 版本号:`packages/core``runtime``commands``cli``kscli` **保持同步**
- 发布范围:`tools/release/lib/packages.mjs` 定义
- `bailian-cli` 走常规定义发布;`knowledge-studio-cli``--knowledge` 通道
- 详见 [docs/agents/publish.md](agents/publish.md)
---
## 13. 关键文件速查
| 我想… | 看这里 |
| ------------------- | --------------------------------------- |
| 了解项目契约 | `AGENTS.md` |
| 改 `bl` 命令路径 | `packages/cli/src/commands.ts` |
| 写/改命令逻辑 | `packages/commands/src/commands/<域>/` |
| 导出命令 | `packages/commands/src/index.ts` |
| 改 CLI 框架行为 | `packages/runtime/src/` |
| 改 HTTP/鉴权/配置 | `packages/core/src/` |
| 改 kscli 路径 | `packages/kscli/src/main.ts` |
| 加 E2E 测试 | `packages/cli/tests/e2e/` |
| 改控制台 URL | `packages/runtime/src/urls.ts` |
| 改 API endpoint | `packages/core/src/client/endpoints.ts` |
| 改配置 schema | `packages/core/src/config/schema.ts` |
| 生成 Agent 参考文档 | `tools/generate-reference.ts` |
---
## 14. 场景导航(维护清单)
| 场景 | 文档 |
| ----------- | ------------------------------------------------------- |
| 命令增删改 | [command-add-remove.md](agents/command-add-remove.md) |
| E2E 测试 | [cli-e2e-tests.md](agents/cli-e2e-tests.md) |
| 加/改 flag | [command-flag-change.md](agents/command-flag-change.md) |
| 模型上下架 | [model-add-remove.md](agents/model-add-remove.md) |
| 错误文案 | [error-hint-change.md](agents/error-hint-change.md) |
| 鉴权扩展 | [auth-change.md](agents/auth-change.md) |
| 配置项扩展 | [config-add.md](agents/config-add.md) |
| 发布 | [publish.md](agents/publish.md) |
| 工具链/lint | [lint-toolchain.md](agents/lint-toolchain.md) |
---
## 15. 架构设计要点(读懂代码的钥匙)
1. **命令实现 ≠ 产品路径** — 同一 `knowledgeRetrieve` 可以是 `bl knowledge retrieve``kscli retrieve`
2. **defineCommand 是契约**`auth` + `flags` + `run(ctx)` 是命令的全部接口;凭证和网络细节下沉到 core/runtime。
3. **registry 从 map 建树**`"asset-center list"` 等 path 自动变成命令组help 动态生成。
4. **flags 用 camelCase 定义** — runtime 渲染为 `--kebab-case``ParsedFlags<typeof FLAGS>` 提供类型安全。
5. **dry-run 是全局 flag**`--dry-run` 在 auth stage 有例外处理,命令在 `run` 开头判断 `ctx.settings.dryRun`
6. **本地路径即 URL** — 所有接受 URL 的参数同时支持本地文件路径core `files/upload` 自动上传。
7. **Console Gateway 统一入口** — 控制台 API 走 `client.console({ product, action, params })`,不散落 raw fetch。
---
## 16. 本地配置速览
配置文件:`~/.bailian/config.json`
```json
{
"api_key": "sk-…",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"access_token": "…",
"console_region": "cn-beijing",
"console_site": "domestic"
}
```
常用环境变量:
| 变量 | 说明 |
| ---------------------------- | -------------------------- |
| `DASHSCOPE_API_KEY` | 模型 API Key |
| `DASHSCOPE_BASE_URL` | API Base URL |
| `BAILIAN_WORKSPACE_ID` | 业务空间 ID |
| `HTTP_PROXY` / `HTTPS_PROXY` | 代理runtime 启动时读取) |
登录:
```bash
bl auth login # API Key
bl auth login --console # Console token扫码
bl auth status
```
---
_文档版本:基于仓库当前结构(含 `asset-center`、`kscli``packages/rag` 已演进为 `packages/kscli`。_
+1 -1
View File
@@ -123,7 +123,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### Console Login (OAuth)
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, asset center, direct console calls). Opens the Bailian console in your browser to sign in.
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
+1 -1
View File
@@ -122,7 +122,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### 控制台登录OAuth
控制台能力命令模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、资产中心、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.14.2",
"version": "1.14.3",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+4 -16
View File
@@ -45,6 +45,8 @@ import {
usageFreetier,
usageStats,
usageSummary,
usageTokenPlan,
usageCodingPlan,
pipelineRun,
pipelineValidate,
advisorRecommend,
@@ -84,14 +86,6 @@ import {
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
assetList,
assetGet,
assetFavorite,
assetUnfavorite,
assetDelete,
assetDownload,
assetStats,
assetStorage,
workspaceInit,
pluginInstall,
pluginLink,
@@ -172,6 +166,8 @@ export const commands: Record<string, AnyCommand> = {
"usage freetier": usageFreetier,
"usage stats": usageStats,
"usage summary": usageSummary,
"usage token-plan": usageTokenPlan,
"usage coding-plan": usageCodingPlan,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"advisor recommend": advisorRecommend,
@@ -211,14 +207,6 @@ export const commands: Record<string, AnyCommand> = {
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
"token-plan add-member": tokenPlanAddMember,
"asset-center list": assetList,
"asset-center get": assetGet,
"asset-center favorite": assetFavorite,
"asset-center unfavorite": assetUnfavorite,
"asset-center delete": assetDelete,
"asset-center download": assetDownload,
"asset-center stats": assetStats,
"asset-center storage": assetStorage,
"workspace init": workspaceInit,
"plugin install": pluginInstall,
"plugin link": pluginLink,
@@ -1,131 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: asset-center", () => {
test("asset-center 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["asset-center"]);
expect(exitCode, stderr).toBe(0);
const output = `${stdout}\n${stderr}`;
expect(output).toContain("list");
expect(output).toContain("storage");
expect(output).not.toContain("oss");
});
test("asset-center list --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--type");
expect(stderr).toContain("--recycle-bin");
expect(stderr).toContain("bl asset-center list");
});
test("asset-center get --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "get", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--asset-id");
});
test("asset-center favorite --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "favorite", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--id");
});
test("asset-center delete --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "delete", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--permanent");
});
test("asset-center download --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "download", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--id");
expect(stderr).not.toMatch(/(^|\s)--out(\s|$)/);
});
test("asset-center stats --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--sync-failed");
});
test("asset-center storage --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "storage", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl asset-center storage");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: asset-centerConsole", () => {
test("asset-center get 缺少 --asset-id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "get", "--quiet"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--asset-id|Missing required argument/i);
});
test("asset-center favorite 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "favorite", "--quiet"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required argument/i);
});
test("asset-center download 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "download", "--quiet"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required argument/i);
});
test("asset-center list --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"asset-center",
"list",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { deleteStatus?: string };
}>(stdout);
expect(data.api).toContain("listModelGeneratedAsset");
expect(data.data?.deleteStatus).toBe("NORMAL");
});
test("asset-center stats --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"asset-center",
"stats",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string }>(stdout);
expect(data.api).toContain("countModelGeneratedAsset");
});
test("asset-center storage --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"asset-center",
"storage",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string }>(stdout);
expect(data.api).toContain("getStorageQuota");
});
test("【console】asset-center list 真实调用或鉴权失败优雅退出", async () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID;
const args = ["asset-center", "list", "--output", "json", "--page-size", "1"];
if (workspaceId) args.push("--workspace-id", workspaceId);
const result = await runCli(args);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
});
@@ -7,10 +7,15 @@ const commandPaths = Object.keys(commands).sort();
const groupPaths = deriveGroupPaths(commandPaths);
describe("e2e: bl registry smoke", () => {
test("根帮助展示 bl 与全局 flag", async () => {
test("根帮助展示 bl、逐命令鉴权域与全局 flag", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/\bbl\b/i);
expect(stderr).not.toMatch(/COMMAND\s+AUTH\s+DESCRIPTION/);
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
expect(stderr).toMatch(/token-plan create-key\s+\[AK\/SK\]\s+Create a Token Plan API key/);
expect(stderr).toMatch(/config show\s+\[No Auth\]\s+Display current configuration/);
expect(stderr).toMatch(/--base-url/);
expect(stderr).toMatch(/--console-region/);
expect(stderr).toMatch(/--console-site/);
@@ -18,6 +23,24 @@ describe("e2e: bl registry smoke", () => {
expect(stderr).not.toMatch(/^\s*--region\s/m);
});
test("分组帮助按叶子命令展示不同鉴权域", async () => {
const { stderr, exitCode } = await runCli(["app", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
});
test.each([
[["text", "chat"], "API Key"],
[["app", "list"], "Console"],
[["token-plan", "list-seats"], "AK/SK"],
[["config", "show"], "No Auth"],
] as const)("%s --help 明确展示鉴权域 %s", async (commandPath, authLabel) => {
const { stderr, exitCode } = await runCli([...commandPath, "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain(`Authentication: ${authLabel}`);
});
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.14.2",
"version": "1.14.3",
"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,434 +0,0 @@
# 资产中心 CLI 命令树设计
> 本文档定义 `bl asset` 命令族的路径结构、help 层级、flags 概览与示例。
> 技术实现细节见 [DESIGN.md](./DESIGN.md)API 字段见 [api-doc.md](./api-doc.md)。
## 1. 命名原则
| 原则 | 说明 |
| ------------ | ------------------------------------------------------------------------- |
| 产品路径前缀 | `asset`(不用 `asset-center`,与 `deploy` / `dataset` 等产品域一致) |
| 层级深度 | 最多三级:`asset <group> <action>` |
| 子组条件 | 仅当子组下 ≥ 2 个 action 时使用子组(见 AGENTS.md |
| bin 前缀 | `usageArgs` / `exampleArgs` 不写 `bl`help 由 runtime 按路径补全 |
| 鉴权 | 全部 `auth: "console"`;自动可见 `--console-region` 等 CONSOLE_AUTH_FLAGS |
---
## 2. 命令树总览
```
bl asset
├── list # 分页查询资产列表
├── get <asset-id> # 查询单个资产详情
├── favorite # 收藏资产
├── unfavorite # 取消收藏
├── delete # 删除资产(默认软删到回收站)
├── restore # 从回收站恢复
├── download # 获取下载链接 / 可选落盘
├── stats # 资产数量统计
├── storage # 存储容量与配额
├── models # [P1] 模型列表(辅助筛选)
│ └── list
├── service # [P1/P2] 服务开通状态
│ ├── status
│ ├── enable # [P2]
│ └── disable # [P2]
```
---
## 3. 产品入口注册 Map
`packages/cli/src/commands.ts` 中预期注册camelCase export → kebab path
| Map Key | Export 名(建议) | Phase |
| ------------------------- | --------------------- | ----- |
| `"asset list"` | `assetList` | 1 |
| `"asset get"` | `assetGet` | 1 |
| `"asset favorite"` | `assetFavorite` | 1 |
| `"asset unfavorite"` | `assetUnfavorite` | 1 |
| `"asset delete"` | `assetDelete` | 1 |
| `"asset restore"` | `assetRestore` | 1 |
| `"asset download"` | `assetDownload` | 1 |
| `"asset stats"` | `assetStats` | 1 |
| `"asset storage"` | `assetStorage` | 1 |
| `"asset models list"` | `assetModelsList` | 2 |
| `"asset service status"` | `assetServiceStatus` | 2 |
| `"asset service enable"` | `assetServiceEnable` | 3 |
| `"asset service disable"` | `assetServiceDisable` | 3 |
---
## 4. Help 层级预览
### 4.1 顶层分组
```
$ bl asset
Asset management commands for Bailian Asset Center.
Commands:
list List model-generated assets
get Get asset details by ID
favorite Mark assets as favorites
unfavorite Remove assets from favorites
delete Delete assets (soft delete by default)
restore Restore soft-deleted assets
download Get asset download URLs
stats Count assets by type
storage View storage quota and usage
models Model configuration helpers
service Asset center service subscription
Run `bl asset <command> --help` for details.
```
---
## 5. 各命令规格
以下 `usageArgs` 为命令 metadata 中的值(不含 global flags。Global flags`--output``--dry-run``--quiet` 等)与 console flags`--workspace-id` 等)由 runtime 自动追加到 help。
---
### 5.1 Phase 1 命令
#### `bl asset list`
```
Description: List model-generated assets with filters and cursor pagination
Usage: bl asset list [flags]
Flags:
--type <type> Asset type: IMAGE, VIDEO, AUDIO
--model <name> Filter by model name
--keyword <text> Filter by asset name (substring)
--favorited Show only favorited assets
--recycle-bin Show soft-deleted assets (recycle bin)
--sync-status <status> OSS sync status filter
--begin-time <datetime> Filter by generate time start (ISO_LOCAL_DATE_TIME)
--end-time <datetime> Filter by generate time end
--include-download-url Include signed download URLs
--include-thumbnail Include thumbnail URLs
--thumbnail-width <px> Thumbnail width
--thumbnail-height <px> Thumbnail height
--page-size <n> Page size (default: 10, max: 100)
--next-token <token> Cursor for next page
--pre-token <token> Cursor for previous page
Examples:
bl asset list
bl asset list --type IMAGE --model qwen-image-3.0
bl asset list --favorited --page-size 20
bl asset list --recycle-bin
bl asset list --keyword landscape --output json
```
**PRD 映射:** #1 查看资产列表
---
#### `bl asset get <asset-id>`
```
Description: Get full details of a model-generated asset
Usage: bl asset get <asset-id> [flags]
Arguments:
<asset-id> Asset ID to query
Flags:
--asset-id <id> Asset ID (alternative to positional)
--include-download-url Include signed download URL
--include-thumbnail Include thumbnail URL
--thumbnail-width <px> Thumbnail width
--thumbnail-height <px> Thumbnail height
Examples:
bl asset get asset-001
bl asset get asset-001 --include-download-url --output json
```
**PRD 映射:** #2 查看资产详情
---
#### `bl asset favorite`
```
Description: Add assets to favorites
Usage: bl asset favorite --id <asset-id> [--id <asset-id>...]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
Examples:
bl asset favorite --id asset-001
bl asset favorite --id asset-001 --id asset-002
```
**PRD 映射:** #3 收藏
---
#### `bl asset unfavorite`
```
Description: Remove assets from favorites
Usage: bl asset unfavorite --id <asset-id> [--id <asset-id>...]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
Examples:
bl asset unfavorite --id asset-001
bl asset unfavorite --id asset-001 --id asset-002
```
**PRD 映射:** #3 取消收藏
---
#### `bl asset delete`
```
Description: Delete assets (soft delete to recycle bin by default)
Usage: bl asset delete --id <asset-id> [--id <asset-id>...] [flags]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
--permanent Permanently delete (cannot be restored)
Examples:
bl asset delete --id asset-001
bl asset delete --id asset-001 --id asset-002
bl asset delete --id asset-001 --permanent
```
**PRD 映射:** #4 删除资产、#5 批量删除
---
#### `bl asset restore`
```
Description: Restore soft-deleted assets from recycle bin
Usage: bl asset restore --id <asset-id> [--id <asset-id>...]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
Examples:
bl asset restore --id asset-001
bl asset restore --id asset-001 --id asset-002
```
**PRD 映射:** 补充能力(配合回收站)
---
#### `bl asset download`
```
Description: Get signed download URLs for assets
Usage: bl asset download --id <asset-id> [--id <asset-id>...] [--out <path>]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
--out <path> Save file to path (only when exactly one --id)
Examples:
bl asset download --id asset-001
bl asset download --id asset-001 --out ./image.png
bl asset download --id asset-001 --id asset-002 --output json
```
**PRD 映射:** #6 下载资产
---
#### `bl asset stats`
```
Description: Count model-generated assets by type
Usage: bl asset stats [flags]
Flags:
--type <type> Filter by asset type
--model <name> Filter by model name
--keyword <text> Filter by asset name
--favorited Count only favorited assets
--recycle-bin Count soft-deleted assets
--sync-failed Also count assets with failed OSS sync
--begin-time <datetime> Filter by generate time start
--end-time <datetime> Filter by generate time end
Examples:
bl asset stats
bl asset stats --sync-failed
bl asset stats --type IMAGE --output json
```
**PRD 映射:** #7 查看资产统计
**text 输出示例:**
```
Total: 200
Image: 150
Video: 30
Audio: 20
Sync failed: 5 # 仅 --sync-failed 时出现
```
---
#### `bl asset storage`
```
Description: View storage quota, usage, and overage pricing
Usage: bl asset storage [flags]
Examples:
bl asset storage
bl asset storage --output json
```
**PRD 映射:** #14 查看容量信息
**text 输出示例:**
```
Used: 1.2 GB
Free quota: 5.0 GB
Overage: ¥0.12/GB/month
```
---
### 5.3 Phase 2/3 可选命令
#### `bl asset models list`
```
Description: List managed models grouped by asset type
Usage: bl asset models list
Examples:
bl asset models list --output json
```
用途:配合 `bl asset list --model` 时查阅可用 modelId。
---
#### `bl asset service status`
```
Description: Check whether asset center service is enabled
Usage: bl asset service status
Examples:
bl asset service status
```
---
---
## 6. PRD 覆盖矩阵
| PRD # | 功能 | CLI 命令 | Phase | 状态 |
| ----- | ------------- | ------------------------------- | ----- | ---------------------------------- |
| 1 | 查看资产列表 | `asset list` | 1 | ✅ 可开发 |
| 2 | 查看资产详情 | `asset get` | 1 | ✅ 可开发 |
| 3 | 收藏/取消收藏 | `asset favorite` / `unfavorite` | 1 | ✅ 可开发 |
| 4 | 删除资产 | `asset delete` | 1 | ✅ 可开发 |
| 5 | 批量删除 | `asset delete`(多 `--id` | 1 | ✅ 可开发 |
| 6 | 下载资产 | `asset download` | 1 | ✅ 可开发 |
| 7 | 查看资产统计 | `asset stats` | 1 | ✅ 可开发(转存失败用 workaround |
| 14 | 查看容量信息 | `asset storage` | 1 | ✅ 可开发 |
---
## 7. 典型工作流
### 7.1 首次使用
```bash
bl auth login --console
bl config set workspace_id ws-xxxxx
bl asset service status # 可选:确认已开通
bl asset storage # 查看容量
```
### 7.2 浏览与筛选
```bash
bl asset list
bl asset list --type IMAGE --model qwen-image-3.0 --keyword landscape
bl asset list --favorited
bl asset list --recycle-bin
bl asset get asset-001 --include-download-url
bl asset stats
bl asset stats --sync-failed
```
### 7.3 资产管理
```bash
bl asset favorite --id asset-001
bl asset unfavorite --id asset-001
bl asset delete --id asset-001
bl asset delete --id asset-001 --id asset-002
bl asset restore --id asset-001
bl asset download --id asset-001 --out ./image.png
```
### 7.5 脚本翻页JSON
```bash
# 第一页
bl asset list --page-size 50 --output json
# 后续页(使用响应中的 nextToken
bl asset list --page-size 50 --next-token 1000 --output json
```
---
## 8. 与现有命令的风格对齐
| 参考命令 | 对齐点 |
| ------------------------------ | -------------------------------------------------- |
| `bl app list` | console gateway 调用、dry-run 输出 `{ api, data }` |
| `bl dataset list` | text 表格 + json items 结构 |
| `bl deploy list/get/create` | 产品域子命令命名、多级 path |
| `bl memory profile get/create` | 三级 path 子组 |
| `bl quota list` | `zeldaHttp.*` API 名、响应 extract |
| `bl video download` | `--out` 落盘 |
| `bl usage stats` | `requireWorkspaceId`、console E2E 模式 |
---
## 9. 变更记录
| 日期 | 版本 | 说明 |
| ---------- | ---- | ---------------------------------------------- |
| 2026-07-09 | 0.1 | 初稿命令树、PRD 映射、分 Phase 规格 |
| 2026-08-07 | 0.2 | 取消 OSS 转存命令(`oss *` / `transfer list` |
@@ -1,408 +0,0 @@
# 资产中心 CLI 设计文档
> 本文档描述 `bl asset` 命令族的技术设计方案,供开发、评审与联调使用。
> 接口字段细节见同目录 [api-doc.md](./api-doc.md);命令路径与 help 结构见 [COMMAND-TREE.md](./COMMAND-TREE.md)。
## 1. 背景与目标
### 1.1 背景
百炼资产中心Asset Center提供模型生成资产的存储、检索、收藏、删除与容量管理能力。产品 PRD 要求 CLI 覆盖以下模块:
| 模块 | PRD 能力 |
| -------- | ----------------------------------------------- |
| 资产管理 | 列表、详情、收藏/取消收藏、删除、批量删除、下载 |
| 资产统计 | 总量、按类型统计 |
| 容量 | 已用容量、免费额度、超额单价 |
后端接口通过 **Zelda HTTP 网关** 暴露Base Path 为 `/zelda/api/v1/bailian/asset`,详见 [api-doc.md](./api-doc.md)。
### 1.2 目标
-`packages/commands` 实现可复用命令库,由 `packages/cli/src/commands.ts` 注册为 `bl asset ...` 产品路径
- 遵循 monorepo 分层约定:`commands` 不写产品 bin 前缀Console Gateway 命令统一 `auth: "console"`
- 服务端错误原样透传CLI 仅对参数校验、缺凭证、网络失败等内部错误发出语义化 `BailianError`
- 支持 `--dry-run``--output json`、text 表格输出等现有 CLI 惯例
### 1.3 非目标
- 不在 `rag` 入口暴露(首期与 `deploy` / `finetune` 一致,仅 `bl`
- 不暴露 `sendMqMessage` 等内部 MQ 接口
- 不在 `core` / `runtime` 层硬编码 `bl` 命令名或控制台 URL
---
## 2. PRD → API → CLI 映射
### 2.1 资产管理
| PRD # | 能力 | CLI 命令 | API Action | 备注 |
| ----- | ------------- | ------------------------------------------- | --------------------------------------------- | ------------------------------------------ |
| 1 | 查看资产列表 | `bl asset list` | `listModelGeneratedAsset` | 游标分页;支持类型/模型/关键词/收藏/回收站 |
| 2 | 查看资产详情 | `bl asset get <asset-id>` | `getModelGeneratedAsset` | positional 或 `--asset-id` |
| 3 | 收藏/取消收藏 | `bl asset favorite` / `bl asset unfavorite` | `batchFavoriteAsset` / `batchUnfavoriteAsset` | 单 ID 也走 batch长度 1 |
| 4 | 删除资产 | `bl asset delete` | `batchDeleteAsset` | 默认 `SOFT_DELETE`(移入回收站) |
| 5 | 批量删除 | `bl asset delete` | `batchDeleteAsset` | `--id` 可重复,最多 100 |
| 6 | 下载资产 | `bl asset download` | `batchGetAssetDownloadUrl` | 默认输出 URL单资产可选 `--out` 落盘 |
**建议补充API 已有、PRD 未写):**
| 能力 | CLI 命令 | API Action |
| ------------ | ------------------ | ------------------- |
| 从回收站恢复 | `bl asset restore` | `batchRestoreAsset` |
### 2.2 资产统计
| PRD # | 能力 | CLI 命令 | API Action | 备注 |
| ----- | ------------ | ---------------- | -------------------------- | --------------------------------------- |
| 7 | 查看资产统计 | `bl asset stats` | `countModelGeneratedAsset` | 输出 total / image / video / audio 计数 |
**转存失败数PRD 子项):**
- API 支持 `syncOssDataStatus=SYNC_FAILED` 筛选,但无独立 `failureCount` 字段
- **Phase 1 方案**`bl asset stats --sync-failed` 额外发起一次 count 查询,输出 `sync_failed_count`
- **Phase 3 备选**:等后端在 stats 响应中增加专用字段后收敛
### 2.4 容量
| PRD # | 能力 | CLI 命令 | API Action |
| ----- | ------------ | ------------------ | ----------------- |
| 14 | 查看容量信息 | `bl asset storage` | `getStorageQuota` |
### 2.5 可选扩展API 有、PRD 未列)
| CLI 命令 | API Action | 优先级 |
| ------------------------------------- | ------------------------------- | -------------------- |
| `bl asset service status` | `checkAssetServiceSubscription` | P1 |
| `bl asset service enable` / `disable` | `subscribeAssetService` | P2 |
| `bl asset models list` | `listModels` | P1配合 list 筛选) |
---
## 3. 架构与分层
### 3.1 在 monorepo 中的位置
```
packages/commands/src/commands/asset-center/*.ts ← 命令实现(本目录)
↓ export
packages/commands/src/index.ts
↓ import + map key
packages/cli/src/commands.ts ← "asset list": assetList, ...
packages/runtime (createCli / authStage / registry)
```
约定:
- 实现文件按能力组织在本目录
- `usageArgs` / `exampleArgs` 不含 `bl` 前缀
- 所有 asset 命令 `auth: "console"`;不重复声明 `CONSOLE_AUTH_FLAGS`runtime 自动注入)
### 3.2 目录结构
```
asset-center/
├── api-doc.md # 后端 API 文档(已有)
├── DESIGN.md # 本文档
├── COMMAND-TREE.md # 命令树与 help 结构
├── types.ts # TypeScript 类型ModelGeneratedAssetItem 等)
├── utils.ts # 公共请求构建、API 调用、响应解析
├── list.ts
├── get.ts
├── favorite.ts
├── unfavorite.ts
├── delete.ts
├── download.ts
├── stats.ts
└── storage.ts
```
### 3.3 共享层 `utils.ts`
参考 `token-plan/utils.ts``usage/stats.ts``requireWorkspaceId` 模式。
#### 3.3.1 API 名称约定
`quota/list.ts``zeldaHttp.dashscopeModel./zelda/api/v1/...` 类似,资产中心预期为:
```typescript
const ASSET_SERVICE = "bailianAsset"; // ⚠️ 编码前需 spike 确认
const ASSET_BASE = "/zelda/api/v1/bailian/asset";
function assetApi(action: string): string {
return `zeldaHttp.${ASSET_SERVICE}.${ASSET_BASE}/${action}`;
}
```
编码第一步用 `bl console call --api <name> --data '{...}'` 验证实际注册名。
#### 3.3.2 公共请求体
所有接口继承 `AssetHttpBaseRequest`(见 api-doc §公共请求参数):
| 字段 | CLI 来源 | 状态 |
| ---------------- | --------------------------------------------------------- | ---------- |
| `workspace` | `settings.workspaceId``--workspace-id` / env / config | ✅ 已有 |
| `tenantId` | 待定 | ⚠️ 需确认 |
| `mainAccountUid` | 待定 | ⚠️ 需确认 |
| `apiSource` | 固定 `"CLI"` | 实现时写入 |
| `aliYunUid` 等 | 网关 session 注入或省略 | 待确认 |
`requireWorkspaceId(settings, binName)` 在缺少 workspace 时抛出 `BailianError(GENERAL)`hint 指向 `bl workspace list`
#### 3.3.3 调用封装
```typescript
async function callAssetApi<T>(
ctx: CommandRunContext,
action: string,
body: Record<string, unknown>,
): Promise<T> {
const payload = { ...buildBaseRequest(ctx), ...body };
const raw = await ctx.client.console(assetApi(action), payload);
return extractAssetResponse<T>(raw);
}
```
#### 3.3.4 响应解析
Console Gateway 响应可能存在多层嵌套(参考 `quota/list.ts``extractResponseData`
1. 剥 gateway 外层:`data``DataV2``data` → ...
2. 到达业务 `Result<T>``{ success, code, message, data }`
3.`success === false`:抛 `BailianError(GENERAL, message)`**不翻译、不替换** message
4. 成功时返回 `data` 字段
---
## 4. 命令实现规范
### 4.1 通用模式
每个命令文件遵循:
```typescript
export default defineCommand({
description: "...",
auth: "console",
usageArgs: "...",
flags: { ... },
exampleArgs: ["...", "--output json"],
validate(ctx) { /* 跨 flag 条件校验 */ },
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
if (ctx.settings.dryRun) {
emitResult({ api: assetApi("..."), data: { ... } }, format);
return;
}
const data = await callAssetApi(ctx, "actionName", { ... });
// text 表格 或 emitResult(json)
},
});
```
参考实现:`app/list.ts`console + dry-run`dataset/list.ts`(表格输出)、`video/download.ts`(落盘)。
### 4.2 分页模型(`asset list`
**与 `app list` 不同**:资产列表使用 **id 游标分页**,不是 page/pageSize 页码模式。
| Flag | API 字段 | 说明 |
| -------------- | ----------- | -------------------------- |
| `--page-size` | `pageSize` | 默认 10最大 100 |
| `--next-token` | `nextToken` | 下一页游标(来自上次响应) |
| `--pre-token` | `preToken` | 上一页游标 |
JSON 输出保留 `nextToken` / `preToken` / `hasNext` / `hasPre`,便于脚本翻页。
### 4.3 批量 ID 传参
批量操作favorite / unfavorite / delete / restore / download统一
```typescript
id: {
type: "array",
valueHint: "<asset-id>",
description: "Asset ID(s) to operate on (repeatable, max 100)",
required: true,
}
```
CLI 用法:`--id asset-001 --id asset-002` 或多次重复。实现时在 `validate` 中校验 `ids.length <= 100`
### 4.4 输出格式
| 命令 | text 默认 | json |
| ---------- | -------------------------------------------------------------- | ----------------------------------------- |
| `list` | 表格assetId / type / name / model / favorited / generateTime | items + pagination |
| `get` | 关键字段摘要 | 完整 item |
| `stats` | 数字摘要 | `{ total_count, image_count, ... }` |
| `storage` | 人类可读字节 + 单价 | 原始 quota 字段 |
| 写操作 | 一行确认affectedCount | `{ success, affected_count }` |
| `download` | URL 列表或 saved 路径 | `{ items: [{ asset_id, download_url }] }` |
使用 `formatTable``dataset/list.ts`)、`formatBytes``video/download.ts`)、`emitResult` / `emitBare`
### 4.5 条件校验(`validate`
| 命令 | 规则 |
| ---------- | --------------------------------------------------------- |
| `delete` | `--permanent` 映射 `PERMANENT_DELETE`;默认 `SOFT_DELETE` |
| 所有 batch | `assetIdList.length <= 100` |
---
## 5. 关键命令 Flag 详设
### 5.1 `bl asset list`
| Flag | 类型 | API 映射 | 说明 |
| ------------------------ | ---------------- | ---------------------------- | ------------------------------------------------------------ |
| `--type` | string (choices) | `assetType` | `IMAGE` / `VIDEO` / `AUDIO` |
| `--model` | string | `modelName` | PRD「按模型筛选」 |
| `--keyword` | string | `assetName` | PRD「关键词」是否同时搜 description 待产品确认 |
| `--favorited` | switch | `favorited: true` | 仅看收藏 |
| `--recycle-bin` | switch | `deleteStatus: SOFT_DELETED` | 仅看回收站 |
| `--sync-status` | string (choices) | `syncOssDataStatus` | `NOT_SYNCED` / `IN_SYNCING` / `SYNC_SUCCESS` / `SYNC_FAILED` |
| `--begin-time` | string | `beginTime` | ISO_LOCAL_DATE_TIME |
| `--end-time` | string | `endTime` | ISO_LOCAL_DATE_TIME |
| `--include-download-url` | switch | `includeDownloadUrl` | |
| `--include-thumbnail` | switch | `includeThumbnail` | |
| `--thumbnail-width` | number | `thumbnailWidth` | 配合 thumbnail |
| `--thumbnail-height` | number | `thumbnailHeight` | 配合 thumbnail |
| `--page-size` | number | `pageSize` | |
| `--next-token` | number | `nextToken` | |
| `--pre-token` | number | `preToken` | |
### 5.2 `bl asset get`
| 参数/Flag | 说明 |
| ------------------------------------------ | --------------------------------------- |
| `<asset-id>` | positionalprimary |
| `--asset-id` | 与 positional 二选一positional 优先) |
| `--include-download-url` | |
| `--include-thumbnail` | |
| `--thumbnail-width` / `--thumbnail-height` | |
### 5.3 `bl asset delete`
| Flag | 说明 |
| ------------- | --------------------------------------------------------- |
| `--id` | array, required, max 100 |
| `--permanent` | switch → `deleteType: PERMANENT_DELETE`;默认 SOFT_DELETE |
### 5.4 `bl asset download`
| Flag | 说明 |
| ------- | ----------------------------------------------------- |
| `--id` | array, required |
| `--out` | 仅当 `--id` 恰好 1 个时有效;调用 `downloadFile` 落盘 |
### 5.5 `bl asset stats`
| Flag | 说明 |
| ------------------------------------- | ---------------------------------------------- |
| (无 filter | 默认 `deleteStatus: NORMAL` |
| `--recycle-bin` | `deleteStatus: SOFT_DELETED` |
| `--sync-failed` | 额外查询 `syncOssDataStatus: SYNC_FAILED` 计数 |
| `--type` / `--model` / `--keyword` 等 | 与 list 相同筛选维度(可选) |
---
## 6. 风险与待确认项
### 6.1 P0 — 编码前必须对齐
| # | 问题 | 影响 | 建议动作 |
| --- | ------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------- |
| 1 | Console API 注册名(`zeldaHttp.{service}./zelda/api/v1/bailian/asset/*` | 无法调用 | `bl console call` spike与后端确认 service 名 |
| 2 | `tenantId` / `mainAccountUid` 由谁填充 | 所有接口必填 | 确认网关是否从 session 自动注入;否则扩展 config 或新增解析 API |
### 6.2 P1 — 产品设计
| # | 问题 | 建议默认 |
| --- | -------------------- | ----------------------------------------------- |
| 4 | 关键词搜索范围 | 仅 `assetName`;后续可加 `--search-description` |
| 5 | PRD 只提 image/video | CLI 暴露 IMAGE/VIDEO/AUDIO与 API 一致) |
| 6 | 下载行为 | 默认输出 URL单 ID + `--out` 落盘 |
| 7 | 永久删除 | 提供 `--permanent`help 注明不可恢复 |
| 8 | 服务未开通 | 不预检查;失败时透传服务端 message |
| 9 | 收藏命令形态 | 两个命令 `favorite` / `unfavorite`(语义清晰) |
---
## 7. 错误处理
遵循 [AGENTS.md](../../../../../../AGENTS.md) 错误边界:
| 场景 | 处理 |
| --------------------------------- | --------------------------------------------- |
| 缺 `--workspace-id` | `BailianError(GENERAL)` + hint |
| 缺 console token | authStage 抛 `BailianError(AUTH)` |
| flag 校验失败 | `UsageError` (exit 2) |
| HTTP 4xx/5xx / 业务 success=false | `BailianError(GENERAL)`message **原样透传** |
| batch ID > 100 | `UsageError` |
Console 未登录参考 `mcp/list.ts`:检测 `BailianGateway.Login.NotLogined` 时 hint 指向 `bl auth login --console`
---
## 8. 测试策略
新建 `packages/cli/tests/e2e/asset.e2e.test.ts`,遵循 [cli-e2e-tests.md](../../../../../../docs/agents/cli-e2e-tests.md)。
### 8.1 不 skip 层
- `bl asset` 分组 help
- 各子命令 `--help`
- 缺参 → exit 2
### 8.2 Console skip 层(`isConsoleE2EReady()`
- 各命令 `--dry-run` 输出 api + data
- 真实 `asset list` / `asset storage` 集成(需已开通资产中心的工作空间)
环境:`BAILIAN_E2E=1` + console `access_token` + `BAILIAN_WORKSPACE_ID`
---
## 9. 注册与文档变更清单
| 文件 | 变更 |
| -------------------------------------------------- | ------------------- |
| `packages/commands/src/commands/asset-center/*.ts` | 新建 |
| `packages/commands/src/index.ts` | export |
| `packages/cli/src/commands.ts` | 注册 map |
| `packages/cli/tests/e2e/asset.e2e.test.ts` | 新建 |
| `skills/bailian-cli/reference/` | pre-commit 自动生成 |
| `README.md` / `README.zh.md` | 发版前补充命令一览 |
---
## 10. 分期实施
### Phase 1 — 核心资产P0
```
asset list | get | favorite | unfavorite | delete | restore | download | stats | storage
```
**前置:** §6.1 #1 #2 确认。
### Phase 2+ — 可选扩展
```
asset models list | service status | service enable/disable
```
> OSS 转存相关命令(`asset-center oss *` / `transfer list`)已取消,不再排期。
## 11. 参考
- 命令注册:[docs/agents/command-add-remove.md](../../../../../../docs/agents/command-add-remove.md)
- E2E 规范:[docs/agents/cli-e2e-tests.md](../../../../../../docs/agents/cli-e2e-tests.md)
- Console 命令样例:`packages/commands/src/commands/app/list.ts`
- 游标/表格:`packages/commands/src/commands/quota/list.ts`
- workspace 必填:`packages/commands/src/commands/usage/stats.ts`
- 文件落盘:`packages/commands/src/commands/video/download.ts`
@@ -1,35 +0,0 @@
# Asset Center 命令测试报告 — Phase 2
- **测试时间**: 2026-07-10 09:07:09 (UTC)
- **Workspace**: `llm-0xvms4kqhbqjlg8s`
- **测试 IMAGE**: `asset_98175cbf83294f7b8ada86657623dcf3`
- **测试 VIDEO**: `asset_df026105d2274ff9b8c824058fa23d60`
- **策略**: 可逆写操作favorite/unfavorite 往返download 到 /tmp 后删除;其余只读
- **汇总**: 16 通过 / 0 失败 / 16 总计
> Phase 1 报告见同目录 [TEST-REPORT.md](./TEST-REPORT.md)24 项 dry-run + 只读基础验证)
## Phase 2 测试结果
| # | 分类 | 命令 | 模式 | 状态 | Exit | 耗时 | 结果摘要 |
| --- | ---- | ------------------------------------------ | -------- | ------- | ---- | ------- | -------------------------------------------------------------------------------------------------------- |
| 1 | 下载 | `asset-center download (IMAGE)` | 真实调用 | ✅ PASS | 0 | 20045ms | saved /tmp/asset-center-test-asset_98175cbf83294f7b8ada86657623dcf3.png (1449847 bytes, reported 1.4 MB) |
| 2 | 查询 | `asset-center get --include-download-url` | 真实调用 | ✅ PASS | 0 | 21226ms | download_url present |
| 3 | 查询 | `asset-center list --include-download-url` | 真实调用 | ✅ PASS | 0 | 19057ms | items contain download_url |
| 4 | 查询 | `asset-center list --next-token` | 真实调用 | ✅ PASS | 0 | 19584ms | page2=3 items, overlap=0, has_pre=true |
| 5 | 统计 | `asset-center stats --type IMAGE` | 真实调用 | ✅ PASS | 0 | 18830ms | image=7, total=7 |
| 6 | 统计 | `asset-center stats --sync-failed` | 真实调用 | ✅ PASS | 0 | 19084ms | total=27, sync_failed=0 |
| 7 | 查询 | `asset-center list --recycle-bin` | 真实调用 | ✅ PASS | 0 | 19314ms | 0 soft-deleted item(s) |
| 8 | 输出 | `asset-center list (text)` | 真实调用 | ✅ PASS | 0 | 17840ms | 4 lines table output |
| 9 | 收藏 | `asset-center favorite (真实)` | 真实调用 | ✅ PASS | 0 | 22082ms | affected=1 |
| 10 | 收藏 | `get 验证 favorited=true` | 真实调用 | ✅ PASS | 0 | 18865ms | favorited=true ✓ |
| 11 | 查询 | `list --favorited 含测试资产` | 真实调用 | ✅ PASS | 0 | 22120ms | found in favorited list |
| 12 | 收藏 | `asset-center unfavorite (真实)` | 真实调用 | ✅ PASS | 0 | 22133ms | affected=1 |
| 13 | 收藏 | `get 验证 favorited=false (恢复)` | 真实调用 | ✅ PASS | 0 | 27797ms | favorited=false ✓ |
| 14 | 收藏 | `favorite 批量 (--id x2)` | 真实调用 | ✅ PASS | 0 | 22947ms | affected=2 |
| 15 | 收藏 | `unfavorite 批量 (--id x2)` | 真实调用 | ✅ PASS | 0 | 15385ms | affected=2 |
| 16 | 边界 | `get 不存在的 asset-id` | 真实调用 | ✅ PASS | 1 | 12207ms | exit 1, 服务端错误原样透传: "资产不存在" |
## 边界行为说明
查询不存在的 `asset-id` 时,服务端返回业务错误 **「资产不存在」**CLI 按约定 **原样透传**exit code 1不会替换为本地文案。这与 AGENTS.md 错误处理边界一致。
@@ -1,36 +0,0 @@
# Asset Center 命令测试报告
- **测试时间**: 2026-07-10 08:41:06 (UTC)
- **Workspace**: `llm-0xvms4kqhbqjlg8s`
- **样本 Asset ID**: `asset_df026105d2274ff9b8c824058fa23d60`
- **策略**: 只读命令真实调用;写操作/下载一律 `--dry-run`
- **汇总**: 24 通过 / 0 失败 / 24 总计
## 测试结果
| # | 分类 | 命令 | 模式 | 状态 | Exit | 耗时 | 结果摘要 |
| --- | ---- | ---------------------------------- | -------- | ------- | ---- | ------- | -------------------------------------------------------------- |
| 1 | 查询 | `asset-center list` | 真实调用 | ✅ PASS | 0 | 18962ms | 3 item(s), next=94 |
| 2 | 查询 | `asset-center list --type IMAGE` | 真实调用 | ✅ PASS | 0 | 16411ms | 2 item(s), next=90 |
| 3 | 查询 | `asset-center get` | 真实调用 | ✅ PASS | 0 | 16541ms | {gmtModified, aliyunUid, generateTime, aliyunMainId} |
| 4 | 统计 | `asset-center stats` | 真实调用 | ✅ PASS | 0 | 14226ms | total=27 |
| 5 | 统计 | `asset-center storage` | 真实调用 | ✅ PASS | 0 | 18622ms | {used_storage_size, free_storage_quota, extra_storage_price} |
| 9 | 查询 | `asset-center list --dry-run` | dry-run | ✅ PASS | 0 | 23855ms | dry-run → /zelda/api/v1/bailian/asset/listModelGeneratedAsset |
| 10 | 查询 | `asset-center get --dry-run` | dry-run | ✅ PASS | 0 | 17398ms | dry-run → /zelda/api/v1/bailian/asset/getModelGeneratedAsset |
| 11 | 收藏 | `asset-center favorite` | dry-run | ✅ PASS | 0 | 16471ms | dry-run → /zelda/api/v1/bailian/asset/batchFavoriteAsset |
| 12 | 收藏 | `asset-center unfavorite` | dry-run | ✅ PASS | 0 | 19724ms | dry-run → /zelda/api/v1/bailian/asset/batchUnfavoriteAsset |
| 13 | 删除 | `asset-center delete` | dry-run | ✅ PASS | 0 | 20612ms | dry-run → /zelda/api/v1/bailian/asset/batchDeleteAsset |
| 14 | 下载 | `asset-center download` | dry-run | ✅ PASS | 0 | 20348ms | dry-run → /zelda/api/v1/bailian/asset/batchGetAssetDownloadUrl |
| 15 | 统计 | `asset-center stats --dry-run` | dry-run | ✅ PASS | 0 | 14898ms | dry-run → /zelda/api/v1/bailian/asset/countModelGeneratedAsset |
| 16 | 统计 | `asset-center storage --dry-run` | dry-run | ✅ PASS | 0 | 14305ms | dry-run → /zelda/api/v1/bailian/asset/getStorageQuota |
| 21 | 校验 | `asset-center get (缺 asset-id)` | 参数校验 | ✅ PASS | 2 | 15202ms | Error: Missing required flag: --asset-id |
| 22 | 校验 | `asset-center favorite (缺 --id)` | 参数校验 | ✅ PASS | 2 | 16697ms | Error: Missing required flag: --id |
| 23 | 校验 | `asset-center download (缺 --out)` | 参数校验 | ✅ PASS | 2 | 18803ms | Error: Missing required flag: --out |
## 模式说明
| 模式 | 说明 |
| -------- | -------------------------------------------------- |
| 真实调用 | 只读 API不修改数据 |
| dry-run | 输出 `{ api, data, gateway }` 请求体,不发起写操作 |
| 参数校验 | 预期 exit code 2用法错误 |
@@ -1,624 +0,0 @@
# BailianAssetZeldaHttpService API 文档
通过 Zelda 网关调用 bailian-asset HTTP 接口文档。
## 基础信息
- **Base Path**: `/zelda/api/v1/bailian/asset`
- **Method**: POST
- **Content-Type**: `application/json`
- **Accept**: `application/json`
## 统一响应格式
所有接口返回 `Result<T>` 结构:
```json
{
"requestId": "string",
"success": true,
"code": "string",
"message": "string",
"data": { ... }
}
```
| 字段 | 类型 | 说明 |
| --------- | ------- | ---------------------- |
| requestId | String | 请求唯一ID |
| success | Boolean | 是否成功 |
| code | String | 错误码(失败时返回) |
| message | String | 错误信息(失败时返回) |
| data | Object | 业务数据(成功时返回) |
## 公共请求参数(基类字段)
所有接口请求体均继承自 `AssetHttpBaseRequest`,包含以下公共字段:
| 字段 | 类型 | 必填 | 说明 |
| -------------- | ------ | ---- | ---------------------------------------------- |
| requestId | String | 否 | 请求唯一ID |
| apiSource | String | 否 | 调用入口渠道,如 OpenAPI、CloudSDK |
| tenantId | String | 是 | 内部租户ID |
| workspace | String | 是 | 业务空间ID |
| aliYunUid | String | 否 | 阿里云子账号ID |
| mainAccountUid | String | 是 | 阿里云主账号ID |
| callerType | String | 否 | 账号类型partner/customer/sub/AssumedRoleUser |
| callerParentId | Long | 否 | 调用者所属主账号ID |
| accessKeyId | String | 否 | STS认证用户AccessKeyId |
| securityToken | String | 否 | STS认证扮演者的STS Token |
---
## 1. 开通/关闭资产中心服务
**POST** `/zelda/api/v1/bailian/asset/subscribeAssetService`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------ | ------------ | ---- | --------------------------------------- |
| action | String(Enum) | 是 | 操作类型:`ENABLE`-开通,`DISABLE`-关闭 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------ | ------------ | ------------ |
| status | String(Enum) | 当前服务状态 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"action": "ENABLE"
}
```
---
## 8. 批量收藏资产
**POST** `/zelda/api/v1/bailian/asset/batchFavoriteAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ---------------------------------- |
| assetIdList | List<String> | 是 | 待收藏的资产ID列表长度不超过 100 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------------- | ------- | -------------------- |
| success | Boolean | 是否收藏成功 |
| affectedCount | Integer | 实际被收藏的资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002", "asset-003"]
}
```
---
## 9. 批量取消收藏资产
**POST** `/zelda/api/v1/bailian/asset/batchUnfavoriteAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | -------------------------------------- |
| assetIdList | List<String> | 是 | 待取消收藏的资产ID列表长度不超过 100 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------------- | ------- | ------------------------ |
| success | Boolean | 是否取消收藏成功 |
| affectedCount | Integer | 实际被取消收藏的资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002"]
}
```
---
## 10. 批量删除资产
**POST** `/zelda/api/v1/bailian/asset/batchDeleteAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ----------------------------------------------------------- |
| assetIdList | List<String> | 是 | 待删除的资产ID列表长度不超过 100 |
| deleteType | String(Enum) | 是 | 删除类型:`SOFT_DELETE`-软删除,`PERMANENT_DELETE`-彻底删除 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------------- | ------- | -------------------- |
| success | Boolean | 是否删除成功 |
| affectedCount | Integer | 实际被删除的资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002"],
"deleteType": "SOFT_DELETE"
}
```
---
## 11. 批量恢复软删除资产
**POST** `/zelda/api/v1/bailian/asset/batchRestoreAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ---------------------------------- |
| assetIdList | List<String> | 是 | 待恢复的资产ID列表长度不超过 100 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------------- | ------- | -------------------- |
| success | Boolean | 是否恢复成功 |
| affectedCount | Integer | 实际被恢复的资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002"]
}
```
---
## 12. 分页查询模型生成资产
**POST** `/zelda/api/v1/bailian/asset/listModelGeneratedAsset`
采用 id 游标分页,默认 pageSize=10最大 100。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------------------- | ------------ | ---- | ---------------------------------------------------------------------------------- |
| preToken | Long | 否 | 向前翻页游标 |
| nextToken | Long | 否 | 向后翻页游标(查询下一页时传入上一次响应的 nextToken |
| pageSize | Integer | 否 | 每页大小,默认 10最大 100 |
| includeDownloadUrl | Boolean | 否 | 是否返回文件下载链接,默认 false |
| includeThumbnail | Boolean | 否 | 是否返回资产缩放图 URL默认 false |
| thumbnailWidth | Integer | 否 | 缩放图宽度像素includeThumbnail=true 时生效 |
| thumbnailHeight | Integer | 否 | 缩放图高度像素includeThumbnail=true 时生效 |
| softDeleteTimeOrder | String(Enum) | 否 | 软删除时间排序方式:`ASC`-正序,`DESC`-倒序;仅在 deleteStatus=SOFT_DELETED 时有效 |
| assetType | String(Enum) | 否 | 资产类型:`IMAGE`-图片,`VIDEO`-视频,`AUDIO`-音频 |
| favorited | Boolean | 否 | 是否被收藏 |
| assetName | String | 否 | 资产名称(子串模糊匹配) |
| assetDescription | String | 否 | 资产描述(子串模糊匹配) |
| trusted | Boolean | 否 | 是否可信 |
| modelType | String | 否 | 生成资产的模型类型 |
| modelName | String | 否 | 生成资产的模型型号 |
| syncWhiteListStatus | String(Enum) | 否 | 同步白名单状态:`NOT_SYNCED` / `SYNC_SUCCESS` / `SYNC_FAILED` |
| syncOssDataStatus | String(Enum) | 否 | 同步OSS数据状态`NOT_SYNCED` / `IN_SYNCING` / `SYNC_SUCCESS` / `SYNC_FAILED` |
| deleteStatus | String(Enum) | 否 | 删除状态:`NORMAL` / `SOFT_DELETED` / `PERMANENTLY_DELETED` |
| beginTime | String | 否 | 资产生成时间起始(含),格式 ISO_LOCAL_DATE_TIME`2023-10-25T14:30:00` |
| endTime | String | 否 | 资产生成时间截止(含),格式 ISO_LOCAL_DATE_TIME`2023-10-25T14:30:00` |
### 响应 data
| 字段 | 类型 | 说明 |
| --------- | ----------------------------- | ------------ |
| dataList | List<ModelGeneratedAssetItem> | 资产列表 |
| preToken | Long | 前一页游标 |
| nextToken | Long | 下一页游标 |
| hasNext | Boolean | 是否有下一页 |
| hasPre | Boolean | 是否有前一页 |
**ModelGeneratedAssetItem 结构:**
| 字段 | 类型 | 说明 |
| ------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| id | Long | 主键 ID分页游标 token |
| gmtCreate | Date | 创建时间 |
| gmtModified | Date | 修改时间 |
| workspaceId | String | 工作空间ID |
| tenantId | String | 租户ID |
| aliyunUid | String | 阿里云子账号ID |
| aliyunMainId | String | 阿里云主账号ID |
| assetId | String | 资产 ID |
| assetType | String | 资产类型IMAGE/VIDEO/AUDIO |
| assetSource | String | 资产来源MODEL_GENERATED/OFFICIAL/USER_UPLOADED |
| favorited | Boolean | 是否被收藏 |
| assetName | String | 资产名称 |
| assetDescription | String | 资产描述 |
| assetSize | Long | 资产大小(字节) |
| md5 | String | 资产 MD5 |
| ossBucket | String | 资产所在 OSS Bucket |
| ossKey | String | 资产在 OSS bucket 中的 key |
| region | String | 工作空间地域 |
| ossRegion | String | 资产所在 OSS bucket 的地域 |
| trusted | Boolean | 是否可信 |
| modelType | String | 模型类型 |
| modelName | String | 模型型号 |
| syncWhiteListStatus | String | 同步白名单状态 |
| syncOssDataStatus | String | 同步 OSS 数据状态 |
| deleteStatus | String | 删除状态NORMAL/SOFT_DELETED/PERMANENTLY_DELETED |
| generateTime | Long | 资产生成时间戳(毫秒) |
| softDeleteDays | Integer | 已被软删除的天数(仅当 deleteStatus=SOFT_DELETED 且请求 softDeleteTimeOrder 时返回) |
| originalOssUrl | String | 原始 OSS URL |
| downloadUrl | String | 文件下载链接(仅当请求 includeDownloadUrl=true 时返回) |
| thumbnailUrl | String | 资产缩放图 URL仅当请求 includeThumbnail=true 时返回;视频返回首帧缩放图,图片返回缩放图,音频返回 null |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"pageSize": 20,
"includeDownloadUrl": true,
"includeThumbnail": true,
"thumbnailWidth": 200,
"thumbnailHeight": 200,
"assetType": "IMAGE",
"favorited": true,
"beginTime": "2024-01-01T00:00:00",
"endTime": "2024-12-31T23:59:59"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"dataList": [
{
"id": 1001,
"assetId": "asset-001",
"assetType": "IMAGE",
"assetName": "generated_image_01.png",
"assetDescription": "A landscape painting",
"favorited": true,
"generateTime": 1700000000000
}
],
"nextToken": 1000,
"hasNext": true,
"hasPre": false
}
}
```
---
## 13. 统计模型生成资产数量
**POST** `/zelda/api/v1/bailian/asset/countModelGeneratedAsset`
查询条件与 `listModelGeneratedAsset` 一致(不需要分页参数),按资产类型分组返回数量。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------------------- | ------------ | ---- | ------------------------------------------------ |
| assetType | String(Enum) | 否 | 资产类型:`IMAGE` / `VIDEO` / `AUDIO` |
| favorited | Boolean | 否 | 是否被收藏 |
| assetName | String | 否 | 资产名称(子串模糊匹配) |
| assetDescription | String | 否 | 资产描述(子串模糊匹配) |
| trusted | Boolean | 否 | 是否可信 |
| modelType | String | 否 | 模型类型 |
| modelName | String | 否 | 模型型号 |
| syncWhiteListStatus | String(Enum) | 否 | 同步白名单状态 |
| syncOssDataStatus | String(Enum) | 否 | 同步OSS数据状态 |
| deleteStatus | String(Enum) | 否 | 删除状态 |
| beginTime | String | 否 | 资产生成时间起始(含),格式 ISO_LOCAL_DATE_TIME |
| endTime | String | 否 | 资产生成时间截止(含),格式 ISO_LOCAL_DATE_TIME |
### 响应 data
| 字段 | 类型 | 说明 |
| ---------- | ---- | ---------------- |
| imageCount | Long | 图片类型资产数量 |
| videoCount | Long | 视频类型资产数量 |
| audioCount | Long | 音频类型资产数量 |
| totalCount | Long | 总资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"deleteStatus": "NORMAL"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"imageCount": 150,
"videoCount": 30,
"audioCount": 20,
"totalCount": 200
}
}
```
---
## 14. 批量获取资产下载链接
**POST** `/zelda/api/v1/bailian/asset/batchGetAssetDownloadUrl`
一次最多获取 100 个资产的下载链接。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ------------------------------------------ |
| assetIdList | List<String> | 是 | 待获取下载链接的资产ID列表长度不超过 100 |
### 响应 data
| 字段 | 类型 | 说明 |
| ----- | -------------------------- | -------------------------------- |
| items | List<AssetDownloadUrlItem> | 资产下载链接列表,按请求顺序返回 |
**AssetDownloadUrlItem 结构:**
| 字段 | 类型 | 说明 |
| ----------- | ------ | ---------------------------------------------------------- |
| assetId | String | 资产 ID |
| downloadUrl | String | 资产下载链接(带签名);资产不存在或缺少 OSS 信息时为 null |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002", "asset-003"]
}
```
---
## 15. 查询模型生成资产详情
**POST** `/zelda/api/v1/bailian/asset/getModelGeneratedAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------------------ | ------- | ---- | ------------------------------------------------ |
| assetId | String | 是 | 待查询的资产 ID |
| includeDownloadUrl | Boolean | 否 | 是否返回文件下载链接,默认 false |
| includeThumbnail | Boolean | 否 | 是否返回资产缩放图 URL默认 false |
| thumbnailWidth | Integer | 否 | 缩放图宽度像素includeThumbnail=true 时生效 |
| thumbnailHeight | Integer | 否 | 缩放图高度像素includeThumbnail=true 时生效 |
### 响应 data
| 字段 | 类型 | 说明 |
| ---- | ----------------------- | ------------------------ |
| item | ModelGeneratedAssetItem | 资产详情结构同第12节 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetId": "asset-001",
"includeDownloadUrl": true,
"includeThumbnail": true,
"thumbnailWidth": 200,
"thumbnailHeight": 200
}
```
---
## 16. 获取存储额度与用量
**POST** `/zelda/api/v1/bailian/asset/getStorageQuota`
### 请求参数
仅需公共参数(`workspace``tenantId` 必填)。
### 响应 data
| 字段 | 类型 | 说明 |
| ----------------- | ------ | --------------------------------------------- |
| freeStorageQuota | Long | 平台免费存储额度(单位:字节) |
| usedStorageSize | Long | 当前用户已使用的存储量(单位:字节) |
| extraStoragePrice | String | 超出免费额度的费用说明(如 "¥0.12元/GB/月" |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890"
}
```
---
## 19. 通用 MQ 消息发送
**POST** `/zelda/api/v1/bailian/asset/sendMqMessage`
向指定的 RocketMQ Producer 发送 JSON 格式的消息。producerType 对应 `EnumRocketMqProducerType` 枚举的 code 值mainAccountUid 作为消息路由 key。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------------ | ------ | ---- | --------------------------------------------------------------------------------------------------- |
| producerType | String | 是 | 生产者类型:`WHITE_LIST_ASSET_PRODUCER` / `OSS_DATA_HANDEL_PRODUCER` / `ORIGIN_ASSET_INFO_PRODUCER` |
| messageBody | String | 是 | JSON 格式的消息体字符串 |
| messageKey | String | 否 | 消息 key可选为空时默认使用 mainAccountUid |
### 响应 data
| 字段 | 类型 | 说明 |
| ------- | ------- | ------------ |
| success | Boolean | 是否发送成功 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"producerType": "ORIGIN_ASSET_INFO_PRODUCER",
"messageBody": "{\"time\":1700000000000,\"modelId\":\"model-abc\",\"type\":\"IMAGE\",\"workspace\":\"ws-xxxxx\",\"ossUrl\":\"oss://my-bucket/path/to/asset.png\"}"
}
```
---
## 21. 查询模型列表
**POST** `/zelda/api/v1/bailian/asset/listModels`
返回当前服务管理的模型配置列表按资产类型分组包含每个模型的ID及是否可信标识。
### 请求参数
仅需公共参数。
### 响应 data
| 字段 | 类型 | 说明 |
| ----------- | ---------------- | ---------------------------- |
| modelGroups | List<ModelGroup> | 按资产类型分组的模型配置列表 |
**ModelGroup 结构:**
| 字段 | 类型 | 说明 |
| --------- | --------------- | ------------------------------------- |
| assetType | String(Enum) | 资产类型:`IMAGE` / `VIDEO` / `AUDIO` |
| models | List<ModelItem> | 该类型下管理的模型列表 |
**ModelItem 结构:**
| 字段 | 类型 | 说明 |
| ------- | ------- | -------------- |
| modelId | String | 模型ID |
| trusted | Boolean | 该模型是否可信 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"modelGroups": [
{
"assetType": "IMAGE",
"models": [
{ "modelId": "qwen-image-3.0", "trusted": true },
{ "modelId": "qwen-image-3.0-pro", "trusted": true }
]
},
{
"assetType": "VIDEO",
"models": [
{ "modelId": "wan2.7-t2v", "trusted": true },
{ "modelId": "wan2.7-i2v", "trusted": true }
]
}
]
}
}
```
---
## 22. 查询用户是否已开通资产中心服务
**POST** `/zelda/api/v1/bailian/asset/checkAssetServiceSubscription`
查询当前用户是否已开通资产中心服务。
### 请求参数
仅需公共参数(`mainAccountUid` 必填)。
### 响应 data
| 字段 | 类型 | 说明 |
| ------- | ------- | ------------------------------------------------- |
| enabled | Boolean | 是否已开通资产中心服务true-已开通false-未开通 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"enabled": true
}
}
```
@@ -1,67 +0,0 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import type { AssetBatchResponse } from "./types.ts";
import {
ASSET_API,
ASSET_ID_FLAG,
callAssetApi,
dryRunPayload,
validateAssetIds,
} from "./utils.ts";
const DELETE_FLAGS = {
...ASSET_ID_FLAG,
permanent: {
type: "switch",
description: "Permanently delete assets (cannot be restored)",
},
} satisfies FlagsDef;
/**
* `bl asset-center delete` — 删除资产(默认软删到回收站)。
*
* 软删可恢复;--permanent 为永久删除。支持重复 --id单次最多 100 个)。
*/
export default defineCommand({
description: "Delete assets (soft delete to recycle bin by default)",
auth: "console",
usageArgs: "--id <asset-id> [--id <asset-id>...] [flags]",
flags: DELETE_FLAGS,
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002", "--id asset-001 --permanent"],
validate(flags) {
return validateAssetIds(flags.id);
},
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetIdList = flags.id;
const deleteType = flags.permanent ? "PERMANENT_DELETE" : "SOFT_DELETE";
const body = { assetIdList, deleteType };
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.batchDeleteAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetBatchResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.batchDeleteAsset,
body,
);
const verb = flags.permanent ? "Permanently deleted" : "Deleted";
if (settings.quiet || format === "text") {
emitBare(`${verb} ${data.affectedCount ?? assetIdList.length} asset(s).`);
} else {
emitResult(
{ affected_count: data.affectedCount ?? assetIdList.length, delete_type: deleteType },
format,
);
}
},
});
@@ -1,76 +0,0 @@
import {
defineCommand,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
import type { AssetDownloadResponse } from "./types.ts";
import { ASSET_API, callAssetApi, dryRunPayload } from "./utils.ts";
const DOWNLOAD_FLAGS = {
id: {
type: "string",
valueHint: "<asset-id>",
description: "Asset ID to get download URL for",
required: true,
},
} satisfies FlagsDef;
/**
* `bl asset-center download` — 通过资产 ID 获取签名下载链接。
*
* 调用 batchGetAssetDownloadUrl输出 download URL不落盘。
*/
export default defineCommand({
description: "Get a signed download URL for an asset by ID",
auth: "console",
usageArgs: "--id <asset-id>",
flags: DOWNLOAD_FLAGS,
exampleArgs: ["--id asset-001", "--id asset-001 --output json", "--id asset-001 --quiet"],
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetId = flags.id;
const body = { assetIdList: [assetId] };
if (settings.dryRun) {
emitResult(
{
asset_id: assetId,
action: "download",
...dryRunPayload(settings, identity.binName, ASSET_API.batchGetAssetDownloadUrl, body),
},
format,
);
return;
}
const data = await callAssetApi<AssetDownloadResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.batchGetAssetDownloadUrl,
body,
);
const url = data.items?.[0]?.downloadUrl;
if (!url) {
throw new BailianError(`No download URL available for ${assetId}.`, ExitCode.GENERAL);
}
if (settings.quiet) {
emitBare(url);
return;
}
if (format === "json") {
emitResult({ asset_id: assetId, download_url: url }, format);
return;
}
emitBare(`${padEnd("AssetId", 16)} ${assetId}`);
emitBare(`${padEnd("DownloadUrl", 16)} ${url}`);
},
});
@@ -1,54 +0,0 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import type { AssetBatchResponse } from "./types.ts";
import {
ASSET_API,
ASSET_ID_FLAG,
callAssetApi,
dryRunPayload,
validateAssetIds,
} from "./utils.ts";
/**
* `bl asset-center favorite` — 收藏一个或多个资产。
*
* 支持重复 --id单次最多 100 个),调用 batchFavoriteAsset。
*/
export default defineCommand({
description: "Add assets to favorites",
auth: "console",
usageArgs: "--id <asset-id> [--id <asset-id>...]",
flags: ASSET_ID_FLAG,
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002"],
validate(flags) {
return validateAssetIds(flags.id);
},
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetIdList = flags.id;
const body = { assetIdList };
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.batchFavoriteAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetBatchResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.batchFavoriteAsset,
body,
);
if (settings.quiet || format === "text") {
emitBare(`Favorited ${data.affectedCount ?? assetIdList.length} asset(s).`);
} else {
emitResult({ affected_count: data.affectedCount ?? assetIdList.length }, format);
}
},
});
@@ -1,95 +0,0 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
import type { AssetGetResponse } from "./types.ts";
import { ASSET_API, callAssetApi, dryRunPayload, formatGenerateTime } from "./utils.ts";
const GET_FLAGS = {
assetId: {
type: "string",
valueHint: "<id>",
description: "Asset ID to query",
required: true,
},
includeDownloadUrl: {
type: "switch",
description: "Include signed download URL",
},
includeThumbnail: {
type: "switch",
description: "Include thumbnail URL",
},
thumbnailWidth: {
type: "number",
valueHint: "<px>",
description: "Thumbnail width in pixels",
},
thumbnailHeight: {
type: "number",
valueHint: "<px>",
description: "Thumbnail height in pixels",
},
} satisfies FlagsDef;
/**
* `bl asset-center get` — 按 ID 查询单个资产详情。
*
* 可选 --include-download-url / --include-thumbnail 获取签名 URL。
*/
export default defineCommand({
description: "Get full details of a model-generated asset",
auth: "console",
usageArgs: "--asset-id <id> [flags]",
flags: GET_FLAGS,
exampleArgs: [
"--asset-id asset-001",
"--asset-id asset-001 --include-download-url --output json",
],
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetId = flags.assetId;
const body: Record<string, unknown> = { assetId };
if (flags.includeDownloadUrl) body.includeDownloadUrl = true;
if (flags.includeThumbnail) body.includeThumbnail = true;
if (flags.thumbnailWidth !== undefined) body.thumbnailWidth = flags.thumbnailWidth;
if (flags.thumbnailHeight !== undefined) body.thumbnailHeight = flags.thumbnailHeight;
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.getModelGeneratedAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetGetResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.getModelGeneratedAsset,
body,
);
const item = data.item;
if (!item) {
emitBare("Asset not found.");
return;
}
if (format === "json") {
emitResult(item, format);
return;
}
emitBare(`${padEnd("AssetId", 16)} ${item.assetId ?? "-"}`);
emitBare(`${padEnd("Type", 16)} ${item.assetType ?? "-"}`);
emitBare(`${padEnd("Name", 16)} ${item.assetName ?? "-"}`);
emitBare(`${padEnd("Description", 16)} ${item.assetDescription ?? "-"}`);
emitBare(`${padEnd("Model", 16)} ${item.modelName ?? "-"}`);
emitBare(`${padEnd("Favorited", 16)} ${item.favorited ? "yes" : "no"}`);
emitBare(`${padEnd("Generated", 16)} ${formatGenerateTime(item.generateTime)}`);
if (item.downloadUrl) emitBare(`${padEnd("DownloadUrl", 16)} ${item.downloadUrl}`);
if (item.thumbnailUrl) emitBare(`${padEnd("ThumbnailUrl", 16)} ${item.thumbnailUrl}`);
},
});
@@ -1,150 +0,0 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
import type { AssetListResponse, ModelGeneratedAssetItem } from "./types.ts";
import {
ASSET_API,
ASSET_LIST_FILTER_FLAGS,
buildListFilterBody,
callAssetApi,
dryRunPayload,
formatGenerateTime,
} from "./utils.ts";
const LIST_FLAGS = {
...ASSET_LIST_FILTER_FLAGS,
includeDownloadUrl: {
type: "switch",
description: "Include signed download URLs in the response",
},
includeThumbnail: {
type: "switch",
description: "Include thumbnail URLs in the response",
},
thumbnailWidth: {
type: "number",
valueHint: "<px>",
description: "Thumbnail width in pixels",
},
thumbnailHeight: {
type: "number",
valueHint: "<px>",
description: "Thumbnail height in pixels",
},
pageSize: {
type: "number",
valueHint: "<n>",
description: "Results per page (default: 10, max: 100)",
},
nextToken: {
type: "number",
valueHint: "<token>",
description: "Cursor for the next page",
},
preToken: {
type: "number",
valueHint: "<token>",
description: "Cursor for the previous page",
},
} satisfies FlagsDef;
function normalizeItem(item: ModelGeneratedAssetItem) {
return {
asset_id: item.assetId ?? "",
asset_type: item.assetType ?? "",
asset_name: item.assetName ?? "",
model_name: item.modelName ?? "",
favorited: item.favorited ?? false,
generate_time: item.generateTime,
download_url: item.downloadUrl,
thumbnail_url: item.thumbnailUrl,
};
}
/**
* `bl asset-center list` — 分页查询模型生成资产列表。
*
* 支持类型/模型/关键词/收藏/回收站/OSS 同步状态/时间范围筛选,以及
* --next-token / --pre-token 游标翻页;可选返回签名下载链接与缩略图 URL。
*/
export default defineCommand({
description: "List model-generated assets with filters and cursor pagination",
auth: "console",
usageArgs: "[flags]",
flags: LIST_FLAGS,
exampleArgs: [
"",
"--type IMAGE --model qwen-image-3.0",
"--favorited --page-size 20",
"--recycle-bin",
"--keyword landscape --output json",
],
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const pageSize = flags.pageSize ?? 10;
const body: Record<string, unknown> = {
...buildListFilterBody(flags),
pageSize,
};
if (flags.includeDownloadUrl) body.includeDownloadUrl = true;
if (flags.includeThumbnail) body.includeThumbnail = true;
if (flags.thumbnailWidth !== undefined) body.thumbnailWidth = flags.thumbnailWidth;
if (flags.thumbnailHeight !== undefined) body.thumbnailHeight = flags.thumbnailHeight;
if (flags.nextToken !== undefined) body.nextToken = flags.nextToken;
if (flags.preToken !== undefined) body.preToken = flags.preToken;
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.listModelGeneratedAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetListResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.listModelGeneratedAsset,
body,
);
const items = (data.dataList ?? []).map(normalizeItem);
if (format === "json") {
emitResult(
{
items,
pre_token: data.preToken,
next_token: data.nextToken,
has_next: data.hasNext,
has_pre: data.hasPre,
},
format,
);
return;
}
if (items.length === 0) {
emitBare("No assets found.");
return;
}
const headers = ["ASSET_ID", "TYPE", "NAME", "MODEL", "FAVORITED", "GENERATED"];
const rows = items.map((item) => [
item.asset_id,
item.asset_type,
item.asset_name,
item.model_name,
item.favorited ? "yes" : "-",
formatGenerateTime(item.generate_time),
]);
for (const line of formatTable(headers, rows)) emitBare(line);
const parts: string[] = [];
if (data.hasPre) parts.push("has previous page");
if (data.hasNext) parts.push(`next token: ${data.nextToken}`);
if (parts.length > 0) emitBare(`\n${parts.join("; ")}`);
},
});
@@ -1,86 +0,0 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
import type { AssetCountResponse } from "./types.ts";
import {
ASSET_API,
ASSET_LIST_FILTER_FLAGS,
buildListFilterBody,
callAssetApi,
dryRunPayload,
} from "./utils.ts";
const STATS_FLAGS = {
...ASSET_LIST_FILTER_FLAGS,
syncFailed: {
type: "switch",
description: "Also count assets with failed OSS sync",
},
} satisfies FlagsDef;
/**
* `bl asset-center stats` — 按类型统计模型生成资产数量。
*
* 复用 list 的筛选条件;--sync-failed 额外统计 OSS 同步失败的资产数。
*/
export default defineCommand({
description: "Count model-generated assets by type",
auth: "console",
usageArgs: "[flags]",
flags: STATS_FLAGS,
exampleArgs: ["", "--sync-failed", "--type IMAGE --output json"],
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const body = buildListFilterBody(flags);
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.countModelGeneratedAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetCountResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.countModelGeneratedAsset,
body,
);
let syncFailedCount: number | undefined;
if (flags.syncFailed) {
const failed = await callAssetApi<AssetCountResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.countModelGeneratedAsset,
{ ...body, syncOssDataStatus: "SYNC_FAILED" },
);
syncFailedCount = failed.totalCount ?? 0;
}
if (format === "json") {
emitResult(
{
total_count: data.totalCount ?? 0,
image_count: data.imageCount ?? 0,
video_count: data.videoCount ?? 0,
audio_count: data.audioCount ?? 0,
...(syncFailedCount !== undefined ? { sync_failed_count: syncFailedCount } : {}),
},
format,
);
return;
}
emitBare(`${padEnd("Total", 14)} ${data.totalCount ?? 0}`);
emitBare(`${padEnd("Image", 14)} ${data.imageCount ?? 0}`);
emitBare(`${padEnd("Video", 14)} ${data.videoCount ?? 0}`);
emitBare(`${padEnd("Audio", 14)} ${data.audioCount ?? 0}`);
if (syncFailedCount !== undefined) {
emitBare(`${padEnd("Sync failed", 14)} ${syncFailedCount}`);
}
},
});
@@ -1,47 +0,0 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
import type { AssetStorageQuotaResponse } from "./types.ts";
import { ASSET_API, callAssetApi, dryRunPayload, formatStorageBytes } from "./utils.ts";
/**
* `bl asset-center storage` — 查看存储配额、已用容量与超额计费说明。
*/
export default defineCommand({
description: "View storage quota, usage, and overage pricing",
auth: "console",
usageArgs: "[flags]",
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings, identity } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(dryRunPayload(settings, identity.binName, ASSET_API.getStorageQuota, {}), format);
return;
}
const data = await callAssetApi<AssetStorageQuotaResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.getStorageQuota,
{},
);
if (format === "json") {
emitResult(
{
used_storage_size: data.usedStorageSize,
free_storage_quota: data.freeStorageQuota,
extra_storage_price: data.extraStoragePrice,
},
format,
);
return;
}
emitBare(`${padEnd("Used", 14)} ${formatStorageBytes(data.usedStorageSize)}`);
emitBare(`${padEnd("Free quota", 14)} ${formatStorageBytes(data.freeStorageQuota)}`);
emitBare(`${padEnd("Overage", 14)} ${data.extraStoragePrice ?? "-"}`);
},
});
@@ -1,72 +0,0 @@
export type AssetType = "IMAGE" | "VIDEO" | "AUDIO";
export type AssetDeleteStatus = "NORMAL" | "SOFT_DELETED" | "PERMANENTLY_DELETED";
export type AssetSyncOssStatus = "NOT_SYNCED" | "IN_SYNCING" | "SYNC_SUCCESS" | "SYNC_FAILED";
export type AssetDeleteType = "SOFT_DELETE" | "PERMANENT_DELETE";
export interface AssetHttpBaseRequest {
workspace: string;
tenantId?: string;
mainAccountUid?: string;
apiSource?: string;
}
export interface ModelGeneratedAssetItem {
id?: number;
assetId?: string;
assetType?: string;
assetName?: string;
assetDescription?: string;
favorited?: boolean;
assetSize?: number;
modelType?: string;
modelName?: string;
deleteStatus?: string;
syncOssDataStatus?: string;
generateTime?: number;
downloadUrl?: string;
thumbnailUrl?: string;
gmtCreate?: string;
gmtModified?: string;
}
export interface AssetListResponse {
dataList?: ModelGeneratedAssetItem[];
preToken?: number;
nextToken?: number;
hasNext?: boolean;
hasPre?: boolean;
}
export interface AssetGetResponse {
item?: ModelGeneratedAssetItem;
}
export interface AssetBatchResponse {
success?: boolean;
affectedCount?: number;
}
export interface AssetDownloadUrlItem {
assetId?: string;
downloadUrl?: string | null;
}
export interface AssetDownloadResponse {
items?: AssetDownloadUrlItem[];
}
export interface AssetCountResponse {
imageCount?: number;
videoCount?: number;
audioCount?: number;
totalCount?: number;
}
export interface AssetStorageQuotaResponse {
freeStorageQuota?: number;
usedStorageSize?: number;
extraStoragePrice?: string;
}
@@ -1,54 +0,0 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import type { AssetBatchResponse } from "./types.ts";
import {
ASSET_API,
ASSET_ID_FLAG,
callAssetApi,
dryRunPayload,
validateAssetIds,
} from "./utils.ts";
/**
* `bl asset-center unfavorite` — 取消收藏一个或多个资产。
*
* 支持重复 --id单次最多 100 个),调用 batchUnfavoriteAsset。
*/
export default defineCommand({
description: "Remove assets from favorites",
auth: "console",
usageArgs: "--id <asset-id> [--id <asset-id>...]",
flags: ASSET_ID_FLAG,
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002"],
validate(flags) {
return validateAssetIds(flags.id);
},
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetIdList = flags.id;
const body = { assetIdList };
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.batchUnfavoriteAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetBatchResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.batchUnfavoriteAsset,
body,
);
if (settings.quiet || format === "text") {
emitBare(`Unfavorited ${data.affectedCount ?? assetIdList.length} asset(s).`);
} else {
emitResult({ affected_count: data.affectedCount ?? assetIdList.length }, format);
}
},
});
@@ -1,209 +0,0 @@
import {
BailianError,
ExitCode,
effectiveConsoleGatewayConfig,
type Client,
type FlagsDef,
type ParsedFlags,
type Settings,
} from "bailian-cli-core";
import type { AssetHttpBaseRequest, AssetSyncOssStatus, AssetType } from "./types.ts";
const ASSET_SERVICE = "dashscopeModel";
const ASSET_BASE = "/zelda/api/v1/bailian/asset";
export const MAX_ASSET_BATCH_SIZE = 100;
export const ASSET_API = {
listModelGeneratedAsset: assetApi("listModelGeneratedAsset"),
getModelGeneratedAsset: assetApi("getModelGeneratedAsset"),
batchFavoriteAsset: assetApi("batchFavoriteAsset"),
batchUnfavoriteAsset: assetApi("batchUnfavoriteAsset"),
batchDeleteAsset: assetApi("batchDeleteAsset"),
batchGetAssetDownloadUrl: assetApi("batchGetAssetDownloadUrl"),
countModelGeneratedAsset: assetApi("countModelGeneratedAsset"),
getStorageQuota: assetApi("getStorageQuota"),
} as const;
export const ASSET_ID_FLAG = {
id: {
type: "array",
valueHint: "<asset-id>",
description: "Asset ID(s) to operate on (repeatable, max 100)",
required: true,
},
} satisfies FlagsDef;
export const ASSET_LIST_FILTER_FLAGS = {
type: {
type: "string",
valueHint: "<type>",
description: "Asset type: IMAGE, VIDEO, or AUDIO",
choices: ["IMAGE", "VIDEO", "AUDIO"] as const,
},
model: {
type: "string",
valueHint: "<name>",
description: "Filter by model name",
},
keyword: {
type: "string",
valueHint: "<text>",
description: "Filter by asset name (substring match)",
},
favorited: {
type: "switch",
description: "Show or count only favorited assets",
},
recycleBin: {
type: "switch",
description: "Show or count soft-deleted assets (recycle bin)",
},
syncStatus: {
type: "string",
valueHint: "<status>",
description: "OSS sync status filter",
choices: ["NOT_SYNCED", "IN_SYNCING", "SYNC_SUCCESS", "SYNC_FAILED"] as const,
},
beginTime: {
type: "string",
valueHint: "<datetime>",
description: "Filter by generate time start (ISO_LOCAL_DATE_TIME)",
},
endTime: {
type: "string",
valueHint: "<datetime>",
description: "Filter by generate time end (ISO_LOCAL_DATE_TIME)",
},
} satisfies FlagsDef;
type AssetListFilterFlags = ParsedFlags<typeof ASSET_LIST_FILTER_FLAGS>;
function assetApi(action: string): string {
return `zeldaHttp.${ASSET_SERVICE}.${ASSET_BASE}/${action}`;
}
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;
}
export function extractAssetResponse<T>(result: unknown): T {
const raw = result as Record<string, unknown>;
const data = getNestedRecord(raw, "data");
if (!data) {
throw new BailianError("Unexpected empty response from asset API.", ExitCode.GENERAL);
}
const dataV2 = getNestedRecord(data, "DataV2");
const payload = dataV2
? (getNestedRecord(getNestedRecord(dataV2, "data") ?? dataV2, "data") ??
getNestedRecord(dataV2, "data") ??
dataV2)
: (getNestedRecord(data, "data") ?? data);
if (payload.success === false) {
const message =
typeof payload.message === "string" && payload.message.length > 0
? payload.message
: typeof payload.code === "string"
? payload.code
: "Asset API request failed.";
throw new BailianError(message, ExitCode.GENERAL);
}
if (payload.data !== undefined) {
return payload.data as T;
}
return payload as T;
}
export 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.`,
);
}
export function buildBaseRequest(settings: Settings, binName: string): AssetHttpBaseRequest {
// workspace 由 CLI 注入tenantId / mainAccountUid 由 Console 网关从登录 session 自动填充,
// CLI 侧无需也不应手动解析阿里云账号 ID。
return {
workspace: requireWorkspaceId(settings, binName),
apiSource: "CLI",
};
}
export function buildListFilterBody(flags: AssetListFilterFlags): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (flags.type) body.assetType = flags.type as AssetType;
if (flags.model) body.modelName = flags.model;
if (flags.keyword) body.assetName = flags.keyword;
if (flags.favorited) body.favorited = true;
if (flags.recycleBin) {
body.deleteStatus = "SOFT_DELETED";
} else {
body.deleteStatus = "NORMAL";
}
if (flags.syncStatus) body.syncOssDataStatus = flags.syncStatus as AssetSyncOssStatus;
if (flags.beginTime) body.beginTime = flags.beginTime;
if (flags.endTime) body.endTime = flags.endTime;
return body;
}
export function validateAssetIds(ids: string[] | undefined): string | undefined {
if (!ids || ids.length === 0) {
return "At least one --id is required.";
}
if (ids.length > MAX_ASSET_BATCH_SIZE) {
return `At most ${MAX_ASSET_BATCH_SIZE} asset IDs are allowed per request.`;
}
return undefined;
}
export async function callAssetApi<T>(
client: Client,
settings: Settings,
binName: string,
api: string,
body: Record<string, unknown>,
): Promise<T> {
const payload = { ...buildBaseRequest(settings, binName), ...body };
const raw = await client.console(api, payload);
return extractAssetResponse<T>(raw);
}
export function dryRunPayload(
settings: Settings,
binName: string,
api: string,
body: Record<string, unknown>,
): Record<string, unknown> {
return {
api,
data: { ...buildBaseRequest(settings, binName), ...body },
...effectiveConsoleGatewayConfig(settings),
};
}
export function formatGenerateTime(ts?: number): string {
if (ts == null) return "-";
return new Date(ts).toISOString().replace("T", " ").slice(0, 19);
}
export function formatStorageBytes(bytes?: number): string {
if (bytes == null) return "-";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
@@ -25,7 +25,7 @@ export default defineCommand({
},
},
exampleArgs: [
`--api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`--api zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`--api some.api.name --data '{"key":"value"}' --console-region cn-beijing`,
],
async run(ctx) {
@@ -1,11 +1,11 @@
import { BailianError } from "bailian-cli-core";
import { BailianError, isStreamableHttpUnsupported } from "bailian-cli-core";
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
/** Detect MCP-not-activated / invalid 404 errors (CLI-wrapped server message). */
export function isMcpNotActivated(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
const message = error.message;
if (!/MCP request failed:\s*404\b/i.test(message)) return false;
if (!/^MCP request failed:\s*404\b/i.test(message)) return false;
return /未开通|MCP不存在|MCP_IS_INVALID/i.test(message);
}
@@ -26,14 +26,28 @@ export function mcpActivateHint(serverCode: string): string {
/**
* For not-activated errors, keep the original message / exitCode and append a hint only.
* Do not replace the server error message.
* WebSearch + 405 streamableHttp: do not fall back; attach a re-activate / upgrade hint.
*/
export function rethrowWithMcpActivateHint(error: unknown, serverCode: string): never {
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
if (!(error instanceof BailianError) || error.hint) {
throw error;
}
if (isMcpNotActivated(error)) {
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
if (serverCode === "WebSearch" && isStreamableHttpUnsupported(error)) {
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
throw error;
}
+11 -7
View File
@@ -36,7 +36,8 @@ const CALL_FLAGS = {
url: {
type: "string",
valueHint: "<url>",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
},
} satisfies FlagsDef;
type CallFlags = ParsedFlags<typeof CALL_FLAGS>;
@@ -114,14 +115,14 @@ export default defineCommand({
const { serverCode, toolName } = parseTarget(flags.target);
const toolArgs = buildToolArgs(flags);
const url = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
server: serverCode,
url,
url: previewUrl,
tool: toolName,
arguments: toolArgs,
},
@@ -130,13 +131,14 @@ export default defineCommand({
return;
}
const client = ctx.client.mcp(url);
let client: { close?(): void } | undefined;
try {
await client.initialize();
const result = await client.callTool(toolName, toolArgs);
const connected = await ctx.client.connectBailianMcp(serverCode, flags.url);
client = connected.client;
const result = await connected.client.callTool(toolName, toolArgs);
if (result.isError) {
const errText = result.content.map((c) => c.text || "").join("\n");
const errText = result.content.map((contentItem) => contentItem.text || "").join("\n");
throw new BailianError(`Tool error: ${errText}`);
}
@@ -146,6 +148,8 @@ export default defineCommand({
rethrowWithMcpActivateHint(error, serverCode);
}
throw error;
} finally {
client?.close?.();
}
},
});
+11 -7
View File
@@ -16,7 +16,8 @@ export default defineCommand({
url: {
type: "string",
valueHint: "<url>",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
},
},
exampleArgs: [
@@ -28,24 +29,27 @@ export default defineCommand({
const { settings, flags } = ctx;
const code = flags.server;
const url = flags.url || ctx.client.url(bailianMcpPath(code));
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(code));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ server: code, url, action: "tools/list" }, format);
emitResult({ server: code, url: previewUrl, action: "tools/list" }, format);
return;
}
const client = ctx.client.mcp(url);
let client: { close?(): void } | undefined;
try {
await client.initialize();
const tools = await client.listTools();
emitResult({ server: code, url, tools }, format);
const connected = await ctx.client.connectBailianMcp(code, flags.url);
client = connected.client;
const tools = await connected.client.listTools();
emitResult({ server: code, url: connected.url, tools }, format);
} catch (error) {
if (!flags.url) {
rethrowWithMcpActivateHint(error, code);
}
throw error;
} finally {
client?.close?.();
}
},
});
@@ -12,6 +12,13 @@ import {
stripUndefined,
taskPath,
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
type AsrApiRoute,
type AsrFlashFamily,
type OutputFormat,
type FlagsDef,
type ParsedFlags,
@@ -27,8 +34,18 @@ const RECOGNIZE_FLAGS = {
description: "Audio file URL or local file path (repeatable, max 100)",
required: true,
},
model: { type: "string", valueHint: "<model>", description: "Model ID (default: fun-asr)" },
language: { type: "string", valueHint: "<lang>", description: "Language hint (e.g. zh, en, ja)" },
model: {
type: "string",
valueHint: "<model>",
description:
"Model ID (default: fun-asr). Async: fun-asr / *-filetrans / paraformer-*; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash",
},
language: {
type: "string",
valueHint: "<lang>",
description:
"Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language",
},
diarization: { type: "switch", description: "Enable automatic speaker diarization" },
speakerCount: {
type: "number",
@@ -55,8 +72,33 @@ const RECOGNIZE_FLAGS = {
} satisfies FlagsDef;
type RecognizeFlags = ParsedFlags<typeof RECOGNIZE_FLAGS>;
function assertSyncFlashFlagsAllowed(
flags: RecognizeFlags,
model: string,
flashFamily: AsrFlashFamily,
): void {
const unsupported: string[] = [];
if (flags.diarization === true) unsupported.push("--diarization");
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
// qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does
if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) {
unsupported.push("--vocabulary-id");
}
if (flags.channelId !== undefined) unsupported.push("--channel-id");
if (flags.async === true) unsupported.push("--async");
if (flags.pollInterval !== undefined) unsupported.push("--poll-interval");
if (unsupported.length > 0) {
throw new BailianError(
`Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` +
`Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`,
ExitCode.USAGE,
);
}
}
export default defineCommand({
description: "Recognize speech from audio files (FunAudio-ASR)",
description: "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
auth: "apiKey",
usageArgs: "--url <audio-url> [flags]",
flags: RECOGNIZE_FLAGS,
@@ -68,6 +110,7 @@ export default defineCommand({
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
"--url https://example.com/audio.mp3 --out result.json",
"--url https://example.com/audio.mp3 --async --quiet",
"--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en",
],
async run(ctx) {
const { settings, flags } = ctx;
@@ -90,22 +133,70 @@ export default defineCommand({
}
const model = flags.model || "fun-asr";
const route = resolveAsrApi(model);
if (route.kind === "unsupported") {
throw new BailianError(
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
ExitCode.USAGE,
);
}
if (route.kind === "sync-flash") {
assertSyncFlashFlagsAllowed(flags, model, route.flashFamily!);
if (rawUrls.length !== 1) {
throw new BailianError(
`Model "${model}" is a sync Flash ASR model and accepts exactly one --url (got ${rawUrls.length}).\n` +
`Hint: Pass a single audio URL, or use an async filetrans model for batch files.`,
ExitCode.USAGE,
);
}
}
if (
route.kind === "async-filetrans" &&
route.asyncInputStyle === "file_url" &&
rawUrls.length !== 1
) {
throw new BailianError(
`Model "${model}" accepts exactly one --url (got ${rawUrls.length}).\n` +
"Hint: qwen3-asr-flash-filetrans* requires a single file_url.",
ExitCode.USAGE,
);
}
const format = detectOutputFormat(settings.output);
// Auto-upload local files in parallel
const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model)));
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
if (route.kind === "sync-flash") {
await handleSyncFlashMode(
ctx.client,
settings,
flags,
format,
model,
route,
resolvedUrls[0]!,
);
return;
}
const channelId = flags.channelId;
const language = flags.language;
const vocabularyId = flags.vocabularyId;
const languageFields = buildAsyncAsrLanguageFields(
route.asyncLanguageStyle ?? "language_hints",
flags.language,
);
const body: DashScopeASRRequest = {
model,
input: {
file_urls: resolvedUrls,
},
input:
route.asyncInputStyle === "file_url"
? { file_url: resolvedUrls[0]! }
: { file_urls: resolvedUrls },
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
language_hints: language ? [language] : undefined,
...languageFields,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
@@ -116,7 +207,7 @@ export default defineCommand({
stripUndefined(body.parameters as Record<string, unknown>);
if (settings.dryRun) {
emitResult({ request: body, mode: "async" }, format);
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
return;
}
@@ -128,6 +219,55 @@ export default defineCommand({
},
});
async function handleSyncFlashMode(
client: Client,
settings: Settings,
flags: RecognizeFlags,
format: OutputFormat,
model: string,
route: AsrApiRoute,
audioUrl: string,
): Promise<void> {
const flashFamily = route.flashFamily as AsrFlashFamily;
const body = buildAsrFlashRequest({
model,
audioUrl,
language: flags.language,
vocabularyId: flags.vocabularyId,
flashFamily,
});
if (settings.dryRun) {
emitResult({ request: body, mode: "sync", path: route.path }, format);
return;
}
if (!settings.quiet) {
process.stderr.write(`[Model: ${model}] [Mode: sync] [Files: 1]\n`);
}
const response = await client.requestJson<Record<string, unknown>>({
path: route.path,
method: "POST",
headers: { "X-DashScope-SSE": "disable" },
body,
});
const text = extractAsrFlashText(response, flashFamily);
if (text) {
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
} else {
emitBare(JSON.stringify(response));
}
if (flags.out) {
writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n");
if (!settings.quiet) {
process.stderr.write(`Full result saved to: ${flags.out}\n`);
}
}
}
async function handleAsyncMode(
client: Client,
settings: Settings,
@@ -160,16 +300,16 @@ async function handleAsyncMode(
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: settings.timeout,
isComplete: (d) => (d as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
isFailed: (d) => (d as DashScopeASRTaskResult).output.task_status === "FAILED",
getStatus: (d) => (d as DashScopeASRTaskResult).output.task_status,
getErrorMessage: (d) => {
const o = (d as DashScopeASRTaskResult).output;
return (o as unknown as Record<string, unknown>).message as string | undefined;
isComplete: (data) => (data as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
isFailed: (data) => (data as DashScopeASRTaskResult).output.task_status === "FAILED",
getStatus: (data) => (data as DashScopeASRTaskResult).output.task_status,
getErrorMessage: (data) => {
const output = (data as DashScopeASRTaskResult).output;
return (output as unknown as Record<string, unknown>).message as string | undefined;
},
});
const results = result.output.results ?? [];
const results = collectAsrTranscriptionItems(result.output);
if (results.length === 0) {
emitResult({ task_id: taskId, status: result.output.task_status }, format);
@@ -179,12 +319,14 @@ async function handleAsyncMode(
// Collect all transcription data for --out
const allTransData: Record<string, unknown>[] = [];
for (let i = 0; i < results.length; i++) {
const subResult = results[i]!;
for (let index = 0; index < results.length; index++) {
const subResult = results[index]!;
const isMulti = fileCount > 1;
if (isMulti) {
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
process.stdout.write(
`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`,
);
}
if (subResult.subtask_status === "FAILED") {
@@ -0,0 +1,132 @@
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts";
import { formatNumber } from "./shared.ts";
const CODING_PLAN_USAGE_API =
"zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2";
const COMMODITY_CODES: Record<string, string> = {
domestic: "sfm_codingplan_public_cn",
international: "sfm_codingplan_public_intl",
};
interface CodingPlanWindow {
usedQuota?: number;
totalQuota?: number;
/** Usage ratio in [0, 1]; absent when the window has no positive total or no used value. */
percentage?: number;
resetTime?: number;
}
interface CodingPlanUsage {
instanceType?: string;
per5Hour: CodingPlanWindow;
perWeek: CodingPlanWindow;
perBillMonth: CodingPlanWindow;
}
function readWindow(
quotaInfo: Record<string, unknown> | undefined,
fieldPrefix: string,
): CodingPlanWindow {
const window: CodingPlanWindow = {};
if (!quotaInfo) return window;
const usedQuota = readNumber(quotaInfo[`${fieldPrefix}UsedQuota`]);
if (usedQuota !== undefined) window.usedQuota = usedQuota;
const totalQuota = readNumber(quotaInfo[`${fieldPrefix}TotalQuota`]);
if (totalQuota !== undefined) window.totalQuota = totalQuota;
const resetTime = readNumber(quotaInfo[`${fieldPrefix}QuotaNextRefreshTime`]);
if (resetTime !== undefined) window.resetTime = resetTime;
// Console rule: the usage rate only exists with a positive total and a used value.
if (usedQuota !== undefined && totalQuota !== undefined && totalQuota > 0) {
window.percentage = usedQuota / totalQuota;
}
return window;
}
/** Pick the first VALID instance's quota info, mirroring the Coding Plan console. */
function readUsage(result: unknown): CodingPlanUsage | undefined {
const response = unwrapResponse(result as Record<string, unknown>);
const instances = Array.isArray(response.codingPlanInstanceInfos)
? (response.codingPlanInstanceInfos as Record<string, unknown>[])
: [];
const validInstance = instances.find((instance) => instance.status === "VALID");
if (!validInstance) return undefined;
const quotaInfo = validInstance.codingPlanQuotaInfo as Record<string, unknown> | undefined;
const usage: CodingPlanUsage = {
per5Hour: readWindow(quotaInfo, "per5Hour"),
perWeek: readWindow(quotaInfo, "perWeek"),
perBillMonth: readWindow(quotaInfo, "perBillMonth"),
};
if (typeof validInstance.instanceType === "string" && validInstance.instanceType) {
usage.instanceType = validInstance.instanceType;
}
return usage;
}
function toSection(label: string, window: CodingPlanWindow): QuotaSection {
const section: QuotaSection = {
label,
emptyMessage: "No quota data for this window; verify in the Bailian Coding Plan console.",
percentage: window.percentage,
resetTime: window.resetTime,
};
if (window.usedQuota !== undefined && window.totalQuota !== undefined) {
section.detail = `Used: ${formatNumber(window.usedQuota)} / ${formatNumber(window.totalQuota)}`;
}
return section;
}
function printView(usage: CodingPlanUsage, generatedAt: number): void {
const planSuffix = usage.instanceType ? ` (${usage.instanceType})` : "";
printQuotaBox(
`Coding Plan Usage${planSuffix}`,
[
toSection("5-hour quota", usage.per5Hour),
toSection("1-week quota", usage.perWeek),
toSection("Monthly quota", usage.perBillMonth),
],
generatedAt,
);
}
export default defineCommand({
description: "Show Coding Plan quota usage",
auth: "console",
usageArgs: "[flags]",
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings } = ctx;
const format = detectOutputFormat(settings.output);
const requestData = {
queryCodingPlanInstanceInfoRequest: {
commodityCode: COMMODITY_CODES[settings.consoleSite ?? "domestic"],
onlyLatestOne: true,
},
};
if (settings.dryRun) {
emitResult({ api: CODING_PLAN_USAGE_API, data: requestData }, format);
return;
}
const result = await ctx.client.console(CODING_PLAN_USAGE_API, requestData);
const usage = readUsage(result);
if (format === "json") {
emitResult(usage ?? {}, format);
return;
}
if (!usage) {
process.stdout.write("No active Coding Plan subscription found.\n");
return;
}
printView(usage, Date.now());
},
});
@@ -1,95 +1,22 @@
import { defineCommand, detectOutputFormat, fetchModelList, type Client } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
FREE_TIER_API,
FREE_TIER_ONLY_STATUS_API,
extractFreeTierOnlyStatuses,
extractQuotas,
fetchAllModels,
pollFreeTierBatch,
} from "./shared.ts";
const ACTIVATE_API = "zeldaEasy.broadscope-bailian.freeTrial.batchActivateFreeTierOnly";
const DEACTIVATE_API = "zeldaEasy.broadscope-bailian.freeTrial.batchDeactivateFreeTierOnly";
const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
const FREE_TIER_ONLY_STATUS_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
interface FreeTierQuota {
model: string;
quotaTotal: number;
quotaInitTotal: number;
}
interface FreeTierOnlyStatus {
model: string;
freeTierOnly: boolean;
}
const ACTIVATE_API = "zeldaEasy.bailian-commerce.freeTrial.batchActivateFreeTierOnly";
const DEACTIVATE_API = "zeldaEasy.bailian-commerce.freeTrial.batchDeactivateFreeTierOnly";
interface BatchResultFailure {
failureModelId: string;
errorCode: string;
}
function getNestedRecord(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
return undefined;
}
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
const data = getNestedRecord(result, "data");
if (!data) return result;
const dataV2 = getNestedRecord(data, "DataV2");
if (dataV2) {
const inner = getNestedRecord(dataV2, "data");
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
return innerData ?? inner ?? dataV2;
}
const direct = getNestedRecord(data, "data");
return direct ?? data;
}
const POLL_INTERVAL_MS = 500;
const MAX_POLLS = 20;
async function pollUntilDone(
client: Client,
api: string,
requestKey: string,
models: string[],
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
const requestData = {
[requestKey]: nextTaskId ? { taskId: nextTaskId } : { models },
};
const raw = await client.console(api, requestData);
const resp = extractResponseData(raw as Record<string, unknown>);
if (resp.taskId && Object.keys(resp).length === 1) {
nextTaskId = resp.taskId as string;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
return raw;
}
return null;
}
async function fetchAllModelNames(client: Client): Promise<string[]> {
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList((api, data) => client.console(api, data), {
pageNo: page,
pageSize: 50,
});
allModels.push(...result.models);
if (allModels.length >= result.total) break;
page++;
}
return allModels.map((item) => item.model as string).filter(Boolean);
}
export default defineCommand({
description:
"Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable",
@@ -161,7 +88,7 @@ export default defineCommand({
}
if (!modelFlag) {
models = await fetchAllModelNames(ctx.client);
models = (await fetchAllModels(ctx.client)).map((model) => model.name);
}
if (off) {
@@ -172,12 +99,10 @@ export default defineCommand({
}),
]);
const quotaData = extractResponseData(quotaResult as Record<string, unknown>);
const quotas = (quotaData.freeTierQuotas ?? []) as FreeTierQuota[];
const quotas = extractQuotas(quotaResult);
const quotaMap = new Map(quotas.map((quota) => [quota.model, quota]));
const stopData = extractResponseData(stopResult as Record<string, unknown>);
const stopStatuses = (stopData.freeTierOnlyStatuses ?? []) as FreeTierOnlyStatus[];
const stopStatuses = extractFreeTierOnlyStatuses(stopResult);
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
for (const name of models) {
@@ -192,7 +117,7 @@ export default defineCommand({
);
continue;
}
await pollUntilDone(ctx.client, api, requestKey, [name]);
await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
process.stdout.write(`Disabled auto-stop for "${name}".\n`);
}
return;
@@ -200,13 +125,13 @@ export default defineCommand({
const jsonResults: unknown[] = [];
for (const name of models) {
const result = await pollUntilDone(ctx.client, api, requestKey, [name]);
const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
if (format === "json") {
jsonResults.push(result);
continue;
}
if (result) {
const resultData = extractResponseData(result as Record<string, unknown>);
const resultData = unwrapResponse(result as Record<string, unknown>);
const failureModels = (resultData.failureModels as BatchResultFailure[]) ?? [];
if (failureModels.length > 0) {
process.stderr.write(
@@ -0,0 +1,91 @@
import {
ansi,
displayWidth,
renderGauge,
type GaugeCell,
type TextStyle,
} from "bailian-cli-runtime";
import { formatDateTime } from "./shared.ts";
const BOX_WIDTH = 76;
/** One quota window rendered inside the box: a label + usage ratio + reset time. */
export interface QuotaSection {
label: string;
/** Shown instead of the gauge when the usage ratio is absent. */
emptyMessage: string;
/** Usage ratio in [0, 1]; absent means no data (possibly unlimited). */
percentage?: number;
resetTime?: number;
/** Optional dim line under the gauge, e.g. "Used: 38 / 100". */
detail?: string;
}
/** Accept only finite numbers; anything else counts as absent (possibly unlimited). */
export function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Match the `usage free` gauge label style: 0.1% precision, no trailing zeros. */
function formatPercentage(ratio: number): string {
const percent = Math.round(ratio * 1000) / 10;
return `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`;
}
function formatRemainingTime(resetTime: number, now: number): string {
const remainingMs = Math.max(0, resetTime - now);
const totalMinutes = Math.floor(remainingMs / 60_000);
if (totalMinutes === 0) return "now";
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
return parts.join(" ");
}
/** Print a bordered quota box with a title line and one gauge per section. */
export function printQuotaBox(title: string, sections: QuotaSection[], generatedAt: number): void {
const color = ansi(process.stdout);
const writeLine = (text = "", style?: TextStyle) => {
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`));
process.stdout.write(`${style ? style(text) : text}${" ".repeat(padding)}\n`);
};
// Pre-colored gauge cell: pad from the plain variant so ANSI escapes never shift the border.
const writeGaugeLine = (cell: GaugeCell) => {
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${cell.plain}`));
process.stdout.write(`${cell.colored}${" ".repeat(padding)}\n`);
};
const writeQuota = (section: QuotaSection) => {
writeLine(section.label, color.bold);
if (section.percentage === undefined) {
writeLine(section.emptyMessage, color.dim);
return;
}
const gaugeLabel = `${formatPercentage(section.percentage)} used`;
writeGaugeLine(renderGauge(section.percentage * 100, gaugeLabel));
if (section.detail) {
writeLine(section.detail, color.dim);
}
if (section.resetTime === undefined) {
writeLine("Resets: not applicable (no usage yet)", color.dim);
return;
}
const resetText = `Resets: ${formatDateTime(section.resetTime)} (in ${formatRemainingTime(section.resetTime, generatedAt)})`;
writeLine(resetText, color.dim);
};
process.stdout.write(`${"─".repeat(BOX_WIDTH)}\n`);
writeLine(title, color.cyan);
writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim);
for (const section of sections) {
process.stdout.write(`${"─".repeat(BOX_WIDTH)}\n`);
writeQuota(section);
}
process.stdout.write(`${"─".repeat(BOX_WIDTH)}\n`);
}
+51 -12
View File
@@ -24,6 +24,14 @@ export function formatDate(ts: number): string {
return `${year}-${month}-${day}`;
}
export function formatDateTime(ts: number): string {
const date = new Date(ts);
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
return `${formatDate(ts)} ${hour}:${minute}:${second}`;
}
export function requireWorkspaceId(settings: Settings, binName: string): string {
if (settings.workspaceId) return settings.workspaceId;
@@ -102,9 +110,9 @@ export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
// Free-tier quota
// ---------------------------------------------------------------------------
export const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
export const FREE_TIER_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota";
export const FREE_TIER_ONLY_STATUS_API =
"zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
"zeldaEasy.bailian-commerce.freeTrial.queryFreeTierOnlyStatus";
export interface FreeTierQuota {
model: string;
@@ -257,22 +265,27 @@ export interface ListStatisticResponse {
}
const POLL_INTERVAL_MS = 500;
const MAX_POLLS = 30;
const DEFAULT_MAX_POLLS = 30;
export async function pollTelemetryApi(
/**
* Poll a console API until it returns a terminal (non task-id) response.
* The gateway answers an async request with a bare `{taskId}` envelope; the
* caller re-issues with that id until real data arrives or the budget runs out.
* `buildRequest` shapes each attempt (initial call vs. taskId follow-up) so the
* same loop serves every request-wrapper convention (telemetry `reqDTO`,
* free-tier batch `…Request`).
*/
export async function pollConsoleUntilDone(
client: Client,
api: string,
reqDTO: Record<string, unknown>,
buildRequest: (taskId: string | undefined) => Record<string, unknown>,
maxPolls = DEFAULT_MAX_POLLS,
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
const requestData = nextTaskId
? { reqDTO: { ...reqDTO, asyncTaskId: nextTaskId } }
: { reqDTO };
const raw = await client.console(api, requestData);
const resp = extractResponseData(raw as Record<string, unknown>);
for (let attempt = 0; attempt < maxPolls; attempt++) {
const raw = await client.console(api, buildRequest(nextTaskId));
const resp = unwrapResponse(raw as Record<string, unknown>);
if (resp.taskId && Object.keys(resp).length === 1) {
nextTaskId = resp.taskId as string;
@@ -284,6 +297,32 @@ export async function pollTelemetryApi(
return null;
}
/** Telemetry APIs wrap the payload in `reqDTO` and echo the task id as `asyncTaskId`. */
export async function pollTelemetryApi(
client: Client,
api: string,
reqDTO: Record<string, unknown>,
): Promise<unknown> {
return pollConsoleUntilDone(client, api, (taskId) =>
taskId ? { reqDTO: { ...reqDTO, asyncTaskId: taskId } } : { reqDTO },
);
}
/** Free-tier batch activate/deactivate wrap the payload in `requestKey` and echo `taskId`. */
export async function pollFreeTierBatch(
client: Client,
api: string,
requestKey: string,
models: string[],
): Promise<unknown> {
return pollConsoleUntilDone(
client,
api,
(taskId) => ({ [requestKey]: taskId ? { taskId } : { models } }),
20,
);
}
export function extractOverviewData(result: unknown): OverviewStatistic | undefined {
const resp = extractResponseData(result as Record<string, unknown>);
if (resp.callSuccessCount !== undefined || resp.usages !== undefined) {
+15 -165
View File
@@ -1,176 +1,26 @@
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
type Settings,
type Client,
} from "bailian-cli-core";
import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
const OVERVIEW_API = "zeldaEasy.bailian-telemetry.model.getModelUsageStatistic";
const LIST_API = "zeldaEasy.bailian-telemetry.model.listModelUsageStatisticData";
interface UsageItem {
key: string;
value: number;
unit: string;
}
interface OverviewStatistic {
callCount: number;
modelCount: number;
callSuccessCount: number;
usages: UsageItem[];
}
interface ModelStatisticItem {
model: string;
callSuccessCount: number;
usages?: UsageItem[];
usage?: Record<string, number | undefined>;
}
interface ListStatisticResponse {
list: ModelStatisticItem[];
totalCount: number;
maxResults: number;
}
function getNestedRecord(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
return undefined;
}
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
const data = getNestedRecord(result, "data");
if (!data) return result;
const dataV2 = getNestedRecord(data, "DataV2");
if (dataV2) {
const inner = getNestedRecord(dataV2, "data");
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
return innerData ?? inner ?? dataV2;
}
const direct = getNestedRecord(data, "data");
return direct ?? data;
}
const POLL_INTERVAL_MS = 500;
const MAX_POLLS = 30;
async function pollTelemetryApi(
client: Client,
api: string,
reqDTO: Record<string, unknown>,
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
const requestData = nextTaskId
? { reqDTO: { ...reqDTO, asyncTaskId: nextTaskId } }
: { reqDTO };
const raw = await client.console(api, requestData);
const resp = extractResponseData(raw as Record<string, unknown>);
if (resp.taskId && Object.keys(resp).length === 1) {
nextTaskId = resp.taskId as string;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
return raw;
}
return null;
}
function requireWorkspaceId(settings: Settings, binName: string): string {
if (settings.workspaceId) return settings.workspaceId;
throw new BailianError(
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id <id>\`.`,
ExitCode.GENERAL,
`Run \`${binName} workspace list\` to view available workspaces.`,
);
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
function formatDate(ts: number): string {
const date = new Date(ts);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function extractOverviewData(result: unknown): OverviewStatistic | undefined {
const resp = extractResponseData(result as Record<string, unknown>);
if (resp.callSuccessCount !== undefined || resp.usages !== undefined) {
return resp as unknown as OverviewStatistic;
}
return undefined;
}
function extractListData(result: unknown): ListStatisticResponse {
const resp = extractResponseData(result as Record<string, unknown>);
const list = (resp.list as ModelStatisticItem[]) ?? [];
const totalCount = (resp.totalCount as number) ?? 0;
const maxResults = (resp.maxResults as number) ?? 0;
return { list, totalCount, maxResults };
}
function resolveUsageMap(item: ModelStatisticItem): Record<string, number> {
const out: Record<string, number> = {};
if (item.usages && Array.isArray(item.usages)) {
for (const entry of item.usages) {
if (entry.key && entry.value != null) {
out[entry.key] = entry.value;
}
}
}
if (item.usage && typeof item.usage === "object") {
for (const [key, val] of Object.entries(item.usage)) {
if (val != null) out[key] = val;
}
}
return out;
}
import {
LIST_API,
OVERVIEW_API,
USAGE_KEY_LABELS,
extractListData,
extractOverviewData,
formatDate,
formatNumber,
pollTelemetryApi,
requireWorkspaceId,
resolveUsageMap,
type ModelStatisticItem,
type OverviewStatistic,
} from "./shared.ts";
interface UsageLabel {
en: string;
unit?: string;
}
const USAGE_KEY_LABELS: Record<string, UsageLabel> = {
total_token: { en: "Total Tokens", unit: "tokens" },
input_token: { en: "Input Tokens", unit: "tokens" },
output_token: { en: "Output Tokens", unit: "tokens" },
input_token_cache: { en: "Cached Tokens", unit: "tokens" },
input_token_cache_read: { en: "Cache Read", unit: "tokens" },
input_token_cache_creation: { en: "Cache Creation", unit: "tokens" },
thinking_input_token: { en: "Thinking Input", unit: "tokens" },
thinking_output_token: { en: "Thinking Output", unit: "tokens" },
text_input_token: { en: "Text Input", unit: "tokens" },
purein_text_output_token: { en: "Text Output", unit: "tokens" },
embedding_token: { en: "Embedding", unit: "tokens" },
image_number: { en: "Images", unit: "images" },
video_duration: { en: "Video Duration", unit: "sec" },
content_duration: { en: "Audio Duration", unit: "sec" },
tts_text_number: { en: "TTS Chars", unit: "chars" },
total_token_avg: { en: "Avg Tokens/Req" },
};
function formatLabel(label: UsageLabel): string {
const unitSuffix = label.unit ? ` [${label.unit}]` : "";
return `${label.en}${unitSuffix}`;
@@ -0,0 +1,77 @@
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { printQuotaBox, readNumber } from "./quota-box.ts";
const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
interface TokenPlanUsage {
per5HourPercentage?: number;
per5HourResetTime?: number;
per1WeekPercentage?: number;
per1WeekResetTime?: number;
}
function readUsage(result: unknown): TokenPlanUsage {
const response = unwrapResponse(result as Record<string, unknown>);
const usage: TokenPlanUsage = {};
const per5HourPercentage = readNumber(response.per5HourPercentage);
if (per5HourPercentage !== undefined) usage.per5HourPercentage = per5HourPercentage;
const per5HourResetTime = readNumber(response.per5HourResetTime);
if (per5HourResetTime !== undefined) usage.per5HourResetTime = per5HourResetTime;
const per1WeekPercentage = readNumber(response.per1WeekPercentage);
if (per1WeekPercentage !== undefined) usage.per1WeekPercentage = per1WeekPercentage;
const per1WeekResetTime = readNumber(response.per1WeekResetTime);
if (per1WeekResetTime !== undefined) usage.per1WeekResetTime = per1WeekResetTime;
return usage;
}
function printView(usage: TokenPlanUsage, generatedAt: number): void {
printQuotaBox(
"Token Plan Usage",
[
{
label: "5-hour quota",
emptyMessage:
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
percentage: usage.per5HourPercentage,
resetTime: usage.per5HourResetTime,
},
{
label: "1-week quota",
emptyMessage:
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
percentage: usage.per1WeekPercentage,
resetTime: usage.per1WeekResetTime,
},
],
generatedAt,
);
}
export default defineCommand({
description: "Show Token Plan quota usage",
auth: "console",
usageArgs: "[flags]",
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, format);
return;
}
const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
const usage = readUsage(result);
if (format === "json") {
emitResult(usage, format);
return;
}
printView(usage, Date.now());
},
});
+2 -8
View File
@@ -48,6 +48,8 @@ export { default as usageFree } from "./commands/usage/free.ts";
export { default as usageFreetier } from "./commands/usage/freetier.ts";
export { default as usageStats } from "./commands/usage/stats.ts";
export { default as usageSummary } from "./commands/usage/summary.ts";
export { default as usageTokenPlan } from "./commands/usage/token-plan.ts";
export { default as usageCodingPlan } from "./commands/usage/coding-plan.ts";
export { default as pipelineRun } from "./commands/pipeline/run.ts";
export { default as pipelineValidate } from "./commands/pipeline/validate.ts";
export { default as advisorRecommend } from "./commands/advisor/recommend.ts";
@@ -91,14 +93,6 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as assetList } from "./commands/asset-center/list.ts";
export { default as assetGet } from "./commands/asset-center/get.ts";
export { default as assetFavorite } from "./commands/asset-center/favorite.ts";
export { default as assetUnfavorite } from "./commands/asset-center/unfavorite.ts";
export { default as assetDelete } from "./commands/asset-center/delete.ts";
export { default as assetDownload } from "./commands/asset-center/download.ts";
export { default as assetStats } from "./commands/asset-center/stats.ts";
export { default as assetStorage } from "./commands/asset-center/storage.ts";
export { default as managedAgentInit } from "./commands/managed-agent/init.ts";
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
@@ -0,0 +1,213 @@
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import codingPlanUsage from "../src/commands/usage/coding-plan.ts";
const originalNoColor = process.env.NO_COLOR;
const originalForceColor = process.env.FORCE_COLOR;
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
afterEach(() => {
if (originalNoColor === undefined) delete process.env.NO_COLOR;
else process.env.NO_COLOR = originalNoColor;
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
else process.env.FORCE_COLOR = originalForceColor;
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
else delete (process.stdout as { isTTY?: boolean }).isTTY;
vi.restoreAllMocks();
});
function captureStdout(): string[] {
const output: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
return output;
}
async function runCodingPlan(response: Record<string, unknown>, output?: string): Promise<void> {
await codingPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(response) },
flags: {},
settings: { dryRun: false, output },
} as never);
}
function wrapResponse(data: Record<string, unknown>): Record<string, unknown> {
return {
data: {
DataV2: {
data: {
data,
},
},
},
};
}
function makeInstanceResponse(
quotaInfo: Record<string, unknown>,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return wrapResponse({
codingPlanInstanceInfos: [
{ status: "VALID", instanceType: "pro", codingPlanQuotaInfo: quotaInfo, ...overrides },
],
});
}
const FULL_QUOTA_INFO = {
per5HourUsedQuota: 38,
per5HourTotalQuota: 100,
per5HourQuotaNextRefreshTime: 1_786_000_000_000,
perWeekUsedQuota: 500,
perWeekTotalQuota: 1000,
perWeekQuotaNextRefreshTime: 1_786_100_000_000,
perBillMonthUsedQuota: 950,
perBillMonthTotalQuota: 1000,
perBillMonthQuotaNextRefreshTime: 1_786_200_000_000,
};
describe("usage coding-plan view", () => {
test("renders the three quota windows with usage rates and used/total details", async () => {
const output = captureStdout();
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
const renderedOutput = output.join("");
expect(renderedOutput).toContain("Coding Plan Usage (pro)");
expect(renderedOutput).toContain("5-hour quota");
expect(renderedOutput).toContain("1-week quota");
expect(renderedOutput).toContain("Monthly quota");
expect(renderedOutput).toContain("38% used");
expect(renderedOutput).toContain("50% used");
expect(renderedOutput).toContain("95% used");
expect(renderedOutput).toContain("Used: 38 / 100");
expect(renderedOutput).toContain("Used: 950 / 1,000");
});
test("skips non-VALID instances when picking quota info", async () => {
const output = captureStdout();
await runCodingPlan(
wrapResponse({
codingPlanInstanceInfos: [
{
status: "EXPIRED",
codingPlanQuotaInfo: { per5HourUsedQuota: 1, per5HourTotalQuota: 2 },
},
{ status: "VALID", codingPlanQuotaInfo: FULL_QUOTA_INFO },
],
}),
);
expect(output.join("")).toContain("38% used");
});
test("renders windows without a positive total as missing quota data", async () => {
const output = captureStdout();
await runCodingPlan(
makeInstanceResponse({
per5HourUsedQuota: 38,
per5HourTotalQuota: 0,
perWeekUsedQuota: 500,
perBillMonthUsedQuota: "not-a-number",
perBillMonthTotalQuota: 1000,
}),
);
const renderedOutput = output.join("");
const emptyMessageCount = renderedOutput.split(
"No quota data for this window; verify in the Bailian Coding Plan console.",
).length;
expect(emptyMessageCount - 1).toBe(3);
});
test("reports when there is no active subscription", async () => {
const output = captureStdout();
await runCodingPlan(wrapResponse({ codingPlanInstanceInfos: [] }));
expect(output.join("")).toContain("No active Coding Plan subscription found.");
});
test("renders the gauge in the usage-free style: brand fill and proportional cells", async () => {
delete process.env.NO_COLOR;
process.env.FORCE_COLOR = "3";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const output = captureStdout();
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
// Brand-cyan fill cell, same as the `usage free` gauge column
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
});
test("fills gauge cells proportionally to the usage rate", async () => {
process.env.NO_COLOR = "1";
const output = captureStdout();
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
const renderedOutput = output.join("");
// 95% of the default 20-cell gauge → 19 filled cells + 1 track space
expect(renderedOutput).toContain(`${"\u2588".repeat(19)} `);
expect(renderedOutput).not.toContain("\u2588".repeat(20));
});
});
describe("usage coding-plan json", () => {
test("outputs the three windows with used/total/percentage/resetTime", async () => {
const output = captureStdout();
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO), "json");
expect(JSON.parse(output.join(""))).toEqual({
instanceType: "pro",
per5Hour: {
usedQuota: 38,
totalQuota: 100,
percentage: 0.38,
resetTime: 1_786_000_000_000,
},
perWeek: {
usedQuota: 500,
totalQuota: 1000,
percentage: 0.5,
resetTime: 1_786_100_000_000,
},
perBillMonth: {
usedQuota: 950,
totalQuota: 1000,
percentage: 0.95,
resetTime: 1_786_200_000_000,
},
});
});
test("returns an empty JSON object when no VALID instance exists", async () => {
const output = captureStdout();
await runCodingPlan(wrapResponse({}), "json");
expect(output.join("").trim()).toBe("{}");
});
test("omits non-numeric quota fields from the JSON output", async () => {
const output = captureStdout();
await runCodingPlan(
makeInstanceResponse(
{ per5HourUsedQuota: "not-a-number", per5HourTotalQuota: 100 },
{ instanceType: undefined },
),
"json",
);
expect(JSON.parse(output.join(""))).toEqual({
per5Hour: { totalQuota: 100 },
perWeek: {},
perBillMonth: {},
});
});
});
@@ -1,4 +1,6 @@
import { readFileSync } from "node:fs";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import {
@@ -16,6 +18,37 @@ import { SPEECH_ROUTES } from "./topic-routes.ts";
*/
describe("e2e: speech recognize", () => {
async function runRecognizeDryRun(args: string[]) {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
...args,
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
return parseStdoutJson<{
mode?: string;
path?: string;
request?: {
model?: string;
parameters?: {
format?: string;
language_hints?: string[];
language?: string;
vocabulary_id?: string;
};
input?: {
file_url?: string;
file_urls?: string[];
messages?: Array<{ content?: Array<{ type?: string }> }>;
};
};
}>(stdout);
}
test("speech recognize --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
@@ -25,6 +58,217 @@ describe("e2e: speech recognize", () => {
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recognize|--url|model|audio/i);
});
test("speech recognize sync-flash dry-run 走 multimodal-generation", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
"--language",
"en",
"--vocabulary-id",
"vocab-e2e",
]);
expect(body.mode).toBe("sync");
expect(body.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(body.request?.model).toBe("qwen-audio-3.0-asr-flash");
expect(body.request?.parameters?.format).toBe("wav");
expect(body.request?.parameters?.language_hints).toEqual(["en"]);
expect(body.request?.parameters?.vocabulary_id).toBe("vocab-e2e");
expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize qwen3 filetrans dry-run 使用 file_url 与 language", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen3-asr-flash-filetrans",
"--url",
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
"--language",
"zh",
]);
expect(body.mode).toBe("async");
expect(body.path).toBe("/api/v1/services/audio/asr/transcription");
expect(body.request?.input?.file_url?.startsWith("https://")).toBe(true);
expect(body.request?.input?.file_urls).toBeUndefined();
expect(body.request?.parameters?.language).toBe("zh");
expect(body.request?.parameters?.language_hints).toBeUndefined();
});
test("speech recognize realtime 模型报用法错误", async () => {
// Use --dry-run to skip auth so CI without API keys still hits USAGE(2)
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash-realtime",
"--url",
"https://example.com/a.wav",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/realtime|WebSocket|unsupported/i);
});
test("speech recognize flash 真实请求走 sync endpoint 并落盘 --out", async () => {
let requestPath = "";
let requestBody: Record<string, unknown> = {};
let sseHeader: string | undefined;
const server = http.createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
requestPath = request.url ?? "";
requestBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
sseHeader = request.headers["x-dashscope-sse"] as string | undefined;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
output: { text: "flash recognition works" },
request_id: "request-146",
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const outDir = makeE2eOutputDir("speech-recognize-flash-sync");
const outPath = join(outDir, "result.json");
try {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"fun-asr-flash-2026-06-15",
"--url",
"https://example.com/sample.wav",
"--api-key",
"sk-e2e-placeholder",
"--base-url",
`http://127.0.0.1:${address.port}`,
"--out",
outPath,
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("flash recognition works");
expect(requestPath).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(sseHeader).toBe("disable");
expect(requestBody).toMatchObject({
model: "fun-asr-flash-2026-06-15",
parameters: { format: "wav" },
});
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
output: { text: "flash recognition works" },
request_id: "request-146",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test("speech recognize flash 多 --url 在发请求前报用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://example.com/a.wav",
"--url",
"https://example.com/b.wav",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/exactly one --url|sync Flash/i);
});
test("speech recognize qwen3-filetrans 轮询成功后下载 result.transcription_url", async () => {
const server = http.createServer((request, response) => {
const url = request.url ?? "";
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
response.writeHead(200, { "Content-Type": "application/json" });
if (url.startsWith("/api/v1/services/audio/asr/transcription")) {
response.end(
JSON.stringify({
output: { task_id: "task-qwen3", task_status: "PENDING" },
request_id: "req-submit",
}),
);
return;
}
if (url.startsWith("/api/v1/tasks/")) {
const address = server.address() as AddressInfo;
response.end(
JSON.stringify({
output: {
task_id: "task-qwen3",
task_status: "SUCCEEDED",
result: {
transcription_url: `http://127.0.0.1:${address.port}/transcription.json`,
},
},
request_id: "req-poll",
}),
);
return;
}
if (url.startsWith("/transcription.json")) {
response.end(
JSON.stringify({
file_url: "https://example.com/a.wav",
transcripts: [{ text: "你好世界", sentences: [{ text: "你好世界" }] }],
}),
);
return;
}
response.writeHead(404);
response.end(JSON.stringify({ message: `unexpected path: ${url}` }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const outDir = makeE2eOutputDir("speech-recognize-qwen3-filetrans");
const outPath = join(outDir, "result.json");
try {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--language",
"zh",
"--api-key",
"sk-e2e-placeholder",
"--base-url",
`http://127.0.0.1:${address.port}`,
"--poll-interval",
"1",
"--out",
outPath,
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("你好世界");
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
transcripts: [{ text: "你好世界" }],
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
@@ -109,6 +109,8 @@ export const USAGE_ROUTES: E2eRouteExports = {
"usage free": "usageFree",
"usage freetier": "usageFreetier",
"usage stats": "usageStats",
"usage token-plan": "usageTokenPlan",
"usage coding-plan": "usageCodingPlan",
};
export const DEPLOY_ROUTES: E2eRouteExports = {
@@ -0,0 +1,80 @@
import { describe, expect, test } from "vite-plus/test";
import {
isConsoleAuthFailure,
isConsoleE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
describe("e2e: usage coding-plan", () => {
test("usage coding-plan --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"coding-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Coding Plan|quota/i);
});
test("usage coding-plan --help 包含 --output json 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"coding-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage coding-plan --output json");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-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");
});
});
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vite-plus/test";
import {
isConsoleAuthFailure,
isConsoleE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
describe("e2e: usage token-plan", () => {
test("usage token-plan --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Token Plan|quota/i);
});
test("usage token-plan --help 包含 --output json 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage token-plan --output json");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage token-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,6 +26,12 @@ describe("mcp-activate-hint", () => {
false,
);
expect(isMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false);
// Nested wrapper phrase must not match (anchored at start).
expect(
isMcpNotActivated(
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found - 未开通"),
),
).toBe(false);
});
test("hint 含对应 server 的 MCP 广场深链", () => {
@@ -38,6 +44,36 @@ describe("mcp-activate-hint", () => {
expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i);
});
test("WebSearch + 405 streamableHttp 补重开通 hint", () => {
const original = new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
ExitCode.GENERAL,
);
try {
rethrowWithMcpActivateHint(original, "WebSearch");
expect.unreachable("should throw");
} catch (error) {
expect(error).toBeInstanceOf(BailianError);
const wrapped = error as BailianError;
expect(wrapped.message).toBe(original.message);
expect(wrapped.hint).toMatch(/SSE|Streamable HTTP|Activate|re-activate/i);
expect(wrapped.hint).toContain(mcpMarketplaceDetailPage("WebSearch"));
}
});
test("非 WebSearch 的 405 streamableHttp 不补 hint由 fallback 处理)", () => {
const original = new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
ExitCode.GENERAL,
);
try {
rethrowWithMcpActivateHint(original, "WebParser");
expect.unreachable("should throw");
} catch (error) {
expect(error).toBe(original);
}
});
test("rethrow 保留原 message补 hint", () => {
const serverCode = "market-cmapi00073529";
const original = new BailianError(
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import tokenPlanUsage from "../src/commands/usage/token-plan.ts";
const originalNoColor = process.env.NO_COLOR;
const originalForceColor = process.env.FORCE_COLOR;
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
afterEach(() => {
if (originalNoColor === undefined) delete process.env.NO_COLOR;
else process.env.NO_COLOR = originalNoColor;
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
else process.env.FORCE_COLOR = originalForceColor;
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
else delete (process.stdout as { isTTY?: boolean }).isTTY;
vi.restoreAllMocks();
});
function captureStdout(): string[] {
const output: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
return output;
}
async function runTokenPlan(response: Record<string, unknown>, output?: string): Promise<void> {
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(response) },
flags: {},
settings: { dryRun: false, output },
} as never);
}
function makeUsageResponse(
per5HourPercentage?: number,
per1WeekPercentage = per5HourPercentage,
): Record<string, unknown> {
const usage: Record<string, number> = {};
if (per5HourPercentage !== undefined) {
usage.per5HourPercentage = per5HourPercentage;
if (per5HourPercentage !== 0) usage.per5HourResetTime = 1_786_000_000_000;
}
if (per1WeekPercentage !== undefined) {
usage.per1WeekPercentage = per1WeekPercentage;
if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000;
}
return wrapResponse(usage);
}
function wrapResponse(usage: Record<string, unknown>): Record<string, unknown> {
return {
data: {
DataV2: {
data: {
data: usage,
},
},
},
};
}
describe("usage token-plan view", () => {
test("renders the gauge with the usage-free brand fill color", async () => {
delete process.env.NO_COLOR;
process.env.FORCE_COLOR = "3";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5));
// Brand-cyan fill cell, same as the `usage free` gauge column
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
});
test("renders proportional gauge cells with a transparent track", async () => {
process.env.NO_COLOR = "1";
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5));
const renderedOutput = output.join("");
// 50% of the default 20-cell gauge → 10 filled cells + 10 track spaces
expect(renderedOutput).toContain(`${"\u2588".repeat(10)}${" ".repeat(10)}`);
expect(renderedOutput).not.toContain("\u2588".repeat(11));
});
test("accepts missing reset times when the quota usage is zero", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0));
expect(output.join("")).toContain("Resets: not applicable (no usage yet)");
});
test("allows one unused quota window without masking another reset time", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0, 0.5));
const renderedOutput = output.join("");
expect(renderedOutput).toContain("Resets: not applicable (no usage yet)");
expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
test("renders missing quota windows as possibly unlimited", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse());
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
test("renders only the missing quota window as possibly unlimited", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(undefined, 0.5));
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).not.toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
test("renders a window with a missing percentage as possibly unlimited even when its reset time is present", async () => {
const output = captureStdout();
await runTokenPlan(wrapResponse({ per5HourResetTime: 1_786_000_000_000 }));
expect(output.join("")).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
test("treats non-numeric quota fields as absent instead of failing", async () => {
const output = captureStdout();
await runTokenPlan(
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: Number.NaN }),
);
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
});
describe("usage token-plan json", () => {
test("outputs the four core usage fields with --output json", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5, 0.25), "json");
expect(JSON.parse(output.join(""))).toEqual({
per5HourPercentage: 0.5,
per5HourResetTime: 1_786_000_000_000,
per1WeekPercentage: 0.25,
per1WeekResetTime: 1_786_100_000_000,
});
});
test("returns an empty JSON object when no quota fields are available", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(), "json");
expect(output.join("").trim()).toBe("{}");
});
test("omits non-numeric quota fields from the JSON output", async () => {
const output = captureStdout();
await runTokenPlan(
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: 0 }),
"json",
);
expect(JSON.parse(output.join(""))).toEqual({ per1WeekPercentage: 0 });
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.14.2",
"version": "1.14.3",
"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
@@ -0,0 +1,327 @@
import { imageSyncPath, speechRecognizePath } from "./endpoints.ts";
/**
* DashScope ASR APIs differ by model family:
*
* - async file transcription (`.../audio/asr/transcription`):
* fun-asr*, paraformer* (non-realtime), *-filetrans, sensevoice*
* language via `parameters.language_hints`
* - sync multimodal (`.../aigc/multimodal-generation/generation`):
* - qwen3: `{ content: [{ audio }] }` + optional `asr_options.language`
* (qwen3-asr-flash*)
* - input-audio: `{ type: input_audio, input_audio.data }` +
* `format`/`sample_rate` + optional `language_hints`
* (fun-asr-flash*, qwen-audio-*-asr-flash*)
* - realtime / streaming: WebSocket — not supported by `speech recognize`
*/
export type AsrApiKind = "async-filetrans" | "sync-flash" | "unsupported";
/** Sync-flash request body shape differs by Flash protocol family. */
export type AsrFlashFamily = "qwen3" | "input-audio";
export interface AsrApiRoute {
kind: AsrApiKind;
path: string;
/** True when the call is synchronous (no X-DashScope-Async / task poll). */
useSync: boolean;
/**
* Async transcription request input style.
* - `file_urls`: classic async models (fun-asr / paraformer / qwen-audio filetrans...)
* - `file_url`: qwen3-asr-flash-filetrans family
*/
asyncInputStyle?: "file_urls" | "file_url";
/**
* Async transcription language field style.
* - `language_hints`: fun-asr / paraformer / qwen-audio filetrans...
* - `language`: qwen3-asr-flash-filetrans*
*/
asyncLanguageStyle?: "language_hints" | "language";
flashFamily?: AsrFlashFamily;
/** Human-readable reason when kind is unsupported. */
unsupportedReason?: string;
}
function isRealtimeOrStreaming(model: string): boolean {
return /realtime|streaming/i.test(model);
}
function isFiletransModel(model: string): boolean {
return /filetrans/i.test(model);
}
function isQwen3FiletransModel(model: string): boolean {
return /^qwen3-asr-flash-filetrans(?:-|$)/i.test(model);
}
const INPUT_AUDIO_FLASH_PREFIXES = ["fun-asr-flash", "qwen-audio"] as const;
/**
* Fun-ASR-Flash / Qwen-Audio-*-ASR-Flash share the input_audio + format protocol.
* Examples: fun-asr-flash-2026-06-15, qwen-audio-3.0-asr-flash
*/
function isInputAudioFlashModel(model: string): boolean {
if (isRealtimeOrStreaming(model) || isFiletransModel(model)) return false;
if (model.startsWith(INPUT_AUDIO_FLASH_PREFIXES[0])) return true;
if (model.startsWith(INPUT_AUDIO_FLASH_PREFIXES[1]) && /asr-flash/i.test(model)) return true;
return false;
}
/**
* Qwen3-ASR-Flash sync models use content.audio + asr_options.
* Examples: qwen3-asr-flash, qwen3-asr-flash-2025-09-08, qwen3-asr-flash-us
*/
function isQwen3AsrFlashModel(model: string): boolean {
if (!/^qwen3-asr-flash(?:-|$)/i.test(model)) return false;
if (isFiletransModel(model) || isRealtimeOrStreaming(model)) return false;
if (isInputAudioFlashModel(model)) return false;
return true;
}
/**
* Resolve which DashScope ASR API a model should use for file recognition.
* Unknown models default to async-filetrans (preserves existing CLI behavior).
*/
export function resolveAsrApi(model: string): AsrApiRoute {
if (isRealtimeOrStreaming(model)) {
return {
kind: "unsupported",
path: "",
useSync: false,
unsupportedReason:
`Model "${model}" is a realtime/streaming ASR model and requires a WebSocket API. ` +
`Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) or a sync flash model ` +
`(e.g. qwen3-asr-flash, qwen-audio-3.0-asr-flash) with this command.`,
};
}
if (isFiletransModel(model)) {
const isQwen3Filetrans = isQwen3FiletransModel(model);
return {
kind: "async-filetrans",
path: speechRecognizePath(),
useSync: false,
asyncInputStyle: isQwen3Filetrans ? "file_url" : "file_urls",
asyncLanguageStyle: isQwen3Filetrans ? "language" : "language_hints",
};
}
if (isInputAudioFlashModel(model)) {
return {
kind: "sync-flash",
path: imageSyncPath(),
useSync: true,
flashFamily: "input-audio",
};
}
if (isQwen3AsrFlashModel(model)) {
return {
kind: "sync-flash",
path: imageSyncPath(),
useSync: true,
flashFamily: "qwen3",
};
}
// fun-asr / paraformer / sensevoice / unknown → keep legacy async path
return {
kind: "async-filetrans",
path: speechRecognizePath(),
useSync: false,
asyncInputStyle: "file_urls",
asyncLanguageStyle: "language_hints",
};
}
/** Infer audio container hint for input-audio Flash `parameters.format`. */
export function inferAudioFormatHint(audioUrl: string): string {
// data URI: data:audio/mpeg;base64,... → mp3; data:audio/x-wav;... → wav
const dataType = /^data:audio\/([^;,]+)/i.exec(audioUrl)?.[1]?.toLowerCase();
if (dataType) {
if (dataType === "mpeg") return "mp3";
if (dataType === "x-wav" || dataType === "wave") return "wav";
return dataType;
}
const pathPart = audioUrl.split(/[?#]/, 1)[0] ?? audioUrl;
const match = pathPart.match(/\.([a-zA-Z0-9]+)$/);
const extension = match?.[1]?.toLowerCase();
if (!extension) return "wav";
if (extension === "mpeg") return "mp3";
return extension;
}
export interface BuildAsrFlashRequestOpts {
model: string;
audioUrl: string;
language?: string;
/** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */
vocabularyId?: string;
flashFamily: AsrFlashFamily;
}
/**
* Build language fields for async ASR routes.
* qwen3-asr-flash-filetrans* → `language`; other async models → `language_hints`.
*/
export function buildAsyncAsrLanguageFields(
languageStyle: "language_hints" | "language",
language?: string,
): { language_hints?: string[]; language?: string } {
if (!language) return {};
if (languageStyle === "language") {
return { language };
}
return { language_hints: [language] };
}
/** Build a sync multimodal ASR request body for Flash models. */
export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<string, unknown> {
const { model, audioUrl, language, vocabularyId, flashFamily } = opts;
if (flashFamily === "input-audio") {
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id
const parameters: Record<string, unknown> = {
format: inferAudioFormatHint(audioUrl),
sample_rate: "16000",
};
if (language) {
parameters.language_hints = [language];
}
if (vocabularyId) {
parameters.vocabulary_id = vocabularyId;
}
return {
model,
input: {
messages: [
{
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
},
],
},
parameters,
};
}
const asrOptions: Record<string, unknown> = {};
if (language) {
asrOptions.language = language;
}
const parameters: Record<string, unknown> = {};
if (Object.keys(asrOptions).length > 0) {
parameters.asr_options = asrOptions;
}
const body: Record<string, unknown> = {
model,
input: {
messages: [
{
role: "user",
content: [{ audio: audioUrl }],
},
],
},
};
if (Object.keys(parameters).length > 0) {
body.parameters = parameters;
}
return body;
}
/**
* Extract recognition text from a sync Flash ASR response.
* Qwen3 uses choices[].message.content; input-audio Flash uses output.text /
* output.sentence.text / output.output.sentence.text.
*/
export function extractAsrFlashText(
response: Record<string, unknown>,
flashFamily: AsrFlashFamily,
): string {
const output = response.output as Record<string, unknown> | undefined;
if (!output) return "";
if (flashFamily === "input-audio") {
if (typeof output.text === "string" && output.text.length > 0) {
return output.text;
}
const topSentence = output.sentence as Record<string, unknown> | undefined;
if (typeof topSentence?.text === "string" && topSentence.text.length > 0) {
return topSentence.text;
}
const nested = output.output as Record<string, unknown> | undefined;
const nestedSentence = nested?.sentence as Record<string, unknown> | undefined;
if (typeof nestedSentence?.text === "string") {
return nestedSentence.text;
}
return "";
}
const choices = output.choices as Array<Record<string, unknown>> | undefined;
if (!choices?.length) return "";
const texts: string[] = [];
for (const choice of choices) {
const message = choice.message as Record<string, unknown> | undefined;
if (!message) continue;
const content = message.content;
if (typeof content === "string") {
texts.push(content);
continue;
}
if (!Array.isArray(content)) continue;
for (const item of content) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
if (typeof record.text === "string") {
texts.push(record.text);
}
}
}
}
return texts.join("");
}
/**
* Normalize async ASR task transcription items:
* - classic models: `output.results[]`
* - qwen3-asr-flash-filetrans*: `output.result.transcription_url`
*/
export function collectAsrTranscriptionItems(output: {
results?: Array<{
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}>;
result?: { transcription_url?: string };
}): Array<{
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}> {
if (output.results && output.results.length > 0) {
return output.results;
}
const transcriptionUrl = output.result?.transcription_url;
if (typeof transcriptionUrl === "string" && transcriptionUrl.length > 0) {
return [{ transcription_url: transcriptionUrl, subtask_status: "SUCCEEDED" }];
}
return [];
}
+26 -1
View File
@@ -5,7 +5,13 @@ import { ExitCode } from "../errors/codes.ts";
import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts";
import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts";
import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts";
import { McpClient } from "./mcp.ts";
import {
bailianMcpPath,
bailianMcpSsePath,
connectBailianMcpWithFallback,
McpClient,
type McpConnectedClient,
} from "./mcp.ts";
import { callConsoleGateway } from "../console/gateway.ts";
import { refreshAccessToken } from "../auth/refresh-token.ts";
import { maskToken } from "../utils/token.ts";
@@ -164,6 +170,25 @@ export class Client {
return new McpClient(this.http, url, this.deps.apiCred?.token);
}
/**
* Connect to a Bailian MCP: try Streamable HTTP, then SSE on 405 (except WebSearch).
* `urlOverride` maps to `--url`: Streamable first, then classic SSE on the same URL (405/404).
*/
connectBailianMcp(
serverCode: string,
urlOverride?: string,
): Promise<{ client: McpConnectedClient; url: string }> {
this.requireApi();
return connectBailianMcpWithFallback({
deps: this.http,
authToken: this.deps.apiCred?.token,
httpUrl: this.url(bailianMcpPath(serverCode)),
sseUrl: this.url(bailianMcpSsePath(serverCode)),
serverCode,
urlOverride,
});
}
async console<T>(api: string, data: Record<string, unknown>): Promise<T> {
if (!this.deps.consoleCred) {
throw new BailianError("This command needs a console access token.", ExitCode.AUTH);
+26 -2
View File
@@ -34,6 +34,18 @@ export {
type ImageInputStyle,
type ImageSizeProfile,
} from "./image-routes.ts";
export {
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
inferAudioFormatHint,
resolveAsrApi,
type AsrApiKind,
type AsrApiRoute,
type AsrFlashFamily,
type BuildAsrFlashRequestOpts,
} from "./asr-routes.ts";
export { CHANNEL, sourceConfig, trackingHeaders, type TrackingIdentity } from "./headers.ts";
export type { HttpDeps, RequestOpts } from "./http.ts";
export { request, requestJson } from "./http.ts";
@@ -57,7 +69,19 @@ export {
type AcsQueryParams,
type AcsSignConfig,
} from "./acs.ts";
export type { McpTool, McpToolResult } from "./mcp.ts";
export { McpClient, bailianMcpPath } from "./mcp.ts";
export type {
McpTool,
McpToolResult,
McpConnectedClient,
ConnectBailianMcpOptions,
} from "./mcp.ts";
export {
McpClient,
bailianMcpPath,
bailianMcpSsePath,
isStreamableHttpUnsupported,
isUrlOverrideSseFallbackCandidate,
connectBailianMcpWithFallback,
} from "./mcp.ts";
export type { ServerSentEvent } from "./stream.ts";
export { parseSSE } from "./stream.ts";
+474
View File
@@ -0,0 +1,474 @@
/**
* MCP classic HTTP+SSE client (protocol 2024-11-05 transport).
*
* Flow: GET /sse → endpoint event → POST JSON-RPC to message URL;
* responses arrive as SSE `message` events matched by JSON-RPC id.
*/
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { HttpDeps } from "./http.ts";
import { trackingHeaders } from "./headers.ts";
import type { McpTool, McpToolResult } from "./mcp.ts";
import { parseSSE } from "./stream.ts";
interface JsonRpcResponse {
jsonrpc: "2.0";
id?: number | string | null;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
type PendingResolver = {
resolve: (value: JsonRpcResponse) => void;
reject: (reason: unknown) => void;
};
/** Match JSON-RPC ids with string keys (number or string echo from server). */
function pendingKey(id: number | string): string {
return String(id);
}
export class McpSseClient {
private sseUrl: string;
private messageUrl: string | undefined;
private nextId = 1;
private deps: HttpDeps;
private authToken: string | undefined;
private abortController: AbortController | undefined;
private pending = new Map<string, PendingResolver>();
private endpointReady: Promise<void>;
private resolveEndpoint: (() => void) | undefined;
private rejectEndpoint: ((reason: unknown) => void) | undefined;
private closed = false;
/** Set when the SSE GET ends without an intentional close(); later RPCs fail fast. */
private streamEnded = false;
constructor(deps: HttpDeps, sseUrl: string, authToken?: string) {
this.deps = deps;
this.sseUrl = sseUrl;
this.authToken = authToken;
this.endpointReady = new Promise<void>((resolve, reject) => {
this.resolveEndpoint = resolve;
this.rejectEndpoint = reject;
});
}
/** Open the SSE session and run initialize / notifications/initialized. */
async initialize(): Promise<void> {
if (!this.authToken) {
throw new BailianError("This command needs a model-domain API key.", ExitCode.AUTH);
}
await this.openSse();
const result = await this.rpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: {
name: this.deps.identity.clientName,
version: this.deps.identity.version,
},
});
if (this.deps.settings.verbose) {
console.error(`[MCP SSE] Session initialized`);
console.error(`[MCP SSE] Server: ${JSON.stringify(result)}`);
}
await this.notify("notifications/initialized");
}
async listTools(): Promise<McpTool[]> {
const result = (await this.rpc("tools/list")) as { tools: McpTool[] };
return result.tools || [];
}
async callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
const result = (await this.rpc("tools/call", { name, arguments: args })) as McpToolResult;
return result;
}
/** Abort the hanging GET /sse so the CLI process can exit. */
close(): void {
if (this.closed) return;
this.closed = true;
this.abortController?.abort();
this.failPending(new BailianError("MCP SSE session closed.", ExitCode.GENERAL));
this.messageUrl = undefined;
}
private failPending(reason: unknown): void {
for (const [, waiter] of this.pending) {
waiter.reject(reason);
}
this.pending.clear();
}
private markStreamEnded(reason: BailianError): void {
this.streamEnded = true;
this.messageUrl = undefined;
this.failPending(reason);
}
private async openSse(): Promise<void> {
if (this.abortController) return;
// One abortController for header/error-body wait; clear timer before the long-lived stream.
this.abortController = new AbortController();
const timeoutMs = this.deps.settings.timeout * 1000;
let headerTimedOut = false;
const headerTimer = setTimeout(() => {
headerTimedOut = true;
this.abortController?.abort();
}, timeoutMs);
const headers: Record<string, string> = {
Accept: "text/event-stream",
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
...trackingHeaders(this.deps.identity),
};
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
if (this.deps.settings.verbose) {
console.error(`> GET ${this.sseUrl}`);
}
let response: Response;
try {
response = await fetch(this.sseUrl, {
method: "GET",
headers,
signal: this.abortController.signal,
});
} catch (error) {
clearTimeout(headerTimer);
// Allow a later initialize() to openSse again on this instance.
this.abortController = undefined;
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (headerTimedOut) {
throw new BailianError("MCP SSE timed out waiting for response headers.", ExitCode.TIMEOUT);
}
// Rethrow fetch failures so runtime can surface errno (e.g. ENOTFOUND) in JSON/text.
throw error;
}
if (this.deps.settings.verbose) {
console.error(`< ${response.status} ${response.statusText}`);
}
if (!response.ok) {
// Keep headerTimer until error body is read (or times out).
let errMsg = `MCP request failed: ${response.status} ${response.statusText}`;
try {
const errBody = await response.text();
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
} catch (error) {
clearTimeout(headerTimer);
this.abortController = undefined;
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (headerTimedOut) {
throw new BailianError(
"MCP SSE timed out reading error response body.",
ExitCode.TIMEOUT,
);
}
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
}
clearTimeout(headerTimer);
this.abortController = undefined;
// Do not rejectEndpoint — openSse never awaits endpointReady on this path.
throw new BailianError(errMsg, ExitCode.GENERAL);
}
clearTimeout(headerTimer);
void this.consumeSse(response).catch((error) => {
if (this.closed) return;
const reason =
error instanceof BailianError
? error
: new BailianError(
`MCP SSE stream failed: ${error instanceof Error ? error.message : String(error)}`,
ExitCode.GENERAL,
);
this.rejectEndpoint?.(reason);
// consumeSse already markStreamEnded on a clean end; cover parse/read failures here.
if (!this.streamEnded) {
this.markStreamEnded(reason);
}
});
const endpointTimeout = cancellableTimeoutReject(
timeoutMs,
"MCP SSE timed out waiting for endpoint event.",
);
try {
await Promise.race([this.endpointReady, endpointTimeout.promise]);
} finally {
endpointTimeout.cancel();
}
}
private async consumeSse(response: Response): Promise<void> {
for await (const event of parseSSE(response)) {
if (this.closed) break;
// Spec requires event: endpoint; ignore unnamed events so JSON is not treated as a URL.
if (event.event === "endpoint") {
const raw = event.data.trim();
if (!raw) continue;
// Only accept same-origin message URLs so we never forward the Bearer token cross-origin.
this.messageUrl = resolveSameOriginMessageUrl(this.sseUrl, raw);
this.resolveEndpoint?.();
this.resolveEndpoint = undefined;
this.rejectEndpoint = undefined;
continue;
}
// Omitted SSE event type defaults to "message".
if (event.event === "message" || event.event === undefined) {
let payload: JsonRpcResponse;
try {
payload = JSON.parse(event.data) as JsonRpcResponse;
} catch {
continue;
}
if (typeof payload.id !== "number" && typeof payload.id !== "string") continue;
const key = pendingKey(payload.id);
const waiter = this.pending.get(key);
if (!waiter) continue;
this.pending.delete(key);
waiter.resolve(payload);
}
}
if (this.closed) return;
if (!this.messageUrl) {
const error = new BailianError(
"MCP SSE stream ended before endpoint event.",
ExitCode.GENERAL,
);
this.rejectEndpoint?.(error);
throw error;
}
// After endpoint: mark dead and wake pending; don't throw (avoid unhandledRejection).
this.markStreamEnded(new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL));
}
private async rpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
const id = this.nextId++;
const key = pendingKey(id);
const body = {
jsonrpc: "2.0" as const,
id,
method,
...(params ? { params } : {}),
};
const timeoutMs = this.deps.settings.timeout * 1000;
const responsePromise = new Promise<JsonRpcResponse>((resolve, reject) => {
this.pending.set(key, { resolve, reject });
});
// Stream may end and reject pending before Promise.race; attach catch to avoid unhandledRejection.
void responsePromise.catch(() => undefined);
const responseTimeout = cancellableTimeoutReject(
timeoutMs,
`MCP SSE timed out waiting for response to ${method}.`,
);
try {
await this.postMessage(body);
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
const data = await Promise.race([responsePromise, responseTimeout.promise]);
if (data.error) {
throw new BailianError(
`MCP error (${data.error.code}): ${data.error.message}`,
ExitCode.GENERAL,
);
}
return data.result;
} catch (error) {
this.pending.delete(key);
throw error;
} finally {
responseTimeout.cancel();
}
}
private async notify(method: string, params?: Record<string, unknown>): Promise<void> {
const body = {
jsonrpc: "2.0" as const,
method,
...(params ? { params } : {}),
};
await this.postMessage(body);
}
private async postMessage(body: unknown): Promise<void> {
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
if (!this.messageUrl) {
throw new BailianError("MCP SSE message endpoint is not ready.", ExitCode.GENERAL);
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
...trackingHeaders(this.deps.identity),
};
// Bearer is only sent to a messageUrl that already passed the same-origin check.
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
if (this.deps.settings.verbose) {
console.error(`> POST ${this.messageUrl}`);
console.error(`> Method: ${(body as { method?: string }).method}`);
}
const timeoutMs = this.deps.settings.timeout * 1000;
// Combine per-RPC timeout with session abort so close() cancels in-flight POSTs.
const requestSignal = createLinkedAbortSignal(timeoutMs, this.abortController?.signal);
let res: Response;
try {
try {
res = await fetch(this.messageUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: requestSignal.signal,
});
} catch (error) {
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
throw error;
}
if (this.deps.settings.verbose) {
console.error(`< ${res.status} ${res.statusText}`);
}
if (!res.ok) {
// Keep signal until error body is read (same class of bug as GET openSse).
let errMsg = `MCP request failed: ${res.status} ${res.statusText}`;
try {
const errBody = await res.text();
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
} catch (error) {
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (requestSignal.timedOut) {
throw new BailianError(
"MCP SSE timed out reading error response body.",
ExitCode.TIMEOUT,
);
}
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
}
throw new BailianError(errMsg, ExitCode.GENERAL);
}
} finally {
requestSignal.cleanup();
}
}
}
/** Resolve the SSE endpoint data to an absolute URL and require same origin as sseUrl. */
export function resolveSameOriginMessageUrl(sseUrl: string, endpointData: string): string {
let resolved: URL;
let base: URL;
try {
base = new URL(sseUrl);
resolved = new URL(endpointData, sseUrl);
} catch {
throw new BailianError(
`MCP SSE endpoint is not a valid URL: ${endpointData}`,
ExitCode.GENERAL,
);
}
if (resolved.origin !== base.origin) {
throw new BailianError(
`MCP SSE endpoint origin mismatch: expected ${base.origin}, got ${resolved.origin}`,
ExitCode.GENERAL,
);
}
return resolved.toString();
}
/**
* Cancellable timeout rejection: after Promise.race settles, call cancel()
* to clear the timer and avoid unhandledRejection.
*/
function cancellableTimeoutReject(
timeoutMs: number,
message: string,
): { promise: Promise<never>; cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined;
const promise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
timer = undefined;
reject(new BailianError(message, ExitCode.TIMEOUT));
}, timeoutMs);
});
// Swallow late rejects after cancel to avoid unhandledRejection.
void promise.catch(() => undefined);
return {
promise,
cancel: () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
},
};
}
/** Timeout + optional parent abort without AbortSignal.any (Node 18). */
function createLinkedAbortSignal(
timeoutMs: number,
parentSignal?: AbortSignal,
): { signal: AbortSignal; cleanup: () => void; timedOut: boolean } {
const controller = new AbortController();
const state = { timedOut: false };
const timeout = setTimeout(() => {
state.timedOut = true;
controller.abort();
}, timeoutMs);
const abortFromParent = () => controller.abort(parentSignal?.reason);
const cleanup = () => {
clearTimeout(timeout);
parentSignal?.removeEventListener("abort", abortFromParent);
};
if (parentSignal?.aborted) abortFromParent();
else parentSignal?.addEventListener("abort", abortFromParent, { once: true });
controller.signal.addEventListener("abort", cleanup, { once: true });
return {
signal: controller.signal,
cleanup,
get timedOut() {
return state.timedOut;
},
};
}
+137 -2
View File
@@ -15,6 +15,8 @@ import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { HttpDeps } from "./http.ts";
import { trackingHeaders } from "./headers.ts";
import { McpSseClient } from "./mcp-sse.ts";
import { parseSSE } from "./stream.ts";
// ---- JSON-RPC 2.0 Types ----
@@ -27,7 +29,7 @@ interface JsonRpcRequest {
interface JsonRpcResponse {
jsonrpc: "2.0";
id: number;
id?: number | string | null;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
@@ -61,6 +63,101 @@ export function bailianMcpPath(serverCode: string): string {
return `/api/v1/mcps/${serverCode}/mcp`;
}
/** Classic SSE path: `/api/v1/mcps/<serverCode>/sse`. */
export function bailianMcpSsePath(serverCode: string): string {
return `/api/v1/mcps/${serverCode}/sse`;
}
/**
* True when Streamable HTTP is unsupported and classic SSE fallback should be tried.
* Anchored to HTTP wrapper text only (not JSON-RPC / nested copies). Bailian 404 excluded.
*/
export function isStreamableHttpUnsupported(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
return /^MCP request failed:\s*405\b/i.test(error.message);
}
/**
* SSE fallback for `--url` (official backwards-compat: same URL, HTTP 405/404 then GET SSE).
*/
export function isUrlOverrideSseFallbackCandidate(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
return /^MCP request failed:\s*(405|404)\b/i.test(error.message);
}
export type McpConnectedClient = {
initialize(): Promise<void>;
listTools(): Promise<McpTool[]>;
callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult>;
close?(): void;
};
export type ConnectBailianMcpOptions = {
deps: HttpDeps;
authToken: string | undefined;
/** Full Streamable HTTP URL (/mcp). */
httpUrl: string;
/** Full classic SSE URL (/sse). */
sseUrl: string;
serverCode: string;
/**
* Explicit `--url` override: try Streamable on that URL first;
* on 405/404 fall back to classic SSE on the same URL.
*/
urlOverride?: string;
};
/**
* Connect via Streamable HTTP first; on 405 (except WebSearch), fall back to SSE.
* `--url` uses the same URL for Streamable then classic SSE (official backwards-compat).
* For WebSearch, rethrow the original error so commands can attach a re-activate hint.
*/
export async function connectBailianMcpWithFallback(
options: ConnectBailianMcpOptions,
): Promise<{ client: McpConnectedClient; url: string }> {
const { deps, authToken, httpUrl, sseUrl, serverCode, urlOverride } = options;
if (urlOverride) {
const httpClient = new McpClient(deps, urlOverride, authToken);
try {
await httpClient.initialize();
return { client: httpClient, url: urlOverride };
} catch (error) {
if (!isUrlOverrideSseFallbackCandidate(error)) {
throw error;
}
}
const sseClient = new McpSseClient(deps, urlOverride, authToken);
try {
await sseClient.initialize();
return { client: sseClient, url: urlOverride };
} catch (error) {
sseClient.close();
throw error;
}
}
const httpClient = new McpClient(deps, httpUrl, authToken);
try {
await httpClient.initialize();
return { client: httpClient, url: httpUrl };
} catch (error) {
if (!isStreamableHttpUnsupported(error) || serverCode === "WebSearch") {
throw error;
}
}
const sseClient = new McpSseClient(deps, sseUrl, authToken);
try {
await sseClient.initialize();
return { client: sseClient, url: sseUrl };
} catch (error) {
sseClient.close();
throw error;
}
}
// ---- MCP Client ----
export class McpClient {
@@ -121,7 +218,7 @@ export class McpClient {
};
const response = await this.send(body);
const data = (await response.json()) as JsonRpcResponse;
const data = await this.readJsonRpcResponse(response, id);
if (data.error) {
throw new BailianError(
@@ -143,6 +240,44 @@ export class McpClient {
await this.send(body);
}
/**
* Read a JSON-RPC response by Content-Type: application/json or text/event-stream.
*/
private async readJsonRpcResponse(
response: Response,
expectedId: number,
): Promise<JsonRpcResponse> {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
return await this.readJsonRpcFromSse(response, expectedId);
}
return (await response.json()) as JsonRpcResponse;
}
private async readJsonRpcFromSse(
response: Response,
expectedId: number,
): Promise<JsonRpcResponse> {
const expectedKey = String(expectedId);
for await (const event of parseSSE(response)) {
if (event.event && event.event !== "message") continue;
let payload: JsonRpcResponse;
try {
payload = JSON.parse(event.data) as JsonRpcResponse;
} catch {
continue;
}
if (payload.id == null) continue;
if (String(payload.id) !== expectedKey) continue;
return payload;
}
throw new BailianError(
"MCP SSE response stream ended without a matching JSON-RPC response.",
ExitCode.GENERAL,
);
}
private async send(body: unknown): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
+97 -46
View File
@@ -7,6 +7,71 @@ export interface ServerSentEvent {
id?: string;
}
/** Normalize CRLF/CR to LF; hold a trailing `\r` so a split CRLF is not double-broken. */
function takeNormalizedSseLines(buffer: string): { lines: string[]; rest: string } {
let text = buffer;
let holdTrailingCr = false;
if (text.endsWith("\r")) {
holdTrailingCr = true;
text = text.slice(0, -1);
}
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const parts = text.split("\n");
const incomplete = parts.pop() ?? "";
return {
lines: parts,
rest: holdTrailingCr ? `${incomplete}\r` : incomplete,
};
}
function applySseLine(
line: string,
event: Partial<ServerSentEvent>,
maxBuffer: number,
): { event: Partial<ServerSentEvent>; completed?: ServerSentEvent } {
if (line === "") {
if (event.data === undefined) {
return { event: {} };
}
return {
event: {},
completed: { data: event.data, event: event.event, id: event.id },
};
}
if (line.startsWith(":")) {
return { event };
}
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
return { event };
}
const field = line.slice(0, colonIndex);
const fieldValue = line.slice(colonIndex + 1).trimStart();
const nextEvent: Partial<ServerSentEvent> = { ...event };
switch (field) {
case "data":
nextEvent.data =
nextEvent.data !== undefined ? `${nextEvent.data}\n${fieldValue}` : fieldValue;
if (nextEvent.data.length > maxBuffer) {
throw new BailianError("SSE event exceeded the maximum buffer size.", ExitCode.GENERAL);
}
break;
case "event":
nextEvent.event = fieldValue;
break;
case "id":
nextEvent.id = fieldValue;
break;
}
return { event: nextEvent };
}
export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEvent> {
const reader = response.body?.getReader();
if (!reader) return;
@@ -14,69 +79,55 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
const decoder = new TextDecoder();
let buffer = "";
// Guard against a hostile or malfunctioning stream that never emits a newline
// (or builds a single absurdly large event): bound the in-memory buffer so the
// parser cannot be driven to exhaust process memory.
const MAX_SSE_BUFFER = 16 * 1024 * 1024; // 16 MiB
try {
// Keep partial event fields across chunks.
let event: Partial<ServerSentEvent> = {};
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (done) {
// EOF: treat any held `\r` as a line ending.
if (buffer.length > 0) {
const finalText = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const parts = finalText.split("\n");
buffer = parts.pop() ?? "";
for (const line of parts) {
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
event = applied.event;
if (applied.completed) {
yield applied.completed;
}
}
}
break;
}
buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER) {
throw new BailianError("SSE stream exceeded the maximum buffer size.", ExitCode.GENERAL);
}
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let event: Partial<ServerSentEvent> = {};
const { lines, rest } = takeNormalizedSseLines(buffer);
buffer = rest;
for (const line of lines) {
if (line === "") {
if (event.data !== undefined) {
yield { data: event.data, event: event.event, id: event.id };
}
event = {};
continue;
}
if (line.startsWith(":")) continue; // comment
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const field = line.slice(0, colonIndex);
const value = line.slice(colonIndex + 1).trimStart();
switch (field) {
case "data":
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
if (event.data.length > MAX_SSE_BUFFER) {
throw new BailianError(
"SSE event exceeded the maximum buffer size.",
ExitCode.GENERAL,
);
}
break;
case "event":
event.event = value;
break;
case "id":
event.id = value;
break;
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
event = applied.event;
if (applied.completed) {
yield applied.completed;
}
}
}
// Flush remaining
if (buffer.trim() && buffer.includes("data:")) {
const colonIndex = buffer.indexOf(":");
if (colonIndex !== -1) {
yield { data: buffer.slice(colonIndex + 1).trimStart() };
}
// Legacy EOF flush: apply trailing field line and dispatch with event/id intact.
if (buffer.length > 0) {
const applied = applySseLine(buffer, event, MAX_SSE_BUFFER);
event = applied.event;
}
if (event.data !== undefined) {
yield { data: event.data, event: event.event, id: event.id };
}
} finally {
reader.releaseLock();
+1 -1
View File
@@ -58,7 +58,7 @@ export function effectiveConsoleGatewayConfig(
}
export interface ConsoleGatewayRequest {
/** Console API name, e.g. zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota */
/** Console API name, e.g. zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota */
api: string;
data: Record<string, unknown>;
}
+20 -7
View File
@@ -533,33 +533,46 @@ export interface DashScopeTTSStreamChunk {
export interface DashScopeASRRequest {
model: string;
input: {
file_urls: string[];
file_urls?: string[];
file_url?: string;
};
parameters?: {
channel_id?: number[];
/** Classic async models (fun-asr / paraformer / qwen-audio filetrans, etc.) */
language_hints?: string[];
/** qwen3-asr-flash-filetrans* uses singular `language` */
language?: string;
diarization_enabled?: boolean;
speaker_count?: number;
vocabulary_id?: string;
};
}
export interface DashScopeASRTranscriptionItem {
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}
export interface DashScopeASRTaskResult {
output: {
task_id: string;
task_status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "UNKNOWN";
results?: Array<{
file_url?: string;
/** Multi-file async results (fun-asr / paraformer / qwen-audio filetrans, etc.) */
results?: DashScopeASRTranscriptionItem[];
/** Singular result returned by qwen3-asr-flash-filetrans* on success */
result?: {
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}>;
};
task_metrics?: {
TOTAL: number;
SUCCEEDED: number;
FAILED: number;
};
code?: string;
message?: string;
};
usage?: Record<string, unknown>;
request_id: string;
+1
View File
@@ -48,6 +48,7 @@ export type {
ChatTool,
DashScopeASRRequest,
DashScopeASRTaskResult,
DashScopeASRTranscriptionItem,
DashScopeAsyncResponse,
DashScopeImageRequest,
DashScopeImageSyncResponse,
+213
View File
@@ -0,0 +1,213 @@
import { expect, test } from "vite-plus/test";
import {
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
inferAudioFormatHint,
resolveAsrApi,
} from "../src/client/asr-routes.ts";
test("resolveAsrApi routes model families correctly", () => {
const cases = [
{
model: "fun-asr",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_urls",
},
},
{
model: "qwen3-asr-flash-filetrans-2025-11-17",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_url",
asyncLanguageStyle: "language",
},
},
{
model: "qwen-audio-3.0-asr-flash-filetrans",
expected: {
kind: "async-filetrans",
useSync: false,
asyncInputStyle: "file_urls",
asyncLanguageStyle: "language_hints",
},
},
{
model: "qwen3-asr-flash-us",
expected: {
kind: "sync-flash",
useSync: true,
flashFamily: "qwen3",
path: "/api/v1/services/aigc/multimodal-generation/generation",
},
},
{
model: "qwen-audio-3.0-asr-flash",
expected: {
kind: "sync-flash",
useSync: true,
flashFamily: "input-audio",
},
},
{
model: "qwen3-asr-flash-realtime",
expected: {
kind: "unsupported",
},
},
{
model: "foo-asr-flash",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_urls",
},
},
] as const;
for (const { model, expected } of cases) {
const route = resolveAsrApi(model);
expect(route, model).toMatchObject(expected);
if (expected.kind === "unsupported") {
expect(route.unsupportedReason, model).toMatch(/realtime|streaming|WebSocket/i);
}
}
});
test("unknown models default to async-filetrans for backward compatibility", () => {
expect(resolveAsrApi("custom-asr-model")).toMatchObject({
kind: "async-filetrans",
useSync: false,
});
});
test("inferAudioFormatHint reads extension from url", () => {
expect(inferAudioFormatHint("https://example.com/a.mp3")).toBe("mp3");
expect(inferAudioFormatHint("oss://bucket/path/file.WAV")).toBe("wav");
expect(inferAudioFormatHint("https://example.com/a.mpeg?x=1")).toBe("mp3");
expect(inferAudioFormatHint("https://example.com/noext")).toBe("wav");
expect(inferAudioFormatHint("data:audio/mpeg;base64,AAA")).toBe("mp3");
expect(inferAudioFormatHint("data:audio/x-wav;base64,AAA")).toBe("wav");
expect(inferAudioFormatHint("data:audio/ogg;codecs=opus;base64,AAA")).toBe("ogg");
});
test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => {
expect(
buildAsrFlashRequest({
model: "qwen3-asr-flash",
audioUrl: "https://example.com/a.mp3",
language: "en",
flashFamily: "qwen3",
}),
).toEqual({
model: "qwen3-asr-flash",
input: {
messages: [{ role: "user", content: [{ audio: "https://example.com/a.mp3" }] }],
},
parameters: { asr_options: { language: "en" } },
});
expect(
buildAsrFlashRequest({
model: "qwen-audio-3.0-asr-flash",
audioUrl: "https://example.com/a.wav",
language: "en",
vocabularyId: "vocab-abc",
flashFamily: "input-audio",
}),
).toEqual({
model: "qwen-audio-3.0-asr-flash",
input: {
messages: [
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
parameters: {
format: "wav",
sample_rate: "16000",
language_hints: ["en"],
vocabulary_id: "vocab-abc",
},
});
});
test("buildAsyncAsrLanguageFields maps language by async style", () => {
expect(buildAsyncAsrLanguageFields("language_hints", "zh")).toEqual({
language_hints: ["zh"],
});
expect(buildAsyncAsrLanguageFields("language", "zh")).toEqual({ language: "zh" });
expect(buildAsyncAsrLanguageFields("language", undefined)).toEqual({});
});
test("extractAsrFlashText reads qwen3 choices and input-audio text fields", () => {
expect(
extractAsrFlashText(
{
output: {
choices: [{ message: { content: [{ text: "你好" }] } }],
},
},
"qwen3",
),
).toBe("你好");
expect(
extractAsrFlashText(
{
output: {
text: "Hello World",
output: { sentence: { text: "ignored when text present" } },
},
},
"input-audio",
),
).toBe("Hello World");
expect(
extractAsrFlashText(
{
output: {
sentence: { text: "top-level sentence" },
},
},
"input-audio",
),
).toBe("top-level sentence");
expect(
extractAsrFlashText(
{
output: {
output: { sentence: { text: "nested sentence" } },
},
},
"input-audio",
),
).toBe("nested sentence");
});
test("collectAsrTranscriptionItems prefers results[] then singular result", () => {
expect(
collectAsrTranscriptionItems({
results: [{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }],
}),
).toEqual([{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }]);
expect(
collectAsrTranscriptionItems({
result: { transcription_url: "https://example.com/qwen3.json" },
}),
).toEqual([{ transcription_url: "https://example.com/qwen3.json", subtask_status: "SUCCEEDED" }]);
expect(collectAsrTranscriptionItems({})).toEqual([]);
});
+736
View File
@@ -0,0 +1,736 @@
import { expect, test } from "vite-plus/test";
import type { Identity, Settings } from "../src/index.ts";
import {
BailianError,
bailianMcpPath,
bailianMcpSsePath,
connectBailianMcpWithFallback,
isStreamableHttpUnsupported,
isUrlOverrideSseFallbackCandidate,
McpClient,
} from "../src/index.ts";
import { McpSseClient, resolveSameOriginMessageUrl } from "../src/client/mcp-sse.ts";
function testDeps(overrides?: Partial<Settings>): { identity: Identity; settings: Settings } {
return {
identity: {
binName: "bl",
version: "0.0.0-test",
npmPackage: "bailian-cli",
clientName: "bailian-cli",
},
settings: {
output: "json",
outputExplicit: true,
timeout: 5,
verbose: false,
quiet: true,
dryRun: false,
telemetry: true,
...overrides,
},
};
}
function jsonRpcResult(id: number | string, result: unknown): string {
return `event:message\ndata:${JSON.stringify({ jsonrpc: "2.0", id, result })}\n\n`;
}
function requestUrl(input: string | URL | Request): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
test("bailianMcp 路径与 isStreamableHttpUnsupported", () => {
expect(bailianMcpPath("WebParser")).toBe("/api/v1/mcps/WebParser/mcp");
expect(bailianMcpSsePath("WebParser")).toBe("/api/v1/mcps/WebParser/sse");
expect(
isStreamableHttpUnsupported(
new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
),
),
).toBe(true);
expect(
isStreamableHttpUnsupported(new BailianError("MCP request failed: 405 Method Not Allowed")),
).toBe(true);
expect(isStreamableHttpUnsupported(new BailianError("MCP request failed: 404 Not Found"))).toBe(
false,
);
expect(isStreamableHttpUnsupported(new Error("405 streamableHttp"))).toBe(false);
// JSON-RPC business 405 must not trigger HTTP transport fallback
expect(isStreamableHttpUnsupported(new BailianError("MCP error (405): Method Not Allowed"))).toBe(
false,
);
// Nested wrapper phrase in a JSON-RPC message must not trigger fallback.
expect(
isStreamableHttpUnsupported(
new BailianError("MCP error (-32000): MCP request failed: 405 Method Not Allowed"),
),
).toBe(false);
expect(
isUrlOverrideSseFallbackCandidate(new BailianError("MCP request failed: 404 Not Found")),
).toBe(true);
expect(
isUrlOverrideSseFallbackCandidate(
new BailianError("MCP request failed: 405 Method Not Allowed"),
),
).toBe(true);
expect(isUrlOverrideSseFallbackCandidate(new BailianError("MCP error (404): not found"))).toBe(
false,
);
expect(
isUrlOverrideSseFallbackCandidate(
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found"),
),
).toBe(false);
});
test("resolveSameOriginMessageUrl同源通过、跨域拒绝", () => {
expect(
resolveSameOriginMessageUrl(
"https://example.test/api/v1/mcps/WebParser/sse",
"/api/v1/mcps/WebParser/message?sessionId=x",
),
).toBe("https://example.test/api/v1/mcps/WebParser/message?sessionId=x");
expect(() =>
resolveSameOriginMessageUrl(
"https://example.test/api/v1/mcps/WebParser/sse",
"https://evil.example/steal",
),
).toThrow(/origin mismatch/i);
});
test("connectBailianMcpWithFallback成功走 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;
}
});
+79
View File
@@ -0,0 +1,79 @@
import { expect, test } from "vite-plus/test";
import { parseSSE } from "../src/client/stream.ts";
async function collectEvents(
chunks: string[],
): Promise<Array<{ data: string; event?: string; id?: string }>> {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
const response = new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
const events: Array<{ data: string; event?: string; id?: string }> = [];
for await (const event of parseSSE(response)) {
events.push(event);
}
return events;
}
test("parseSSE单 chunk 完整事件保持原行为", async () => {
const events = await collectEvents([
'event: message\ndata: {"ok":true}\nid: 1\n\ndata: plain\n\n',
]);
expect(events).toEqual([{ data: '{"ok":true}', event: "message", id: "1" }, { data: "plain" }]);
});
test("parseSSE多行 data 与注释保持原行为", async () => {
const events = await collectEvents([": keep-alive\ndata: line1\ndata: line2\n\n"]);
expect(events).toEqual([{ data: "line1\nline2" }]);
});
test("parseSSE跨 chunk 保留 event 类型", async () => {
const events = await collectEvents(["event: endpoint\n", "data: /message?sessionId=abc\n\n"]);
expect(events).toEqual([{ data: "/message?sessionId=abc", event: "endpoint" }]);
});
test("parseSSE跨 chunk 保留 id且多事件连续正确", async () => {
const events = await collectEvents([
"id: a\nevent: message\n",
'data: {"n":1}\n\n',
"event: message\ndata: ",
'{"n":2}\n\n',
]);
expect(events).toEqual([
{ data: '{"n":1}', event: "message", id: "a" },
{ data: '{"n":2}', event: "message" },
]);
});
test("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.14.2",
"version": "1.14.3",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.14.2",
"version": "1.14.3",
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+2 -1
View File
@@ -78,11 +78,12 @@ function fromFetchFailed(err: TypeError): BailianError {
if (causeMsg && causeMsg !== code) detailParts.push(causeMsg);
const detail = detailParts.length > 0 ? detailParts.join(": ") : "unknown cause";
// Prefer the errno (ENOTFOUND, …) so JSON toJSON() exposes cause.code for agents.
return new BailianError(
`Network request failed: ${detail}`,
ExitCode.NETWORK,
pickNetworkHint(code),
{ cause: err },
{ cause: cause ?? err },
);
}
+7 -1
View File
@@ -42,7 +42,13 @@ export {
// Output facilities consumed by commands
export { emitResult, emitBare, emitRequestId } from "./output/output.ts";
export { formatTable } from "./output/table.ts";
export { renderBoxTable, type BoxTableOptions, type BarColumn } from "./output/box-table.ts";
export {
renderBoxTable,
renderGauge,
type BoxTableOptions,
type BarColumn,
type GaugeCell,
} from "./output/box-table.ts";
export { createSpinner, createProgressBar } from "./output/progress.ts";
export { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
export { maybeShowStatusBar } from "./output/status-bar.ts";
+20
View File
@@ -136,6 +136,26 @@ interface RenderedCell {
colored: string;
}
/** A standalone gauge cell (bar + label) for non-table layouts, e.g. the usage quota box. */
export interface GaugeCell {
plain: string;
colored: string;
}
/**
* Render a single gauge cell in the `usage free` table style: brand-cyan fill,
* transparent track, and a light-blue label after the bar. `percent` is 0-100;
* null renders an empty gauge with a neutral label.
*/
export function renderGauge(
percent: number | null,
label: string,
width: number = DEFAULT_BAR_WIDTH,
out: NodeJS.WriteStream = process.stdout,
): GaugeCell {
return buildBarCell(percent, label, width, colorLevel(out));
}
function buildBarCell(
percent: number | null,
label: string,
+180 -14
View File
@@ -10,6 +10,11 @@ import {
taskPath,
speechSynthesizePath,
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
stripUndefined,
resolveBooleanFlag,
resolveWatermark,
@@ -23,6 +28,7 @@ import {
type DashScopeTTSRequest,
type DashScopeTTSResponse,
type DashScopeASRRequest,
type DashScopeASRTaskResult,
type ChatMessageContent,
isLocalFile,
} from "bailian-cli-core";
@@ -573,27 +579,103 @@ export async function speechRecognize(
});
}
const model = input.model || "fun-asr";
const route = resolveAsrApi(model);
if (route.kind === "unsupported") {
throw new PipelineError(
"invalid_input",
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
{
step: "speech/recognize",
},
);
}
if (route.kind === "sync-flash") {
if (rawUrls.length !== 1) {
throw new PipelineError(
"invalid_input",
`Model "${model}" is a sync Flash ASR model and accepts exactly one url (got ${rawUrls.length})`,
{ step: "speech/recognize" },
);
}
const unsupportedFlags: string[] = [];
if (input.diarization) unsupportedFlags.push("diarization");
if (input["speaker-count"] !== undefined) unsupportedFlags.push("speaker-count");
// input-audio Flash supports vocabulary_id; qwen3 sync Flash does not
if (route.flashFamily === "qwen3" && input["vocabulary-id"] !== undefined) {
unsupportedFlags.push("vocabulary-id");
}
if (input["channel-id"] !== undefined) unsupportedFlags.push("channel-id");
if (unsupportedFlags.length > 0) {
throw new PipelineError(
"invalid_input",
`Model "${model}" uses sync Flash ASR and does not support: ${unsupportedFlags.join(", ")}`,
{ step: "speech/recognize" },
);
}
}
if (
route.kind === "async-filetrans" &&
route.asyncInputStyle === "file_url" &&
rawUrls.length !== 1
) {
throw new PipelineError(
"invalid_input",
`Model "${model}" accepts exactly one url (got ${rawUrls.length})`,
{ step: "speech/recognize" },
);
}
// Resolve local files to upload URLs
const fileUrls: string[] = [];
for (const u of rawUrls) {
if (isLocalFile(u)) {
for (const audioUrl of rawUrls) {
if (isLocalFile(audioUrl)) {
fileUrls.push(
await env.client.uploadFile(u, input.model || "fun-asr", {
await env.client.uploadFile(audioUrl, model, {
signal: ctx.signal,
}),
);
} else {
fileUrls.push(u);
fileUrls.push(audioUrl);
}
}
const model = input.model || "fun-asr";
if (route.kind === "sync-flash") {
const flashFamily = route.flashFamily!;
const body = buildAsrFlashRequest({
model,
audioUrl: fileUrls[0]!,
language: input.language,
vocabularyId: input["vocabulary-id"],
flashFamily,
});
const response = await env.client.requestJson<Record<string, unknown>>({
path: route.path,
method: "POST",
headers: { "X-DashScope-SSE": "disable" },
body,
signal: ctx.signal,
});
return {
text: extractAsrFlashText(response, flashFamily),
model,
mode: "sync",
raw: response,
};
}
const languageFields = buildAsyncAsrLanguageFields(
route.asyncLanguageStyle ?? "language_hints",
input.language,
);
const body: DashScopeASRRequest = {
model,
input: { file_urls: fileUrls },
input:
route.asyncInputStyle === "file_url" ? { file_url: fileUrls[0]! } : { file_urls: fileUrls },
parameters: {
channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined,
language_hints: input.language ? [input.language] : undefined,
...languageFields,
diarization_enabled: input.diarization,
speaker_count: input["speaker-count"],
vocabulary_id: input["vocabulary-id"],
@@ -601,9 +683,8 @@ export async function speechRecognize(
};
stripUndefined(body.parameters as Record<string, unknown>);
const url = speechRecognizePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
path: url,
path: speechRecognizePath(),
method: "POST",
body,
async: true,
@@ -614,7 +695,65 @@ export async function speechRecognize(
const pollIntervalMs = (input["poll-interval"] ?? 2) * 1000;
const timeoutMs = (ctx.timeoutSeconds ?? 300) * 1000;
return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
// ASR polling reads original output, avoids generic flatten (avoids transcription_url polluting media urls)
const asrTask = await pollAsrTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
const transcriptionItems = collectAsrTranscriptionItems(asrTask.output);
const base: Record<string, unknown> = {
task_id: asrTask.output.task_id,
task_status: asrTask.output.task_status,
request_id: asrTask.request_id,
mode: "async",
model,
};
if (asrTask.output.results) base.results = asrTask.output.results;
if (asrTask.output.result) {
base.result = asrTask.output.result;
if (typeof asrTask.output.result.transcription_url === "string") {
base.transcription_url = asrTask.output.result.transcription_url;
}
}
if (asrTask.output.task_metrics) base.task_metrics = asrTask.output.task_metrics;
if (asrTask.usage) base.usage = asrTask.usage;
if (transcriptionItems.length === 0) {
return base;
}
const texts: string[] = [];
const transcripts: Record<string, unknown>[] = [];
for (const item of transcriptionItems) {
if (!item.transcription_url) continue;
const transRes = await fetch(item.transcription_url, { signal: ctx.signal });
if (!transRes.ok) {
throw new PipelineError(
"async_task_failed",
`Failed to download transcription: HTTP ${transRes.status}`,
{ step: "speech/recognize", details: { taskId, url: item.transcription_url } },
);
}
const transData = (await transRes.json()) as Record<string, unknown>;
transcripts.push(transData);
const transcriptList = transData.transcripts as
| Array<{ text?: string; sentences?: Array<{ text?: string }> }>
| undefined;
if (!transcriptList?.length) continue;
for (const transcript of transcriptList) {
if (transcript.sentences?.length) {
for (const sentence of transcript.sentences) {
if (sentence.text) texts.push(sentence.text);
}
} else if (transcript.text) {
texts.push(transcript.text);
}
}
}
return {
...base,
text: texts.join("\n"),
transcripts,
};
}
// --- Shared: task polling ---
@@ -640,7 +779,7 @@ function flattenTaskResponse(resp: DashScopeTaskResponse): Record<string, unknow
if (urls.length > 0) flat.urls = urls;
}
if (output.results) {
const urls = output.results.map((r) => r.url).filter(Boolean);
const urls = output.results.map((item) => item.url).filter(Boolean);
if (urls.length > 0 && !flat.urls) flat.urls = urls;
}
if (output.task_metrics) flat.task_metrics = output.task_metrics;
@@ -658,13 +797,13 @@ async function pollTask(
return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
}
async function pollTaskWithOptions(
async function pollUntilSucceeded(
env: PipelineEnv,
taskId: string,
pollIntervalMs: number,
timeoutMs: number,
ctx?: StepContext,
): Promise<Record<string, unknown>> {
): Promise<DashScopeTaskResponse> {
const started = Date.now();
let attempt = 0;
@@ -687,7 +826,7 @@ async function pollTaskWithOptions(
const status = result.output.task_status;
if (status === "SUCCEEDED") {
return flattenTaskResponse(result);
return result;
}
if (status === "FAILED") {
@@ -718,6 +857,33 @@ async function pollTaskWithOptions(
}
}
async function pollTaskWithOptions(
env: PipelineEnv,
taskId: string,
pollIntervalMs: number,
timeoutMs: number,
ctx?: StepContext,
): Promise<Record<string, unknown>> {
return flattenTaskResponse(await pollUntilSucceeded(env, taskId, pollIntervalMs, timeoutMs, ctx));
}
/** ASR task polling: preserve original output (includes results[] / result.transcription_url). */
async function pollAsrTaskWithOptions(
env: PipelineEnv,
taskId: string,
pollIntervalMs: number,
timeoutMs: number,
ctx?: StepContext,
): Promise<DashScopeASRTaskResult> {
return (await pollUntilSucceeded(
env,
taskId,
pollIntervalMs,
timeoutMs,
ctx,
)) as DashScopeASRTaskResult;
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve, reject) => {
+51 -14
View File
@@ -25,6 +25,13 @@ interface CommandNode {
children: Map<string, CommandNode>;
}
const AUTH_LABELS = {
apiKey: "API Key",
console: "Console",
openapi: "AK/SK",
none: "No Auth",
} satisfies Record<AuthRequirement, string>;
/**
* What a command path resolves to in the registry. The single judgement that
* feeds `resolve()` — no scattered `isGroupPath` + throwing `resolve`.
@@ -157,14 +164,37 @@ export class CommandRegistry {
};
}
private buildResourceLines(a: (s: string) => string, d: (s: string) => string): string {
const entries: Array<{ path: string; desc: string }> = [];
private buildCommandLines(
entries: Array<{ path: string; auth: AuthRequirement; desc: string }>,
accent: (text: string) => string,
dim: (text: string) => string,
): string {
const maxPathLength = Math.max(...entries.map((entry) => entry.path.length));
const maxAuthLength = Math.max(
...entries.map((entry) => `[${AUTH_LABELS[entry.auth]}]`.length),
);
const rows = entries.map((entry) => {
const authLabel = `[${AUTH_LABELS[entry.auth]}]`;
return ` ${accent(entry.path.padEnd(maxPathLength + 2))} ${accent(authLabel.padEnd(maxAuthLength + 2))} ${dim(entry.desc)}`;
});
return rows.join("\n");
}
private buildResourceLines(
accent: (text: string) => string,
dim: (text: string) => string,
): string {
const entries: Array<{ path: string; auth: AuthRequirement; desc: string }> = [];
const collect = (node: CommandNode, prefix: string) => {
for (const [name, child] of node.children) {
const fullPath = prefix ? `${prefix} ${name}` : name;
if (child.command) {
entries.push({ path: fullPath, desc: child.command.description });
entries.push({
path: fullPath,
auth: child.command.auth,
desc: child.command.description,
});
}
if (child.children.size > 0) {
collect(child, fullPath);
@@ -173,8 +203,7 @@ export class CommandRegistry {
};
collect(this.root, "");
const maxLen = Math.max(...entries.map((e) => e.path.length));
return entries.map((e) => ` ${a(e.path.padEnd(maxLen + 2))} ${d(e.desc)}`).join("\n");
return this.buildCommandLines(entries, accent, dim);
}
private buildFlagLines(
@@ -341,6 +370,7 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
out.write(`\n${cmd.description}\n`);
out.write(`${b("Usage:")} ${prefix}${cmd.usageArgs ? ` ${cmd.usageArgs}` : ""}\n`);
out.write(`${b("Authentication:")} ${a(AUTH_LABELS[cmd.auth])}\n`);
const flagEntries = [
...Object.entries(cmd.flags ?? {}),
...Object.entries(credentialFlagDefs(cmd)),
@@ -373,18 +403,25 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
}
private printChildren(node: CommandNode, prefix: string, out: NodeJS.WriteStream): void {
const entries: Array<{ fullName: string; description: string }> = [];
const collect = (n: CommandNode, p: string) => {
for (const [name, child] of n.children) {
const entries: Array<{ path: string; auth: AuthRequirement; desc: string }> = [];
const collect = (currentNode: CommandNode, currentPath: string) => {
for (const [name, child] of currentNode.children) {
if (child.command)
entries.push({ fullName: `${p} ${name}`, description: child.command.description });
if (child.children.size > 0) collect(child, `${p} ${name}`);
entries.push({
path: `${currentPath} ${name}`,
auth: child.command.auth,
desc: child.command.description,
});
if (child.children.size > 0) collect(child, `${currentPath} ${name}`);
}
};
collect(node, prefix);
const maxLen = Math.max(...entries.map((e) => e.fullName.length));
for (const { fullName, description } of entries) {
out.write(` ${this.accent(fullName.padEnd(maxLen), out)} ${this.dim(description, out)}\n`);
}
out.write(
this.buildCommandLines(
entries,
(text) => this.accent(text, out),
(text) => this.dim(text, out),
) + "\n",
);
}
}
@@ -0,0 +1,85 @@
import { ExitCode } from "bailian-cli-core";
import { expect, test } from "vite-plus/test";
import { handleError } from "../src/error-handler.ts";
test("handleError: fetch failed JSON includes cause.code from errno", () => {
const previousOutput = process.env.DASHSCOPE_OUTPUT;
process.env.DASHSCOPE_OUTPUT = "json";
let stderr = "";
const originalWrite = process.stderr.write.bind(process.stderr);
const originalExit = process.exit;
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit;
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), {
code: "ENOTFOUND",
});
const fetchFailed = new TypeError("fetch failed", { cause: root });
try {
expect(() => handleError(fetchFailed, "bl")).toThrow(
new RegExp(`process\\.exit:${ExitCode.NETWORK}`),
);
const payload = JSON.parse(stderr.trim()) as {
error: { code: number; message: string; cause?: { message: string; code?: string } };
};
expect(payload.error.code).toBe(ExitCode.NETWORK);
expect(payload.error.message).toMatch(/ENOTFOUND/);
expect(payload.error.cause).toEqual({
message: root.message,
code: "ENOTFOUND",
});
} finally {
process.stderr.write = originalWrite;
process.exit = originalExit;
if (previousOutput === undefined) {
delete process.env.DASHSCOPE_OUTPUT;
} else {
process.env.DASHSCOPE_OUTPUT = previousOutput;
}
}
});
test("handleError: fetch failed without nested cause still maps to NETWORK", () => {
const previousOutput = process.env.DASHSCOPE_OUTPUT;
process.env.DASHSCOPE_OUTPUT = "json";
let stderr = "";
const originalWrite = process.stderr.write.bind(process.stderr);
const originalExit = process.exit;
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit;
const fetchFailed = new TypeError("fetch failed");
try {
expect(() => handleError(fetchFailed, "bl")).toThrow(
new RegExp(`process\\.exit:${ExitCode.NETWORK}`),
);
const payload = JSON.parse(stderr.trim()) as {
error: { code: number; message: string; cause?: { message: string; code?: string } };
};
expect(payload.error.code).toBe(ExitCode.NETWORK);
expect(payload.error.message).toMatch(/unknown cause/);
expect(payload.error.cause).toEqual({ message: "fetch failed" });
} finally {
process.stderr.write = originalWrite;
process.exit = originalExit;
if (previousOutput === undefined) {
delete process.env.DASHSCOPE_OUTPUT;
} else {
process.env.DASHSCOPE_OUTPUT = previousOutput;
}
}
});
@@ -0,0 +1,184 @@
import { expect, test } from "vite-plus/test";
import type { Client } from "bailian-cli-core";
import { PipelineError } from "../src/pipeline/errors.ts";
import type { PipelineEnv } from "../src/pipeline/bl-config.ts";
import { speechRecognize } from "../src/pipeline/steps/bl-api.ts";
import type { StepContext } from "../src/pipeline/types.ts";
type CapturedRequest = {
path?: string;
method?: string;
headers?: Record<string, string>;
body?: Record<string, unknown>;
async?: boolean;
};
function makeEnv(requestJsonImpl?: (opts: CapturedRequest) => Promise<unknown>): {
env: PipelineEnv;
captured: CapturedRequest[];
} {
const captured: CapturedRequest[] = [];
const client = {
uploadFile: async (source: string) => source,
requestJson: async (opts: CapturedRequest) => {
captured.push(opts);
if (requestJsonImpl) return requestJsonImpl(opts);
return { output: { text: "ok" } };
},
} as unknown as Client;
return {
env: {
client,
settings: { quiet: true, output: "json" } as PipelineEnv["settings"],
},
captured,
};
}
function makeCtx(): StepContext {
return { dryRun: false, signal: new AbortController().signal };
}
test("pipeline speechRecognize routes input-audio flash to sync multimodal endpoint", async () => {
const { env, captured } = makeEnv();
const result = (await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen-audio-3.0-asr-flash",
language: "en",
"vocabulary-id": "vocab-1",
},
makeCtx(),
)) as { mode?: string; text?: string };
expect(result.mode).toBe("sync");
expect(result.text).toBe("ok");
expect(captured).toHaveLength(1);
expect(captured[0]?.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(captured[0]?.headers?.["X-DashScope-SSE"]).toBe("disable");
expect(captured[0]?.body).toMatchObject({
model: "qwen-audio-3.0-asr-flash",
parameters: {
format: "wav",
language_hints: ["en"],
vocabulary_id: "vocab-1",
},
});
});
test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", async () => {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
return { output: { task_id: "task-1", task_status: "PENDING" } };
}
return {
output: { task_id: "task-1", task_status: "SUCCEEDED", results: [] },
request_id: "r1",
};
});
await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen3-asr-flash-filetrans",
language: "zh",
"poll-interval": 0,
},
makeCtx(),
);
expect(captured[0]?.path).toBe("/api/v1/services/audio/asr/transcription");
expect(captured[0]?.async).toBe(true);
expect(captured[0]?.body).toMatchObject({
model: "qwen3-asr-flash-filetrans",
input: { file_url: "https://example.com/a.wav" },
parameters: { language: "zh" },
});
expect(
(captured[0]?.body?.parameters as Record<string, unknown> | undefined)?.language_hints,
).toBeUndefined();
});
test("pipeline speechRecognize rejects realtime models before requesting", async () => {
const { env, captured } = makeEnv();
await expect(
speechRecognize(
env,
{ url: "https://example.com/a.wav", model: "qwen3-asr-flash-realtime" },
makeCtx(),
),
).rejects.toBeInstanceOf(PipelineError);
expect(captured).toHaveLength(0);
});
test("pipeline speechRecognize rejects multiple urls for sync flash", async () => {
const { env, captured } = makeEnv();
await expect(
speechRecognize(
env,
{
url: ["https://example.com/a.wav", "https://example.com/b.wav"],
model: "fun-asr-flash-2026-06-15",
},
makeCtx(),
),
).rejects.toBeInstanceOf(PipelineError);
expect(captured).toHaveLength(0);
});
test("pipeline speechRecognize downloads qwen3 singular result.transcription_url", async () => {
const originalFetch = globalThis.fetch;
const transcriptionUrl = "https://example.com/transcription.json";
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
expect(url).toBe(transcriptionUrl);
return new Response(
JSON.stringify({
transcripts: [{ text: "pipeline hello", sentences: [{ text: "pipeline hello" }] }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
try {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
return { output: { task_id: "task-1", task_status: "PENDING" } };
}
return {
output: {
task_id: "task-1",
task_status: "SUCCEEDED",
result: { transcription_url: transcriptionUrl },
},
request_id: "r1",
};
});
const result = (await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen3-asr-flash-filetrans",
"poll-interval": 0,
},
makeCtx(),
)) as {
mode?: string;
text?: string;
transcription_url?: string;
result?: { transcription_url?: string };
};
expect(captured[0]?.async).toBe(true);
expect(result.mode).toBe("async");
expect(result.text).toBe("pipeline hello");
expect(result.transcription_url).toBe(transcriptionUrl);
expect(result.result?.transcription_url).toBe(transcriptionUrl);
} finally {
globalThis.fetch = originalFetch;
}
});
+3 -3
View File
@@ -24,12 +24,12 @@ catalogs:
chalk:
specifier: ^5.6.2
version: 5.6.2
tar-stream:
specifier: ^3.2.0
version: 3.2.0
smol-toml:
specifier: ^1.4.2
version: 1.7.0
tar-stream:
specifier: ^3.2.0
version: 3.2.0
tsx:
specifier: ^4.23.0
version: 4.23.0
+3 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-cli
metadata:
version: "1.14.2"
version: "1.14.3"
requires:
bins: ["bl"]
description: >-
@@ -71,6 +71,8 @@ Use this table only after the decision table in [`bailian-protocol`](../bailian-
| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions |
| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed |
| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed |
| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed |
| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed |
| Console API (advanced) | `bl console call` | Console auth |
| Bailian workspace listing | `bl workspace list` | Console auth |
| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` |
+9 -8
View File
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ---------------------- | ---------------------------------------------------------------------------------------------- |
| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
| Command | Authentication | Description |
| ---------------------- | -------------- | ---------------------------------------------------------------------------------------------- |
| `bl advisor recommend` | API Key | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
## Command details
### `bl advisor recommend`
| Field | Value |
| --------------- | ---------------------------------------------------------------------------------------------- |
| **Name** | `advisor recommend` |
| **Description** | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
| **Usage** | `bl advisor recommend --message <text> [flags]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------------------------------------------- |
| **Name** | `advisor recommend` |
| **Description** | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) |
| **Authentication** | API Key |
| **Usage** | `bl advisor recommend --message <text> [flags]` |
#### Flags
+16 -14
View File
@@ -7,20 +7,21 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------- | ---------------------------------------------- |
| `bl app call` | Call a Bailian application (agent or workflow) |
| `bl app list` | List Bailian applications |
| Command | Authentication | Description |
| ------------- | -------------- | ---------------------------------------------- |
| `bl app call` | API Key | Call a Bailian application (agent or workflow) |
| `bl app list` | Console | List Bailian applications |
## Command details
### `bl app call`
| Field | Value |
| --------------- | --------------------------------------------------- |
| **Name** | `app call` |
| **Description** | Call a Bailian application (agent or workflow) |
| **Usage** | `bl app call --app-id <id> --prompt <text> [flags]` |
| Field | Value |
| ------------------ | --------------------------------------------------- |
| **Name** | `app call` |
| **Description** | Call a Bailian application (agent or workflow) |
| **Authentication** | API Key |
| **Usage** | `bl app call --app-id <id> --prompt <text> [flags]` |
#### Flags
@@ -67,11 +68,12 @@ bl app call --app-id abc123 --prompt "Start" --biz-params '{"key":"value"}'
### `bl app list`
| Field | Value |
| --------------- | ------------------------- |
| **Name** | `app list` |
| **Description** | List Bailian applications |
| **Usage** | `bl app list [flags]` |
| Field | Value |
| ------------------ | ------------------------- |
| **Name** | `app list` |
| **Description** | List Bailian applications |
| **Authentication** | Console |
| **Usage** | `bl app list [flags]` |
#### Flags
@@ -1,295 +0,0 @@
# `bl asset-center` commands
> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand.
> Regenerate: `pnpm --filter bailian-cli run generate:reference`.
Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ---------------------------- | -------------------------------------------------------------- |
| `bl asset-center delete` | Delete assets (soft delete to recycle bin by default) |
| `bl asset-center download` | Get a signed download URL for an asset by ID |
| `bl asset-center favorite` | Add assets to favorites |
| `bl asset-center get` | Get full details of a model-generated asset |
| `bl asset-center list` | List model-generated assets with filters and cursor pagination |
| `bl asset-center stats` | Count model-generated assets by type |
| `bl asset-center storage` | View storage quota, usage, and overage pricing |
| `bl asset-center unfavorite` | Remove assets from favorites |
## Command details
### `bl asset-center delete`
| Field | Value |
| --------------- | --------------------------------------------------------------------- |
| **Name** | `asset-center delete` |
| **Description** | Delete assets (soft delete to recycle bin by default) |
| **Usage** | `bl asset-center delete --id <asset-id> [--id <asset-id>...] [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--id <asset-id>` | array | yes | Asset ID(s) to operate on (repeatable, max 100) |
| `--permanent` | switch | no | Permanently delete assets (cannot be restored) |
| `--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 asset-center delete --id asset-001
```
```bash
bl asset-center delete --id asset-001 --id asset-002
```
```bash
bl asset-center delete --id asset-001 --permanent
```
### `bl asset-center download`
| Field | Value |
| --------------- | -------------------------------------------- |
| **Name** | `asset-center download` |
| **Description** | Get a signed download URL for an asset by ID |
| **Usage** | `bl asset-center download --id <asset-id>` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--id <asset-id>` | string | yes | Asset ID to get download URL for |
| `--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 asset-center download --id asset-001
```
```bash
bl asset-center download --id asset-001 --output json
```
```bash
bl asset-center download --id asset-001 --quiet
```
### `bl asset-center favorite`
| Field | Value |
| --------------- | --------------------------------------------------------------- |
| **Name** | `asset-center favorite` |
| **Description** | Add assets to favorites |
| **Usage** | `bl asset-center favorite --id <asset-id> [--id <asset-id>...]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--id <asset-id>` | array | yes | Asset ID(s) to operate on (repeatable, max 100) |
| `--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 asset-center favorite --id asset-001
```
```bash
bl asset-center favorite --id asset-001 --id asset-002
```
### `bl asset-center get`
| Field | Value |
| --------------- | --------------------------------------------- |
| **Name** | `asset-center get` |
| **Description** | Get full details of a model-generated asset |
| **Usage** | `bl asset-center get --asset-id <id> [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--asset-id <id>` | string | yes | Asset ID to query |
| `--include-download-url` | switch | no | Include signed download URL |
| `--include-thumbnail` | switch | no | Include thumbnail URL |
| `--thumbnail-width <px>` | number | no | Thumbnail width in pixels |
| `--thumbnail-height <px>` | number | no | Thumbnail height in pixels |
| `--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 asset-center get --asset-id asset-001
```
```bash
bl asset-center get --asset-id asset-001 --include-download-url --output json
```
### `bl asset-center list`
| Field | Value |
| --------------- | -------------------------------------------------------------- |
| **Name** | `asset-center list` |
| **Description** | List model-generated assets with filters and cursor pagination |
| **Usage** | `bl asset-center list [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------------------------------------------- | ------ | -------- | -------------------------------------------------------- |
| `--type <IMAGE\|VIDEO\|AUDIO>` | string | no | Asset type: IMAGE, VIDEO, or AUDIO |
| `--model <name>` | string | no | Filter by model name |
| `--keyword <text>` | string | no | Filter by asset name (substring match) |
| `--favorited` | switch | no | Show or count only favorited assets |
| `--recycle-bin` | switch | no | Show or count soft-deleted assets (recycle bin) |
| `--sync-status <NOT_SYNCED\|IN_SYNCING\|SYNC_SUCCESS\|SYNC_FAILED>` | string | no | OSS sync status filter |
| `--begin-time <datetime>` | string | no | Filter by generate time start (ISO_LOCAL_DATE_TIME) |
| `--end-time <datetime>` | string | no | Filter by generate time end (ISO_LOCAL_DATE_TIME) |
| `--include-download-url` | switch | no | Include signed download URLs in the response |
| `--include-thumbnail` | switch | no | Include thumbnail URLs in the response |
| `--thumbnail-width <px>` | number | no | Thumbnail width in pixels |
| `--thumbnail-height <px>` | number | no | Thumbnail height in pixels |
| `--page-size <n>` | number | no | Results per page (default: 10, max: 100) |
| `--next-token <token>` | number | no | Cursor for the next page |
| `--pre-token <token>` | number | no | Cursor for the previous page |
| `--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 asset-center list
```
```bash
bl asset-center list --type IMAGE --model qwen-image-3.0
```
```bash
bl asset-center list --favorited --page-size 20
```
```bash
bl asset-center list --recycle-bin
```
```bash
bl asset-center list --keyword landscape --output json
```
### `bl asset-center stats`
| Field | Value |
| --------------- | ------------------------------------ |
| **Name** | `asset-center stats` |
| **Description** | Count model-generated assets by type |
| **Usage** | `bl asset-center stats [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------------------------------------------- | ------ | -------- | -------------------------------------------------------- |
| `--type <IMAGE\|VIDEO\|AUDIO>` | string | no | Asset type: IMAGE, VIDEO, or AUDIO |
| `--model <name>` | string | no | Filter by model name |
| `--keyword <text>` | string | no | Filter by asset name (substring match) |
| `--favorited` | switch | no | Show or count only favorited assets |
| `--recycle-bin` | switch | no | Show or count soft-deleted assets (recycle bin) |
| `--sync-status <NOT_SYNCED\|IN_SYNCING\|SYNC_SUCCESS\|SYNC_FAILED>` | string | no | OSS sync status filter |
| `--begin-time <datetime>` | string | no | Filter by generate time start (ISO_LOCAL_DATE_TIME) |
| `--end-time <datetime>` | string | no | Filter by generate time end (ISO_LOCAL_DATE_TIME) |
| `--sync-failed` | switch | no | Also count assets with failed OSS sync |
| `--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 asset-center stats
```
```bash
bl asset-center stats --sync-failed
```
```bash
bl asset-center stats --type IMAGE --output json
```
### `bl asset-center storage`
| Field | Value |
| --------------- | ---------------------------------------------- |
| **Name** | `asset-center storage` |
| **Description** | View storage quota, usage, and overage pricing |
| **Usage** | `bl asset-center storage [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 asset-center storage
```
```bash
bl asset-center storage --output json
```
### `bl asset-center unfavorite`
| Field | Value |
| --------------- | ----------------------------------------------------------------- |
| **Name** | `asset-center unfavorite` |
| **Description** | Remove assets from favorites |
| **Usage** | `bl asset-center unfavorite --id <asset-id> [--id <asset-id>...]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--id <asset-id>` | array | yes | Asset ID(s) to operate on (repeatable, max 100) |
| `--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 asset-center unfavorite --id asset-001
```
```bash
bl asset-center unfavorite --id asset-001 --id asset-002
```
+30 -26
View File
@@ -7,22 +7,23 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------------------- | -------------------------------------------------------------------------------------------- |
| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK |
| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) |
| `bl auth logout` | Clear stored credentials; full logout also clears the model Base URL |
| `bl auth status` | Show current authentication state |
| Command | Authentication | Description |
| ------------------------------- | -------------- | -------------------------------------------------------------------------------------------- |
| `bl auth generate-access-token` | No Auth | Generate a CLI access token using OpenAPI AK/SK |
| `bl auth login` | No Auth | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) |
| `bl auth logout` | No Auth | Clear stored credentials; full logout also clears the model Base URL |
| `bl auth status` | No Auth | Show current authentication state |
## Command details
### `bl auth generate-access-token`
| Field | Value |
| --------------- | ---------------------------------------------------------------------------------------------------------- |
| **Name** | `auth generate-access-token` |
| **Description** | Generate a CLI access token using OpenAPI AK/SK |
| **Usage** | `bl auth generate-access-token --access-key-id <id> --access-key-secret <secret> --security-token <token>` |
| Field | Value |
| ------------------ | ---------------------------------------------------------------------------------------------------------- |
| **Name** | `auth generate-access-token` |
| **Description** | Generate a CLI access token using OpenAPI AK/SK |
| **Authentication** | No Auth |
| **Usage** | `bl auth generate-access-token --access-key-id <id> --access-key-secret <secret> --security-token <token>` |
#### Flags
@@ -40,11 +41,12 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx
### `bl auth login`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------ |
| **Name** | `auth login` |
| **Description** | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) |
| **Usage** | `bl auth login --api-key <key> \| --console \| --open-api --access-key-id <id> --access-key-secret <secret>` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| **Name** | `auth login` |
| **Description** | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) |
| **Authentication** | No Auth |
| **Usage** | `bl auth login --api-key <key> \| --console \| --open-api --access-key-id <id> --access-key-secret <secret>` |
#### Flags
@@ -78,11 +80,12 @@ bl auth login --open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx
### `bl auth logout`
| Field | Value |
| --------------- | -------------------------------------------------------------------- |
| **Name** | `auth logout` |
| **Description** | Clear stored credentials; full logout also clears the model Base URL |
| **Usage** | `bl auth logout [--console \| --open-api] [--dry-run]` |
| Field | Value |
| ------------------ | -------------------------------------------------------------------- |
| **Name** | `auth logout` |
| **Description** | Clear stored credentials; full logout also clears the model Base URL |
| **Authentication** | No Auth |
| **Usage** | `bl auth logout [--console \| --open-api] [--dry-run]` |
#### Flags
@@ -111,11 +114,12 @@ bl auth logout --dry-run
### `bl auth status`
| Field | Value |
| --------------- | --------------------------------- |
| **Name** | `auth status` |
| **Description** | Show current authentication state |
| **Usage** | `bl auth status` |
| Field | Value |
| ------------------ | --------------------------------- |
| **Name** | `auth status` |
| **Description** | Show current authentication state |
| **Authentication** | No Auth |
| **Usage** | `bl auth status` |
#### Flags
+44 -38
View File
@@ -7,24 +7,25 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ----------------- | ------------------------------------------------ |
| `bl config agent` | Configure a coding agent to use DashScope API |
| `bl config list` | List config profiles and show the active profile |
| `bl config set` | Set a config value |
| `bl config show` | Display current configuration |
| `bl config ui` | Open a local web UI to manage config profiles |
| `bl config use` | Set the active config profile |
| Command | Authentication | Description |
| ----------------- | -------------- | ------------------------------------------------ |
| `bl config agent` | No Auth | Configure a coding agent to use DashScope API |
| `bl config list` | No Auth | List config profiles and show the active profile |
| `bl config set` | No Auth | Set a config value |
| `bl config show` | No Auth | Display current configuration |
| `bl config ui` | No Auth | Open a local web UI to manage config profiles |
| `bl config use` | No Auth | Set the active config profile |
## Command details
### `bl config agent`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `config agent` |
| **Description** | Configure a coding agent to use DashScope API |
| **Usage** | `bl config agent --agent <name> (--base-url <url> \| --region <region>) (--api-key <key> \| --key <encoded>) --model <model>` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `config agent` |
| **Description** | Configure a coding agent to use DashScope API |
| **Authentication** | No Auth |
| **Usage** | `bl config agent --agent <name> (--base-url <url> \| --region <region>) (--api-key <key> \| --key <encoded>) --model <model>` |
#### Flags
@@ -55,11 +56,12 @@ bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatib
### `bl config list`
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `config list` |
| **Description** | List config profiles and show the active profile |
| **Usage** | `bl config list` |
| Field | Value |
| ------------------ | ------------------------------------------------ |
| **Name** | `config list` |
| **Description** | List config profiles and show the active profile |
| **Authentication** | No Auth |
| **Usage** | `bl config list` |
#### Flags
@@ -77,11 +79,12 @@ bl config list --output json
### `bl config set`
| Field | Value |
| --------------- | ------------------------------------------- |
| **Name** | `config set` |
| **Description** | Set a config value |
| **Usage** | `bl config set --key <key> --value <value>` |
| Field | Value |
| ------------------ | ------------------------------------------- |
| **Name** | `config set` |
| **Description** | Set a config value |
| **Authentication** | No Auth |
| **Usage** | `bl config set --key <key> --value <value>` |
#### Flags
@@ -106,11 +109,12 @@ bl config set --key base_url --value https://dashscope.aliyuncs.com
### `bl config show`
| Field | Value |
| --------------- | ----------------------------- |
| **Name** | `config show` |
| **Description** | Display current configuration |
| **Usage** | `bl config show` |
| Field | Value |
| ------------------ | ----------------------------- |
| **Name** | `config show` |
| **Description** | Display current configuration |
| **Authentication** | No Auth |
| **Usage** | `bl config show` |
#### Flags
@@ -128,11 +132,12 @@ bl config show --output json
### `bl config ui`
| Field | Value |
| --------------- | --------------------------------------------- |
| **Name** | `config ui` |
| **Description** | Open a local web UI to manage config profiles |
| **Usage** | `bl config ui [--port <port>] [--no-open]` |
| Field | Value |
| ------------------ | --------------------------------------------- |
| **Name** | `config ui` |
| **Description** | Open a local web UI to manage config profiles |
| **Authentication** | No Auth |
| **Usage** | `bl config ui [--port <port>] [--no-open]` |
#### Flags
@@ -157,11 +162,12 @@ bl config ui --no-open
### `bl config use`
| Field | Value |
| --------------- | ----------------------------- |
| **Name** | `config use` |
| **Description** | Set the active config profile |
| **Usage** | `bl config use --name <name>` |
| Field | Value |
| ------------------ | ----------------------------- |
| **Name** | `config use` |
| **Description** | Set the active config profile |
| **Authentication** | No Auth |
| **Usage** | `bl config use --name <name>` |
#### Flags
+10 -9
View File
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ----------------- | ---------------------------------------------- |
| `bl console call` | Call a Bailian console API via the CLI gateway |
| Command | Authentication | Description |
| ----------------- | -------------- | ---------------------------------------------- |
| `bl console call` | Console | Call a Bailian console API via the CLI gateway |
## Command details
### `bl console call`
| Field | Value |
| --------------- | --------------------------------------------------- |
| **Name** | `console call` |
| **Description** | Call a Bailian console API via the CLI gateway |
| **Usage** | `bl console call --api <api> --data <json> [flags]` |
| Field | Value |
| ------------------ | --------------------------------------------------- |
| **Name** | `console call` |
| **Description** | Call a Bailian console API via the CLI gateway |
| **Authentication** | Console |
| **Usage** | `bl console call --api <api> --data <json> [flags]` |
#### Flags
@@ -35,7 +36,7 @@ Index: [index.md](index.md)
#### Examples
```bash
bl console call --api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'
bl console call --api zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'
```
```bash
+9 -8
View File
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ---------------- | -------------------------------------------------------- |
| `bl file upload` | Upload a local file to DashScope temporary storage (48h) |
| Command | Authentication | Description |
| ---------------- | -------------- | -------------------------------------------------------- |
| `bl file upload` | API Key | Upload a local file to DashScope temporary storage (48h) |
## Command details
### `bl file upload`
| Field | Value |
| --------------- | -------------------------------------------------------- |
| **Name** | `file upload` |
| **Description** | Upload a local file to DashScope temporary storage (48h) |
| **Usage** | `bl file upload --file <path> --model <model>` |
| Field | Value |
| ------------------ | -------------------------------------------------------- |
| **Name** | `file upload` |
| **Description** | Upload a local file to DashScope temporary storage (48h) |
| **Authentication** | API Key |
| **Usage** | `bl file upload --file <path> --model <model>` |
#### Flags
+83 -90
View File
@@ -9,99 +9,92 @@ Use this index for the skill-scoped quick index and global flags.
## Quick index
| Command | Description | Detail |
| ------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------- |
| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) |
| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) |
| `bl app list` | List Bailian applications | [app.md](app.md) |
| `bl asset-center delete` | Delete assets (soft delete to recycle bin by default) | [asset-center.md](asset-center.md) |
| `bl asset-center download` | Get a signed download URL for an asset by ID | [asset-center.md](asset-center.md) |
| `bl asset-center favorite` | Add assets to favorites | [asset-center.md](asset-center.md) |
| `bl asset-center get` | Get full details of a model-generated asset | [asset-center.md](asset-center.md) |
| `bl asset-center list` | List model-generated assets with filters and cursor pagination | [asset-center.md](asset-center.md) |
| `bl asset-center stats` | Count model-generated assets by type | [asset-center.md](asset-center.md) |
| `bl asset-center storage` | View storage quota, usage, and overage pricing | [asset-center.md](asset-center.md) |
| `bl asset-center unfavorite` | Remove assets from favorites | [asset-center.md](asset-center.md) |
| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) |
| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) |
| `bl auth logout` | Clear stored credentials; full logout also clears the model Base URL | [auth.md](auth.md) |
| `bl auth status` | Show current authentication state | [auth.md](auth.md) |
| `bl config agent` | Configure a coding agent to use DashScope API | [config.md](config.md) |
| `bl config list` | List config profiles and show the active profile | [config.md](config.md) |
| `bl config set` | Set a config value | [config.md](config.md) |
| `bl config show` | Display current configuration | [config.md](config.md) |
| `bl config ui` | Open a local web UI to manage config profiles | [config.md](config.md) |
| `bl config use` | Set the active config profile | [config.md](config.md) |
| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) |
| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) |
| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) |
| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |
| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) |
| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) |
| `bl memory delete` | Delete a memory node | [memory.md](memory.md) |
| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) |
| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) |
| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) |
| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) |
| `bl memory update` | Update a memory node content | [memory.md](memory.md) |
| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) |
| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) |
| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) |
| `bl quota history` | View quota change history | [quota.md](quota.md) |
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
| `bl skill init` | Install all bailian-\* skills (one-shot bootstrap for new environments) | [skill.md](skill.md) |
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) |
| Command | Authentication | Description | Detail |
| ------------------------------- | -------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ |
| `bl advisor recommend` | API Key | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) |
| `bl app call` | API Key | Call a Bailian application (agent or workflow) | [app.md](app.md) |
| `bl app list` | Console | List Bailian applications | [app.md](app.md) |
| `bl auth generate-access-token` | No Auth | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) |
| `bl auth login` | No Auth | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) |
| `bl auth logout` | No Auth | Clear stored credentials; full logout also clears the model Base URL | [auth.md](auth.md) |
| `bl auth status` | No Auth | Show current authentication state | [auth.md](auth.md) |
| `bl config agent` | No Auth | Configure a coding agent to use DashScope API | [config.md](config.md) |
| `bl config list` | No Auth | List config profiles and show the active profile | [config.md](config.md) |
| `bl config set` | No Auth | Set a config value | [config.md](config.md) |
| `bl config show` | No Auth | Display current configuration | [config.md](config.md) |
| `bl config ui` | No Auth | Open a local web UI to manage config profiles | [config.md](config.md) |
| `bl config use` | No Auth | Set the active config profile | [config.md](config.md) |
| `bl console call` | Console | Call a Bailian console API via the CLI gateway | [console.md](console.md) |
| `bl file upload` | API Key | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) |
| `bl knowledge chat` | API Key | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) |
| `bl knowledge retrieve` | API Key | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |
| `bl knowledge search` | API Key | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) |
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
| `bl mcp list` | Console | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
| `bl memory add` | API Key | Add memory from messages or custom content | [memory.md](memory.md) |
| `bl memory delete` | API Key | Delete a memory node | [memory.md](memory.md) |
| `bl memory list` | API Key | List memory nodes for a user | [memory.md](memory.md) |
| `bl memory profile create` | API Key | Create a user profile schema for memory profiling | [memory.md](memory.md) |
| `bl memory profile get` | API Key | Get user profile by schema ID and user ID | [memory.md](memory.md) |
| `bl memory search` | API Key | Search memory nodes by query or messages | [memory.md](memory.md) |
| `bl memory update` | API Key | Update a memory node content | [memory.md](memory.md) |
| `bl model list` | Console | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
| `bl pipeline run` | No Auth | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
| `bl pipeline validate` | No Auth | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
| `bl plugin install` | No Auth | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
| `bl plugin link` | No Auth | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
| `bl plugin list` | No Auth | List installed Command Packs and their load status | [plugin.md](plugin.md) |
| `bl plugin remove` | No Auth | Remove an installed Command Pack | [plugin.md](plugin.md) |
| `bl quota check` | Console | Check current usage against rate limits | [quota.md](quota.md) |
| `bl quota history` | Console | View quota change history | [quota.md](quota.md) |
| `bl quota list` | Console | View model RPM/TPM rate limits | [quota.md](quota.md) |
| `bl quota request` | Console | Request a temporary quota increase | [quota.md](quota.md) |
| `bl search web` | API Key | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
| `bl skill add` | No Auth | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
| `bl skill init` | No Auth | Install all bailian-\* skills (one-shot bootstrap for new environments) | [skill.md](skill.md) |
| `bl skill list` | No Auth | List registry skills and diff against local installs | [skill.md](skill.md) |
| `bl skill remove` | No Auth | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
| `bl skill update` | No Auth | Update installed skills to the latest registry versions | [skill.md](skill.md) |
| `bl text chat` | API Key | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
| `bl update` | No Auth | Update the CLI to the latest or a specified version | [update.md](update.md) |
| `bl usage coding-plan` | Console | Show Coding Plan quota usage | [usage.md](usage.md) |
| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
| `bl usage stats` | Console | Query model usage statistics | [usage.md](usage.md) |
| `bl usage summary` | Console | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
| `bl usage token-plan` | Console | Show Token Plan quota usage | [usage.md](usage.md) |
| `bl workspace init` | No Auth | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
| `bl workspace list` | Console | List all workspaces | [workspace.md](workspace.md) |
## By group
| Group | Commands | Reference |
| -------------- | --------------------------------------------------------------------------------- | ---------------------------------- |
| `advisor` | `recommend` | [advisor.md](advisor.md) |
| `app` | `call`, `list` | [app.md](app.md) |
| `asset-center` | `delete`, `download`, `favorite`, `get`, `list`, `stats`, `storage`, `unfavorite` | [asset-center.md](asset-center.md) |
| `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) |
| `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) |
| `console` | `call` | [console.md](console.md) |
| `file` | `upload` | [file.md](file.md) |
| `knowledge` | `chat`, `retrieve`, `search` | [knowledge.md](knowledge.md) |
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
| `model` | `list` | [model.md](model.md) |
| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) |
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
| `search` | `web` | [search.md](search.md) |
| `skill` | `add`, `init`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `text` | `chat` | [text.md](text.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
| `update` | `(root)` | [update.md](update.md) |
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
| Group | Commands | Reference |
| ------------ | ---------------------------------------------------------------------------- | ------------------------------ |
| `advisor` | `recommend` | [advisor.md](advisor.md) |
| `app` | `call`, `list` | [app.md](app.md) |
| `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) |
| `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) |
| `console` | `call` | [console.md](console.md) |
| `file` | `upload` | [file.md](file.md) |
| `knowledge` | `chat`, `retrieve`, `search` | [knowledge.md](knowledge.md) |
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
| `model` | `list` | [model.md](model.md) |
| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) |
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
| `search` | `web` | [search.md](search.md) |
| `skill` | `add`, `init`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `text` | `chat` | [text.md](text.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
| `update` | `(root)` | [update.md](update.md) |
| `usage` | `coding-plan`, `free`, `freetier`, `stats`, `summary`, `token-plan` | [usage.md](usage.md) |
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
## Global flags
+23 -20
View File
@@ -7,21 +7,22 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ----------------------- | ------------------------------------------------------------------------- |
| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) |
| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) |
| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) |
| Command | Authentication | Description |
| ----------------------- | -------------- | ------------------------------------------------------------------------- |
| `bl knowledge chat` | API Key | Chat with a Bailian knowledge base (RAG Q&A with streaming) |
| `bl knowledge retrieve` | API Key | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) |
| `bl knowledge search` | API Key | Search a Bailian knowledge base (RAG semantic retrieval) |
## Command details
### `bl knowledge chat`
| Field | Value |
| --------------- | ------------------------------------------------------------ |
| **Name** | `knowledge chat` |
| **Description** | Chat with a Bailian knowledge base (RAG Q&A with streaming) |
| **Usage** | `bl knowledge chat --message <text> --agent-id <id> [flags]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------ |
| **Name** | `knowledge chat` |
| **Description** | Chat with a Bailian knowledge base (RAG Q&A with streaming) |
| **Authentication** | API Key |
| **Usage** | `bl knowledge chat --message <text> --agent-id <id> [flags]` |
#### Flags
@@ -57,11 +58,12 @@ bl knowledge chat --message "Describe these images" --image https://example.com/
### `bl knowledge retrieve`
| Field | Value |
| --------------- | ------------------------------------------------------------------------- |
| **Name** | `knowledge retrieve` |
| **Description** | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) |
| **Usage** | `bl knowledge retrieve --index-id <id> --query <text> [flags]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------- |
| **Name** | `knowledge retrieve` |
| **Description** | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) |
| **Authentication** | API Key |
| **Usage** | `bl knowledge retrieve --index-id <id> --query <text> [flags]` |
#### Flags
@@ -92,11 +94,12 @@ bl knowledge retrieve --index-id idx_xxx --query "RAG retrieval" --rerank --rera
### `bl knowledge search`
| Field | Value |
| --------------- | ------------------------------------------------------------ |
| **Name** | `knowledge search` |
| **Description** | Search a Bailian knowledge base (RAG semantic retrieval) |
| **Usage** | `bl knowledge search --query <text> --agent-id <id> [flags]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------ |
| **Name** | `knowledge search` |
| **Description** | Search a Bailian knowledge base (RAG semantic retrieval) |
| **Authentication** | API Key |
| **Usage** | `bl knowledge search --query <text> --agent-id <id> [flags]` |
#### Flags
+38 -35
View File
@@ -7,33 +7,34 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `bl mcp call` | Call a tool on an MCP server (tools/call) |
| `bl mcp list` | List MCP servers activated under your Bailian account |
| `bl mcp tools` | List tools exposed by an MCP server (tools/list) |
| Command | Authentication | Description |
| -------------- | -------------- | ----------------------------------------------------- |
| `bl mcp call` | API Key | Call a tool on an MCP server (tools/call) |
| `bl mcp list` | Console | List MCP servers activated under your Bailian account |
| `bl mcp tools` | API Key | List tools exposed by an MCP server (tools/list) |
## Command details
### `bl mcp call`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------- |
| **Name** | `mcp call` |
| **Description** | Call a tool on an MCP server (tools/call) |
| **Usage** | `bl mcp call --target <server.tool> [--arg k=v ...] [--json '{...}'] [--url <url>]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------------------- |
| **Name** | `mcp call` |
| **Description** | Call a tool on an MCP server (tools/call) |
| **Authentication** | API Key |
| **Usage** | `bl mcp call --target <server.tool> [--arg k=v ...] [--json '{...}'] [--url <url>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------ | ------ | -------- | ---------------------------------------------------------------------------------------- |
| `--target <server.tool>` | string | yes | Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection |
| `--arg <kv>` | array | no | Tool argument (repeatable). Values parsed as JSON if possible, else string. |
| `--json <obj>` | string | no | Full arguments object as JSON; merged with --arg (arg wins). |
| `--query <text>` | string | no | Shortcut for --arg query=<text> (mirrors many DashScope MCP tools). |
| `--url <url>` | string | no | Override the MCP endpoint URL (for non-Bailian servers) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| ------------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- |
| `--target <server.tool>` | string | yes | Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection |
| `--arg <kv>` | array | no | Tool argument (repeatable). Values parsed as JSON if possible, else string. |
| `--json <obj>` | string | no | Full arguments object as JSON; merged with --arg (arg wins). |
| `--query <text>` | string | no | Shortcut for --arg query=<text> (mirrors many DashScope MCP tools). |
| `--url <url>` | string | no | Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL. |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
@@ -51,11 +52,12 @@ bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3
### `bl mcp list`
| Field | Value |
| --------------- | ----------------------------------------------------- |
| **Name** | `mcp list` |
| **Description** | List MCP servers activated under your Bailian account |
| **Usage** | `bl mcp list [flags]` |
| Field | Value |
| ------------------ | ----------------------------------------------------- |
| **Name** | `mcp list` |
| **Description** | List MCP servers activated under your Bailian account |
| **Authentication** | Console |
| **Usage** | `bl mcp list [flags]` |
#### Flags
@@ -86,20 +88,21 @@ bl mcp list --output json
### `bl mcp tools`
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `mcp tools` |
| **Description** | List tools exposed by an MCP server (tools/list) |
| **Usage** | `bl mcp tools --server <code> [--url <url>]` |
| Field | Value |
| ------------------ | ------------------------------------------------ |
| **Name** | `mcp tools` |
| **Description** | List tools exposed by an MCP server (tools/list) |
| **Authentication** | API Key |
| **Usage** | `bl mcp tools --server <code> [--url <url>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | ------------------------------------------------------- |
| `--server <code>` | string | yes | Server code from `mcp list` (e.g. market-cmapi00073529) |
| `--url <url>` | string | no | Override the MCP endpoint URL (for non-Bailian servers) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- |
| `--server <code>` | string | yes | Server code from `mcp list` (e.g. market-cmapi00073529) |
| `--url <url>` | string | no | Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL. |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
+51 -44
View File
@@ -7,25 +7,26 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| -------------------------- | ------------------------------------------------- |
| `bl memory add` | Add memory from messages or custom content |
| `bl memory delete` | Delete a memory node |
| `bl memory list` | List memory nodes for a user |
| `bl memory profile create` | Create a user profile schema for memory profiling |
| `bl memory profile get` | Get user profile by schema ID and user ID |
| `bl memory search` | Search memory nodes by query or messages |
| `bl memory update` | Update a memory node content |
| Command | Authentication | Description |
| -------------------------- | -------------- | ------------------------------------------------- |
| `bl memory add` | API Key | Add memory from messages or custom content |
| `bl memory delete` | API Key | Delete a memory node |
| `bl memory list` | API Key | List memory nodes for a user |
| `bl memory profile create` | API Key | Create a user profile schema for memory profiling |
| `bl memory profile get` | API Key | Get user profile by schema ID and user ID |
| `bl memory search` | API Key | Search memory nodes by query or messages |
| `bl memory update` | API Key | Update a memory node content |
## Command details
### `bl memory add`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------- |
| **Name** | `memory add` |
| **Description** | Add memory from messages or custom content |
| **Usage** | `bl memory add --user-id <id> [--messages <json>] [--content <text>] [flags]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------------- |
| **Name** | `memory add` |
| **Description** | Add memory from messages or custom content |
| **Authentication** | API Key |
| **Usage** | `bl memory add --user-id <id> [--messages <json>] [--content <text>] [flags]` |
#### Flags
@@ -55,11 +56,12 @@ bl memory add --user-id user1 --content "Lives in Beijing" --profile-schema sche
### `bl memory delete`
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `memory delete` |
| **Description** | Delete a memory node |
| **Usage** | `bl memory delete --node-id <id> --user-id <id>` |
| Field | Value |
| ------------------ | ------------------------------------------------ |
| **Name** | `memory delete` |
| **Description** | Delete a memory node |
| **Authentication** | API Key |
| **Usage** | `bl memory delete --node-id <id> --user-id <id>` |
#### Flags
@@ -79,11 +81,12 @@ bl memory delete --node-id node_xxx --user-id user1
### `bl memory list`
| Field | Value |
| --------------- | --------------------------------------- |
| **Name** | `memory list` |
| **Description** | List memory nodes for a user |
| **Usage** | `bl memory list --user-id <id> [flags]` |
| Field | Value |
| ------------------ | --------------------------------------- |
| **Name** | `memory list` |
| **Description** | List memory nodes for a user |
| **Authentication** | API Key |
| **Usage** | `bl memory list --user-id <id> [flags]` |
#### Flags
@@ -108,11 +111,12 @@ bl memory list --user-id user1 --page-size 20 --page 2
### `bl memory profile create`
| Field | Value |
| --------------- | -------------------------------------------------------------------- |
| **Name** | `memory profile create` |
| **Description** | Create a user profile schema for memory profiling |
| **Usage** | `bl memory profile create --name <name> --attributes <json> [flags]` |
| Field | Value |
| ------------------ | -------------------------------------------------------------------- |
| **Name** | `memory profile create` |
| **Description** | Create a user profile schema for memory profiling |
| **Authentication** | API Key |
| **Usage** | `bl memory profile create --name <name> --attributes <json> [flags]` |
#### Flags
@@ -132,11 +136,12 @@ bl memory profile create --name "user_basic" --attributes '[{"name":"age","descr
### `bl memory profile get`
| Field | Value |
| --------------- | ------------------------------------------------------- |
| **Name** | `memory profile get` |
| **Description** | Get user profile by schema ID and user ID |
| **Usage** | `bl memory profile get --schema-id <id> --user-id <id>` |
| Field | Value |
| ------------------ | ------------------------------------------------------- |
| **Name** | `memory profile get` |
| **Description** | Get user profile by schema ID and user ID |
| **Authentication** | API Key |
| **Usage** | `bl memory profile get --schema-id <id> --user-id <id>` |
#### Flags
@@ -155,11 +160,12 @@ bl memory profile get --schema-id schema_xxx --user-id user1
### `bl memory search`
| Field | Value |
| --------------- | ---------------------------------------------------------- |
| **Name** | `memory search` |
| **Description** | Search memory nodes by query or messages |
| **Usage** | `bl memory search --user-id <id> [--query <text>] [flags]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------- |
| **Name** | `memory search` |
| **Description** | Search memory nodes by query or messages |
| **Authentication** | API Key |
| **Usage** | `bl memory search --user-id <id> [--query <text>] [flags]` |
#### Flags
@@ -185,11 +191,12 @@ bl memory search --user-id user1 --messages '[{"role":"user","content":"recommen
### `bl memory update`
| Field | Value |
| --------------- | ----------------------------------------------------------------- |
| **Name** | `memory update` |
| **Description** | Update a memory node content |
| **Usage** | `bl memory update --node-id <id> --user-id <id> --content <text>` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------- |
| **Name** | `memory update` |
| **Description** | Update a memory node content |
| **Authentication** | API Key |
| **Usage** | `bl memory update --node-id <id> --user-id <id> --content <text>` |
#### Flags
+9 -8
View File
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| --------------- | ---------------------------------------------------------------------------------- |
| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace |
| Command | Authentication | Description |
| --------------- | -------------- | ---------------------------------------------------------------------------------- |
| `bl model list` | Console | Browse model families or show detailed model info in the Bailian model marketplace |
## Command details
### `bl model list`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `model list` |
| **Description** | Browse model families or show detailed model info in the Bailian model marketplace |
| **Usage** | `bl model list [--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `model list` |
| **Description** | Browse model families or show detailed model info in the Bailian model marketplace |
| **Authentication** | Console |
| **Usage** | `bl model list [--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]` |
#### Flags
+16 -14
View File
@@ -7,20 +7,21 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ---------------------- | ------------------------------------------------ |
| `bl pipeline run` | Run a pipeline workflow definition |
| `bl pipeline validate` | Validate a pipeline definition without executing |
| Command | Authentication | Description |
| ---------------------- | -------------- | ------------------------------------------------ |
| `bl pipeline run` | No Auth | Run a pipeline workflow definition |
| `bl pipeline validate` | No Auth | Validate a pipeline definition without executing |
## Command details
### `bl pipeline run`
| Field | Value |
| --------------- | --------------------------------------- |
| **Name** | `pipeline run` |
| **Description** | Run a pipeline workflow definition |
| **Usage** | `bl pipeline run --file <path> [flags]` |
| Field | Value |
| ------------------ | --------------------------------------- |
| **Name** | `pipeline run` |
| **Description** | Run a pipeline workflow definition |
| **Authentication** | No Auth |
| **Usage** | `bl pipeline run --file <path> [flags]` |
#### Flags
@@ -57,11 +58,12 @@ bl pipeline run --file workflow.yaml --output json
### `bl pipeline validate`
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `pipeline validate` |
| **Description** | Validate a pipeline definition without executing |
| **Usage** | `bl pipeline validate --file <path>` |
| Field | Value |
| ------------------ | ------------------------------------------------ |
| **Name** | `pipeline validate` |
| **Description** | Validate a pipeline definition without executing |
| **Authentication** | No Auth |
| **Usage** | `bl pipeline validate --file <path>` |
#### Flags
+30 -26
View File
@@ -7,22 +7,23 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------- | ------------------------------------------------------ |
| `bl plugin install` | Install or upgrade an allowlisted Command Pack |
| `bl plugin link` | Link an allowlisted local Command Pack for development |
| `bl plugin list` | List installed Command Packs and their load status |
| `bl plugin remove` | Remove an installed Command Pack |
| Command | Authentication | Description |
| ------------------- | -------------- | ------------------------------------------------------ |
| `bl plugin install` | No Auth | Install or upgrade an allowlisted Command Pack |
| `bl plugin link` | No Auth | Link an allowlisted local Command Pack for development |
| `bl plugin list` | No Auth | List installed Command Packs and their load status |
| `bl plugin remove` | No Auth | Remove an installed Command Pack |
## Command details
### `bl plugin install`
| Field | Value |
| --------------- | ---------------------------------------------- |
| **Name** | `plugin install` |
| **Description** | Install or upgrade an allowlisted Command Pack |
| **Usage** | `bl plugin install --package <name[@version]>` |
| Field | Value |
| ------------------ | ---------------------------------------------- |
| **Name** | `plugin install` |
| **Description** | Install or upgrade an allowlisted Command Pack |
| **Authentication** | No Auth |
| **Usage** | `bl plugin install --package <name[@version]>` |
#### Flags
@@ -42,11 +43,12 @@ bl plugin install --package @ali/bailian-plugin-agent@beta
### `bl plugin link`
| Field | Value |
| --------------- | ------------------------------------------------------ |
| **Name** | `plugin link` |
| **Description** | Link an allowlisted local Command Pack for development |
| **Usage** | `bl plugin link --path <directory>` |
| Field | Value |
| ------------------ | ------------------------------------------------------ |
| **Name** | `plugin link` |
| **Description** | Link an allowlisted local Command Pack for development |
| **Authentication** | No Auth |
| **Usage** | `bl plugin link --path <directory>` |
#### Flags
@@ -62,11 +64,12 @@ bl plugin link --path ../bailian-plugin-agent
### `bl plugin list`
| Field | Value |
| --------------- | -------------------------------------------------- |
| **Name** | `plugin list` |
| **Description** | List installed Command Packs and their load status |
| **Usage** | `bl plugin list` |
| Field | Value |
| ------------------ | -------------------------------------------------- |
| **Name** | `plugin list` |
| **Description** | List installed Command Packs and their load status |
| **Authentication** | No Auth |
| **Usage** | `bl plugin list` |
#### Flags
@@ -84,11 +87,12 @@ bl plugin list --output json
### `bl plugin remove`
| Field | Value |
| --------------- | ----------------------------------- |
| **Name** | `plugin remove` |
| **Description** | Remove an installed Command Pack |
| **Usage** | `bl plugin remove --name <package>` |
| Field | Value |
| ------------------ | ----------------------------------- |
| **Name** | `plugin remove` |
| **Description** | Remove an installed Command Pack |
| **Authentication** | No Auth |
| **Usage** | `bl plugin remove --name <package>` |
#### Flags
+30 -26
View File
@@ -7,22 +7,23 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------ | --------------------------------------- |
| `bl quota check` | Check current usage against rate limits |
| `bl quota history` | View quota change history |
| `bl quota list` | View model RPM/TPM rate limits |
| `bl quota request` | Request a temporary quota increase |
| Command | Authentication | Description |
| ------------------ | -------------- | --------------------------------------- |
| `bl quota check` | Console | Check current usage against rate limits |
| `bl quota history` | Console | View quota change history |
| `bl quota list` | Console | View model RPM/TPM rate limits |
| `bl quota request` | Console | Request a temporary quota increase |
## Command details
### `bl quota check`
| Field | Value |
| --------------- | ------------------------------------------ |
| **Name** | `quota check` |
| **Description** | Check current usage against rate limits |
| **Usage** | `bl quota check [--model <model>] [flags]` |
| Field | Value |
| ------------------ | ------------------------------------------ |
| **Name** | `quota check` |
| **Description** | Check current usage against rate limits |
| **Authentication** | Console |
| **Usage** | `bl quota check [--model <model>] [flags]` |
#### Flags
@@ -59,11 +60,12 @@ bl quota check --output json
### `bl quota history`
| Field | Value |
| --------------- | -------------------------- |
| **Name** | `quota history` |
| **Description** | View quota change history |
| **Usage** | `bl quota history [flags]` |
| Field | Value |
| ------------------ | -------------------------- |
| **Name** | `quota history` |
| **Description** | View quota change history |
| **Authentication** | Console |
| **Usage** | `bl quota history [flags]` |
#### Flags
@@ -101,11 +103,12 @@ bl quota history --output json
### `bl quota list`
| Field | Value |
| --------------- | ----------------------------------------- |
| **Name** | `quota list` |
| **Description** | View model RPM/TPM rate limits |
| **Usage** | `bl quota list [--model <model>] [flags]` |
| Field | Value |
| ------------------ | ----------------------------------------- |
| **Name** | `quota list` |
| **Description** | View model RPM/TPM rate limits |
| **Authentication** | Console |
| **Usage** | `bl quota list [--model <model>] [flags]` |
#### Flags
@@ -137,11 +140,12 @@ bl quota list --output json
### `bl quota request`
| Field | Value |
| --------------- | -------------------------------------------------------- |
| **Name** | `quota request` |
| **Description** | Request a temporary quota increase |
| **Usage** | `bl quota request --model <model> --tpm <value> [flags]` |
| Field | Value |
| ------------------ | -------------------------------------------------------- |
| **Name** | `quota request` |
| **Description** | Request a temporary quota increase |
| **Authentication** | Console |
| **Usage** | `bl quota request --model <model> --tpm <value> [flags]` |
#### Flags
+9 -8
View File
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| --------------- | ---------------------------------------------------- |
| `bl search web` | Search the web using DashScope MCP WebSearch service |
| Command | Authentication | Description |
| --------------- | -------------- | ---------------------------------------------------- |
| `bl search web` | API Key | Search the web using DashScope MCP WebSearch service |
## Command details
### `bl search web`
| Field | Value |
| --------------- | ---------------------------------------------------- |
| **Name** | `search web` |
| **Description** | Search the web using DashScope MCP WebSearch service |
| **Usage** | `bl search web --query <text> [flags]` |
| Field | Value |
| ------------------ | ---------------------------------------------------- |
| **Name** | `search web` |
| **Description** | Search the web using DashScope MCP WebSearch service |
| **Authentication** | API Key |
| **Usage** | `bl search web --query <text> [flags]` |
#### Flags
+37 -32
View File
@@ -7,23 +7,24 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ----------------- | ----------------------------------------------------------------------- |
| `bl skill add` | Install skills from the Bailian skill registry into local agents |
| `bl skill init` | Install all bailian-\* skills (one-shot bootstrap for new environments) |
| `bl skill list` | List registry skills and diff against local installs |
| `bl skill remove` | Remove locally installed skills (registry is untouched) |
| `bl skill update` | Update installed skills to the latest registry versions |
| Command | Authentication | Description |
| ----------------- | -------------- | ----------------------------------------------------------------------- |
| `bl skill add` | No Auth | Install skills from the Bailian skill registry into local agents |
| `bl skill init` | No Auth | Install all bailian-\* skills (one-shot bootstrap for new environments) |
| `bl skill list` | No Auth | List registry skills and diff against local installs |
| `bl skill remove` | No Auth | Remove locally installed skills (registry is untouched) |
| `bl skill update` | No Auth | Update installed skills to the latest registry versions |
## Command details
### `bl skill add`
| Field | Value |
| --------------- | ---------------------------------------------------------------- |
| **Name** | `skill add` |
| **Description** | Install skills from the Bailian skill registry into local agents |
| **Usage** | `bl skill add --all \| --name <name,...>` |
| Field | Value |
| ------------------ | ---------------------------------------------------------------- |
| **Name** | `skill add` |
| **Description** | Install skills from the Bailian skill registry into local agents |
| **Authentication** | No Auth |
| **Usage** | `bl skill add --all \| --name <name,...>` |
#### Flags
@@ -44,11 +45,12 @@ bl skill add --name spark-video,bailian-model-recommend
### `bl skill init`
| Field | Value |
| --------------- | ----------------------------------------------------------------------- |
| **Name** | `skill init` |
| **Description** | Install all bailian-\* skills (one-shot bootstrap for new environments) |
| **Usage** | `bl skill init` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------- |
| **Name** | `skill init` |
| **Description** | Install all bailian-\* skills (one-shot bootstrap for new environments) |
| **Authentication** | No Auth |
| **Usage** | `bl skill init` |
#### Flags
@@ -67,11 +69,12 @@ bl skill init
### `bl skill list`
| Field | Value |
| --------------- | ---------------------------------------------------- |
| **Name** | `skill list` |
| **Description** | List registry skills and diff against local installs |
| **Usage** | `bl skill list` |
| Field | Value |
| ------------------ | ---------------------------------------------------- |
| **Name** | `skill list` |
| **Description** | List registry skills and diff against local installs |
| **Authentication** | No Auth |
| **Usage** | `bl skill list` |
#### Flags
@@ -93,11 +96,12 @@ bl skill list --output json
### `bl skill remove`
| Field | Value |
| --------------- | ------------------------------------------------------- |
| **Name** | `skill remove` |
| **Description** | Remove locally installed skills (registry is untouched) |
| **Usage** | `bl skill remove --name <all\|name,...>` |
| Field | Value |
| ------------------ | ------------------------------------------------------- |
| **Name** | `skill remove` |
| **Description** | Remove locally installed skills (registry is untouched) |
| **Authentication** | No Auth |
| **Usage** | `bl skill remove --name <all\|name,...>` |
#### Flags
@@ -117,11 +121,12 @@ bl skill remove --name all
### `bl skill update`
| Field | Value |
| --------------- | ------------------------------------------------------- |
| **Name** | `skill update` |
| **Description** | Update installed skills to the latest registry versions |
| **Usage** | `bl skill update [--all] [--name <name,...>]` |
| Field | Value |
| ------------------ | ------------------------------------------------------- |
| **Name** | `skill update` |
| **Description** | Update installed skills to the latest registry versions |
| **Authentication** | No Auth |
| **Usage** | `bl skill update [--all] [--name <name,...>]` |
#### Flags
+9 -8
View File
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| -------------- | ----------------------------------------------------- |
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) |
| Command | Authentication | Description |
| -------------- | -------------- | ----------------------------------------------------- |
| `bl text chat` | API Key | Send a chat completion (OpenAI compatible, DashScope) |
## Command details
### `bl text chat`
| Field | Value |
| --------------- | ----------------------------------------------------- |
| **Name** | `text chat` |
| **Description** | Send a chat completion (OpenAI compatible, DashScope) |
| **Usage** | `bl text chat --message <text> [flags]` |
| Field | Value |
| ------------------ | ----------------------------------------------------- |
| **Name** | `text chat` |
| **Description** | Send a chat completion (OpenAI compatible, DashScope) |
| **Authentication** | API Key |
| **Usage** | `bl text chat --message <text> [flags]` |
#### Flags
+30 -26
View File
@@ -7,22 +7,23 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ---------------------------- | ----------------------------------------- |
| `bl token-plan add-member` | Add a member to a Token Plan organization |
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members |
| `bl token-plan create-key` | Create a Token Plan API key for a seat |
| `bl token-plan list-seats` | List Token Plan subscription seat details |
| Command | Authentication | Description |
| ---------------------------- | -------------- | ----------------------------------------- |
| `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization |
| `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members |
| `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat |
| `bl token-plan list-seats` | AK/SK | List Token Plan subscription seat details |
## Command details
### `bl token-plan add-member`
| Field | Value |
| --------------- | ---------------------------------------------------------------------- |
| **Name** | `token-plan add-member` |
| **Description** | Add a member to a Token Plan organization |
| **Usage** | `bl token-plan add-member --account-name <name> --org-id <id> [flags]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------------------- |
| **Name** | `token-plan add-member` |
| **Description** | Add a member to a Token Plan organization |
| **Authentication** | AK/SK |
| **Usage** | `bl token-plan add-member --account-name <name> --org-id <id> [flags]` |
#### Flags
@@ -54,11 +55,12 @@ bl token-plan add-member --account-name member1 --org-id org_123 --spec-type sta
### `bl token-plan assign-seats`
| Field | Value |
| --------------- | --------------------------------------------------------------------------------------------- |
| **Name** | `token-plan assign-seats` |
| **Description** | Batch assign Token Plan seats to members |
| **Usage** | `bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]` |
| Field | Value |
| ------------------ | --------------------------------------------------------------------------------------------- |
| **Name** | `token-plan assign-seats` |
| **Description** | Batch assign Token Plan seats to members |
| **Authentication** | AK/SK |
| **Usage** | `bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]` |
#### Flags
@@ -86,11 +88,12 @@ bl token-plan assign-seats --workspace-id ws_456 --seat-type pro --account-id ac
### `bl token-plan create-key`
| Field | Value |
| --------------- | ------------------------------------------------------------------------ |
| **Name** | `token-plan create-key` |
| **Description** | Create a Token Plan API key for a seat |
| **Usage** | `bl token-plan create-key --account-id <id> --workspace-id <id> [flags]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------ |
| **Name** | `token-plan create-key` |
| **Description** | Create a Token Plan API key for a seat |
| **Authentication** | AK/SK |
| **Usage** | `bl token-plan create-key --account-id <id> --workspace-id <id> [flags]` |
#### Flags
@@ -117,11 +120,12 @@ bl token-plan create-key --account-id acc_123 --workspace-id ws_456 --descriptio
### `bl token-plan list-seats`
| Field | Value |
| --------------- | ----------------------------------------- |
| **Name** | `token-plan list-seats` |
| **Description** | List Token Plan subscription seat details |
| **Usage** | `bl token-plan list-seats [flags]` |
| Field | Value |
| ------------------ | ----------------------------------------- |
| **Name** | `token-plan list-seats` |
| **Description** | List Token Plan subscription seat details |
| **Authentication** | AK/SK |
| **Usage** | `bl token-plan list-seats [flags]` |
#### Flags
+9 -8
View File
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ----------- | --------------------------------------------------- |
| `bl update` | Update the CLI to the latest or a specified version |
| Command | Authentication | Description |
| ----------- | -------------- | --------------------------------------------------- |
| `bl update` | No Auth | Update the CLI to the latest or a specified version |
## Command details
### `bl update`
| Field | Value |
| --------------- | --------------------------------------------------- |
| **Name** | `update` |
| **Description** | Update the CLI to the latest or a specified version |
| **Usage** | `bl update [--to <version>]` |
| Field | Value |
| ------------------ | --------------------------------------------------- |
| **Name** | `update` |
| **Description** | Update the CLI to the latest or a specified version |
| **Authentication** | No Auth |
| **Usage** | `bl update [--to <version>]` |
#### Flags
+88 -26
View File
@@ -7,22 +7,53 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------- | ------------------------------------------------------------------------------------------ |
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) |
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
| `bl usage stats` | Query model usage statistics |
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview |
| Command | Authentication | Description |
| ---------------------- | -------------- | ------------------------------------------------------------------------------------------ |
| `bl usage coding-plan` | Console | Show Coding Plan quota usage |
| `bl usage free` | Console | Query free-tier quota for models (all models if --model is omitted) |
| `bl usage freetier` | Console | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
| `bl usage stats` | Console | Query model usage statistics |
| `bl usage summary` | Console | Show a unified usage summary: free-tier quota and recent usage overview |
| `bl usage token-plan` | Console | Show Token Plan quota usage |
## Command details
### `bl usage coding-plan`
| Field | Value |
| ------------------ | ------------------------------ |
| **Name** | `usage coding-plan` |
| **Description** | Show Coding Plan quota usage |
| **Authentication** | Console |
| **Usage** | `bl usage coding-plan [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Examples
```bash
bl usage coding-plan
```
```bash
bl usage coding-plan --output json
```
### `bl usage free`
| Field | Value |
| --------------- | ------------------------------------------------------------------- |
| **Name** | `usage free` |
| **Description** | Query free-tier quota for models (all models if --model is omitted) |
| **Usage** | `bl usage free [--model <model>[,model2,...]] [flags]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------- |
| **Name** | `usage free` |
| **Description** | Query free-tier quota for models (all models if --model is omitted) |
| **Authentication** | Console |
| **Usage** | `bl usage free [--model <model>[,model2,...]] [flags]` |
#### Flags
@@ -73,11 +104,12 @@ bl usage free --model qwen3-max --console-region cn-beijing
### `bl usage freetier`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------ |
| **Name** | `usage freetier` |
| **Description** | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
| **Usage** | `bl usage freetier <--model <model>[,model2,...] \| --all> [--off] [flags]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------ |
| **Name** | `usage freetier` |
| **Description** | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable |
| **Authentication** | Console |
| **Usage** | `bl usage freetier <--model <model>[,model2,...] \| --all> [--off] [flags]` |
#### Flags
@@ -120,11 +152,12 @@ bl usage freetier --off --all
### `bl usage stats`
| Field | Value |
| --------------- | ---------------------------------------------------------- |
| **Name** | `usage stats` |
| **Description** | Query model usage statistics |
| **Usage** | `bl usage stats [--model <model>] [--days <days>] [flags]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------- |
| **Name** | `usage stats` |
| **Description** | Query model usage statistics |
| **Authentication** | Console |
| **Usage** | `bl usage stats [--model <model>] [--days <days>] [flags]` |
#### Flags
@@ -170,11 +203,12 @@ bl usage stats --output json
### `bl usage summary`
| Field | Value |
| --------------- | ----------------------------------------------------------------------- |
| **Name** | `usage summary` |
| **Description** | Show a unified usage summary: free-tier quota and recent usage overview |
| **Usage** | `bl usage summary [--days <days>] [flags]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------- |
| **Name** | `usage summary` |
| **Description** | Show a unified usage summary: free-tier quota and recent usage overview |
| **Authentication** | Console |
| **Usage** | `bl usage summary [--days <days>] [flags]` |
#### Flags
@@ -199,3 +233,31 @@ bl usage summary --days 30
```bash
bl usage summary --output json
```
### `bl usage token-plan`
| Field | Value |
| ------------------ | ----------------------------- |
| **Name** | `usage token-plan` |
| **Description** | Show Token Plan quota usage |
| **Authentication** | Console |
| **Usage** | `bl usage token-plan [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Examples
```bash
bl usage token-plan
```
```bash
bl usage token-plan --output json
```
+16 -14
View File
@@ -7,20 +7,21 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------- | ----------------------------------------------------------- |
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services |
| `bl workspace list` | List all workspaces |
| Command | Authentication | Description |
| ------------------- | -------------- | ----------------------------------------------------------- |
| `bl workspace init` | No Auth | Initialize Bailian workspace and activate postpaid services |
| `bl workspace list` | Console | List all workspaces |
## Command details
### `bl workspace init`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------ |
| **Name** | `workspace init` |
| **Description** | Initialize Bailian workspace and activate postpaid services |
| **Usage** | `bl workspace init --access-key-id <id> --access-key-secret <secret> [--security-token <token>]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| **Name** | `workspace init` |
| **Description** | Initialize Bailian workspace and activate postpaid services |
| **Authentication** | No Auth |
| **Usage** | `bl workspace init --access-key-id <id> --access-key-secret <secret> [--security-token <token>]` |
#### Flags
@@ -38,11 +39,12 @@ bl workspace init --access-key-id LTAIxxxxx --access-key-secret xxxxx
### `bl workspace list`
| Field | Value |
| --------------- | --------------------------- |
| **Name** | `workspace list` |
| **Description** | List all workspaces |
| **Usage** | `bl workspace list [flags]` |
| Field | Value |
| ------------------ | --------------------------- |
| **Name** | `workspace list` |
| **Description** | List all workspaces |
| **Authentication** | Console |
| **Usage** | `bl workspace list [flags]` |
#### Flags
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-finetune
metadata:
version: "1.14.2"
version: "1.14.3"
requires:
bins: ["bl"]
description: >-
+37 -32
View File
@@ -7,23 +7,24 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| --------------------- | ------------------------------------------------------------------ |
| `bl dataset delete` | Delete a dataset file by ID |
| `bl dataset get` | Get details of a single dataset file |
| `bl dataset list` | List uploaded dataset files |
| `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian |
| `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading |
| Command | Authentication | Description |
| --------------------- | -------------- | ------------------------------------------------------------------ |
| `bl dataset delete` | API Key | Delete a dataset file by ID |
| `bl dataset get` | API Key | Get details of a single dataset file |
| `bl dataset list` | API Key | List uploaded dataset files |
| `bl dataset upload` | API Key | Upload a dataset file (.jsonl or .zip) to Bailian |
| `bl dataset validate` | No Auth | Locally validate a dataset file (.jsonl or .zip) without uploading |
## Command details
### `bl dataset delete`
| Field | Value |
| --------------- | ---------------------------------- |
| **Name** | `dataset delete` |
| **Description** | Delete a dataset file by ID |
| **Usage** | `bl dataset delete --file-id <id>` |
| Field | Value |
| ------------------ | ---------------------------------- |
| **Name** | `dataset delete` |
| **Description** | Delete a dataset file by ID |
| **Authentication** | API Key |
| **Usage** | `bl dataset delete --file-id <id>` |
#### Flags
@@ -45,11 +46,12 @@ bl dataset delete --file-id file-id-xxx --dry-run
### `bl dataset get`
| Field | Value |
| --------------- | ------------------------------------ |
| **Name** | `dataset get` |
| **Description** | Get details of a single dataset file |
| **Usage** | `bl dataset get --file-id <id>` |
| Field | Value |
| ------------------ | ------------------------------------ |
| **Name** | `dataset get` |
| **Description** | Get details of a single dataset file |
| **Authentication** | API Key |
| **Usage** | `bl dataset get --file-id <id>` |
#### Flags
@@ -71,11 +73,12 @@ bl dataset get --file-id file-xxx --output json
### `bl dataset list`
| Field | Value |
| --------------- | ------------------------------------------------------------------- |
| **Name** | `dataset list` |
| **Description** | List uploaded dataset files |
| **Usage** | `bl dataset list [--page <n>] [--page-size <n>] [--purpose <name>]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------- |
| **Name** | `dataset list` |
| **Description** | List uploaded dataset files |
| **Authentication** | API Key |
| **Usage** | `bl dataset list [--page <n>] [--page-size <n>] [--purpose <name>]` |
#### Flags
@@ -107,11 +110,12 @@ bl dataset list --output json
### `bl dataset upload`
| Field | Value |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `dataset upload` |
| **Description** | Upload a dataset file (.jsonl or .zip) to Bailian |
| **Usage** | `bl dataset upload --file <path> [--purpose <name>] [--schema <chatml\|dpo\|cpt\|tts\|image>] [--no-validate] [--full-validate]` |
| Field | Value |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `dataset upload` |
| **Description** | Upload a dataset file (.jsonl or .zip) to Bailian |
| **Authentication** | API Key |
| **Usage** | `bl dataset upload --file <path> [--purpose <name>] [--schema <chatml\|dpo\|cpt\|tts\|image>] [--no-validate] [--full-validate]` |
#### Flags
@@ -170,11 +174,12 @@ bl dataset upload --file train.jsonl --no-validate
### `bl dataset validate`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------- |
| **Name** | `dataset validate` |
| **Description** | Locally validate a dataset file (.jsonl or .zip) without uploading |
| **Usage** | `bl dataset validate --file <path> [--full-validate] [--schema <chatml\|dpo\|cpt\|tts\|image>]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| **Name** | `dataset validate` |
| **Description** | Locally validate a dataset file (.jsonl or .zip) without uploading |
| **Authentication** | No Auth |
| **Usage** | `bl dataset validate --file <path> [--full-validate] [--schema <chatml\|dpo\|cpt\|tts\|image>]` |
#### Flags
+65 -56
View File
@@ -7,27 +7,28 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------------ | --------------------------------------------------------- |
| `bl deploy audio create` | Create an audio (TTS) model deployment |
| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) |
| `bl deploy get` | Get details of a single model deployment |
| `bl deploy image create` | Create an image generation model deployment |
| `bl deploy list` | List model deployments |
| `bl deploy models` | List models available for deployment |
| `bl deploy scale` | Scale a deployment's capacity |
| `bl deploy text create` | Create a text model deployment |
| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) |
| Command | Authentication | Description |
| ------------------------ | -------------- | --------------------------------------------------------- |
| `bl deploy audio create` | API Key | Create an audio (TTS) model deployment |
| `bl deploy delete` | API Key | Delete a model deployment (must be STOPPED or FAILED) |
| `bl deploy get` | API Key | Get details of a single model deployment |
| `bl deploy image create` | API Key | Create an image generation model deployment |
| `bl deploy list` | API Key | List model deployments |
| `bl deploy models` | API Key | List models available for deployment |
| `bl deploy scale` | API Key | Scale a deployment's capacity |
| `bl deploy text create` | API Key | Create a text model deployment |
| `bl deploy update` | API Key | Update a deployment's rate limits (rpm_limit / tpm_limit) |
## Command details
### `bl deploy audio create`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy audio create` |
| **Description** | Create an audio (TTS) model deployment |
| **Usage** | `bl deploy audio create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy audio create` |
| **Description** | Create an audio (TTS) model deployment |
| **Authentication** | API Key |
| **Usage** | `bl deploy audio create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
#### Flags
@@ -83,11 +84,12 @@ bl deploy audio create --model my-cosyvoice-ft --name my-tts --dry-run
### `bl deploy delete`
| Field | Value |
| --------------- | ---------------------------------------------------------- |
| **Name** | `deploy delete` |
| **Description** | Delete a model deployment (must be STOPPED or FAILED) |
| **Usage** | `bl deploy delete --deployed-model <id> [--skip-precheck]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------- |
| **Name** | `deploy delete` |
| **Description** | Delete a model deployment (must be STOPPED or FAILED) |
| **Authentication** | API Key |
| **Usage** | `bl deploy delete --deployed-model <id> [--skip-precheck]` |
#### Flags
@@ -110,11 +112,12 @@ bl deploy delete --deployed-model dep-... --dry-run
### `bl deploy get`
| Field | Value |
| --------------- | ---------------------------------------- |
| **Name** | `deploy get` |
| **Description** | Get details of a single model deployment |
| **Usage** | `bl deploy get --deployed-model <id>` |
| Field | Value |
| ------------------ | ---------------------------------------- |
| **Name** | `deploy get` |
| **Description** | Get details of a single model deployment |
| **Authentication** | API Key |
| **Usage** | `bl deploy get --deployed-model <id>` |
#### Flags
@@ -136,11 +139,12 @@ bl deploy get --deployed-model qwen-plus-2025-12-01-b6d61c71 --output json
### `bl deploy image create`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy image create` |
| **Description** | Create an image generation model deployment |
| **Usage** | `bl deploy image create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy image create` |
| **Description** | Create an image generation model deployment |
| **Authentication** | API Key |
| **Usage** | `bl deploy image create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
#### Flags
@@ -196,11 +200,12 @@ bl deploy image create --model my-wan-ft --name my-wan --dry-run
### `bl deploy list`
| Field | Value |
| --------------- | -------------------------------------------------------------- |
| **Name** | `deploy list` |
| **Description** | List model deployments |
| **Usage** | `bl deploy list [--page <n>] [--page-size <n>] [--status <s>]` |
| Field | Value |
| ------------------ | -------------------------------------------------------------- |
| **Name** | `deploy list` |
| **Description** | List model deployments |
| **Authentication** | API Key |
| **Usage** | `bl deploy list [--page <n>] [--page-size <n>] [--status <s>]` |
#### Flags
@@ -228,11 +233,12 @@ bl deploy list --page-size 20 --output json
### `bl deploy models`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------- |
| **Name** | `deploy models` |
| **Description** | List models available for deployment |
| **Usage** | `bl deploy models [--page <n>] [--page-size <n>] [--catalog-version <v>] [--source <custom\|public>]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| **Name** | `deploy models` |
| **Description** | List models available for deployment |
| **Authentication** | API Key |
| **Usage** | `bl deploy models [--page <n>] [--page-size <n>] [--catalog-version <v>] [--source <custom\|public>]` |
#### Flags
@@ -265,11 +271,12 @@ bl deploy models --catalog-version v1.0 --output json
### `bl deploy scale`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------- |
| **Name** | `deploy scale` |
| **Description** | Scale a deployment's capacity |
| **Usage** | `bl deploy scale --deployed-model <id> --capacity <n> [--input-tpm <n>] [--output-tpm <n>]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------- |
| **Name** | `deploy scale` |
| **Description** | Scale a deployment's capacity |
| **Authentication** | API Key |
| **Usage** | `bl deploy scale --deployed-model <id> --capacity <n> [--input-tpm <n>] [--output-tpm <n>]` |
#### Flags
@@ -294,11 +301,12 @@ bl deploy scale --deployed-model dep-... --capacity 2
### `bl deploy text create`
| Field | Value |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy text create` |
| **Description** | Create a text model deployment |
| **Usage** | `bl deploy text create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `deploy text create` |
| **Description** | Create a text model deployment |
| **Authentication** | API Key |
| **Usage** | `bl deploy text create --model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]` |
#### Flags
@@ -358,11 +366,12 @@ bl deploy text create --model qwen3-8b --name my-qwen3 --plan mu --deploy-spec M
### `bl deploy update`
| Field | Value |
| --------------- | ---------------------------------------------------------------------------- |
| **Name** | `deploy update` |
| **Description** | Update a deployment's rate limits (rpm_limit / tpm_limit) |
| **Usage** | `bl deploy update --deployed-model <id> [--rpm-limit <n>] [--tpm-limit <n>]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------------------------- |
| **Name** | `deploy update` |
| **Description** | Update a deployment's rate limits (rpm_limit / tpm_limit) |
| **Authentication** | API Key |
| **Usage** | `bl deploy update --deployed-model <id> [--rpm-limit <n>] [--tpm-limit <n>]` |
#### Flags
+86 -74
View File
@@ -7,30 +7,31 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `bl finetune audio create` | Create an audio TTS model fine-tune job (sft-lora) |
| `bl finetune cancel` | Cancel a running fine-tune job |
| `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
| `bl finetune checkpoints` | List checkpoints produced by a fine-tune job |
| `bl finetune delete` | Delete a fine-tune job record |
| `bl finetune export` | Publish a checkpoint as a deployable model |
| `bl finetune get` | Get details of a single fine-tune job |
| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) |
| `bl finetune list` | List fine-tune jobs |
| `bl finetune logs` | Fetch training logs for a fine-tune job |
| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
| Command | Authentication | Description |
| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `bl finetune audio create` | API Key | Create an audio TTS model fine-tune job (sft-lora) |
| `bl finetune cancel` | API Key | Cancel a running fine-tune job |
| `bl finetune capability` | No Auth | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
| `bl finetune checkpoints` | API Key | List checkpoints produced by a fine-tune job |
| `bl finetune delete` | API Key | Delete a fine-tune job record |
| `bl finetune export` | API Key | Publish a checkpoint as a deployable model |
| `bl finetune get` | API Key | Get details of a single fine-tune job |
| `bl finetune image create` | API Key | Create an image generation model fine-tune job (sft-lora) |
| `bl finetune list` | API Key | List fine-tune jobs |
| `bl finetune logs` | API Key | Fetch training logs for a fine-tune job |
| `bl finetune text create` | API Key | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
| `bl finetune watch` | API Key | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
## Command details
### `bl finetune audio create`
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune audio create` |
| **Description** | Create an audio TTS model fine-tune job (sft-lora) |
| **Usage** | `bl finetune audio create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune audio create` |
| **Description** | Create an audio TTS model fine-tune job (sft-lora) |
| **Authentication** | API Key |
| **Usage** | `bl finetune audio create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>]` |
#### Flags
@@ -79,11 +80,12 @@ bl finetune audio create --model cosyvoice-v3-flash --datasets ./audio.zip --dry
### `bl finetune cancel`
| Field | Value |
| --------------- | ---------------------------------- |
| **Name** | `finetune cancel` |
| **Description** | Cancel a running fine-tune job |
| **Usage** | `bl finetune cancel --job-id <id>` |
| Field | Value |
| ------------------ | ---------------------------------- |
| **Name** | `finetune cancel` |
| **Description** | Cancel a running fine-tune job |
| **Authentication** | API Key |
| **Usage** | `bl finetune cancel --job-id <id>` |
#### Flags
@@ -110,11 +112,12 @@ bl finetune cancel --job-id ft-xxx --dry-run
### `bl finetune capability`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune capability` |
| **Description** | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
| **Usage** | `bl finetune capability --model <m> \| --training-type <t>` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune capability` |
| **Description** | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) |
| **Authentication** | No Auth |
| **Usage** | `bl finetune capability --model <m> \| --training-type <t>` |
#### Flags
@@ -150,11 +153,12 @@ bl finetune capability --training-type sft --quiet
### `bl finetune checkpoints`
| Field | Value |
| --------------- | -------------------------------------------- |
| **Name** | `finetune checkpoints` |
| **Description** | List checkpoints produced by a fine-tune job |
| **Usage** | `bl finetune checkpoints --job-id <id>` |
| Field | Value |
| ------------------ | -------------------------------------------- |
| **Name** | `finetune checkpoints` |
| **Description** | List checkpoints produced by a fine-tune job |
| **Authentication** | API Key |
| **Usage** | `bl finetune checkpoints --job-id <id>` |
#### Flags
@@ -181,11 +185,12 @@ bl finetune checkpoints --job-id ft-xxx --output json
### `bl finetune delete`
| Field | Value |
| --------------- | ---------------------------------- |
| **Name** | `finetune delete` |
| **Description** | Delete a fine-tune job record |
| **Usage** | `bl finetune delete --job-id <id>` |
| Field | Value |
| ------------------ | ---------------------------------- |
| **Name** | `finetune delete` |
| **Description** | Delete a fine-tune job record |
| **Authentication** | API Key |
| **Usage** | `bl finetune delete --job-id <id>` |
#### Flags
@@ -212,11 +217,12 @@ bl finetune delete --job-id ft-xxx --dry-run
### `bl finetune export`
| Field | Value |
| --------------- | -------------------------------------------------------------------------- |
| **Name** | `finetune export` |
| **Description** | Publish a checkpoint as a deployable model |
| **Usage** | `bl finetune export --job-id <id> --checkpoint <name> --model-name <name>` |
| Field | Value |
| ------------------ | -------------------------------------------------------------------------- |
| **Name** | `finetune export` |
| **Description** | Publish a checkpoint as a deployable model |
| **Authentication** | API Key |
| **Usage** | `bl finetune export --job-id <id> --checkpoint <name> --model-name <name>` |
#### Flags
@@ -242,11 +248,12 @@ bl finetune export --job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft
### `bl finetune get`
| Field | Value |
| --------------- | ------------------------------------- |
| **Name** | `finetune get` |
| **Description** | Get details of a single fine-tune job |
| **Usage** | `bl finetune get --job-id <id>` |
| Field | Value |
| ------------------ | ------------------------------------- |
| **Name** | `finetune get` |
| **Description** | Get details of a single fine-tune job |
| **Authentication** | API Key |
| **Usage** | `bl finetune get --job-id <id>` |
#### Flags
@@ -268,11 +275,12 @@ bl finetune get --job-id ft-xxx --output json
### `bl finetune image create`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Name** | `finetune image create` |
| **Description** | Create an image generation model fine-tune job (sft-lora) |
| **Usage** | `bl finetune image create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i\|i2i>] [--learning-rate <str>]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Name** | `finetune image create` |
| **Description** | Create an image generation model fine-tune job (sft-lora) |
| **Authentication** | API Key |
| **Usage** | `bl finetune image create --model <model> --datasets <id\|path> [--validations <id\|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i\|i2i>] [--learning-rate <str>]` |
#### Flags
@@ -329,11 +337,12 @@ bl finetune image create --model wan2.7-image-pro --datasets ./images.zip --dry-
### `bl finetune list`
| Field | Value |
| --------------- | ---------------------------------------------------------------- |
| **Name** | `finetune list` |
| **Description** | List fine-tune jobs |
| **Usage** | `bl finetune list [--page <n>] [--page-size <n>] [--status <s>]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------------- |
| **Name** | `finetune list` |
| **Description** | List fine-tune jobs |
| **Authentication** | API Key |
| **Usage** | `bl finetune list [--page <n>] [--page-size <n>] [--status <s>]` |
#### Flags
@@ -361,11 +370,12 @@ bl finetune list --page-size 20 --output json
### `bl finetune logs`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------- |
| **Name** | `finetune logs` |
| **Description** | Fetch training logs for a fine-tune job |
| **Usage** | `bl finetune logs --job-id <id> [--page <n>] [--page-size <n>] [--search <keyword>] [--tail <n>]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------- |
| **Name** | `finetune logs` |
| **Description** | Fetch training logs for a fine-tune job |
| **Authentication** | API Key |
| **Usage** | `bl finetune logs --job-id <id> [--page <n>] [--page-size <n>] [--search <keyword>] [--tail <n>]` |
#### Flags
@@ -407,11 +417,12 @@ bl finetune logs --job-id ft-xxx --search checkpoint --tail 5
### `bl finetune text create`
| Field | Value |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune text create` |
| **Description** | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
| **Usage** | `bl finetune text create --model <model> --datasets <id\|path,...> [--validations <id\|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft\|sft-lora\|dpo\|dpo-lora\|cpt>]` |
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune text create` |
| **Description** | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) |
| **Authentication** | API Key |
| **Usage** | `bl finetune text create --model <model> --datasets <id\|path,...> [--validations <id\|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft\|sft-lora\|dpo\|dpo-lora\|cpt>]` |
#### Flags
@@ -486,11 +497,12 @@ bl finetune text create --model qwen3-8b --datasets file-xxx --dry-run
### `bl finetune watch`
| Field | Value |
| --------------- | ---------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune watch` |
| **Description** | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
| **Usage** | `bl finetune watch --job-id <id> [--follow] [--interval <sec>] [--poll-timeout <sec>]` |
| Field | Value |
| ------------------ | ---------------------------------------------------------------------------------------------------------- |
| **Name** | `finetune watch` |
| **Description** | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. |
| **Authentication** | API Key |
| **Usage** | `bl finetune watch --job-id <id> [--follow] [--interval <sec>] [--poll-timeout <sec>]` |
#### Flags
+28 -28
View File
@@ -9,34 +9,34 @@ Use this index for the skill-scoped quick index and global flags.
## Quick index
| Command | Description | Detail |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `bl dataset delete` | Delete a dataset file by ID | [dataset.md](dataset.md) |
| `bl dataset get` | Get details of a single dataset file | [dataset.md](dataset.md) |
| `bl dataset list` | List uploaded dataset files | [dataset.md](dataset.md) |
| `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) |
| `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) |
| `bl deploy audio create` | Create an audio (TTS) model deployment | [deploy.md](deploy.md) |
| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) |
| `bl deploy get` | Get details of a single model deployment | [deploy.md](deploy.md) |
| `bl deploy image create` | Create an image generation model deployment | [deploy.md](deploy.md) |
| `bl deploy list` | List model deployments | [deploy.md](deploy.md) |
| `bl deploy models` | List models available for deployment | [deploy.md](deploy.md) |
| `bl deploy scale` | Scale a deployment's capacity | [deploy.md](deploy.md) |
| `bl deploy text create` | Create a text model deployment | [deploy.md](deploy.md) |
| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) |
| `bl finetune audio create` | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
| `bl finetune cancel` | Cancel a running fine-tune job | [finetune.md](finetune.md) |
| `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) |
| `bl finetune checkpoints` | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) |
| `bl finetune delete` | Delete a fine-tune job record | [finetune.md](finetune.md) |
| `bl finetune export` | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) |
| `bl finetune get` | Get details of a single fine-tune job | [finetune.md](finetune.md) |
| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
| `bl finetune list` | List fine-tune jobs | [finetune.md](finetune.md) |
| `bl finetune logs` | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) |
| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) |
| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) |
| Command | Authentication | Description | Detail |
| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `bl dataset delete` | API Key | Delete a dataset file by ID | [dataset.md](dataset.md) |
| `bl dataset get` | API Key | Get details of a single dataset file | [dataset.md](dataset.md) |
| `bl dataset list` | API Key | List uploaded dataset files | [dataset.md](dataset.md) |
| `bl dataset upload` | API Key | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) |
| `bl dataset validate` | No Auth | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) |
| `bl deploy audio create` | API Key | Create an audio (TTS) model deployment | [deploy.md](deploy.md) |
| `bl deploy delete` | API Key | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) |
| `bl deploy get` | API Key | Get details of a single model deployment | [deploy.md](deploy.md) |
| `bl deploy image create` | API Key | Create an image generation model deployment | [deploy.md](deploy.md) |
| `bl deploy list` | API Key | List model deployments | [deploy.md](deploy.md) |
| `bl deploy models` | API Key | List models available for deployment | [deploy.md](deploy.md) |
| `bl deploy scale` | API Key | Scale a deployment's capacity | [deploy.md](deploy.md) |
| `bl deploy text create` | API Key | Create a text model deployment | [deploy.md](deploy.md) |
| `bl deploy update` | API Key | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) |
| `bl finetune audio create` | API Key | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
| `bl finetune cancel` | API Key | Cancel a running fine-tune job | [finetune.md](finetune.md) |
| `bl finetune capability` | No Auth | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) |
| `bl finetune checkpoints` | API Key | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) |
| `bl finetune delete` | API Key | Delete a fine-tune job record | [finetune.md](finetune.md) |
| `bl finetune export` | API Key | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) |
| `bl finetune get` | API Key | Get details of a single fine-tune job | [finetune.md](finetune.md) |
| `bl finetune image create` | API Key | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) |
| `bl finetune list` | API Key | List fine-tune jobs | [finetune.md](finetune.md) |
| `bl finetune logs` | API Key | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) |
| `bl finetune text create` | API Key | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) |
| `bl finetune watch` | API Key | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) |
## By group
+3 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-gen
metadata:
version: "1.14.2"
version: "1.14.3"
requires:
bins: ["bl"]
description: >-
@@ -39,6 +39,8 @@ description: >-
| A/V understanding (files the host can't play) | `bl omni --video` / `--audio` | `qwen3.5-omni-plus` |
| Image/video describe (user names Bailian) | `bl vision describe` | `qwen-vl-max`; host-first for plain image Q&A |
For ASR model selection, keep `fun-asr` (or other `*-filetrans`) for long recordings, repeated files, speaker diarization, or asynchronous task IDs. For one local or remote audio file up to about five minutes when the user asks for low-latency Flash models, use `--model fun-asr-flash-2026-06-15`, `--model qwen-audio-3.0-asr-flash`, or `--model qwen3-asr-flash`. Flash recognition is synchronous and accepts exactly one file per call.
Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl <command> --help` — do not guess flags.
## Local files (mandatory)
+16 -14
View File
@@ -7,20 +7,21 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------- | -------------------------------------------------------------------- |
| `bl image edit` | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) |
| `bl image generate` | Generate images (Qwen-Image / wan2.x) |
| Command | Authentication | Description |
| ------------------- | -------------- | -------------------------------------------------------------------- |
| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) |
| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) |
## Command details
### `bl image edit`
| Field | Value |
| --------------- | -------------------------------------------------------------------- |
| **Name** | `image edit` |
| **Description** | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) |
| **Usage** | `bl image edit --image <url> --prompt <text> [flags]` |
| Field | Value |
| ------------------ | -------------------------------------------------------------------- |
| **Name** | `image edit` |
| **Description** | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) |
| **Authentication** | API Key |
| **Usage** | `bl image edit --image <url> --prompt <text> [flags]` |
#### Flags
@@ -80,11 +81,12 @@ bl image edit --image ./photo.png --prompt "Replace the background with a beach"
### `bl image generate`
| Field | Value |
| --------------- | ------------------------------------------- |
| **Name** | `image generate` |
| **Description** | Generate images (Qwen-Image / wan2.x) |
| **Usage** | `bl image generate --prompt <text> [flags]` |
| Field | Value |
| ------------------ | ------------------------------------------- |
| **Name** | `image generate` |
| **Description** | Generate images (Qwen-Image / wan2.x) |
| **Authentication** | API Key |
| **Usage** | `bl image generate --prompt <text> [flags]` |
#### Flags
+13 -13
View File
@@ -9,19 +9,19 @@ Use this index for the skill-scoped quick index and global flags.
## Quick index
| Command | Description | Detail |
| ---------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------- |
| `bl image edit` | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) |
| `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) |
| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) |
| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) |
| `bl video download` | Download a completed video by task ID | [video.md](video.md) |
| `bl video edit` | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) |
| `bl video generate` | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) |
| `bl video ref` | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) |
| `bl video task get` | Query async task status | [video.md](video.md) |
| `bl vision describe` | Describe an image or video using Qwen-VL | [vision.md](vision.md) |
| Command | Authentication | Description | Detail |
| ---------------------- | -------------- | ----------------------------------------------------------------------------------------------------- | ---------------------- |
| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) |
| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) |
| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) |
| `bl speech synthesize` | API Key | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) |
| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) |
| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) |
| `bl video generate` | API Key | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) |
| `bl video ref` | API Key | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) |
| `bl video task get` | API Key | Query async task status | [video.md](video.md) |
| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) |
## By group
+9 -8
View File
@@ -7,19 +7,20 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| --------- | ---------------------------------------------------- |
| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) |
| Command | Authentication | Description |
| --------- | -------------- | ---------------------------------------------------- |
| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) |
## Command details
### `bl omni`
| Field | Value |
| --------------- | ---------------------------------------------------- |
| **Name** | `omni` |
| **Description** | Multimodal chat with text + audio output (Qwen-Omni) |
| **Usage** | `bl omni --message <text> [flags]` |
| Field | Value |
| ------------------ | ---------------------------------------------------- |
| **Name** | `omni` |
| **Description** | Multimodal chat with text + audio output (Qwen-Omni) |
| **Authentication** | API Key |
| **Usage** | `bl omni --message <text> [flags]` |
#### Flags
+34 -28
View File
@@ -7,37 +7,38 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ---------------------- | ------------------------------------------------ |
| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) |
| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) |
| Command | Authentication | Description |
| ---------------------- | -------------- | ----------------------------------------------------------------- |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) |
| `bl speech synthesize` | API Key | Synthesize speech from text (CosyVoice TTS) |
## Command details
### `bl speech recognize`
| Field | Value |
| --------------- | ------------------------------------------------ |
| **Name** | `speech recognize` |
| **Description** | Recognize speech from audio files (FunAudio-ASR) |
| **Usage** | `bl speech recognize --url <audio-url> [flags]` |
| Field | Value |
| ------------------ | ----------------------------------------------------------------- |
| **Name** | `speech recognize` |
| **Description** | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) |
| **Authentication** | API Key |
| **Usage** | `bl speech recognize --url <audio-url> [flags]` |
#### Flags
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ------------------------------------------------------- |
| `--url <url>` | array | yes | Audio file URL or local file path (repeatable, max 100) |
| `--model <model>` | string | no | Model ID (default: fun-asr) |
| `--language <lang>` | string | no | Language hint (e.g. zh, en, ja) |
| `--diarization` | switch | no | Enable automatic speaker diarization |
| `--speaker-count <n>` | number | no | Expected number of speakers (requires --diarization) |
| `--vocabulary-id <id>` | string | no | Hot-word vocabulary ID for improved accuracy |
| `--channel-id <n>` | number | no | Audio channel ID (default: 0) |
| `--out <path>` | string | no | Save full transcription result to JSON file |
| `--async` | switch | no | Return async task id without waiting |
| `--poll-interval <seconds>` | number | no | Polling interval in seconds (default: 2) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | array | yes | Audio file URL or local file path (repeatable, max 100) |
| `--model <model>` | string | no | Model ID (default: fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash |
| `--language <lang>` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language |
| `--diarization` | switch | no | Enable automatic speaker diarization |
| `--speaker-count <n>` | number | no | Expected number of speakers (requires --diarization) |
| `--vocabulary-id <id>` | string | no | Hot-word vocabulary ID for improved accuracy |
| `--channel-id <n>` | number | no | Audio channel ID (default: 0) |
| `--out <path>` | string | no | Save full transcription result to JSON file |
| `--async` | switch | no | Return async task id without waiting |
| `--poll-interval <seconds>` | number | no | Polling interval in seconds (default: 2) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
@@ -69,13 +70,18 @@ bl speech recognize --url https://example.com/audio.mp3 --out result.json
bl speech recognize --url https://example.com/audio.mp3 --async --quiet
```
```bash
bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en
```
### `bl speech synthesize`
| Field | Value |
| --------------- | -------------------------------------------- |
| **Name** | `speech synthesize` |
| **Description** | Synthesize speech from text (CosyVoice TTS) |
| **Usage** | `bl speech synthesize --text <text> [flags]` |
| Field | Value |
| ------------------ | -------------------------------------------- |
| **Name** | `speech synthesize` |
| **Description** | Synthesize speech from text (CosyVoice TTS) |
| **Authentication** | API Key |
| **Usage** | `bl speech synthesize --text <text> [flags]` |
#### Flags

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