Merge remote-tracking branch 'origin/main' into feat/cma-deployment

# Conflicts:
#	packages/cli/package.json
#	packages/commands/package.json
#	packages/core/package.json
#	packages/kscli/package.json
#	packages/runtime/package.json
#	skills/bailian-cli/SKILL.md
#	skills/bailian-finetune/SKILL.md
#	skills/bailian-gen/SKILL.md
#	skills/bailian-managed-agent/SKILL.md
#	skills/bailian-protocol/SKILL.md
This commit is contained in:
chenanran555
2026-08-17 17:18:53 +08:00
188 changed files with 21844 additions and 1986 deletions
+27
View File
@@ -0,0 +1,27 @@
# Poke the FC publish-skills flow after skills/ changes land.
# The FC side reconciles this repo's skills/ directory against OSS
# (bailian-wiki/skills/) using the repo HEAD snapshot as the only
# source of truth — the request itself carries no content. Both the
# repo and branch params are validated against FC-side whitelists
# (PUBLISH_REPOS / PUBLISH_BRANCHES).
#
# feat/cli-skill-sync is temporary for end-to-end testing; remove it
# (here and from the FC PUBLISH_BRANCHES whitelist) once the sync
# link is verified on main.
name: Publish skills to OSS
on:
push:
branches:
- main
- feat/cli-skill-sync
paths:
- "skills/**"
jobs:
poke:
runs-on: ubuntu-latest
steps:
- name: Trigger FC publish-skills
run: |
curl -sf -X POST "${{ vars.FC_TRIGGER_URL }}/publish-skills?repo=modelstudioai/cli&branch=${{ github.ref_name }}"
+36
View File
@@ -6,6 +6,42 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.16.0] - 2026-08-17
> Full knowledge-base lifecycle management arrives in the CLI: create and configure knowledge bases, upload documents, tune chunks, and deploy retrieval/Q&A services — all from `bl knowledge` and `kscli`.
### Added
- **Knowledge base management** — `bl knowledge create` / `list` / `info` / `update` / `delete` manage knowledge bases end to end; `bl knowledge stats` reports document counts and usage over a past time range.
- **Document management** — `bl knowledge doc upload` uploads local files or whole directories (recursive scan, skips unsupported formats and tool directories like `node_modules`); `doc list` / `status` / `tag` / `delete` cover the rest of the document lifecycle, and `doc import-oss` imports documents from OSS.
- **Retrieval / Q&A service management** — `bl knowledge service list` / `get` / `create` / `update` / `deploy` / `delete` / `copy` manage retrieval and Q&A service configurations, including deploying a draft to a published version.
- **Chunk management** — `bl knowledge chunk add` / `list` / `update` / `delete` inspect and fine-tune document chunks.
- **Data-center management** — `bl knowledge category list` / `add` / `delete`, `bl knowledge file list` / `get` / `delete`, and `bl knowledge collection create` / `get` manage categories, raw files, and data collections.
- **Service version selection for retrieval and chat** — `bl knowledge search` and `bl knowledge chat` accept `--agent-version` to call the beta (draft) config for debugging or a specific published version.
- **`kscli` parity** — all new knowledge commands are also available in Knowledge Studio CLI under shorter paths, e.g. `kscli kb list`, `kscli doc upload`, `kscli service deploy`.
### Removed
- **`bl knowledge search --query-history` removed** — the parameter never took effect; use `bl knowledge chat` with `--message` history for multi-turn scenarios.
### Internal
- Requests now carry a static OpenAPI source identification header for backend channel attribution.
- Added knowledge-base E2E suites, including five user-journey scenarios covering cold start, content ops, chunk tuning, service tuning, and the data plane.
## [1.15.1] - 2026-08-17
### Added
- **Model permission management** — `bl permission list` shows per-model inference / fine-tune / deploy grants; `bl permission grant` and `bl permission revoke` manage them, with `--all` to one-key grant inference for every model in the workspace (including future ones).
### Changed
- **`bl quota request` renamed to `bl quota update`** — set per-model QPM/TPM via `--rpm`/`--tpm` and clear custom limits with the new `--delete`; omitted fields keep their current values, and the old `quota request` path keeps working as an alias.
- **`bl quota list` reworked** — now reads the model-limits API and shows per-model and workspace-level request/usage limits plus async queue/concurrency limits in a single table.
- **`bl model list` no longer requires Console login** — the model catalog and `--enrich` parameter-schema endpoints are public.
- **`bl skill init` output simplified** — per-skill status is now `success`/`failed` (previously `installed`) with an aggregate `success`/`partial`/`failed` result; the `publishedAt` and `agents` fields were removed.
## [1.15.0] - 2026-08-14
### Added
+36
View File
@@ -6,6 +6,42 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.16.0] - 2026-08-17
> CLI 迎来知识库全生命周期管理:从创建配置知识库、上传文档、调优切片,到部署检索/问答服务,均可通过 `bl knowledge` 与 `kscli` 完成。
### 新增
- **知识库管理** —— `bl knowledge create` / `list` / `info` / `update` / `delete` 覆盖知识库的完整生命周期;`bl knowledge stats` 查询指定过去时间段内的文档数量与用量统计。
- **文档管理** —— `bl knowledge doc upload` 支持上传本地文件或整个目录(递归扫描,自动跳过不支持的格式及 `node_modules` 等工具目录);`doc list` / `status` / `tag` / `delete` 覆盖文档生命周期其余环节,`doc import-oss` 支持从 OSS 导入文档。
- **检索 / 问答服务管理** —— `bl knowledge service list` / `get` / `create` / `update` / `deploy` / `delete` / `copy` 管理检索与问答服务配置,支持将草稿部署为正式版本。
- **切片管理** —— `bl knowledge chunk add` / `list` / `update` / `delete` 查看并精调文档切片。
- **数据中心管理** —— `bl knowledge category list` / `add` / `delete`、`bl knowledge file list` / `get` / `delete`、`bl knowledge collection create` / `get` 管理类目、原始文件与数据集。
- **检索与问答支持指定服务版本** —— `bl knowledge search` 和 `bl knowledge chat` 新增 `--agent-version`,可调用 beta(草稿)配置进行调试,或指定已发布的版本号。
- **`kscli` 同步支持** —— 全部新知识库命令在 Knowledge Studio CLI 中以更短路径提供,如 `kscli kb list`、`kscli doc upload`、`kscli service deploy`。
### 移除
- **移除 `bl knowledge search --query-history`** —— 该参数此前并未实际生效;多轮场景请改用 `bl knowledge chat` 并通过 `--message` 传入对话历史。
### 内部
- 请求现在携带静态的 OpenAPI 来源标识请求头,用于后端渠道归因。
- 新增知识库 E2E 测试套件,含冷启动、内容运营、切片调优、服务调优、数据面五条用户旅程场景。
## [1.15.1] - 2026-08-17
### 新增
- **模型权限管理** —— `bl permission list` 查看各模型的推理 / 微调 / 部署授权;`bl permission grant` 与 `bl permission revoke` 负责授予和回收,支持 `--all` 一键为工作区全部模型(含后续新增模型)开启推理授权。
### 变更
- **`bl quota request` 更名为 `bl quota update`** —— 通过 `--rpm`/`--tpm` 设置单模型 QPM/TPM,新增 `--delete` 一键清除自定义限制;未指定的字段保持当前值,旧命令 `quota request` 仍作为别名可用。
- **`bl quota list` 重构** —— 改从模型限制接口读取数据,单表展示模型级与工作区级的请求/用量限制及异步队列/并发限制。
- **`bl model list` 不再需要控制台登录** —— 模型目录与 `--enrich` 参数结构端点均为公开接口。
- **`bl skill init` 输出精简** —— 单技能状态改为 `success`/`failed`(原为 `installed`),新增 `success`/`partial`/`failed` 汇总结果;移除 `publishedAt` 与 `agents` 字段。
## [1.15.0] - 2026-08-14
### 新增
+10 -1
View File
@@ -6,6 +6,7 @@
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) |
| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);每用例最小路由 |
| **Journey E2E** | `packages/commands/tests/e2e/knowledge/journeys` | 用户旅程全链路(跨命令回路 + 标记词召回闭环),全部 live gated;见 `journeys/README.md` |
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help |
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组;identity(`--version`、`search --help` path) |
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
@@ -27,7 +28,7 @@
### commands E2E
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`;knowledge 领域集中在 `packages/commands/tests/e2e/knowledge/` 子目录(新增 knowledge 命令测试放这里)
- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射)
- fixtures:`packages/commands/tests/e2e/fixtures/`
- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map)
@@ -78,6 +79,14 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
4. **真实集成**:放在 skip 块**末尾**
## Journey 层(用户旅程全链路)
- **定位**:命令 E2E 验单命令契约;journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复
- **闭环断言**:fixture 埋独特标记词,以“标记词能否被召回”判定回路闭合;硬断言 fail,软断言 `recordSoft` 落报告人工复核
- **日志产物**:`createJourneyReporter` 在 `test/output/<session>/` 落盘 `journey-report.md`、分步 stdout/stderr、`resources.json`(未清理资源警示)
- **入口**:`pnpm run test:journey`;旅程清单与约定见 [journeys/README.md](../../packages/commands/tests/e2e/knowledge/journeys/README.md)
- **新增命令时**:评估是否属于某条旅程的环节,是则纳入对应 journey 并更新 README 映射表
## 增删命令同步
- **commands export** + **topic 路由**(`topic-routes.ts` 或测试文件内 `ROUTES`)+ **产品 map**(`cli/commands.ts` / `kscli/commands.ts`)
+248
View File
@@ -0,0 +1,248 @@
# Chunk 管理命令手册
Chunk 是知识库中最小的检索单元。文档导入后自动切分为 chunk,也可以手动添加。
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
---
#### `bl knowledge chunk add`
直接向知识库添加 chunk。
**用法**
```bash
bl knowledge chunk add --index-id <id> (--content <text> | --field <k=v>) [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ------------------------------------------------------------------------------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--doc-id <id>` | string | 否² | 所属文档 ID;表格/图片知识库必填,文档型可选 |
| `--content <text>` | string | 否¹ | Chunk 正文,最多 6000 字符(文档型);与 `--content-file` 互斥 |
| `--content-file <path>` | string | 否¹ | 从 UTF-8 文本文件读取正文(`.md`/`.txt` 等);与 `--content` 互斥 |
| `--title <text>` | string | 否 | Chunk 标题,最多 50 字符(文档型) |
| `--image-url <url>` | array | 否 | Chunk 图片 URL(可重复,最多 10 个;文档型) |
| `--field <key=value>` | array | 否¹ | 任意字段键值对(可重复),用于表格/图片知识库,键为 Excel 列名;与 content/title/image 互斥 |
> ¹ `--content`/`--content-file`/`--title`/`--image-url` 与 `--field` 互斥,必须提供其一。
> ² 表格/图片知识库必须提供 `--doc-id`。文档型知识库可选。
**参数约束**
- `--field` 与 `--content`/`--content-file`/`--title`/`--image-url` 互斥
- `--content` 与 `--content-file` 互斥
- `--content` 最多 6000 字符
- `--title` 最多 50 字符
- `--image-url` 最多 10 个
**输出**
text 模式:
```
chunk created (pipeline: idx-xxx)
List chunks to find the new chunk id.
```
quiet 模式:无输出(成功退出码 0)。
json 模式:返回 API 原始响应(不含 chunk ID)。
**注意事项**
- 支持文档/表格/图片知识库;音视频知识库不支持。
- API 响应不含 chunk ID,需用 `chunk list` 查找新 chunk。
- API 幂等但限流 10 次/秒,批量脚本需自行节流。
- 表格/图片知识库用 `--field`,键为 Excel 列名,值为字符串。
**示例**
```bash
# 添加文本 chunk
bl knowledge chunk add --index-id idx-xxx --content "chunk text" --title intro --workspace-id ws-xxx
# 添加表格行(字段方式)
bl knowledge chunk add --index-id idx-xxx --field 列A=v1 --field 列B=v2
# 从文件读取内容
bl knowledge chunk add --index-id idx-xxx --content-file ./chunk.md --doc-id doc-xxx
```
---
#### `bl knowledge chunk list`
列出知识库中的 chunk,含内容和状态。
**用法**
```bash
bl knowledge chunk list --index-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ------------------- | ------ | ---- | ------------------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--doc-id <id>` | string | 否 | 只显示属于此文档的 chunk |
| `--page-number <n>` | number | 否 | 页码(默认:1) |
| `--page-size <n>` | number | 否 | 每页条数(默认:20,最大 100) |
**参数约束**
- `--page-size` 范围 1-100
**输出**
text 模式:
```
[chunk] chunk-xxx (doc: intro.md, doc_id: file-xxx) status: COMPLETED
chunk content preview (truncated at 200 chars)…
total: 1
```
> 如果 chunk 被排除检索,行尾会显示 `[excluded from retrieval]`。
quiet 模式:每行一个 `metadata._id`(chunk ID),用于管道传给 update/delete。
json 模式:返回 API 原始响应,`data.nodes[]` 含完整 chunk 数据。
**注意事项**
- 用 `metadata._id` 作为 chunk ID,`metadata.doc_id` 作为文档 ID,在 chunk update/delete 中使用。
- 页大小默认 20,最大 100。
**示例**
```bash
# 列出所有 chunk
bl knowledge chunk list --index-id idx-xxx --workspace-id ws-xxx
# 只看某文档的 chunk
bl knowledge chunk list --index-id idx-xxx --doc-id file-xxx --page-size 50
```
---
#### `bl knowledge chunk update`
更新 chunk 内容或切换其检索可见性。
**用法**
```bash
bl knowledge chunk update --index-id <id> --chunk-id <id> --doc-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ------------------------------------------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--chunk-id <id>` | string | 是 | Chunk ID(`metadata._id`,来自 chunk list 输出) |
| `--doc-id <id>` | string | 是 | 所属文档 ID(`metadata.doc_id`,来自 chunk list 输出) |
| `--content <text>` | string | 否¹ | 新内容,10-6000 字符;与 `--content-file` 互斥 |
| `--content-file <path>` | string | 否¹ | 从 UTF-8 文本文件读取新内容 |
| `--title <text>` | string | 否 | Chunk 标题,0-50 字符(空字符串清除标题;不传则不变) |
| `--exclude` | switch | 否² | 将此 chunk 排除出检索 |
| `--include` | switch | 否² | 将此 chunk 恢复检索(默认行为) |
> ¹ `--content` 与 `--content-file` 互斥。
> ² `--exclude` 与 `--include` 互斥。
**参数约束**
- `--content` 与 `--content-file` 互斥
- `--exclude` 与 `--include` 互斥
- 至少提供一个更新项(`--content`/`--content-file`/`--title`/`--exclude`/`--include`)
- `--content` 长度 10-6000 字符
- `--title` 最多 50 字符
**输出**
text 模式:
```
updated: chunk-xxx
```
quiet 模式:无输出。
json 模式:返回 API 原始响应。
**注意事项**
- 内容必须 10-6000 字符,且不超过知识库的 max chunk size。
- `--content-file` 期望 UTF-8 纯文本文件,不解析 `.docx`/`.pdf` 等文档格式。
- 仅切换 `--exclude`/`--include` 而不提供新内容时,CLI 自动读回当前内容并重新提交(API 要求 content 字段必填,CLI 隐藏了此限制)。
**示例**
```bash
# 修改内容
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --content "corrected text" --workspace-id ws-xxx
# 排除 chunk 不参与检索
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --exclude
# 恢复检索
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --include
```
---
#### `bl knowledge chunk delete`
从知识库中删除 chunk(不可逆)。
**用法**
```bash
bl knowledge chunk delete --index-id <id> --chunk-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ------------------------------------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--chunk-id <id>` | array | 是 | Chunk ID(可重复,每批最多 10 个,超出自动分批) |
| `--yes` | switch | 否 | 跳过确认提示 |
**输出**
text 模式:
```
deleted: 2 chunk(s) in 1 batch(es)
```
quiet 模式:无输出。
json 模式:返回 `{ deleted_count, batches }`。
**注意事项**
- 服务端每次最多接受 10 个 chunk ID,CLI 自动分批。
- 如果某批失败,操作停止,已删除的批次会在错误 hint 中列出。
- Chunk 被永久移除,不可恢复。
**示例**
```bash
# 删除多个 chunk
bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --workspace-id ws-xxx
# 跳过确认
bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --yes
```
---
← [返回总览](../knowledge-cli-guide.md)
+268
View File
@@ -0,0 +1,268 @@
# 数据中心集合与分类命令手册
集合(collection)是数据中心的顶层容器,对应服务端的 connector。分类(category)用于组织集合内的文件,支持多级嵌套。
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
---
#### `bl knowledge collection create`
创建 FILE 数据集合。
**用法**
```bash
bl knowledge collection create --name <text> --description <text> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------------- | ------ | ---- | ---------------------------------------------------------------- |
| `--name <text>` | string | 是 | 集合名称(1-20 字符) |
| `--description <text>` | string | 是 | 集合描述 |
| `--store-type <type>` | string | 否 | 存储类型:`platform`(托管,默认)或 `custom`(自有 OSS bucket) |
| `--oss-region <id>` | string | 否 | OSS region ID(`--store-type custom` 时必填) |
| `--oss-bucket <name>` | string | 否 | OSS bucket 名称(`--store-type custom` 时必填) |
**参数约束**
- `--name` 长度 1-20 字符
- `--store-type` 只能是 `platform` 或 `custom`
- `--store-type custom` 时 `--oss-region` 和 `--oss-bucket` 必填
**输出**
text 模式:
```
created: conn-xxx (my-collection, PLATFORM)
```
quiet 模式:输出集合 ID。
json 模式:返回 API 原始响应。
**注意事项**
- `platform` 使用平台托管存储;`custom` 使用已授权的 OSS bucket。
- 自定义 bucket 必须携带标签 `bailian-connector-access=ReadAndWrite`(百炼的标签访问控制),否则服务端报 `setBucketCORS failed` 误导性错误。
- **无集合删除 API**,创建需谨慎。
**示例**
```bash
# 创建平台托管的集合
bl knowledge collection create --name my-collection --description "team docs" --workspace-id ws-xxx
# 创建使用自有 OSS bucket 的集合
bl knowledge collection create --name oss-coll --description "own bucket" --store-type custom --oss-region cn-beijing --oss-bucket my-bucket
```
---
#### `bl knowledge collection get`
查看数据集合详情。
**用法**
```bash
bl knowledge collection get (--collection-id <id> | --name <text>) [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------------- | ------ | ---- | -------- |
| `--collection-id <id>` | string | 否¹ | 集合 ID |
| `--name <text>` | string | 否¹ | 集合名称 |
> ¹ `--collection-id` 和 `--name` 二选一,必须提供其一。
**参数约束**
- `--collection-id` 和 `--name` 互斥,必须提供其一
**输出**
text 模式:
```
id: conn-xxx
name: my-collection
description: team docs
```
quiet 模式:输出集合 ID。
json 模式:返回 API 原始响应。
**注意事项**
- getConnector 不返回 `fileConnectorConfig`(`storeType`/`regionId`/`bucketName`),这些字段仅在创建时通过请求体传入,查询时不可读回。
**示例**
```bash
# 按 ID 查询
bl knowledge collection get --collection-id conn-xxx --workspace-id ws-xxx
# 按名称查询
bl knowledge collection get --name my-collection
```
---
#### `bl knowledge category list`
列出数据中心分类。
**用法**
```bash
bl knowledge category list [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------------- | ------ | ---- | ------------------------------------------------------ |
| `--collection-id <id>` | string | 否 | 按集合 ID 过滤 |
| `--parent-id <id>` | string | 否 | 列出此分类的子分类 |
| `--name <text>` | string | 否 | 按分类名称过滤(精确匹配,与知识库列表的模糊匹配不同) |
| `--next-token <token>` | string | 否 | 游标分页令牌 |
| `--max-result <n>` | number | 否 | 每页条数(默认:20) |
**输出**
text 模式:
```
cate-xxx product-docs
cate-yyy system-docs [default]
next: --next-token eyJ...
```
> 标记 `[default]` 的是文件未指定分类时的默认归属。
quiet 模式:每行一个 `categoryId`。
json 模式:返回 API 原始响应。
**注意事项**
- 分页是游标方式:使用输出的 `next: --next-token <token>` 继续翻页。
**示例**
```bash
# 列出所有分类
bl knowledge category list --workspace-id ws-xxx
# 按名称过滤
bl knowledge category list --name my-category
# 翻页
bl knowledge category list --next-token eyJ...
```
---
#### `bl knowledge category add`
创建数据中心分类。
**用法**
```bash
bl knowledge category add --name <text> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------------- | ------ | ---- | -------------------------------- |
| `--name <text>` | string | 是 | 分类名称(1-20 字符) |
| `--parent-id <id>` | string | 否 | 创建为指定分类的子分类 |
| `--collection-id <id>` | string | 否 | 创建在此集合下(默认:平台集合) |
**参数约束**
- `--name` 长度 1-20 字符
**输出**
text 模式:
```
created: cate-xxx (product-docs)
```
quiet 模式:输出分类 ID。
json 模式:返回 API 原始响应。
**注意事项**
- 用分类按业务域组织数据中心文件。
**示例**
```bash
# 创建分类
bl knowledge category add --name product-docs --workspace-id ws-xxx
# 创建子分类
bl knowledge category add --name sub --parent-id cate-xxx
```
---
#### `bl knowledge category delete`
删除数据中心分类。
**用法**
```bash
bl knowledge category delete --category-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------ |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
**输出**
text 模式:
```
deleted: cate-xxx
```
quiet 模式:无输出。
json 模式:返回 API 原始响应。
**注意事项**
- 含文件或子分类的分类的删除行为由服务端定义——服务端错误原样透传。
**示例**
```bash
# 删除分类(交互确认)
bl knowledge category delete --category-id cate-xxx --workspace-id ws-xxx
# 跳过确认
bl knowledge category delete --category-id cate-xxx --yes
```
---
← [返回总览](../knowledge-cli-guide.md)
+344
View File
@@ -0,0 +1,344 @@
# 文档管理命令手册
文档管理覆盖文件上传、OSS 导入、解析状态跟踪、文档删除和标签管理。文档导入知识库后自动解析为 chunk。
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
---
#### `bl knowledge doc list`
列出知识库中的文档及其解析/索引状态。
**用法**
```bash
bl knowledge doc list --index-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ------------------- | ------ | ---- | ------------------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--page-number <n>` | number | 否 | 页码(默认:1) |
| `--page-size <n>` | number | 否 | 每页条数(默认:10,最大 100) |
**参数约束**
- `--page-size` 范围 1-100
**输出**
text 模式:每行一个文档,`FAILED` 状态的文档红色高亮。
```
doc-xxx COMPLETED intro.md md 1024
total: 1
```
quiet 模式:每行一个 `doc_id`。
json 模式:返回 API 原始响应。
**注意事项**
- `doc_id` 与 `file_id` 的关系:通过 `knowledge create --doc-id` 导入的文档,`doc_id` 等于 `fileId`;通过 `knowledge doc upload --index-id` 导入的,`doc_id` 可能包含 workspace 后缀。
- 页大小默认 10(服务端默认),最大 100。
**示例**
```bash
# 列出文档
bl knowledge doc list --index-id idx-xxx --workspace-id ws-xxx
# 每页 100 条
bl knowledge doc list --index-id idx-xxx --page-size 100
```
---
#### `bl knowledge doc status`
查看知识库导入任务状态。
**用法**
```bash
bl knowledge doc status --index-id <id> --job-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| --------------------------- | ------ | ---- | --------------------------------------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--job-id <id>` | string | 是 | 导入任务 ID(`ingestionId`,由 create/upload 返回) |
| `--page-number <n>` | number | 否 | 页码 |
| `--page-size <n>` | number | 否 | 每页条数 |
| `--wait` | switch | 否 | 轮询直到任务到达终态 |
| `--poll-interval <seconds>` | number | 否 | 轮询间隔秒数(默认:5) |
**输出**
text 模式:
```
status: COMPLETED
doc-xxx COMPLETED intro.md
```
quiet 模式:输出任务状态(`PENDING`/`RUNNING`/`COMPLETED`)。
json 模式:返回 API 原始响应,`data.rows[]` 包含每个文档的状态。
**注意事项**
- `--index-id` 和 `--job-id` 服务端均要求必传,只传一个会返回 `SystemError`。
- 整体任务状态为 `PENDING` / `RUNNING` / `COMPLETED`(无 `FAILED` 值)。
- 单个文档可能解析失败(如 `PARSE_FAILED`),此时 CLI 以非零退出码报错,服务端消息原样透传。
- 如果服务端对空闲知识库返回 `SystemError`,说明该 job 可能不存在。
**示例**
```bash
# 查看任务状态
bl knowledge doc status --index-id idx-xxx --job-id job-xxx --workspace-id ws-xxx
# 轮询等待完成,10 秒间隔
bl knowledge doc status --index-id idx-xxx --job-id job-xxx --wait --poll-interval 10
```
---
#### `bl knowledge doc upload`
上传本地文件或目录到数据中心,可选导入到知识库。
**用法**
```bash
bl knowledge doc upload --file <path> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| --------------------------- | ------ | ---- | ---------------------------------------------------------------- |
| `--file <path>` | array | 是 | 本地文件或目录路径(可重复)。目录递归扫描,不支持的格式自动跳过 |
| `--index-id <id>` | string | 否 | 上传后导入到此知识库(所有文件合并为一个导入任务) |
| `--category-id <id>` | string | 否 | 目标数据中心分类(默认:工作区默认分类) |
| `--tag <text>` | array | 否 | 文件标签(可重复),应用到每个上传的文件 |
| `--wait` | switch | 否 | 轮询导入任务直到终态(需要 `--index-id`) |
| `--poll-interval <seconds>` | number | 否 | 轮询间隔秒数(默认:5) |
**参数约束**
- `--wait` 要求同时指定 `--index-id`
**输出**
text 模式:
```
intro.md file-xxx registered
job: job-xxx
status: COMPLETED
Uploaded 1 file.
```
quiet 模式:每行一个 `fileId`。
json 模式:返回自定义结构,包含 `files`(路径和 fileId)、`skipped`、`index_id`、`ingestion_id`、`final_status`。
**注意事项**
- 上传管道:申请 lease → PUT 到 OSS → 注册文件 →(可选)创建导入任务。
- 目录递归扫描,`node_modules`、`.git` 等自动跳过。
- 多文件按顺序处理(无并发),避免 OSS 限流。
- 支持的文件格式:`.pdf .doc .docx .ppt .pptx .xls .xlsx .csv .md .txt .html .png .jpg .jpeg .bmp .gif`
- 部分文件上传失败时,已注册的 fileId 会在错误 hint 中列出。
**示例**
```bash
# 上传单个文件
bl knowledge doc upload --file ./a.md --workspace-id ws-xxx
# 上传多个文件并导入到知识库,等待完成
bl knowledge doc upload --file ./a.md --file ./b.pdf --index-id idx-xxx --wait
# 上传整个目录
bl knowledge doc upload --file ./docs/ --workspace-id ws-xxx
# 干跑预览(查看将上传和跳过的文件)
bl knowledge doc upload --file ./docs/ --dry-run --verbose
```
---
#### `bl knowledge doc delete`
从知识库中删除文档及其 chunk。
**用法**
```bash
bl knowledge doc delete --index-id <id> --doc-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ----------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--doc-id <id>` | array | 是 | 文档 ID(可重复) |
| `--yes` | switch | 否 | 跳过确认提示 |
**输出**
text 模式:
```
deleted: 2 document(s)
doc-a
doc-b
```
quiet 模式:每行一个已删除的 `doc_id`。
json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID 列表。
**注意事项**
- 只从知识库索引中移除文档,数据中心源文件不受影响(用 `file delete` 删除源文件)。
- `doc_id` 应从 `knowledge doc list --quiet` 获取,而非 `doc upload` 返回的 `fileId`。
- 删除是异步的:服务端立即返回 Success,但 `doc list` 中可能仍显示该文档(约 30 秒后传播完成)。
- 输出的是服务端实际删除的 ID 列表,可能与请求的数量不一致(会在 stderr 警告)。
**示例**
```bash
# 删除单个文档
bl knowledge doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx
# 批量删除,跳过确认
bl knowledge doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes
```
---
#### `bl knowledge doc tag`
批量更新数据中心文件的标签。
**用法**
```bash
bl knowledge doc tag --doc-id <id> --tag <text> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| --------------- | ------ | ---- | ------------------------------------------------------ |
| `--doc-id <id>` | array | 是 | 数据中心文件 ID(可重复,最多 20 个/次) |
| `--tag <text>` | array | 是 | 标签(可重复),应用到每个 `--doc-id` |
| `--mode <mode>` | string | 否 | 更新模式:`append`(默认,追加)或 `overwrite`(覆盖) |
**参数约束**
- `--doc-id` 最多 20 个/次
- `--tag` 最多 100 个
- 每个标签最多 32 字符
- 标签总长度最多 700 字符
- `--mode` 只能是 `append` 或 `overwrite`
**输出**
text 模式:
```
tagged: 2 file(s) with [project-a, draft]
```
quiet 模式:无输出。
json 模式:返回 API 原始响应。
**注意事项**
- 同一组标签应用到所有 `--doc-id`;不同标签集需多次执行。
**示例**
```bash
# 追加标签
bl knowledge doc tag --doc-id file-xxx --tag project-a --tag draft --workspace-id ws-xxx
# 覆盖标签
bl knowledge doc tag --doc-id file-a --doc-id file-b --tag final --mode overwrite
```
---
#### `bl knowledge doc import-oss`
从已授权的 OSS bucket 批量导入文件到数据中心。
**用法**
```bash
bl knowledge doc import-oss --bucket <name> --region <id> --oss-key <key> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------------------------------- |
| `--bucket <name>` | string | 是 | 已授权的 OSS bucket 名称 |
| `--region <id>` | string | 是 | OSS region ID(如 `cn-beijing`) |
| `--oss-key <key>` | array | 是 | OSS 对象 key(可重复,最多 10 个/次) |
| `--category-id <id>` | string | 否 | 目标数据中心分类(默认:默认分类) |
| `--tag <text>` | array | 否 | 文件标签(可重复,最多 10 个) |
| `--overwrite` | switch | 否 | 覆盖之前从相同 OSS key 导入的文件 |
**参数约束**
- `--oss-key` 最多 10 个/次
- `--tag` 最多 10 个
**输出**
text 模式:
```
imported: 2 file(s)
file-a SUCCESS docs/a.pdf
file-b SUCCESS docs/b.docx
```
quiet 模式:每行一个 `fileId`。
json 模式:返回 API 原始响应,`data.addFileResultList[]` 包含每个文件的 fileId、status 和 ossKey。
**注意事项**
- bucket 必须事先授权给平台服务角色(RAM 中的 `AliyunServiceRoleForBailian`)。
- 文件名取自 OSS key 的 basename。
- `--overwrite` 会替换之前导入的文件并生成**新的 fileId**(旧 fileId 失效)。
**示例**
```bash
# 导入单个文件
bl knowledge doc import-oss --bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --workspace-id ws-xxx
# 导入多个文件并覆盖
bl knowledge doc import-oss --bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --oss-key docs/b.docx --overwrite
```
---
← [返回总览](../knowledge-cli-guide.md)
+157
View File
@@ -0,0 +1,157 @@
# 数据中心文件管理命令手册
数据中心是知识库文件的存储层。文件通过 `doc upload` 或 `doc import-oss` 进入数据中心,再导入到知识库。数据中心文件可被多个知识库引用。
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
---
#### `bl knowledge file list`
列出数据中心分类下的文件。
**用法**
```bash
bl knowledge file list --category-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------------- | ------ | ---- | -------------------------------------------------- |
| `--category-id <id>` | string | 是 | 分类 ID(通过 `category list` 或 `file get` 获取) |
| `--name <text>` | string | 否 | 按文件名过滤 |
| `--file-id <id>` | array | 否 | 按文件 ID 过滤(可重复) |
| `--next-token <token>` | string | 否 | 游标分页令牌(从上次输出获取) |
| `--max-result <n>` | number | 否 | 每页条数 |
**输出**
text 模式:
```
file-xxx SUCCESS intro.md 1024
next: --next-token eyJ...
```
quiet 模式:每行一个 `fileId`。
json 模式:返回 API 原始响应。
**注意事项**
- `--category-id` 必须是真实的分类 ID。与上传 API 不同,字面量 `default` 在此不被解析,传入会返回空列表。通过 `file get` 的 category 字段或 `category list` 获取真实 ID。
- 分页是游标方式:使用输出的 `next: --next-token <token>` 继续翻页。
**示例**
```bash
# 列出分类下文件
bl knowledge file list --category-id cate-xxx --workspace-id ws-xxx
# 按名称过滤
bl knowledge file list --category-id cate-xxx --name report
# 翻页
bl knowledge file list --category-id cate-xxx --next-token eyJ...
```
---
#### `bl knowledge file get`
查看数据中心文件详情。
**用法**
```bash
bl knowledge file get --file-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | --------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
**输出**
text 模式:
```
id: file-xxx
name: intro.md
type: md
size: 1024
status: SUCCESS
parser: AUTO_SELECT
category: cate-xxx
uploaded: 2026-01-01T00:00:00Z
tags: project-a, draft
```
quiet 模式:输出 JSON 格式。
json 模式:返回 API 原始响应。
**注意事项**
- 无特殊注意事项。
**示例**
```bash
# 查看文件详情
bl knowledge file get --file-id file-xxx --workspace-id ws-xxx
```
---
#### `bl knowledge file delete`
从数据中心永久删除文件。
**用法**
```bash
bl knowledge file delete --file-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | --------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
**输出**
text 模式:
```
deleted: file-xxx
```
quiet 模式:无输出。
json 模式:返回 API 原始响应。
**注意事项**
- **不可逆操作**:如果知识库引用了此文件,相关文档索引会失效。
- 与 `doc delete` 的区别:`doc delete` 只从单个知识库索引中移除文档,数据中心源文件保留;`file delete` 删除源文件本身,影响所有引用它的知识库。
**示例**
```bash
# 删除文件(交互确认)
bl knowledge file delete --file-id file-xxx --workspace-id ws-xxx
# 跳过确认
bl knowledge file delete --file-id file-xxx --yes
```
---
← [返回总览](../knowledge-cli-guide.md)
+340
View File
@@ -0,0 +1,340 @@
# 知识库管理命令手册
知识库(Knowledge Base / pipeline / index)是 RAG 的核心载体,存储文档解析后的向量索引。本组命令覆盖知识库的创建、查看、更新、删除和监控。
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
---
#### `bl knowledge list`
列出工作区中的知识库。
**用法**
```bash
bl knowledge list [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ------------------- | ------ | ---- | --------------------------------- |
| `--name <text>` | string | 否 | 按知识库名称模糊过滤(1-20 字符) |
| `--page-number <n>` | number | 否 | 页码(默认:1) |
| `--page-size <n>` | number | 否 | 每页条数(默认:20,最大 100) |
**参数约束**
- `--name` 长度 1-20 字符
- `--page-size` 范围 1-100
**输出**
text 模式:每行一个知识库,字段以双空格分隔,末尾显示总数。
```
idx-xxx my-kb text-embedding-v4 600 product docs
total: 1
```
quiet 模式:每行一个知识库 ID。
json 模式:返回 API 原始响应,`data.rows[]` 包含完整知识库信息。
**注意事项**
- 返回的 `id` 字段作为后续命令的 `--index-id` 使用。
**示例**
```bash
# 列出所有知识库
bl knowledge list --workspace-id ws-xxx
# 按名称过滤,第二页
bl knowledge list --name demo --page-number 2 --page-size 50
```
---
#### `bl knowledge info`
查看知识库配置详情。
**用法**
```bash
bl knowledge info --index-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | --------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
**输出**
text 模式:按诊断维度分组展示。
```
Basic:
id: idx-xxx
name: my-kb
description: product docs
dataType: ...
Indexing: [immutable — recreate required to change]
embeddingModelName: text-embedding-v4
embeddingDimension: 1024
chunkSize: 600
overlapSize: ...
chunkMode: ...
separator: ...
Retrieval:
rerankModelName: ...
rerankMinScore: ...
rerankTopN: ...
rerankMode: ...
enableRewrite: ...
denseSimilarityTopK: ...
sparseSimilarityTopK: ...
Data:
sourceType: ...
connectorId: ...
```
quiet 模式:输出知识库 ID。
json 模式:返回知识库完整配置 JSON。
**注意事项**
- 索引设置(向量模型、切片大小等)不可变,修改需重建知识库。
**示例**
```bash
# 查看知识库详情
bl knowledge info --index-id idx-xxx --workspace-id ws-xxx
```
---
#### `bl knowledge create`
创建知识库并导入数据中心文件或分类。
**用法**
```bash
bl knowledge create --name <text> (--doc-id <id> | --category-id <id>) [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| --------------------------- | ------ | ---- | -------------------------------------------------------- |
| `--name <text>` | string | 是 | 知识库名称(1-20 字符,工作区内唯一) |
| `--doc-id <id>` | array | 否¹ | 数据中心文件 ID(可重复);与 `--category-id` 互斥 |
| `--category-id <id>` | array | 否¹ | 按分类导入该分类下所有文件(可重复);与 `--doc-id` 互斥 |
| `--embedding-model <name>` | string | 否 | 向量模型名称(默认:`text-embedding-v4`) |
| `--chunk-size <n>` | number | 否 | 切片大小,字符数(默认:600,建议 300-800) |
| `--wait` | switch | 否 | 轮询初始导入任务直到终态 |
| `--poll-interval <seconds>` | number | 否 | 轮询间隔秒数(默认:5) |
> ¹ `--doc-id` 和 `--category-id` 二选一,必须提供其一。
**参数约束**
- `--name` 长度 1-20 字符
- `--doc-id` 和 `--category-id` 互斥,必须提供其一
**输出**
text 模式:
```
index_id: idx-xxx
ingestion_id: job-xxx
status: COMPLETED
Next: check the import job status, then search against this knowledge base.
```
quiet 模式:只输出知识库 ID。
json 模式:返回 API 原始响应,包含 `pipelineId`(知识库 ID)和 `ingestionId`(导入任务 ID)。`--wait` 时追加 `final_status` 字段。
**注意事项**
- 结构/存储类型固定为默认文档知识库(非结构化,BUILT_IN 存储)。
- 返回知识库 ID(`pipelineId`)和初始导入任务 ID(`ingestionId`)。
- 使用 `doc status` 或 `--wait` 跟踪导入进度。
- 如果 `--wait` 后部分文档解析失败,CLI 以非零退出码报错,知识库已创建成功的事实会在 hint 中提示。
**示例**
```bash
# 从指定文件创建知识库
bl knowledge create --name demo --doc-id file-xxx --workspace-id ws-xxx
# 从分类导入并等待导入完成
bl knowledge create --name demo --category-id cate-xxx --wait
# 指定向量模型和切片大小
bl knowledge create --name my-kb --doc-id file-a --doc-id file-b --embedding-model text-embedding-v4 --chunk-size 400 --workspace-id ws-xxx
```
---
#### `bl knowledge update`
更新知识库名称、描述或 rerank 阈值。
**用法**
```bash
bl knowledge update --index-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------------------- | ------ | ---- | -------------------------------------------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--name <text>` | string | 否 | 新名称(1-20 字符) |
| `--description <text>` | string | 否 | 新描述 |
| `--rerank-min-score <score>` | number | 否 | rerank 最低分数阈值,范围 0-1(低于此分的 chunk 被过滤) |
**参数约束**
- 至少提供 `--name`、`--description`、`--rerank-min-score` 之一,否则报错 "Nothing to update"
- `--name` 长度 1-20 字符
- `--rerank-min-score` 范围 0-1
**输出**
text 模式:
```
updated: idx-xxx
```
quiet 模式:无输出。
json 模式:返回 API 原始响应。
**注意事项**
- 索引设置(向量模型、切片大小等)不可变,修改需重建知识库。
**示例**
```bash
# 更新描述
bl knowledge update --index-id idx-xxx --description "product docs v2" --workspace-id ws-xxx
# 调整 rerank 阈值
bl knowledge update --index-id idx-xxx --rerank-min-score 0.3
```
---
#### `bl knowledge delete`
删除知识库及其所有文档和 chunk。
**用法**
```bash
bl knowledge delete --index-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
**输出**
text 模式:
```
deleted: idx-xxx
```
quiet 模式:无输出。
json 模式:返回 API 原始响应。
**注意事项**
- **不可逆操作**:知识库及所有索引内容被永久删除。
- 数据中心中的源文件不受影响,仅删除知识库索引。
- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要。
**示例**
```bash
# 删除(交互确认)
bl knowledge delete --index-id idx-xxx --workspace-id ws-xxx
# 跳过确认
bl knowledge delete --index-id idx-xxx --yes
```
---
#### `bl knowledge stats`
查看知识库存储和 QPS 监控数据。
**用法**
```bash
bl knowledge stats --index-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ----------------------------------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--start <time>` | string | 否 | 范围起始:Unix 秒或 ISO 日期(默认:24 小时前) |
| `--end <time>` | string | 否 | 范围结束:Unix 秒或 ISO 日期(默认:当前时间) |
**输出**
text 模式:
```
plan: ...
storage: 100 / 1000
peak qps: 5
qps windows: 24 data point(s)
```
quiet 模式:输出 json 格式。
json 模式:返回 API 原始响应,包含 `storageMonitorData` 和 `qpsMonitorData`。
**注意事项**
- 默认查询最近 24 小时数据。
- 时间戳自动转换为 epoch 秒(API 要求秒级字符串)。13 位毫秒时间戳会自动降为秒。
**示例**
```bash
# 查看最近 24 小时监控
bl knowledge stats --index-id idx-xxx --workspace-id ws-xxx
# 指定日期范围
bl knowledge stats --index-id idx-xxx --start 2026-07-30 --end 2026-07-31
```
---
← [返回总览](../knowledge-cli-guide.md)
+822
View File
@@ -0,0 +1,822 @@
# `bl knowledge` 命令完整用法指南
> `bl knowledge` / `kscli` 知识库 CLI 命令总览,覆盖全部 34 个子命令。完整参数与示例请参阅各子域手册。
---
## 目录
1. [概述](#概述)
2. [核心概念与实体关系](#核心概念与实体关系)
3. [通用约定](#通用约定)
4. [典型工作流](#典型工作流)
5. [命令手册](#命令手册)
- [知识库管理](#知识库管理) → [完整手册](knowledge/kb.md)
- [文档管理](#文档管理) → [完整手册](knowledge/doc.md)
- [检索服务管理](#检索服务管理) → [完整手册](knowledge/service.md)
- [Chunk 管理](#chunk-管理) → [完整手册](knowledge/chunk.md)
- [数据中心文件管理](#数据中心文件管理) → [完整手册](knowledge/file.md)
- [数据中心集合与分类](#数据中心集合与分类) → [完整手册](knowledge/collection-category.md)
- [检索与对话](#检索与对话) → [完整手册](knowledge/search-chat.md)
6. [常见错误与排查](#常见错误与排查)
7. [附录:命令速查表](#附录命令速查表)
---
## 概述
`bl knowledge` 是阿里云百炼 CLI 的知识库命令组,覆盖 RAG(检索增强生成)全链路能力:
- **知识库全生命周期管理**:创建、查看、更新、删除、监控
- **文档管理**:上传本地文件、从 OSS 批量导入、查看解析状态、删除、打标签
- **Chunk 级运维**:直接增删改查知识库中的内容切片
- **检索服务管理**:创建/部署/复制/删除 Q&A 和检索服务(agent),管理 draft 与发布版本
- **数据中心管理**:文件、集合(connector)、分类的增删查
- **检索与对话**:语义检索(search)、多轮对话(chat)、兼容旧检索(retrieve)
共 34 个子命令,按功能域分为 7 组。所有命令均使用 DashScope API Key 鉴权。
---
## 核心概念与实体关系
```
┌─────────────────────────────────────────────────────────────┐
│ 数据中心 (Data Center) │
│ │
│ 集合 (Collection) ──┬── 分类 (Category) ── 文件 (File) │
│ │ "connector" 可多级嵌套 │
│ └── 默认分类 │
│ │
│ 文件来源:doc upload(本地上传) / doc import-oss(OSS导入) │
└──────────────────────────┬──────────────────────────────────┘
│ 导入 (import job)
▼
┌─────────────────────────────────────────────────────────────┐
│ 知识库 (Knowledge Base) │
│ │
│ 知识库 (KB / pipeline / index) │
│ ├── 文档 (Doc) ── 解析状态: PENDING/RUNNING/COMPLETED │
│ │ └── Chunk ── 内容切片,可增删改查、排除/恢复检索 │
│ └── 索引设置 (immutable): 向量模型、切片大小等 │
│ │
│ 知识库管理命令: create / list / info / update / delete / stats │
└──────────────────────────┬──────────────────────────────────┘
│ 绑定 (agent_config.kb_search_configs)
▼
┌─────────────────────────────────────────────────────────────┐
│ 检索服务 (Service / Agent) │
│ │
│ Service (agent) │
│ ├── scene: chat (Q&A) 或 search (检索) │
│ ├── 版本: beta (草稿) → 1, 2, 3... (已发布) │
│ ├── 状态: draft → deployed → edited → deleted │
│ └── 配置: 模型、温度、策略、rerank 等 │
│ │
│ 消费方式: search (语义检索) / chat (多轮对话) │
│ 管理命令: create / update / deploy / copy / delete / list / get │
└─────────────────────────────────────────────────────────────┘
```
**关键关系**:
- **数据中心文件 → 知识库**:通过 `knowledge create --doc-id` 或 `knowledge doc upload --index-id` 导入,文件解析后自动生成 chunk
- **知识库 → 检索服务**:一个服务可绑定多个知识库,服务配置中 `kb_search_configs` 指定关联的知识库 ID
- **检索服务 → 检索/对话**:`search` 和 `chat` 命令通过 `--agent-id` 指定服务来执行检索或对话
---
## 通用约定
### 鉴权
所有 `bl knowledge` 命令均使用 **DashScope API Key**(Bearer token)鉴权。获取方式:百炼控制台 API Key 页面。
优先级(高 → 低):
1. `--api-key <key>` 命令行参数
2. `DASHSCOPE_API_KEY` 环境变量
3. 配置文件中的 `api_key`(`bl config set api_key <key>`)
### Workspace ID
知识库 API 使用 workspace 级域名(`{workspaceId}.cn-beijing.maas.aliyuncs.com`),因此 **几乎所有 knowledge 命令都需要 workspace ID**。
优先级(高 → 低):
1. `--workspace-id <id>` 命令行参数
2. `BAILIAN_WORKSPACE_ID` 环境变量
3. 配置文件中的 `workspace_id`(`bl config set workspace_id <id>`)
缺失时报错:`Workspace ID is required.`
### 全局通用参数
以下参数在所有 `bl knowledge` 子命令中通用,后续命令手册中不再逐条列出:
| 参数 | 类型 | 说明 |
| --------------------- | ------ | ----------------------------------------------------------- |
| `--output <format>` | string | 输出格式:`text`(默认,人类友好)或 `json`(API 原始响应) |
| `--api-key <key>` | string | DashScope API Key |
| `--base-url <url>` | string | API 基地址(一般不需要指定) |
| `--timeout <seconds>` | number | 请求超时秒数 |
| `--quiet` | switch | 静默模式,只输出关键结果(如 ID 列表) |
| `--verbose` | switch | 详细模式,打印 HTTP 请求/响应详情到 stderr |
| `--dry-run` | switch | 干跑模式,预览将发送的请求结构,不实际调用 API |
| `--config <name>` | string | 使用指定配置 profile 执行命令 |
> **注意**:命令手册中每个命令的参数表只列出该命令**特有**的参数。上述全局参数对所有命令有效。
### 输出格式约定
- **text 模式**(默认):人类友好的表格/结构化文本,适合终端查看。不同命令的输出格式见各命令的「输出」部分。
- **json 模式**(`--output json`):返回 API 原始 JSON 响应,适合程序化处理和 agent 解析。
- **quiet 模式**(`--quiet`):只输出最精简的结果(通常只有 ID),适合管道串联。
### 危险操作确认
涉及删除的命令(`kb delete`、`doc delete`、`chunk delete`、`file delete`、`category delete`、`service delete`、`service deploy`)在执行前会弹出二次确认提示。使用 `--yes` 可跳过确认,适用于自动化脚本。
### Dry-run 模式
`--dry-run` 模式下,命令会输出将发送的 endpoint 和 request body,但**不实际发起网络请求**。部分命令在 dry-run 下仍会执行本地校验(如文件扩展名检查、参数约束检查)。
---
## 典型工作流
### 场景 A:从零搭建知识库并检索
```bash
# 1. 上传本地文件到数据中心,同时导入到新知识库
bl knowledge doc upload --file ./docs/intro.md --workspace-id ws-xxx
# → 返回 file-id
# 2. 用文件创建知识库
bl knowledge create --name my-kb --doc-id file-xxx --workspace-id ws-xxx --wait
# → 返回 index-id (pipelineId) 和导入任务状态
# 3. 创建检索服务(search 场景)
bl knowledge service create --name my-search --scene search --index-id idx-xxx --workspace-id ws-xxx
# → 返回 agent-id
# 4. 部署服务
bl knowledge service deploy --agent-id aid-xxx --workspace-id ws-xxx --yes
# 5. 执行检索
bl knowledge search --query "什么是RAG" --agent-id aid-xxx --workspace-id ws-xxx
```
### 场景 B:上传目录并导入到已有知识库
```bash
# 1. 上传整个目录到数据中心并直接导入到知识库(一步到位)
bl knowledge doc upload --file ./docs/ --index-id idx-xxx --workspace-id ws-xxx --wait
# → 文件逐个上传到 OSS → 注册到数据中心 → 创建合并导入任务 → 轮询到完成
# 2. 检查文档状态
bl knowledge doc list --index-id idx-xxx --workspace-id ws-xxx
# → 查看 doc_id 和解析状态
# 3. 如果有文档解析失败,查看导入任务详情
bl knowledge doc status --index-id idx-xxx --job-id job-xxx --workspace-id ws-xxx
```
### 场景 C:创建并部署 Q&A 服务
```bash
# 1. 创建 chat 场景的检索服务
bl knowledge service create --name my-qa --scene chat --index-id idx-xxx --workspace-id ws-xxx
# → 初始状态: draft, 版本: beta
# 2. 调整配置(如修改模型、温度)
bl knowledge service update --agent-id aid-xxx --model qwen-max --temperature 0.7 --workspace-id ws-xxx
# 3. 用 beta 版本测试
bl knowledge chat --message "什么是RAG?" --agent-id aid-xxx --agent-version beta --workspace-id ws-xxx
# 4. 测试通过后发布
bl knowledge service deploy --agent-id aid-xxx --version-desc "首版" --workspace-id ws-xxx --yes
```
### 场景 D:知识库内容运维
```bash
# 1. 查看 chunk 列表
bl knowledge chunk list --index-id idx-xxx --workspace-id ws-xxx
# → 返回 metadata._id (chunk id) 和 metadata.doc_id (document id)
# 2. 修改 chunk 内容
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id doc-xxx --content "修正后的内容" --workspace-id ws-xxx
# 3. 排除某个 chunk 不参与检索(不删除内容)
bl knowledge chunk update --index-id idx-xxx --chunk-id chunk-xxx --doc-id doc-xxx --exclude --workspace-id ws-xxx
# 4. 手动添加新 chunk
bl knowledge chunk add --index-id idx-xxx --content "新增的知识片段" --title "补充说明" --workspace-id ws-xxx
# 5. 删除 chunk(批量,自动分批每 10 个一组)
bl knowledge chunk delete --index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --yes --workspace-id ws-xxx
```
### 场景 E:服务迁移/复用
```bash
# 1. 复制现有服务为新草稿
bl knowledge service copy --agent-id aid-source --workspace-id ws-xxx
# → 返回新的 agent-id,名称加 copy_ 前缀
# 2. 修改新服务配置
bl knowledge service update --agent-id aid-new --name "改进版" --temperature 0.5 --workspace-id ws-xxx
# 3. 测试并发布
bl knowledge chat --message "测试" --agent-id aid-new --agent-version beta --workspace-id ws-xxx
bl knowledge service deploy --agent-id aid-new --workspace-id ws-xxx --yes
```
### 场景 F:从 OSS 批量导入文件
```bash
# 1. 从已授权的 OSS bucket 批量导入文件到数据中心
bl knowledge doc import-oss \
--bucket my-bucket --region cn-beijing \
--oss-key docs/a.pdf --oss-key docs/b.docx \
--workspace-id ws-xxx
# → 返回各文件的 fileId
# 2. 创建知识库并导入这些文件
bl knowledge create --name oss-kb --doc-id file-a --doc-id file-b --workspace-id ws-xxx --wait
# 3. 检索
bl knowledge search --query "相关内容" --agent-id aid-xxx --workspace-id ws-xxx
```
---
## 命令手册
以下按功能域分组,覆盖全部 34 个子命令。每个条目包含功能说明、用法签名(kscli 前缀)和详细手册链接。
> 完整参数表、参数约束、输出说明、注意事项与示例请参阅各子域手册。子域手册中的用法签名使用 `bl knowledge` 前缀。
---
### 知识库管理
> 📖 [完整手册](knowledge/kb.md) — 6 个命令
#### `kscli kb list`
列出工作区中的知识库。
```bash
kscli kb list [flags]
```
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-list)
---
#### `kscli kb info`
查看知识库配置详情。
```bash
kscli kb info --index-id <id> [flags]
```
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-info)
---
#### `kscli kb create`
创建知识库并导入数据中心文件或分类。
```bash
kscli kb create --name <text> (--doc-id <id> | --category-id <id>) [flags]
```
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-create)
---
#### `kscli kb update`
更新知识库名称、描述或 rerank 阈值。
```bash
kscli kb update --index-id <id> [flags]
```
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-update)
---
#### `kscli kb delete`
删除知识库及其所有文档和 chunk。
```bash
kscli kb delete --index-id <id> [flags]
```
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-delete)
---
#### `kscli kb stats`
查看知识库存储和 QPS 监控数据。
```bash
kscli kb stats --index-id <id> [flags]
```
→ [完整参数与示例](knowledge/kb.md#bl-knowledge-stats)
---
### 文档管理
> 📖 [完整手册](knowledge/doc.md) — 6 个命令
#### `kscli doc list`
列出知识库中的文档及其解析/索引状态。
```bash
kscli doc list --index-id <id> [flags]
```
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-list)
---
#### `kscli doc status`
查看知识库导入任务状态。
```bash
kscli doc status --index-id <id> --job-id <id> [flags]
```
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-status)
---
#### `kscli doc upload`
上传本地文件或目录到数据中心,可选导入到知识库。
```bash
kscli doc upload --file <path> [flags]
```
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-upload)
---
#### `kscli doc delete`
从知识库中删除文档及其 chunk。
```bash
kscli doc delete --index-id <id> --doc-id <id> [flags]
```
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-delete)
---
#### `kscli doc tag`
批量更新数据中心文件的标签。
```bash
kscli doc tag --doc-id <id> --tag <text> [flags]
```
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-tag)
---
#### `kscli doc import-oss`
从已授权的 OSS bucket 批量导入文件到数据中心。
```bash
kscli doc import-oss --bucket <name> --region <id> --oss-key <key> [flags]
```
→ [完整参数与示例](knowledge/doc.md#bl-knowledge-doc-import-oss)
---
### 检索服务管理
> 📖 [完整手册](knowledge/service.md) — 7 个命令
#### `kscli service list`
列出工作区中的检索/Q&A 服务。
```bash
kscli service list --scene <chat|search> [flags]
```
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-list)
---
#### `kscli service get`
查看服务详情,含各版本配置。
```bash
kscli service get --agent-id <id> [flags]
```
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-get)
---
#### `kscli service create`
创建检索/Q&A 服务,初始状态为 draft,版本为 beta。
```bash
kscli service create --name <text> --scene <chat|search> [flags]
```
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-create)
---
#### `kscli service update`
更新服务名称、描述或草稿配置。
```bash
kscli service update --agent-id <id> [flags]
```
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-update)
---
#### `kscli service deploy`
发布 beta 草稿为新版本。
```bash
kscli service deploy --agent-id <id> [flags]
```
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-deploy)
---
#### `kscli service delete`
删除检索/Q&A 服务(软删除,幂等)。
```bash
kscli service delete --agent-id <id> [flags]
```
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-delete)
---
#### `kscli service copy`
复制服务为新草稿(名称自动加 `copy_` 前缀)。
```bash
kscli service copy --agent-id <id> [flags]
```
→ [完整参数与示例](knowledge/service.md#bl-knowledge-service-copy)
---
### Chunk 管理
> 📖 [完整手册](knowledge/chunk.md) — 4 个命令
#### `kscli chunk add`
直接向知识库添加 chunk。
```bash
kscli chunk add --index-id <id> (--content <text> | --field <k=v>) [flags]
```
→ [完整参数与示例](knowledge/chunk.md#bl-knowledge-chunk-add)
---
#### `kscli chunk list`
列出知识库中的 chunk,含内容和状态。
```bash
kscli chunk list --index-id <id> [flags]
```
→ [完整参数与示例](knowledge/chunk.md#bl-knowledge-chunk-list)
---
#### `kscli chunk update`
更新 chunk 内容或切换其检索可见性。
```bash
kscli chunk update --index-id <id> --chunk-id <id> --doc-id <id> [flags]
```
→ [完整参数与示例](knowledge/chunk.md#bl-knowledge-chunk-update)
---
#### `kscli chunk delete`
从知识库中删除 chunk(不可逆)。
```bash
kscli chunk delete --index-id <id> --chunk-id <id> [flags]
```
→ [完整参数与示例](knowledge/chunk.md#bl-knowledge-chunk-delete)
---
### 数据中心文件管理
> 📖 [完整手册](knowledge/file.md) — 3 个命令
#### `kscli file list`
列出数据中心分类下的文件。
```bash
kscli file list --category-id <id> [flags]
```
→ [完整参数与示例](knowledge/file.md#bl-knowledge-file-list)
---
#### `kscli file get`
查看数据中心文件详情。
```bash
kscli file get --file-id <id> [flags]
```
→ [完整参数与示例](knowledge/file.md#bl-knowledge-file-get)
---
#### `kscli file delete`
从数据中心永久删除文件。
```bash
kscli file delete --file-id <id> [flags]
```
→ [完整参数与示例](knowledge/file.md#bl-knowledge-file-delete)
---
### 数据中心集合与分类
> 📖 [完整手册](knowledge/collection-category.md) — 5 个命令
#### `kscli collection create`
创建 FILE 数据集合。
```bash
kscli collection create --name <text> --description <text> [flags]
```
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-collection-create)
---
#### `kscli collection get`
查看数据集合详情。
```bash
kscli collection get (--collection-id <id> | --name <text>) [flags]
```
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-collection-get)
---
#### `kscli category list`
列出数据中心分类。
```bash
kscli category list [flags]
```
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-category-list)
---
#### `kscli category add`
创建数据中心分类。
```bash
kscli category add --name <text> [flags]
```
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-category-add)
---
#### `kscli category delete`
删除数据中心分类。
```bash
kscli category delete --category-id <id> [flags]
```
→ [完整参数与示例](knowledge/collection-category.md#bl-knowledge-category-delete)
---
### 检索与对话
> 📖 [完整手册](knowledge/search-chat.md) — 3 个命令
#### `kscli retrieve`
从知识库检索(已废弃,请用 `search` 替代)。
```bash
kscli retrieve --index-id <id> --query <text> [flags]
```
→ [完整参数与示例](knowledge/search-chat.md#bl-knowledge-retrieve)
---
#### `kscli search`
对知识库执行语义检索(RAG 检索)。
```bash
kscli search --query <text> --agent-id <id> [flags]
```
→ [完整参数与示例](knowledge/search-chat.md#bl-knowledge-search)
---
#### `kscli chat`
与知识库进行 RAG 对话(流式输出)。
```bash
kscli chat --message <text> --agent-id <id> [flags]
```
→ [完整参数与示例](knowledge/search-chat.md#bl-knowledge-chat)
---
## 常见错误与排查
### Workspace ID 缺失
**报错**:`Workspace ID is required.`
**原因**:所有 knowledge 管理命令都需要 workspace ID 来构造 API 端点(`{workspaceId}.cn-beijing.maas.aliyuncs.com`)。
**解决**:
```bash
# 方式1:命令行参数
bl knowledge list --workspace-id ws-xxx
# 方式2:环境变量
export BAILIAN_WORKSPACE_ID=ws-xxx
# 方式3:配置文件
bl config set workspace_id ws-xxx
```
### 知识库 ID 不存在
**报错**:`Knowledge base not found: idx-xxx`
**原因**:`--index-id` 指定的知识库在当前 workspace 中不存在。
**解决**:先 `bl knowledge list` 确认知识库 ID。
### 导入任务 SystemError
**报错**:服务端返回 `SystemError`
**原因**:`doc status` 传入了不存在的 job ID,或知识库空闲无任务。
**解决**:检查 `doc list` 输出中的 `ingestionId`,或从 `doc upload`/`knowledge create` 的返回值获取。
### doc_id 与 fileId 混淆
**问题**:`doc delete` 时用了 `doc upload` 返回的 `fileId` 而非 `doc list` 返回的 `doc_id`。
**原因**:通过 `knowledge create --doc-id` 导入的文档,`doc_id` 等于 `fileId`;但通过 `doc upload --index-id` 导入的,`doc_id` 可能含 workspace 后缀。
**解决**:始终用 `doc list --quiet` 获取 `doc_id`。
### retrieve 已废弃
**问题**:`retrieve` 命令输出废弃警告。
**解决**:改用 `search` 命令。`search` 通过 `--agent-id` 驱动检索策略,支持多知识库、路由、rerank 等高级特性。`retrieve` 直接操作 `--index-id`,功能受限且不再迭代。
### OSS 导入权限错误
**报错**:服务端返回权限相关错误。
**原因**:OSS bucket 未授权给平台服务角色。
**解决**:检查 RAM 控制台中的 `AliyunServiceRoleForBailian` 角色是否已正确授权。
### Chat SSE error
**报错**:`Chat API error` + API error code。
**原因**:流式对话过程中服务端返回 error 事件。
**解决**:检查 `--agent-id` 是否存在、服务是否已部署、API Key 是否有效。错误消息和 code 原样透传,不二次包装。
### file list 返回空
**问题**:`file list --category-id default` 返回空列表。
**原因**:与上传 API 不同,`file list` 不解析字面量 `default`,需要真实分类 ID。
**解决**:通过 `file get` 的 category 字段或 `category list` 获取真实分类 ID。
### 集合无法删除
**问题**:没有 `collection delete` 命令。
**原因**:暂不支持通过 CLI 删除。
**解决**:创建集合需谨慎。如需隔离,创建新集合并迁移文件。
---
## 附录:命令速查表
| 命令 | 功能 | 关键参数 |
| ------------------------- | ------------ | ----------------------------------------------------------- |
| `kscli kb list` | 列出知识库 | `--name` |
| `kscli kb info` | 知识库详情 | `--index-id` |
| `kscli kb create` | 创建知识库 | `--name`, `--doc-id`/`--category-id` |
| `kscli kb update` | 更新知识库 | `--index-id`, `--name`/`--description`/`--rerank-min-score` |
| `kscli kb delete` | 删除知识库 | `--index-id`, `--yes` |
| `kscli kb stats` | 监控数据 | `--index-id`, `--start`/`--end` |
| `kscli doc list` | 文档列表 | `--index-id` |
| `kscli doc status` | 导入任务状态 | `--index-id`, `--job-id`, `--wait` |
| `kscli doc upload` | 上传文件 | `--file`, `--index-id`, `--wait` |
| `kscli doc delete` | 删除文档 | `--index-id`, `--doc-id` |
| `kscli doc tag` | 文件打标签 | `--doc-id`, `--tag`, `--mode` |
| `kscli doc import-oss` | OSS 导入 | `--bucket`, `--region`, `--oss-key` |
| `kscli service list` | 服务列表 | `--scene` |
| `kscli service get` | 服务详情 | `--agent-id` |
| `kscli service create` | 创建服务 | `--name`, `--scene`, `--index-id` |
| `kscli service update` | 更新服务 | `--agent-id`, 配置参数 |
| `kscli service deploy` | 发布服务 | `--agent-id`, `--yes` |
| `kscli service delete` | 删除服务 | `--agent-id`, `--yes` |
| `kscli service copy` | 复制服务 | `--agent-id` |
| `kscli chunk add` | 添加 chunk | `--index-id`, `--content`/`--field` |
| `kscli chunk list` | chunk 列表 | `--index-id`, `--doc-id` |
| `kscli chunk update` | 更新 chunk | `--index-id`, `--chunk-id`, `--doc-id` |
| `kscli chunk delete` | 删除 chunk | `--index-id`, `--chunk-id`, `--yes` |
| `kscli file list` | 文件列表 | `--category-id` |
| `kscli file get` | 文件详情 | `--file-id` |
| `kscli file delete` | 删除文件 | `--file-id`, `--yes` |
| `kscli collection create` | 创建集合 | `--name`, `--description` |
| `kscli collection get` | 集合详情 | `--collection-id`/`--name` |
| `kscli category list` | 分类列表 | `--collection-id`, `--parent-id` |
| `kscli category add` | 创建分类 | `--name`, `--parent-id` |
| `kscli category delete` | 删除分类 | `--category-id`, `--yes` |
| `kscli retrieve` | 检索(废弃) | `--index-id`, `--query` |
| `kscli search` | 语义检索 | `--query`, `--agent-id` |
| `kscli chat` | RAG 对话 | `--message`, `--agent-id` |
+218
View File
@@ -0,0 +1,218 @@
# 检索与对话命令手册
以下命令通过检索服务(agent)消费知识库。`search` 用于语义检索,`chat` 用于多轮对话。`retrieve` 已废弃。
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
---
#### `bl knowledge retrieve`
从知识库检索(已废弃,请用 `search` 替代)。
**用法**
```bash
bl knowledge retrieve --index-id <id> --query <text> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ------------------------------- | ------ | ---- | --------------------------------------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--query <text>` | string | 是 | 检索查询文本 |
| `--dense-similarity-top-k <n>` | number | 否 | 稠密检索 top K |
| `--sparse-similarity-top-k <n>` | number | 否 | 稀疏检索 top K |
| `--rerank` | switch | 否 | 启用 rerank |
| `--rerank-top-n <n>` | number | 否 | rerank 返回 top N 结果 |
| `--rerank-model <name>` | string | 否 | rerank 模型名,如 `qwen3-rerank-hybrid` |
| `--rerank-mode <mode>` | string | 否 | rerank 模式:`qa`、`similar` 或 `custom` |
| `--rerank-instruct <text>` | string | 否 | 自定义 rerank 指令(`--rerank-mode custom` 时使用) |
| `--top-k <n>` | number | 否 | 返回结果数(已废弃,用 `--rerank-top-n` 替代) |
**输出**
text/quiet 模式:
```
[1] (score: 0.9512)
检索到的文本内容...
[2] (score: 0.8734)
另一段文本内容...
```
> 无结果时输出 `No results found.`
json 模式:返回 API 原始响应。
**注意事项**
- **已废弃**,推荐使用 `search` 命令。`search` 通过 agent_id 驱动检索策略,支持更多高级特性。
- `--top-k` 已废弃,使用 `--rerank-top-n` 替代,传入 `--top-k` 会输出 stderr 警告。
- 此命令直接用 `--index-id` 检索,不需要创建检索服务。
**示例**
```bash
# 基础检索
bl knowledge retrieve --index-id idx-xxx --query "How to use Alibaba Cloud Bailian" --workspace-id ws-xxx
# 启用 rerank
bl knowledge retrieve --index-id idx-xxx --query "RAG retrieval" --rerank --rerank-model qwen3-rerank-hybrid
```
---
#### `bl knowledge search`
对知识库执行语义检索(RAG 检索)。
**用法**
```bash
bl knowledge search --query <text> --agent-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| --------------------------- | ------ | ---- | ------------------------------------------------------------------- |
| `--query <text>` | string | 是 | 检索查询文本(不可为空) |
| `--agent-id <id>` | string | 是 | 检索服务 ID(在控制台知识检索页面获取,或通过 `service list` 查看) |
| `--agent-version <version>` | string | 否 | 服务版本:`beta`(调试草稿)或已发布版本号;默认调用最新已发布版本 |
| `--image <url>` | array | 否 | 图片 URL(可重复),用于多模态检索 |
**参数约束**
- `--query` 不可为空(API 要求 `minLength: 1`)
**输出**
text/quiet 模式:
```
[1] (score: 0.9512)
检索到的文本内容...
[2] (score: 0.8734)
另一段文本内容...
```
> 无结果时输出 `No results found.`
json 模式:返回 API 原始响应,`data.nodes[]` 包含检索结果。
**注意事项**
- 检索范围和策略(多知识库加权、路由、rerank 等)由 `--agent-id` 对应的服务配置驱动。只需 `--query` 和 `--agent-id` 即可调用。
- `--agent-version beta` 调试草稿配置进行调试,部署前验证效果。
- 与 `retrieve` 的区别:`search` 通过 agent_id 间接驱动检索策略(支持多知识库、路由、rerank 等),`retrieve` 直接操作 index_id 且功能较少。
**示例**
```bash
# 基础检索
bl knowledge search --query "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx
# 多模态检索(带图片)
bl knowledge search --query "describe this image" --agent-id aid-xxx --workspace-id ws-xxx --image https://example.com/img.jpg
# 调试草稿版本
bl knowledge search --query "test" --agent-id aid-xxx --agent-version beta --workspace-id ws-xxx
```
---
#### `bl knowledge chat`
与知识库进行 RAG 对话(流式输出)。
**用法**
```bash
bl knowledge chat --message <text> --agent-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| --------------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------------------------------------ |
| `--message <text>` | array | 是¹ | 消息文本(可重复)。支持 `role:content` 前缀设置角色(如 `user:hello`),默认角色为 `user`。也支持完整 JSON 对象传递结构化消息 |
| `--agent-id <id>` | string | 是 | Q&A 服务 ID(在控制台知识问答页面获取,或通过 `service list --scene chat` 查看) |
| `--agent-version <version>` | string | 否 | 服务版本:`beta`(调试草稿)或已发布版本号;默认调用最新已发布版本 |
| `--image <url>` | array | 否 | 图片 URL(可重复)。附加到最后一条 user 消息作为多模态内容 |
> ¹ `--message` 或 `--image` 至少提供其一。纯图片查询可以只传 `--image`(CLI 会自动创建空 user 消息承载图片)。
**参数约束**
- `--message` 或 `--image` 至少提供一个
- `--image` 不能与已包含 `image_url` 内容部分的消息同时使用
**输出**
**TTY text 模式**(实时流式):
```
🔍 Retrieving...
✍️ Generating...
这是AI生成的回答内容,逐字流式输出...
```
> 进度标签由 SSE `step_change` 事件驱动:`tool_calling`(检索中)→ `plan_start`(规划中)→ `generation_start`(生成中)。
**非 TTY text 模式**(缓冲输出):
```
完整的回答文本...
```
**json 模式**(`--output json`):
```json
{
"answer": "完整的回答文本...",
"request_id": "xxx"
}
```
quiet 模式:输出完整的回答文本。
**注意事项**
- API 仅支持 SSE 流式响应。TTY 环境下实时打印 token;非 TTY 环境缓冲后输出完整文本。
- SSE 事件生命周期:`tool_calling` → `tool_return` → `plan_start` → `planning` → `plan_end` → `generation_start` → `generating` → `generation_end`。`tool_calling` → `tool_return` 可能循环多次。
- 多轮对话:用 `--message "user:..."` 和 `--message "assistant:..."` 传递对话历史。
- `--agent-version beta` 调用草稿配置进行调试。
- `--image` 附加到最后一条 user 消息上。如果消息中已包含 `image_url` 内容部分,则不能再用 `--image`。
- `--verbose` 模式下,所有 SSE 事件详情会输出到 stderr。
**示例**
```bash
# 单轮对话
bl knowledge chat --message "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx
# 多轮对话(带历史)
bl knowledge chat \
--message "user:What is RAG?" \
--message "assistant:RAG is retrieval-augmented generation..." \
--message "How does it work?" \
--agent-id aid-xxx --workspace-id ws-xxx
# 多模态对话(带图片)
bl knowledge chat \
--message "Describe these images" \
--image https://example.com/a.png \
--image https://example.com/b.png \
--agent-id aid-xxx --workspace-id ws-xxx
# 调试草稿版本
bl knowledge chat --message "test" --agent-id aid-xxx --agent-version beta --workspace-id ws-xxx
```
---
← [返回总览](../knowledge-cli-guide.md)
+401
View File
@@ -0,0 +1,401 @@
# 检索服务管理命令手册
检索服务(也称 agent)是知识库的检索入口。通过 `--agent-id` 在 search/chat 命令中使用。服务有 `chat`(问答)和 `search`(检索)两种场景。
> **通用约定**(鉴权、Workspace ID、全局参数、输出格式、危险操作确认、Dry-run 模式)请参阅 [总览文档](../knowledge-cli-guide.md#通用约定)。
---
#### `bl knowledge service list`
列出工作区中的检索/Q&A 服务。
**用法**
```bash
bl knowledge service list --scene <chat|search> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ------------------------ | ------ | ---- | ------------------------------------------------------- |
| `--scene <chat\|search>` | string | 是 | 服务场景:`chat`(Q&A)或 `search`(检索) |
| `--status <status>` | string | 否 | 按状态过滤:`draft`、`deployed`(含 edited)、`deleted` |
| `--name <text>` | string | 否 | 按服务名称模糊过滤 |
| `--agent-id <id>` | string | 否 | 按精确 agent ID 过滤 |
| `--index-id <id>` | string | 否 | 按关联知识库 ID 过滤 |
| `--page-number <n>` | number | 否 | 页码(默认:1) |
| `--page-size <n>` | number | 否 | 每页条数(默认:10,最大 100) |
**参数约束**
- `--scene` 只能是 `chat` 或 `search`
- `--status` 只能是 `draft`、`deployed`、`deleted`
- `--page-size` 范围 1-100
**输出**
text 模式:
```
aid-xxx deployed 2 my-qa (kb: my-kb)
total: 1
Use an agent_id above with the knowledge chat command.
```
> 最后一行根据 scene 自动提示用 `search` 还是 `chat` 命令消费。
quiet 模式:每行一个 `agent_id`。
json 模式:返回 API 原始响应。
**注意事项**
- 服务端要求 `--scene` 必填,要查看两种场景的服务需分别执行。
**示例**
```bash
# 列出 chat 服务
bl knowledge service list --scene chat --workspace-id ws-xxx
# 只看已部署的检索服务
bl knowledge service list --scene search --status deployed
```
---
#### `bl knowledge service get`
查看服务详情,含各版本配置。
**用法**
```bash
bl knowledge service get --agent-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| --------------------------- | ------ | ---- | --------------------------------------------------------- |
| `--agent-id <id>` | string | 是 | 服务(agent)ID |
| `--agent-version <version>` | string | 否 | 指定版本查看(`beta` 或已发布版本号);不传则返回所有版本 |
**输出**
text 模式:
```
Basic:
id: aid-xxx
name: my-qa
desc: product Q&A
scene: chat
status: deployed
Version beta:
desc: draft
policy: turbo
model: qwen-max
temperature: 0.7
kb: idx-xxx (my-kb)
Version 1:
published: 2026-01-01
...
```
quiet 模式:输出 JSON 格式。
json 模式:返回 API 原始响应。
**注意事项**
- 不传 `--agent-version` 时返回所有版本(beta 草稿 + 已发布版本号)。
- 版本值原样传递,有效值集合由服务端维护。
**示例**
```bash
# 查看服务完整详情
bl knowledge service get --agent-id aid-xxx --workspace-id ws-xxx
# 只看 beta 草稿配置
bl knowledge service get --agent-id aid-xxx --agent-version beta
```
---
#### `bl knowledge service create`
创建检索/Q&A 服务,初始状态为 draft,版本为 beta。
**用法**
```bash
bl knowledge service create --name <text> --scene <chat|search> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ------------------------ | ------ | ---- | ------------------------------------------------- |
| `--name <text>` | string | 是 | 服务名称(最多 200 字符,同一场景下工作区内唯一) |
| `--scene <chat\|search>` | string | 是 | 服务场景:`chat`(Q&A)或 `search`(检索) |
| `--description <text>` | string | 否 | 服务描述(最多 1000 字符) |
| `--index-id <id>` | string | 否 | 绑定此知识库;其他配置使用服务端默认值 |
**参数约束**
- `--name` 最多 200 字符
- `--scene` 只能是 `chat` 或 `search`
- `--description` 最多 1000 字符
**输出**
text 模式:
```
created: aid-xxx (status: draft, version: beta)
Test the draft with --agent-version beta on search/chat, then deploy it to publish.
```
quiet 模式:输出 agent ID。
json 模式:返回 API 原始响应。
**注意事项**
- 不指定 `--index-id` 时,服务端使用默认 agent 配置。
- beta 草稿可通过 search/chat 的 `--agent-version beta` 测试,部署后才生效。
- 需要工作区的知识库创建权限。
**示例**
```bash
# 创建 Q&A 服务
bl knowledge service create --name my-qa --scene chat --workspace-id ws-xxx
# 创建检索服务并绑定知识库
bl knowledge service create --name my-search --scene search --index-id idx-xxx
```
---
#### `bl knowledge service update`
更新服务名称、描述或草稿配置。
**用法**
```bash
bl knowledge service update --agent-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ------------------------------ | ------ | ---- | ---------------------------------------------------------------------------------------- |
| `--agent-id <id>` | string | 是 | 服务(agent)ID |
| `--name <text>` | string | 否 | 新名称(最多 200 字符) |
| `--description <text>` | string | 否 | 新描述(最多 1000 字符) |
| `--agent-version <version>` | string | 否 | 目标版本(默认:beta 草稿。已发布版本只接受 `--version-desc`) |
| `--version-desc <text>` | string | 否 | 版本描述 |
| `--policy <policy>` | string | 否 | Agent 策略:`turbo`(快速)或 `agentic`(多轮) |
| `--model <name>` | string | 否 | 生成模型代码(须在平台白名单中) |
| `--temperature <n>` | number | 否 | 采样温度,范围 0-2 |
| `--max-llm-calls <n>` | number | 否 | 单次请求最大 LLM 调用次数,范围 1-30 |
| `--enable-session-file <bool>` | string | 否 | 启用会话文件:`true` 或 `false` |
| `--enable-refusal <bool>` | string | 否 | 启用拒答:`true` 或 `false` |
| `--enable-anti-leak <bool>` | string | 否 | 启用防泄漏:`true` 或 `false` |
| `--enable-rich-text <bool>` | string | 否 | 启用富文本输出:`true` 或 `false` |
| `--enable-citation <bool>` | string | 否 | 启用引用标注:`true` 或 `false` |
| `--config-file <path>` | string | 否 | JSON 文件替换整个 `agent_config`(含嵌套设置如 `kb_search_configs`);与标量配置参数互斥 |
**参数约束**
- 至少提供一个更新项(`--name`/`--description`/`--version-desc`/`--config-file`/标量配置参数),否则报错 "Nothing to update"
- `--config-file` 与标量配置参数(`--policy`/`--model`/`--temperature` 等)互斥
- 已发布版本 + 配置变更 → 报错(已发布版本只接受 `--version-desc`)
- `--name` 最多 200 字符;`--description` 最多 1000 字符
- `--policy` 只能是 `turbo` 或 `agentic`
- `--temperature` 范围 0-2
- `--max-llm-calls` 范围 1-30
- 布尔参数(`--enable-*`)只能是 `true` 或 `false`
**输出**
text 模式:
```
updated: aid-xxx
Draft config changed — verify with --agent-version beta, then deploy.
```
quiet 模式:无输出。
json 模式:返回 API 原始响应。
**注意事项**
- 配置变更只作用于 beta 草稿;已发布版本只接受 `--version-desc`。
- 标量配置参数采用 read-merge-write:CLI 先读取当前 beta 配置,再合并变更后整体提交(API 是整替换语义)。
- `--config-file` 替换整个配置,适合设置嵌套字段(如 `kb_search_configs`)。
- 修改草稿后用 `--agent-version beta` 在 search/chat 上测试,通过后 `service deploy` 发布。
**示例**
```bash
# 调整温度
bl knowledge service update --agent-id aid-xxx --temperature 0.7 --workspace-id ws-xxx
# 用 JSON 文件替换整个配置
bl knowledge service update --agent-id aid-xxx --config-file ./agent-config.json
# 给已发布版本 1 加描述
bl knowledge service update --agent-id aid-xxx --agent-version 1 --version-desc "first stable release"
```
---
#### `bl knowledge service deploy`
发布 beta 草稿为新版本。
**用法**
```bash
bl knowledge service deploy --agent-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------- |
| `--agent-id <id>` | string | 是 | 服务(agent)ID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 跳过确认提示 |
**输出**
text 模式:
```
deployed: aid-xxx version 2
```
quiet 模式:输出新版本号。
json 模式:返回 API 原始响应。
**注意事项**
- 版本号自动递增,状态变为 `deployed`。
- 发布影响线上调用方,确认提示会警告。
- 如果当前状态为 `edited`(已发布后又改了草稿),确认提示会额外警告「发布会覆盖线上行为」。
- 需要工作区的知识库修改权限。
**示例**
```bash
# 发布(交互确认)
bl knowledge service deploy --agent-id aid-xxx --workspace-id ws-xxx
# 带描述并跳过确认
bl knowledge service deploy --agent-id aid-xxx --version-desc "tuned rerank params" --yes
```
---
#### `bl knowledge service delete`
删除检索/Q&A 服务(软删除,幂等)。
**用法**
```bash
bl knowledge service delete --agent-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | --------------- |
| `--agent-id <id>` | string | 是 | 服务(agent)ID |
| `--yes` | switch | 否 | 跳过确认提示 |
**输出**
text 模式:
```
deleted: aid-xxx (status: deleted)
```
quiet 模式:无输出。
json 模式:返回 API 原始响应。
**注意事项**
- 删除不可撤销,`agent_id` 不再可用于 search/chat 调用。
- API 是幂等的:删除已删除的服务不会报错。
- 如果服务状态为 `deployed` 或 `edited`,确认提示会额外警告「此服务正在线上运行」。
- 需要工作区的知识库删除权限。
**示例**
```bash
# 删除(交互确认)
bl knowledge service delete --agent-id aid-xxx --workspace-id ws-xxx
# 跳过确认
bl knowledge service delete --agent-id aid-xxx --yes
```
---
#### `bl knowledge service copy`
复制服务为新草稿(名称自动加 `copy_` 前缀)。
**用法**
```bash
bl knowledge service copy --agent-id <id> [flags]
```
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ----------------- |
| `--agent-id <id>` | string | 是 | 源服务(agent)ID |
**输出**
text 模式:
```
new agent_id: aid-new (name: copy_my-qa, status: draft)
Test the draft with --agent-version beta on search/chat, then deploy it to publish.
```
quiet 模式:输出新 agent ID。
json 模式:返回 API 原始响应。
**注意事项**
- 副本初始为 beta 草稿,测试后需 deploy 发布。
- 需要工作区的知识库创建权限。
**示例**
```bash
# 复制服务
bl knowledge service copy --agent-id aid-source --workspace-id ws-xxx
```
---
← [返回总览](../knowledge-cli-guide.md)
+1
View File
@@ -21,6 +21,7 @@
"bl": "pnpm -F bailian-cli dev",
"kscli": "pnpm -F knowledge-studio-cli dev",
"test": "vp test",
"test:journey": "vp test packages/commands/tests/e2e/knowledge/journeys",
"release:check": "node tools/release/check.mjs",
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
"test:stress": "node packages/cli/tests/stress/run.mjs"
+160
View File
@@ -0,0 +1,160 @@
# 迭代一设计 · doc 组命令
> 命令:`doc upload` / `doc list` / `doc status` / `doc delete` / `doc tag` / `doc import-oss`
> 公共约定见 [README.md](README.md)。
## doc upload — 上传本地文件入库(编排命令)
**说明**:本迭代最复杂命令。把"本地文件 → 数据中心 →(可选)导入知识库"封装为一条命令,替代构建期最高频的控制台操作(S2.2 痛点:高)。对标竞品 add-file。
**编排四步**:
| 步 | API | 输入 | 输出 |
| ---------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------- |
| 1 申请租约 | `POST /api/v1/connector/dash/applyFileUploadLease` | `category`(类目ID) + `fileName` + `sizeBytes`(字符串!) + `contentMd5`(Base64) | `leaseId` + `param.url/method/headers` |
| 2 OSS 上传 | `PUT {param.url}` | 文件二进制 + `param.headers`(含 `x-bailian-extra`、`Content-Type`) | HTTP 200 |
| 3 注册文件 | `POST /api/v1/connector/dash/addFile` | `leaseId` + `category` + `parser: "AUTO_SELECT"` + `tags?` | `fileId` |
| 4 导入(可选,传 `--index-id` 时) | `POST /api/v1/indices/rag/index/job/create` | `indexId` + `dataSource: { sourceType: "DATA_CENTER_FILE", fileIds }` | `ingestionId` |
坑位(实现注释必须标注):
- `sizeBytes` 必须字符串;`contentMd5` = `crypto.createHash("md5").update(buf).digest("base64")`
- 租约/注册的类目参数名是 `category`,不是 `categoryId`
- 第 4 步 body 是嵌套 `dataSource: { sourceType, fileIds }`(实测;公开文档的平铺 `documentIds` 会报 `Index.InvalidParameter`)
- **第 4 步必须显式传 `sourceType`,不传会导入整个数据中心(API 文档明示的默认行为)**
- 步骤 2 走 OSS 域名不走 DashScope 网关,用原生 fetch 而非 ctx.client(无 Bearer 头);失败归类 NETWORK
**Flags**:
| flag | 类型 | 必填 | 说明 |
| -------------------------------------------------- | ------ | ---- | --------------------------------------------------------------------------------------------------------------------- |
| `--file <path>` | array | 是 | 本地文件路径,可重复;扩展名与大小按产品支持范围预校验(见下方格式白名单) |
| `--index-id <id>` | string | 否 | 注册后立即导入该知识库(触发第 4 步,多文件合并为一个 job) |
| `--category-id <id>` | string | 否 | 目标类目;缺省自动解析默认类目(listCategory 取 `isDefault: true`),解析失败报 GENERAL + hint 显式传 `--category-id` |
| `--tag <text>` | array | 否 | addFile tags,可重复 |
| `--wait` / `--poll-interval <s>` / `--timeout <s>` | — | 否 | 与 `--index-id` 联用,轮询 job status 至终态 |
**validate**:`--wait` 无 `--index-id` → USAGE;文件不存在/不可读 → GENERAL + errno hint(沿用错误边界规范)。
**格式白名单与大小预校验**(依据 data/documents.md「支持的格式」,读文件前拦截,避免白传 OSS):
| 类型 | 扩展名 | 硬限(超限 USAGE) |
| ------ | -------------------------- | ----------------------------------------------------- |
| 文档 | .doc .docx .ppt .pptx .pdf | 150 MB |
| 表格 | .xls .xlsx | 10 MB(产品为“建议值”,超限降级为 stderr 警告不拦截) |
| 图片 | .png .jpg .jpeg .bmp .gif | 20 MB(尺寸约束不做客户端校验,留服务端) |
| 纯文本 | .md .txt .html | 10 MB(同表格,警告不拦截) |
- 扩展名不在白名单 → USAGE,错误信息列出支持格式;白名单常量独立导出便于后续随产品更新
- 开放问题:create-kb.md 提及 .csv 但 documents.md 格式表未列——文档口径不一致,实现前向产品确认;确认前 .csv 暂入白名单(服务端拒绝会透传)
**输出**:
- text:每文件一行 `<fileName> <fileId> registered`;有导入时追加 `job: <ingestionId>`;--wait 结束追加终态
- json:`{ files: [{path, fileId}], index_id?, ingestion_id?, final_status? }`(编排命令无单一响应可透传,输出自定义稳定结构)
- quiet:仅 fileId 每行一个
**实现方案**:
- 文件 `doc-upload.ts`;多文件串行执行 1-3 步(首版不并发,避免 OSS 限流复杂化),全部注册成功后合并执行第 4 步
- 部分失败语义:任一文件步骤 1-3 失败即中止并报错,已成功的 fileId 列入错误 hint(幂等重传代价低)
- 默认类目解析结果进程内缓存(多文件只查一次)
- dry-run:不读文件内容(size/md5 以占位符表示),输出四步编排计划 `{ steps: [{step, endpoint, request}] }`
**测试方案**:
- help / 缺 `--file` exitCode 2 / `--wait` 无 `--index-id` exitCode 2
- 文件不存在 → 非零退出 + ENOENT hint;`.zip` 扩展名 → USAGE 列出支持格式
- dry-run:断言 steps 长度(带/不带 --index-id 为 4/3)、lease 请求 `sizeBytes` 为字符串类型、job 请求含 `sourceType: "DATA_CENTER_FILE"`
- live:上传 1KB 临时 md 文件 → 断言 fileId 前缀 `file_` → afterAll doc delete + 数据中心 deleteFile 清理
## doc list — 查询知识库文档列表
**说明**:列出库内文档及解析/索引状态,含 FAILED 发现(S2.3 / S5.2)。
**API**:`GET /api/v1/indices/rag/index/files`,query string:`index_id` + `page_num`(注意本接口是 page_num)+ `page_size`(默认 10,最大 100)。
**Flags**:`--index-id` 必填;`--page-number` / `--page-size`。
**输出**:
- text:每行 `doc_id status doc_name doc_type size`;status=FAILED 行红色高亮(TTY);尾行 `total: N`
- json 透传;quiet 仅 doc_id
**实现/测试**:单 API 直映射(`doc-list.ts`);dry-run 断言 query 参数名为 `page_num`;live 断言 rows 结构与 doc_id 前缀。
## doc status — 查询导入任务状态
**说明**:查导入任务进度,`--wait` 阻塞至终态供脚本串行(S2.3 痛点:高,L3 验收:FAILED 时非零 exit code)。
**API**:`GET /api/v1/indices/rag/index_job/status`,query string:`index_id` + `job_id`(**双必填,仅传其一服务端返回 SystemError,客户端前置双校验拦截**)+ 分页参数。
**Flags**:
| flag | 必填 | 说明 |
| -------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------- |
| `--index-id <id>` | 是 | 知识库 ID |
| `--job-id <id>` | 是 | 导入任务 ID(kb create / doc upload 返回的 ingestionId;也见 doc list 的 ingestion_id) |
| `--page-number` / `--page-size` | 否 | 任务含大量文档时分页 |
| `--wait` / `--poll-interval <s>`(默认 5) / `--timeout <s>`(默认 600) | 否 | 轮询至终态 |
**行为**:
- 终态 FINISH → exit 0;FAILED → `BailianError(GENERAL)` 透传服务端 message(含文档级失败明细摘要),exit 1
- `--wait` 超时 → TIMEOUT(5)
- 已知行为:库无进行中任务时接口可能返回 SystemError——hint 引导 "check ingestion_id via doc list"
**输出**:text 顶部任务总状态 + 文档级状态列表(FAILED 高亮);json 透传。
**测试方案**:help / 缺任一必填(两条用例)/ dry-run 断言 query 含两个 id / live:配合 upload 用例拿真实 job 轮询到 FINISH;`--wait --timeout 1` 对慢任务断言 exitCode 5(若不稳定则仅静态覆盖超时路径,live 标记 skip 原因)。
## doc delete — 删除文档【危险操作】
**说明**:从知识库删除文档及其全部切片(S5.1 内容更新循环)。
**API**:`POST /api/v1/indices/rag/index/delete_file`,body `{ index_id, doc_ids }`(snake_case)。响应 `data.deleted[]` 为实际删除列表。
**Flags**:`--index-id` 必填;`--doc-id` array 必填(可重复);`--yes`。
**实现方案**:`doc-delete.ts`;确认摘要含 index_id + doc_id 列表(≤5 个全列,超出显示前 5 + 总数);输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。
**测试方案**:help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 非 TTY 无 `--yes` exitCode 2 / live 配合 upload 清理链。
## doc tag — 批量更新文档标签
**说明**:批量打标,支撑标签过滤检索(S2.4)。
**API**:`POST /api/v1/connector/dash/batchUpdateFileTag`。`fileInfos`(1-20 项,每项 `fileId` + `tags`,单标签 ≤32 字符、单文件 ≤100 个、总长 ≤700)+ `updateMode`(OVERWRITE/APPEND)。
**Flags**:
| flag | 必填 | 说明 |
| --------------- | ---- | ------------------------------------------------------------------------- |
| `--doc-id <id>` | 是 | 可重复,1-20 个(客户端预校验),映射 fileInfos[].fileId |
| `--tag <text>` | 是 | 可重复,应用到所有 `--doc-id`(首版同一组标签批量打;异构标签用多次调用) |
| `--mode <m>` | 否 | choices: `overwrite`/`append`,默认 `append`(追加比覆盖安全,作为缺省) |
**实现/测试**:`doc-tag.ts` 单 API 直映射;客户端预校验标签长度约束(USAGE 前置拦截);dry-run 断言 `updateMode: "APPEND"` 大写映射与 fileInfos 结构;live 打标后 listFile/describeFile 验证回读。
## doc import-oss — 从授权 OSS 批量导入
**说明**:从已 SLR 授权的 OSS Bucket 批量导入数据中心(大客户批量场景)。
**API**:`POST /api/v1/connector/dash/addFilesFromAuthorizedOss`。必填 `categoryId/categoryType/ossBucket/ossRegionId/fileDetails`(1-10 项,每项 `fileName+ossKey`)。返回 `data.fileIds`。
**Flags**:
| flag | 必填 | 说明 |
| -------------------- | ---- | -------------------------------------------- |
| `--bucket <name>` | 是 | 映射 ossBucket |
| `--region <id>` | 是 | 映射 ossRegionId(如 cn-beijing) |
| `--oss-key <key>` | 是 | 可重复,1-10 个;fileName 取 key 的 basename |
| `--category-id <id>` | 否 | 缺省走默认类目解析(复用 upload 的解析函数) |
| `--tag <text>` | 否 | 可重复,≤10 |
| `--overwrite` | 否 | switch,映射 overWriteFileByOssKey |
固定值:`categoryType: "UNSTRUCTURED"`;`parser` 不暴露(默认 AUTO_SELECT,审慎原则——DASH_QWEN_VL_PARSER 等需配 parserConfig,使用方式未验证)。
**错误边界**:SLR 未授权的服务端权限错误原样透传,hint 附 RAM 控制台确认 `AliyunServiceRoleForBailian` 的指引(该指引来自 API 文档 Note,属可权威解释范围)。
**实现/测试**:`doc-import-oss.ts` 单 API 直映射;dry-run 断言 fileDetails 结构与 fileName 派生逻辑;live 依赖 OSS 授权环境,gating 追加 `BAILIAN_E2E_OSS_BUCKET` 环境变量,无则 skip。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.15.0",
"version": "1.16.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+90 -2
View File
@@ -33,6 +33,37 @@ import {
knowledgeRetrieve,
knowledgeSearch,
knowledgeChat,
knowledgeKbList,
knowledgeKbInfo,
knowledgeDocList,
knowledgeDocStatus,
knowledgeDocUpload,
knowledgeKbCreate,
knowledgeKbUpdate,
knowledgeKbDelete,
knowledgeDocDelete,
knowledgeDocTag,
knowledgeServiceList,
knowledgeServiceGet,
knowledgeServiceCreate,
knowledgeServiceUpdate,
knowledgeServiceDeploy,
knowledgeServiceDelete,
knowledgeServiceCopy,
knowledgeChunkAdd,
knowledgeChunkList,
knowledgeChunkUpdate,
knowledgeChunkDelete,
knowledgeKbStats,
knowledgeCategoryList,
knowledgeCategoryAdd,
knowledgeCategoryDelete,
knowledgeFileList,
knowledgeFileGet,
knowledgeFileDelete,
knowledgeCollectionCreate,
knowledgeCollectionGet,
knowledgeDocImportOss,
mcpCall,
mcpList,
mcpTools,
@@ -53,9 +84,12 @@ import {
modelList,
workspaceList,
quotaList,
quotaRequest,
quotaUpdate,
quotaHistory,
quotaCheck,
permissionList,
permissionGrant,
permissionRevoke,
datasetUpload,
datasetList,
datasetGet,
@@ -64,6 +98,7 @@ import {
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
finetuneVideoCreate,
finetuneList,
finetuneGet,
finetuneCancel,
@@ -73,6 +108,7 @@ import {
finetuneExport,
finetuneWatch,
finetuneCapability,
finetunePrice,
deployTextCreate,
deployAudioCreate,
deployImageCreate,
@@ -82,6 +118,8 @@ import {
deployScale,
deployUpdate,
deployDelete,
deployPause,
deployResume,
tokenPlanListSeats,
tokenPlanCreateKey,
tokenPlanAssignSeats,
@@ -154,6 +192,39 @@ export const commands: Record<string, AnyCommand> = {
"knowledge retrieve": knowledgeRetrieve,
"knowledge search": knowledgeSearch,
"knowledge chat": knowledgeChat,
"knowledge list": knowledgeKbList,
"knowledge info": knowledgeKbInfo,
"knowledge create": knowledgeKbCreate,
"knowledge update": knowledgeKbUpdate,
"knowledge delete": knowledgeKbDelete,
"knowledge doc list": knowledgeDocList,
"knowledge doc status": knowledgeDocStatus,
"knowledge doc upload": knowledgeDocUpload,
"knowledge doc delete": knowledgeDocDelete,
"knowledge doc tag": knowledgeDocTag,
"knowledge service list": knowledgeServiceList,
"knowledge service get": knowledgeServiceGet,
"knowledge service create": knowledgeServiceCreate,
"knowledge service update": knowledgeServiceUpdate,
"knowledge service deploy": knowledgeServiceDeploy,
"knowledge service delete": knowledgeServiceDelete,
"knowledge service copy": knowledgeServiceCopy,
"knowledge chunk add": knowledgeChunkAdd,
"knowledge chunk list": knowledgeChunkList,
"knowledge chunk update": knowledgeChunkUpdate,
"knowledge chunk delete": knowledgeChunkDelete,
"knowledge stats": knowledgeKbStats,
"knowledge doc import-oss": knowledgeDocImportOss,
// Data-center commands live under knowledge (no separate connector namespace);
// the user-facing term for connector is "collection".
"knowledge collection create": knowledgeCollectionCreate,
"knowledge collection get": knowledgeCollectionGet,
"knowledge category list": knowledgeCategoryList,
"knowledge category add": knowledgeCategoryAdd,
"knowledge category delete": knowledgeCategoryDelete,
"knowledge file list": knowledgeFileList,
"knowledge file get": knowledgeFileGet,
"knowledge file delete": knowledgeFileDelete,
"mcp call": mcpCall,
"mcp list": mcpList,
"mcp tools": mcpTools,
@@ -174,9 +245,12 @@ export const commands: Record<string, AnyCommand> = {
"model list": modelList,
"workspace list": workspaceList,
"quota list": quotaList,
"quota request": quotaRequest,
"quota update": quotaUpdate,
"quota history": quotaHistory,
"quota check": quotaCheck,
"permission list": permissionList,
"permission grant": permissionGrant,
"permission revoke": permissionRevoke,
"dataset upload": datasetUpload,
"dataset list": datasetList,
"dataset get": datasetGet,
@@ -185,6 +259,7 @@ export const commands: Record<string, AnyCommand> = {
"finetune text create": finetuneTextCreate,
"finetune audio create": finetuneAudioCreate,
"finetune image create": finetuneImageCreate,
"finetune video create": finetuneVideoCreate,
"finetune list": finetuneList,
"finetune get": finetuneGet,
"finetune cancel": finetuneCancel,
@@ -194,6 +269,7 @@ export const commands: Record<string, AnyCommand> = {
"finetune export": finetuneExport,
"finetune watch": finetuneWatch,
"finetune capability": finetuneCapability,
"finetune price": finetunePrice,
"deploy text create": deployTextCreate,
"deploy audio create": deployAudioCreate,
"deploy image create": deployImageCreate,
@@ -203,6 +279,8 @@ export const commands: Record<string, AnyCommand> = {
"deploy scale": deployScale,
"deploy update": deployUpdate,
"deploy delete": deployDelete,
"deploy pause": deployPause,
"deploy resume": deployResume,
"token-plan list-seats": tokenPlanListSeats,
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
@@ -235,3 +313,13 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent session events": managedAgentSessionEvents,
"managed-agent skill-list": managedAgentSkillList,
};
/**
* Runtime-only aliases for renamed commands: dispatched by the CLI (merged in
* main.ts) but kept out of the canonical map so generate-reference.ts only
* documents the canonical path.
*/
export const commandAliases: Record<string, AnyCommand> = {
// Pre-migration name of "quota update".
"quota request": quotaUpdate,
};
+12 -9
View File
@@ -1,5 +1,5 @@
import { createCli } from "bailian-cli-runtime";
import { commands } from "./commands.ts";
import { commandAliases, commands } from "./commands.ts";
import { commandPackPolicy } from "./command-pack-policy.ts";
import pkg from "../package.json" with { type: "json" };
@@ -10,11 +10,14 @@ const quickStartTasks = [
"Help me analyze this video and write a Xiaohongshu-style post",
] as const;
void createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
}).run();
void createCli(
{ ...commands, ...commandAliases },
{
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
},
).run();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.15.0",
"version": "1.16.0",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, deleteDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
fileId: {
@@ -19,20 +19,18 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const fileId = flags.fileId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "dataset.delete", file_id: fileId }, format);
emitResult({ action: "dataset.delete", file_id: fileId }, "json");
return;
}
const response = await deleteDataset(ctx.client, fileId);
if (settings.quiet || format === "text") {
emitBare(`Deleted ${fileId}.`);
emitRequestId(response.request_id, settings.quiet);
if (settings.quiet) {
emitBare(fileId);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
+7 -17
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const GET_FLAGS = {
fileId: {
@@ -19,10 +19,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const fileId = flags.fileId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "dataset.get", file_id: fileId }, format);
emitResult({ action: "dataset.get", file_id: fileId }, "json");
return;
}
@@ -45,19 +44,10 @@ export default defineCommand({
description: file.description ?? "",
};
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
if (settings.quiet) {
emitBare(item.file_id);
} else {
emitResult({ ...item, request_id: response.request_id }, "json");
}
// text / quiet
emitBare(`file_id: ${item.file_id}`);
emitBare(`name: ${item.name}`);
emitBare(`size: ${item.size}`);
if (item.md5) emitBare(`md5: ${item.md5}`);
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
if (item.description) emitBare(`description: ${item.description}`);
emitRequestId(response.request_id, settings.quiet);
},
});
+7 -19
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDatasets, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -23,7 +23,6 @@ export default defineCommand({
exampleArgs: ["", "--purpose fine-tune", "--purpose evaluation --page-size 20", "--output json"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -33,7 +32,7 @@ export default defineCommand({
page_size: flags.pageSize,
purpose: flags.purpose,
},
format,
"json",
);
return;
}
@@ -46,7 +45,6 @@ export default defineCommand({
const files = response.data?.files ?? [];
const total = response.data?.total;
// Normalize to consistent structure for both text/json output.
const items = files.map((item) => ({
file_id: item.file_id ?? "",
name: item.name ?? "",
@@ -54,20 +52,10 @@ export default defineCommand({
purpose: item.purpose ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
if (settings.quiet) {
for (const item of items) emitBare(item.file_id);
} else {
emitResult({ items, total, request_id: response.request_id }, "json");
}
// text / quiet
if (items.length === 0) {
emitBare("No dataset files found.");
return;
}
const headers = ["FILE_ID", "NAME", "SIZE", "PURPOSE"];
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -1,23 +1,23 @@
import {
defineCommand,
detectOutputFormat,
uploadDataset,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
MAX_DATASET_BYTES,
MAX_CPT_BYTES,
MAX_MEDIA_ZIP_BYTES,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const UPLOAD_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image)",
description: "Local dataset file (.jsonl or .zip; ≤200MB SFT/DPO, ≤300MB CPT, ≤2GB media zip)",
required: true,
},
purpose: {
@@ -29,7 +29,7 @@ const UPLOAD_FLAGS = {
type: "string",
valueHint: "<s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record.',
},
noValidate: {
type: "switch",
@@ -45,7 +45,7 @@ export default defineCommand({
description: "Upload a dataset file (.jsonl or .zip) to Bailian",
auth: "apiKey",
usageArgs:
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image>] [--no-validate] [--full-validate]",
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image|video>] [--no-validate] [--full-validate]",
flags: UPLOAD_FLAGS,
exampleArgs: [
"--file train.jsonl",
@@ -58,13 +58,14 @@ export default defineCommand({
],
notes: [
"Supports .jsonl (text) and .zip (audio/image archives with a data.jsonl",
"manifest). Five record schemas are recognized: chatml = {messages:[...]}",
"manifest). Six record schemas are recognized: chatml = {messages:[...]}",
'(SFT); dpo = {messages:[...], chosen, rejected}; cpt = {text:"..."}',
'(continual pre-training, raw text); tts = {wav_fn:"train/xxx.wav",',
'text:"..."} (audio fine-tuning); image = {img_path:"..."} (image',
"generation). With no --schema, a record carrying wav_fn is validated as",
"TTS, img_path as image, chosen/rejected as DPO, text (no messages) as CPT,",
"otherwise ChatML. Upload cap: 300MB text, 1GB image. Upload uses the",
"generation); video = {first_frame_path:...} (video generation). With no",
"--schema, a record carrying wav_fn is validated as TTS, img_path as image,",
"chosen/rejected as DPO, text (no messages) as CPT, otherwise ChatML.",
"Upload cap: 200MB SFT/DPO text, 300MB CPT, 2GB media zip. Upload uses the",
"OpenAI-compatible /compatible-mode/v1/files endpoint so the purpose tag is",
"persisted (the DashScope-native /api/v1/files drops it).",
],
@@ -73,19 +74,15 @@ export default defineCommand({
const filePath = flags.file;
const purpose = flags.purpose || "fine-tune";
const schema = parseDatasetSchemaFlag(flags.schema);
if (schema === "video") {
throw new BailianError(
`--schema video is not supported.`,
ExitCode.USAGE,
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
// Image schema allows larger ZIPs (1 GB vs 300 MB for text).
const isMediaSchema = schema === "image";
// Size caps differ per training type: SFT/DPO 200MB, CPT 300MB, media ZIP 2GB.
const isMediaSchema = schema === "image" || schema === "video";
const maxBytes = isMediaSchema
? MAX_MEDIA_ZIP_BYTES
: schema === "cpt"
? MAX_CPT_BYTES
: MAX_DATASET_BYTES;
if (!flags.noValidate) {
const maxBytes = isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES;
const result = await validateDataset(filePath, {
fullValidate: flags.fullValidate,
schema,
@@ -125,11 +122,11 @@ export default defineCommand({
action: "dataset.upload",
file: filePath,
purpose,
max_bytes: isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES,
max_bytes: maxBytes,
validate: !flags.noValidate,
schema: schema ?? "auto",
},
format,
"json",
);
return;
}
@@ -142,11 +139,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(file.file_id);
} else if (format === "text") {
emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`);
emitRequestId(request_id, settings.quiet);
} else {
emitResult({ ...file, request_id }, format);
emitResult({ ...file, request_id }, "json");
}
},
});
@@ -1,26 +1,13 @@
import {
defineCommand,
detectOutputFormat,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
BailianError,
ExitCode,
type ValidationResult,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
function formatStats(result: ValidationResult): string[] {
const out: string[] = [];
if (result.stats.totalRecords !== undefined) out.push(`records: ${result.stats.totalRecords}`);
if (result.stats.sampledRecords !== undefined)
out.push(`sampled: ${result.stats.sampledRecords}`);
if (result.stats.bytes !== undefined) out.push(`bytes: ${result.stats.bytes}`);
if (result.stats.durationMs !== undefined) out.push(`took: ${result.stats.durationMs}ms`);
return out;
}
const VALIDATE_FLAGS = {
file: {
type: "string",
@@ -36,7 +23,7 @@ const VALIDATE_FLAGS = {
type: "string",
valueHint: "<s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record.',
},
} satisfies FlagsDef;
@@ -44,13 +31,14 @@ export default defineCommand({
description: "Locally validate a dataset file (.jsonl or .zip) without uploading",
// 纯本地校验,不触网、不需 API key(与 `pipeline validate` 一致)。
auth: "none",
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image>]",
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image|video>]",
flags: VALIDATE_FLAGS,
exampleArgs: [
"--file train.jsonl",
"--file dpo.jsonl --schema dpo",
"--file cpt.jsonl --schema cpt",
"--file audio.zip --schema tts",
"--file wan-i2v-training-dataset.zip --schema video",
"--file eval.jsonl --full-validate",
"--file train.jsonl --output json",
],
@@ -60,27 +48,20 @@ export default defineCommand({
"Schemas: chatml = {messages:[...]} (SFT); dpo = {messages:[...], chosen,",
'rejected}; cpt = {text:"..."} (continual pre-training, raw text);',
'tts = {wav_fn:"train/xxx.wav", text:"..."} (audio fine-tuning);',
'image = {img_path:"..."} (image generation). With no --schema, a record',
"carrying wav_fn is validated as TTS, img_path as image, chosen/rejected",
"as DPO, text (no messages) as CPT, otherwise ChatML. Pass --schema to",
"require a specific shape on every record. ZIP archives (.zip) are",
"validated structurally (data.jsonl present, media references resolve) in",
"addition to per-record content checks. Use --full-validate to JSON.parse",
"every line.",
'image = {img_path:"..."} (image generation);',
'video = {first_frame_path:"...", video_path:"..."} (video generation,',
"i2v first-frame or kf2v first+last-frame with last_frame_path). With no",
"--schema, a record carrying wav_fn is validated as TTS, img_path as image,",
"first_frame_path/video_path as video, chosen/rejected as DPO, text (no",
"messages) as CPT, otherwise ChatML. Pass --schema to require a specific",
"shape on every record. ZIP archives (.zip) are validated structurally",
"(data.jsonl present, media references resolve) in addition to per-record",
"content checks. Use --full-validate to JSON.parse every line.",
],
async run(ctx) {
const { settings, flags } = ctx;
const filePath = flags.file;
const schema = parseDatasetSchemaFlag(flags.schema);
if (schema === "video") {
throw new BailianError(
`--schema video is not supported.`,
ExitCode.USAGE,
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
@@ -89,38 +70,17 @@ export default defineCommand({
full: flags.fullValidate,
schema: schema ?? "auto",
},
format,
"json",
);
return;
}
const result = await validateDataset(filePath, { fullValidate: flags.fullValidate, schema });
if (format === "json") {
// For json output we always emit the structured result, exit code conveys validity.
emitResult(result, format);
} else if (settings.quiet) {
if (settings.quiet) {
emitBare(result.valid ? "ok" : "fail");
} else {
const status = result.valid ? "PASSED" : "FAILED";
emitBare(`Dataset validation ${status} for ${result.filePath}`);
const stats = formatStats(result);
if (stats.length) emitBare(` ${stats.join(" · ")}`);
if (result.errors.length) {
emitBare(`Errors (${result.errors.length}):`);
for (const error of result.errors.slice(0, 20)) emitBare(formatIssue(error));
if (result.errors.length > 20) {
emitBare(` … and ${result.errors.length - 20} more.`);
}
}
if (result.warnings.length) {
emitBare(`Warnings (${result.warnings.length}):`);
for (const warning of result.warnings.slice(0, 10)) emitBare(formatIssue(warning));
if (result.warnings.length > 10) {
emitBare(` … and ${result.warnings.length - 10} more.`);
}
}
emitResult(result, "json");
}
if (!result.valid) {
+25 -39
View File
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
createDeployment,
pickPlanStrategy,
STRATEGIES,
@@ -11,16 +10,16 @@ import {
type CommandContext,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const CREATE_FLAGS = {
model: {
modelName: {
type: "string",
valueHint: "<name>",
description: "Model name (catalog model or fine-tuned output) (required)",
valueHint: "<model_name>",
description: "Model to deploy — fine-tuned output name or catalog model (required)",
required: true,
},
name: {
displayName: {
type: "string",
valueHint: "<display_name>",
description: "Console display name for the deployment (required)",
@@ -64,7 +63,7 @@ const CREATE_FLAGS = {
} satisfies FlagsDef;
const CREATE_USAGE =
"--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>]";
"--model-name <model_name> --display-name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
const CREATE_NOTES = [
"Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-",
@@ -78,14 +77,11 @@ const CREATE_NOTES = [
"Use `bl deploy models --source base` to inspect available templates.",
"After creation, status starts at PENDING and transitions to RUNNING.",
"Invoke the deployed model with: bl text chat --model <deployed_model>",
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
"values. `bl deploy <modality> create --model` takes the exported model_name",
"(e.g. `qwen3-8b-ft-...`), but the create response also returns a",
"`deployed_model` field (the deployment instance id, e.g.",
"`qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use",
"the `deployed_model` from the create response — NOT the `model_name` you",
"passed to `deploy <modality> create`. Do not reuse the value across the two",
"commands.",
"NOTE: --model-name is the model being deployed (e.g. `qwen3-8b-ft-...`).",
"The create response also returns a `deployed_model` field — the deployment",
"instance id (e.g. `qwen3-8b-5ecb5f068d79`). Use that id for inference",
"(`bl text chat --model <deployed_model>`) and lifecycle commands",
"(`deploy get/scale/pause/resume/delete --deployed-model <id>`).",
];
/**
@@ -119,10 +115,9 @@ async function runCreate(
ctx: CommandContext<typeof CREATE_FLAGS>,
): Promise<void> {
const { identity, settings, flags } = ctx;
const model = flags.model as string;
const name = flags.name as string;
const model = flags.modelName as string;
const name = flags.displayName as string;
const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality);
const format = detectOutputFormat(settings.output);
// Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves
// the plan-specific body fragment (mu may auto-pick a template from the
@@ -146,7 +141,7 @@ async function runCreate(
};
if (settings.dryRun) {
emitResult({ action: "deploy.create", body }, format);
emitResult({ action: "deploy.create", body }, "json");
return;
}
@@ -155,17 +150,8 @@ async function runCreate(
if (settings.quiet) {
emitBare(deployment?.deployed_model ?? "");
} else if (format === "text") {
emitBare(`Created deployment.`);
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
if (deployment?.status) emitBare(` status: ${deployment.status}`);
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
emitBare(
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
}
@@ -176,10 +162,10 @@ export const deployTextCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-qwen-sft --name my-sft-test",
"--model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
"--model qwen3-8b --name my-qwen3-mu --plan mu",
"--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
"--model-name my-qwen-sft --display-name my-sft-test",
"--model-name qwen3.6-flash-2026-04-16 --display-name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
"--model-name qwen3-8b --display-name my-qwen3-mu --plan mu",
"--model-name qwen3-8b --display-name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("text", flags),
@@ -193,9 +179,9 @@ export const deployAudioCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-cosyvoice-ft --name my-tts",
"--model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1",
"--model my-cosyvoice-ft --name my-tts --dry-run",
"--model-name my-cosyvoice-ft --display-name my-tts",
"--model-name my-cosyvoice-ft --display-name my-tts --deploy-spec dps-xxxx --capacity 1",
"--model-name my-cosyvoice-ft --display-name my-tts --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("audio", flags),
@@ -209,9 +195,9 @@ export const deployImageCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-wan-ft --name my-wan",
"--model my-wan-ft --name my-wan-mu --plan mu",
"--model my-wan-ft --name my-wan --dry-run",
"--model-name my-wan-ft --display-name my-wan",
"--model-name my-wan-ft --display-name my-wan-mu --plan mu",
"--model-name my-wan-ft --display-name my-wan --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("image", flags),
@@ -1,13 +1,12 @@
import {
defineCommand,
detectOutputFormat,
deleteDeployment,
getDeployment,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
deployedModel: {
@@ -38,10 +37,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, format);
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, "json");
return;
}
@@ -55,7 +53,8 @@ export default defineCommand({
if (status && status !== "STOPPED" && status !== "FAILED") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only STOPPED / FAILED deployments can be deleted. ` +
`Stop it first via the platform console, or pass --skip-precheck to attempt deletion anyway.`,
`Run \`bl deploy pause --deployed-model ${deployedModel}\` to pause it first, ` +
`or pass --skip-precheck to attempt deletion anyway.`,
ExitCode.USAGE,
);
}
@@ -69,11 +68,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
emitBare(`Deleted ${deployedModel}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
+5 -18
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const GET_FLAGS = {
deployedModel: {
@@ -22,10 +22,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "deploy.get", deployed_model: deployedModel }, format);
emitResult({ action: "deploy.get", deployed_model: deployedModel }, "json");
return;
}
@@ -33,7 +32,7 @@ export default defineCommand({
const deployment = response.output ?? response.data;
if (!deployment) {
emitBare(`No data returned for ${deployedModel}`);
emitResult({ deployed_model: deployedModel, request_id: response.request_id }, "json");
return;
}
@@ -57,18 +56,6 @@ export default defineCommand({
if (deployment.gmt_create) item.created_at = deployment.gmt_create;
if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified;
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
}
// text / quiet — fixed-width label column for alignment
const label = (key: string) => `${key}:`.padEnd(18);
for (const [key, value] of Object.entries(item)) {
if (value === "" || value === undefined) continue;
const display = typeof value === "string" ? value : JSON.stringify(value);
emitBare(`${label(key)}${display}`);
}
emitRequestId(response.request_id, settings.quiet);
emitResult({ ...item, request_id: response.request_id }, "json");
},
});
+4 -31
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listDeployments,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDeployments, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -28,13 +23,12 @@ export default defineCommand({
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const status = flags.status || undefined;
if (settings.dryRun) {
emitResult(
{ action: "deploy.list", page: flags.page, page_size: flags.pageSize, status },
format,
"json",
);
return;
}
@@ -57,27 +51,6 @@ export default defineCommand({
created_at: item.gmt_create ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet
if (items.length === 0) {
emitBare("No deployments found.");
return;
}
const headers = ["DEPLOYED_MODEL", "MODEL_NAME", "STATUS", "PLAN", "CAPACITY", "CREATED_AT"];
const rows = items.map((item) => [
item.deployed_model,
item.model_name,
item.status,
item.plan,
item.capacity,
item.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
+49 -102
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listDeployableModels,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDeployableModels, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const MODELS_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -39,7 +34,6 @@ export default defineCommand({
],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
// Default version to v1.0 — without it, the API returns the legacy catalog
// (only old fine-tune outputs). Pass --catalog-version "" to opt out.
const version = flags.catalogVersion === "" ? undefined : (flags.catalogVersion ?? "v1.0");
@@ -54,7 +48,7 @@ export default defineCommand({
version,
model_source: modelSource,
},
format,
"json",
);
return;
}
@@ -72,102 +66,55 @@ export default defineCommand({
// Two response shapes:
// - custom (fine-tuned): top-level supported_plans: string[]
// - base (catalog): plans: [{plan, templates?, cu_specs?}]
// For json: surface the deployment-relevant fields preserved as a tree, so
// Surface the deployment-relevant fields preserved as a tree, so
// downstream tooling can drive `bl deploy <modality> create --deploy-spec <…>`
// without a second round-trip. For text: keep the compact one-line summary.
if (format === "json") {
const items = models.map((model) => {
const out: Record<string, unknown> = {
model_name: model.model_name ?? "",
};
if (model.base_model) out.base_model = model.base_model;
if (model.model_source) out.model_source = model.model_source;
if (model.supported_plans && model.supported_plans.length > 0) {
out.supported_plans = model.supported_plans;
}
if (model.plans && model.plans.length > 0) {
out.plans = model.plans.map((plan) => {
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
if (plan.cu_specs && plan.cu_specs.length > 0) {
planEntry.cu_specs = plan.cu_specs;
}
if (plan.templates && plan.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = plan.templates.map((template) => {
const tpl: Record<string, unknown> = {};
if (template.template_id) tpl.template_id = template.template_id;
if (template.template_name) tpl.template_name = template.template_name;
if (template.charge_type) tpl.charge_type = template.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = template.roles?.unified;
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
if (unified?.capacity_unit_per_instance !== undefined)
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
// Preserve split-role configs (SEPERATED) as-is so callers
// can still drive prefill/decode sizing.
if (template.roles?.prefill || template.roles?.decode) {
tpl.roles = {
prefill: template.roles?.prefill,
decode: template.roles?.decode,
};
}
if (template.template_desc) tpl.template_desc = template.template_desc;
return tpl;
});
}
return planEntry;
});
}
return out;
});
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet — keep the compact single-line summary table.
const textItems = models.map((model) => {
let plansSummary = "";
if (model.supported_plans && model.supported_plans.length > 0) {
plansSummary = model.supported_plans.join(",");
} else if (model.plans && model.plans.length > 0) {
plansSummary = model.plans
.map((plan) => {
const planName = plan.plan ?? "?";
if (plan.templates && plan.templates.length > 0) {
return `${planName}(${plan.templates.length}t)`;
}
if (plan.cu_specs && plan.cu_specs.length > 0) {
return `${planName}(${plan.cu_specs.join("/")})`;
}
return planName;
})
.join(",");
} else {
plansSummary = "-";
}
return {
// without a second round-trip.
const items = models.map((model) => {
const out: Record<string, unknown> = {
model_name: model.model_name ?? "",
base_model: model.base_model ?? "",
source: model.model_source ?? "",
plans: plansSummary,
};
if (model.base_model) out.base_model = model.base_model;
if (model.model_source) out.model_source = model.model_source;
if (model.supported_plans && model.supported_plans.length > 0) {
out.supported_plans = model.supported_plans;
}
if (model.plans && model.plans.length > 0) {
out.plans = model.plans.map((plan) => {
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
if (plan.cu_specs && plan.cu_specs.length > 0) {
planEntry.cu_specs = plan.cu_specs;
}
if (plan.templates && plan.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = plan.templates.map((template) => {
const tpl: Record<string, unknown> = {};
if (template.template_id) tpl.template_id = template.template_id;
if (template.template_name) tpl.template_name = template.template_name;
if (template.charge_type) tpl.charge_type = template.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = template.roles?.unified;
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
if (unified?.capacity_unit_per_instance !== undefined)
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
// Preserve split-role configs (SEPERATED) as-is so callers
// can still drive prefill/decode sizing.
if (template.roles?.prefill || template.roles?.decode) {
tpl.roles = {
prefill: template.roles?.prefill,
decode: template.roles?.decode,
};
}
if (template.template_desc) tpl.template_desc = template.template_desc;
return tpl;
});
}
return planEntry;
});
}
return out;
});
if (textItems.length === 0) {
emitBare("No deployable models found.");
return;
}
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
const rows = textItems.map((item) => [
item.model_name,
item.base_model,
item.source,
item.plans,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
@@ -0,0 +1,85 @@
import {
defineCommand,
stopModelService,
listIndependentDeployedModels,
findDeploymentEntry,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const PAUSE_FLAGS = {
deployedModel: {
type: "string",
valueHint: "<id>",
description: "Deployed model identifier (required)",
required: true,
},
skipPrecheck: {
type: "switch",
description: "Skip the local RUNNING/PENDING status precheck",
},
} satisfies FlagsDef;
/**
* `bl deploy pause` — pause a running deployment.
*
* Takes the model service offline so it no longer serves inference requests.
* For mu/ptu plans, billing stops while paused.
* Precheck: status must be RUNNING or PENDING.
*/
export default defineCommand({
description: "Pause a running model deployment (stops billing for mu/ptu)",
auth: "console",
usageArgs: "--deployed-model <id> [--skip-precheck]",
flags: PAUSE_FLAGS,
exampleArgs: [
"--deployed-model dep-...",
"--deployed-model dep-... --skip-precheck",
"--deployed-model dep-... --dry-run",
],
notes: [
"While paused, billing ceases for mu/ptu plans. Use `deploy resume` to bring it back online or `deploy delete` to remove.",
"Precheck verifies status is RUNNING/PENDING before issuing the pause; pass --skip-precheck to bypass.",
],
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
if (settings.dryRun) {
emitResult({ action: "deploy.pause", deployed_model: deployedModel }, "json");
return;
}
// Precheck: verify the deployment is in a pausable state.
if (!flags.skipPrecheck) {
try {
const entries = await listIndependentDeployedModels(ctx.client);
const entry = findDeploymentEntry(entries, deployedModel);
if (entry) {
const status = (entry.status ?? "").toUpperCase();
if (status && status !== "RUNNING" && status !== "PENDING") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only RUNNING / PENDING deployments can be paused. ` +
`Pass --skip-precheck to attempt the pause anyway.`,
ExitCode.USAGE,
);
}
}
// If entry not found in list, proceed — the server will surface the real error.
} catch (error) {
if (error instanceof BailianError) throw error;
// If the list call itself failed, proceed and let the API call surface the error.
}
}
const response = await stopModelService(ctx.client, deployedModel);
if (settings.quiet) {
emitBare(deployedModel);
} else {
emitResult({ deployed_model: deployedModel, action: "pause", ...response }, "json");
}
},
});
@@ -0,0 +1,84 @@
import {
defineCommand,
startModelService,
listIndependentDeployedModels,
findDeploymentEntry,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const RESUME_FLAGS = {
deployedModel: {
type: "string",
valueHint: "<id>",
description: "Deployed model identifier (required)",
required: true,
},
skipPrecheck: {
type: "switch",
description: "Skip the local STOPPED status precheck",
},
} satisfies FlagsDef;
/**
* `bl deploy resume` — resume a paused deployment.
*
* Brings the model service back online so it can serve inference requests.
* Precheck: status must be STOPPED.
*/
export default defineCommand({
description: "Resume a paused model deployment (brings service back online)",
auth: "console",
usageArgs: "--deployed-model <id> [--skip-precheck]",
flags: RESUME_FLAGS,
exampleArgs: [
"--deployed-model dep-...",
"--deployed-model dep-... --skip-precheck",
"--deployed-model dep-... --dry-run",
],
notes: [
"Precheck verifies status is STOPPED before issuing the resume; pass --skip-precheck to bypass.",
"For mu/ptu plans, billing resumes once the service is back online.",
],
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
if (settings.dryRun) {
emitResult({ action: "deploy.resume", deployed_model: deployedModel }, "json");
return;
}
// Precheck: verify the deployment is in a resumable state.
if (!flags.skipPrecheck) {
try {
const entries = await listIndependentDeployedModels(ctx.client);
const entry = findDeploymentEntry(entries, deployedModel);
if (entry) {
const status = (entry.status ?? "").toUpperCase();
if (status && status !== "STOPPED") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only STOPPED deployments can be resumed. ` +
`Pass --skip-precheck to attempt the resume anyway.`,
ExitCode.USAGE,
);
}
}
// If entry not found in list, proceed — the server will surface the real error.
} catch (error) {
if (error instanceof BailianError) throw error;
// If the list call itself failed, proceed and let the API call surface the error.
}
}
const response = await startModelService(ctx.client, deployedModel);
if (settings.quiet) {
emitBare(deployedModel);
} else {
emitResult({ deployed_model: deployedModel, action: "resume", ...response }, "json");
}
},
});
+4 -15
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
scaleDeployment,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, scaleDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const SCALE_FLAGS = {
deployedModel: {
@@ -52,7 +47,6 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
const body: Record<string, unknown> = {};
if (flags.capacity !== undefined) body.capacity = flags.capacity;
@@ -60,21 +54,16 @@ export default defineCommand({
if (flags.outputTpm !== undefined) body.output_tpm = flags.outputTpm;
if (settings.dryRun) {
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, format);
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, "json");
return;
}
const response = await scaleDeployment(ctx.client, deployedModel, body);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
emitBare(`Scaled ${deployedModel}${cap}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
updateDeployment,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, updateDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const UPDATE_FLAGS = {
deployedModel: {
@@ -48,31 +43,22 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
const body: Record<string, unknown> = {};
if (flags.rpmLimit !== undefined) body.rpm_limit = flags.rpmLimit;
if (flags.tpmLimit !== undefined) body.tpm_limit = flags.tpmLimit;
if (settings.dryRun) {
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, format);
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, "json");
return;
}
const response = await updateDeployment(ctx.client, deployedModel, body);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
const parts: string[] = [];
if (deployment?.rpm_limit !== undefined) parts.push(`rpm_limit=${deployment.rpm_limit}`);
if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`);
const summary = parts.length ? ` (${parts.join(", ")})` : "";
emitBare(`Updated ${deployedModel}${summary}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, cancelFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const CANCEL_FLAGS = {
jobId: {
@@ -23,24 +23,18 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.cancel", job_id: jobId }, format);
emitResult({ action: "finetune.cancel", job_id: jobId }, "json");
return;
}
const response = await cancelFineTune(ctx.client, jobId);
const job = response.output ?? response.data;
if (settings.quiet) {
emitBare(jobId);
} else if (format === "text") {
const status = job?.status ? ` (status=${job.status})` : "";
emitBare(`Cancelled ${jobId}${status}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,15 +1,13 @@
import {
defineCommand,
detectOutputFormat,
fetchModelList,
fetchModelListAll,
fetchModelCapability,
listSupportedTrainingTypes,
modelSupportsTrainingType,
isTrainingTypeCli,
trainingTypeMethodVariant,
TRAINING_TYPES_CLI,
callConsoleGateway,
effectiveConsoleGatewayConfig,
anonymousConsoleCall,
UsageError,
type Settings,
type ModelCapability,
@@ -17,8 +15,6 @@ import {
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const PAGE_SIZE = 50;
/**
* Page through every foundation-model page (listFoundationModels, public — no
* console login needed, so the gateway is called anonymously). Returns raw
@@ -26,36 +22,12 @@ const PAGE_SIZE = 50;
* for filtering.
*/
async function fetchAllFoundationModels(settings: Settings): Promise<ModelCapability[]> {
const eff = effectiveConsoleGatewayConfig(settings);
const call = (api: string, data: Record<string, unknown>) =>
callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
settings.timeout,
{ api, data },
);
const first = await fetchModelList(call, { pageNo: 1, pageSize: PAGE_SIZE });
const all = [...first.models];
const totalPages = Math.ceil(first.total / PAGE_SIZE);
for (let pageNo = 2; pageNo <= totalPages; pageNo++) {
const result = await fetchModelList(call, { pageNo, pageSize: PAGE_SIZE });
all.push(...result.models);
}
const all = await fetchModelListAll(anonymousConsoleCall(settings));
return all as ModelCapability[];
}
const VARIANT_LABEL: Record<string, string> = {
full: "full-parameter",
lora: "LoRA",
};
function describeTrainingType(value: string): string {
if (!isTrainingTypeCli(value)) return value;
const { method, variant } = trainingTypeMethodVariant(value);
return `${VARIANT_LABEL[variant] ?? variant} ${method.toUpperCase()}`;
}
const CAPABILITY_FLAGS = {
model: {
baseModel: {
type: "string",
valueHint: "<m>",
description: "List training types supported by this base model.",
@@ -71,31 +43,31 @@ export default defineCommand({
description:
"Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it)",
auth: "none",
usageArgs: "--model <m> | --training-type <t>",
usageArgs: "--base-model <m> | --training-type <t>",
flags: CAPABILITY_FLAGS,
exampleArgs: [
"--model qwen3-8b",
"--base-model qwen3-8b",
"--training-type sft-lora",
"--training-type cpt --output json",
"--training-type sft --quiet",
],
notes: [
"Exactly one of --model / --training-type is required.",
"Exactly one of --base-model / --training-type is required.",
"Training-type values use the `<method>` / `<method>-lora` convention:",
"sft | sft-lora | dpo | dpo-lora | cpt. (cpt has no -lora variant server-side.)",
"Queries listFoundationModels, a public API — no console login needed.",
],
validate: (f) => {
if (f.model && f.trainingType)
return "--model and --training-type are mutually exclusive; pass one.";
if (!f.model && !f.trainingType) return "one of --model / --training-type is required.";
if (f.baseModel && f.trainingType)
return "--base-model and --training-type are mutually exclusive; pass one.";
if (!f.baseModel && !f.trainingType)
return "one of --base-model / --training-type is required.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const model = flags.model || undefined;
const model = flags.baseModel || undefined;
const trainingType = flags.trainingType || undefined;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -104,7 +76,7 @@ export default defineCommand({
model,
training_type: trainingType,
},
format,
"json",
);
return;
}
@@ -113,7 +85,7 @@ export default defineCommand({
if (model) {
const capability = await fetchModelCapability(settings, model);
if (!capability) {
emitBare(`No foundation model found matching "${model}".`);
emitResult({ model, error: `No foundation model found matching "${model}".` }, "json");
return;
}
const supported = listSupportedTrainingTypes(capability);
@@ -121,23 +93,15 @@ export default defineCommand({
for (const value of supported) emitBare(value);
return;
}
if (format !== "text") {
emitResult(
{
model: capability.model ?? model,
supported,
supports: capability.supports,
trainingTypes: capability.trainingTypes,
},
format,
);
return;
}
emitBare(`${capability.model ?? model}`);
emitBare(supported.length ? "Supported training types:" : "No supported training types.");
for (const value of supported) {
emitBare(` ${value.padEnd(10)} ${describeTrainingType(value)}`);
}
emitResult(
{
model: capability.model ?? model,
supported,
supports: capability.supports,
trainingTypes: capability.trainingTypes,
},
"json",
);
return;
}
@@ -162,20 +126,15 @@ export default defineCommand({
for (const entry of matched) emitBare(entry.model);
return;
}
if (format !== "text") {
emitResult(
{
training_type: trainingType,
method,
variant,
count: matched.length,
models: matched,
},
format,
);
return;
}
emitBare(`Models supporting ${trainingType} (${method} / ${variant}): ${matched.length}`);
for (const entry of matched) emitBare(` ${entry.model}`);
emitResult(
{
training_type: trainingType,
method,
variant,
count: matched.length,
models: matched,
},
"json",
);
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listCheckpoints,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listCheckpoints, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const CHECKPOINTS_FLAGS = {
jobId: {
@@ -15,6 +10,8 @@ const CHECKPOINTS_FLAGS = {
},
} satisfies FlagsDef;
const EXPIRY_WARN_THRESHOLD_MS = 72 * 60 * 60 * 1000; // 72 hours
export default defineCommand({
description: "List checkpoints produced by a fine-tune job",
auth: "apiKey",
@@ -22,16 +19,15 @@ export default defineCommand({
flags: CHECKPOINTS_FLAGS,
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
notes: [
"Use the returned `checkpoint` value with `finetune export` to publish",
"a deployable model.",
"`model_name` (shown for SUCCEEDED checkpoints) is the direct input for `deploy create --model-name`.",
"Checkpoints expire ~15 days after creation; `expire_time` shows the deadline. Export or deploy before expiry.",
],
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.checkpoints", job_id: jobId }, format);
emitResult({ action: "finetune.checkpoints", job_id: jobId }, "json");
return;
}
@@ -44,22 +40,26 @@ export default defineCommand({
checkpoint: item.checkpoint ?? item.checkpoint_id ?? "",
step: item.step !== undefined ? String(item.step) : "",
status: item.status ?? "",
model_name: item.model_name ?? "",
expire_time: item.expire_time ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
emitResult({ items, total, request_id: response.request_id }, "json");
// text / quiet
if (items.length === 0) {
emitBare("No checkpoints found.");
return;
// Near-expiry warning: check if any non-expired checkpoint is within 72h of expiry.
const now = Date.now();
const expiringSoon = items.filter((item) => {
if (!item.expire_time) return false;
const deadline = new Date(item.expire_time).getTime();
if (Number.isNaN(deadline)) return false;
const remaining = deadline - now;
return remaining > 0 && remaining < EXPIRY_WARN_THRESHOLD_MS;
});
if (expiringSoon.length > 0) {
process.stderr.write(
`\n[warning] ${expiringSoon.length} checkpoint(s) will expire within 72 hours. ` +
"Export or deploy before expiry to avoid losing the model artifact.\n",
);
}
const headers = ["CHECKPOINT", "STEP", "STATUS"];
const rows = items.map((i) => [i.checkpoint, i.step, i.status]);
for (const line of formatTable(headers, rows)) emitBare(line);
emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
createFineTune,
getDataset,
uploadDataset,
@@ -27,7 +26,7 @@ import {
} from "bailian-cli-core";
import { existsSync, statSync } from "fs";
import { basename } from "path";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
/**
* A `--datasets` / `--validations` token is treated as a local file to upload
@@ -208,7 +207,7 @@ async function uploadResolvedLocal(
}
/** The modality a `finetune <modality> create` subcommand is bound to. */
type CommandModality = "text" | "audio" | "image";
type CommandModality = "text" | "audio" | "image" | "video";
/**
* Flags shared by every `finetune <modality> create` subcommand: what to train
@@ -216,10 +215,10 @@ type CommandModality = "text" | "audio" | "image";
* output. Every modality's model consumes these.
*/
const COMMON_FLAGS = {
model: {
baseModel: {
type: "string",
valueHint: "<model>",
description: "Base model to fine-tune",
description: "Base model to fine-tune (e.g. qwen3-8b; not the output model name)",
required: true,
},
datasets: {
@@ -317,13 +316,41 @@ const IMAGE_FLAGS = {
} satisfies FlagsDef;
const TEXT_USAGE =
"--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>]";
"--base-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>]";
const AUDIO_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
const IMAGE_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
/**
* Video (Wan i2v/kf2v) flags: exposes the three hyper-parameters that the
* video API supports and users may want to override. Defaults are model-specific
* (resolved by the sft-lora profile: wan2.7 → batch_size 1 / max_pixels 102400,
* wan2.5 → 4 / 36864, wan2.2 → 4 / 262144).
*/
const VIDEO_FLAGS = {
...COMMON_FLAGS,
nEpochs: {
type: "number",
valueHint: "<n>",
description: "Training epochs (default: 50)",
},
batchSize: {
type: "number",
valueHint: "<n>",
description: "Batch size (default: model-specific, 1 for wan2.7, 4 for wan2.5/2.2)",
},
learningRate: {
type: "string",
valueHint: "<str>",
description: 'Learning rate as a string to preserve precision (default: "2e-5")',
},
} satisfies FlagsDef;
const VIDEO_USAGE =
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>]";
const COMMON_NOTES = [
"Creating a job uploads any local datasets and consumes training quota.",
@@ -383,7 +410,7 @@ async function runCreate<F extends FlagsDef>(
): Promise<void> {
const { identity, settings } = ctx;
const flags = ctx.flags as Record<string, unknown>;
const model = flags.model as string;
const model = flags.baseModel as string;
const datasetsRaw = flags.datasets as string;
// CosyVoice audio fine-tuning accepts exactly one training file
@@ -441,6 +468,10 @@ async function runCreate<F extends FlagsDef>(
if (detected === "image-i2i") modality = "image-i2i";
}
}
if (commandModality === "video" && firstLocalPath && !settings.dryRun) {
const detected = await detectModality(firstLocalPath);
if (detected === "video-kf2v") modality = "video-kf2v";
}
const training = await analyzeDatasetTokens(
settings,
@@ -606,8 +637,6 @@ async function runCreate<F extends FlagsDef>(
if (modelName) body.model_name = modelName;
if (suffix) body.finetuned_output_suffix = suffix;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const pending = [
...training.localPaths.map((path) => ({ field: "datasets", path })),
@@ -617,7 +646,7 @@ async function runCreate<F extends FlagsDef>(
pending.length > 0
? { action: "finetune.create", body, pending_uploads: pending }
: { action: "finetune.create", body },
format,
"json",
);
return;
}
@@ -627,16 +656,8 @@ async function runCreate<F extends FlagsDef>(
if (settings.quiet) {
if (job?.job_id) emitBare(job.job_id);
} else if (format === "text") {
if (job?.job_id) {
emitBare(`Created fine-tune job: ${job.job_id}`);
if (job.status) emitBare(`Status: ${job.status}`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
} else {
emitResult(response, format);
emitResult(response, "json");
}
}
@@ -647,14 +668,14 @@ export const finetuneTextCreate = defineCommand({
usageArgs: TEXT_USAGE,
flags: TEXT_FLAGS,
exampleArgs: [
"--model qwen3-8b --datasets file-xxx",
"--model qwen3-8b --datasets ./train.jsonl",
"--model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
"--model qwen3-8b --datasets file-aaa,./extra.jsonl",
"--model qwen3-8b --datasets ./train.jsonl --training-type sft",
'--model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
"--model qwen3-8b --datasets file-xxx --output json",
"--model qwen3-8b --datasets file-xxx --dry-run",
"--base-model qwen3-8b --datasets file-xxx",
"--base-model qwen3-8b --datasets ./train.jsonl",
"--base-model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
"--base-model qwen3-8b --datasets file-aaa,./extra.jsonl",
"--base-model qwen3-8b --datasets ./train.jsonl --training-type sft",
'--base-model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
"--base-model qwen3-8b --datasets file-xxx --output json",
"--base-model qwen3-8b --datasets file-xxx --dry-run",
],
notes: TEXT_NOTES,
run: (ctx) => runCreate("text", ctx),
@@ -667,11 +688,11 @@ export const finetuneAudioCreate = defineCommand({
usageArgs: AUDIO_USAGE,
flags: AUDIO_FLAGS,
exampleArgs: [
"--model cosyvoice-v3-flash --datasets ./audio.zip",
"--model cosyvoice-v3-flash --datasets file-xxx",
"--model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
"--model cosyvoice-v3-flash --datasets file-xxx --output json",
"--model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip",
"--base-model cosyvoice-v3-flash --datasets file-xxx",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
"--base-model cosyvoice-v3-flash --datasets file-xxx --output json",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
],
notes: AUDIO_NOTES,
run: (ctx) => runCreate("audio", ctx),
@@ -684,13 +705,38 @@ export const finetuneImageCreate = defineCommand({
usageArgs: IMAGE_USAGE,
flags: IMAGE_FLAGS,
exampleArgs: [
"--model wan2.7-image-pro --datasets ./images.zip",
"--model wan2.7-image-pro --datasets file-xxx",
"--model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
"--model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
"--model wan2.7-image-pro --datasets file-xxx --output json",
"--model wan2.7-image-pro --datasets ./images.zip --dry-run",
"--base-model wan2.7-image-pro --datasets ./images.zip",
"--base-model wan2.7-image-pro --datasets file-xxx",
"--base-model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
"--base-model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
"--base-model wan2.7-image-pro --datasets file-xxx --output json",
"--base-model wan2.7-image-pro --datasets ./images.zip --dry-run",
],
notes: IMAGE_NOTES,
run: (ctx) => runCreate("image", ctx),
});
const VIDEO_NOTES = [
...COMMON_NOTES,
"Video generation training (Wan i2v/kf2v) runs efficient_sft with model-",
"specific defaults: wan2.7 (batch_size=1, max_pixels=102400), wan2.5/2.2",
"(batch_size=4, max_pixels per model). Override with --batch-size/--n-epochs.",
"Datasets are .zip archives with data.jsonl + frame images + videos.",
"Recommended: ≥10 training samples, 20-100 for stable results.",
];
/** `bl finetune video create` — fine-tune a video generation model. Datasets are `.zip`. */
export const finetuneVideoCreate = defineCommand({
description: "Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft)",
auth: "apiKey",
usageArgs: VIDEO_USAGE,
flags: VIDEO_FLAGS,
exampleArgs: [
"--base-model wan2.7-i2v --datasets file-xxx",
"--base-model wan2.7-i2v --datasets ./i2v-data.zip",
"--base-model wan2.2-kf2v-flash --datasets file-xxx --n-epochs 100",
"--base-model wan2.7-i2v --datasets file-xxx --dry-run",
],
notes: VIDEO_NOTES,
run: (ctx) => runCreate("video", ctx),
});
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, deleteFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
jobId: {
@@ -23,10 +23,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.delete", job_id: jobId }, format);
emitResult({ action: "finetune.delete", job_id: jobId }, "json");
return;
}
@@ -34,11 +33,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(jobId);
} else if (format === "text") {
emitBare(`Deleted ${jobId}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
exportCheckpoint,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, exportCheckpoint, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const EXPORT_FLAGS = {
jobId: {
@@ -39,11 +34,10 @@ export default defineCommand({
"explicit export is the canonical path for non-best checkpoints.",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
const { settings, flags } = ctx;
const jobId = flags.jobId;
const checkpoint = flags.checkpoint;
const modelName = flags.modelName;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -53,7 +47,7 @@ export default defineCommand({
checkpoint,
model_name: modelName,
},
format,
"json",
);
return;
}
@@ -64,14 +58,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(exported);
} else if (format === "text") {
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
emitBare(
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -0,0 +1,105 @@
/**
* Best-effort actual training fee calculation using the model catalog's
* "ft" (fine-tune) price entry. Pure API-key domain — no console auth needed.
*
* The model catalog (`listFoundationModels` via public gateway) returns a
* `prices[]` array **only when `queryPrice: true` is passed** (the same flag
* `fetchModelDetail` uses). Combined with the job's `output.usage` (actual
* consumed tokens, present on SUCCEEDED / CANCELED), this gives the exact
* training cost without any console-domain login.
*/
import {
callConsoleGateway,
effectiveConsoleGatewayConfig,
unwrapResponse,
MODEL_LIST_API,
type Settings,
type ModelPriceInfo,
} from "bailian-cli-core";
export interface ActualFee {
cost: number;
unitPrice: number;
priceUnit: string;
}
/**
* Fetch the model's training price from the public catalog gateway.
* Uses the same anonymous gateway path as `fetchModelCapability` (no console
* token required), but adds `queryPrice: true` to include the prices array.
*/
async function fetchTrainingPrice(
settings: Settings,
model: string,
): Promise<ModelPriceInfo | null> {
const eff = effectiveConsoleGatewayConfig(settings);
const result = await callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
settings.timeout,
{
api: MODEL_LIST_API,
data: {
input: {
pageNo: 1,
pageSize: 10,
group: true,
model,
queryPrice: true,
querySampleCode: false,
queryGroupByModel: true,
queryQuota: false,
queryQpmInfo: false,
queryApplyStatus: false,
queryPermissions: false,
queryActivationStatus: false,
},
},
},
);
const responseData = unwrapResponse(result as Record<string, unknown>);
const list = (responseData.list as Record<string, unknown>[]) ?? [];
// The response is grouped; find the exact model in items.
for (const group of list) {
const items = (group.items as Record<string, unknown>[]) ?? [];
for (const item of items) {
if (item.model === model) {
const prices = (item.prices as ModelPriceInfo[]) ?? [];
return prices.find((entry) => entry.type === "ft") ?? null;
}
}
// Flat response fallback (no items nesting).
if (group.model === model) {
const prices = (group.prices as ModelPriceInfo[]) ?? [];
return prices.find((entry) => entry.type === "ft") ?? null;
}
}
return null;
}
/**
* Compute the actual training fee from the model catalog's "ft" price entry.
* Returns null when the price is unavailable (network error, model not in
* catalog, or no "ft" entry). Never throws.
*
* Only uses the public model catalog (model metadata) — does NOT call
* console-domain pricing APIs (modelCenter.getModelPrice). Models whose
* catalog entry lacks a "ft" price (e.g. CosyVoice) will simply omit the
* training_cost field until the platform adds it to the catalog.
*/
export async function computeActualFee(
settings: Settings,
model: string,
usageTokens: number,
): Promise<ActualFee | null> {
try {
const ftEntry = await fetchTrainingPrice(settings, model);
const unitPrice = Number(ftEntry?.price);
if (!Number.isFinite(unitPrice) || unitPrice <= 0) return null;
const priceUnit = ftEntry?.priceUnit ?? "每百万tokens";
// Catalog price is yuan per million tokens.
const cost = (usageTokens / 1_000_000) * unitPrice;
return { cost: Number(cost.toFixed(4)), unitPrice, priceUnit };
} catch {
return null;
}
}
+29 -32
View File
@@ -1,5 +1,6 @@
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { computeActualFee } from "./fee.ts";
const GET_FLAGS = {
jobId: {
@@ -17,12 +18,11 @@ export default defineCommand({
flags: GET_FLAGS,
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.get", job_id: jobId }, format);
emitResult({ action: "finetune.get", job_id: jobId }, "json");
return;
}
@@ -30,18 +30,24 @@ export default defineCommand({
const job = response.output ?? response.data;
if (!job) {
emitBare(`No data returned for ${jobId}`);
emitResult({ job_id: jobId, error: "No data returned" }, "json");
return;
}
const hp = job.hyper_parameters;
const hyperParameters = job.hyper_parameters;
const hyperParts: string[] = [];
if (hp?.n_epochs !== undefined) hyperParts.push(`n_epochs=${hp.n_epochs}`);
if (hp?.batch_size !== undefined) hyperParts.push(`batch_size=${hp.batch_size}`);
if (hp?.learning_rate !== undefined) hyperParts.push(`learning_rate=${hp.learning_rate}`);
if (hp?.max_length !== undefined) hyperParts.push(`max_length=${hp.max_length}`);
if (hyperParameters?.n_epochs !== undefined)
hyperParts.push(`n_epochs=${hyperParameters.n_epochs}`);
if (hyperParameters?.batch_size !== undefined)
hyperParts.push(`batch_size=${hyperParameters.batch_size}`);
if (hyperParameters?.learning_rate !== undefined)
hyperParts.push(`learning_rate=${hyperParameters.learning_rate}`);
if (hyperParameters?.max_length !== undefined)
hyperParts.push(`max_length=${hyperParameters.max_length}`);
const item = {
const usageTokens = typeof job.usage === "number" ? job.usage : undefined;
const item: Record<string, unknown> = {
job_id: job.job_id ?? jobId,
base_model: job.model ?? "",
status: job.status ?? "",
@@ -53,29 +59,20 @@ export default defineCommand({
model_name: job.model_name ?? "",
created_at: job.create_time ?? job.gmt_create ?? "",
updated_at: job.end_time ?? job.gmt_modified ?? "",
usage_tokens: usageTokens ?? "",
charge_type: typeof job.charge_type === "string" ? job.charge_type : "",
};
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
// Actual fee: only when the platform reports a concrete token count
// (SUCCEEDED / CANCELED). Best-effort — silently omitted on lookup failure.
if (usageTokens !== undefined && usageTokens > 0 && job.model) {
const fee = await computeActualFee(settings, job.model, usageTokens);
if (fee) {
item.training_cost = fee.cost;
item.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
// text / quiet
emitBare(`job_id: ${item.job_id}`);
if (item.base_model) emitBare(`base_model: ${item.base_model}`);
if (item.status) emitBare(`status: ${item.status}`);
if (item.training_type) emitBare(`training_type: ${item.training_type}`);
if (item.training_files.length) emitBare(`training_files: ${item.training_files.join(", ")}`);
if (item.validation_files.length)
emitBare(`validation_files: ${item.validation_files.join(", ")}`);
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
if (item.output_model)
emitBare(
`output_model: ${item.output_model} (→ ${identity.binName} deploy text create --model)`,
);
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ ...item, request_id: response.request_id }, "json");
},
});
+24 -47
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listFineTunes, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -13,71 +13,48 @@ const LIST_FLAGS = {
valueHint: "<s>",
description: "Filter by status (PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED)",
},
baseModel: {
type: "string",
valueHint: "<model>",
description: "Filter by base model ID (server-side)",
},
} satisfies FlagsDef;
export default defineCommand({
description: "List fine-tune jobs",
auth: "apiKey",
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>]",
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>] [--base-model <model>]",
flags: LIST_FLAGS,
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
exampleArgs: ["", "--status RUNNING", "--base-model qwen3-8b", "--page-size 20"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const { settings, flags } = ctx;
const pageNo = flags.page;
const pageSize = flags.pageSize;
const status = flags.status || undefined;
const model = flags.baseModel || undefined;
if (settings.dryRun) {
emitResult({ action: "finetune.list", page: pageNo, page_size: pageSize, status }, format);
emitResult(
{ action: "finetune.list", page: pageNo, page_size: pageSize, status, model },
"json",
);
return;
}
const response = await listFineTunes(ctx.client, { pageNo, pageSize, status });
const response = await listFineTunes(ctx.client, { pageNo, pageSize, status, model });
const payload = response.output ?? response.data;
const jobs = payload?.jobs ?? [];
const total = payload?.total;
const items = jobs.map((item) => ({
job_id: item.job_id ?? "",
base_model: item.model ?? "",
status: item.status ?? "",
training_type: item.training_type ?? "",
output_model: item.finetuned_output ?? "",
created_at: item.create_time ?? item.gmt_create ?? "",
const items = jobs.map((job) => ({
job_id: job.job_id ?? "",
base_model: job.model ?? "",
status: job.status ?? "",
training_type: job.training_type ?? "",
output_model: job.finetuned_output ?? "",
created_at: job.create_time ?? job.gmt_create ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet
if (items.length === 0) {
emitBare("No fine-tune jobs found.");
return;
}
const headers = [
"JOB_ID",
"BASE_MODEL",
"STATUS",
"TRAINING_TYPE",
"OUTPUT_MODEL",
"CREATED_AT",
];
const rows = items.map((i) => [
i.job_id,
i.base_model,
i.status,
i.training_type,
i.output_model,
i.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitBare(
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
+15 -44
View File
@@ -1,25 +1,24 @@
import {
defineCommand,
detectOutputFormat,
getFineTuneLogs,
type Client,
type FineTuneLogEntry,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult } from "bailian-cli-runtime";
/**
* Render a single log entry as a single line (mirrors the flatten logic used
* for non-search text output: prefer common fields, fall back to JSON).
* Render a single log entry as a single line (used for search matching:
* prefer common fields, fall back to JSON).
*/
function renderEntry(entry: FineTuneLogEntry | string): string {
if (typeof entry === "string") return entry;
const record = entry as Record<string, unknown>;
const ts = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
const timestamp = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
const level = (record.level ?? "") as string;
const msg = (record.message ?? record.msg ?? record.log ?? "") as string;
if (msg || ts || level) {
return [ts, level, msg].filter(Boolean).join("\t");
const message = (record.message ?? record.msg ?? record.log ?? "") as string;
if (message || timestamp || level) {
return [timestamp, level, message].filter(Boolean).join("\t");
}
return JSON.stringify(entry);
}
@@ -48,16 +47,16 @@ async function fetchAllLogs(
let total = 0;
// Hard cap to avoid an unbounded loop if the server misreports `total`.
const maxPages = 200;
for (let i = 0; i < maxPages; i++) {
for (let page = 0; page < maxPages; page++) {
const response = await getFineTuneLogs(client, jobId, { pageNo, pageSize });
const payload = response.output ?? response.data;
const page = payload?.logs ?? [];
const logs = payload?.logs ?? [];
total = payload?.total ?? total;
if (page.length === 0) break;
entries.push(...page);
if (logs.length === 0) break;
entries.push(...logs);
// Stop once we've collected everything the server claims exists.
if (total && entries.length >= total) break;
if (page.length < pageSize) break;
if (logs.length < pageSize) break;
pageNo++;
}
return { entries, total };
@@ -110,7 +109,6 @@ export default defineCommand({
const pageSize = flags.pageSize;
const search = flags.search || undefined;
const tail = flags.tail;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -122,7 +120,7 @@ export default defineCommand({
search,
tail,
},
format,
"json",
);
return;
}
@@ -147,18 +145,6 @@ export default defineCommand({
const result =
tailApplied !== undefined ? scanned.slice(scanned.length - tailApplied) : scanned;
if (settings.quiet || format === "text") {
if (result.length === 0) {
emitBare(search ? `No logs matched "${search}".` : "No logs returned.");
return;
}
for (const entry of result) emitBare(renderEntry(entry));
const parts: string[] = [`${result.length} shown`];
if (matched !== undefined) parts.push(`matched ${matched}`);
parts.push(`of ${entries.length}` + (total ? ` (total ${total})` : ""));
emitBare(`\n${parts.join(", ")}`);
return;
}
emitResult(
{
...(matched !== undefined ? { matched } : {}),
@@ -168,28 +154,13 @@ export default defineCommand({
...(tailApplied !== undefined ? { tail: tailApplied } : {}),
logs: result,
},
format,
"json",
);
return;
}
// Default: single page, verbatim response.
const response = await getFineTuneLogs(ctx.client, jobId, { pageNo, pageSize });
const payload = response.output ?? response.data;
const logs = payload?.logs ?? [];
if (settings.quiet || format === "text") {
if (logs.length === 0) {
emitBare("No logs returned.");
return;
}
for (const entry of logs) {
emitBare(renderEntry(entry));
}
if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
emitResult(response, "json");
},
});
@@ -0,0 +1,139 @@
import {
defineCommand,
fetchTrainingModelPrice,
estimateSftDpoTokens,
estimateCptTokens,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const PRICE_FLAGS = {
baseModel: {
type: "string",
valueHint: "<model>",
description: "Base model to fine-tune (e.g. qwen3-8b; not the output model name)",
required: true,
},
datasets: {
type: "string",
valueHint: "<ids>",
description: "Training dataset file IDs, comma-separated (required)",
required: true,
},
trainingType: {
type: "string",
valueHint: "<type>",
description: "Training type: sft | dpo | cpt (default: sft)",
},
nEpochs: {
type: "number",
valueHint: "<n>",
description: "Number of training epochs (default: 3)",
},
} satisfies FlagsDef;
const SUPPORTED_TRAINING_TYPES = ["sft", "dpo", "cpt"];
// Fixed hyper-parameters used for estimation. Only n_epochs materially affects
// the estimate; the rest are held at representative defaults (not exposed as
// flags to keep the command surface minimal).
const ESTIMATE_BATCH_SIZE = 16;
const ESTIMATE_MAX_LENGTH = 8192;
const DEFAULT_N_EPOCHS = 3;
export default defineCommand({
description: "Estimate the training cost for a fine-tune job (token billing)",
auth: "console",
usageArgs: "--base-model <model> --datasets <ids> [--training-type <type>] [--n-epochs <n>]",
flags: PRICE_FLAGS,
exampleArgs: [
"--base-model qwen3-8b --datasets file-ft-xxx",
"--base-model qwen3-8b --datasets file-ft-xxx,file-ft-yyy --n-epochs 2",
"--base-model qwen3-8b --datasets file-ft-xxx --training-type cpt",
],
notes: [
"Estimate only — the server computes token usage from the datasets; final cost is subject to the bill.",
"Covers token billing for sft / dpo / cpt. Training-unit (MTU) billing is not supported by this command.",
"Hyper-parameters other than --n-epochs are fixed at representative defaults for estimation.",
],
async run(ctx) {
const { settings, flags } = ctx;
const model = flags.baseModel;
const datasetIds = flags.datasets
.split(",")
.map((datasetId) => datasetId.trim())
.filter(Boolean);
const trainingType = (flags.trainingType ?? "sft").toLowerCase();
const nEpochs = flags.nEpochs ?? DEFAULT_N_EPOCHS;
if (!SUPPORTED_TRAINING_TYPES.includes(trainingType)) {
throw new BailianError(
`Unsupported training type "${trainingType}". Supported: ${SUPPORTED_TRAINING_TYPES.join(", ")}.`,
ExitCode.USAGE,
);
}
if (datasetIds.length === 0) {
throw new BailianError("--datasets must contain at least one file ID.", ExitCode.USAGE);
}
if (settings.dryRun) {
emitResult(
{ action: "finetune.price", model, datasets: datasetIds, trainingType, nEpochs },
"json",
);
return;
}
// Unit price (yuan per 千Token).
const priceInfo = await fetchTrainingModelPrice(ctx.client, model);
const unitPrice = Number(priceInfo.price);
if (!Number.isFinite(unitPrice)) {
throw new BailianError(
`No training price found for model "${model}".`,
ExitCode.GENERAL,
undefined,
{ rawResponse: JSON.stringify(priceInfo) },
);
}
// Per-epoch token estimate (min/max range).
const estimate =
trainingType === "cpt"
? await estimateCptTokens(ctx.client, model, datasetIds.join(","), nEpochs)
: await estimateSftDpoTokens(ctx.client, datasetIds, {
nEpochs,
batchSize: ESTIMATE_BATCH_SIZE,
maxLength: ESTIMATE_MAX_LENGTH,
});
const minPerEpoch = estimate.estimatedDatasetConsumedTokensMinPerEpoch ?? 0;
const maxPerEpoch = estimate.estimatedDatasetConsumedTokensMaxPerEpoch ?? 0;
const mixedMinPerEpoch = estimate.estimatedMixedConsumedTokensMinPerEpoch ?? 0;
const mixedMaxPerEpoch = estimate.estimatedMixedConsumedTokensMaxPerEpoch ?? 0;
const minTokens = (minPerEpoch + mixedMinPerEpoch) * nEpochs;
const maxTokens = (maxPerEpoch + mixedMaxPerEpoch) * nEpochs;
// price is yuan per 1000 tokens.
const minFee = (minTokens / 1000) * unitPrice;
const maxFee = (maxTokens / 1000) * unitPrice;
emitResult(
{
model,
training_type: trainingType,
n_epochs: nEpochs,
unit_price: unitPrice,
price_unit: priceInfo.priceUnit ?? "千Token",
estimated_tokens: { min: minTokens, max: maxTokens },
estimated_fee_yuan: {
min: Number(minFee.toFixed(4)),
max: Number(maxFee.toFixed(4)),
},
disclaimer: "Server-side estimate; final cost is subject to the bill.",
},
"json",
);
},
});
@@ -1,12 +1,12 @@
import {
defineCommand,
detectOutputFormat,
getFineTune,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { computeActualFee } from "./fee.ts";
const DEFAULT_INTERVAL_SEC = 10;
const MIN_INTERVAL_SEC = 1;
@@ -103,7 +103,6 @@ export default defineCommand({
const follow = flags.follow;
const intervalSec = Math.max(MIN_INTERVAL_SEC, flags.interval ?? DEFAULT_INTERVAL_SEC);
const pollTimeoutSec = flags.pollTimeout;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -114,7 +113,7 @@ export default defineCommand({
interval: intervalSec,
timeout: pollTimeoutSec,
},
format,
"json",
);
return;
}
@@ -132,16 +131,24 @@ export default defineCommand({
if (settings.quiet) {
// Just the status word — ideal for `status=$(... finetune watch ... --quiet)`.
emitBare(status || "UNKNOWN");
} else if (format === "text") {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`);
emitRequestId(response.request_id, settings.quiet);
} else {
// json: a compact, purpose-built status probe.
emitResult(
{ job_id: jobId, status: status || "UNKNOWN", terminal, request_id: response.request_id },
format,
);
const output: Record<string, unknown> = {
job_id: jobId,
status: status || "UNKNOWN",
terminal,
request_id: response.request_id,
};
// Enrich terminal output with actual fee when usage is reported.
const usageTokens = typeof job?.usage === "number" ? job.usage : undefined;
if (terminal && usageTokens && usageTokens > 0 && job?.model) {
output.usage_tokens = usageTokens;
const fee = await computeActualFee(settings, job.model as string, usageTokens);
if (fee) {
output.training_cost = fee.cost;
output.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
emitResult(output, "json");
}
if (terminal && status !== "SUCCEEDED") {
@@ -168,18 +175,28 @@ export default defineCommand({
const job = response.output ?? response.data;
const status = String(job?.status ?? "").toUpperCase();
if (format === "text" && !settings.quiet && status !== lastStatus) {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (!settings.quiet && status !== lastStatus) {
process.stderr.write(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}\n`);
lastStatus = status;
}
if (TERMINAL_STATUSES.has(status)) {
const elapsed = Date.now() - startedAt;
if (format !== "text" || settings.quiet) {
emitResult(response, format);
} else if (status === "SUCCEEDED") {
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
emitRequestId(response.request_id, settings.quiet);
if (settings.quiet) {
emitBare(status || "UNKNOWN");
} else {
// Enrich the raw response with actual fee when usage is available.
const usageTokens = typeof job?.usage === "number" ? job.usage : undefined;
const enriched: Record<string, unknown> = { ...response };
if (usageTokens && usageTokens > 0 && job?.model) {
const fee = await computeActualFee(settings, job.model as string, usageTokens);
if (fee) {
enriched.training_cost = fee.cost;
enriched.usage_tokens = usageTokens;
enriched.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
emitResult(enriched, "json");
}
if (status !== "SUCCEEDED") {
throw new BailianError(
@@ -205,7 +222,7 @@ export default defineCommand({
// Any other error (including the BailianError thrown above) propagates to
// the central handler.
if (controller.signal.aborted) {
emitBare("\nInterrupted.");
process.stderr.write("\nInterrupted.\n");
return;
}
throw error;
@@ -0,0 +1,79 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAddCategoryResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CATEGORY_ADD_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Category name (1-20 chars)",
required: true,
},
parentId: {
type: "string",
valueHint: "<id>",
description: "Create as a sub-category of this category",
},
collectionId: {
type: "string",
valueHint: "<id>",
description: "Create under this collection (defaults to the platform collection)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Create a data-center category",
auth: "apiKey",
usageArgs: "--name <text> [flags]",
flags: CATEGORY_ADD_FLAGS,
notes: ["Use categories to organize data-center files by business domain."],
exampleArgs: ["--name product-docs --workspace-id ws-xxx", "--name sub --parent-id cate-xxx"],
validate(flags) {
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// categoryType fixed to UNSTRUCTURED (the only valid value for knowledge-base creation today)
const body = {
categoryName: flags.name,
categoryType: "UNSTRUCTURED",
...(flags.parentId ? { parentCategoryId: flags.parentId } : {}),
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addCategory);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAddCategoryResponse>({
path: endpoint,
method: "POST",
body,
});
const categoryId = response.data?.categoryId;
if (settings.quiet) {
emitBare(categoryId ?? "");
return;
}
if (format === "text") {
emitBare(`created: ${categoryId ?? "-"} (${flags.name})`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,65 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagConnectorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CATEGORY_DELETE_FLAGS = {
categoryId: {
type: "string",
valueHint: "<id>",
description: "Category ID to delete",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Delete a data-center category",
auth: "apiKey",
usageArgs: "--category-id <id> [flags]",
flags: CATEGORY_DELETE_FLAGS,
notes: [
"Behavior for categories containing files or sub-categories is server-defined — the server error is passed through as-is.",
],
exampleArgs: ["--category-id cate-xxx --workspace-id ws-xxx", "--category-id cate-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { categoryId: flags.categoryId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteCategory);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
await confirmDangerousAction(
`Delete category ${flags.categoryId}\nThis cannot be undone.`,
flags.yes ?? false,
);
const response = await ctx.client.requestJson<
RagConnectorResponse<Record<string, unknown> | undefined>
>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`deleted: ${flags.categoryId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,98 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagListCategoryResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts";
const CATEGORY_LIST_FLAGS = {
collectionId: {
type: "string",
valueHint: "<id>",
description: "Filter by exact collection ID",
},
parentId: {
type: "string",
valueHint: "<id>",
description: "List sub-categories of this exact parent category",
},
name: {
type: "string",
valueHint: "<text>",
description: "Filter by category name (exact match, unlike the knowledge base list)",
},
nextToken: {
type: "string",
valueHint: "<token>",
description: "Cursor for the next page (from previous output)",
},
maxResult: {
type: "number",
valueHint: "<n>",
description: "Items per page (default: 20)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List data-center categories",
auth: "apiKey",
usageArgs: "[flags]",
flags: CATEGORY_LIST_FLAGS,
notes: [
"Categories marked [default] are where files land when no category is specified.",
"Pagination is cursor-based: reuse the printed next token to continue.",
],
exampleArgs: ["--workspace-id ws-xxx", "--name my-category", "--next-token <token>"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// type fixed to UNSTRUCTURED, not exposed as a flag (the only valid value today); note: maxResult is singular
const body = {
type: "UNSTRUCTURED",
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
...(flags.parentId ? { parentId: flags.parentId } : {}),
...(flags.name ? { categoryName: flags.name } : {}),
...(flags.nextToken ? { nextToken: flags.nextToken } : {}),
...(flags.maxResult !== undefined ? { maxResult: flags.maxResult } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listCategory);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagListCategoryResponse>({
path: endpoint,
method: "POST",
body,
});
const categories = response.data?.categoryList ?? [];
if (settings.quiet) {
for (const category of categories) emitBare(category.categoryId ?? "");
return;
}
if (format === "text") {
if (categories.length === 0) {
emitBare("No categories found.");
} else {
for (const category of categories) {
const defaultMark = category.isDefault ? " [default]" : "";
emitBare(truncateLine(`${category.categoryId} ${category.categoryName}${defaultMark}`));
}
}
const nextToken = response.data?.nextToken;
if (nextToken) emitBare(`next: --next-token ${nextToken}`);
} else {
emitResult(response, format);
}
},
});
@@ -13,6 +13,7 @@ import {
type KnowledgeChatStreamChunk,
} from "bailian-cli-core";
import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CHAT_FLAGS = {
message: {
@@ -27,11 +28,15 @@ const CHAT_FLAGS = {
description: "Q&A service ID (find in console knowledge Q&A page)",
required: true,
},
// 知识库走 workspace 专属域名,--workspace-id 属命令自有 flag(console 凭证域不适用)。
workspaceId: {
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
// flag here (the console credential scope does not apply).
...WORKSPACE_FLAG,
// Named to avoid the runtime-reserved global --version flag
agentVersion: {
type: "string",
valueHint: "<id>",
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
valueHint: "<version>",
description:
"Service version to call: beta (draft for debugging) or a published number; default is the latest published version",
},
image: {
type: "array",
@@ -146,6 +151,7 @@ export default defineCommand({
"Auth: uses DashScope API Key (Bearer token). Get yours from the console API Key page.",
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or `kscli config set workspace_id <id>`.",
'Multi-turn: use --message "user:..." and --message "assistant:..." to pass conversation history.',
"`--agent-version beta` calls the draft config for debugging before it is deployed.",
],
exampleArgs: [
'--message "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx',
@@ -168,14 +174,7 @@ export default defineCommand({
messages = [{ role: "user", content: "" }];
}
const workspaceId = flags.workspaceId || settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required.",
ExitCode.USAGE,
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
);
}
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// API only supports SSE; streamOutput controls whether to print tokens in real-time
@@ -199,6 +198,9 @@ export default defineCommand({
parameters: {
agent_options: {
agent_id: flags.agentId,
// Omitted flag → field not sent (default behavior unchanged); the value is
// not validated — the set of versions is server-side state
...(flags.agentVersion ? { agent_version: flags.agentVersion } : {}),
},
},
stream: true,
@@ -0,0 +1,165 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
import { readUtf8TextFile } from "./upload-support.ts";
const CHUNK_ADD_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
docId: {
type: "string",
valueHint: "<id>",
description:
"Owning document ID from the doc list command; required in practice for all knowledge base types",
},
content: {
type: "string",
valueHint: "<text>",
description: "Chunk body text, up to 6000 chars (document-type); alternative to --content-file",
},
contentFile: {
type: "string",
valueHint: "<path>",
description: "Read chunk body from a UTF-8 plain text file (.md/.txt etc.)",
},
title: {
type: "string",
valueHint: "<text>",
description: "Chunk title, up to 50 chars (document-type)",
},
imageUrl: {
type: "array",
valueHint: "<url>",
description: "Chunk image URL (repeatable, up to 10; document-type)",
},
field: {
type: "array",
valueHint: "<key=value>",
description:
"Arbitrary field entry (repeatable) for table/image knowledge bases where keys are Excel column headers; mutually exclusive with content/title/image flags",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Parse --field key=value: split on the first =, value may contain = */
export function parseFieldEntries(entries: string[]): Record<string, string> {
const field: Record<string, string> = {};
for (const entry of entries) {
const separatorIndex = entry.indexOf("=");
if (separatorIndex <= 0) {
throw new BailianError(`--field must be key=value, got: ${entry}`, ExitCode.USAGE);
}
field[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1);
}
return field;
}
export default defineCommand({
description: "Add a chunk directly to a knowledge base",
auth: "apiKey",
usageArgs: "--index-id <id> (--content <text> | --field <k=v>) [flags]",
flags: CHUNK_ADD_FLAGS,
notes: [
"Document / table / image knowledge bases are supported; audio-video ones are not.",
"--doc-id is required in practice for all knowledge base types. Use the document-level id from the doc list command; the per-row doc_id in chunk list output is not accepted.",
"Image-type documents do not support text chunks. Target a text-type document (docx/pdf/txt) instead.",
"The API is idempotent but rate-limited to 10 calls per second — throttle batch scripts.",
"The response carries no chunk id; list chunks afterwards to find the new one.",
"For table/image knowledge bases use --field with Excel column headers as keys; values are passed through as strings.",
],
exampleArgs: [
'--index-id idx-xxx --content "chunk text" --title intro --workspace-id ws-xxx',
"--index-id idx-xxx --field 列A=v1 --field 列B=v2",
],
validate(flags) {
const hasConvenience =
flags.content !== undefined ||
flags.contentFile !== undefined ||
flags.title !== undefined ||
!!flags.imageUrl?.length;
const hasField = !!flags.field?.length;
if (hasConvenience && hasField) {
return "--field is mutually exclusive with --content/--content-file/--title/--image-url";
}
if (!hasConvenience && !hasField) {
return "Provide chunk content via --content/--content-file or --field entries";
}
if (flags.content !== undefined && flags.contentFile !== undefined) {
return "Use either --content or --content-file, not both";
}
if (flags.content !== undefined && flags.content.length > 6000) {
return "--content must be at most 6000 characters";
}
if (flags.title !== undefined && flags.title.length > 50) {
return "--title must be at most 50 characters";
}
if (flags.imageUrl !== undefined && flags.imageUrl.length > 10) {
return "--image-url accepts at most 10 entries";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// dry-run also reads the file and parses --field (rehearsal semantics)
let field: Record<string, unknown>;
if (flags.field?.length) {
field = parseFieldEntries(flags.field);
} else {
const content =
flags.contentFile !== undefined
? readUtf8TextFile(flags.contentFile, "--content")
: flags.content;
if (typeof content === "string" && content.length > 6000) {
throw new BailianError("Chunk content must be at most 6000 characters", ExitCode.USAGE);
}
field = {
...(content !== undefined ? { content } : {}),
...(flags.title !== undefined ? { title: flags.title } : {}),
...(flags.imageUrl?.length ? { image_urls: flags.imageUrl } : {}),
};
}
const body = {
pipelineId: flags.indexId,
...(flags.docId ? { dataId: flags.docId } : {}),
field,
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkCreate);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body,
});
// The response carries no chunk_id — quiet mode exits 0 silently on success
if (settings.quiet) return;
if (format === "text") {
emitBare(`chunk created (pipeline: ${flags.indexId})`);
emitBare("List chunks to find the new chunk id.");
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,105 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
type FlagsDef,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CHUNK_DELETE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
chunkId: {
type: "array",
valueHint: "<id>",
description: "Chunk ID to delete (repeatable; batches of 10 are sent automatically)",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** The server caps each request at 10 chunk ids — the client batches automatically (bulk delete is where the CLI beats the console) */
export function splitIntoBatches(chunkIds: string[], batchSize = 10): string[][] {
const batches: string[][] = [];
for (let batchStart = 0; batchStart < chunkIds.length; batchStart += batchSize) {
batches.push(chunkIds.slice(batchStart, batchStart + batchSize));
}
return batches;
}
export default defineCommand({
description: "Delete chunks from a knowledge base (irreversible)",
auth: "apiKey",
usageArgs: "--index-id <id> --chunk-id <id> [flags]",
flags: CHUNK_DELETE_FLAGS,
notes: ["Accepts at most 10 chunk ids per call; larger sets are batched automatically."],
exampleArgs: [
"--index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --workspace-id ws-xxx",
"--index-id idx-xxx --chunk-id chunk-a --yes",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const batches = splitIntoBatches(flags.chunkId);
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkDelete);
if (settings.dryRun) {
emitResult(
{
endpoint,
batches: batches.map((batchIds) => ({
request: { pipelineId: flags.indexId, chunkIds: batchIds },
})),
},
format,
);
return;
}
await confirmDangerousAction(
`Delete ${flags.chunkId.length} chunk(s) from knowledge base ${flags.indexId} in ${batches.length} batch(es).\nChunks are permanently removed. This cannot be undone.`,
flags.yes ?? false,
);
// Sequential batches; any batch failure aborts, listing already-deleted batches in the error
let deletedCount = 0;
for (const batchIds of batches) {
try {
await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body: { pipelineId: flags.indexId, chunkIds: batchIds },
});
deletedCount += batchIds.length;
} catch (error) {
if (deletedCount > 0 && error instanceof BailianError && !error.hint) {
throw new BailianError(
error.message,
error.exitCode,
`${deletedCount} chunk(s) in earlier batches were already deleted.`,
{ cause: error, api: error.api, rawResponse: error.rawResponse },
);
}
throw error;
}
}
if (settings.quiet) return;
if (format === "text") {
emitBare(`deleted: ${deletedCount} chunk(s) in ${batches.length} batch(es)`);
return;
}
emitResult({ deleted_count: deletedCount, batches: batches.length }, format);
},
});
@@ -0,0 +1,101 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagChunkListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
const CHUNK_LIST_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
docId: {
type: "string",
valueHint: "<id>",
description: "Only show chunks belonging to this document",
},
...PAGE_FLAGS,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List chunks in a knowledge base with content and status",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: CHUNK_LIST_FLAGS,
notes: [
"Use metadata._id as the chunk id and metadata.doc_id as the document id in chunk update/delete commands.",
"Page size defaults to 20 (server default), max 100.",
],
exampleArgs: [
"--index-id idx-xxx --workspace-id ws-xxx",
"--index-id idx-xxx --doc-id file-xxx --page-size 50",
],
validate(flags) {
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
return "--page-size must be between 1 and 100";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Gotcha: this endpoint's pagination keys are pageNum/pageSize (camelCase, in the body)
const body = {
indexId: flags.indexId,
pageNum: flags.pageNumber ?? 1,
pageSize: flags.pageSize ?? 20,
...(flags.docId ? { docId: flags.docId } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkList);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagChunkListResponse>({
path: endpoint,
method: "POST",
body,
});
const nodes = response.data?.nodes ?? [];
if (settings.quiet) {
// chunk ids only, for piping into chunk update/delete
for (const node of nodes) emitBare(node.metadata?._id ?? "");
return;
}
if (format === "text") {
if (nodes.length === 0) {
emitBare("No chunks found.");
} else {
for (const node of nodes) {
const metadata = node.metadata ?? {};
const statusPart = metadata._chunk_status_message
? ` status: ${metadata._chunk_status_message}`
: "";
const excludedPart =
metadata.is_displayed_chunk_content === false ? " [excluded from retrieval]" : "";
emitBare(
`[chunk] ${metadata._id ?? "?"} (doc: ${metadata.doc_name ?? "?"}, doc_id: ${metadata.doc_id ?? "?"})${statusPart}${excludedPart}`,
);
const contentText = metadata.content ?? node.text ?? "";
emitBare(` ${contentText.length > 200 ? `${contentText.slice(0, 200)}…` : contentText}`);
}
}
emitBare(`total: ${response.data?.total ?? nodes.length}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,175 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type Client,
type FlagsDef,
type RagChunkListResponse,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
import { readUtf8TextFile } from "./upload-support.ts";
const CHUNK_UPDATE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
chunkId: {
type: "string",
valueHint: "<id>",
description: "Chunk ID (metadata._id from the chunk list output)",
required: true,
},
docId: {
type: "string",
valueHint: "<id>",
description: "Document ID owning the chunk (metadata.doc_id from the chunk list output)",
required: true,
},
content: {
type: "string",
valueHint: "<text>",
description: "New chunk content, 10-6000 chars; alternative to --content-file",
},
contentFile: {
type: "string",
valueHint: "<path>",
description: "Read new content from a UTF-8 plain text file (.md/.txt etc.)",
},
title: {
type: "string",
valueHint: "<text>",
description: "Chunk title, 0-50 chars (empty string clears it; omit to keep unchanged)",
},
exclude: { type: "switch", description: "Exclude this chunk from retrieval" },
include: { type: "switch", description: "Include this chunk in retrieval (default)" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** When only toggling include/exclude, read back the current content first (the API requires content — hide that quirk from users) */
async function fetchChunkContent(
client: Client,
workspaceId: string,
indexId: string,
chunkId: string,
docId: string,
): Promise<string> {
const maxPages = 10;
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
const response = await client.requestJson<RagChunkListResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.chunkList),
method: "POST",
body: { indexId, docId, pageNum, pageSize: 100 },
});
const nodes = response.data?.nodes ?? [];
const match = nodes.find((node) => node.metadata?._id === chunkId);
const matchContent = match?.metadata?.content ?? match?.text;
if (typeof matchContent === "string") return matchContent;
if (nodes.length < 100) break;
}
throw new BailianError(
`Chunk not found: ${chunkId}`,
ExitCode.GENERAL,
"Check the chunk id via the chunk list command.",
);
}
export default defineCommand({
description: "Update chunk content or toggle its retrieval visibility",
auth: "apiKey",
usageArgs: "--index-id <id> --chunk-id <id> --doc-id <id> [flags]",
flags: CHUNK_UPDATE_FLAGS,
notes: [
"Content must be 10-6000 characters and within the knowledge base's max chunk size.",
"--content-file expects a UTF-8 plain text file; document formats (.docx/.pdf) are not parsed here.",
"Toggling --exclude/--include without new content re-submits the existing content automatically.",
],
exampleArgs: [
'--index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --content "corrected text"',
"--index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --exclude",
],
validate(flags) {
if (flags.content !== undefined && flags.contentFile !== undefined) {
return "Use either --content or --content-file, not both";
}
if (flags.exclude && flags.include) return "--exclude and --include are mutually exclusive";
const hasContent = flags.content !== undefined || flags.contentFile !== undefined;
if (!hasContent && !flags.exclude && !flags.include && flags.title === undefined) {
return "Nothing to update — pass --content/--content-file, --title, --exclude or --include";
}
// Content lower-bound is enforced here (not deferred to run) so dry-run and
// missing-flag diagnostics surface the same error as the live request.
if (flags.content !== undefined && (flags.content.length < 10 || flags.content.length > 6000)) {
return "--content must be 10-6000 characters";
}
if (flags.title !== undefined && flags.title.length > 50) {
return "--title must be at most 50 characters";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// dry-run also reads the file and validates (rehearsal semantics); the read-back
// request is only made outside dry-run and when no new content is given
let content =
flags.contentFile !== undefined
? readUtf8TextFile(flags.contentFile, "--content")
: flags.content;
if (content !== undefined && (content.length < 10 || content.length > 6000)) {
throw new BailianError("Chunk content must be 10-6000 characters", ExitCode.USAGE);
}
if (content === undefined) {
if (settings.dryRun) {
content = "<current-content (fetched at run time)>";
} else {
content = await fetchChunkContent(
ctx.client,
workspaceId,
flags.indexId,
flags.chunkId,
flags.docId,
);
}
}
const body = {
pipelineId: flags.indexId,
chunkId: flags.chunkId,
dataId: flags.docId,
content,
// Without exclude/include the chunk stays retrievable (safe default)
isDisplayedChunkContent: !flags.exclude,
...(flags.title !== undefined ? { title: flags.title } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkUpdate);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`updated: ${flags.chunkId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,113 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAddConnectorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const COLLECTION_CREATE_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Collection name",
required: true,
},
description: {
type: "string",
valueHint: "<text>",
description: "Collection description (required by the server)",
required: true,
},
storeType: {
type: "string",
valueHint: "<type>",
description: "Storage: platform (managed) or custom (your own OSS bucket)",
},
ossRegion: {
type: "string",
valueHint: "<id>",
description: "OSS region id (required with --store-type custom)",
},
ossBucket: {
type: "string",
valueHint: "<name>",
description: "OSS bucket name (required with --store-type custom)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Create a FILE data collection",
auth: "apiKey",
usageArgs: "--name <text> --description <text> [flags]",
flags: COLLECTION_CREATE_FLAGS,
notes: [
"Store type defaults to platform (managed storage); custom uses your authorized OSS bucket.",
"Custom buckets must carry the bucket tag bailian-connector-access=ReadAndWrite (Bailian's tag-based access control); without it the server rejects creation with a misleading 'setBucketCORS failed' error.",
"There is no collection delete API — create collections deliberately.",
],
exampleArgs: [
"--name my-collection --description 'team docs' --workspace-id ws-xxx",
"--name oss-coll --description 'own bucket' --store-type custom --oss-region cn-beijing --oss-bucket my-bucket",
],
validate(flags) {
// Server rejects names longer than 20 characters ("Connector name is longer than 20")
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
const storeType = flags.storeType ?? "platform";
if (storeType !== "platform" && storeType !== "custom") {
return "--store-type must be platform or custom";
}
if (storeType === "custom" && (!flags.ossRegion || !flags.ossBucket)) {
return "--store-type custom requires --oss-region and --oss-bucket";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const storeType = (flags.storeType ?? "platform").toUpperCase();
// The server contract still uses connector* fields; only the CLI-facing term is collection.
// CUSTOM fields are regionId/bucketName per api/connector/add-connector.md (live-verified;
// the earlier ossRegionId/ossBucket naming was an implementation error, rejected with InvalidParameter).
const body = {
connectorType: "FILE",
connectorName: flags.name,
description: flags.description,
fileConnectorConfig: {
storeType,
...(storeType === "CUSTOM"
? { regionId: flags.ossRegion, bucketName: flags.ossBucket }
: {}),
},
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addConnector);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAddConnectorResponse>({
path: endpoint,
method: "POST",
body,
});
const collectionId = response.data?.connectorId;
if (settings.quiet) {
emitBare(collectionId ?? "");
return;
}
if (format === "text") {
emitBare(`created: ${collectionId ?? "-"} (${flags.name}, ${storeType})`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,75 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagGetConnectorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const COLLECTION_GET_FLAGS = {
collectionId: {
type: "string",
valueHint: "<id>",
description: "Collection ID; alternative to --name",
},
name: {
type: "string",
valueHint: "<text>",
description: "Collection name; alternative to --collection-id",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Show data collection details",
auth: "apiKey",
usageArgs: "(--collection-id <id> | --name <text>) [flags]",
flags: COLLECTION_GET_FLAGS,
exampleArgs: ["--collection-id conn-xxx --workspace-id ws-xxx", "--name my-collection"],
validate(flags) {
if (!flags.collectionId && !flags.name) return "Pass --collection-id or --name";
if (flags.collectionId && flags.name) return "Use either --collection-id or --name, not both";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// The server contract still uses connector* fields; only the CLI-facing term is collection
const body = {
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
...(flags.name ? { connectorName: flags.name } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.getConnector);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagGetConnectorResponse>({
path: endpoint,
method: "POST",
body,
});
const collection = response.data;
if (settings.quiet) {
emitBare(collection?.connectorId ?? "");
return;
}
if (format === "text") {
emitBare(`id: ${collection?.connectorId ?? "-"}`);
emitBare(`name: ${collection?.connectorName ?? "-"}`);
emitBare(`description: ${collection?.description ?? "-"}`);
// getConnector does not return fileConnectorConfig (storeType/regionId/bucketName);
// these fields are only available on the create request body.
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,96 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagDeleteFileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const DOC_DELETE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
docId: {
type: "array",
valueHint: "<id>",
description: "Document ID to delete (repeatable)",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary: list all doc_ids up to 5, otherwise show the first 5 + total count */
function buildDeleteSummary(indexId: string, docIds: string[]): string {
const listed =
docIds.length <= 5
? docIds.join("\n ")
: `${docIds.slice(0, 5).join("\n ")}\n ... (${docIds.length} documents total)`;
return `Delete ${docIds.length} document(s) from knowledge base ${indexId}:\n ${listed}\nDocuments and all their chunks are permanently removed from the index. This cannot be undone.`;
}
export default defineCommand({
description: "Delete documents and their chunks from a knowledge base",
auth: "apiKey",
usageArgs: "--index-id <id> --doc-id <id> [flags]",
flags: DOC_DELETE_FLAGS,
notes: [
"Removes documents from the knowledge base index only; the source files remain in the data center.",
"Use the doc_id from `knowledge doc list --quiet`, not the fileId from `knowledge doc upload`. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix.",
"Deletion may take up to ~30s to propagate — the document may still appear in the doc list briefly.",
"The output lists the ids actually deleted.",
],
exampleArgs: [
"--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx",
"--index-id idx-xxx --doc-id file-a --doc-id file-b --yes",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// snake_case: body { index_id, doc_ids }
const body = { index_id: flags.indexId, doc_ids: flags.docId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDeleteFile);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
await confirmDangerousAction(
buildDeleteSummary(flags.indexId, flags.docId),
flags.yes ?? false,
);
const response = await ctx.client.requestJson<RagDeleteFileResponse>({
path: endpoint,
method: "POST",
body,
});
// Output follows the server's data.deleted list
const deleted = response.data?.deleted ?? [];
if (settings.quiet) {
for (const docId of deleted) emitBare(docId);
return;
}
if (format === "text") {
emitBare(`deleted: ${deleted.length} document(s)`);
for (const docId of deleted) emitBare(` ${docId}`);
if (deleted.length !== flags.docId.length) {
process.stderr.write(
`Warning: requested ${flags.docId.length} deletion(s) but the server reported ${deleted.length}.\n`,
);
}
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,117 @@
import { basename } from "node:path";
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagOssImportResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const DOC_IMPORT_OSS_FLAGS = {
bucket: {
type: "string",
valueHint: "<name>",
description: "Authorized OSS bucket name",
required: true,
},
region: {
type: "string",
valueHint: "<id>",
description: "OSS region id (e.g. cn-beijing)",
required: true,
},
ossKey: {
type: "array",
valueHint: "<key>",
description: "OSS object key to import (repeatable, 1-10 per call)",
required: true,
},
categoryId: {
type: "string",
valueHint: "<id>",
description: "Target data-center category (default: the default category)",
},
tag: {
type: "array",
valueHint: "<text>",
description: "File tag applied to every imported file (repeatable, up to 10)",
},
overwrite: {
type: "switch",
description: "Overwrite files previously imported from the same OSS keys",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Batch import files from an authorized OSS bucket into the data center",
auth: "apiKey",
usageArgs: "--bucket <name> --region <id> --oss-key <key> [flags]",
flags: DOC_IMPORT_OSS_FLAGS,
notes: [
"The bucket must be authorized to the platform service role beforehand; permission errors from the server are passed through with a pointer to check AliyunServiceRoleForBailian in the RAM console.",
"File names are derived from the OSS key basename.",
"--overwrite replaces the previously imported file and issues a NEW fileId (the old one becomes invalid) — verified live.",
],
exampleArgs: [
"--bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --workspace-id ws-xxx",
"--bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --oss-key docs/b.docx --overwrite",
],
validate(flags) {
if (flags.ossKey.length > 10) return "--oss-key accepts at most 10 entries per call";
if (flags.tag !== undefined && flags.tag.length > 10) {
return "--tag accepts at most 10 entries";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// categoryType fixed to UNSTRUCTURED; parser not exposed as a flag (defaults to AUTO_SELECT)
const body = {
categoryId: flags.categoryId ?? "default",
categoryType: "UNSTRUCTURED",
ossBucket: flags.bucket,
ossRegionId: flags.region,
fileDetails: flags.ossKey.map((ossKey) => ({ fileName: basename(ossKey), ossKey })),
...(flags.tag?.length ? { tags: flags.tag } : {}),
...(flags.overwrite ? { overWriteFileByOssKey: true } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addFilesFromAuthorizedOss);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagOssImportResponse>({
path: endpoint,
method: "POST",
body,
});
// Live-verified shape: results come back as addFileResultList (the docs' flat
// fileIds field is not returned); per-file status is SUCCESS on success
const results = response.data?.addFileResultList ?? [];
const fileIds = results
.map((result) => result.fileId)
.filter((fileId): fileId is string => !!fileId);
if (settings.quiet) {
for (const fileId of fileIds) emitBare(fileId);
return;
}
if (format === "text") {
emitBare(`imported: ${fileIds.length} file(s)`);
for (const result of results) {
emitBare(` ${result.fileId ?? "-"} ${result.status ?? "-"} ${result.ossKey ?? ""}`);
}
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,83 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagIndexFilesResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, ansi } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
const DOC_LIST_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
...PAGE_FLAGS,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List documents in a knowledge base with parse/index status",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: DOC_LIST_FLAGS,
notes: [
"Documents with status FAILED are highlighted in text mode — use the import job status command to inspect failures.",
"Page size defaults to 10 (server default), max 100.",
],
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx", "--index-id idx-xxx --page-size 100"],
validate(flags) {
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
return "--page-size must be between 1 and 100";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Gotcha: this endpoint's page parameter is page_num (not page_number)
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles));
url.searchParams.set("index_id", flags.indexId);
url.searchParams.set("page_num", String(flags.pageNumber ?? 1));
url.searchParams.set("page_size", String(flags.pageSize ?? 10));
const endpoint = url.toString();
if (settings.dryRun) {
emitResult({ endpoint, request: null }, format);
return;
}
const response = await ctx.client.requestJson<RagIndexFilesResponse>({
path: endpoint,
method: "GET",
});
const rows = response.data?.rows ?? [];
if (settings.quiet) {
for (const row of rows) emitBare(row.doc_id ?? "");
return;
}
if (format === "text") {
const styles = ansi(process.stdout);
if (rows.length === 0) {
emitBare("No documents found.");
} else {
for (const row of rows) {
const line = truncateLine(
[row.doc_id, row.status, row.doc_name, row.doc_type ?? "-", row.size ?? "-"].join(" "),
);
emitBare(row.status === "FAILED" ? styles.red(line) : line);
}
}
emitBare(`total: ${response.data?.total_count ?? rows.length}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,124 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagIndexJobStatusResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, ansi } from "bailian-cli-runtime";
import {
resolveWorkspaceId,
PAGE_FLAGS,
WORKSPACE_FLAG,
failedImportDocs,
importJobFailureMessage,
pollImportJob,
} from "./shared.ts";
const DOC_STATUS_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
jobId: {
type: "string",
valueHint: "<id>",
description: "Import job ID (ingestionId returned by import commands)",
required: true,
},
...PAGE_FLAGS,
wait: { type: "switch", description: "Poll until the job reaches a terminal state" },
pollInterval: {
type: "number",
valueHint: "<seconds>",
description: "Polling interval when waiting (default: 5)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
function printStatus(response: RagIndexJobStatusResponse): void {
const styles = ansi(process.stdout);
emitBare(`status: ${response.data?.ingestion_status ?? "UNKNOWN"}`);
for (const doc of response.data?.rows ?? []) {
const docState = doc.code ?? doc.status ?? "?";
const line = ` ${doc.doc_id ?? "?"} ${docState} ${doc.doc_name ?? ""}`;
emitBare(docState.includes("FAILED") ? styles.red(line) : line);
}
}
export default defineCommand({
description: "Check knowledge base import job status",
auth: "apiKey",
usageArgs: "--index-id <id> --job-id <id> [flags]",
flags: DOC_STATUS_FLAGS,
notes: [
"Both --index-id and --job-id are required (passing only one returns SystemError).",
"If you see a SystemError, the job may not exist — check the ingestion id in the document list output.",
"Overall job states are PENDING / RUNNING / COMPLETED; per-document failures (for example PARSE_FAILED) exit non-zero with the error message passed through.",
],
exampleArgs: [
"--index-id idx-xxx --job-id job-xxx --workspace-id ws-xxx",
"--index-id idx-xxx --job-id job-xxx --wait --poll-interval 10",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Both required flags are enforced by the parser up front; parameters go in
// the query string (they are ignored in the body)
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexJobStatus));
url.searchParams.set("index_id", flags.indexId);
url.searchParams.set("job_id", flags.jobId);
if (flags.pageNumber !== undefined) {
url.searchParams.set("page_number", String(flags.pageNumber));
}
if (flags.pageSize !== undefined) {
url.searchParams.set("page_size", String(flags.pageSize));
}
const endpoint = url.toString();
if (settings.dryRun) {
emitResult({ endpoint, request: null }, format);
return;
}
let response: RagIndexJobStatusResponse;
if (flags.wait) {
// Reuse the shared polling (timeout → TIMEOUT(5)); failure detection happens
// uniformly after return, based on per-document status
response = await pollImportJob(ctx.client, settings, {
statusUrl: endpoint,
intervalSec: flags.pollInterval ?? 5,
});
} else {
response = await ctx.client.requestJson<RagIndexJobStatusResponse>({
path: endpoint,
method: "GET",
});
}
// Any per-document failure means a non-zero exit; the server message is passed through verbatim
if (failedImportDocs(response).length > 0) {
throw new BailianError(
importJobFailureMessage(response, "Import job reported document failures."),
ExitCode.GENERAL,
);
}
if (settings.quiet) {
emitBare(response.data?.ingestion_status ?? "UNKNOWN");
return;
}
if (format === "text") {
printStatus(response);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,88 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagBatchUpdateTagResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const DOC_TAG_FLAGS = {
docId: {
type: "array",
valueHint: "<id>",
description: "Data-center file ID to tag (repeatable, 1-20 per call)",
required: true,
},
tag: {
type: "array",
valueHint: "<text>",
description: "Tag applied to every --doc-id (repeatable, each up to 32 chars)",
required: true,
},
mode: {
type: "string",
valueHint: "<mode>",
description: "Update mode: append (default) or overwrite",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Batch update tags on data-center files",
auth: "apiKey",
usageArgs: "--doc-id <id> --tag <text> [flags]",
flags: DOC_TAG_FLAGS,
notes: [
"The same tag set is applied to every --doc-id; run the command multiple times for different tag sets.",
"Server limits: up to 100 tags per file, total tag length up to 700 chars, tag up to 32 chars.",
],
exampleArgs: [
"--doc-id file-xxx --tag project-a --tag draft --workspace-id ws-xxx",
"--doc-id file-a --doc-id file-b --tag final --mode overwrite",
],
validate(flags) {
if (flags.docId.length > 20) return "--doc-id accepts at most 20 ids per call";
if (flags.mode !== undefined && flags.mode !== "append" && flags.mode !== "overwrite") {
return "--mode must be append or overwrite";
}
// Hard limits stated by the API contract: each tag ≤32 chars; ≤100 tags per file; total length ≤700
if (flags.tag.length > 100) return "At most 100 tags per file";
const overlongTag = flags.tag.find((tag) => tag.length > 32);
if (overlongTag) return `Tag exceeds 32 characters: ${overlongTag}`;
const totalLength = flags.tag.reduce((sum, tag) => sum + tag.length, 0);
if (totalLength > 700) return "Total tag length exceeds 700 characters";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = {
fileInfos: flags.docId.map((fileId) => ({ fileId, tags: flags.tag })),
updateMode: (flags.mode ?? "append").toUpperCase(),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.batchUpdateFileTag);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagBatchUpdateTagResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`tagged: ${flags.docId.length} file(s) with [${flags.tag.join(", ")}]`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,322 @@
// Orchestration command: local file → data center → (optional) import into a knowledge base.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagUploadLeaseResponse,
type RagAddFileResponse,
type RagJobCreateResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
resolveWorkspaceId,
WORKSPACE_FLAG,
failedImportDocs,
importJobFailureMessage,
importJobStatus,
importJobStatusUrl,
pollImportJob,
withPartialSuccessHint,
} from "./shared.ts";
import { checkUploadFile, expandUploadPaths } from "./upload-support.ts";
const DOC_UPLOAD_FLAGS = {
file: {
type: "array",
valueHint: "<path>",
description:
"Local file or directory path (repeatable). Directories are scanned recursively; unsupported formats are skipped",
required: true,
},
indexId: {
type: "string",
valueHint: "<id>",
description: "Import into this knowledge base after registration (one job for all files)",
},
categoryId: {
type: "string",
valueHint: "<id>",
description: "Target data-center category; defaults to the workspace default category",
},
tag: {
type: "array",
valueHint: "<text>",
description: "File tag (repeatable), applied to every uploaded file",
},
wait: {
type: "switch",
description: "Poll the import job to a terminal state (needs --index-id)",
},
pollInterval: {
type: "number",
valueHint: "<seconds>",
description: "Polling interval when waiting (default: 5)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
interface UploadedFile {
path: string;
fileId: string;
}
export default defineCommand({
description:
"Upload local files or directories to the data center and optionally import into a knowledge base",
auth: "apiKey",
usageArgs: "--file <path> [flags]",
flags: DOC_UPLOAD_FLAGS,
notes: [
"Pipeline: apply upload lease → PUT to OSS → register file → (with --index-id) create import job.",
"Without --category-id the workspace default category is resolved automatically.",
"Directories are scanned recursively; node_modules, .git, and similar are skipped automatically.",
"Multiple files are processed sequentially; on failure, already-registered file ids are listed in the error hint.",
],
exampleArgs: [
"--file ./a.md --workspace-id ws-xxx",
"--file ./a.md --file ./b.pdf --index-id idx-xxx --wait",
"--file ./docs/ --workspace-id ws-xxx",
"--file ./docs/ --dry-run --verbose",
],
validate(flags) {
if (flags.wait && !flags.indexId) return "--wait requires --index-id";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Expand directories into individual file paths; unsupported extensions are
// collected into `skipped` rather than throwing (directory-scan semantics)
const { files: expandedFiles, skipped } = expandUploadPaths(flags.file);
if (expandedFiles.length === 0) {
throw new BailianError(
"No supported files found",
ExitCode.USAGE,
`Supported formats: .pdf .doc .docx .ppt .pptx .xls .xlsx .csv .md .txt .html .png .jpg .jpeg .bmp .gif`,
);
}
// Local pre-flight validation also runs in dry-run (rehearsal semantics: surface
// file problems early); exceeding a soft limit only warns
const checkedFiles = expandedFiles.map((filePath) => {
const checked = checkUploadFile(filePath);
if (checked.warning) process.stderr.write(`Warning: ${checked.warning}\n`);
return { filePath, sizeBytes: checked.sizeBytes };
});
if (settings.dryRun) {
// dry-run does not read file contents (md5 shown as a placeholder)
const categoryPlaceholder = flags.categoryId ?? "default";
const steps = checkedFiles.flatMap((checkedFile) => [
{
step: "applyFileUploadLease",
endpoint: ragEndpoint(workspaceId, RAG_PATHS.applyFileUploadLease),
request: {
category: categoryPlaceholder,
fileName: basename(checkedFile.filePath),
sizeBytes: String(checkedFile.sizeBytes), // gotcha: must be a string
contentMd5: "<md5-base64>",
} as unknown,
},
{
step: "ossPut",
endpoint: "<lease.param.url>",
request: { method: "PUT", headers: "<lease.param.headers>" } as unknown,
},
{
step: "addFile",
endpoint: ragEndpoint(workspaceId, RAG_PATHS.addFile),
request: {
leaseId: "<leaseId>",
category: categoryPlaceholder,
parser: "AUTO_SELECT",
...(flags.tag?.length ? { tags: flags.tag } : {}),
} as unknown,
},
]);
if (flags.indexId) {
steps.push({
step: "createImportJob",
endpoint: ragEndpoint(workspaceId, RAG_PATHS.indexJobCreate),
request: {
indexId: flags.indexId,
// Live-verified: the field name is docIds (not documentIds as in the
// public docs); omitting sourceType would import the entire data center.
sourceType: "DATA_CENTER_FILE",
docIds: ["<fileId>"],
} as unknown,
});
}
emitResult({ steps, skipped }, format);
return;
}
// Default category: the literal "default" is accepted by lease/addFile
// (verified against the live API), so no listCategory resolution is needed
const categoryId = flags.categoryId ?? "default";
// Multiple files run steps 1-3 sequentially (no concurrency in this version,
// to avoid OSS rate-limit complexity)
const uploaded: UploadedFile[] = [];
for (const checkedFile of checkedFiles) {
try {
const fileBuffer = readFileSync(checkedFile.filePath);
const contentMd5 = createHash("md5").update(fileBuffer).digest("base64");
// 1) Apply for an upload lease (gotcha: the category parameter is named
// category, not categoryId; sizeBytes must be a string)
const lease = await ctx.client.requestJson<RagUploadLeaseResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.applyFileUploadLease),
method: "POST",
body: {
category: categoryId,
fileName: basename(checkedFile.filePath),
sizeBytes: String(checkedFile.sizeBytes),
contentMd5,
},
});
const leaseId = lease.data?.leaseId;
const leaseParam = lease.data?.param;
if (!leaseId || !leaseParam?.url) {
throw new BailianError(
`Upload lease response missing leaseId/url for ${checkedFile.filePath}`,
ExitCode.GENERAL,
);
}
// 2) OSS upload: goes to the OSS host, not the DashScope gateway — native fetch without a Bearer header
let ossResponse: Response;
try {
ossResponse = await fetch(leaseParam.url, {
method: leaseParam.method ?? "PUT",
headers: leaseParam.headers,
body: fileBuffer,
});
} catch (error) {
const causeCode = (error as { cause?: { code?: string } }).cause?.code;
throw new BailianError(
`OSS upload failed for ${basename(checkedFile.filePath)}`,
ExitCode.NETWORK,
causeCode ? `Network error (${causeCode}).` : undefined,
{ cause: error },
);
}
if (!ossResponse.ok) {
const ossBody = await ossResponse.text().catch(() => "");
throw new BailianError(
`OSS upload rejected (HTTP ${ossResponse.status}) for ${basename(checkedFile.filePath)}${ossBody ? `: ${ossBody.slice(0, 300)}` : ""}`,
ExitCode.GENERAL,
);
}
// 3) Register the file
const added = await ctx.client.requestJson<RagAddFileResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.addFile),
method: "POST",
body: {
leaseId,
category: categoryId,
parser: "AUTO_SELECT",
...(flags.tag?.length ? { tags: flags.tag } : {}),
},
});
const fileId = added.data?.fileId;
if (!fileId) {
throw new BailianError(
`addFile response missing fileId for ${checkedFile.filePath}`,
ExitCode.GENERAL,
);
}
uploaded.push({ path: checkedFile.filePath, fileId });
} catch (error) {
// Partial-failure semantics: abort with an error, listing already-registered
// fileIds in the hint (re-uploading is cheap and idempotent)
if (uploaded.length > 0) {
throw withPartialSuccessHint(
error,
`Already registered: ${uploaded.map((item) => item.fileId).join(", ")}`,
);
}
throw error;
}
}
// 4) Optional import (merged into a single job after all files are registered)
let ingestionId: string | undefined;
let finalStatus: string | undefined;
if (flags.indexId) {
const job = await ctx.client.requestJson<RagJobCreateResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.indexJobCreate),
method: "POST",
body: {
indexId: flags.indexId,
// Live-verified: the field name is docIds (not documentIds as in the
// public docs); omitting sourceType would import the entire data center.
sourceType: "DATA_CENTER_FILE",
docIds: uploaded.map((item) => item.fileId),
},
});
ingestionId = job.data?.ingestionId;
if (flags.wait && ingestionId) {
const statusResponse = await pollImportJob(ctx.client, settings, {
statusUrl: importJobStatusUrl(workspaceId, flags.indexId, ingestionId).toString(),
intervalSec: flags.pollInterval ?? 5,
});
finalStatus = importJobStatus(statusResponse);
// Job finished but some documents failed to parse → non-zero exit, server message passed through verbatim
if (failedImportDocs(statusResponse).length > 0) {
throw new BailianError(
importJobFailureMessage(statusResponse, "Import job reported document failures."),
ExitCode.GENERAL,
`Registered file ids: ${uploaded.map((item) => item.fileId).join(", ")}`,
);
}
}
}
if (settings.quiet) {
for (const item of uploaded) emitBare(item.fileId);
return;
}
if (format === "text") {
for (const item of uploaded) {
emitBare(`${basename(item.path)} ${item.fileId} registered`);
}
if (ingestionId) emitBare(`job: ${ingestionId}`);
if (finalStatus) emitBare(`status: ${finalStatus}`);
// Summary line: always show counts; list skipped files only with --verbose
const summaryParts = [`Uploaded ${uploaded.length} file${uploaded.length !== 1 ? "s" : ""}`];
if (skipped.length > 0) {
summaryParts.push(`skipped ${skipped.length} unsupported`);
}
emitBare(`\n${summaryParts.join(", ")}.`);
if (settings.verbose && skipped.length > 0) {
emitBare("Skipped files:");
for (const skippedPath of skipped) {
emitBare(` ${basename(skippedPath)}`);
}
}
return;
}
// An orchestration command has no single response to pass through — emit a custom stable shape
emitResult(
{
files: uploaded.map((item) => ({ path: item.path, fileId: item.fileId })),
skipped,
...(flags.indexId ? { index_id: flags.indexId } : {}),
...(ingestionId ? { ingestion_id: ingestionId } : {}),
...(finalStatus ? { final_status: finalStatus } : {}),
},
format,
);
},
});
@@ -0,0 +1,88 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type Client,
type FlagsDef,
type RagConnectorResponse,
type RagDescribeFileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const FILE_DELETE_FLAGS = {
fileId: {
type: "string",
valueHint: "<id>",
description: "Data-center file ID to delete",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary lookup (file name/size); failure degrades to id-only */
async function buildDeleteSummary(
client: Client,
workspaceId: string,
fileId: string,
): Promise<string> {
let infoPart = "";
try {
const detail = await client.requestJson<RagDescribeFileResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.describeFile),
method: "POST",
body: { fileId },
});
if (detail.data?.fileName) infoPart = ` name: ${detail.data.fileName}`;
} catch {
// Degrade gracefully: a failed lookup does not block confirmation
}
return `Delete data-center file ${fileId}${infoPart}\nPERMANENT: if the file is referenced by knowledge bases, their document indexes break too. This differs from removing a document from one knowledge base.`;
}
export default defineCommand({
description: "Permanently delete a file from the data center",
auth: "apiKey",
usageArgs: "--file-id <id> [flags]",
flags: FILE_DELETE_FLAGS,
notes: [
"Irreversible. If knowledge bases reference this file, their related document indexes become invalid.",
"To remove a document from a single knowledge base only, use the document delete command instead.",
],
exampleArgs: ["--file-id file-xxx --workspace-id ws-xxx", "--file-id file-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { fileId: flags.fileId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteFile);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const summary = flags.yes
? ""
: await buildDeleteSummary(ctx.client, workspaceId, flags.fileId);
await confirmDangerousAction(summary, flags.yes ?? false);
const response = await ctx.client.requestJson<
RagConnectorResponse<Record<string, unknown> | undefined>
>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`deleted: ${flags.fileId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,67 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagDescribeFileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const FILE_GET_FLAGS = {
fileId: {
type: "string",
valueHint: "<id>",
description: "Data-center file ID",
required: true,
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Show data-center file details (size, MD5, tags, timestamps)",
auth: "apiKey",
usageArgs: "--file-id <id> [flags]",
flags: FILE_GET_FLAGS,
exampleArgs: ["--file-id file-xxx --workspace-id ws-xxx"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { fileId: flags.fileId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.describeFile);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagDescribeFileResponse>({
path: endpoint,
method: "POST",
body,
});
const file = response.data;
if (settings.quiet) {
emitBare(file?.fileId ?? "");
return;
}
if (format !== "text") {
emitResult(response, format);
return;
}
emitBare(`id: ${file?.fileId ?? "-"}`);
emitBare(`name: ${file?.fileName ?? "-"}`);
emitBare(`type: ${file?.fileType ?? "-"}`);
emitBare(`size: ${file?.sizeBytes ?? "-"}`);
emitBare(`status: ${file?.status ?? "-"}`);
emitBare(`parser: ${file?.parser ?? "-"}`);
emitBare(`category: ${file?.category ?? "-"}`);
emitBare(`uploaded: ${file?.uploadTime ?? "-"}`);
const tags = Array.isArray(file?.tags) ? file.tags.join(", ") : (file?.tags ?? "-");
emitBare(`tags: ${tags || "-"}`);
},
});
@@ -0,0 +1,104 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagListFileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts";
const FILE_LIST_FLAGS = {
categoryId: {
type: "string",
valueHint: "<id>",
description: "Category to list (find ids via the category list command); exact match",
required: true,
},
name: {
type: "string",
valueHint: "<text>",
description: "Filter by exact file name without its extension (a.md → pass a)",
},
fileId: {
type: "array",
valueHint: "<id>",
description: "Filter by exact file ID (repeatable)",
},
nextToken: {
type: "string",
valueHint: "<token>",
description: "Cursor for the next page (from previous output)",
},
maxResult: {
type: "number",
valueHint: "<n>",
description: "Items per page",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List files in a data-center category",
auth: "apiKey",
usageArgs: "--category-id <id> [flags]",
flags: FILE_LIST_FLAGS,
notes: [
"A real category id is required — the default value is not resolved here. Find the id via the category list command.",
"--name matches the exact file name without its extension (for a.md pass a); partial keywords return no results.",
"Pagination is cursor-based: reuse the printed next token to continue.",
],
exampleArgs: [
"--category-id cate-xxx --workspace-id ws-xxx",
"--category-id cate-xxx --name report",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = {
categoryId: flags.categoryId,
...(flags.name ? { fileName: flags.name } : {}),
...(flags.fileId?.length ? { fileIds: flags.fileId } : {}),
...(flags.nextToken ? { nextToken: flags.nextToken } : {}),
...(flags.maxResult !== undefined ? { maxResult: flags.maxResult } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listFile);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagListFileResponse>({
path: endpoint,
method: "POST",
body,
});
const files = response.data?.fileList ?? [];
if (settings.quiet) {
for (const file of files) emitBare(file.fileId ?? "");
return;
}
if (format === "text") {
if (files.length === 0) {
emitBare("No files found.");
} else {
for (const file of files) {
emitBare(
truncateLine(
[file.fileId, file.status ?? "-", file.fileName, file.sizeBytes ?? "-"].join(" "),
),
);
}
}
const nextToken = response.data?.nextToken;
if (nextToken) emitBare(`next: --next-token ${nextToken}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,166 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagCreateIndexV2Response,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
resolveWorkspaceId,
WORKSPACE_FLAG,
failedImportDocs,
importJobFailureMessage,
importJobStatus,
importJobStatusUrl,
pollImportJob,
} from "./shared.ts";
const KB_CREATE_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Knowledge base name (1-20 chars, unique in workspace)",
required: true,
},
docId: {
type: "array",
valueHint: "<id>",
description:
"Data-center file id to import (repeatable); mutually exclusive with --category-id",
},
categoryId: {
type: "array",
valueHint: "<id>",
description:
"Import every file under this category (repeatable); mutually exclusive with --doc-id",
},
embeddingModel: {
type: "string",
valueHint: "<name>",
description: "Embedding model name (default: text-embedding-v4)",
},
chunkSize: {
type: "number",
valueHint: "<n>",
description: "Chunk size in characters (default: 600, recommended 300-800)",
},
wait: { type: "switch", description: "Poll the initial import job to a terminal state" },
pollInterval: {
type: "number",
valueHint: "<seconds>",
description: "Polling interval when waiting (default: 5)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** sourceType/docIds/categoryIds derivation, centralized for unit testing (gotcha: the parameter is docIds, not fileIds) */
export function buildDataSourceFields(flags: { docId?: string[]; categoryId?: string[] }): {
sourceType: string;
docIds?: string[];
categoryIds?: string[];
dataSources: Array<{ sourceType: string }>;
} {
if (flags.docId?.length) {
return {
sourceType: "DATA_CENTER_FILE",
docIds: flags.docId,
dataSources: [{ sourceType: "DATA_CENTER_FILE" }],
};
}
return {
sourceType: "DATA_CENTER_CATEGORY",
categoryIds: flags.categoryId,
dataSources: [{ sourceType: "DATA_CENTER_CATEGORY" }],
};
}
export default defineCommand({
description: "Create a knowledge base and import data-center files or categories",
auth: "apiKey",
usageArgs: "--name <text> (--doc-id <id> | --category-id <id>) [flags]",
flags: KB_CREATE_FLAGS,
notes: [
"Structure/sink types are fixed to the default document knowledge base (unstructured, BUILT_IN storage).",
"Returns the knowledge base id (pipelineId) and the initial import job id (ingestionId).",
"Use the import job status command (or --wait) to track the initial import.",
],
exampleArgs: [
"--name demo --doc-id file-xxx --workspace-id ws-xxx",
"--name demo --category-id cate-xxx --wait",
],
validate(flags) {
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
const hasDocIds = !!flags.docId?.length;
const hasCategoryIds = !!flags.categoryId?.length;
if (hasDocIds && hasCategoryIds) return "Use either --doc-id or --category-id, not both";
if (!hasDocIds && !hasCategoryIds)
return "Provide --doc-id or --category-id as the data source";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Fixed values, not exposed as flags in this version: structureType unstructured, sinkType BUILT_IN.
// Note: the public docs' example uses sinkType DEFAULT, but BUILT_IN is what works against the live API.
const body = {
name: flags.name,
structureType: "unstructured",
sinkType: "BUILT_IN",
embeddingModelName: flags.embeddingModel ?? "text-embedding-v4",
chunkSize: flags.chunkSize ?? 600,
...buildDataSourceFields(flags),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexCreateV2);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagCreateIndexV2Response>({
path: endpoint,
method: "POST",
body,
});
const pipelineId = response.data?.pipelineId;
const ingestionId = response.data?.ingestionId;
let finalStatus: string | undefined;
if (flags.wait && pipelineId && ingestionId) {
const statusResponse = await pollImportJob(ctx.client, settings, {
statusUrl: importJobStatusUrl(workspaceId, pipelineId, ingestionId).toString(),
intervalSec: flags.pollInterval ?? 5,
});
finalStatus = importJobStatus(statusResponse);
// Job finished but some documents failed to parse → non-zero exit, server message
// passed through verbatim (the knowledge base was created; its id goes in the hint)
if (failedImportDocs(statusResponse).length > 0) {
throw new BailianError(
importJobFailureMessage(statusResponse, "Initial import reported document failures."),
ExitCode.GENERAL,
`Knowledge base created: ${pipelineId}`,
{ api: { requestId: statusResponse.request_id } },
);
}
}
if (settings.quiet) {
emitBare(pipelineId ?? "");
return;
}
if (format === "text") {
emitBare(`index_id: ${pipelineId ?? "-"}`);
if (ingestionId) emitBare(`ingestion_id: ${ingestionId}`);
if (finalStatus) emitBare(`status: ${finalStatus}`);
emitBare("Next: check the import job status, then search against this knowledge base.");
return;
}
emitResult(finalStatus ? { ...response, final_status: finalStatus } : response, format);
},
});
@@ -0,0 +1,98 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagIndexFilesResponse,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
import { fetchIndexDetail } from "./kb-info.ts";
const KB_DELETE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary lookup: name + document count; any lookup failure degrades to id-only (never blocks deletion) */
async function buildDeleteSummary(
ctx: { client: Parameters<typeof fetchIndexDetail>[0] },
workspaceId: string,
indexId: string,
): Promise<string> {
let namePart = "";
let docCountPart = "";
try {
const detail = await fetchIndexDetail(ctx.client, workspaceId, indexId);
namePart = ` name: ${detail.name}`;
} catch {
// Degrade gracefully: a missing name does not block confirmation
}
try {
const filesUrl = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles));
filesUrl.searchParams.set("index_id", indexId);
filesUrl.searchParams.set("page_num", "1");
filesUrl.searchParams.set("page_size", "1");
const files = await ctx.client.requestJson<RagIndexFilesResponse>({
path: filesUrl.toString(),
method: "GET",
});
const totalCount = files.data?.total_count;
if (typeof totalCount === "number") docCountPart = ` documents: ${totalCount}`;
} catch {
// Same graceful degradation as above
}
return `Delete knowledge base ${indexId}${namePart}${docCountPart}\nThis permanently removes the knowledge base with all documents and chunks. It cannot be undone.`;
}
export default defineCommand({
description: "Delete a knowledge base with all its documents and chunks",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: KB_DELETE_FLAGS,
notes: [
"Irreversible — the knowledge base and all indexed content are permanently removed.",
"Files in the data center are not affected; only the knowledge base index is deleted.",
],
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx", "--index-id idx-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// This endpoint is back to snake_case: body { index_id }
const body = { index_id: flags.indexId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDelete);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const summary = flags.yes
? "" // --yes bypasses the prompt, so skip the summary lookups
: await buildDeleteSummary(ctx, workspaceId, flags.indexId);
await confirmDangerousAction(summary, flags.yes ?? false);
const response = await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`deleted: ${flags.indexId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,123 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type Client,
type FlagsDef,
type RagIndexListResponse,
type RagIndexRow,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const KB_INFO_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
function indexDetailUrl(workspaceId: string, indexId: string): string {
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList));
url.searchParams.set("pipeline_id", indexId);
url.searchParams.set("page_number", "1");
url.searchParams.set("page_size", "1");
return url.toString();
}
/**
* The index/list API now supports pipeline_id filtering, so a single
* request suffices instead of paginating. Reused by kb delete for its
* confirmation summary.
*/
export async function fetchIndexDetail(
client: Client,
workspaceId: string,
indexId: string,
): Promise<RagIndexRow> {
const response = await client.requestJson<RagIndexListResponse>({
path: indexDetailUrl(workspaceId, indexId),
method: "GET",
});
const row = response.data?.rows?.[0];
if (!row) {
throw new BailianError(
`Knowledge base not found: ${indexId}`,
ExitCode.GENERAL,
"Check the id — list knowledge bases in this workspace to verify it.",
);
}
return row;
}
function formatField(label: string, value: string | number | boolean | null | undefined): string {
return ` ${label}: ${value ?? "-"}`;
}
export default defineCommand({
description: "Show knowledge base configuration details",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: KB_INFO_FLAGS,
notes: ["Indexing settings are immutable; changing them requires recreating the knowledge base."],
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
endpoint: indexDetailUrl(workspaceId, flags.indexId),
request: null,
},
format,
);
return;
}
const row = await fetchIndexDetail(ctx.client, workspaceId, flags.indexId);
if (settings.quiet) {
emitBare(row.id ?? "");
return;
}
if (format !== "text") {
emitResult(row, format);
return;
}
// Grouped by diagnostic concern; the immutable annotation on Indexing tells
// users which settings require recreating the knowledge base
emitBare("Basic:");
emitBare(formatField("id", row.id));
emitBare(formatField("name", row.name));
emitBare(formatField("description", row.description));
emitBare(formatField("dataType", row.dataType));
emitBare("Indexing: [immutable — recreate required to change]");
emitBare(formatField("embeddingModelName", row.embeddingModelName));
emitBare(formatField("embeddingDimension", row.embeddingDimension));
emitBare(formatField("chunkSize", row.chunkSize));
emitBare(formatField("overlapSize", row.overlapSize));
emitBare(formatField("chunkMode", row.chunkMode));
emitBare(formatField("separator", row.separator));
emitBare("Retrieval:");
emitBare(formatField("rerankModelName", row.rerankModelName));
emitBare(formatField("rerankMinScore", row.rerankMinScore));
emitBare(formatField("rerankTopN", row.rerankTopN));
emitBare(formatField("rerankMode", row.rerankMode));
emitBare(formatField("enableRewrite", row.enableRewrite));
emitBare(formatField("denseSimilarityTopK", row.denseSimilarityTopK));
emitBare(formatField("sparseSimilarityTopK", row.sparseSimilarityTopK));
emitBare("Data:");
emitBare(formatField("sourceType", row.sourceType));
emitBare(formatField("connectorId", row.connectorId));
},
});
@@ -0,0 +1,92 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagIndexListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
const KB_LIST_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Filter by knowledge base name (fuzzy match, 1-20 chars)",
},
...PAGE_FLAGS,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List knowledge bases in the workspace",
auth: "apiKey",
usageArgs: "[flags]",
flags: KB_LIST_FLAGS,
notes: [
"Auth: uses DashScope API Key (Bearer token).",
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or config workspace_id.",
"Use the returned id as --index-id in knowledge base / document management commands.",
],
exampleArgs: ["--workspace-id ws-xxx", "--name demo --page-number 2 --page-size 50"],
validate(flags) {
if (flags.name !== undefined && (flags.name.length < 1 || flags.name.length > 20)) {
return "--name must be 1-20 characters";
}
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
return "--page-size must be between 1 and 100";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Pagination and filter parameters must go in the query string — the server ignores them in the body
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList));
if (flags.name) url.searchParams.set("pipeline_name", flags.name);
url.searchParams.set("page_number", String(flags.pageNumber ?? 1));
url.searchParams.set("page_size", String(flags.pageSize ?? 20));
const endpoint = url.toString();
if (settings.dryRun) {
emitResult({ endpoint, request: null }, format);
return;
}
const response = await ctx.client.requestJson<RagIndexListResponse>({
path: endpoint,
method: "GET",
});
const rows = response.data?.rows ?? [];
if (settings.quiet) {
for (const row of rows) emitBare(row.id);
return;
}
if (format === "text") {
if (rows.length === 0) {
emitBare("No knowledge bases found.");
} else {
for (const row of rows) {
emitBare(
truncateLine(
[
row.id,
row.name,
row.embeddingModelName ?? "-",
row.chunkSize ?? "-",
row.description ?? "",
].join(" "),
),
);
}
}
emitBare(`total: ${response.data?.total ?? rows.length}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,125 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagMonitorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const KB_STATS_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
start: {
type: "string",
valueHint: "<time>",
description:
"Range start: Unix seconds or ISO date, must be in the past (default: 24 hours ago)",
},
end: {
type: "string",
valueHint: "<time>",
description: "Range end: Unix seconds or ISO date, must be in the past (default: now)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Normalize time input to a second-precision string (the API requires seconds as a string). Accepts Unix seconds or an ISO date. */
export function toEpochSecondsString(input: string): string {
if (/^\d+$/.test(input)) {
// Digits-only input is treated as Unix seconds; 13-digit millisecond timestamps are reduced to seconds
return input.length >= 13 ? String(Math.floor(Number(input) / 1000)) : input;
}
// Non-numeric input must be a full ISO date (YYYY-MM-DD, optionally with a time
// part). Date.parse alone is too lenient — V8 silently reads truncated input
// like "2026-" as Jan 1st, which would query a misleading range.
const parsedMs = /^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(input) ? Date.parse(input) : Number.NaN;
if (Number.isNaN(parsedMs)) {
throw new BailianError(
`Invalid time value: ${input}`,
ExitCode.USAGE,
"Pass Unix seconds (e.g. 1780900000) or an ISO date (e.g. 2026-07-30T00:00:00Z).",
);
}
return String(Math.floor(parsedMs / 1000));
}
export default defineCommand({
description: "Show knowledge base storage and QPS monitoring data",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: KB_STATS_FLAGS,
notes: [
"Defaults to the last 24 hours when --start/--end are omitted.",
"Timestamps are normalized to epoch seconds as required by the server.",
"Future timestamps are rejected for --start and clamped to now for --end, since the monitor API only returns past data.",
],
exampleArgs: [
"--index-id idx-xxx --workspace-id ws-xxx",
"--index-id idx-xxx --start 2026-07-30 --end 2026-07-31",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const nowSeconds = Math.floor(Date.now() / 1000);
const startTimestamp = flags.start
? toEpochSecondsString(flags.start)
: String(nowSeconds - 24 * 3600);
let endTimestamp = flags.end ? toEpochSecondsString(flags.end) : String(nowSeconds);
// The monitor API rejects future timestamps with a misleading
// "missing or invalid" error — validate here with a clear message.
if (Number(startTimestamp) > nowSeconds) {
throw new BailianError(
`Start time is in the future; the monitor API only accepts past or current timestamps.`,
ExitCode.USAGE,
"Use a start date/time at or before now, or omit --start to default to 24 hours ago.",
);
}
const clampedEnd = Number(endTimestamp) > nowSeconds;
if (clampedEnd) {
endTimestamp = String(nowSeconds);
}
const body = { indexId: flags.indexId, startTimestamp, endTimestamp };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexMonitor);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagMonitorResponse>({
path: endpoint,
method: "POST",
body,
});
if (format !== "text") {
emitResult(response, format);
return;
}
if (clampedEnd) {
emitBare("note: end time was in the future, clamped to now.");
}
// Shape verified against the live API: the monitor fields are objects, not arrays
const storage = response.data?.storageMonitorData;
const qps = response.data?.qpsMonitorData;
emitBare(`plan: ${response.data?.pipelineCommercialType ?? "-"}`);
emitBare(
`storage: ${storage?.indexStorageUsage ?? "-"} / ${storage?.indexStorageLimit ?? "-"}`,
);
emitBare(`peak qps: ${qps?.peakQps ?? "-"}`);
emitBare(`qps windows: ${qps?.monitorData?.length ?? 0} data point(s)`);
},
});
@@ -0,0 +1,100 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const KB_UPDATE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
name: {
type: "string",
valueHint: "<text>",
description: "New knowledge base name (1-20 chars)",
},
description: {
type: "string",
valueHint: "<text>",
description: "New knowledge base description",
},
rerankMinScore: {
type: "number",
valueHint: "<score>",
description: "Rerank minimum score threshold, range 0-1 (chunks below are filtered)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Update knowledge base name, description or rerank threshold",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: KB_UPDATE_FLAGS,
notes: [
"Indexing settings (embedding model, chunk size, etc.) are immutable — recreate the knowledge base to change them.",
],
exampleArgs: [
"--index-id idx-xxx --description 'product docs v2' --workspace-id ws-xxx",
"--index-id idx-xxx --rerank-min-score 0.3",
],
validate(flags) {
if (
flags.name === undefined &&
flags.description === undefined &&
flags.rerankMinScore === undefined
) {
return "Nothing to update — pass --name, --description or --rerank-min-score";
}
if (flags.name !== undefined && (flags.name.length < 1 || flags.name.length > 20)) {
return "--name must be 1-20 characters";
}
if (
flags.rerankMinScore !== undefined &&
(flags.rerankMinScore < 0 || flags.rerankMinScore > 1)
) {
return "--rerank-min-score must be between 0 and 1";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Gotcha: this endpoint names the knowledge base ID parameter `id` (not index_id/indexId)
const body = {
id: flags.indexId,
...(flags.name !== undefined ? { name: flags.name } : {}),
...(flags.description !== undefined ? { description: flags.description } : {}),
...(flags.rerankMinScore !== undefined ? { rerankMinScore: flags.rerankMinScore } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexUpdate);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`updated: ${flags.indexId}`);
return;
}
emitResult(response, format);
},
});
@@ -2,13 +2,12 @@ import {
defineCommand,
knowledgeSearchEndpoint,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type KnowledgeSearchRequest,
type KnowledgeSearchResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SEARCH_FLAGS = {
query: {
@@ -23,23 +22,21 @@ const SEARCH_FLAGS = {
description: "Retrieval service ID (find in console knowledge retrieval page)",
required: true,
},
// 知识库走 workspace 专属域名,--workspace-id 属命令自有 flag(console 凭证域不适用)。
workspaceId: {
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
// flag here (the console credential scope does not apply).
...WORKSPACE_FLAG,
// Named to avoid the runtime-reserved global --version flag
agentVersion: {
type: "string",
valueHint: "<id>",
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
valueHint: "<version>",
description:
"Service version to call: beta (draft for debugging) or a published number; default is the latest published version",
},
image: {
type: "array",
valueHint: "<url>",
description: "Image URL for multimodal retrieval (repeatable)",
},
queryHistory: {
type: "string",
valueHint: "<json>",
description:
'User conversation history JSON for context understanding and query rewriting. Format: \'[{"role":"user","content":"What is RAG"},{"role":"assistant","content":"RAG is..."}]\'',
},
} satisfies FlagsDef;
export default defineCommand({
@@ -51,24 +48,16 @@ export default defineCommand({
"Retrieval scope and strategy (multi-index weighting, routing, reranking, etc.) are driven by the agent_id service config. Only query and agent_id are required.",
"Auth: uses DashScope API Key (Bearer token). Get yours from the console API Key page.",
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or `kscli config set workspace_id <id>`.",
"`--query-history` passes prior conversation turns; the server rewrites the query based on context to improve retrieval relevance.",
"`--agent-version beta` calls the draft config for debugging before it is deployed.",
],
exampleArgs: [
'--query "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx',
'--api-key $DASHSCOPE_API_KEY --query "test search" --agent-id aid-xxx --workspace-id ws-xxx --image https://example.com/img.jpg',
'--query "How does it work" --agent-id aid-xxx --workspace-id ws-xxx --query-history \'[{"role":"user","content":"What is RAG"},{"role":"assistant","content":"RAG is retrieval-augmented generation"}]\'',
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = flags.workspaceId || settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required.",
ExitCode.USAGE,
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
);
}
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
@@ -77,23 +66,14 @@ export default defineCommand({
agent_id: flags.agentId,
};
if (flags.image && flags.image.length > 0) {
body.images = flags.image;
// Omitted flag → field not sent (default behavior unchanged: latest published
// version); the value is not validated — the set of versions is server-side state
if (flags.agentVersion) {
body.agent_version = flags.agentVersion;
}
// Parse query_history JSON for multi-turn context
if (flags.queryHistory) {
try {
body.query_history = JSON.parse(flags.queryHistory) as Array<{
role: "user" | "assistant";
content: string;
}>;
} catch {
throw new BailianError(
'--query-history must be valid JSON. Example: --query-history \'[{"role":"user","content":"What is RAG"}]\'',
ExitCode.USAGE,
);
}
if (flags.image && flags.image.length > 0) {
body.images = flags.image;
}
const url = knowledgeSearchEndpoint(workspaceId);
@@ -0,0 +1,67 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_COPY_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Source service (agent) ID to copy",
required: true,
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Copy a service into a new draft (name gets a copy_ prefix)",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_COPY_FLAGS,
notes: [
"The copy starts as a beta draft; test it with --agent-version beta, then deploy to publish.",
"Requires the knowledge-base create permission in the workspace.",
],
exampleArgs: ["--agent-id aid-xxx --workspace-id ws-xxx"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { agent_id: flags.agentId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentCopy);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
const newAgentId = agentMutationField(response, "agent_id");
if (settings.quiet) {
emitBare(newAgentId ?? "");
return;
}
if (format === "text") {
emitBare(
`new agent_id: ${newAgentId ?? "-"} (name: ${agentMutationField(response, "agent_name") ?? "-"}, status: ${agentMutationField(response, "agent_status") ?? "draft"})`,
);
emitBare(
"Test the draft with --agent-version beta on search/chat, then deploy it to publish.",
);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,105 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_CREATE_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Service name (up to 200 chars, unique per scene in the workspace)",
required: true,
},
scene: {
type: "string",
valueHint: "<scene>",
description: "Service scene: chat (Q&A) or search (retrieval)",
required: true,
},
description: {
type: "string",
valueHint: "<text>",
description: "Service description (up to 1000 chars)",
},
indexId: {
type: "string",
valueHint: "<id>",
description: "Bind this knowledge base; other settings use server defaults",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Create a retrieval / Q&A service (initial status: draft, version: beta)",
auth: "apiKey",
usageArgs: "--name <text> --scene <chat|search> [flags]",
flags: SERVICE_CREATE_FLAGS,
notes: [
"Without an explicit configuration the server applies its default agent settings.",
"The draft (beta) version can be tested via --agent-version beta on search/chat before deploying.",
"Requires the knowledge-base create permission in the workspace.",
],
exampleArgs: [
"--name my-qa --scene chat --workspace-id ws-xxx",
"--name my-search --scene search --index-id idx-xxx",
],
validate(flags) {
if (flags.name.length > 200) return "--name must be at most 200 characters";
if (flags.scene !== "chat" && flags.scene !== "search") {
return "--scene must be chat or search";
}
if (flags.description !== undefined && flags.description.length > 1000) {
return "--description must be at most 1000 characters";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// agent_config is optional — omit to use server defaults; with --index-id build
// the minimal kb_search_configs (name/desc etc. are backfilled by the system from
// the knowledge base, so they are not sent)
const body = {
agent_name: flags.name,
agent_scene: flags.scene,
...(flags.description ? { agent_desc: flags.description } : {}),
...(flags.indexId ? { agent_config: { kb_search_configs: [{ id: flags.indexId }] } } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentCreate);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
const agentId = agentMutationField(response, "agent_id");
if (settings.quiet) {
emitBare(agentId ?? "");
return;
}
if (format === "text") {
emitBare(
`created: ${agentId ?? "-"} (status: ${agentMutationField(response, "agent_status") ?? "draft"}, version: ${agentMutationField(response, "agent_version") ?? "beta"})`,
);
emitBare(
"Test the draft with --agent-version beta on search/chat, then deploy it to publish.",
);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,98 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type Client,
type FlagsDef,
type RagAgentGetResponse,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_DELETE_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Service (agent) ID",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary lookup (name/status); failure degrades to id-only */
async function buildDeleteSummary(
client: Client,
workspaceId: string,
agentId: string,
): Promise<string> {
let infoPart = "";
let liveWarning = "";
try {
const detail = await client.requestJson<RagAgentGetResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
method: "POST",
body: { agent_id: agentId },
});
const name = detail.data?.agent_name;
const status = detail.data?.agent_status;
if (name) infoPart += ` name: ${name}`;
if (status) {
infoPart += ` status: ${status}`;
if (status === "deployed" || status === "edited") {
liveWarning = "\nWARNING: this service is LIVE — deleting it breaks existing callers.";
}
}
} catch {
// Degrade gracefully: a failed lookup does not block confirmation
}
return `Delete service ${agentId}${infoPart}${liveWarning}\nDeletion cannot be undone; the agent_id can no longer be used for search or chat calls.`;
}
export default defineCommand({
description: "Delete a retrieval / Q&A service (soft delete, idempotent)",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_DELETE_FLAGS,
notes: [
"Deletion cannot be undone; the agent_id becomes unusable for search and chat calls.",
"Idempotent — deleting an already-deleted service does not fail.",
"Requires the knowledge-base delete permission in the workspace.",
],
exampleArgs: ["--agent-id aid-xxx --workspace-id ws-xxx", "--agent-id aid-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { agent_id: flags.agentId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentDelete);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const summary = flags.yes
? ""
: await buildDeleteSummary(ctx.client, workspaceId, flags.agentId);
await confirmDangerousAction(summary, flags.yes ?? false);
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(
`deleted: ${flags.agentId} (status: ${agentMutationField(response, "agent_status") ?? "deleted"})`,
);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,112 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type Client,
type FlagsDef,
type RagAgentGetResponse,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_DEPLOY_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Service (agent) ID",
required: true,
},
versionDesc: {
type: "string",
valueHint: "<text>",
description: "Description for the newly published version",
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary lookup (name/status); warns that deploying an edited draft overwrites live behavior; failure degrades to id-only */
async function buildDeploySummary(
client: Client,
workspaceId: string,
agentId: string,
): Promise<string> {
let infoPart = "";
let editedWarning = "";
try {
const detail = await client.requestJson<RagAgentGetResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
method: "POST",
body: { agent_id: agentId },
});
const name = detail.data?.agent_name;
const status = detail.data?.agent_status;
if (name) infoPart += ` name: ${name}`;
if (status) {
infoPart += ` status: ${status}`;
if (status === "edited") {
editedWarning =
"\nWARNING: a published version is live — deploying replaces its behavior with the current draft.";
}
}
} catch {
// Degrade gracefully: a failed lookup does not block confirmation
}
return `Deploy service ${agentId}${infoPart}${editedWarning}\nPublishing changes what live callers get from this service.`;
}
export default defineCommand({
description: "Publish the beta draft of a service as a new version",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_DEPLOY_FLAGS,
notes: [
"The version number auto-increments; status becomes deployed.",
"Publishing affects live callers — the confirmation prompt guards against accidents.",
"Requires the knowledge-base modify permission in the workspace.",
],
exampleArgs: [
"--agent-id aid-xxx --workspace-id ws-xxx",
"--agent-id aid-xxx --version-desc 'tuned rerank params' --yes",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = {
agent_id: flags.agentId,
...(flags.versionDesc ? { agent_version_desc: flags.versionDesc } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentDeploy);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const summary = flags.yes
? ""
: await buildDeploySummary(ctx.client, workspaceId, flags.agentId);
await confirmDangerousAction(summary, flags.yes ?? false);
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
const newVersion = agentMutationField(response, "agent_version");
if (settings.quiet) {
emitBare(newVersion ?? "");
return;
}
if (format === "text") {
emitBare(`deployed: ${flags.agentId} version ${newVersion ?? "-"}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,98 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAgentDetail,
type RagAgentGetResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_GET_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Service (agent) ID",
required: true,
},
agentVersion: {
type: "string",
valueHint: "<version>",
description: "Specific version to inspect (beta or a published number); omit for all versions",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
function printDetail(detail: RagAgentDetail): void {
emitBare(`Version ${detail.agent_version ?? "?"}:`);
if (detail.agent_version_desc) emitBare(` desc: ${detail.agent_version_desc}`);
if (detail.publish_time !== undefined) emitBare(` published: ${detail.publish_time}`);
const config = detail.agent_config;
if (!config) return;
if (config.agent_policy) emitBare(` policy: ${config.agent_policy}`);
if (config.agent_model) emitBare(` model: ${config.agent_model}`);
if (config.temperature !== undefined) emitBare(` temperature: ${config.temperature}`);
for (const kbConfig of config.kb_search_configs ?? []) {
const kbId = typeof kbConfig.id === "string" ? kbConfig.id : "?";
const kbName = typeof kbConfig.name === "string" ? ` (${kbConfig.name})` : "";
emitBare(` kb: ${kbId}${kbName}`);
}
}
export default defineCommand({
description: "Show service (agent) details including per-version configuration",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_GET_FLAGS,
notes: [
"Without --agent-version all versions are returned (beta draft plus published numbers).",
"The version value is passed through as-is; the valid set is server-side state.",
],
exampleArgs: [
"--agent-id aid-xxx --workspace-id ws-xxx",
"--agent-id aid-xxx --agent-version beta",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = {
agent_id: flags.agentId,
...(flags.agentVersion ? { agent_version: flags.agentVersion } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentGet);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAgentGetResponse>({
path: endpoint,
method: "POST",
body,
});
const data = response.data;
if (settings.quiet) {
emitBare(data?.agent_id ?? "");
return;
}
if (format !== "text") {
emitResult(response, format);
return;
}
emitBare("Basic:");
emitBare(` id: ${data?.agent_id ?? "-"}`);
emitBare(` name: ${data?.agent_name ?? "-"}`);
emitBare(` desc: ${data?.agent_desc ?? "-"}`);
emitBare(` scene: ${data?.agent_scene ?? "-"}`);
emitBare(` status: ${data?.agent_status ?? "-"}`);
for (const detail of data?.agent_details ?? []) {
printDetail(detail);
}
},
});
@@ -0,0 +1,134 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAgentListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_LIST_FLAGS = {
scene: {
type: "string",
valueHint: "<scene>",
description: "Service scene: chat (Q&A) or search (retrieval). Required by the server",
required: true,
},
status: {
type: "string",
valueHint: "<status>",
description: "Filter by status: draft, deployed (includes edited) or deleted",
},
name: {
type: "string",
valueHint: "<text>",
description: "Filter by service name (fuzzy match)",
},
agentId: {
type: "string",
valueHint: "<id>",
description: "Filter by exact agent ID",
},
indexId: {
type: "string",
valueHint: "<id>",
description: "Filter by exact linked knowledge base (pipeline) ID",
},
...PAGE_FLAGS,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
const SCENES = ["chat", "search"];
const STATUSES = ["draft", "deployed", "deleted"];
export default defineCommand({
description: "List retrieval / Q&A services (agents) in the workspace",
auth: "apiKey",
usageArgs: "--scene <chat|search> [flags]",
flags: SERVICE_LIST_FLAGS,
notes: [
"A scene (chat or search) is required — run once per scene to see both.",
"Use the returned agent_id with the search or chat commands, or with service management commands.",
],
exampleArgs: ["--scene chat --workspace-id ws-xxx", "--scene search --status deployed"],
validate(flags) {
if (!SCENES.includes(flags.scene)) return "--scene must be chat or search";
if (flags.status !== undefined && !STATUSES.includes(flags.status)) {
return "--status must be draft, deployed or deleted";
}
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
return "--page-size must be between 1 and 100";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// agent/list pagination goes in the body
const body = {
agent_scene: flags.scene,
...(flags.status ? { agent_status: flags.status } : {}),
...(flags.name ? { agent_name: flags.name } : {}),
...(flags.agentId ? { agent_id: flags.agentId } : {}),
...(flags.indexId ? { pipeline_id: flags.indexId } : {}),
page_number: flags.pageNumber ?? 1,
page_size: flags.pageSize ?? 10,
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentList);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAgentListResponse>({
path: endpoint,
method: "POST",
body,
});
const rows = response.data?.rows ?? [];
if (settings.quiet) {
for (const row of rows) emitBare(row.agent_id ?? "");
return;
}
if (format === "text") {
if (rows.length === 0) {
emitBare("No services found.");
} else {
for (const row of rows) {
const kbNames = (row.pipeline_list ?? [])
.map((pipeline) => pipeline.pipeline_name)
.filter(Boolean)
.join(",");
emitBare(
truncateLine(
[
row.agent_id,
row.agent_status,
row.agent_version,
row.agent_name,
kbNames ? `(kb: ${kbNames})` : "",
]
.filter(Boolean)
.join(" "),
),
);
}
}
emitBare(`total: ${response.data?.total_count ?? rows.length}`);
// Connect finding an ID with using it (capability wording, no hardcoded product path)
emitBare(
flags.scene === "search"
? "Use an agent_id above with the knowledge search command."
: "Use an agent_id above with the knowledge chat command.",
);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,362 @@
import { readFileSync } from "node:fs";
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type Client,
type FlagsDef,
type RagAgentConfig,
type RagAgentGetResponse,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const BOOL_CHOICES = ["true", "false"];
const SERVICE_UPDATE_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Service (agent) ID",
required: true,
},
name: {
type: "string",
valueHint: "<text>",
description: "New service name (up to 200 chars)",
},
description: {
type: "string",
valueHint: "<text>",
description: "New service description (up to 1000 chars)",
},
agentVersion: {
type: "string",
valueHint: "<version>",
description:
"Target version (default: beta draft). Published versions only accept --version-desc",
},
versionDesc: {
type: "string",
valueHint: "<text>",
description: "Version description",
},
policy: {
type: "string",
valueHint: "<policy>",
description: "Agent policy: turbo (fast) or agentic (multi-turn)",
},
model: {
type: "string",
valueHint: "<name>",
description: "Generation model code (must be in the platform allowlist)",
},
temperature: {
type: "number",
valueHint: "<n>",
description: "Sampling temperature, range 0-2",
},
maxLlmCalls: {
type: "number",
valueHint: "<n>",
description: "Max LLM calls per request, range 1-30",
},
enableSessionFile: {
type: "string",
valueHint: "<bool>",
description: "Enable session files: true or false",
},
enableRefusal: {
type: "string",
valueHint: "<bool>",
description: "Enable refusal answers: true or false",
},
enableAntiLeak: {
type: "string",
valueHint: "<bool>",
description: "Enable anti prompt-leak: true or false",
},
enableRichText: {
type: "string",
valueHint: "<bool>",
description: "Enable rich text output: true or false",
},
enableCitation: {
type: "string",
valueHint: "<bool>",
description: "Enable citations: true or false",
},
configFile: {
type: "string",
valueHint: "<path>",
description:
"JSON file replacing the whole agent_config (for nested settings like kb_search_configs); mutually exclusive with scalar config flags",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
type UpdateFlags = {
policy?: string;
model?: string;
temperature?: number;
maxLlmCalls?: number;
enableSessionFile?: string;
enableRefusal?: string;
enableAntiLeak?: string;
enableRichText?: string;
enableCitation?: string;
};
/** Scalar config flag → agent_config field mapping (centralized for validation and merge) */
function collectScalarConfig(flags: UpdateFlags): Partial<RagAgentConfig> {
const scalar: Partial<RagAgentConfig> = {};
if (flags.policy !== undefined) scalar.agent_policy = flags.policy;
if (flags.model !== undefined) scalar.agent_model = flags.model;
if (flags.temperature !== undefined) scalar.temperature = flags.temperature;
if (flags.maxLlmCalls !== undefined) scalar.max_num_llm_calls = flags.maxLlmCalls;
if (flags.enableSessionFile !== undefined) scalar.enable_session_file = flags.enableSessionFile;
if (flags.enableRefusal !== undefined) scalar.enable_refusal = flags.enableRefusal;
if (flags.enableAntiLeak !== undefined) scalar.enable_anti_leak = flags.enableAntiLeak;
if (flags.enableRichText !== undefined) scalar.enable_rich_text = flags.enableRichText;
if (flags.enableCitation !== undefined) scalar.enable_citation = flags.enableCitation;
return scalar;
}
/** --config-file: read JSON + structural/enum/range validation; unknown fields warn but pass through */
export function parseConfigFile(filePath: string): RagAgentConfig {
let raw: string;
try {
raw = readFileSync(filePath, "utf-8");
} catch (error) {
const errno = (error as { code?: string }).code ?? "unknown";
throw new BailianError(
`Cannot read config file: ${filePath}`,
ExitCode.GENERAL,
`File system error (${errno}) — check the path and permissions.`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new BailianError("--config-file must contain valid JSON.", ExitCode.USAGE);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new BailianError("--config-file must contain a JSON object.", ExitCode.USAGE);
}
const config = parsed as RagAgentConfig;
validateConfigValues(config);
return config;
}
const KNOWN_CONFIG_KEYS = new Set([
"agent_policy",
"agent_model",
"enable_session_file",
"enable_refusal",
"enable_anti_leak",
"enable_rich_text",
"enable_citation",
"temperature",
"max_num_llm_calls",
"max_completion_tokens",
"session_file_max_parse_length",
"enable_kb_router",
"kb_router_model",
"rerank_top_n",
"hybrid_rerank",
"kb_search_configs",
]);
/** Enum/range validation + behavioral warnings, per the agent_config contract */
export function validateConfigValues(config: RagAgentConfig): void {
if (config.agent_policy !== undefined && !["turbo", "agentic"].includes(config.agent_policy)) {
throw new BailianError("agent_policy must be turbo or agentic", ExitCode.USAGE);
}
if (config.temperature !== undefined && (config.temperature < 0 || config.temperature > 2)) {
throw new BailianError("temperature must be between 0 and 2", ExitCode.USAGE);
}
if (
config.max_num_llm_calls !== undefined &&
(config.max_num_llm_calls < 1 || config.max_num_llm_calls > 30)
) {
throw new BailianError("max_num_llm_calls must be between 1 and 30", ExitCode.USAGE);
}
if (config.rerank_top_n !== undefined && (config.rerank_top_n < 1 || config.rerank_top_n > 20)) {
throw new BailianError("rerank_top_n must be between 1 and 20", ExitCode.USAGE);
}
// Behavioral warning: the server clears rerank_instruct unless rerank_mode is custom
for (const kbConfig of config.kb_search_configs ?? []) {
const rerank = kbConfig.rerank as
| { rerank_mode?: string; rerank_instruct?: string }
| undefined;
if (rerank?.rerank_instruct && rerank.rerank_mode !== "custom") {
process.stderr.write(
"Warning: rerank_instruct is only effective with rerank_mode=custom; the server clears it otherwise.\n",
);
}
}
// Unknown fields: warn but pass through (the server is the source of truth)
for (const key of Object.keys(config)) {
if (!KNOWN_CONFIG_KEYS.has(key)) {
process.stderr.write(`Warning: unknown agent_config field passed through: ${key}\n`);
}
}
}
/** read-merge-write: fetch the full beta-draft config and merge scalar changes (the API has whole-replace semantics) */
async function fetchBetaConfig(
client: Client,
workspaceId: string,
agentId: string,
): Promise<RagAgentConfig> {
const response = await client.requestJson<RagAgentGetResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
method: "POST",
body: { agent_id: agentId, agent_version: "beta" },
});
const betaDetail = (response.data?.agent_details ?? []).find(
(detail) => detail.agent_version === "beta",
);
return betaDetail?.agent_config ?? {};
}
export default defineCommand({
description: "Update service name, description or draft configuration",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_UPDATE_FLAGS,
notes: [
"Configuration changes only apply to the beta draft; published versions accept --version-desc only.",
"To change the configuration of a published version, first update the beta draft (this command without --agent-version or with --agent-version beta), then run service deploy to publish a new version.",
"Scalar config flags merge into the current draft config (read-merge-write); --config-file replaces the whole config and is mutually exclusive with them.",
"After updating the draft, verify with --agent-version beta on search/chat, then deploy.",
"Requires the knowledge-base modify permission in the workspace.",
],
// Note on `agent_version` in the request body: this is the agent-management
// domain's "target version" parameter (beta draft or a published version
// number). It is NOT the search/chat debug-draft field that the project-wide
// agent_version cleanup removes — the two share a name but live in different
// API surfaces and have different semantics.
exampleArgs: [
"--agent-id aid-xxx --temperature 0.7 --workspace-id ws-xxx",
"--agent-id aid-xxx --config-file ./agent-config.json",
"--agent-id aid-xxx --agent-version 1 --version-desc 'first stable release'",
],
validate(flags) {
const scalarTouched = Object.keys(collectScalarConfig(flags)).length > 0;
const anyChange =
flags.name !== undefined ||
flags.description !== undefined ||
flags.versionDesc !== undefined ||
flags.configFile !== undefined ||
scalarTouched;
if (!anyChange)
return "Nothing to update — pass a name/description/version-desc or config flags";
if (flags.configFile !== undefined && scalarTouched) {
return "--config-file is mutually exclusive with scalar config flags";
}
// Version check up front: published version + config change → USAGE (the server
// would also reject it, but failing early is faster)
const publishedVersion = flags.agentVersion !== undefined && flags.agentVersion !== "beta";
if (publishedVersion && (scalarTouched || flags.configFile !== undefined)) {
return "Published versions only accept --version-desc; update the beta draft to change config";
}
if (flags.name !== undefined && flags.name.length > 200) {
return "--name must be at most 200 characters";
}
if (flags.description !== undefined && flags.description.length > 1000) {
return "--description must be at most 1000 characters";
}
if (flags.policy !== undefined && !["turbo", "agentic"].includes(flags.policy)) {
return "--policy must be turbo or agentic";
}
if (flags.temperature !== undefined && (flags.temperature < 0 || flags.temperature > 2)) {
return "--temperature must be between 0 and 2";
}
if (flags.maxLlmCalls !== undefined && (flags.maxLlmCalls < 1 || flags.maxLlmCalls > 30)) {
return "--max-llm-calls must be between 1 and 30";
}
for (const [flagName, value] of [
["--enable-session-file", flags.enableSessionFile],
["--enable-refusal", flags.enableRefusal],
["--enable-anti-leak", flags.enableAntiLeak],
["--enable-rich-text", flags.enableRichText],
["--enable-citation", flags.enableCitation],
] as const) {
if (value !== undefined && !BOOL_CHOICES.includes(value)) {
return `${flagName} must be true or false`;
}
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const scalarConfig = collectScalarConfig(flags);
const hasConfigChange = flags.configFile !== undefined || Object.keys(scalarConfig).length > 0;
let agentConfig: RagAgentConfig | undefined;
if (flags.configFile !== undefined) {
// dry-run also reads the file and validates (rehearsal semantics)
agentConfig = parseConfigFile(flags.configFile);
} else if (Object.keys(scalarConfig).length > 0) {
if (settings.dryRun) {
// dry-run does not issue the read-merge request; show the scalar delta with a placeholder note
agentConfig = { ...scalarConfig };
} else {
// The API has whole-replace semantics — read the full beta config first,
// then merge (hidden from the user)
const currentConfig = await fetchBetaConfig(ctx.client, workspaceId, flags.agentId);
agentConfig = { ...currentConfig, ...scalarConfig };
validateConfigValues(agentConfig);
}
}
const body = {
agent_id: flags.agentId,
...(flags.name !== undefined ? { agent_name: flags.name } : {}),
...(flags.description !== undefined ? { agent_desc: flags.description } : {}),
...(flags.agentVersion !== undefined ? { agent_version: flags.agentVersion } : {}),
...(flags.versionDesc !== undefined ? { agent_version_desc: flags.versionDesc } : {}),
...(agentConfig !== undefined ? { agent_config: agentConfig } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentUpdate);
if (settings.dryRun) {
emitResult(
{
endpoint,
request: body,
...(hasConfigChange && flags.configFile === undefined
? { note: "scalar config flags are merged into the current beta config at run time" }
: {}),
},
format,
);
return;
}
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`updated: ${flags.agentId}`);
if (hasConfigChange) {
emitBare("Draft config changed — verify with --agent-version beta, then deploy.");
}
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,183 @@
// Shared building blocks for the knowledge admin commands.
import {
BailianError,
ExitCode,
ragEndpoint,
RAG_PATHS,
type Client,
type FlagsDef,
type RagIndexJobDoc,
type RagIndexJobStatusResponse,
type Settings,
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
// flag here (the console credential scope does not apply).
export const WORKSPACE_FLAG = {
workspaceId: {
type: "string",
valueHint: "<id>",
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
},
} satisfies FlagsDef;
// Unified pagination flags for admin list commands. The server-side page/size
// parameter names differ per endpoint (page_number/page_num/pageNum/pageNumber…)
// and the body-vs-query-string placement also varies — each command maps these
// flags to its own API contract. Never pass the flag names through verbatim;
// the CLI-facing flag vocabulary is stable even when the backend is not.
export const PAGE_FLAGS = {
pageNumber: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Page size per request" },
} satisfies FlagsDef;
/** Three-level fallback: flag > BAILIAN_WORKSPACE_ID env > config (env/config are merged into settings); missing → USAGE. */
export function resolveWorkspaceId(ctx: {
flags: { workspaceId?: string };
settings: { workspaceId?: string };
identity: { binName: string };
}): string {
const workspaceId = ctx.flags.workspaceId || ctx.settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required.",
ExitCode.USAGE,
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
);
}
return workspaceId;
}
/** Truncate text-mode table rows to the terminal width; no truncation when not a TTY (pipe/redirect). */
export function truncateLine(line: string): string {
if (!process.stdout.isTTY) return line;
const width = process.stdout.columns ?? 120;
return line.length > width ? `${line.slice(0, Math.max(0, width - 1))}…` : line;
}
// ---- Shared import-job (index_job/status) logic ----
// Verified against the live API: the overall job state lives in `ingestion_status`
// (PENDING/RUNNING/COMPLETED, no FAILED value); the per-document list is `rows[]`
// and failures surface via `rows[].code` (e.g. PARSE_FAILED).
// Shared by doc status / doc upload / kb create — contract changes only touch this file.
/** Overall job state (ingestion_status) */
export function importJobStatus(response: unknown): string {
return (response as RagIndexJobStatusResponse).data?.ingestion_status ?? "UNKNOWN";
}
/** Per-document failures: rows[].code / status containing FAILED */
export function failedImportDocs(response: RagIndexJobStatusResponse): RagIndexJobDoc[] {
return (response.data?.rows ?? []).filter((doc) =>
[doc.code, doc.status].some((value) => typeof value === "string" && value.includes("FAILED")),
);
}
/**
* Whether every document has reached a terminal state (FINISH or *FAILED).
* Used to stop polling before ingestion_status becomes COMPLETED — the server
* may leave the job on RUNNING indefinitely even after all documents finish.
*/
function allDocsTerminal(response: RagIndexJobStatusResponse): boolean {
const rows = response.data?.rows ?? [];
if (rows.length === 0) return false;
const totalCount = response.data?.total_count;
if (typeof totalCount === "number" && rows.length < totalCount) return false;
return rows.every((doc) => {
const code = doc.code ?? doc.status ?? "";
return code === "FINISH" || code.includes("FAILED");
});
}
/** Per-document failure summary: lists both failed and succeeded documents so the user knows the full picture. */
export function importJobFailureMessage(
response: RagIndexJobStatusResponse,
fallbackMessage: string,
): string {
const rows = response.data?.rows ?? [];
const failed = failedImportDocs(response);
const failedIds = new Set(failed.map((doc) => doc.doc_id));
const succeeded = rows.filter((doc) => !failedIds.has(doc.doc_id));
const failedDetail = failed
.map((doc) => `${doc.doc_name ?? doc.doc_id ?? "?"}: ${doc.message ?? doc.code ?? "unknown"}`)
.join("; ");
const succeededDetail = succeeded.map((doc) => doc.doc_name ?? doc.doc_id ?? "?").join(", ");
const parts: string[] = [];
if (failedDetail) parts.push(`failed: ${failedDetail}`);
if (succeededDetail) parts.push(`succeeded: ${succeededDetail}`);
return parts.length > 0 ? `${fallbackMessage} (${parts.join("; ")})` : fallbackMessage;
}
/** Build the index_job/status query string (both index_id and job_id are required) */
export function importJobStatusUrl(workspaceId: string, indexId: string, jobId: string): URL {
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexJobStatus));
url.searchParams.set("index_id", indexId);
url.searchParams.set("job_id", jobId);
return url;
}
/**
* Poll the import job until the overall state is COMPLETED or every document
* has reached a terminal state (FINISH or FAILED). The overall state has no
* FAILED value, so isFailed is always false — failures are determined by the
* caller after return via `failedImportDocs`. The server may leave
* ingestion_status on RUNNING indefinitely after documents finish, so checking
* rows[] mid-poll avoids a misleading timeout; callers re-check failedImportDocs
* after return, keeping the error path uniform.
*/
export async function pollImportJob(
client: Client,
settings: Settings,
options: { statusUrl: string; intervalSec: number },
): Promise<RagIndexJobStatusResponse> {
return poll<RagIndexJobStatusResponse>(client, settings, {
url: options.statusUrl,
intervalSec: options.intervalSec,
timeoutSec: settings.timeout,
isComplete: (data) => {
const response = data as RagIndexJobStatusResponse;
return importJobStatus(response) === "COMPLETED" || allDocsTerminal(response);
},
isFailed: () => false,
getStatus: (data) => {
const response = data as RagIndexJobStatusResponse;
const status = importJobStatus(response);
const failedCount = failedImportDocs(response).length;
const total = response.data?.total_count;
if (typeof total === "number" && total > 0) {
return `${status} (${failedCount}/${total} failed)`;
}
return failedCount > 0 ? `${status} (${failedCount} failed)` : status;
},
});
}
/** Attach a hint for partial-success cases while preserving server context (api/rawResponse kept) */
export function withPartialSuccessHint(error: unknown, hint: string): unknown {
if (!(error instanceof BailianError) || error.hint) return error;
return new BailianError(error.message, error.exitCode, hint, {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
/**
* Read response fields from agent-domain mutation endpoints. Gotcha verified
* against the live API: create/copy return agent_id etc. inside data, but
* deploy returns agent_version/agent_status at the top level next to code —
* the server envelope is inconsistent, so read both locations defensively.
*/
export function agentMutationField(
response: { data?: Record<string, unknown>; [key: string]: unknown },
field: "agent_id" | "agent_name" | "agent_version" | "agent_status",
): string | undefined {
const nested = response.data?.[field];
if (typeof nested === "string") return nested;
const topLevel = response[field];
return typeof topLevel === "string" ? topLevel : undefined;
}
@@ -0,0 +1,236 @@
// Local pre-flight validation for file uploads (doc upload).
// Default category: the lease/addFile `category` parameter accepts the literal
// "default" (verified against the live API), so no listCategory resolution is needed.
import { readFileSync, readdirSync, statSync } from "node:fs";
import { basename, extname, join } from "node:path";
import { BailianError, ExitCode } from "bailian-cli-core";
const MB = 1024 * 1024;
export interface UploadFormatRule {
maxBytes: number;
/** block: exceeding the limit throws USAGE; warn: only a stderr warning (documented as a recommended limit) */
enforce: "block" | "warn";
}
/**
* Format allowlist based on the officially supported document formats.
* .csv is inconsistent across the public docs — kept in the allowlist for now;
* a server-side rejection would be passed through verbatim.
*/
export const UPLOAD_FORMAT_RULES: Record<string, UploadFormatRule> = {
".doc": { maxBytes: 150 * MB, enforce: "block" },
".docx": { maxBytes: 150 * MB, enforce: "block" },
".ppt": { maxBytes: 150 * MB, enforce: "block" },
".pptx": { maxBytes: 150 * MB, enforce: "block" },
".pdf": { maxBytes: 150 * MB, enforce: "block" },
".png": { maxBytes: 20 * MB, enforce: "block" },
".jpg": { maxBytes: 20 * MB, enforce: "block" },
".jpeg": { maxBytes: 20 * MB, enforce: "block" },
".bmp": { maxBytes: 20 * MB, enforce: "block" },
".gif": { maxBytes: 20 * MB, enforce: "block" },
".xls": { maxBytes: 10 * MB, enforce: "warn" },
".xlsx": { maxBytes: 10 * MB, enforce: "warn" },
".csv": { maxBytes: 10 * MB, enforce: "warn" },
".md": { maxBytes: 10 * MB, enforce: "warn" },
".txt": { maxBytes: 10 * MB, enforce: "warn" },
".html": { maxBytes: 10 * MB, enforce: "warn" },
};
/** Returns true when the extension is in the upload format allowlist. */
export function isSupportedExtension(filePath: string): boolean {
const extension = extname(filePath).toLowerCase();
return extension in UPLOAD_FORMAT_RULES;
}
/**
* Directory names skipped when expanding a directory path (recursive scan).
* Covers common tooling artifacts that should never contain user documents.
*/
const IGNORED_DIRECTORIES = new Set([
"node_modules",
".git",
".svn",
".hg",
"__pycache__",
".venv",
"venv",
".env",
".tox",
"dist",
"build",
".cache",
".next",
".nuxt",
]);
export interface ExpandResult {
files: string[];
skipped: string[];
}
/**
* Expand an array of paths into individual file paths.
* - Regular files are included as-is.
* - Directories are recursively scanned; files with unsupported extensions are
* collected into `skipped` instead of throwing.
* - Common tooling directories (node_modules, .git, …) are silently skipped.
* - A non-existent path throws USAGE so the user gets a clear error.
*/
export function expandUploadPaths(paths: string[]): ExpandResult {
const files: string[] = [];
const skipped: string[] = [];
function walkDirectory(directoryPath: string): void {
let entries: import("node:fs").Dirent[];
try {
entries = readdirSync(directoryPath, { withFileTypes: true });
} catch (error) {
const errno = (error as { code?: string }).code ?? "unknown";
throw new BailianError(
`Cannot read directory: ${directoryPath}`,
ExitCode.GENERAL,
`File system error (${errno}) — check the path and permissions.`,
);
}
for (const entry of entries) {
const entryFullPath = join(directoryPath, entry.name);
if (entry.isDirectory()) {
if (!IGNORED_DIRECTORIES.has(entry.name)) {
walkDirectory(entryFullPath);
}
continue;
}
if (entry.isFile()) {
if (isSupportedExtension(entry.name)) {
files.push(entryFullPath);
} else {
skipped.push(entryFullPath);
}
}
// Symlinks: withFileTypes follows symlinks for isFile/isDirectory,
// so they are handled by the branches above.
}
}
for (const inputPath of paths) {
let pathStat: import("node:fs").Stats;
try {
pathStat = statSync(inputPath);
} catch (error) {
const errno = (error as { code?: string }).code ?? "unknown";
throw new BailianError(
`Cannot read path: ${inputPath}`,
ExitCode.GENERAL,
`File system error (${errno}) — check the path and permissions.`,
);
}
if (pathStat.isDirectory()) {
walkDirectory(inputPath);
} else if (pathStat.isFile()) {
files.push(inputPath);
}
// Other types (socket, block device, etc.) are silently ignored.
}
return { files, skipped };
}
/** Local pre-flight check before reading the file: extension allowlist + hard/soft size limits. File I/O failure → GENERAL + errno hint. */
export function checkUploadFile(filePath: string): { sizeBytes: number; warning?: string } {
const extension = extname(filePath).toLowerCase();
const rule = UPLOAD_FORMAT_RULES[extension];
if (!rule) {
throw new BailianError(
`Unsupported file type: ${extension || basename(filePath)}`,
ExitCode.USAGE,
`Supported formats: ${Object.keys(UPLOAD_FORMAT_RULES).join(" ")}`,
);
}
let sizeBytes: number;
try {
sizeBytes = statSync(filePath).size;
} catch (error) {
const errno = (error as { code?: string }).code ?? "unknown";
throw new BailianError(
`Cannot read file: ${filePath}`,
ExitCode.GENERAL,
`File system error (${errno}) — check the path and permissions.`,
);
}
if (sizeBytes > rule.maxBytes) {
const limitMb = rule.maxBytes / MB;
if (rule.enforce === "block") {
throw new BailianError(
`File exceeds the ${limitMb} MB limit for ${extension}: ${basename(filePath)}`,
ExitCode.USAGE,
);
}
return {
sizeBytes,
warning: `${basename(filePath)} exceeds the recommended 10 MB for ${extension}; the server may reject or truncate it.`,
};
}
return { sizeBytes };
}
// Known document-format extensions — used to tailor the hint on decode failure
// (pointing users to the document upload flow instead)
const DOCUMENT_EXTENSIONS = new Set([
".doc",
".docx",
".ppt",
".pptx",
".pdf",
".xls",
".xlsx",
".png",
".jpg",
".jpeg",
".bmp",
".gif",
]);
/**
* Read a UTF-8 plain-text file.
* Support is defined by content, not extension: any UTF-8 text is valid.
* Strict decode failure → USAGE, with a hint pointing to the document upload
* flow when the extension is a document format; file I/O failure → GENERAL + errno.
*
* @param inlineAlternativeFlag When provided, an ENOENT on a value that looks
* like inline text (no path separator / no extension) appends a hint pointing
* the user to this flag instead. Pass the inline-text flag name (e.g.
* `"--content"`) only from commands that have a file-vs-inline choice.
*/
export function readUtf8TextFile(filePath: string, inlineAlternativeFlag?: string): string {
let fileBuffer: Buffer;
try {
fileBuffer = readFileSync(filePath);
} catch (error) {
const errno = (error as { code?: string }).code ?? "unknown";
let hint = `File system error (${errno}) — check the path and permissions.`;
if (
inlineAlternativeFlag &&
errno === "ENOENT" &&
!extname(filePath) &&
!filePath.includes("/") &&
!filePath.includes("\\")
) {
hint += ` If you meant to pass text content directly, use ${inlineAlternativeFlag} instead of --content-file.`;
}
throw new BailianError(`Cannot read file: ${filePath}`, ExitCode.GENERAL, hint);
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(fileBuffer);
} catch {
const extension = extname(filePath).toLowerCase();
const hint = DOCUMENT_EXTENSIONS.has(extension)
? "Document formats cannot be used as chunk content. Upload documents via the document upload command; to edit a chunk, save the text as .md/.txt first."
: "Convert the file to UTF-8 encoding and retry.";
throw new BailianError(
`File is not valid UTF-8 plain text: ${basename(filePath)}`,
ExitCode.USAGE,
hint,
);
}
}
+9 -7
View File
@@ -1,4 +1,5 @@
import {
anonymousConsoleCall,
defineCommand,
detectOutputFormat,
fetchModelDetail,
@@ -290,7 +291,7 @@ function printPredictConfigTable(entries: PredictConfigEntry[]): void {
export default defineCommand({
description: "Browse model families or show detailed model info in the Bailian model marketplace",
auth: "console",
auth: "none",
usageArgs:
"[--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]",
flags: LIST_FLAGS,
@@ -302,10 +303,14 @@ export default defineCommand({
"--model qwen-max --enrich --output json",
"--feature function-calling --output json",
],
notes: [
"Both the catalog and --enrich parameter-schema endpoints are public — no console login needed.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
const modelKey = flags.model;
const call = anonymousConsoleCall(settings);
// ── Detail mode ──
if (modelKey) {
@@ -316,7 +321,7 @@ export default defineCommand({
return;
}
const detail = await fetchModelDetail(ctx.client.console.bind(ctx.client), modelKey);
const detail = await fetchModelDetail(call, modelKey);
if (!detail) {
emitBare(`Model "${modelKey}" not found.`);
@@ -328,10 +333,7 @@ export default defineCommand({
await Promise.all(
trunkItems.map(async (item) => {
if (!item.model) return;
const config = await fetchPredictConfig(
ctx.client.console.bind(ctx.client),
item.model,
);
const config = await fetchPredictConfig(call, item.model);
if (config) item.predictConfig = config;
}),
);
@@ -361,7 +363,7 @@ export default defineCommand({
return;
}
const { total, groups } = await fetchModelGroups(ctx.client.console.bind(ctx.client), params);
const { total, groups } = await fetchModelGroups(call, params);
if (format === "json") {
emitResult(formatBrowseJson(groups, total), format);
@@ -0,0 +1,41 @@
import { defineCommand } from "bailian-cli-core";
import { runPermissionChange, validatePermissionChange } from "./shared.ts";
export default defineCommand({
description: "Grant model permissions (inference / finetune / deploy)",
auth: "apiKey",
usageArgs: "--model <models> [--action <actions>] | --all",
flags: {
model: {
type: "string",
valueHint: "<models>",
description: "Model ID(s), comma-separated (max 20)",
},
action: {
type: "string",
valueHint: "<actions>",
description:
"Permission action(s), comma-separated: inference, finetune, deploy (default: inference)",
},
all: {
type: "switch",
description:
"One-key grant inference for all models in the workspace (including future ones)",
},
},
exampleArgs: [
"--model qwen-plus",
"--model qwen-plus,qwen3-max --action inference,finetune",
"--all",
"--model qwen-plus --dry-run --output json",
],
notes: [
"Grants apply to the business workspace your API key belongs to.",
"--all maps to the server one-key switch (access_all_entities: OPEN) and only covers inference.",
"Actions you omit keep their current grants (server-side tri-state patch).",
],
validate: (flags) => validatePermissionChange(flags),
async run(ctx) {
await runPermissionChange(ctx, ctx.flags, true);
},
});
@@ -0,0 +1,145 @@
import { defineCommand, detectOutputFormat, modelsPermissionsPath } from "bailian-cli-core";
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
// ---------------------------------------------------------------------------
// Types — mirror GET /api/v1/models/permissions
// ---------------------------------------------------------------------------
interface PermissionDetail {
inference?: boolean | null;
fine_tune?: boolean | null;
deploy?: boolean | null;
}
interface ModelPermission {
model: string;
name?: string;
permissions?: PermissionDetail;
}
interface PermissionsResponse {
output?: {
total?: number;
page_no?: number;
page_size?: number;
permissions?: ModelPermission[];
};
request_id?: string;
}
// ---------------------------------------------------------------------------
// Formatters
// ---------------------------------------------------------------------------
/** Tri-state permission cell: true → yes, false → no, null/undefined → "-". */
function formatGrant(granted: boolean | null | undefined): string {
if (granted == null) return "-";
return granted ? "yes" : "no";
}
function printTable(permissions: ModelPermission[], total: number, emptyHint: string): void {
if (permissions.length === 0) {
process.stdout.write(`No model permissions found.\n${emptyHint}\n`);
return;
}
const headers = ["Model", "Name", "Inference", "Fine-tune", "Deploy"];
const rows = permissions.map((entry) => [
entry.model,
entry.name ?? "-",
formatGrant(entry.permissions?.inference),
formatGrant(entry.permissions?.fine_tune),
formatGrant(entry.permissions?.deploy),
]);
const lines = renderBoxTable({
headers,
rows,
align: ["left", "left", "right", "right", "right"],
});
for (const line of lines) process.stdout.write(line + "\n");
process.stdout.write(`\nTotal: ${total}\n`);
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
export default defineCommand({
description: "List model permissions (inference / fine-tune / deploy) in the workspace",
auth: "apiKey",
usageArgs: "[--scope <scope>] [--model <model>] [--name <name>] [--page <n>] [--page-size <n>]",
flags: {
scope: {
type: "string",
valueHint: "<scope>",
choices: ["authorized", "authorizable"] as const,
description: "Authorization scope: authorizable (default, full catalog), authorized",
},
model: {
type: "string",
valueHint: "<model>",
description: "Model ID (exact match)",
},
name: {
type: "string",
valueHint: "<name>",
description: "Fuzzy search by model name or ID",
},
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 20)" },
},
exampleArgs: [
"",
"--model qwen-plus",
"--scope authorized",
"--name qwen --page-size 50",
"--output text",
],
notes: [
"Default scope is `authorizable` (the full grantable catalog); use `--scope authorized` to see only models already granted.",
"Output defaults to JSON; pass `--output text` for a table. Permission values are tri-state: true / false / null (never set).",
"Values mirror the server's grant records as-is for the workspace bound to your API key. A model reporting false/null can still be callable (access may come from other channels); see the Model Studio authorization docs for the exact semantics.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
const scope = flags.scope ?? "authorizable";
const query = {
authorization_scope: scope.toUpperCase(),
model: flags.model || undefined,
name: flags.name || undefined,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
};
if (settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsPermissionsPath()), method: "GET", query },
format,
);
return;
}
const resp = await ctx.client.requestJson<PermissionsResponse>({
path: modelsPermissionsPath() + buildQuery(query),
});
const permissions = resp.output?.permissions ?? [];
const total = resp.output?.total ?? permissions.length;
if (format === "json") {
emitResult({ items: permissions, total }, format);
return;
}
// The default authorized view is empty until something is granted — point
// at the authorizable catalog instead of ending with a bare "nothing".
const binName = ctx.identity.binName;
const emptyHint =
scope === "authorized"
? `Nothing granted yet in this workspace. Browse grantable models with \`${binName} permission list --scope authorizable\`, then grant with \`${binName} permission grant --model <model>\`.`
: `Adjust --name/--model filters, or check pagination with --page/--page-size.`;
printTable(permissions, total, emptyHint);
},
});
@@ -0,0 +1,52 @@
import { defineCommand, BailianError, ExitCode } from "bailian-cli-core";
import { runPermissionChange, validatePermissionChange } from "./shared.ts";
export default defineCommand({
description: "Revoke model permissions (inference / finetune / deploy)",
auth: "apiKey",
usageArgs: "--model <models> [--action <actions>] | --all --yes",
flags: {
model: {
type: "string",
valueHint: "<models>",
description: "Model ID(s), comma-separated (max 20)",
},
action: {
type: "string",
valueHint: "<actions>",
description:
"Permission action(s), comma-separated: inference, finetune, deploy (default: inference)",
},
all: {
type: "switch",
description: "Close one-key authorization and clear ALL historical inference grants",
},
yes: {
type: "switch",
description: "Confirm --all without an interactive prompt (required)",
},
},
exampleArgs: [
"--model qwen-plus",
"--model qwen-plus,qwen3-max --action inference,finetune",
"--all --yes",
"--model qwen-plus --dry-run --output json",
],
notes: [
"Grants apply to the business workspace your API key belongs to.",
"--all maps to the server one-key switch (access_all_entities: CLOSE): it clears every historical inference grant and cannot be undone, so it requires --yes.",
"Actions you omit keep their current grants (server-side tri-state patch).",
],
validate: (flags) => validatePermissionChange(flags),
async run(ctx) {
const { flags, settings } = ctx;
if (flags.all && !flags.yes && !settings.dryRun) {
throw new BailianError(
"Refusing to clear all historical inference grants without confirmation.",
ExitCode.USAGE,
"Re-run with --yes to close one-key authorization (or preview with --dry-run).",
);
}
await runPermissionChange(ctx, flags, false);
},
});
@@ -0,0 +1,109 @@
import {
detectOutputFormat,
modelsPermissionsPath,
type Client,
type Settings,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { parseCommaList } from "../shared/params.ts";
// POST /api/v1/models/permissions accepts at most 20 models per call.
export const MAX_MODELS_PER_REQUEST = 20;
// POST body field names (server ignores unknown keys silently — the docs' curl
// example spells `fine_tune`, but only `finetune` actually takes effect).
export const PERMISSION_ACTIONS = ["inference", "finetune", "deploy"] as const;
export type PermissionAction = (typeof PERMISSION_ACTIONS)[number];
/** Parse --action into deduped actions (default: inference); returns an error message on bad values. */
export function parsePermissionActions(
actionFlag: string | undefined,
): PermissionAction[] | { error: string } {
if (!actionFlag) return ["inference"];
const actions = parseCommaList(actionFlag);
if (actions.length === 0) return { error: "--action must not be empty." };
for (const action of actions) {
if (!(PERMISSION_ACTIONS as readonly string[]).includes(action)) {
return { error: `--action "${action}" is invalid; use ${PERMISSION_ACTIONS.join(", ")}.` };
}
}
return actions as PermissionAction[];
}
/** Cross-flag validation shared by grant and revoke. */
export function validatePermissionChange(flags: {
model?: string;
action?: string;
all: boolean;
}): string | undefined {
if (flags.all && flags.model) return "--all cannot be combined with --model.";
if (!flags.all && !flags.model) return "one of --model / --all is required.";
const actions = parsePermissionActions(flags.action);
if ("error" in actions) return actions.error;
if (flags.all && (actions.length !== 1 || actions[0] !== "inference"))
return "--all only supports the inference action.";
if (flags.model) {
const models = parseCommaList(flags.model);
if (models.length === 0) return "--model must not be empty.";
if (models.length > MAX_MODELS_PER_REQUEST)
return `--model accepts at most ${MAX_MODELS_PER_REQUEST} models per call.`;
}
}
/**
* Shared grant/revoke execution: build the POST body (per-model tri-state
* patch, or the access_all_entities one-key switch) and send it. Validation
* (mutual exclusion, action values, model count) has already run.
*/
export async function runPermissionChange(
ctx: { settings: Settings; client: Client },
flags: { model?: string; action?: string; all: boolean },
grant: boolean,
): Promise<void> {
const format = ctx.settings.outputExplicit ? detectOutputFormat(ctx.settings.output) : "json";
const actions = parsePermissionActions(flags.action) as PermissionAction[];
const models = flags.model ? parseCommaList(flags.model) : [];
const body: Record<string, unknown> = flags.all
? { access_all_entities: grant ? "OPEN" : "CLOSE" }
: {
models: models.map((model) => {
const entry: Record<string, unknown> = { model };
for (const action of actions) entry[action] = grant;
return entry;
}),
};
if (ctx.settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsPermissionsPath()), method: "POST", request: body },
format,
);
return;
}
const result = await ctx.client.requestJson<{ request_id?: string }>({
path: modelsPermissionsPath(),
method: "POST",
body,
});
const verb = grant ? "granted" : "revoked";
if (format === "json") {
const summary: Record<string, unknown> = flags.all
? { all: true, action: "inference" }
: { models, actions };
emitResult({ ...summary, [verb]: true, request_id: result.request_id }, format);
return;
}
if (flags.all) {
process.stdout.write(
grant
? "Inference permission granted for all models in the workspace (including future ones).\n"
: "One-key authorization closed; historical inference grants cleared.\n",
);
return;
}
process.stdout.write(`Permissions ${verb} (${actions.join(", ")}): ${models.join(", ")}\n`);
}
@@ -1,6 +1,7 @@
import { defineCommand, detectOutputFormat, BailianError, ExitCode } from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
const HISTORY_API = "zeldaEasy.broadscope-platform.modelInstance.listModelLimitApplications";
@@ -49,10 +50,6 @@ function formatDateTime(ts: string | undefined): string {
}
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
function printTable(records: LimitApplicationItem[], total: number): void {
const color = ansi(process.stdout);
+142 -244
View File
@@ -1,297 +1,195 @@
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
unwrapResponse,
MODEL_LIST_API,
type Client,
} from "bailian-cli-core";
import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core";
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
import { buildQuery, parseCommaList } from "../shared/params.ts";
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
// ---------------------------------------------------------------------------
// Types — mirror GET /api/v1/models/limits
// ---------------------------------------------------------------------------
interface QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
interface LimitSpec {
request_limit: number | null;
request_limit_period: number | null;
usage_limit: number | null;
usage_limit_field: string | null;
usage_limit_period: number | null;
async_user_queue_limit: number | null;
async_user_concurrency_limit: number | null;
}
interface ModelWithQpm {
interface ModelQuota {
model: string;
qpmInfo?: Record<string, QpmInfoItem>;
workspace_id?: string;
model_limit?: LimitSpec | null;
workspace_limit?: LimitSpec | null;
}
interface MonitorPoint {
value: number;
timestamp: number;
interface LimitsResponse {
output?: {
total?: number;
page_no?: number;
page_size?: number;
quotas?: ModelQuota[];
};
request_id?: string;
}
interface MonitorMetric {
aggMethod: string;
metricName: string;
points: MonitorPoint[];
// ---------------------------------------------------------------------------
// Formatters
// ---------------------------------------------------------------------------
/** Compact rate display: `500/s`, `60/min`, `83,333/6s`; "-" when unlimited. */
function formatLimit(limit: number | null | undefined, period: number | null | undefined): string {
if (limit == null) return "-";
const seconds = period ?? 60;
if (seconds === 1) return `${formatNumber(limit)}/s`;
if (seconds === 60) return `${formatNumber(limit)}/min`;
return `${formatNumber(limit)}/${seconds}s`;
}
function calculateRPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.count_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.count_limit * 60) / period);
function formatRequestLimit(spec: LimitSpec | null | undefined): string {
if (!spec) return "-";
return formatLimit(spec.request_limit, spec.request_limit_period);
}
function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.usage_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.usage_limit * 60) / period);
function formatUsageLimit(spec: LimitSpec | null | undefined): string {
if (!spec) return "-";
return formatLimit(spec.usage_limit, spec.usage_limit_period);
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
async function fetchMonitorData(
client: Client,
modelName: string,
windowMinutes: number,
): Promise<{ rpm: number; tpm: number }> {
const now = Date.now();
const startTime = now - windowMinutes * 60 * 1000;
try {
const raw = await client.console(MONITOR_API, {
reqDTO: {
monitorType: "Advanced",
metricFilters: [
{ aggMethod: "sum_pm", metricName: "model_total_amount" },
{ aggMethod: "sum_pm", metricName: "model_call_count" },
],
labelFilters: {
resourceId: modelName,
resourceType: "model",
},
startTime,
endTime: now,
},
});
const resp = unwrapResponse(raw as Record<string, unknown>);
const metrics = (resp.data ?? resp) as MonitorMetric[] | Record<string, unknown>;
if (!Array.isArray(metrics)) {
return { rpm: 0, tpm: 0 };
}
let rpm = 0;
let tpm = 0;
for (const metric of metrics) {
if (metric.aggMethod !== "sum_pm" || !metric.points?.length) continue;
const lastValue = metric.points[metric.points.length - 1].value ?? 0;
if (metric.metricName === "model_call_count") rpm = Math.round(lastValue);
if (metric.metricName === "model_total_amount") tpm = Math.round(lastValue);
}
return { rpm, tpm };
} catch (error) {
// Re-throw authentication errors (BailianError with ExitCode.AUTH);
// other errors are treated as "no data" and show "-" in the table.
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
throw error;
}
return { rpm: -1, tpm: -1 };
/** Async task headroom as `queue/concurrency`; "-" when the model has no async limits. */
function formatAsync(spec: LimitSpec | null | undefined): string {
if (!spec || (spec.async_user_queue_limit == null && spec.async_user_concurrency_limit == null)) {
return "-";
}
const queue =
spec.async_user_queue_limit != null ? formatNumber(spec.async_user_queue_limit) : "-";
const concurrency =
spec.async_user_concurrency_limit != null
? formatNumber(spec.async_user_concurrency_limit)
: "-";
return `${queue}/${concurrency}`;
}
async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
const allModels: ModelWithQpm[] = [];
let pageNo = 1;
while (true) {
const input: Record<string, unknown> = {
pageNo,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
};
const raw = await client.console(MODEL_LIST_API, { input });
const resp = unwrapResponse(raw as Record<string, unknown>);
const list = (resp.list as ModelWithQpm[]) ?? [];
const total = (resp.total as number) ?? 0;
allModels.push(...list);
if (allModels.length >= total || list.length === 0) break;
pageNo++;
function printTable(quotas: ModelQuota[], total: number): void {
if (quotas.length === 0) {
process.stdout.write("No rate limits found.\n");
return;
}
return allModels;
}
interface ListRow {
model: string;
rpm: string;
tpm: string;
rpmQuotaLeft: number | null;
tpmQuotaLeft: number | null;
rpmQuotaLabel: string | null;
tpmQuotaLabel: string | null;
}
function printTable(rows: ListRow[]): void {
const headers = ["Model", "Req/min", "Token/min", "RPM Left", "TPM Left"];
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
const tableRows = rows.map((r) => [r.model, r.rpm, r.tpm, "", ""]);
const headers = ["Model", "Req Limit", "Usage Limit", "WS Req", "WS Usage", "Async Q/C"];
const rows = quotas.map((quota) => [
quota.model,
formatRequestLimit(quota.model_limit),
formatUsageLimit(quota.model_limit),
formatRequestLimit(quota.workspace_limit),
formatUsageLimit(quota.workspace_limit),
formatAsync(quota.model_limit),
]);
const lines = renderBoxTable({
headers,
rows: tableRows,
align: ["left", "right", "right", "left", "left"],
barColumns: [
{ index: 3, percents: rpmPercents, labels: rpmLabels, width: 15 },
{ index: 4, percents: tpmPercents, labels: tpmLabels, width: 15 },
],
rows,
align: ["left", "right", "right", "right", "right", "right"],
});
for (const line of lines) process.stdout.write(line + "\n");
process.stdout.write(`\nTotal: ${total}\n`);
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
export default defineCommand({
description: "View model RPM/TPM rate limits",
auth: "console",
usageArgs: "[--model <model>] [flags]",
description: "View model rate limits (QPM/TPM, account and workspace level)",
auth: "apiKey",
usageArgs: "[--model <model>] [--name <name>] [--page <n>] [--page-size <n>]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name(s), comma-separated",
description: "Model name(s), comma-separated (exact match)",
},
name: {
type: "string",
valueHint: "<name>",
description: "Fuzzy search by model name",
},
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 20)" },
},
exampleArgs: ["", "--model qwen3.6-plus", "--model qwen3.6-plus,qwen-turbo", "--output json"],
exampleArgs: [
"",
"--model qwen3-max",
"--model qwen3-max,qwen-plus",
"--name qwen --page-size 50",
"--output json",
],
notes: ["Usage-vs-limit pressure checks live in `quota check` (console auth)."],
async run(ctx) {
const { settings, flags } = ctx;
const modelFlag = flags.model || undefined;
const nameFlag = flags.name || undefined;
const format = detectOutputFormat(settings.output);
const endpoint = ctx.client.url(modelsLimitsPath());
if (settings.dryRun) {
const input: Record<string, unknown> = {
pageNo: 1,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
};
emitResult(
{
apis: [
MODEL_LIST_API,
{ api: MONITOR_API, note: "called per-model for text output with gauges" },
],
modelListInput: { input },
},
format,
);
if (modelFlag) {
// One exact-match GET per model; dry-run lists them all.
const requests = parseCommaList(modelFlag).map((model) => ({
endpoint,
method: "GET",
query: { model, page_size: 100 },
}));
emitResult({ requests }, format);
} else {
emitResult(
{
endpoint,
method: "GET",
query: {
name: nameFlag,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
},
},
format,
);
}
return;
}
let models = await fetchAllModelsWithQpm(ctx.client);
let quotas: ModelQuota[];
let total: number;
if (modelFlag) {
const names = new Set(
modelFlag
.split(",")
.map((n) => n.trim())
.filter(Boolean),
// Exact lookup per model, then merge.
const responses = await Promise.all(
parseCommaList(modelFlag).map((model) =>
ctx.client.requestJson<LimitsResponse>({
path: modelsLimitsPath() + buildQuery({ model, page_size: 100 }),
}),
),
);
models = models.filter((m) => names.has(m.model));
if (models.length === 0) {
throw new BailianError(`no matching models found for "${modelFlag}".`);
}
quotas = responses.flatMap((resp) => resp.output?.quotas ?? []);
total = quotas.length;
} else {
const resp = await ctx.client.requestJson<LimitsResponse>({
path:
modelsLimitsPath() +
buildQuery({
name: nameFlag,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
}),
});
quotas = resp.output?.quotas ?? [];
total = resp.output?.total ?? quotas.length;
}
if (format === "json") {
const items = models.map((m) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
return {
model: m.model,
rpm: currentRPM > 0 ? currentRPM : null,
tpm: currentTPM > 0 ? currentTPM : null,
};
});
emitResult(items, format);
emitResult({ items: quotas, total }, format);
return;
}
// For text output with gauges, we need monitor data
const monitorResults = await Promise.all(
models.map((m) => fetchMonitorData(ctx.client, m.model, 2)),
);
const rows: ListRow[] = models.map((m, idx) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
const rpmUsage = monitorResults[idx].rpm;
const tpmUsage = monitorResults[idx].tpm;
// RPM Quota Left = 1 - (rpmUsage / currentRPM) in percentage
let rpmQuotaPercent: number | null = null;
let rpmQuotaLabel: string | null = null;
if (rpmUsage >= 0 && currentRPM > 0) {
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / currentRPM) * 100);
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
}
// TPM Quota Left = 1 - (tpmUsage / currentTPM) in percentage
let tpmQuotaPercent: number | null = null;
let tpmQuotaLabel: string | null = null;
if (tpmUsage >= 0 && currentTPM > 0) {
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / currentTPM) * 100);
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
}
return {
model: m.model,
rpm: currentRPM > 0 ? formatNumber(currentRPM) : "-",
tpm: currentTPM > 0 ? formatNumber(currentTPM) : "-",
rpmQuotaLeft: rpmQuotaPercent,
tpmQuotaLeft: tpmQuotaPercent,
rpmQuotaLabel,
tpmQuotaLabel,
};
});
if (rows.length === 0) {
process.stdout.write("No models found.\n");
return;
}
printTable(rows);
printTable(quotas, total);
},
});
@@ -1,188 +0,0 @@
import {
defineCommand,
UsageError,
BailianError,
ExitCode,
detectOutputFormat,
type Client,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
const UPDATE_LIMITS_API = "zeldaEasy.broadscope-platform.modelInstance.updateFoundationModelLimits";
interface QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
}
function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.usage_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.usage_limit * 60) / period);
}
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;
}
async function fetchModelQpmInfo(
client: Client,
modelName: string,
): Promise<{ model: string; qpmInfo: Record<string, QpmInfoItem> } | undefined> {
const raw = await client.console(MODEL_LIST_API, {
input: {
pageNo: 1,
pageSize: 50,
name: modelName,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
},
});
const resp = extractResponseData(raw as Record<string, unknown>);
const list = (resp.list as Array<{ model: string; qpmInfo?: Record<string, QpmInfoItem> }>) ?? [];
return list.find((m) => m.model === modelName && m.qpmInfo) as
| { model: string; qpmInfo: Record<string, QpmInfoItem> }
| undefined;
}
export default defineCommand({
description: "Request a temporary quota increase",
auth: "console",
usageArgs: "--model <model> --tpm <value> [flags]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name (required)",
required: true,
},
tpm: {
type: "string",
valueHint: "<value>",
description: "Target TPM value (required)",
required: true,
},
},
exampleArgs: [
"--model qwen-turbo --tpm 100000",
"--model qwen3.6-plus --tpm 8000000",
"--model qwen-turbo --tpm 100000 --output json",
],
validate: (f) => (Number(f.tpm) > 0 ? undefined : "--tpm must be a positive number."),
async run(ctx) {
const { identity, settings, flags } = ctx;
const modelName = flags.model;
const tpmValue = Number(flags.tpm);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const requestData = {
input: {
model: modelName,
limit: { usage_limit: tpmValue },
},
};
emitResult({ api: UPDATE_LIMITS_API, data: requestData }, format);
return;
}
const modelInfo = await fetchModelQpmInfo(ctx.client, modelName);
if (!modelInfo) {
throw new BailianError(
`model "${modelName}" not found or does not support self-service quota increase.`,
ExitCode.GENERAL,
`Run \`${identity.binName} quota list\` to view available models.`,
);
}
const modelDefault = modelInfo.qpmInfo["model-default"];
const userSpec = modelInfo.qpmInfo["user-spec"];
const minLimit = calculateTPM(modelDefault);
const currentLimit = calculateTPM(userSpec, modelDefault?.usage_limit_period) || minLimit;
const maxLimit = minLimit * 2;
if (tpmValue < minLimit || tpmValue > maxLimit) {
throw new UsageError(
`TPM value ${tpmValue.toLocaleString()} is out of range. ` +
`Current: ${currentLimit.toLocaleString()}, Range: ${minLimit.toLocaleString()} ~ ${maxLimit.toLocaleString()}.`,
);
}
const requestData = {
input: {
model: modelName,
limit: { usage_limit: tpmValue },
originalQpmInfo: modelInfo.qpmInfo,
} as Record<string, unknown>,
};
const submitRequest = async (confirmedDowngrade?: boolean): Promise<unknown> => {
if (confirmedDowngrade) {
requestData.input.confirmedDowngrade = true;
}
try {
return await ctx.client.console(UPDATE_LIMITS_API, requestData);
} catch (err) {
if (err instanceof BailianError && err.message.includes("NotLogined")) {
throw new BailianError(
"session expired.",
ExitCode.AUTH,
`Run \`${identity.binName} auth login --console\` to re-authenticate.`,
);
}
throw err;
}
};
let result = await submitRequest();
const resp = extractResponseData(result as Record<string, unknown>);
if (resp.needConfirm) {
const confirmCode = resp.confirmCode as string;
if (confirmCode === "Refresh_Required") {
throw new BailianError("rate limit has been updated externally. Please retry.");
}
if (confirmCode === "Downgrade") {
result = await submitRequest(true);
}
}
if (format === "json") {
emitResult(result, format);
return;
}
process.stdout.write(
`Quota updated for "${modelName}": TPM ${currentLimit.toLocaleString()} → ${tpmValue.toLocaleString()}\n`,
);
},
});
@@ -0,0 +1,100 @@
import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
const MINUTE_SECONDS = 60;
export default defineCommand({
description: "Update model rate limits (QPM/TPM), or clear them with --delete",
auth: "apiKey",
usageArgs: "--model <model> [--rpm <n>] [--tpm <n>] [--delete]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name (required)",
required: true,
},
rpm: {
type: "number",
valueHint: "<n>",
description: "Max requests per minute (QPM)",
},
tpm: {
type: "number",
valueHint: "<n>",
description: "Max tokens per minute (TPM)",
},
delete: {
type: "switch",
description: "Clear all custom rate limits for the model",
},
},
exampleArgs: [
"--model qwen-plus --rpm 60 --tpm 100000",
"--model qwen3-max --tpm 500000",
"--model qwen-plus --delete",
"--model qwen-plus --rpm 60 --output json",
],
notes: [
"Fields you omit keep their current values (server-side OVERLAY merge); --delete clears all custom limits.",
"Setting TPM without an existing QPM limit is rejected server-side — pass --rpm first or together.",
],
validate: (flags) => {
if (flags.delete && (flags.rpm !== undefined || flags.tpm !== undefined))
return "--delete cannot be combined with --rpm/--tpm.";
if (!flags.delete && flags.rpm === undefined && flags.tpm === undefined)
return "one of --rpm / --tpm / --delete is required.";
if (flags.rpm !== undefined && flags.rpm < 0) return "--rpm must be a non-negative number.";
if (flags.tpm !== undefined && flags.tpm < 0) return "--tpm must be a non-negative number.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const modelName = flags.model;
const format = detectOutputFormat(settings.output);
const entry: Record<string, unknown> = { model: modelName };
if (flags.delete) {
entry.operation_type = "DELETE";
} else {
if (flags.rpm !== undefined) {
entry.request_limit = flags.rpm;
entry.request_limit_period = MINUTE_SECONDS;
}
if (flags.tpm !== undefined) {
entry.usage_limit = flags.tpm;
entry.usage_limit_period = MINUTE_SECONDS;
}
}
const body = { models: [entry] };
if (settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsLimitsPath()), method: "POST", request: body },
format,
);
return;
}
const result = await ctx.client.requestJson<{ request_id?: string }>({
path: modelsLimitsPath(),
method: "POST",
body,
});
if (format === "json") {
emitResult({ model: modelName, ...result }, format);
return;
}
if (flags.delete) {
process.stdout.write(`Rate limits cleared for "${modelName}".\n`);
return;
}
const parts: string[] = [];
if (flags.rpm !== undefined) parts.push(`QPM ${formatNumber(flags.rpm)}`);
if (flags.tpm !== undefined) parts.push(`TPM ${formatNumber(flags.tpm)}`);
process.stdout.write(`Rate limits updated for "${modelName}": ${parts.join(", ")}\n`);
},
});
@@ -0,0 +1,4 @@
/** Format an integer with en-US thousands separators for table / text output. */
export function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
@@ -0,0 +1,21 @@
/** Split a comma-separated flag value into trimmed, deduped, non-empty entries. */
export function parseCommaList(value: string): string[] {
return [
...new Set(
value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
/** Serialize defined, non-empty params into a `?key=value` query string ("" when empty). */
export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") search.set(key, String(value));
}
const queryString = search.toString();
return queryString ? `?${queryString}` : "";
}
+54 -36
View File
@@ -4,21 +4,12 @@ import {
defineCommand,
detectInstalledAgents,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
installSkillWithFanout,
readSkillLock,
runWithConcurrency,
writeSkillLock,
} from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
interface InitOutcome {
name: string;
status: "installed" | "failed";
publishedAt?: string;
agents?: string[];
reason?: string;
}
import { emitBare, emitResult } from "bailian-cli-runtime";
/** Prefix used to identify first-party Bailian skills in the registry. */
const BAILIAN_PREFIX = "bailian-";
@@ -26,6 +17,22 @@ const BAILIAN_PREFIX = "bailian-";
/** Max number of skills downloading/installing at the same time. */
const INIT_CONCURRENCY = 3;
/** Default output format when user does not pass --output explicitly. */
const DEFAULT_FORMAT = "json";
/** All status values used by skill init (per-skill outcome + aggregate result). */
const STATUS = {
success: "success",
partial: "partial",
failed: "failed",
} as const;
interface InitOutcome {
name: string;
status: typeof STATUS.success | typeof STATUS.failed;
reason?: string;
}
export default defineCommand({
description: "Install all bailian-* skills (one-shot bootstrap for new environments)",
auth: "none",
@@ -36,7 +43,7 @@ export default defineCommand({
"Equivalent to: bl skill add --all (filtered to bailian-* skills)",
],
async run(ctx) {
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const format = ctx.settings.outputExplicit ? ctx.settings.output : DEFAULT_FORMAT;
const index = await fetchSkillsIndex();
// Discover all bailian-* skills from the live registry index
@@ -55,16 +62,11 @@ export default defineCommand({
lock.skills[name]?.links ?? [],
);
lock.skills[name] = record.lockEntry;
return {
name,
status: "installed",
publishedAt: entry.publishedAt,
agents: record.linkedAgents,
};
return { name, status: STATUS.success };
} catch (err) {
return {
name,
status: "failed",
status: STATUS.failed,
reason: err instanceof Error ? err.message : String(err),
};
}
@@ -72,30 +74,46 @@ export default defineCommand({
const results = await runWithConcurrency(tasks, INIT_CONCURRENCY);
writeSkillLock(lock);
if (format === "json") {
emitResult(
{
registry: getSkillRegistryBaseUrl(),
agents: agents.map((agent) => agent.id),
skills: results,
},
format,
);
const installed = results.filter((result) => result.status === STATUS.success);
const failed = results.filter((result) => result.status === STATUS.failed);
const status =
failed.length === 0
? STATUS.success
: installed.length === 0
? STATUS.failed
: STATUS.partial;
if (format === DEFAULT_FORMAT) {
const agentIds = agents.map((agent) => agent.id);
const payload: Record<string, unknown> = {
status,
skills: installed.map((result) => result.name),
};
if (failed.length > 0) {
payload.failed = failed.map((result) => ({
name: result.name,
reason: result.reason,
agents: agentIds,
}));
}
emitResult(payload, format);
} else if (results.length === 0) {
emitBare("No bailian-* skills found in the registry.");
} else {
const rows = results.map((result) => [
result.name,
result.status,
result.publishedAt ? result.publishedAt.slice(0, 10) : "-",
result.status === "installed" ? result.agents?.join(", ") || "-" : (result.reason ?? "-"),
]);
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) {
emitBare(line);
emitBare(
status === STATUS.success
? `Installed ${installed.length} bailian-* skills.`
: `Installed ${installed.length}/${results.length} bailian-* skills.`,
);
if (failed.length > 0) {
emitBare("Failed:");
for (const item of failed) {
emitBare(` ${item.name}: ${item.reason}`);
}
}
}
const failed = results.filter((result) => result.status === "failed");
if (failed.length > 0) {
throw new BailianError(
`${failed.length}/${results.length} skill(s) failed to install`,
@@ -1,7 +1,7 @@
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";
import { formatNumber } from "../shared/format.ts";
const CODING_PLAN_USAGE_API =
"zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2";
+4 -6
View File
@@ -1,4 +1,4 @@
import { defineCommand, detectOutputFormat, fetchModelList } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, findModelByName } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
FREE_TIER_API,
@@ -93,13 +93,11 @@ export default defineCommand({
}
requestData.queryFreeTierQuotaRequest.models = models;
} else {
const searchResults = await Promise.all(
models.map((name) =>
fetchModelList((api, data) => ctx.client.console(api, data), { name, pageSize: 50 }),
),
const matches = await Promise.all(
models.map((name) => findModelByName((api, data) => ctx.client.console(api, data), name)),
);
for (let idx = 0; idx < models.length; idx++) {
const matched = searchResults[idx].models.find((item) => item.model === models[idx]);
const matched = matches[idx];
if (matched) {
typeMap.set(models[idx], resolveModelType((matched.capabilities as string[]) || []));
}
+3 -16
View File
@@ -1,5 +1,5 @@
import {
fetchModelList,
fetchModelListAll,
BailianError,
ExitCode,
unwrapResponse,
@@ -7,15 +7,12 @@ import {
type Settings,
} from "bailian-cli-core";
import { ansi, renderBoxTable, displayWidth, padEnd } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
// ---------------------------------------------------------------------------
// Common formatters
// ---------------------------------------------------------------------------
export function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
export function formatDate(ts: number): string {
const date = new Date(ts);
const year = date.getFullYear();
@@ -87,17 +84,7 @@ export interface ModelInfo {
}
export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
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++;
}
const allModels = await fetchModelListAll((api, data) => client.console(api, data));
return allModels
.filter((item) => typeof item.model === "string" && item.model)
.map((item) => ({
@@ -8,13 +8,13 @@ import {
extractListData,
extractOverviewData,
formatDate,
formatNumber,
pollTelemetryApi,
requireWorkspaceId,
resolveUsageMap,
type ModelStatisticItem,
type OverviewStatistic,
} from "./shared.ts";
import { formatNumber } from "../shared/format.ts";
interface UsageLabel {
en: string;
@@ -1,6 +1,7 @@
import {
defineCommand,
videoGeneratePath,
image2videoPath,
taskPath,
detectOutputFormat,
type DashScopeVideoRequest,
@@ -43,6 +44,11 @@ export default defineCommand({
valueHint: "<url>",
description: "Input image URL for image-to-video generation",
},
lastFrame: {
type: "string",
valueHint: "<url>",
description: "Last frame image URL (with --image, enables kf2v first+last frame mode)",
},
negativePrompt: {
type: "string",
valueHint: "<text>",
@@ -110,12 +116,20 @@ export default defineCommand({
const format = detectOutputFormat(settings.output);
const imageUrl = flags.image;
const lastFrameUrl = flags.lastFrame as string | undefined;
// Auto-upload local image file for i2v
let resolvedImageUrl: string | undefined;
if (imageUrl) {
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
}
let resolvedLastFrameUrl: string | undefined;
if (lastFrameUrl) {
resolvedLastFrameUrl = await ctx.client.resolveImageInput(lastFrameUrl, model);
}
// kf2v mode: both --image and --last-frame provided.
const isKf2v = Boolean(resolvedImageUrl && resolvedLastFrameUrl);
const watermark = resolveWatermark(flags.watermark);
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
@@ -125,10 +139,16 @@ export default defineCommand({
input: {
prompt: prompt,
negative_prompt: flags.negativePrompt || undefined,
// i2v models (happyhorse-1.1-i2v) require input.media with type 'first_frame'
...(resolvedImageUrl
? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
// kf2v: first+last frame flat fields via image2video endpoint.
// wan2.1~2.6 i2v: flat img_url via video-generation endpoint.
// wan2.7+ / happyhorse i2v: media[] via video-generation endpoint.
...(isKf2v
? { first_frame_url: resolvedImageUrl, last_frame_url: resolvedLastFrameUrl }
: resolvedImageUrl
? /wan[x]?2\.[1-6]/i.test(model)
? { img_url: resolvedImageUrl }
: { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
},
parameters: {
resolution: flags.resolution || undefined,
@@ -141,15 +161,28 @@ export default defineCommand({
};
if (settings.dryRun) {
const previewBody = resolvedImageUrl
? {
...body,
input: {
...body.input,
media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }],
},
}
: body;
let previewBody = body;
if (isKf2v) {
previewBody = {
...body,
input: {
...body.input,
first_frame_url: redactDataUri(resolvedImageUrl ?? ""),
last_frame_url: redactDataUri(resolvedLastFrameUrl ?? ""),
},
};
} else if (resolvedImageUrl) {
const redactedUrl = redactDataUri(resolvedImageUrl);
previewBody = {
...body,
input: {
...body.input,
...(/wan[x]?2\.[1-6]/i.test(model)
? { img_url: redactedUrl }
: { media: [{ type: "first_frame" as const, url: redactedUrl }] }),
},
};
}
emitResult({ request: previewBody }, format);
return;
}
@@ -162,7 +195,7 @@ export default defineCommand({
settings,
() =>
ctx.client.requestJson<DashScopeAsyncResponse>({
path: videoGeneratePath(),
path: isKf2v ? image2videoPath() : videoGeneratePath(),
method: "POST",
body,
async: true,
+39 -1
View File
@@ -36,6 +36,37 @@ export { default as memoryProfileGet } from "./commands/memory/profile-get.ts";
export { default as knowledgeRetrieve } from "./commands/knowledge/retrieve.ts";
export { default as knowledgeSearch } from "./commands/knowledge/search.ts";
export { default as knowledgeChat } from "./commands/knowledge/chat.ts";
export { default as knowledgeKbList } from "./commands/knowledge/kb-list.ts";
export { default as knowledgeKbInfo } from "./commands/knowledge/kb-info.ts";
export { default as knowledgeDocList } from "./commands/knowledge/doc-list.ts";
export { default as knowledgeDocStatus } from "./commands/knowledge/doc-status.ts";
export { default as knowledgeDocUpload } from "./commands/knowledge/doc-upload.ts";
export { default as knowledgeKbCreate } from "./commands/knowledge/kb-create.ts";
export { default as knowledgeKbUpdate } from "./commands/knowledge/kb-update.ts";
export { default as knowledgeKbDelete } from "./commands/knowledge/kb-delete.ts";
export { default as knowledgeDocDelete } from "./commands/knowledge/doc-delete.ts";
export { default as knowledgeDocTag } from "./commands/knowledge/doc-tag.ts";
export { default as knowledgeServiceList } from "./commands/knowledge/service-list.ts";
export { default as knowledgeServiceGet } from "./commands/knowledge/service-get.ts";
export { default as knowledgeServiceCreate } from "./commands/knowledge/service-create.ts";
export { default as knowledgeServiceUpdate } from "./commands/knowledge/service-update.ts";
export { default as knowledgeServiceDeploy } from "./commands/knowledge/service-deploy.ts";
export { default as knowledgeServiceDelete } from "./commands/knowledge/service-delete.ts";
export { default as knowledgeServiceCopy } from "./commands/knowledge/service-copy.ts";
export { default as knowledgeChunkAdd } from "./commands/knowledge/chunk-add.ts";
export { default as knowledgeChunkList } from "./commands/knowledge/chunk-list.ts";
export { default as knowledgeChunkUpdate } from "./commands/knowledge/chunk-update.ts";
export { default as knowledgeChunkDelete } from "./commands/knowledge/chunk-delete.ts";
export { default as knowledgeKbStats } from "./commands/knowledge/kb-stats.ts";
export { default as knowledgeCategoryList } from "./commands/knowledge/category-list.ts";
export { default as knowledgeCategoryAdd } from "./commands/knowledge/category-add.ts";
export { default as knowledgeCategoryDelete } from "./commands/knowledge/category-delete.ts";
export { default as knowledgeFileList } from "./commands/knowledge/file-list.ts";
export { default as knowledgeFileGet } from "./commands/knowledge/file-get.ts";
export { default as knowledgeFileDelete } from "./commands/knowledge/file-delete.ts";
export { default as knowledgeCollectionCreate } from "./commands/knowledge/collection-create.ts";
export { default as knowledgeCollectionGet } from "./commands/knowledge/collection-get.ts";
export { default as knowledgeDocImportOss } from "./commands/knowledge/doc-import-oss.ts";
export { default as mcpCall } from "./commands/mcp/call.ts";
export { default as mcpList } from "./commands/mcp/list.ts";
export { default as mcpTools } from "./commands/mcp/tools.ts";
@@ -56,9 +87,12 @@ export { default as advisorRecommend } from "./commands/advisor/recommend.ts";
export { default as modelList } from "./commands/model/list.ts";
export { default as workspaceList } from "./commands/workspace/list.ts";
export { default as quotaList } from "./commands/quota/list.ts";
export { default as quotaRequest } from "./commands/quota/request.ts";
export { default as quotaUpdate } from "./commands/quota/update.ts";
export { default as quotaHistory } from "./commands/quota/history.ts";
export { default as quotaCheck } from "./commands/quota/check.ts";
export { default as permissionList } from "./commands/permission/list.ts";
export { default as permissionGrant } from "./commands/permission/grant.ts";
export { default as permissionRevoke } from "./commands/permission/revoke.ts";
export { default as datasetUpload } from "./commands/dataset/upload.ts";
export { default as datasetList } from "./commands/dataset/list.ts";
export { default as datasetGet } from "./commands/dataset/get.ts";
@@ -68,6 +102,7 @@ export {
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
finetuneVideoCreate,
} from "./commands/finetune/create.ts";
export { default as finetuneList } from "./commands/finetune/list.ts";
export { default as finetuneGet } from "./commands/finetune/get.ts";
@@ -78,6 +113,7 @@ export { default as finetuneCheckpoints } from "./commands/finetune/checkpoints.
export { default as finetuneExport } from "./commands/finetune/export.ts";
export { default as finetuneWatch } from "./commands/finetune/watch.ts";
export { default as finetuneCapability } from "./commands/finetune/capability.ts";
export { default as finetunePrice } from "./commands/finetune/price.ts";
export {
deployTextCreate,
deployAudioCreate,
@@ -89,6 +125,8 @@ export { default as deployModels } from "./commands/deploy/models.ts";
export { default as deployScale } from "./commands/deploy/scale.ts";
export { default as deployUpdate } from "./commands/deploy/update.ts";
export { default as deployDelete } from "./commands/deploy/delete.ts";
export { default as deployPause } from "./commands/deploy/pause.ts";
export { default as deployResume } from "./commands/deploy/resume.ts";
export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.ts";
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
@@ -20,6 +20,7 @@ interface ValidationServer {
body: Record<string, unknown>;
authorization?: string;
sourceConfig?: string;
openApiSource?: string;
}>;
close(): Promise<void>;
}
@@ -36,6 +37,7 @@ async function startValidationServer(statusCode = 200): Promise<ValidationServer
body: rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {},
authorization: request.headers.authorization,
sourceConfig: request.headers["x-dashscope-source-config"] as string | undefined,
openApiSource: request.headers["x-dashscope-openapisource"] as string | undefined,
});
response.writeHead(statusCode, { "Content-Type": "application/json" });
if (statusCode >= 400) {
@@ -216,6 +218,7 @@ describe("e2e: auth", () => {
path: "/compatible-mode/v1/chat/completions",
authorization: "Bearer sk-e2e-placeholder",
sourceConfig: expect.any(String),
openApiSource: "BailianCLI",
body: {
model: "qwen3.8-max",
stream: false,
@@ -313,6 +316,7 @@ describe("e2e: auth", () => {
path: "/compatible-mode/v1/chat/completions",
authorization: "Bearer sk-sp-e2e-placeholder",
sourceConfig: expect.any(String),
openApiSource: "BailianCLI",
body: {
model: "qwen3.8-max",
stream: false,
@@ -30,7 +30,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--name/i);
expect(stderr).toMatch(/--model-name|--display-name/i);
});
test("deploy create --dry-run 构造 lora 部署请求体", async () => {
@@ -38,9 +38,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"text",
"create",
"--model",
"--model-name",
"qwen-plus-2025-12-01",
"--name",
"--display-name",
"my-qwen-plus",
"--dry-run",
"--output",
@@ -68,9 +68,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"text",
"create",
"--model",
"--model-name",
"qwen3-8b",
"--name",
"--display-name",
"my-qwen3-mu",
"--plan",
"mu",
@@ -102,9 +102,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"audio",
"create",
"--model",
"--model-name",
"my-cosyvoice-ft",
"--name",
"--display-name",
"my-tts",
"--dry-run",
"--output",
+112 -14
View File
@@ -31,7 +31,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
expect(stderr).toMatch(/--base-model|--datasets/i);
});
test("finetune create --dry-run 构造 SFT 默认请求体", async () => {
@@ -39,7 +39,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa,file-bbb",
@@ -73,7 +73,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -135,7 +135,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -157,7 +157,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -176,7 +176,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
`${localPath},file-bbb`,
@@ -207,7 +207,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
" , ",
@@ -228,7 +228,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
localPath,
@@ -250,7 +250,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
localPath,
@@ -272,7 +272,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
["cancel", ["--job-id", "ft-xxx"]],
["delete", ["--job-id", "ft-xxx"]],
["watch", ["--job-id", "ft-xxx"]],
["capability", ["--model", "qwen3-8b"]],
["capability", ["--base-model", "qwen3-8b"]],
])("finetune %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
@@ -292,7 +292,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
" file-a , ,file-b ",
@@ -314,7 +314,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"audio",
"create",
"--model",
"--base-model",
"cosyvoice-v3-flash",
"--datasets",
"file-audio",
@@ -343,7 +343,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
expect(stderr).toMatch(/--base-model|--datasets/i);
expect(stderr).not.toMatch(/--training-type|--n-epochs|--batch-size|--max-length/);
});
@@ -352,7 +352,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"image",
"create",
"--model",
"--base-model",
"wan2.7-image-pro",
"--datasets",
"file-image",
@@ -365,6 +365,104 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
expect(data.action).toBe("finetune.create");
expect(data.body.training_type).toBe("efficient_sft");
});
test("finetune video create --help 暴露视频超参且不含文本超参", async () => {
// Video exposes --n-epochs / --batch-size / --learning-rate; the text-only
// --training-type / --max-length surface is not offered.
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--base-model/);
expect(stderr).toMatch(/--n-epochs/);
expect(stderr).toMatch(/--batch-size/);
expect(stderr).toMatch(/--learning-rate/);
expect(stderr).not.toMatch(/--training-type|--max-length/);
});
test("finetune video create --datasets 缺失时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
"wan2.7-i2v",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--datasets|Missing required/i);
});
test.each([
// Model-family-specific defaults resolved by the sft-lora video profile.
["wan2.7-i2v", 1, 102400],
["wan2.5-i2v-preview", 4, 36864],
["wan2.2-kf2v-flash", 4, 262144],
])(
"finetune video create --dry-run %s 解析 batch_size=%i / max_pixels=%i",
async (baseModel, batchSize, maxPixels) => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
baseModel,
"--datasets",
"file-video",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
model: string;
training_type: string;
hyper_parameters: Record<string, unknown>;
};
}>(stdout);
expect(data.action).toBe("finetune.create");
expect(data.body.model).toBe(baseModel);
expect(data.body.training_type).toBe("efficient_sft");
expect(data.body.hyper_parameters.batch_size).toBe(batchSize);
expect(data.body.hyper_parameters.max_pixels).toBe(maxPixels);
expect(data.body.hyper_parameters.learning_rate).toBe("2e-5");
expect(data.body.hyper_parameters.lora_rank).toBe(32);
},
);
test("finetune video create --dry-run 转发超参覆盖且不做 clamp", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
"wan2.7-i2v",
"--datasets",
"file-video",
"--n-epochs",
"100",
"--batch-size",
"2",
"--learning-rate",
"1e-5",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
body: { hyper_parameters: Record<string, unknown> };
}>(stdout);
// Video overrides are forwarded verbatim (no [8, 1024] text clamp).
expect(data.body.hyper_parameters.n_epochs).toBe(100);
expect(data.body.hyper_parameters.batch_size).toBe(2);
expect(data.body.hyper_parameters.learning_rate).toBe("1e-5");
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {

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