Merge branch 'main' into feat/composable-cli

This commit is contained in:
若麒
2026-06-30 23:07:18 +08:00
129 changed files with 9104 additions and 818 deletions
+12 -5
View File
@@ -3,6 +3,13 @@ name: Publish
on:
workflow_dispatch:
inputs:
package:
description: "Which package set to publish"
required: true
type: choice
options:
- bailian-cli
- knowledge-studio-cli
mode:
description: "Publish mode"
required: true
@@ -16,13 +23,13 @@ on:
type: string
concurrency:
group: publish-${{ inputs.mode }}-${{ inputs.channel }}
group: publish-${{ inputs.package }}-${{ inputs.mode }}-${{ inputs.channel }}
cancel-in-progress: false
jobs:
publish-stable:
if: inputs.mode == 'stable'
name: publish stable to npm + tag
name: publish stable (${{ inputs.package }}) to npm + tag
runs-on: ubuntu-latest
environment: production # Required Reviewers gate
permissions:
@@ -51,11 +58,11 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: publish-stable
run: node tools/release/publish-stable.mjs
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
publish-channel:
if: inputs.mode == 'channel'
name: publish beta to npm
name: publish channel (${{ inputs.package }}) to npm
runs-on: ubuntu-latest
permissions:
contents: read # no tag, no Release; just publish
@@ -83,4 +90,4 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: publish-channel
run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}"
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
+1
View File
@@ -0,0 +1 @@
24
+23
View File
@@ -6,6 +6,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.4.2] - 2026-06-24
### Added
- `bl omni --list-voices` prints the built-in output voices (ID, name, description, language) and exits without needing an API key. The built-in voice table is expanded from 6 to 17 voices, including dialect voices such as Dylan, Sunny, and Kiki.
### Changed
- `bl omni` default `--voice` is now `Tina` (previously `Cherry`). The `--voice` help points at `--list-voices` instead of listing every option inline.
- `bl speech synthesize --list-voices` and its missing-`--voice` hint now include a link to the official CosyVoice voice documentation.
- Agent skill setup guidance now covers console site selection (`--console-site domestic` / `international`) for console login and gateway commands.
### Fixed
- `bl speech synthesize` corrects the `cosyvoice-v3-flash` built-in voice ID from `longanhuan` to `longanhuan_v3`.
## [1.4.1] - 2026-06-22
### Changed
- Video generation now defaults to the upgraded HappyHorse 1.1 model for better quality. The 1.0 models are still available via `--model`.
- `bl update` now keeps the agent skill in sync across all your agent apps (Claude Code, Cursor, etc.), and refreshes it even when the CLI is already up to date.
## [1.4.0] - 2026-06-17
### Added
+23
View File
@@ -6,6 +6,29 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.4.2] - 2026-06-24
### 新增
- `bl omni --list-voices` 无需 API key 即可打印内置输出音色列表(ID、名称、描述、语言)并退出。内置音色表从 6 个扩展到 17 个,新增 Dylan、Sunny、Kiki 等方言音色。
### 变更
- `bl omni` 默认 `--voice` 改为 `Tina`(原为 `Cherry`)。`--voice` 帮助文案改为指向 `--list-voices`,不再内联列出全部音色。
- `bl speech synthesize --list-voices` 输出及缺少 `--voice` 时的提示中,新增官方 CosyVoice 音色文档链接。
- Agent skill 配置指引新增 console 站点选择说明(`--console-site domestic` / `international`),适用于 console 登录与网关类命令。
### 修复
- `bl speech synthesize` 修正 `cosyvoice-v3-flash` 内置音色 ID,由 `longanhuan` 改为 `longanhuan_v3`。
## [1.4.1] - 2026-06-22
### 变更
- 视频生成默认升级到 HappyHorse 1.1 模型,画面质量更佳。如需使用 1.0 模型,可通过 `--model` 指定。
- `bl update` 现在会把 agent skill 同步更新到所有 agent 应用(Claude Code、Cursor 等),即使 CLI 已是最新版本也会刷新 skill。
## [1.4.0] - 2026-06-17
### 新增
+25 -16
View File
@@ -27,7 +27,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
@@ -38,6 +38,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Fine-tuning & deployment** — Upload datasets, create SFT/LoRA/DPO/CPT jobs (`finetune create`), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy create`)
- **Console capabilities** — Browse Bailian apps (`app list`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
@@ -54,7 +55,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.0**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
### The single prompt
@@ -67,7 +68,7 @@ A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.0** in parallel.
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
@@ -111,22 +112,30 @@ bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
# Browser login (required for console capability commands)
bl auth login --console
# Fine-tune & deploy — a one-shot train-to-serve workflow
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
bl finetune watch --job-id ft-xxx --output json # Non-blocking status probe (exit 0/1/3 = done/failed/running)
bl finetune capability --model qwen3-8b # Which training types a model supports
bl deploy create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
# Browse apps / free-tier quota / usage statistics / workspaces
bl app list
bl usage free --model qwen3-max
bl usage free --expiring 30 # Quotas expiring within 30 days
bl usage free --sort remaining # Sort by remaining % ascending
bl usage stats --workspace-id <id> # Usage overview for a workspace
bl usage stats --model qwen-turbo --workspace-id <id> # Per-model usage
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
bl workspace list # List all workspaces
# Rate limit management
bl quota list # View RPM/TPM limits for all models
bl quota list --model qwen3.6-plus # View limits for a specific model
bl quota check # Current usage vs rate limits
bl quota check --model qwen3.6-plus --period 5 # Check usage over last 5 minutes
# Rate limit management (list / check / request / history)
bl quota list # View RPM/TPM limits (add --model to filter)
bl quota check # Current usage vs rate limits (add --model/--period)
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
bl quota history # View quota change history
bl quota history # View quota-change history
# Token Plan team management (requires AK/SK, see auth below)
bl token-plan list-seats # View subscription seat details
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
@@ -156,9 +165,9 @@ Required for console capability commands (`app list`, `usage free`, `usage stats
bl auth login --console
```
### Alibaba Cloud AK/SK (Knowledge Base only)
### Alibaba Cloud AK/SK (Knowledge Base & Token Plan)
Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
Required for `knowledge retrieve` and the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
+28 -16
View File
@@ -27,7 +27,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
@@ -38,6 +38,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建 SFT/LoRA/DPO/CPT 调优任务(`finetune create`)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy create`)
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
@@ -54,7 +55,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.0**,百炼的文生/图生/参考生视频模型
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
### 唯一的提示词
@@ -65,7 +66,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.0**。
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
@@ -82,7 +83,10 @@ npx skills add modelstudioai/cli --all -g
## 快速开始
```bash
# 认证
# 认证(推荐浏览器登录)
bl auth login --console
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 和通义千问对话
@@ -106,22 +110,30 @@ bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代
# 浏览器登录(控制台能力相关命令需要)
bl auth login --console
# 微调与部署 — 从训练到服务的一站式流程
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
bl finetune watch --job-id ft-xxx --output json # 非阻塞状态探测(退出码 0/1/3 = 成功/失败/进行中)
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
bl deploy create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
bl app list
bl usage free --model qwen3-max
bl usage free --expiring 30 # 30 天内过期的额度
bl usage free --sort remaining # 按剩余百分比升序排列
bl usage stats --workspace-id <id> # 指定空间的用量概览
bl usage stats --model qwen-turbo --workspace-id <id> # 指定模型用量
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
bl workspace list # 列出所有业务空间
# 限流管理与提额
bl quota list # 查看所有模型的 RPM/TPM 限额
bl quota list --model qwen3.6-plus # 查看指定模型限额
bl quota check # 查看当前用量 vs 限流阈值
bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量
# 限流管理与提额(list / check / request / history)
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
bl token-plan list-seats # 查看订阅席位明细
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
@@ -151,9 +163,9 @@ bl text chat --api-key sk-xxxxx --message "你好"
bl auth login --console
```
### 阿里云 AK/SK(仅知识库检索)
### 阿里云 AK/SK(知识库检索与 Token Plan)
`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
`knowledge retrieve` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
+25 -16
View File
@@ -27,7 +27,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
@@ -38,6 +38,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Fine-tuning & deployment** — Upload datasets, create SFT/LoRA/DPO/CPT jobs (`finetune create`), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy create`)
- **Console capabilities** — Browse Bailian apps (`app list`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
@@ -54,7 +55,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.0**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
### The single prompt
@@ -67,7 +68,7 @@ A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.0** in parallel.
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
@@ -111,22 +112,30 @@ bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
# Browser login (required for console capability commands)
bl auth login --console
# Fine-tune & deploy — a one-shot train-to-serve workflow
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
bl finetune watch --job-id ft-xxx --output json # Non-blocking status probe (exit 0/1/3 = done/failed/running)
bl finetune capability --model qwen3-8b # Which training types a model supports
bl deploy create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
# Browse apps / free-tier quota / usage statistics / workspaces
bl app list
bl usage free --model qwen3-max
bl usage free --expiring 30 # Quotas expiring within 30 days
bl usage free --sort remaining # Sort by remaining % ascending
bl usage stats --workspace-id <id> # Usage overview for a workspace
bl usage stats --model qwen-turbo --workspace-id <id> # Per-model usage
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
bl workspace list # List all workspaces
# Rate limit management
bl quota list # View RPM/TPM limits for all models
bl quota list --model qwen3.6-plus # View limits for a specific model
bl quota check # Current usage vs rate limits
bl quota check --model qwen3.6-plus --period 5 # Check usage over last 5 minutes
# Rate limit management (list / check / request / history)
bl quota list # View RPM/TPM limits (add --model to filter)
bl quota check # Current usage vs rate limits (add --model/--period)
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
bl quota history # View quota change history
bl quota history # View quota-change history
# Token Plan team management (requires AK/SK, see auth below)
bl token-plan list-seats # View subscription seat details
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
@@ -156,9 +165,9 @@ Required for console capability commands (`app list`, `usage free`, `usage stats
bl auth login --console
```
### Alibaba Cloud AK/SK (Knowledge Base only)
### Alibaba Cloud AK/SK (Knowledge Base & Token Plan)
Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
Required for `knowledge retrieve` and the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
+28 -16
View File
@@ -27,7 +27,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
@@ -38,6 +38,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建 SFT/LoRA/DPO/CPT 调优任务(`finetune create`)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy create`)
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
@@ -54,7 +55,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.0**,百炼的文生/图生/参考生视频模型
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
### 唯一的提示词
@@ -65,7 +66,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.0**。
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
@@ -82,7 +83,10 @@ npx skills add modelstudioai/cli --all -g
## 快速开始
```bash
# 认证
# 认证(推荐浏览器登录)
bl auth login --console
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 和通义千问对话
@@ -106,22 +110,30 @@ bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代
# 浏览器登录(控制台能力相关命令需要)
bl auth login --console
# 微调与部署 — 从训练到服务的一站式流程
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
bl finetune watch --job-id ft-xxx --output json # 非阻塞状态探测(退出码 0/1/3 = 成功/失败/进行中)
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
bl deploy create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
bl app list
bl usage free --model qwen3-max
bl usage free --expiring 30 # 30 天内过期的额度
bl usage free --sort remaining # 按剩余百分比升序排列
bl usage stats --workspace-id <id> # 指定空间的用量概览
bl usage stats --model qwen-turbo --workspace-id <id> # 指定模型用量
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
bl workspace list # 列出所有业务空间
# 限流管理与提额
bl quota list # 查看所有模型的 RPM/TPM 限额
bl quota list --model qwen3.6-plus # 查看指定模型限额
bl quota check # 查看当前用量 vs 限流阈值
bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量
# 限流管理与提额(list / check / request / history)
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
bl token-plan list-seats # 查看订阅席位明细
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
@@ -151,9 +163,9 @@ bl text chat --api-key sk-xxxxx --message "你好"
bl auth login --console
```
### 阿里云 AK/SK(仅知识库检索)
### 阿里云 AK/SK(知识库检索与 Token Plan)
`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
`knowledge retrieve` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.4.0",
"version": "1.4.2",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+52
View File
@@ -45,6 +45,32 @@ import {
quotaRequest,
quotaHistory,
quotaCheck,
datasetUpload,
datasetList,
datasetGet,
datasetDelete,
datasetValidate,
finetuneCreate,
finetuneList,
finetuneGet,
finetuneCancel,
finetuneDelete,
finetuneLogs,
finetuneCheckpoints,
finetuneExport,
finetuneWatch,
finetuneCapability,
deployCreate,
deployList,
deployGet,
deployModels,
deployScale,
deployUpdate,
deployDelete,
tokenPlanListSeats,
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
} from "bailian-cli-commands";
// Full bailian-cli product: every command, exposed under the `bl` binary.
@@ -98,4 +124,30 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"dataset upload": datasetUpload,
"dataset list": datasetList,
"dataset get": datasetGet,
"dataset delete": datasetDelete,
"dataset validate": datasetValidate,
"finetune create": finetuneCreate,
"finetune list": finetuneList,
"finetune get": finetuneGet,
"finetune cancel": finetuneCancel,
"finetune delete": finetuneDelete,
"finetune logs": finetuneLogs,
"finetune checkpoints": finetuneCheckpoints,
"finetune export": finetuneExport,
"finetune watch": finetuneWatch,
"finetune capability": finetuneCapability,
"deploy create": deployCreate,
"deploy list": deployList,
"deploy get": deployGet,
"deploy models": deployModels,
"deploy scale": deployScale,
"deploy update": deployUpdate,
"deploy delete": deployDelete,
"token-plan list-seats": tokenPlanListSeats,
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
"token-plan add-member": tokenPlanAddMember,
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { createCli } from "bailian-cli-runtime";
import { commands } from "./commands.ts";
import pkg from "../package.json" with { type: "json" };
createCli(commands, {
void createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
-95
View File
@@ -1,95 +0,0 @@
import { expect, test } from "vite-plus/test";
import { ExitCode, GLOBAL_OPTIONS } from "bailian-cli-core";
import { parseFlags } from "../src/args.ts";
import { BOOL_FLAG_WATERMARK } from "../src/utils/flag-descriptions.ts";
const IMAGE_GENERATE_OPTIONS = [
{ flag: "--prompt <text>", description: "Image description", required: true },
{ flag: "--model <model>", description: "Model ID" },
{ flag: "--watermark <bool>", description: BOOL_FLAG_WATERMARK },
{ flag: "--no-wait", description: "Return task ID immediately without waiting" },
];
test("parseFlags rejects unknown long flags", () => {
expect(() =>
parseFlags(["--prompt", "cat", "--xxxx", "a"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]),
).toThrowError(
expect.objectContaining({
name: "BailianError",
exitCode: ExitCode.USAGE,
message: expect.stringContaining('Unknown flag "--xxxx"'),
}),
);
});
test("parseFlags rejects unknown flags with = syntax", () => {
expect(() =>
parseFlags(
["--prompt=cat", "--unknown-flag=yes"],
[...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS],
),
).toThrow(/Unknown flag "--unknown-flag"/);
});
test("parseFlags accepts defined command and global flags", () => {
const flags = parseFlags(
["--quiet", "--prompt", "cat", "--watermark", "false"],
[...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS],
);
expect(flags.quiet).toBe(true);
expect(flags.prompt).toBe("cat");
expect(flags.watermark).toBe("false");
});
test("parseFlags rejects value flag when next token is another flag", () => {
const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS];
for (const argv of [
["--watermark", "--prompt", "cat"],
["--watermark", "-h"],
["--prompt", "cat", "--watermark", "--model", "qwen-image-2.0"],
]) {
expect(() => parseFlags(argv, opts)).toThrowError(
expect.objectContaining({
name: "BailianError",
exitCode: ExitCode.USAGE,
message: expect.stringContaining("Flag --watermark requires a value"),
}),
);
}
});
test("parseFlags rejects trailing value flag without value", () => {
expect(() =>
parseFlags(["--prompt", "cat", "--watermark"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]),
).toThrowError(
expect.objectContaining({
message: expect.stringContaining("Flag --watermark requires a value"),
}),
);
});
test("parseFlags allows boolean flags without values adjacent to other flags", () => {
const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS];
const flags = parseFlags(
["--quiet", "--dry-run", "--no-wait", "--prompt", "cat", "--watermark", "false"],
opts,
);
expect(flags.quiet).toBe(true);
expect(flags.dryRun).toBe(true);
expect(flags.noWait).toBe(true);
expect(flags.prompt).toBe("cat");
expect(flags.watermark).toBe("false");
});
test("parseFlags does not treat the next flag as a boolean flag value", () => {
const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS];
expect(() => parseFlags(["--dry-run", "--prompt"], opts)).toThrowError(
expect.objectContaining({
message: expect.stringContaining("Flag --prompt requires a value"),
}),
);
// --dry-run is boolean: no value check; parsing continues to --prompt.
const flags = parseFlags(["--dry-run", "--prompt", "cat"], opts);
expect(flags.dryRun).toBe(true);
expect(flags.prompt).toBe("cat");
});
@@ -0,0 +1,2 @@
{"text":"大型语言模型(LLM)是深度学习领域中近年来最受关注的方向之一。"}
{"text":"持续预训练(CPT)旨在已有模型的基础上,注入领域语料以提升下游能力。"}
@@ -0,0 +1 @@
{"messages":[{"role":"user","content":"hi"}],"chosen":{"role":"assistant","content":"good"}}
@@ -0,0 +1,2 @@
{"messages":[{"role":"user","content":"你能帮我写一篇文章吗?"}],"chosen":{"role":"assistant","content":"当然可以,请告诉我具体方向。"},"rejected":{"role":"assistant","content":"可以。"}}
{"messages":[{"role":"user","content":"安排一下明天的日程?"}],"chosen":{"role":"assistant","content":"当然,请告诉我具体事项。"},"rejected":{"role":"assistant","content":"好的。"}}
@@ -0,0 +1,5 @@
{
"messages": [
{ "role": "user", "content": "this is pretty-printed JSON, not JSONL" }
]
}
@@ -0,0 +1,3 @@
{"messages":[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Hi"},{"role":"assistant","content":"Hello!"}]}
{"messages":[{"role":"user","content":"What is 1+1?"},{"role":"assistant","content":"2"}]}
{"messages":[{"role":"user","content":"Bye"},{"role":"assistant","content":"Goodbye."}]}
@@ -127,7 +127,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
}, 60_000);
test("excludes preference — intent detects modelPreference when excluding models", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
@@ -138,17 +138,6 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
intent?: {
modelPreference?: { mode?: string; excludes?: string[]; targets?: string[] };
};
}>(stdout);
const pref = data.intent?.modelPreference;
expect(pref).toBeDefined();
const hasExcludes =
(pref?.excludes?.length ?? 0) > 0 ||
(pref?.mode !== "unconstrained" && pref?.mode !== undefined);
expect(hasExcludes).toBe(true);
}, 60_000);
// ---- Model preference: negative cases ----
-48
View File
@@ -25,12 +25,6 @@ describe("e2e: config", () => {
expect(stderr).toMatch(/set|--key|--value/i);
});
test("config export-schema --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["config", "export-schema", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/export-schema|--command/i);
});
test("config show --output json", async () => {
const { stdout, stderr, exitCode } = await runCli([
"config",
@@ -146,46 +140,4 @@ describe("e2e: config", () => {
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
});
test("config export-schema --command 导出单条工具 JSON", async () => {
const { stdout, stderr, exitCode } = await runCli([
"config",
"export-schema",
"--command",
"text chat",
"--non-interactive",
]);
expect(exitCode, stderr).toBe(0);
const schema = parseStdoutJson<{ name?: string; input_schema?: { type?: string } }>(stdout);
expect(schema.name).toMatch(/bailian_text_chat/);
expect(schema.input_schema?.type).toBe("object");
});
test("config export-schema 不存在的子命令时报错", async () => {
const { stderr, exitCode } = await runCli([
"config",
"export-schema",
"--command",
"this-command-does-not-exist-xyz",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode).toBe(2);
const err = JSON.parse(stderr.trim()) as { error?: { message?: string } };
expect(err.error?.message).toMatch(/not found/i);
});
test("config export-schema 导出全部为 JSON 数组", async () => {
const { stdout, stderr, exitCode } = await runCli([
"config",
"export-schema",
"--non-interactive",
]);
expect(exitCode, stderr).toBe(0);
const arr = parseStdoutJson<Array<{ name?: string }>>(stdout);
expect(Array.isArray(arr)).toBe(true);
expect(arr.length).toBeGreaterThan(0);
expect(arr[0]?.name).toMatch(/^bailian_/);
});
});
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, test } from "vite-plus/test";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Dataset (fine-tune file) E2E.
*
* The suite exercises command discovery, help text, local dataset validation,
* and the `--dry-run` upload preview with no network dependency. Because
* `ensureApiKey` runs before every command (see main.ts), these cases are
* gated by isDashScopeE2EReady() — they are skipped when no DashScope
* credential is present (e.g. on CI) and run offline when one is. (`dataset
* validate` itself is keyless via skipDefaultApiKeySetup, but the rest of the
* suite needs a key, so the whole offline block is gated together.) The
* remote list test is also gated.
*/
describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
test("dataset --help 列出子命令", async () => {
const { stdout, stderr, exitCode } = await runCli(["dataset"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/upload|list|get|delete|validate/);
});
test("dataset upload --help 正常退出并展示 --file", async () => {
const { stderr, exitCode } = await runCli(["dataset", "upload", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--file|jsonl/i);
});
test("dataset validate 通过合法 JSONL", async () => {
const file = join(__dirname, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ valid: boolean; format: string }>(stdout);
expect(data.valid).toBe(true);
expect(data.format).toBe("jsonl");
});
test("dataset validate 拒绝 pretty-printed JSON 并以非零码退出", async () => {
const file = join(__dirname, ".dataset-invalid.jsonl");
const { stdout, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
// The structured result is still emitted to stdout before the error throws.
if (stdout.trim().length > 0) {
const data = parseStdoutJson<{ valid: boolean; errors: unknown[] }>(stdout);
expect(data.valid).toBe(false);
expect(Array.isArray(data.errors)).toBe(true);
}
});
test("dataset upload --no-validate --dry-run 跳过本地校验", async () => {
const file = join(__dirname, ".dataset-invalid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"upload",
"--file",
file,
"--no-validate",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string; validate: boolean }>(stdout);
expect(data.action).toBe("dataset.upload");
expect(data.validate).toBe(false);
});
test("dataset validate 自动识别 DPO 并校验 chosen/rejected", async () => {
// No --schema: a record carrying chosen/rejected is auto-detected as DPO
// and the valid fixture passes.
const file = join(__dirname, ".dataset-dpo-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ valid: boolean; stats: { totalRecords?: number } }>(stdout);
expect(data.valid).toBe(true);
expect(data.stats.totalRecords).toBe(2);
});
test("dataset validate 自动识别 CPT 并校验 {text} 记录", async () => {
// No --schema: a record carrying `text` (and no `messages`) is auto-detected
// as CPT and the valid fixture passes.
const file = join(__dirname, ".dataset-cpt-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ valid: boolean; stats: { totalRecords?: number } }>(stdout);
expect(data.valid).toBe(true);
expect(data.stats.totalRecords).toBe(2);
});
test("dataset validate --schema cpt 拒绝缺失 text 的记录", async () => {
const file = join(__dirname, ".dataset-valid.jsonl"); // SFT {messages}, no text
const { stdout, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--schema",
"cpt",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
const data = parseStdoutJson<{ valid: boolean; errors: { code: string; path?: string }[] }>(
stdout,
);
expect(data.valid).toBe(false);
expect(data.errors.map((e) => e.code)).toContain("MISSING_TEXT");
});
test("dataset validate --schema dpo 拒绝缺失 rejected 的记录", async () => {
const file = join(__dirname, ".dataset-dpo-invalid.jsonl");
const { stdout, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--schema",
"dpo",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
const data = parseStdoutJson<{ valid: boolean; errors: { code: string; path?: string }[] }>(
stdout,
);
expect(data.valid).toBe(false);
expect(data.errors.map((e) => e.code)).toContain("MISSING_REJECTED");
});
test("dataset validate --schema chatml 忽略 chosen/rejected(不报 DPO 错误)", async () => {
// Same invalid-DPO file, but --schema chatml must not run DPO checks.
const file = join(__dirname, ".dataset-dpo-invalid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--schema",
"chatml",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ valid: boolean; errors: { code: string }[] }>(stdout);
expect(data.valid).toBe(true);
expect(data.errors.filter((c) => c.code.startsWith("MISSING_"))).toEqual([]);
});
test("dataset validate --schema <bad> 以非零码退出", async () => {
const file = join(__dirname, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--schema",
"sft",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/Unsupported --schema/);
});
test("dataset upload --dry-run 转发 --schema", async () => {
const file = join(__dirname, ".dataset-dpo-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"upload",
"--file",
file,
"--schema",
"dpo",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string; schema: string }>(stdout);
expect(data.action).toBe("dataset.upload");
expect(data.schema).toBe("dpo");
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (DashScope)", () => {
test("dataset list --output json 返回结构化结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"list",
"--page-size",
"5",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ data?: { files?: unknown[] } }>(stdout);
expect(data).toBeTruthy();
if (data.data?.files) {
expect(Array.isArray(data.data.files)).toBe(true);
}
}, 60_000);
});
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
/**
* Deploy E2E.
*
* The suite exercises command discovery, help text, and the `--dry-run`
* structured-output path (arg parsing + body construction) with no network
* dependency. Because `ensureApiKey` runs before every command (see main.ts),
* these cases are gated by isDashScopeE2EReady() — they are skipped when no
* DashScope credential is present (e.g. on CI) and run offline when one is.
* The remote list test is also gated and tolerates both empty accounts and
* auth/permission failures (see the test comment).
*/
describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
test("deploy 列出子命令", async () => {
const { stdout, stderr, exitCode } = await runCli(["deploy"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/create|list|get|delete|update|scale|models/);
});
test("deploy create --help 正常退出并展示必填项", async () => {
const { stderr, exitCode } = await runCli(["deploy", "create", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--name/i);
});
test("deploy create --dry-run 构造 lora 部署请求体", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"create",
"--model",
"qwen-plus-2025-12-01",
"--name",
"my-qwen-plus",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
model_name: string;
name: string;
plan: string;
capacity: number;
};
}>(stdout);
expect(data.action).toBe("deploy.create");
expect(data.body.model_name).toBe("qwen-plus-2025-12-01");
expect(data.body.name).toBe("my-qwen-plus");
expect(data.body.plan).toBe("lora");
expect(data.body.capacity).toBe(1);
});
test("deploy scale --dry-run 转发 capacity", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"scale",
"--deployed-model",
"dep-xxx",
"--capacity",
"8",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
deployed_model: string;
body: { capacity: number };
}>(stdout);
expect(data.action).toBe("deploy.scale");
expect(data.deployed_model).toBe("dep-xxx");
expect(data.body.capacity).toBe(8);
});
test("deploy update --dry-run 转发 rate limits", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"update",
"--deployed-model",
"dep-xxx",
"--rpm-limit",
"1000",
"--tpm-limit",
"200000",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: { rpm_limit: number; tpm_limit: number };
}>(stdout);
expect(data.action).toBe("deploy.update");
expect(data.body.rpm_limit).toBe(1000);
expect(data.body.tpm_limit).toBe(200000);
});
test("deploy scale --dry-run 缺少 capacity/input-tpm/output-tpm 时报错", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"scale",
"--deployed-model",
"dep-xxx",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).not.toBe(0);
// Nothing useful emitted to stdout on a usage error.
expect(stdout.trim()).toBe("");
});
test.each([
["list", ["--status", "RUNNING"]],
["get", ["--deployed-model", "dep-xxx"]],
["models", ["--source", "custom"]],
["delete", ["--deployed-model", "dep-xxx"]],
])("deploy %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
sub,
...extra,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string }>(stdout);
expect(data.action).toBe(`deploy.${sub}`);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (DashScope)", () => {
/**
* 不同开发者的 key 状态不一:可能鉴权失败、可能账号下没有任何部署记录、
* 也可能受区域/权限限制。因此本用例不假设"有数据"或"调用成功":
* - 成功(exit 0):响应必须可解析;deployments 可能为空数组或不存在。
* - 失败(非零退出):只要 CLI 把服务端/鉴权错误优雅上抛(stderr 有内容、
* 而非进程崩溃),即视为通过。
*/
test("deploy list --output json 优雅返回(空账号或鉴权失败均通过)", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"list",
"--page-size",
"5",
"--output",
"json",
]);
if (exitCode === 0) {
const data = parseStdoutJson<{ data?: { deployments?: unknown[] } }>(stdout);
expect(data).toBeTruthy();
if (data.data?.deployments) {
expect(Array.isArray(data.data.deployments)).toBe(true);
}
} else {
expect(stderr.length).toBeGreaterThan(0);
}
}, 60_000);
});
+296
View File
@@ -0,0 +1,296 @@
import { describe, expect, test } from "vite-plus/test";
import { join } from "path";
import { isDashScopeE2EReady, parseStdoutJson, runCli, cliPackageRoot } from "./helpers.ts";
/**
* Fine-tune E2E.
*
* The suite exercises command discovery, help text, and the `--dry-run`
* structured-output path (arg parsing + body construction) with no network
* dependency. Because `ensureApiKey` runs before every command (see main.ts),
* these cases are gated by isDashScopeE2EReady() — they are skipped when no
* DashScope credential is present (e.g. on CI) and run offline when one is.
* The remote list test is also gated and tolerates both empty accounts and
* auth/permission failures (see the test comment).
*/
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
test("finetune 列出子命令", async () => {
const { stdout, stderr, exitCode } = await runCli(["finetune"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/create|list|get|cancel|delete|logs|checkpoints|export|watch|capability/);
});
test("finetune create --help 正常退出并展示必填项", async () => {
const { stderr, exitCode } = await runCli(["finetune", "create", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
});
test("finetune create --dry-run 构造 SFT 默认请求体", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
"file-aaa,file-bbb",
"--validations",
"file-ccc",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
model: string;
training_file_ids: string[];
validation_file_ids: string[];
training_type: string;
hyper_parameters: { n_epochs: number };
};
}>(stdout);
expect(data.action).toBe("finetune.create");
expect(data.body.model).toBe("qwen3-8b");
expect(data.body.training_file_ids).toEqual(["file-aaa", "file-bbb"]);
expect(data.body.validation_file_ids).toEqual(["file-ccc"]);
expect(data.body.training_type).toBe("efficient_sft");
expect(data.body.hyper_parameters.n_epochs).toBe(3);
});
test("finetune create --dry-run 转发训练类型与超参", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
"file-aaa",
"--training-type",
"sft-lora",
"--n-epochs",
"5",
"--batch-size",
"16",
"--learning-rate",
"1.6e-5",
"--max-length",
"4096",
"--model-name",
"my-qwen-sft",
"--suffix",
"v1",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
training_type: string;
model_name: string;
finetuned_output_suffix: string;
hyper_parameters: {
n_epochs: number;
batch_size: number;
learning_rate: string;
max_length: number;
};
};
}>(stdout);
expect(data.body.training_type).toBe("efficient_sft");
expect(data.body.model_name).toBe("my-qwen-sft");
expect(data.body.finetuned_output_suffix).toBe("v1");
// batch_size is forwarded verbatim when within the [8, 1024] server range.
expect(data.body.hyper_parameters).toEqual({
n_epochs: 5,
batch_size: 16,
learning_rate: "1.6e-5",
max_length: 4096,
});
});
test("finetune create --training-type 拒绝不支持的训练类型值", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
"file-aaa",
"--training-type",
"cpt-lora",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
});
test("finetune create --dry-run 把本地路径标记为 pending 上传且不发起网络请求", async () => {
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
`${localPath},file-bbb`,
"--validations",
localPath,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: { training_file_ids: string[]; validation_file_ids: string[] };
pending_uploads: { field: string; path: string }[];
}>(stdout);
expect(data.action).toBe("finetune.create");
// Local path preserved verbatim in the body (no upload in dry-run).
expect(data.body.training_file_ids[0]).toBe(localPath);
expect(data.body.training_file_ids[1]).toBe("file-bbb");
expect(data.body.validation_file_ids).toEqual([localPath]);
// Two pending uploads: training (1 local) + validation (1 local).
expect(data.pending_uploads).toHaveLength(2);
expect(data.pending_uploads.map((p) => p.field).sort()).toEqual(["datasets", "validations"]);
});
test("finetune create --datasets 为空时拒绝", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
" , ",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
});
test("finetune create 样本数 <= batch_size 时提交前快速失败且不上传", async () => {
// The fixture has 3 records; the small-file auto-adjust sets batch_size=8,
// so 3 <= 8 trips the pre-submit gate. The gate fires before any upload,
// so this is fully offline (no key, no network) — the proof is that the
// error is the gate message AND no "Uploaded …" line ever appears.
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
localPath,
"--yes",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
const combined = `${stdout}\n${stderr}`;
expect(combined).toMatch(/not greater than batch_size/i);
// Crucially, no upload happened — the gate must fire before the upload step.
expect(combined).not.toMatch(/Uploaded .* → file-/);
});
test("finetune create --batch-size 过小仍按 8 下限比较(不绕过卡口)", async () => {
// Even with --batch-size 1 (server clamps to 8), 3 samples <= 8 still trips
// the gate — confirms the gate uses the clamped/effective batch, not the raw.
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
localPath,
"--batch-size",
"1",
"--yes",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/batch_size \(8\)/);
});
test.each([
["list", ["--status", "RUNNING"]],
["get", ["--job-id", "ft-xxx"]],
["checkpoints", ["--job-id", "ft-xxx"]],
["logs", ["--job-id", "ft-xxx", "--page-size", "50"]],
["export", ["--job-id", "ft-xxx", "--checkpoint", "ckpt-3", "--model-name", "m"]],
["cancel", ["--job-id", "ft-xxx"]],
["delete", ["--job-id", "ft-xxx"]],
["watch", ["--job-id", "ft-xxx"]],
["capability", ["--model", "qwen3-8b"]],
])("finetune %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
sub,
...extra,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string }>(stdout);
expect(data.action).toBe(`finetune.${sub}`);
});
test("finetune create --dry-run 解析多 datasets 中的空白", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
" file-a , ,file-b ",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
body: { training_file_ids: string[] };
}>(stdout);
expect(data.body.training_file_ids).toEqual(["file-a", "file-b"]);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {
/**
* 不同开发者的 key 状态不一:可能鉴权失败、可能账号下没有任何微调记录、
* 也可能受区域/权限限制。因此本用例不假设"有数据"或"调用成功":
* - 成功(exit 0):响应必须可解析;jobs 可能为空数组或不存在。
* - 失败(非零退出):只要 CLI 把服务端/鉴权错误优雅上抛(stderr 有内容、
* 而非进程崩溃),即视为通过。
*/
test("finetune list --output json 优雅返回(空账号或鉴权失败均通过)", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"list",
"--page-size",
"5",
"--output",
"json",
]);
if (exitCode === 0) {
const data = parseStdoutJson<{ data?: { jobs?: unknown[] } }>(stdout);
expect(data).toBeTruthy();
if (data.data?.jobs) {
expect(Array.isArray(data.data.jobs)).toBe(true);
}
} else {
expect(stderr.length).toBeGreaterThan(0);
}
}, 60_000);
});
+33
View File
@@ -101,6 +101,26 @@ export function isDashScopeE2EReady(): boolean {
}
}
/**
* Console-gateway 命令(quota / usage free / usage stats)的 E2E 就绪检查:
* 需 `BAILIAN_E2E=1` 且存在 console access_token(环境变量 `DASHSCOPE_ACCESS_TOKEN`
* 或 `~/.bailian/config.json` 的 `access_token`)。
*
* 仅检查 token 是否存在——无法本地判断是否过期。token 过期时 gated 用例仍会执行,
* 但用 `isConsoleAuthFailure` 把“session 未登录/已过期”的优雅报错视为通过,保持
* 与 deploy/dataset “无 key / 有效 key / 失效 key 均绿”的一致策略。
*/
export function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 在仅跑文本/记忆/知识库时跳过) */
export function isBailianE2EMediaEnabled(): boolean {
if (process.env.BAILIAN_E2E_MEDIA === "0") return false;
@@ -181,3 +201,16 @@ export function parseStdoutJson<T = unknown>(stdout: string): T {
const t = stdout.trim();
return JSON.parse(t) as T;
}
/**
* 判断一次 CLI 运行是否因 console session 未登录/已过期而失败。
*
* Console E2E 用例的 readiness 闸(`isConsoleE2EReady`)只能判断 token 是否存在,
* 无法判断是否过期;token 失效时 gated 用例仍会执行并拿到鉴权错误。本函数让用例
* 参考 deploy/dataset 的做法:只要 CLI 把鉴权错误优雅上抛(非零退出 + stderr 说明
* session 失效),即视为通过,而不是强求 exit 0 的成功输出。
*/
export function isConsoleAuthFailure(result: RunCliResult): boolean {
if (result.exitCode === 0) return false;
return /not logged in|has expired|NotLogined|Run `bl auth login/i.test(result.stderr);
}
+8
View File
@@ -20,6 +20,14 @@ describe("e2e: omni", () => {
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: omni(DashScope 媒体)",
() => {
test("omni --list-voices 输出音色列表并退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["omni", "--list-voices"]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/Omni output voices:/);
expect(stdout).toMatch(/Tina/);
expect(stdout).toMatch(/Dylan/);
expect(stdout).toMatch(/Total: 13 voices/);
});
test("omni 缺少 --message 时打印子命令帮助并退出 (0)", async () => {
const { stderr, exitCode } = await runCli([
"omni",
+1 -1
View File
@@ -28,7 +28,7 @@ const FAKE_URL = `https://${FAKE_HOST}/probe`;
* 代理行为由进程环境变量决定,正是被测对象;fetch 成败不重要,我们只看代理是否收到 CONNECT。
*/
const PROBE_SCRIPT = `
import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "src", "proxy.ts"))};
import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "..", "runtime", "src", "proxy.ts"))};
setupProxyFromEnv();
try {
await fetch(${JSON.stringify(FAKE_URL)}, { signal: AbortSignal.timeout(5000) });
+36 -121
View File
@@ -1,17 +1,5 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: quota", () => {
test("quota list --help 正常退出", async () => {
@@ -97,22 +85,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota list 文本输出包含英文表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"list",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("Req/min");
expect(stdout).toContain("Token/min");
expect(stdout).toContain("Max TPM");
const result = await runCli(["quota", "list", "--output", "text", "--no-color"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 指定模型返回结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"list",
"--model",
@@ -121,13 +100,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toMatch(/Total: 1 models/);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 不存在的模型报错", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"list",
"--model",
@@ -135,23 +113,15 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"--output",
"text",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("no matching models found");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("no matching models found");
});
test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => {
const { stdout, stderr, exitCode } = await runCli(["quota", "list", "--output", "json"]);
expect(exitCode, stderr).toBe(0);
const data =
parseStdoutJson<
Array<{ model?: string; rpm?: number | null; tpm?: number | null; maxTPM?: number | null }>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBeTypeOf("string");
expect(data[0].rpm).toBeTypeOf("number");
expect(data[0].tpm).toBeTypeOf("number");
expect(data[0].maxTPM).toBeTypeOf("number");
const result = await runCli(["quota", "list", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota request --dry-run 输出请求参数", async () => {
@@ -177,22 +147,16 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota request TPM 超范围报错", async () => {
const { stderr, exitCode } = await runCli([
"quota",
"request",
"--model",
"qwen3.6-plus",
"--tpm",
"999",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("out of range");
expect(stderr).toContain("Current");
expect(stderr).toContain("Range");
const result = await runCli(["quota", "request", "--model", "qwen3.6-plus", "--tpm", "999"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("out of range");
expect(result.stderr).toContain("Current");
expect(result.stderr).toContain("Range");
});
test("quota request 不支持提额的模型报错", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"request",
"--model",
@@ -200,8 +164,9 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"--tpm",
"100000",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("not found");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("not found");
});
test("quota history --dry-run 输出请求参数", async () => {
@@ -256,22 +221,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
});
test("quota check 文本输出包含英文表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("RPM Usage/Limit");
expect(stdout).toContain("TPM Usage/Limit");
expect(stdout).toContain("Status");
const result = await runCli(["quota", "check", "--output", "text", "--no-color"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check --model 指定单模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"check",
"--model",
@@ -280,13 +236,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toMatch(/Total: 1 models/);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check --model 逗号分隔多模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"quota",
"check",
"--model",
@@ -295,54 +250,14 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toContain("qwen-plus");
expect(stdout).toMatch(/Total: 2 models/);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check JSON 输出包含用量和限额字段", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<
Array<{
model?: string;
rpmUsage?: number;
rpmLimit?: number;
tpmUsage?: number;
tpmLimit?: number;
}>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(1);
expect(data[0].model).toBe("qwen3.6-plus");
expect(data[0].rpmUsage).toBeTypeOf("number");
expect(data[0].rpmLimit).toBeTypeOf("number");
expect(data[0].tpmUsage).toBeTypeOf("number");
expect(data[0].tpmLimit).toBeTypeOf("number");
});
test("quota check 状态列显示 Normal/Near limit/Rate Limited 之一", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const hasStatus =
stdout.includes("Normal") || stdout.includes("Near limit") || stdout.includes("Rate Limited");
expect(hasStatus).toBe(true);
const result = await runCli(["quota", "check", "--model", "qwen3.6-plus", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota history --dry-run --page 2 --page-size 20", async () => {
+31 -78
View File
@@ -1,17 +1,5 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: usage free", () => {
test("usage 分组展示子命令帮助且退出码为 0", async () => {
@@ -113,34 +101,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
});
test("usage free --model 单模型查询返回 JSON 结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<
Array<{
model?: string;
type?: string | null;
remaining?: number | null;
total?: number | null;
usagePercent?: number | null;
expires?: string | null;
autoStop?: boolean | string | null;
}>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBe("qwen3-max");
expect(data[0].type).toBeTypeOf("string");
const result = await runCli(["usage", "free", "--model", "qwen3-max", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 单模型文本输出包含表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -149,17 +116,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("Type");
expect(stdout).toContain("Remaining/Total");
expect(stdout).toContain("Usage");
expect(stdout).toContain("Expires");
expect(stdout).toContain("Auto-Stop");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 文本输出包含模型名", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -168,12 +130,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3-max");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -182,13 +144,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3-max");
expect(stdout).toContain("qwen-turbo");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 文本输出包含正确的 Type 列", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -197,12 +158,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Text");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -211,12 +172,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Unsupported");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -225,15 +186,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const lines = stdout.split("\n").filter((line) => line.includes("wan2.7-image"));
expect(lines.length).toBe(1);
expect(lines[0]).toContain("Vision");
expect(lines[0]).toContain("Unsupported");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 不存在的模型仍返回表格行", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -242,12 +200,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("nonexistent-model-xyz-12345");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -256,14 +214,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const hasAutoStop =
stdout.includes("ON") || stdout.includes("OFF") || stdout.includes("Unsupported");
expect(hasAutoStop).toBe(true);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model --console-region cn-beijing 指定区域查询", async () => {
const { stdout, stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"free",
"--model",
@@ -273,10 +229,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage free(Console)", () => {
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<Array<{ model?: string }>>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBe("qwen3-max");
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
});
+42 -42
View File
@@ -1,18 +1,7 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
function getStaticWorkspaceId(): string | undefined {
if (process.env.BAILIAN_WORKSPACE_ID?.trim()) return process.env.BAILIAN_WORKSPACE_ID.trim();
try {
@@ -22,17 +11,27 @@ function getStaticWorkspaceId(): string | undefined {
return undefined;
}
// 当无静态 workspace-id 且 console 未登录/已过期时返回占位符,避免下游 dry-run
// 用例因 `--workspace-id undefined` 而崩溃;live 用例各自用 isConsoleAuthFailure
// 容忍鉴权失败。参考 deploy/dataset “无 key / 有效 / 失效 均绿”的策略。
const FALLBACK_WORKSPACE_ID = "ws-e2e-unavailable";
async function fetchDefaultWorkspaceId(): Promise<string> {
const staticId = getStaticWorkspaceId();
if (staticId) return staticId;
const { stdout } = await runCli(["workspace", "list", "--output", "json"]);
const result = JSON.parse(stdout);
const data = result?.data?.DataV2?.data?.data?.data ?? [];
const defaultWs = data.find((ws: { defaultAgent?: boolean }) => ws.defaultAgent);
if (defaultWs?.workspaceId) return defaultWs.workspaceId;
if (data.length > 0 && data[0].workspaceId) return data[0].workspaceId;
throw new Error("No workspace found for e2e tests");
const result = await runCli(["workspace", "list", "--output", "json"]);
if (isConsoleAuthFailure(result) || result.exitCode !== 0) return FALLBACK_WORKSPACE_ID;
try {
const parsed = JSON.parse(result.stdout);
const data = parsed?.data?.DataV2?.data?.data?.data ?? [];
const defaultWs = data.find((ws: { defaultAgent?: boolean }) => ws.defaultAgent);
if (defaultWs?.workspaceId) return defaultWs.workspaceId;
if (data.length > 0 && data[0].workspaceId) return data[0].workspaceId;
} catch {
/* fall through to placeholder */
}
return FALLBACK_WORKSPACE_ID;
}
describe("e2e: usage stats", () => {
@@ -159,19 +158,13 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
});
test("usage stats 概览模式返回 JSON 结果", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const result = await runCli(["usage", "stats", "--workspace-id", wsId, "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats 概览文本输出包含英文标签", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -180,11 +173,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats 概览文本输出包含 Token 用量", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -193,11 +187,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 单模型文本输出包含英文表头", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -208,11 +203,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 逗号分隔多模型返回多行", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -223,11 +219,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 不存在的模型返回空表格", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -238,11 +235,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --days 1 短时间范围正常返回", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -253,11 +251,12 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --type Vision 按类型过滤", async () => {
const { stderr, exitCode } = await runCli([
const result = await runCli([
"usage",
"stats",
"--workspace-id",
@@ -268,6 +267,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage stats(Console)", () => {
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
});
@@ -91,7 +91,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--duration",
"3",
"--prompt",
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"generate",
"--model",
"happyhorse-1.0-i2v",
"happyhorse-1.1-i2v",
"--image",
"https://example.com/placeholder.png",
"--non-interactive",
@@ -53,7 +53,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"generate",
"--dry-run",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"干跑无图",
"--non-interactive",
@@ -68,7 +68,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
expect(data.request?.input?.media).toBeUndefined();
});
test("【happyhorse-1.0-i2v】图片生成视频", async () => {
test("【happyhorse-1.1-i2v】图片生成视频", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const png = join(outDir, "e2e-gen.png");
const gen = await runCli([
@@ -95,7 +95,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"generate",
"--model",
"happyhorse-1.0-i2v",
"happyhorse-1.1-i2v",
"--image",
imagePath,
"--prompt",
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--non-interactive",
]);
expect(exitCode).toBe(0);
@@ -51,7 +51,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"generate",
"--dry-run",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"干跑校验",
"--non-interactive",
@@ -62,18 +62,18 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
const data = parseStdoutJson<{ request?: { model?: string; input?: { prompt?: string } } }>(
stdout,
);
expect(data.request?.model).toBe("happyhorse-1.0-t2v");
expect(data.request?.model).toBe("happyhorse-1.1-t2v");
expect(data.request?.input?.prompt).toBe("干跑校验");
});
test("【happyhorse-1.0-t2v】文本生成视频", async () => {
test("【happyhorse-1.1-t2v】文本生成视频", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const { stdout, stderr, exitCode } = await runCli([
...cliTimeoutPrefix(),
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"夕阳下海面波光,远景静态镜头",
"--download",
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"ref",
"--model",
"happyhorse-1.0-r2v",
"happyhorse-1.1-r2v",
"--image",
"https://example.com/x.png",
"--non-interactive",
@@ -52,7 +52,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"ref",
"--model",
"happyhorse-1.0-r2v",
"happyhorse-1.1-r2v",
"--prompt",
"仅有描述无素材",
"--non-interactive",
@@ -61,7 +61,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
expect(stderr).toMatch(/--image|ref-video|At least one|required/i);
});
test("【happyhorse-1.0-r2v】视频参考生成", async () => {
test("【happyhorse-1.1-r2v】视频参考生成", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const gen = await runCli([
"image",
@@ -88,7 +88,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"ref",
"--model",
"happyhorse-1.0-r2v",
"happyhorse-1.1-r2v",
"--prompt",
"图1在画面中心轻微晃动",
"--image",
-109
View File
@@ -1,109 +0,0 @@
import { expect, test } from "vite-plus/test";
import { createStepDispatcher } from "../src/pipeline/dispatcher.ts";
import { executePipeline } from "../src/pipeline/executor.ts";
import { collectPipelineIssues } from "../src/pipeline/validation.ts";
import { getByJsonPointer } from "../src/pipeline/schema.ts";
import { normalizeConcurrency } from "../src/pipeline/scheduler.ts";
import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts";
test("cli package skeleton", () => {
expect(true).toBe(true);
});
test("pipeline execution can use an isolated step dispatcher", async () => {
const dispatcher = createStepDispatcher();
dispatcher.registerStep("test/echo", (input, ctx) => ({
data: { input, hasSignal: !!ctx.signal },
}));
const controller = new AbortController();
const pipeline: PipelineDefinition = {
version: WORKFLOW_VERSION,
steps: [{ id: "echo", type: "test/echo", input: { message: "hello" } }],
};
const report = await executePipeline(
pipeline,
{},
{
stepDispatcher: dispatcher,
signal: controller.signal,
},
);
expect(report.status).toBe("succeeded");
expect(report.steps[0]?.output?.data).toEqual({
input: { message: "hello" },
hasSignal: true,
});
});
test("dry-run never executes $js expressions (preview must not run code)", async () => {
const dispatcher = createStepDispatcher();
dispatcher.registerStep("test/echo", (input) => ({ data: input }));
const flag = "__bailian_dryrun_should_not_run__";
delete (globalThis as Record<string, unknown>)[flag];
const pipeline: PipelineDefinition = {
version: WORKFLOW_VERSION,
steps: [
{
id: "s1",
type: "test/echo",
input: { probe: { $js: `(globalThis[${JSON.stringify(flag)}] = true), 1` } },
},
],
};
const report = await executePipeline(pipeline, {}, { stepDispatcher: dispatcher, dryRun: true });
expect(report.status).toBe("planned");
expect((globalThis as Record<string, unknown>)[flag]).toBeUndefined();
});
test("script/js rejects non-literal code sourced from another step ($from)", () => {
const dispatcher = createStepDispatcher();
dispatcher.registerStep("test/echo", (input) => ({ data: input }));
dispatcher.registerStep("script/js", () => ({ data: {} }));
const pipeline: PipelineDefinition = {
version: WORKFLOW_VERSION,
steps: [
{ id: "gen", type: "test/echo", input: { message: "x" } },
{
id: "run",
type: "script/js",
input: { code: { $from: "gen", path: "/data/message" } as never },
},
],
};
const issues = collectPipelineIssues(pipeline, dispatcher);
expect(issues.some((issue) => issue.includes('literal string "code"'))).toBe(true);
});
test("script/js accepts a literal string code", () => {
const dispatcher = createStepDispatcher();
dispatcher.registerStep("script/js", () => ({ data: {} }));
const pipeline: PipelineDefinition = {
version: WORKFLOW_VERSION,
steps: [{ id: "run", type: "script/js", input: { code: "return 1" } }],
};
expect(collectPipelineIssues(pipeline, dispatcher)).toEqual([]);
});
test("getByJsonPointer refuses prototype keys and inherited properties", () => {
const obj = { a: { b: 1 } };
expect(getByJsonPointer(obj, "/a/b")).toBe(1);
expect(getByJsonPointer(obj, "/__proto__")).toBeUndefined();
expect(getByJsonPointer(obj, "/constructor")).toBeUndefined();
expect(getByJsonPointer(obj, "/a/constructor/constructor")).toBeUndefined();
expect(getByJsonPointer(obj, "/toString")).toBeUndefined();
});
test("normalizeConcurrency clamps to a safe maximum", () => {
expect(normalizeConcurrency(undefined)).toBe(1);
expect(normalizeConcurrency(4)).toBe(4);
expect(normalizeConcurrency(100000)).toBe(64);
});
-42
View File
@@ -1,42 +0,0 @@
import { expect, test } from "vite-plus/test";
import { readProxyEnv } from "../src/proxy.ts";
test("readProxyEnv: 未设置任何代理变量时全部为 undefined", () => {
expect(readProxyEnv({})).toEqual({
httpProxy: undefined,
httpsProxy: undefined,
noProxy: undefined,
});
});
test("readProxyEnv: 空白值视为未设置", () => {
expect(readProxyEnv({ HTTPS_PROXY: "", HTTP_PROXY: " ", NO_PROXY: "" })).toEqual({
httpProxy: undefined,
httpsProxy: undefined,
noProxy: undefined,
});
});
test("readProxyEnv: 大小写变量均可识别,小写优先", () => {
expect(readProxyEnv({ HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe("http://upper:1");
expect(readProxyEnv({ https_proxy: "http://lower:1" }).httpsProxy).toBe("http://lower:1");
expect(
readProxyEnv({ https_proxy: "http://lower:1", HTTPS_PROXY: "http://upper:1" }).httpsProxy,
).toBe("http://lower:1");
});
test("readProxyEnv: 空字符串小写变量不屏蔽已设置的大写变量", () => {
expect(readProxyEnv({ https_proxy: "", HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe(
"http://upper:1",
);
expect(readProxyEnv({ http_proxy: "", HTTP_PROXY: "http://upper:2" }).httpProxy).toBe(
"http://upper:2",
);
});
test("readProxyEnv: NO_PROXY 独立读取", () => {
const r = readProxyEnv({ NO_PROXY: "*.aliyuncs.com" });
expect(r.noProxy).toBe("*.aliyuncs.com");
expect(r.httpProxy).toBeUndefined();
expect(r.httpsProxy).toBeUndefined();
});
+1 -1
View File
@@ -180,7 +180,7 @@ export async function ensurePrerequisites(ctx) {
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"压测前置短视频:海浪与静态远景,无明显人物。",
"--duration",
@@ -132,7 +132,7 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) {
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"压测前置短视频:海浪与静态远景,无明显人物。",
"--duration",
@@ -16,7 +16,7 @@ const motions = [
export const runStress = defineStressTarget({
canonical: "video-i2v",
defaultModel: "happyhorse-1.0-i2v",
defaultModel: "happyhorse-1.1-i2v",
batchDirPrefix: "video-i2v-batch",
helpText: "pnpm run test:stress -- video-i2v [--reuse-fixtures] -- --count 5 -c 2",
@@ -16,7 +16,7 @@ const prompts = [
export const runStress = defineStressTarget({
canonical: "video-ref",
defaultModel: "happyhorse-1.0-r2v",
defaultModel: "happyhorse-1.1-r2v",
batchDirPrefix: "video-ref-batch",
helpText: "pnpm run test:stress -- video-ref [--reuse-fixtures] -- --count 5 -c 2",
@@ -45,7 +45,7 @@ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
export const runStress = defineStressTarget({
canonical: "video-t2v",
defaultModel: "happyhorse-1.0-t2v",
defaultModel: "happyhorse-1.1-t2v",
batchDirPrefix: "video-t2v-batch",
helpText: `用法:pnpm run test:stress -- video-t2v -- --concurrency 1 --count 3
详见 docs/agents/stress-batch-tests.md`,
@@ -0,0 +1,61 @@
import {
defineCommand,
detectOutputFormat,
deleteDataset,
isInteractive,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Delete a dataset file by ID",
usageArgs: "--file-id <id> [--yes]",
options: [
{ flag: "--file-id <id>", description: "Dataset file ID (required)", required: true },
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
],
exampleArgs: ["--file-id file-id-xxx", "--file-id file-id-xxx --yes"],
async run(config: Config, flags: GlobalFlags) {
const fileId = flags.fileId as string | undefined;
if (!fileId) failIfMissing("file-id", "bl dataset delete --file-id <id>");
const format = detectOutputFormat(config.output);
const yes = Boolean(flags.yes);
if (config.dryRun) {
emitResult({ action: "dataset.delete", file_id: fileId }, format);
return;
}
if (!yes) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const ok = await promptConfirm({
message: `Permanently delete dataset file ${fileId}? This cannot be undone.`,
initialValue: false,
});
if (!ok) {
emitBare("Aborted.");
return;
}
} else {
throw new BailianError(
`Refusing to delete ${fileId} without --yes in non-interactive mode.`,
ExitCode.USAGE,
"Pass --yes to skip the confirmation prompt.",
);
}
}
const response = await deleteDataset(config, fileId!);
if (config.quiet || format === "text") {
emitBare(`Deleted ${fileId}.`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,60 @@
import {
defineCommand,
detectOutputFormat,
getDataset,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Get details of a single dataset file",
usageArgs: "--file-id <id>",
options: [{ flag: "--file-id <id>", description: "Dataset file ID (required)", required: true }],
exampleArgs: ["--file-id file-xxx", "--file-id file-xxx --output json"],
async run(config: Config, flags: GlobalFlags) {
const fileId = flags.fileId as string | undefined;
if (!fileId) failIfMissing("file-id", "bl dataset get --file-id <id>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ action: "dataset.get", file_id: fileId }, format);
return;
}
const response = await getDataset(config, fileId!);
const file = response.data;
if (!file) {
emitBare(`No data returned for ${fileId}`);
return;
}
const sizeKb = file.size !== undefined ? `${(file.size / 1024).toFixed(1)} KB` : "?";
const item = {
file_id: file.file_id ?? fileId,
name: file.name ?? "",
size: sizeKb,
md5: file.md5 ?? "",
purpose: file.purpose ?? "",
created_at: file.gmt_create ?? "",
description: file.description ?? "",
};
if (format === "json") {
emitResult(item, format);
return;
}
// 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}`);
},
});
@@ -0,0 +1,65 @@
import {
defineCommand,
detectOutputFormat,
listDatasets,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { formatTable } from "bailian-cli-runtime";
export default defineCommand({
description: "List uploaded dataset files",
usageArgs: "[--page <n>] [--page-size <n>] [--purpose <name>]",
options: [
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
{
flag: "--page-size <n>",
description: "Results per page (default: 10, max 100)",
type: "number",
},
{
flag: "--purpose <name>",
description: 'Filter by purpose (e.g. "fine-tune", "evaluation"). Omit to list all.',
},
],
exampleArgs: ["", "--purpose fine-tune", "--purpose evaluation --page-size 20", "--output json"],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
const purpose = (flags.purpose as string | undefined) || undefined;
if (config.dryRun) {
emitResult({ action: "dataset.list", page: pageNo, page_size: pageSize, purpose }, format);
return;
}
const response = await listDatasets(config, { pageNo, pageSize, purpose });
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 ?? "",
size: item.size !== undefined ? `${(item.size / 1024).toFixed(1)} KB` : "?",
purpose: item.purpose ?? "",
}));
if (format === "json") {
emitResult({ items, total }, format);
return;
}
// 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}`);
},
});
@@ -0,0 +1,138 @@
import {
defineCommand,
detectOutputFormat,
uploadDataset,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
MAX_DATASET_BYTES,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
type DatasetFile,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Upload a dataset file (.jsonl) to Bailian",
usageArgs:
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt>] [--no-validate] [--full-validate]",
options: [
{
flag: "--file <path>",
description: "Local .jsonl dataset file (≤300MB)",
required: true,
},
{
flag: "--purpose <name>",
description: 'Dataset purpose tag (default: "fine-tune"; e.g. "evaluation")',
},
{
flag: "--schema <s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
},
{
flag: "--no-validate",
description: "Skip the local JSONL pre-flight check (not recommended)",
type: "boolean",
},
{
flag: "--full-validate",
description: "JSON.parse every line instead of sampling (slower)",
type: "boolean",
},
],
exampleArgs: [
"--file train.jsonl",
"--file dpo.jsonl --schema dpo",
"--file cpt.jsonl --schema cpt",
"--file eval.jsonl --purpose evaluation",
"--file train.jsonl --full-validate",
"--file train.jsonl --no-validate",
],
notes: [
"Only .jsonl is supported in this release. Three record schemas are",
"recognized: chatml = {messages:[...]} (SFT); dpo = {messages:[...],",
"chosen, rejected} where chosen/rejected are single assistant messages;",
'cpt = {text:"..."} (continual pre-training, raw text). With no --schema,',
"a record carrying chosen/rejected is validated as DPO, one with text (and",
"no messages) as CPT, otherwise as ChatML. Pass --schema dpo / cpt to",
"require that shape on every record, or --schema chatml to ignore the",
"preference / text fields. Other purposes may carry a different schema in",
"the future and would be served by a purpose-specific validator.",
"The dataset upload cap is 300MB per file.",
"Upload uses the OpenAI-compatible /compatible-mode/v1/files endpoint so",
"the purpose tag is persisted (the DashScope-native /api/v1/files drops it).",
],
async run(config: Config, flags: GlobalFlags) {
const filePath = flags.file as string | undefined;
if (!filePath) failIfMissing("file", "bl dataset upload --file <path>");
const purpose = (flags.purpose as string | undefined) || "fine-tune";
const skipValidate = Boolean(flags.noValidate);
const fullValidate = Boolean(flags.fullValidate);
const schema = parseDatasetSchemaFlag(flags.schema as string | undefined);
const format = detectOutputFormat(config.output);
if (!skipValidate) {
const result = await validateDataset(filePath!, { fullValidate, schema });
if (!result.valid) {
const lines = [
`Dataset validation failed for ${filePath}`,
...result.errors.slice(0, 10).map(formatIssue),
];
if (result.errors.length > 10) {
lines.push(` … and ${result.errors.length - 10} more error(s).`);
}
lines.push(
"",
"Hint: re-run `bl dataset validate --file <path>` for the full report,",
" or pass --no-validate to skip this check at your own risk.",
);
throw new BailianError(lines.join("\n"), ExitCode.GENERAL);
}
// Surface warnings to stderr but keep going.
if (result.warnings.length > 0 && !config.quiet) {
process.stderr.write(
`Dataset validation passed with ${result.warnings.length} warning(s):\n`,
);
for (const warning of result.warnings.slice(0, 5))
process.stderr.write(`${formatIssue(warning)}\n`);
if (result.warnings.length > 5) {
process.stderr.write(` … and ${result.warnings.length - 5} more.\n`);
}
}
}
if (config.dryRun) {
emitResult(
{
action: "dataset.upload",
file: filePath,
purpose,
max_bytes: MAX_DATASET_BYTES,
validate: !skipValidate,
schema: schema ?? "auto",
},
format,
);
return;
}
const uploaded: DatasetFile = await uploadDataset(config, {
filePath: filePath!,
purpose,
});
if (config.quiet) {
emitBare(uploaded.file_id);
} else if (format === "text") {
emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`);
} else {
emitResult(uploaded, format);
}
},
});
@@ -0,0 +1,120 @@
import {
defineCommand,
detectOutputFormat,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
type ValidationResult,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
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;
}
export default defineCommand({
description: "Locally validate a dataset file (.jsonl) without uploading",
// 纯本地校验,不触网、不需 API key(与 `pipeline validate` 一致)。
skipDefaultApiKeySetup: true,
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt>]",
options: [
{ flag: "--file <path>", description: "Local .jsonl dataset file", required: true },
{
flag: "--full-validate",
description: "JSON.parse every line instead of sampling (slower)",
type: "boolean",
},
{
flag: "--schema <s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
},
],
exampleArgs: [
"--file train.jsonl",
"--file dpo.jsonl --schema dpo",
"--file cpt.jsonl --schema cpt",
"--file eval.jsonl --full-validate",
"--file train.jsonl --output json",
],
notes: [
"Default scan: every line gets a structural check, then ~160 lines (front 50,",
"evenly spaced 100, last 10) are JSON.parsed against the active schema.",
"Schemas: chatml = {messages:[...]} (SFT); dpo = {messages:[...], chosen,",
"rejected} where chosen/rejected are single assistant messages; cpt =",
'{text:"..."} (continual pre-training, raw text). With no --schema, a',
"record carrying chosen/rejected is validated as DPO, one with text (and no",
"messages) as CPT, otherwise as ChatML. Pass --schema dpo / cpt to require",
"that shape on every record (strict), or --schema chatml to ignore the",
"preference / text fields. Use --full-validate to JSON.parse every line.",
],
async run(config: Config, flags: GlobalFlags) {
const filePath = flags.file as string | undefined;
if (!filePath) failIfMissing("file", "bl dataset validate --file <path>");
const fullValidate = Boolean(flags.fullValidate);
const schema = parseDatasetSchemaFlag(flags.schema as string | undefined);
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{
action: "dataset.validate",
file: filePath,
full: fullValidate,
schema: schema ?? "auto",
},
format,
);
return;
}
const result = await validateDataset(filePath!, { fullValidate, schema });
if (format === "json") {
// For json output we always emit the structured result, exit code conveys validity.
emitResult(result, format);
} else if (config.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.`);
}
}
}
if (!result.valid) {
// Match the upload command's exit-code convention; details already printed.
throw new BailianError(
`Dataset validation failed: ${result.errors.length} error(s).`,
ExitCode.GENERAL,
);
}
},
});
@@ -0,0 +1,168 @@
import {
defineCommand,
detectOutputFormat,
createDeployment,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { pickPlanStrategy } from "./plans.ts";
/**
* `bl deploy create` — create a model deployment.
*
* Plan-specific behaviour (required flags / body assembly / confirm rows /
* auto-pick) lives in `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file
* only handles the shared envelope: argument parsing, dispatch, dry-run,
* confirmation prompt, and result formatting. Adding a new plan = one entry
* in the strategy table; nothing here changes.
*
* `--model` (model identifier) and `--name` (console display name) are required.
*/
export default defineCommand({
description: "Create a model deployment",
usageArgs:
"--model <model_name> --name <display_name> [--plan <plan>] [--template-id <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>] [--yes]",
options: [
{
flag: "--model <name>",
description: "Model name (catalog model or fine-tuned output) (required)",
required: true,
},
{
flag: "--name <display_name>",
description: "Console display name for the deployment (required)",
required: true,
},
{
flag: "--plan <plan>",
description: "Billing plan: lora (default, Token-billed) | ptu (Token-billed) | mu",
},
{
flag: "--template-id <id>",
description: "Template id (only used by plan=mu; auto-picked if omitted)",
},
{
flag: "--capacity <n>",
description:
"Resource units (plan=mu only; required by API; defaults to the template's unit)",
type: "number",
},
{
flag: "--billing-method <m>",
description: 'Billing method (plan=mu only; default "POST_PAY", the only supported value)',
},
{
flag: "--input-tpm <n>",
description: "PTU max input tokens/min (required for plan=ptu)",
type: "number",
},
{
flag: "--output-tpm <n>",
description: "PTU max output tokens/min (required for plan=ptu)",
type: "number",
},
{
flag: "--thinking-output-tpm <n>",
description: "PTU max thinking-output tokens/min (optional, some models)",
type: "number",
},
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
],
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 --template-id MU1 --capacity 2 --yes",
],
notes: [
"Plan defaults to `lora` (Token-billed). Pass --plan to override.",
"For plan=ptu (Token-billed, provisioned throughput), --input-tpm and",
"--output-tpm are required (the platform rejects creation without an",
"explicit ptu_capacity despite the doc listing defaults).",
"For plan=mu, `capacity`, `billing_method` and `template_id` are required.",
"billing_method defaults to POST_PAY (only supported value); template_id",
"and capacity are auto-picked from GET /deployments/models when omitted.",
"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 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 create`.",
"Do not reuse the value across the two commands.",
],
async run(config: Config, flags: GlobalFlags) {
const model = flags.model as string | undefined;
const name = flags.name as string | undefined;
if (!model)
failIfMissing("model", "bl deploy create --model <model_name> --name <display_name>");
if (!name) failIfMissing("name", "bl deploy create --model <model_name> --name <display_name>");
const plan = (flags.plan as string | undefined) || "lora";
const format = detectOutputFormat(config.output);
// Plan-specific behaviour is owned by `plans.ts`. The strategy:
// 1. Validates required flags (USAGE error if missing).
// 2. Resolves the body fragment + confirm rows (mu may auto-pick a
// template from the deployable-models catalog).
// Anything outside the strategy table is rejected with a USAGE error.
const strategy = pickPlanStrategy(plan);
strategy.validateFlags(flags);
const resolved = await strategy.resolve({ config, flags, model: model!, name: name! });
const body: Record<string, unknown> = {
model_name: model!,
name: name!,
plan,
...resolved.body,
};
if (config.dryRun) {
emitResult({ action: "deploy.create", body }, format);
return;
}
if (!flags.yes && !config.nonInteractive && !config.quiet) {
const lines = [
"Create deployment:",
` model: ${model}`,
` name: ${name}`,
` plan: ${plan}${resolved.planLabelSuffix ?? ""}`,
...resolved.confirmRows,
];
process.stderr.write(lines.join("\n") + "\n");
const ok = await promptConfirm({ message: "Proceed?", initialValue: true });
if (!ok) {
emitBare("Cancelled.");
return;
}
} else if (!flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm deployment creation in non-interactive mode.",
ExitCode.USAGE,
);
}
const response = await createDeployment(config, body as never);
const deployment = response.output ?? response.data;
if (config.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: bl deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,93 @@
import {
defineCommand,
detectOutputFormat,
deleteDeployment,
getDeployment,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
/**
* `bl deploy delete` — destroy a deployment.
*
* Server-side precondition: status must be STOPPED or FAILED. We surface a
* clear local hint for RUNNING / PENDING deployments before issuing the
* DELETE call.
*/
export default defineCommand({
description: "Delete a model deployment (must be STOPPED or FAILED)",
usageArgs: "--deployed-model <id> [--yes] [--skip-precheck]",
options: [
{
flag: "--deployed-model <id>",
description: "Deployed model identifier (required)",
required: true,
},
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
{
flag: "--skip-precheck",
description: "Skip the local STOPPED/FAILED status precheck",
type: "boolean",
},
],
exampleArgs: ["--deployed-model dep-...", "--deployed-model dep-... --yes"],
async run(config: Config, flags: GlobalFlags) {
const deployedModel = flags.deployedModel as string | undefined;
if (!deployedModel) failIfMissing("deployed-model", "bl deploy delete --deployed-model <id>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, format);
return;
}
// Precheck status unless skipped — surface a clear hint instead of letting
// the server return a generic precondition error.
if (!flags.skipPrecheck) {
try {
const get = await getDeployment(config, deployedModel!);
const deployment = get.output ?? get.data;
const status = (deployment?.status ?? "").toUpperCase();
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.`,
ExitCode.USAGE,
);
}
} catch (e) {
if (e instanceof BailianError) throw e;
// If the get itself failed (e.g. not found), let the DELETE call surface the real error.
}
}
if (!flags.yes && !config.nonInteractive && !config.quiet) {
process.stderr.write(`Delete deployment ${deployedModel}?\n`);
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
if (!ok) {
emitBare("Cancelled.");
return;
}
} else if (!flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm deletion in non-interactive mode.",
ExitCode.USAGE,
);
}
const response = await deleteDeployment(config, deployedModel!);
if (config.quiet) {
emitBare(deployedModel!);
} else if (format === "text") {
emitBare(`Deleted ${deployedModel}.`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,77 @@
import {
defineCommand,
detectOutputFormat,
getDeployment,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Get details of a single model deployment",
usageArgs: "--deployed-model <id>",
options: [
{
flag: "--deployed-model <id>",
description: "Deployed model identifier (required)",
required: true,
},
],
exampleArgs: [
"--deployed-model qwen-plus-2025-12-01-b6d61c71",
"--deployed-model qwen-plus-2025-12-01-b6d61c71 --output json",
],
async run(config: Config, flags: GlobalFlags) {
const deployedModel = flags.deployedModel as string | undefined;
if (!deployedModel) failIfMissing("deployed-model", "bl deploy get --deployed-model <id>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ action: "deploy.get", deployed_model: deployedModel }, format);
return;
}
const response = await getDeployment(config, deployedModel!);
const deployment = response.output ?? response.data;
if (!deployment) {
emitBare(`No data returned for ${deployedModel}`);
return;
}
const item: Record<string, unknown> = {
deployed_model: deployment.deployed_model ?? deployedModel,
deployed_name: deployment.name ?? "",
model_name: deployment.model_name ?? "",
base_model: deployment.base_model ?? "",
status: deployment.status ?? "",
plan: deployment.plan ?? "",
};
if (deployment.model_unit_spec) item.model_unit_spec = deployment.model_unit_spec;
if (deployment.charge_type) item.charge_type = deployment.charge_type;
if (deployment.capacity !== undefined) item.capacity = deployment.capacity;
if (deployment.base_capacity !== undefined) item.base_capacity = deployment.base_capacity;
if (deployment.ready_capacity !== undefined) item.ready_capacity = deployment.ready_capacity;
if (deployment.rpm_limit !== undefined) item.rpm_limit = deployment.rpm_limit;
if (deployment.tpm_limit !== undefined) item.tpm_limit = deployment.tpm_limit;
if (deployment.input_tpm !== undefined) item.input_tpm = deployment.input_tpm;
if (deployment.output_tpm !== undefined) item.output_tpm = deployment.output_tpm;
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, 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}`);
}
},
});
@@ -0,0 +1,74 @@
import {
defineCommand,
detectOutputFormat,
listDeployments,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { formatTable } from "bailian-cli-runtime";
export default defineCommand({
description: "List model deployments",
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>]",
options: [
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
{
flag: "--page-size <n>",
description: "Results per page (default: 10, max 100)",
type: "number",
},
{
flag: "--status <s>",
description: "Filter by status (PENDING / RUNNING / STOPPED / FAILED)",
},
],
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
const status = (flags.status as string | undefined) || undefined;
if (config.dryRun) {
emitResult({ action: "deploy.list", page: pageNo, page_size: pageSize, status }, format);
return;
}
const response = await listDeployments(config, { pageNo, pageSize, status });
const payload = response.output ?? response.data;
const deployments = payload?.deployments ?? [];
const total = payload?.total;
const items = deployments.map((item) => ({
deployed_model: item.deployed_model ?? "",
model_name: item.model_name ?? "",
status: item.status ?? "",
plan: item.plan ?? "",
capacity: item.capacity !== undefined ? String(item.capacity) : "",
created_at: item.gmt_create ?? "",
}));
if (format === "json") {
emitResult({ items, total }, 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((i) => [
i.deployed_model,
i.model_name,
i.status,
i.plan,
i.capacity,
i.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
},
});
@@ -0,0 +1,165 @@
import {
defineCommand,
detectOutputFormat,
listDeployableModels,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { formatTable } from "bailian-cli-runtime";
export default defineCommand({
description: "List models available for deployment",
usageArgs: "[--page <n>] [--page-size <n>] [--version <v>] [--source <custom|public>]",
options: [
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
{
flag: "--page-size <n>",
description: "Results per page (default: 100)",
type: "number",
},
{
flag: "--version <v>",
description: "Catalog version filter (default: v1.0; required for new catalog models)",
},
{
flag: "--source <s>",
description: "Model source filter: custom (fine-tuned) | base (catalog) | public",
},
],
exampleArgs: [
"",
"--source base",
"--source custom --page-size 50",
"--version v1.0 --output json",
],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
// Default version to v1.0 — without it, the API returns the legacy catalog
// (only old fine-tune outputs). Pass --version "" to opt out.
const version =
flags.version === "" ? undefined : ((flags.version as string | undefined) ?? "v1.0");
const modelSource = (flags.source as string | undefined) || undefined;
if (config.dryRun) {
emitResult(
{
action: "deploy.models",
page: pageNo,
page_size: pageSize,
version,
model_source: modelSource,
},
format,
);
return;
}
const response = await listDeployableModels(config, {
pageNo,
pageSize,
version,
modelSource,
});
const payload = response.output ?? response.data;
const models = payload?.models ?? [];
const total = payload?.total;
// 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
// downstream tooling can drive `bl deploy create --template-id <…>` without
// a second round-trip. For text: keep the compact one-line summary.
if (format === "json") {
const items = models.map((m) => {
const out: Record<string, unknown> = {
model_name: m.model_name ?? "",
};
if (m.base_model) out.base_model = m.base_model;
if (m.model_source) out.model_source = m.model_source;
if (m.supported_plans && m.supported_plans.length > 0) {
out.supported_plans = m.supported_plans;
}
if (m.plans && m.plans.length > 0) {
out.plans = m.plans.map((p) => {
const planEntry: Record<string, unknown> = { plan: p.plan ?? "" };
if (p.cu_specs && p.cu_specs.length > 0) {
planEntry.cu_specs = p.cu_specs;
}
if (p.templates && p.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = p.templates.map((t) => {
const tpl: Record<string, unknown> = {};
if (t.template_id) tpl.template_id = t.template_id;
if (t.template_name) tpl.template_name = t.template_name;
if (t.charge_type) tpl.charge_type = t.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = t.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 (t.roles?.prefill || t.roles?.decode) {
tpl.roles = {
prefill: t.roles?.prefill,
decode: t.roles?.decode,
};
}
if (t.template_desc) tpl.template_desc = t.template_desc;
return tpl;
});
}
return planEntry;
});
}
return out;
});
emitResult({ items, total }, format);
return;
}
// text / quiet — keep the compact single-line summary table.
const textItems = models.map((m) => {
let plansSummary = "";
if (m.supported_plans && m.supported_plans.length > 0) {
plansSummary = m.supported_plans.join(",");
} else if (m.plans && m.plans.length > 0) {
plansSummary = m.plans
.map((p) => {
const planName = p.plan ?? "?";
if (p.templates && p.templates.length > 0) {
return `${planName}(${p.templates.length}t)`;
}
if (p.cu_specs && p.cu_specs.length > 0) {
return `${planName}(${p.cu_specs.join("/")})`;
}
return planName;
})
.join(",");
} else {
plansSummary = "-";
}
return {
model_name: m.model_name ?? "",
base_model: m.base_model ?? "",
source: m.model_source ?? "",
plans: plansSummary,
};
});
if (textItems.length === 0) {
emitBare("No deployable models found.");
return;
}
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
const rows = textItems.map((i) => [i.model_name, i.base_model, i.source, i.plans]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
},
});
@@ -0,0 +1,230 @@
/**
* Per-plan strategy table for `bl deploy create`.
*
* Each PlanStrategy owns one slice of plan-specific behaviour:
* - required-flag checks (USAGE errors when the user is missing something)
* - any pre-flight side-effects (e.g. mu auto-picks a template from the
* catalog; lora/ptu are pure)
* - the plan-specific body fragment for POST /api/v1/deployments
* - the plan-specific confirmation-panel rows
*
* The dispatcher in `create.ts` only knows about `STRATEGIES[plan]`. Adding a
* new plan = one new strategy object + one line in `STRATEGIES`. Nothing in
* `create.ts` needs to change. This collapses the 5 places where lora / ptu /
* mu used to be hard-coded (default value list / required-flag checks /
* auto-pick / body assembly / confirm rows) into one strategy entry per plan.
*/
import {
listDeployableModels,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
export interface PlanContext {
config: Config;
flags: GlobalFlags;
/** Underlying model identifier (`--model`). */
model: string;
/** Console display name (`--name`). */
name: string;
}
export interface PlanResolved {
/**
* Plan-specific fields to merge into the request body. The shared envelope
* (`{model_name, name, plan}`) is added by the caller.
*/
body: Record<string, unknown>;
/**
* Lines to append to the confirmation panel — each already formatted like
* ` key: value`.
*/
confirmRows: string[];
/**
* Suffix appended to the `plan: <name>` confirm row, e.g.
* ` (Token-billed)`. Empty / undefined when no annotation is needed.
*/
planLabelSuffix?: string;
}
export interface PlanStrategy {
/** Plan id, matches `--plan` CLI value. */
name: string;
/** Throws USAGE-coded BailianError when required flags are missing. */
validateFlags(flags: GlobalFlags): void;
/**
* Resolve plan-specific bits to a body fragment + confirm rows. May call
* into the API (e.g. mu auto-picks a template from the deployable-models
* catalog).
*/
resolve(ctx: PlanContext): Promise<PlanResolved>;
}
/**
* `lora` (Token-billed) — the CLI default. The API requires `capacity` even
* though it is ignored for token-billed plans (per the working example), so
* the CLI injects `1` as a placeholder.
*/
const loraStrategy: PlanStrategy = {
name: "lora",
validateFlags() {
/* no required flags */
},
async resolve(): Promise<PlanResolved> {
return {
body: { capacity: 1 },
confirmRows: [],
planLabelSuffix: " (Token-billed)",
};
},
};
/**
* `ptu` (Token-billed, provisioned throughput). The platform rejects creation
* without `ptu_capacity.input_tpm` / `output_tpm` ("Miss ptu capacity info")
* even though the doc lists 10000/1000 defaults — so the CLI treats them as
* required.
*/
const ptuStrategy: PlanStrategy = {
name: "ptu",
validateFlags(flags: GlobalFlags): void {
const usage =
"bl deploy create --plan ptu --model <m> --name <n> --input-tpm <n> --output-tpm <n>";
if (flags.inputTpm === undefined) failIfMissing("input-tpm", usage);
if (flags.outputTpm === undefined) failIfMissing("output-tpm", usage);
},
async resolve(ctx: PlanContext): Promise<PlanResolved> {
const inputTpm = ctx.flags.inputTpm as number;
const outputTpm = ctx.flags.outputTpm as number;
const thinkingOutputTpm = ctx.flags.thinkingOutputTpm as number | undefined;
const ptuCapacity: Record<string, number> = {
input_tpm: inputTpm,
output_tpm: outputTpm,
};
if (thinkingOutputTpm !== undefined) ptuCapacity.thinking_output_tpm = thinkingOutputTpm;
const rows = [` input_tpm: ${inputTpm}`, ` output_tpm: ${outputTpm}`];
if (thinkingOutputTpm !== undefined) rows.push(` thinking_output_tpm: ${thinkingOutputTpm}`);
return {
body: { ptu_capacity: ptuCapacity },
confirmRows: rows,
planLabelSuffix: " (Token-billed, provisioned throughput)",
};
},
};
/**
* `mu` (model-unit-billed). `capacity`, `billing_method` and `template_id` are
* all required by the API but every one has a CLI-side default:
* - billing_method defaults to POST_PAY (the only supported value).
* - template_id auto-picks from GET /deployments/models — the one whose
* `charge_type` matches `billing_method`, else the first available.
* - capacity defaults to the template's `capacity_unit_per_instance` (the
* smallest valid multiple of base_capacity).
*
* The catalog lookup is skipped when `--template-id` is supplied explicitly:
* fine-tuned custom models may not appear in the `source=base` catalog, and
* forcing the lookup would otherwise raise a spurious "no template" error.
* It is also skipped in dry-run mode to keep `--dry-run` side-effect-free.
*/
const muStrategy: PlanStrategy = {
name: "mu",
validateFlags() {
/* every required field has a default — nothing to assert up-front */
},
async resolve(ctx: PlanContext): Promise<PlanResolved> {
const billingMethod = (ctx.flags.billingMethod as string | undefined) || "POST_PAY";
let templateId = ctx.flags.templateId as string | undefined;
let capacity = ctx.flags.capacity as number | undefined;
let autoPickedTemplate = false;
if (!ctx.config.dryRun && !templateId) {
try {
const resp = await listDeployableModels(ctx.config, {
modelSource: "base",
pageSize: 100,
version: "v1.0",
});
const payload = resp.output ?? resp.data;
const target = (payload?.models ?? []).find((m) => m.model_name === ctx.model);
const muPlan = target?.plans?.find((p) => p.plan === "mu");
const templates = muPlan?.templates ?? [];
if (templates.length === 0) {
throw new BailianError(
`No mu-plan template found for model "${ctx.model}". ` +
`Run \`bl deploy models --source base\` to inspect available models, ` +
`or pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
// POST_PAY → post_paid template; fall back to the first available.
const wantChargeType = billingMethod === "POST_PAY" ? "post_paid" : "pre_paid";
const picked = templates.find((t) => t.charge_type === wantChargeType) ?? templates[0];
if (!picked?.template_id) {
throw new BailianError(
`No mu-plan template found for model "${ctx.model}". ` +
`Run \`bl deploy models --source base\` to inspect available models, ` +
`or pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
templateId = picked.template_id;
autoPickedTemplate = true;
if (capacity === undefined) {
capacity = picked.roles?.unified?.capacity_unit_per_instance ?? 1;
}
} catch (e) {
if (e instanceof BailianError) throw e;
throw new BailianError(
`Failed to auto-pick template for plan=mu: ${(e as Error).message}. ` +
`Pass --template-id explicitly.`,
ExitCode.USAGE,
);
}
}
const body: Record<string, unknown> = {
capacity: capacity ?? 1,
billing_method: billingMethod,
};
if (templateId) body.template_id = templateId;
const rows: string[] = [];
if (templateId) {
const hint = autoPickedTemplate ? " (auto-picked)" : "";
rows.push(` template_id: ${templateId}${hint}`);
}
rows.push(` capacity: ${capacity ?? 1}`);
rows.push(` billing_method: ${billingMethod}`);
return { body, confirmRows: rows };
},
};
/**
* Registry of supported plans. Adding a new plan = one entry here. The
* catalog lists some additional plan names (e.g. `ptu_v2`) that are NOT
* accepted by the create endpoint, so the dispatcher in `create.ts` will
* reject anything outside this table with a clear USAGE error.
*/
export const STRATEGIES: Record<string, PlanStrategy> = {
lora: loraStrategy,
ptu: ptuStrategy,
mu: muStrategy,
};
/** Throws USAGE if `plan` is not in the strategy table. */
export function pickPlanStrategy(plan: string): PlanStrategy {
const s = STRATEGIES[plan];
if (!s) {
throw new BailianError(
`Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`,
ExitCode.USAGE,
);
}
return s;
}
@@ -0,0 +1,106 @@
import {
defineCommand,
detectOutputFormat,
scaleDeployment,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
/**
* `bl deploy scale` — adjust capacity (and optional PTU input/output token rates).
*
* Server-side capacity constraint: positive integer, < 1000, must be an
* integer multiple of `base_capacity` (visible via `bl deploy get`).
*/
export default defineCommand({
description: "Scale a deployment's capacity",
usageArgs: "--deployed-model <id> --capacity <n> [--input-tpm <n>] [--output-tpm <n>] [--yes]",
options: [
{
flag: "--deployed-model <id>",
description: "Deployed model identifier (required)",
required: true,
},
{
flag: "--capacity <n>",
description: "New capacity in plan units (must be a multiple of base_capacity)",
type: "number",
},
{
flag: "--input-tpm <n>",
description: "PTU only — input tokens per minute",
type: "number",
},
{
flag: "--output-tpm <n>",
description: "PTU only — output tokens per minute",
type: "number",
},
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
],
exampleArgs: [
"--deployed-model qwen-plus-...-b6d61c71 --capacity 8",
"--deployed-model dep-... --capacity 2 --yes",
],
async run(config: Config, flags: GlobalFlags) {
const deployedModel = flags.deployedModel as string | undefined;
if (!deployedModel)
failIfMissing("deployed-model", "bl deploy scale --deployed-model <id> --capacity <n>");
const capacity = flags.capacity !== undefined ? (flags.capacity as number) : undefined;
const inputTpm = flags.inputTpm !== undefined ? (flags.inputTpm as number) : undefined;
const outputTpm = flags.outputTpm !== undefined ? (flags.outputTpm as number) : undefined;
if (capacity === undefined && inputTpm === undefined && outputTpm === undefined) {
throw new BailianError(
"Provide at least one of --capacity / --input-tpm / --output-tpm.",
ExitCode.USAGE,
);
}
const format = detectOutputFormat(config.output);
const body: Record<string, unknown> = {};
if (capacity !== undefined) body.capacity = capacity;
if (inputTpm !== undefined) body.input_tpm = inputTpm;
if (outputTpm !== undefined) body.output_tpm = outputTpm;
if (config.dryRun) {
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, format);
return;
}
if (!flags.yes && !config.nonInteractive && !config.quiet) {
const parts: string[] = [];
if (capacity !== undefined) parts.push(`capacity=${capacity}`);
if (inputTpm !== undefined) parts.push(`input_tpm=${inputTpm}`);
if (outputTpm !== undefined) parts.push(`output_tpm=${outputTpm}`);
process.stderr.write(`Scale deployment ${deployedModel} (${parts.join(", ")})?\n`);
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
if (!ok) {
emitBare("Cancelled.");
return;
}
} else if (!flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm scaling in non-interactive mode.",
ExitCode.USAGE,
);
}
const response = await scaleDeployment(config, deployedModel!, body);
const deployment = response.output ?? response.data;
if (config.quiet) {
emitBare(deployedModel!);
} else if (format === "text") {
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
emitBare(`Scaled ${deployedModel}${cap}.`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,99 @@
import {
defineCommand,
detectOutputFormat,
updateDeployment,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
/**
* `bl deploy update` — update deployment rate limits.
*
* PUT /api/v1/deployments/{deployed_model}
* Body: at least one of `rpm_limit` (requests/min) or `tpm_limit` (tokens/min).
*/
export default defineCommand({
description: "Update a deployment's rate limits (rpm_limit / tpm_limit)",
usageArgs: "--deployed-model <id> [--rpm-limit <n>] [--tpm-limit <n>] [--yes]",
options: [
{
flag: "--deployed-model <id>",
description: "Deployed model identifier (required)",
required: true,
},
{
flag: "--rpm-limit <n>",
description: "Requests per minute",
type: "number",
},
{
flag: "--tpm-limit <n>",
description: "Tokens per minute",
type: "number",
},
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
],
exampleArgs: [
"--deployed-model dep-... --rpm-limit 1000",
"--deployed-model dep-... --rpm-limit 1000 --tpm-limit 200000 --yes",
],
notes: ["At least one of --rpm-limit / --tpm-limit must be provided."],
async run(config: Config, flags: GlobalFlags) {
const deployedModel = flags.deployedModel as string | undefined;
if (!deployedModel)
failIfMissing("deployed-model", "--deployed-model <id> [--rpm-limit <n>] [--tpm-limit <n>]");
const rpmLimit = flags.rpmLimit !== undefined ? (flags.rpmLimit as number) : undefined;
const tpmLimit = flags.tpmLimit !== undefined ? (flags.tpmLimit as number) : undefined;
if (rpmLimit === undefined && tpmLimit === undefined) {
throw new BailianError("Provide at least one of --rpm-limit / --tpm-limit.", ExitCode.USAGE);
}
const format = detectOutputFormat(config.output);
const body: Record<string, unknown> = {};
if (rpmLimit !== undefined) body.rpm_limit = rpmLimit;
if (tpmLimit !== undefined) body.tpm_limit = tpmLimit;
if (config.dryRun) {
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, format);
return;
}
if (!flags.yes && !config.nonInteractive && !config.quiet) {
const parts: string[] = [];
if (rpmLimit !== undefined) parts.push(`rpm_limit=${rpmLimit}`);
if (tpmLimit !== undefined) parts.push(`tpm_limit=${tpmLimit}`);
process.stderr.write(`Update rate limits for ${deployedModel} (${parts.join(", ")})?\n`);
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
if (!ok) {
emitBare("Cancelled.");
return;
}
} else if (!flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm rate-limit update in non-interactive mode.",
ExitCode.USAGE,
);
}
const response = await updateDeployment(config, deployedModel!, body);
const deployment = response.output ?? response.data;
if (config.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}.`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,62 @@
import {
defineCommand,
detectOutputFormat,
cancelFineTune,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Cancel a running fine-tune job",
usageArgs: "--job-id <id> [--yes]",
options: [
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
],
exampleArgs: ["bl finetune cancel --job-id ft-xxx", "bl finetune cancel --job-id ft-xxx --yes"],
notes: [
"Only PENDING / RUNNING jobs can be cancelled. Completed / failed / already-",
"cancelled jobs return a server-side error (passed through verbatim).",
],
async run(config: Config, flags: GlobalFlags) {
const jobId = flags.jobId as string | undefined;
if (!jobId) failIfMissing("job-id", "bl finetune cancel --job-id <id>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ action: "finetune.cancel", job_id: jobId }, format);
return;
}
if (!flags.yes && !config.nonInteractive && !config.quiet) {
process.stderr.write(`Cancel fine-tune job ${jobId}?\n`);
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
if (!ok) {
emitBare("Cancelled.");
return;
}
} else if (!flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm cancellation in non-interactive mode.",
ExitCode.USAGE,
);
}
const response = await cancelFineTune(config, jobId!);
const job = response.output ?? response.data;
if (config.quiet) {
emitBare(jobId!);
} else if (format === "text") {
const status = job?.status ? ` (status=${job.status})` : "";
emitBare(`Cancelled ${jobId}${status}.`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,174 @@
import {
defineCommand,
detectOutputFormat,
fetchModelList,
fetchModelCapability,
listSupportedTrainingTypes,
modelSupportsTrainingType,
isTrainingTypeCli,
trainingTypeMethodVariant,
TRAINING_TYPES_CLI,
type Config,
type GlobalFlags,
type ModelCapability,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const PAGE_SIZE = 50;
/**
* Page through every foundation-model page (listFoundationModels, public — no
* console login needed). Returns raw records so capability fields
* (`supports` / `trainingTypes`) are preserved for filtering.
*/
async function fetchAllFoundationModels(config: Config): Promise<ModelCapability[]> {
const first = await fetchModelList(config, "", { 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(config, "", { pageNo, pageSize: PAGE_SIZE });
all.push(...result.models);
}
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()}`;
}
export default defineCommand({
description:
"Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it)",
usageArgs: "--model <m> | --training-type <t>",
options: [
{
flag: "--model <m>",
description: "List training types supported by this base model.",
},
{
flag: "--training-type <t>",
description: `List models supporting this training type: ${TRAINING_TYPES_CLI.join(" | ")}.`,
},
],
exampleArgs: [
"--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.",
"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.",
],
async run(config: Config, flags: GlobalFlags) {
const model = (flags.model as string | undefined) || undefined;
const trainingType = (flags.trainingType as string | undefined) || undefined;
if (model && trainingType) {
throw new Error("--model and --training-type are mutually exclusive; pass one.");
}
if (!model && !trainingType) {
failIfMissing("model or training-type", "--model <m> | --training-type <t>");
}
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{
action: "finetune.capability",
model,
training_type: trainingType,
},
format,
);
return;
}
// Direction 1: by model → which training types it supports.
if (model) {
const capability = await fetchModelCapability(config, model);
if (!capability) {
emitBare(`No foundation model found matching "${model}".`);
return;
}
const supported = listSupportedTrainingTypes(capability);
if (config.quiet) {
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)}`);
}
return;
}
// Direction 2: by training type → which models support it.
if (!isTrainingTypeCli(trainingType!)) {
throw new Error(
`--training-type "${trainingType}" is not supported. Valid: ${TRAINING_TYPES_CLI.join(", ")}.`,
);
}
const { method, variant } = trainingTypeMethodVariant(
trainingType as Parameters<typeof trainingTypeMethodVariant>[0],
);
const all = await fetchAllFoundationModels(config);
const matched = all
.filter((record) =>
modelSupportsTrainingType(
record,
trainingType as Parameters<typeof modelSupportsTrainingType>[1],
),
)
.map((record) => ({
model: record.model as string,
name: (record.name as string | undefined) ?? (record.model as string),
}))
.filter((entry) => Boolean(entry.model))
.sort((left, right) => left.model.localeCompare(right.model));
if (config.quiet) {
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}`);
},
});
@@ -0,0 +1,58 @@
import {
defineCommand,
detectOutputFormat,
listCheckpoints,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { formatTable } from "bailian-cli-runtime";
export default defineCommand({
description: "List checkpoints produced by a fine-tune job",
usageArgs: "--job-id <id>",
options: [{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true }],
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
notes: [
"Use the returned `checkpoint` value with `bl finetune export` to publish",
"a deployable model.",
],
async run(config: Config, flags: GlobalFlags) {
const jobId = flags.jobId as string | undefined;
if (!jobId) failIfMissing("job-id", "bl finetune checkpoints --job-id <id>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ action: "finetune.checkpoints", job_id: jobId }, format);
return;
}
const response = await listCheckpoints(config, jobId!);
const payload = response.output ?? response.data;
const ckpts = Array.isArray(payload) ? payload : (payload?.checkpoints ?? []);
const total = Array.isArray(payload) ? payload.length : (payload?.total ?? ckpts.length);
const items = ckpts.map((item) => ({
checkpoint: item.checkpoint ?? item.checkpoint_id ?? "",
step: item.step !== undefined ? String(item.step) : "",
status: item.status ?? "",
}));
if (format === "json") {
emitResult({ items, total }, format);
return;
}
// text / quiet
if (items.length === 0) {
emitBare("No checkpoints found.");
return;
}
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}`);
},
});
@@ -0,0 +1,532 @@
import {
defineCommand,
detectOutputFormat,
createFineTune,
getDataset,
uploadDataset,
validateDataset,
fetchModelCapability,
listSupportedTrainingTypes,
preflightBatchSizeGate,
isTrainingTypeCli,
toServerTrainingType,
TRAINING_TYPES_CLI,
DEFAULT_TRAINING_TYPE,
formatIssue,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
type CreateFineTuneRequest,
type FineTuneHyperParameters,
type DatasetFile,
type DatasetSchema,
} from "bailian-cli-core";
import { existsSync, statSync } from "fs";
import { basename } from "path";
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
/**
* A `--datasets` / `--validations` token is treated as a local file to upload
* when it resolves to an existing file on disk; otherwise it is forwarded
* verbatim as a previously-uploaded file-id (the `file-xxx` shape returned by
* `bl dataset upload`). This lets users skip the manual upload step:
* `--datasets ./train.jsonl` uploads then trains in one shot.
*/
function isLocalPath(token: string): boolean {
return existsSync(token) && statSync(token).isFile();
}
interface ResolvedDataset {
/**
* Tokens in input order. Local paths are kept as-is here (a placeholder
* until `uploadResolvedLocal` swaps them for real file-ids); bare file-ids
* pass through untouched. In dry-run the paths stay (the previewed body
* reflects exactly what the user typed).
*/
fileIds: string[];
/** Local paths in input order, for the deferred upload step. */
localPaths: string[];
/** In-hand size for the first local token, if known (local statSync). */
firstSize?: number;
/**
* Total training-sample count across local tokens, when known. Sourced from
* `validateDataset`'s `stats.totalRecords` (summed per token). Undefined when
* any token is a bare file-id (no local file to count) or in dry-run — the
* pre-submit batch-size gate only fires when this is known, so file-id flows
* fall through to the platform rather than risk a false positive.
*/
recordCount?: number;
}
/**
* Analyze a comma-separated `--datasets` / `--validations` value WITHOUT
* uploading: bare file-ids pass through; local paths are validated through the
* same pipeline as `bl dataset upload` (so structural errors surface here),
* their sample count and size are captured for the pre-submit gate, and the
* path itself is recorded in `localPaths` for a later, deferred upload.
*
* Splitting analysis from upload lets the batch-size gate fire before any
* network call — a doomed job (too few samples) is rejected without burning an
* upload, and is offline-testable. In dry-run mode local paths are not
* validated (the preview never touches the network or the disk beyond stat).
*/
async function analyzeDatasetTokens(
config: Config,
raw: string,
label: string,
schema?: DatasetSchema,
): Promise<ResolvedDataset> {
const tokens = raw
.split(",")
.map((token) => token.trim())
.filter(Boolean);
if (tokens.length === 0) {
throw new BailianError(`--${label} must contain at least one entry.`, ExitCode.USAGE);
}
const fileIds: string[] = [];
const localPaths: string[] = [];
let firstSize: number | undefined;
let recordCount: number | undefined;
// A file-id token has no local file to count, so the total sample count is
// only knowable when every token is a local path. Once any file-id is seen,
// flip to unknown and stop accumulating to avoid an undercount that could
// trip the batch-size gate falsely.
let recordCountKnown = true;
for (const token of tokens) {
if (!isLocalPath(token)) {
fileIds.push(token);
recordCountKnown = false;
continue;
}
fileIds.push(token);
localPaths.push(token);
if (config.dryRun) continue;
// Local path → validate (same checks as `bl dataset upload`). Upload is
// deferred to `uploadResolvedLocal` so the gate can run first. The schema
// (SFT vs DPO) is derived from --training-type so a DPO job validates the
// chosen/rejected preference pairs here, not on the platform.
const result = await validateDataset(token, { schema });
if (!result.valid) {
const lines = [
`Dataset validation failed for ${token}`,
...result.errors.slice(0, 10).map(formatIssue),
];
if (result.errors.length > 10) {
lines.push(` … and ${result.errors.length - 10} more error(s).`);
}
lines.push(
"",
"Hint: re-run `bl dataset validate --file <path>` for the full report,",
" or upload manually with `bl dataset upload --no-validate` and",
" pass the resulting file-id here.",
);
throw new BailianError(lines.join("\n"), ExitCode.GENERAL);
}
if (result.warnings.length > 0 && !config.quiet) {
process.stderr.write(
`Dataset validation passed with ${result.warnings.length} warning(s) for ${token}:\n`,
);
for (const warning of result.warnings.slice(0, 5)) {
process.stderr.write(`${formatIssue(warning)}\n`);
}
if (result.warnings.length > 5) {
process.stderr.write(` … and ${result.warnings.length - 5} more.\n`);
}
}
// Accumulate the sample count so the caller can pre-flight the batch-size
// gate before submitting. `totalRecords` is set by the jsonl validator as
// (non-blank lines); undefined stats fall back to "unknown" (no gate).
const tokenRecords = result.stats.totalRecords;
if (typeof tokenRecords === "number") {
recordCount = (recordCount ?? 0) + tokenRecords;
}
if (firstSize === undefined) firstSize = statSync(token).size;
}
return {
fileIds,
localPaths,
firstSize,
recordCount: recordCountKnown ? recordCount : undefined,
};
}
/**
* Upload each local path recorded in `resolved.localPaths`, swapping the
* placeholder path entries in `resolved.fileIds` for the returned file-ids.
* Returns the uploaded file records (for the confirmation panel). No-op in
* dry-run. Validation already happened in `analyzeDatasetTokens`, so this is
* pure upload.
*/
async function uploadResolvedLocal(
config: Config,
resolved: ResolvedDataset,
purpose: string,
label: string,
): Promise<DatasetFile[]> {
const uploaded: DatasetFile[] = [];
for (const [index, token] of resolved.fileIds.entries()) {
if (!isLocalPath(token)) continue;
const file: DatasetFile = await uploadDataset(config, { filePath: token, purpose });
if (!file.file_id) {
throw new BailianError(
`Upload of ${token} succeeded but no file_id was returned.`,
ExitCode.GENERAL,
);
}
uploaded.push(file);
resolved.fileIds[index] = file.file_id;
if (!config.quiet) {
process.stderr.write(
`Uploaded ${basename(token)} → ${file.file_id} (auto from --${label})\n`,
);
}
}
return uploaded;
}
export default defineCommand({
description: "Create a fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
usageArgs:
"--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>] [--yes]",
options: [
{
flag: "--model <model>",
description: "Base model to fine-tune (e.g. qwen3-8b, qwen3-14b)",
required: true,
},
{
flag: "--datasets <ids|paths>",
description:
"Comma-separated dataset file IDs or local .jsonl paths. Local paths are uploaded (validated) first, then their file-ids are used.",
required: true,
},
{
flag: "--validations <ids|paths>",
description:
"Comma-separated validation dataset file IDs or local .jsonl paths (auto-uploaded like --datasets).",
},
{
flag: "--model-name <name>",
description: "Output model name (after training)",
},
{
flag: "--suffix <text>",
description: "Output suffix appended by the platform (finetuned_output_suffix)",
},
{
flag: "--training-type <t>",
description: `Training type: ${TRAINING_TYPES_CLI.join(" | ")} (default: ${DEFAULT_TRAINING_TYPE}). Mapping to the server happens at the interface boundary (e.g. sft-lora -> efficient_sft, dpo -> dpo_full).`,
},
{
flag: "--n-epochs <n>",
description: "Number of epochs (default: 3)",
type: "number",
},
{
flag: "--batch-size <n>",
description:
"Per-device batch size (clamped to [8, 1024]). Auto-set to 8 for small datasets (<100KB)",
type: "number",
},
{
flag: "--learning-rate <str>",
description: 'Learning rate as a string to preserve precision (e.g. "1.6e-5")',
},
{
flag: "--max-length <n>",
description: "Max sequence length",
type: "number",
},
{
flag: "--yes",
description: "Skip the confirmation prompt",
type: "boolean",
},
],
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",
'bl finetune create --model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
"--model qwen3-8b --datasets file-xxx --yes --output json",
],
notes: [
"Training-type values use the `<method>` / `<method>-lora` convention:",
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
"to the server's training_type at the interface boundary, so the rest of the",
"CLI never sees the raw server strings.",
"Before submitting (non dry-run) the job, the model's training capability is",
"checked via listFoundationModels (no console login required); an unsupported",
"training type fails fast with the list the model actually supports.",
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
"--datasets / --validations accept either file-ids (from `bl dataset",
"upload`) or local .jsonl paths. Local paths are validated and uploaded",
"first, then their file-ids are submitted — a one-step upload-and-train.",
"Dataset record schema is chosen from --training-type: dpo* → {messages,",
"chosen, rejected}; cpt → {text} (raw pre-training text); else {messages}.",
"Pre-submit gate: if the training dataset's sample count is not greater",
"than batch_size, the job is rejected before upload or quota consumption",
"(the platform would otherwise fail ~10 min in, after data processing).",
],
async run(config: Config, flags: GlobalFlags) {
const model = flags.model as string | undefined;
if (!model) failIfMissing("model", "bl finetune create --model <model>");
const datasetsRaw = flags.datasets as string | undefined;
if (!datasetsRaw) failIfMissing("datasets", "bl finetune create --datasets <ids|paths>");
// Resolve the training type before analyzing datasets so the validator can
// enforce the right record schema (DPO jobs require chosen/rejected on
// every record). Whitelist is the single source of truth in core
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
const trainingType = (flags.trainingType as string | undefined) || DEFAULT_TRAINING_TYPE;
if (!isTrainingTypeCli(trainingType)) {
throw new BailianError(
`--training-type "${trainingType}" is not supported.`,
ExitCode.USAGE,
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
);
}
// dpo / dpo-lora → "dpo" schema (strict chosen/rejected); cpt → "cpt"
// (raw {text} records); else ChatML ({messages}).
const datasetSchema: DatasetSchema = trainingType.startsWith("dpo")
? "dpo"
: trainingType === "cpt"
? "cpt"
: "chatml";
const training = await analyzeDatasetTokens(config, datasetsRaw!, "datasets", datasetSchema);
const trainingFileIds = training.fileIds;
const validationsRaw = flags.validations as string | undefined;
const validation = validationsRaw
? await analyzeDatasetTokens(config, validationsRaw, "validations", datasetSchema)
: undefined;
const validationFileIds = validation?.fileIds;
const modelName = flags.modelName as string | undefined;
const suffix = flags.suffix as string | undefined;
// Hyper-parameters: inject n_epochs=3 default unless overridden.
const hp: FineTuneHyperParameters = {};
hp.n_epochs = flags.nEpochs !== undefined ? (flags.nEpochs as number) : 3;
if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate as string;
if (flags.maxLength !== undefined) hp.max_length = flags.maxLength as number;
// batch_size: clamp to [8, 1024] (server hard constraint, undocumented).
// Surface the clamp on stderr instead of silently rewriting the user's
// value — otherwise the confirmation panel below would show a number the
// user never typed, with no audit trail. (Range observed on common SFT
// / SFT-LoRA training types; some bases like qwen3.6-flash report a wider
// range, so the warning explicitly mentions "server range".)
if (flags.batchSize !== undefined) {
const requested = flags.batchSize as number;
let batchSize = requested;
if (batchSize < 8) batchSize = 8;
if (batchSize > 1024) batchSize = 1024;
if (batchSize !== requested && !config.quiet) {
process.stderr.write(
`warning: --batch-size ${requested} clamped to ${batchSize} ` +
`(server range [8, 1024] for the common training types).\n`,
);
}
hp.batch_size = batchSize;
}
// Auto batch_size for small datasets: fetch first training file size.
// With default split=0.9, validation_set = 0.1 * rows.
// Platform default batch_size=16 needs rows > 160; batch_size=8 needs rows > 80.
// Files < 100KB are conservatively estimated to have < 200 rows.
// If the first file was just uploaded we already hold its size; otherwise
// fall back to getDataset.
let batchSizeAutoAdjusted = false;
if (hp.batch_size === undefined && !config.dryRun) {
let sizeBytes = training.firstSize ?? 0;
if (sizeBytes === 0) {
try {
const fileInfo = await getDataset(config, trainingFileIds[0]);
sizeBytes = fileInfo.data?.size ?? 0;
} catch {
// If we can't fetch file info, skip auto-adjustment; platform will use default.
}
}
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
hp.batch_size = 8;
batchSizeAutoAdjusted = true;
}
}
// Pre-submit batch-size gate: the platform rejects a job whose number of
// training samples is not greater than batch_size, but only surfaces that
// ~10 minutes into the run (after data processing). Fail fast here, before
// burning quota. `recordCount` is only known when every --datasets token
// was a local file we validated; file-id tokens fall through to the
// platform rather than risk a false positive from an undercount.
//
// The decision lives in core (`preflightBatchSizeGate`) — a structured,
// job-level pre-flight that returns a `ValidationIssue` (same shape / stable
// code as `validateDataset`) so the failure surfaces through the same
// `BailianError` + issue convention used by `bl dataset upload`/`validate`.
// ExitCode.GENERAL matches the existing validation-failed exit code.
if (!config.dryRun && training.recordCount !== undefined) {
// 16 is the platform default when neither the user nor the small-file
// auto-adjust set a batch_size (see the auto-adjust comment above).
const effectiveBatchSize = hp.batch_size ?? 16;
const gate = preflightBatchSizeGate({
recordCount: training.recordCount,
batchSize: effectiveBatchSize,
});
if (!gate.ok && gate.issue) {
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
}
}
// Pre-flight capability check: confirm the model actually supports the
// requested training type BEFORE any upload, so a wrong --model /
// --training-type combo doesn't burn storage on datasets that will never
// be trained against. listFoundationModels is a public API (no console
// login required); on lookup failure (network / 401 / etc.) we fall back
// to letting the server decide rather than blocking the submit.
if (!config.dryRun) {
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
try {
capability = await fetchModelCapability(config, model!);
} catch (error) {
if (!config.quiet) {
process.stderr.write(
`warning: model capability lookup failed (${(error as Error).message}); ` +
"proceeding without local pre-flight.\n",
);
}
}
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
const supported = listSupportedTrainingTypes(capability);
throw new BailianError(
`Model "${model}" does not support training type "${trainingType}".`,
ExitCode.USAGE,
supported.length
? `This model supports: ${supported.join(", ")}.`
: "This model reports no supported training types.",
);
}
}
// Non-interactive guard — moved BEFORE upload. In CI / scripted mode the
// user must opt in via --yes; otherwise we must not silently consume quota
// OR upload any file. (Local validation is still allowed to run.)
if (!config.dryRun && !flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm fine-tune creation in non-interactive mode.",
ExitCode.USAGE,
);
}
// Upload local paths now that pre-flight (validation, batch-size gate,
// capability check, non-interactive guard) has cleared them. This swaps
// the placeholder path entries in `training.fileIds` / `validation?.fileIds`
// for real file-ids, so the body and confirmation panel below see ids.
let uploadedTraining: DatasetFile[] = [];
let uploadedValidation: DatasetFile[] = [];
if (!config.dryRun) {
uploadedTraining = await uploadResolvedLocal(config, training, "fine-tune", "datasets");
if (validation) {
uploadedValidation = await uploadResolvedLocal(
config,
validation,
"fine-tune",
"validations",
);
}
}
const body: CreateFineTuneRequest = {
model: model!,
training_file_ids: trainingFileIds,
// Map the CLI training type to the server value at the interface boundary.
training_type: toServerTrainingType(trainingType),
hyper_parameters: hp,
};
if (validationFileIds && validationFileIds.length > 0) {
body.validation_file_ids = validationFileIds;
}
if (modelName) body.model_name = modelName;
if (suffix) body.finetuned_output_suffix = suffix;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
const pending = [
...training.localPaths.map((path) => ({ field: "datasets", path })),
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
];
emitResult(
pending.length > 0
? { action: "finetune.create", body, pending_uploads: pending }
: { action: "finetune.create", body },
format,
);
return;
}
// Confirmation panel — destructive in the sense that it consumes quota.
// (Capability check and non-interactive guard already ran pre-upload.)
if (!flags.yes && !config.nonInteractive && !config.quiet) {
process.stderr.write("Create fine-tune job:\n");
process.stderr.write(` Model: ${body.model}\n`);
process.stderr.write(` Training type: ${trainingType}\n`);
process.stderr.write(` Training files: ${trainingFileIds.join(", ")}\n`);
if (validationFileIds) {
process.stderr.write(` Validation: ${validationFileIds.join(", ")}\n`);
}
for (const file of uploadedTraining) {
process.stderr.write(` Uploaded: ${file.name} → ${file.file_id}\n`);
}
for (const file of uploadedValidation) {
process.stderr.write(` Uploaded: ${file.name} → ${file.file_id} (validation)\n`);
}
process.stderr.write(` n_epochs: ${hp.n_epochs}\n`);
if (hp.batch_size !== undefined) {
const hint = batchSizeAutoAdjusted ? " (auto: small dataset)" : "";
process.stderr.write(` batch_size: ${hp.batch_size}${hint}\n`);
}
if (hp.learning_rate !== undefined)
process.stderr.write(` learning_rate: ${hp.learning_rate}\n`);
if (hp.max_length !== undefined) process.stderr.write(` max_length: ${hp.max_length}\n`);
if (modelName) process.stderr.write(` model_name: ${modelName}\n`);
if (suffix) process.stderr.write(` suffix: ${suffix}\n`);
const ok = await promptConfirm({ message: "Submit this job?", initialValue: false });
if (!ok) {
emitBare("Cancelled.");
return;
}
}
const response = await createFineTune(config, body);
const job = response.output ?? response.data;
if (config.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}`);
} else {
emitResult(response, format);
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,60 @@
import {
defineCommand,
detectOutputFormat,
deleteFineTune,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing, promptConfirm } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Delete a fine-tune job record",
usageArgs: "--job-id <id> [--yes]",
options: [
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
{ flag: "--yes", description: "Skip the confirmation prompt", type: "boolean" },
],
exampleArgs: ["bl finetune delete --job-id ft-xxx", "bl finetune delete --job-id ft-xxx --yes"],
notes: [
"Cancel a RUNNING job first via `bl finetune cancel` — the platform refuses",
"to delete jobs that are still in flight.",
],
async run(config: Config, flags: GlobalFlags) {
const jobId = flags.jobId as string | undefined;
if (!jobId) failIfMissing("job-id", "bl finetune delete --job-id <id>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ action: "finetune.delete", job_id: jobId }, format);
return;
}
if (!flags.yes && !config.nonInteractive && !config.quiet) {
process.stderr.write(`Permanently delete fine-tune job ${jobId}?\n`);
const ok = await promptConfirm({ message: "Proceed?", initialValue: false });
if (!ok) {
emitBare("Cancelled.");
return;
}
} else if (!flags.yes && config.nonInteractive) {
throw new BailianError(
"Pass --yes to confirm deletion in non-interactive mode.",
ExitCode.USAGE,
);
}
const response = await deleteFineTune(config, jobId!);
if (config.quiet) {
emitBare(jobId!);
} else if (format === "text") {
emitBare(`Deleted ${jobId}.`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,69 @@
import {
defineCommand,
detectOutputFormat,
exportCheckpoint,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Publish a checkpoint as a deployable model",
usageArgs: "--job-id <id> --checkpoint <name> --model-name <name>",
options: [
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
{
flag: "--checkpoint <name>",
description: "Checkpoint identifier from `bl finetune checkpoints`",
required: true,
},
{
flag: "--model-name <name>",
description: "Deployable model name (required)",
required: true,
},
],
exampleArgs: ["bl finetune export --job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft"],
notes: [
"Required before `bl deploy create` can target a checkpoint. The platform",
"may auto-export the best checkpoint when a job reaches SUCCEEDED — explicit",
"export is the canonical path for non-best checkpoints.",
],
async run(config: Config, flags: GlobalFlags) {
const jobId = flags.jobId as string | undefined;
if (!jobId) failIfMissing("job-id", "bl finetune export --job-id <id>");
const checkpoint = flags.checkpoint as string | undefined;
if (!checkpoint) failIfMissing("checkpoint", "bl finetune export --checkpoint <name>");
const modelName = flags.modelName as string | undefined;
if (!modelName) failIfMissing("model-name", "bl finetune export --model-name <name>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{
action: "finetune.export",
job_id: jobId,
checkpoint,
model_name: modelName,
},
format,
);
return;
}
const response = await exportCheckpoint(config, jobId!, checkpoint!, modelName!);
const payload = response.output ?? response.data;
const exported = payload?.model_name ?? modelName;
if (config.quiet) {
emitBare(exported!);
} else if (format === "text") {
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
emitBare("Next: bl deploy create --model " + exported + " --name <display-name>");
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,76 @@
import {
defineCommand,
detectOutputFormat,
getFineTune,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Get details of a single fine-tune job",
usageArgs: "--job-id <id>",
options: [{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true }],
exampleArgs: ["bl finetune get --job-id ft-xxx", "bl finetune get --job-id ft-xxx --output json"],
async run(config: Config, flags: GlobalFlags) {
const jobId = flags.jobId as string | undefined;
if (!jobId) failIfMissing("job-id", "bl finetune get --job-id <id>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ action: "finetune.get", job_id: jobId }, format);
return;
}
const response = await getFineTune(config, jobId!);
const job = response.output ?? response.data;
if (!job) {
emitBare(`No data returned for ${jobId}`);
return;
}
const hp = 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}`);
const item = {
job_id: job.job_id ?? jobId,
base_model: job.model ?? "",
status: job.status ?? "",
training_type: job.training_type ?? "",
training_files: job.training_file_ids ?? [],
validation_files: job.validation_file_ids ?? [],
hyper_params: hyperParts.length ? hyperParts.join(" · ") : "",
output_model: job.finetuned_output ?? "",
model_name: job.model_name ?? "",
created_at: job.create_time ?? job.gmt_create ?? "",
updated_at: job.end_time ?? job.gmt_modified ?? "",
};
if (format === "json") {
emitResult(item, format);
return;
}
// 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} (→ bl deploy 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}`);
},
});
@@ -0,0 +1,82 @@
import {
defineCommand,
detectOutputFormat,
listFineTunes,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { formatTable } from "bailian-cli-runtime";
export default defineCommand({
description: "List fine-tune jobs",
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>]",
options: [
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
{
flag: "--page-size <n>",
description: "Results per page (default: 10, max 100)",
type: "number",
},
{
flag: "--status <s>",
description: "Filter by status (PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED)",
},
],
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
const status = (flags.status as string | undefined) || undefined;
if (config.dryRun) {
emitResult({ action: "finetune.list", page: pageNo, page_size: pageSize, status }, format);
return;
}
const response = await listFineTunes(config, { pageNo, pageSize, status });
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 ?? "",
}));
if (format === "json") {
emitResult({ items, total }, 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 `bl deploy create --model`");
},
});
@@ -0,0 +1,187 @@
import {
defineCommand,
detectOutputFormat,
getFineTuneLogs,
type Config,
type GlobalFlags,
type FineTuneLogEntry,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } 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).
*/
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 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");
}
return JSON.stringify(entry);
}
/**
* Case-insensitive substring match. String entries match against themselves;
* object entries match against their rendered form (so timestamp / level /
* message are all searchable).
*/
function entryMatches(entry: FineTuneLogEntry | string, keywordLower: string): boolean {
return renderEntry(entry).toLowerCase().includes(keywordLower);
}
/**
* Page through every log page for a job (server reports `total`), returning
* the full ordered entry list. Used when filtering by `--search` across the
* complete log rather than a single page.
*/
async function fetchAllLogs(
config: Config,
jobId: string,
pageSize: number,
): Promise<{ entries: Array<FineTuneLogEntry | string>; total: number }> {
const entries: Array<FineTuneLogEntry | string> = [];
let pageNo = 1;
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++) {
const response = await getFineTuneLogs(config, jobId, { pageNo, pageSize });
const payload = response.output ?? response.data;
const page = payload?.logs ?? [];
total = payload?.total ?? total;
if (page.length === 0) break;
entries.push(...page);
// Stop once we've collected everything the server claims exists.
if (total && entries.length >= total) break;
if (page.length < pageSize) break;
pageNo++;
}
return { entries, total };
}
export default defineCommand({
description: "Fetch training logs for a fine-tune job",
usageArgs: "--job-id <id> [--page <n>] [--page-size <n>] [--search <keyword>] [--tail <n>]",
options: [
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
{
flag: "--page-size <n>",
description: "Lines per page (default: server-defined)",
type: "number",
},
{
flag: "--search <keyword>",
description:
"Case-insensitive substring filter. When set, all log pages are fetched and filtered client-side (--page is ignored).",
},
{
flag: "--tail <n>",
description:
"Keep only the last N entries. When set, all log pages are fetched and the trailing N are kept (--page is ignored).",
type: "number",
},
],
exampleArgs: [
"--job-id ft-xxx",
"--job-id ft-xxx --page-size 100 --output json",
"--job-id ft-xxx --search checkpoint",
"--job-id ft-xxx --search error --output json",
"--job-id ft-xxx --tail 20",
"--job-id ft-xxx --search checkpoint --tail 5",
],
async run(config: Config, flags: GlobalFlags) {
const jobId = flags.jobId as string | undefined;
if (!jobId) failIfMissing("job-id", "bl finetune logs --job-id <id>");
const pageNo = flags.page !== undefined ? (flags.page as number) : undefined;
const pageSize = flags.pageSize !== undefined ? (flags.pageSize as number) : undefined;
const search = (flags.search as string | undefined) || undefined;
const tail = flags.tail !== undefined ? (flags.tail as number) : undefined;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{
action: "finetune.logs",
job_id: jobId,
page: pageNo,
page_size: pageSize,
search,
tail,
},
format,
);
return;
}
// --search / --tail both need the full log: fan out across every page,
// then filter (search) and/or take the trailing N (tail) client-side.
if (search || tail !== undefined) {
const { entries, total } = await fetchAllLogs(config, jobId!, pageSize ?? 100);
// Apply --search first: narrow to the matching entries.
let scanned = entries;
let matched: number | undefined;
if (search) {
const keywordLower = search.toLowerCase();
scanned = entries.filter((entry) => entryMatches(entry, keywordLower));
matched = scanned.length;
}
// Then apply --tail: keep the trailing N of whatever remains.
const tailApplied =
tail !== undefined && tail >= 0 ? Math.min(tail, scanned.length) : undefined;
const result =
tailApplied !== undefined ? scanned.slice(scanned.length - tailApplied) : scanned;
if (config.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 } : {}),
scanned: entries.length,
total: total || entries.length,
...(search ? { search } : {}),
...(tailApplied !== undefined ? { tail: tailApplied } : {}),
logs: result,
},
format,
);
return;
}
// Default: single page, verbatim response.
const response = await getFineTuneLogs(config, jobId!, { pageNo, pageSize });
const payload = response.output ?? response.data;
const logs = payload?.logs ?? [];
if (config.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}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,212 @@
import {
defineCommand,
detectOutputFormat,
getFineTune,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DEFAULT_INTERVAL_SEC = 10;
const MIN_INTERVAL_SEC = 1;
const TERMINAL_STATUSES = new Set(["SUCCEEDED", "FAILED", "CANCELED"]);
/** SIGINT exit code (128 + signal 2). */
const EXIT_INTERRUPTED = 130;
const EXIT_FAILED = 1;
const EXIT_TIMEOUT = 2;
/** Non-terminal status: the job is still running. Distinct from failure. */
const EXIT_RUNNING = 3;
function nowStamp(): string {
const date = new Date();
const pad = (value: number) => String(value).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function formatElapsed(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes === 0) return `${seconds}s`;
return `${minutes}m ${seconds}s`;
}
/**
* Exit code for a status value:
* SUCCEEDED -> 0
* FAILED / CANCELED -> 1
* anything else -> 3 (still running)
*/
function exitCodeForStatus(status: string): number {
if (status === "SUCCEEDED") return 0;
if (TERMINAL_STATUSES.has(status)) return EXIT_FAILED;
return EXIT_RUNNING;
}
/**
* Resolve after `milliseconds`, rejecting early if `signal` aborts (Ctrl-C).
* Cleans up its timer + listener so nothing leaks between polls.
*/
function sleep(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("aborted"));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(new Error("aborted"));
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
signal.addEventListener("abort", onAbort, { once: true });
});
}
export default defineCommand({
description:
"Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal.",
usageArgs: "--job-id <id> [--follow] [--interval <sec>] [--timeout <sec>]",
options: [
{ flag: "--job-id <id>", description: "Fine-tune job ID (required)", required: true },
{
flag: "--follow",
description:
"Block and poll until a terminal state (the legacy behavior). Without it, a single status probe is performed and the command returns immediately.",
type: "boolean",
},
{
flag: "--interval <sec>",
description: `Seconds between polls with --follow (default: ${DEFAULT_INTERVAL_SEC}, min: ${MIN_INTERVAL_SEC}). Ignored without --follow.`,
type: "number",
},
{
flag: "--timeout <sec>",
description:
"With --follow, stop polling after this many seconds (default: no limit). Ignored without --follow.",
type: "number",
},
],
exampleArgs: [
"--job-id ft-xxx # single probe, returns immediately",
"--job-id ft-xxx --output json # status probe for agents",
"--job-id ft-xxx --follow # block until terminal",
"--job-id ft-xxx --follow --interval 5",
"--job-id ft-xxx --follow --timeout 3600",
],
notes: [
"Default (no --follow) is a NON-BLOCKING single status probe: one fetch, then",
"return immediately. This is the mode meant for agents / scripts — the caller",
"owns the polling cadence, so the CLI never holds the terminal.",
"Exit codes (both modes): 0 SUCCEEDED | 1 FAILED/CANCELED | 2 --follow timeout",
"| 3 still running (non-terminal, default mode) | 130 interrupted (Ctrl-C).",
"Use --follow for the blocking, human-terminal-follow experience; use the",
"default mode when driving the loop yourself (e.g. from an agent).",
"For per-step training output (not status), use `bl finetune logs`.",
],
async run(config: Config, flags: GlobalFlags) {
const jobId = flags.jobId as string | undefined;
if (!jobId) failIfMissing("job-id", "bl finetune watch --job-id <id>");
const follow = Boolean(flags.follow);
const intervalSec = Math.max(
MIN_INTERVAL_SEC,
flags.interval !== undefined ? (flags.interval as number) : DEFAULT_INTERVAL_SEC,
);
const timeoutSec = flags.timeout !== undefined ? (flags.timeout as number) : undefined;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{
action: "finetune.watch",
job_id: jobId,
follow,
interval: intervalSec,
timeout: timeoutSec,
},
format,
);
return;
}
// ---- Default: non-blocking single status probe -------------------------
if (!follow) {
const response = await getFineTune(config, jobId!);
const job = response.output ?? response.data;
const status = String(job?.status ?? "").toUpperCase();
const terminal = TERMINAL_STATUSES.has(status);
const code = exitCodeForStatus(status);
if (config.quiet) {
// Just the status word — ideal for `status=$(bl finetune watch ... --quiet)`.
emitBare(status || "UNKNOWN");
} else if (format === "text") {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (terminal) {
const mark = status === "SUCCEEDED" ? "✓" : "✗";
emitBare(`${mark} ${jobId} ${status}`);
}
} else {
// json / yaml: a compact, purpose-built status probe.
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
}
process.exit(code);
}
// ---- --follow: blocking poll loop (legacy behavior) -------------------
const controller = new AbortController();
const onSigint = () => controller.abort();
process.on("SIGINT", onSigint);
try {
let lastStatus = "";
const startedAt = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
const response = await getFineTune(config, jobId!, controller.signal);
const job = response.output ?? response.data;
const status = String(job?.status ?? "").toUpperCase();
if (format === "text" && !config.quiet && status !== lastStatus) {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
lastStatus = status;
}
if (TERMINAL_STATUSES.has(status)) {
const elapsed = Date.now() - startedAt;
if (format !== "text" || config.quiet) {
emitResult(response, format);
} else {
const mark = status === "SUCCEEDED" ? "✓" : "✗";
emitBare(`\n${mark} ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
}
process.exit(exitCodeForStatus(status));
}
if (timeoutSec !== undefined && (Date.now() - startedAt) / 1000 >= timeoutSec) {
if (format === "text" && !config.quiet) {
emitBare(
`\n⏼ ${jobId} timed out after ${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"})`,
);
}
process.exit(EXIT_TIMEOUT);
}
await sleep(intervalSec * 1000, controller.signal);
}
} catch (error) {
if (controller.signal.aborted) {
emitBare("\nInterrupted.");
process.exit(EXIT_INTERRUPTED);
}
throw error;
} finally {
process.off("SIGINT", onSigint);
}
},
});
+50 -4
View File
@@ -16,12 +16,48 @@ import {
type StreamChunk,
isInteractive,
resolveFileUrl,
resolveOutputDir,
resolveCredential,
} from "bailian-cli-core";
import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime";
import { emitResult } from "bailian-cli-runtime";
import { resolveOutputDir, resolveCredential } from "bailian-cli-core";
const OMNI_VOICES = ["Chelsie", "Cherry", "Ethan", "Serena", "Sunny", "Tina"];
interface VoiceEntry {
voice: string;
name: string;
desc: string;
lang: string;
}
// qwen-omni 系统音色
const OMNI_VOICES: VoiceEntry[] = [
{ voice: "Tina", name: "甜妹", desc: "甜美亲切", lang: "中文/英文" },
{ voice: "Dylan", name: "北京-晓东", desc: "胡同少年", lang: "中文/北京" },
{ voice: "Kiki", name: "粤语-阿清", desc: "甜美港妹", lang: "中文/英文" },
{ voice: "Li", name: "南京-老李", desc: "南京大叔", lang: "中文/英文" },
{ voice: "Sunny", name: "四川-晴儿", desc: "甜飒川妹", lang: "中文" },
{ voice: "Marcus", name: "陕西-秦川", desc: "陕北汉子", lang: "中文/英文" },
{ voice: "Eric", name: "四川-程川", desc: "成都大哥", lang: "中文/英文" },
{ voice: "Rocky", name: "粤语-阿强", desc: "幽默港仔", lang: "中文/英文" },
{ voice: "Jennifer", name: "詹妮弗", desc: "美剧大女主", lang: "中文/英文" },
{ voice: "Ryan", name: "甜茶", desc: "美剧张力男", lang: "中文/英文" },
{ voice: "Katerina", name: "卡捷琳娜", desc: "御姐深情女", lang: "中文/英文" },
{ voice: "Peter", name: "天津-李彼得", desc: "天津捧哏", lang: "中文/英文" },
{ voice: "Ethan", name: "晨煦", desc: "北方口音男", lang: "中文/英文" },
];
function printVoiceList(): void {
const col = (s: string, w: number) => s.padEnd(w);
process.stdout.write("\nOmni output voices:\n");
process.stdout.write(
`${col("VOICE ID", 12)} ${col("NAME", 14)} ${col("DESCRIPTION", 14)} LANGUAGE\n`,
);
process.stdout.write(`${"-".repeat(12)} ${"-".repeat(14)} ${"-".repeat(14)} ${"-".repeat(12)}\n`);
for (const v of OMNI_VOICES) {
process.stdout.write(`${col(v.voice, 12)} ${col(v.name, 14)} ${col(v.desc, 14)} ${v.lang}\n`);
}
process.stdout.write(`\nTotal: ${OMNI_VOICES.length} voices\n`);
}
/**
* Extension to input audio format.
@@ -109,7 +145,11 @@ export default defineCommand({
},
{
flag: "--voice <voice>",
description: `Output voice (default: Cherry). Options: ${OMNI_VOICES.join(", ")}`,
description: "Output voice ID (default: Tina). Use --list-voices to see all options",
},
{
flag: "--list-voices",
description: "List available output voices and exit",
},
{ flag: "--audio-format <fmt>", description: "Audio output format (default: wav)" },
{ flag: "--audio-out <path>", description: "Save audio to file (default: auto-generate)" },
@@ -118,6 +158,7 @@ export default defineCommand({
{ flag: "--temperature <n>", description: "Sampling temperature (0.0, 2.0]", type: "number" },
],
exampleArgs: [
"--list-voices",
'--message "Hello, who are you?"',
'--message "Describe this image" --image ./photo.jpg',
'--message "What is this audio saying?" --audio https://example.com/audio.wav',
@@ -128,6 +169,11 @@ export default defineCommand({
'--message "Read this passage aloud" --audio-out greeting.wav',
],
async run(config: Config, flags: GlobalFlags) {
if (flags.listVoices) {
printVoiceList();
return;
}
// --- Parse messages ---
let userMessages: string[] = [];
if (flags.message) {
@@ -148,7 +194,7 @@ export default defineCommand({
}
const model = (flags.model as string) || config.defaultOmniModel || "qwen3.5-omni-plus";
const voice = (flags.voice as string) || "Cherry";
const voice = (flags.voice as string) || "Tina";
const audioFormat = (flags.audioFormat as string) || "wav";
const textOnly = flags.textOnly === true;
const format = detectOutputFormat(config.output);
@@ -20,11 +20,13 @@ import {
DOCS_HOSTS,
} from "bailian-cli-core";
const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`;
import { downloadFile } from "bailian-cli-runtime";
import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime";
import { promptText, promptSelect, failIfMissing, cmdUsage } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { VOICE_TTS_PAGE } from "bailian-cli-runtime";
const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`;
interface VoiceEntry {
voice: string;
@@ -37,7 +39,7 @@ interface VoiceEntry {
const COSYVOICE_V3_FLASH_VOICES: VoiceEntry[] = [
// 社交陪伴
{ voice: "longanyang", name: "龙安洋", desc: "阳光大男孩", lang: "中文/英文" },
{ voice: "longanhuan", name: "龙安欢", desc: "欢脱元气女", lang: "中文/英文" },
{ voice: "longanhuan_v3", name: "龙安欢", desc: "欢脱元气女", lang: "中文/英文" },
{ voice: "longantai_v3", name: "龙安台", desc: "嗲甜台湾女", lang: "中文/英文" },
{ voice: "longhua_v3", name: "龙华", desc: "元气甜美女", lang: "中文/英文" },
{ voice: "longcheng_v3", name: "龙橙", desc: "智慧青年男", lang: "中文/英文" },
@@ -121,12 +123,14 @@ function printVoiceList(model: string): void {
const voices = MODEL_VOICES[model];
if (!voices) {
process.stdout.write(`No built-in voice list available for model: ${model}\n`);
process.stdout.write(`Browse voices in the console: ${VOICE_TTS_PAGE}\n`);
return;
}
if (voices.length === 0) {
process.stdout.write(`Model ${model} has no system voices.\n`);
process.stdout.write("Use clone or design voices created via the CosyVoice API.\n");
process.stdout.write(`See: ${COSYVOICE_CLONE_DESIGN_DOC}\n`);
process.stdout.write(`Browse voices in the console: ${VOICE_TTS_PAGE}\n`);
return;
}
const col = (s: string, w: number) => s.padEnd(w);
@@ -139,6 +143,7 @@ function printVoiceList(model: string): void {
process.stdout.write(`${col(v.voice, 26)} ${col(v.name, 10)} ${col(v.desc, 16)} ${v.lang}\n`);
}
process.stdout.write(`\nTotal: ${voices.length} voices\n`);
process.stdout.write(`Preview and browse more voices in the console: \n${VOICE_TTS_PAGE}\n`);
}
export default defineCommand({
@@ -155,11 +160,12 @@ export default defineCommand({
{
flag: "--voice <voice>",
description:
"Voice ID. Use --list-voices to see system voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID",
"Voice ID. Use --list-voices to see built-in voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID",
},
{
flag: "--list-voices",
description: "List available system voices for the selected model and exit",
description:
"List built-in system voices for the selected model and exit (console link shown in output)",
},
{ flag: "--format <format>", description: "Audio format: mp3, pcm, wav, opus (default: mp3)" },
{ flag: "--sample-rate <rate>", description: "Audio sample rate in Hz (e.g. 24000)" },
@@ -263,7 +269,7 @@ export default defineCommand({
const modelVoices = MODEL_VOICES[model];
if (modelVoices && modelVoices.length > 0) {
throw new BailianError(
`--voice is required.\nRun the following to see available voices:\n ${cmdUsage(config, `--list-voices --model ${model}`)}`,
`--voice is required.\nRun the following to see available voices:\n ${cmdUsage(config, `--list-voices --model ${model}`)}\nBrowse more voices: ${VOICE_TTS_PAGE}`,
ExitCode.USAGE,
);
} else {
@@ -160,6 +160,11 @@ export default defineCommand({
if (flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget as number;
}
} else if (!shouldStream) {
// DashScope qwen3 models default to enable_thinking=true server-side, but
// non-streaming calls require it to be explicitly false. Stream calls
// support thinking, so leave the field unset there (server handles it).
body.enable_thinking = false;
}
if (flags.tool) {
@@ -0,0 +1,115 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { padEnd } from "bailian-cli-runtime";
import type { AddOrganizationMemberResponse } from "./types.ts";
import {
TOKEN_PLAN_AK_OPTIONS,
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
appendCommonQueryParams,
callTokenPlanApi,
prepareTokenPlanRequest,
resolveTokenPlanCredentials,
type TokenPlanQueryParams,
} from "./utils.ts";
const API_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";
const DEFAULT_ORG_ROLE = "ORG_MEMBER";
export default defineCommand({
description: "Add a member to a Token Plan organization",
usageArgs: "--account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
exampleArgs: [
"--account-name dev_user --org-id org_123",
"--account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN",
"--account-name member1 --org-id org_123 --spec-type standard",
],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const credentials = resolveTokenPlanCredentials(config, flags);
const accountName = flags.accountName as string | undefined;
const orgId = flags.orgId as string | undefined;
if (!accountName) {
throw new BailianError("Missing required argument --account-name.", ExitCode.USAGE);
}
if (!orgId) {
throw new BailianError("Missing required argument --org-id.", ExitCode.USAGE);
}
const queryParams = buildQueryParams(flags);
if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}
const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});
if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});
function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};
if (flags.accountName) params.AccountName = flags.accountName as string;
if (flags.orgId) params.OrgId = flags.orgId as string;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);
return params;
}
function emitTextMember(data: AddOrganizationMemberResponse): void {
const item = data.Data;
if (!item) {
emitBare("Member added.");
return;
}
emitBare(`${padEnd("AccountId", 14)} ${item.AccountId ?? "-"}`);
emitBare(`${padEnd("SeatAssigned", 14)} ${String(item.SeatAssigned ?? "-")}`);
}
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/
import { createHmac, createHash, randomUUID } from "crypto";
export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
host: string;
pathname: string;
method?: string;
/** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */
queryString?: string;
}
/** Build ACS3 canonical query string from POP query parameters. */
export function buildCanonicalQuery(params: Record<string, string | string[] | undefined>): string {
const pairs: Array<[string, string]> = [];
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === "") continue;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const v = value[i];
if (v !== "") pairs.push([`${key}.${i + 1}`, v]);
}
} else {
pairs.push([key, value]);
}
}
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&");
}
function encodeRFC3986(str: string): string {
return encodeURIComponent(str).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
export function signTokenPlanRequest(cfg: TokenPlanAkSignConfig): Record<string, string> {
const method = cfg.method ?? "POST";
const now = new Date();
const dateISO = now.toISOString().replace(/\.\d{3}Z$/, "Z");
const nonce = randomUUID();
const hashedBody = sha256Hex(cfg.body);
const headers: Record<string, string> = {
host: cfg.host,
"x-acs-action": cfg.action,
"x-acs-version": cfg.version,
"x-acs-date": dateISO,
"x-acs-signature-nonce": nonce,
"x-acs-content-sha256": hashedBody,
"content-type": "application/json",
};
const signedHeaderKeys = Object.keys(headers)
.filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-"))
.sort();
const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n";
const signedHeadersStr = signedHeaderKeys.join(";");
const queryString = cfg.queryString ?? "";
const canonicalRequest = [
method,
cfg.pathname,
queryString,
canonicalHeaders,
signedHeadersStr,
hashedBody,
].join("\n");
const algorithm = "ACS3-HMAC-SHA256";
const hashedCanonical = sha256Hex(canonicalRequest);
const stringToSign = `${algorithm}\n${hashedCanonical}`;
const signature = hmacSHA256Hex(cfg.accessKeySecret, stringToSign);
headers["authorization"] =
`${algorithm} Credential=${cfg.accessKeyId},SignedHeaders=${signedHeadersStr},Signature=${signature}`;
return headers;
}
function sha256Hex(data: string): string {
return createHash("sha256").update(data, "utf8").digest("hex");
}
function hmacSHA256Hex(key: string, data: string): string {
return createHmac("sha256", key).update(data, "utf8").digest("hex");
}
@@ -0,0 +1,110 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import type { BatchAssignSeatsResponse } from "./types.ts";
import {
TOKEN_PLAN_AK_OPTIONS,
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
TOKEN_PLAN_WORKSPACE_OPTION,
appendCommonQueryParams,
callTokenPlanApi,
prepareTokenPlanRequest,
requireWorkspaceId,
resolveTokenPlanCredentials,
type TokenPlanQueryParams,
} from "./utils.ts";
const API_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";
export default defineCommand({
description: "Batch assign Token Plan seats to members",
usageArgs: "--workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
exampleArgs: [
"--workspace-id ws_456 --seat-type standard --account-id acc_123",
"--workspace-id ws_456 --seat-type pro --account-id acc_1 --account-id acc_2",
],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const credentials = resolveTokenPlanCredentials(config, flags);
const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}
const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}
const queryParams = buildQueryParams(flags, workspaceId);
if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}
const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});
if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});
function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};
params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;
const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}
return params;
}
@@ -0,0 +1,110 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { padEnd } from "bailian-cli-runtime";
import type { CreateTokenPlanKeyResponse } from "./types.ts";
import {
TOKEN_PLAN_AK_OPTIONS,
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
TOKEN_PLAN_WORKSPACE_OPTION,
appendCommonQueryParams,
callTokenPlanApi,
prepareTokenPlanRequest,
requireWorkspaceId,
resolveTokenPlanCredentials,
type TokenPlanQueryParams,
} from "./utils.ts";
const API_ACTION = "CreateTokenPlanKey";
const API_PATH = "/tokenplan/api-keys";
export default defineCommand({
description: "Create a Token Plan API key for a seat",
usageArgs: "--account-id <id> --workspace-id <id> [flags]",
options: [
{ flag: "--account-id <id>", description: "Target member account ID", required: true },
TOKEN_PLAN_WORKSPACE_OPTION,
{ flag: "--description <text>", description: "API key description" },
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
exampleArgs: [
"--account-id acc_123 --workspace-id ws_456",
"--account-id acc_123 --workspace-id ws_456 --description 'Dev key'",
],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const credentials = resolveTokenPlanCredentials(config, flags);
const accountId = flags.accountId as string | undefined;
const workspaceId = requireWorkspaceId(config, flags);
if (!accountId) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}
const queryParams = buildQueryParams(flags, { accountId, workspaceId });
if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}
const data = await callTokenPlanApi<CreateTokenPlanKeyResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});
if (config.quiet || format === "text") {
emitTextKey(data);
} else {
emitResult(data, format);
}
},
});
function buildQueryParams(
flags: GlobalFlags,
resolved: { accountId: string; workspaceId: string },
): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};
params.AccountId = resolved.accountId;
params.WorkspaceId = resolved.workspaceId;
if (flags.description) params.Description = flags.description as string;
appendCommonQueryParams(params, flags);
return params;
}
function emitTextKey(data: CreateTokenPlanKeyResponse): void {
const item = data.Data;
if (!item) {
emitBare("API key created.");
return;
}
emitBare(`${padEnd("ApiKeyId", 14)} ${item.ApiKeyId ?? "-"}`);
emitBare(`${padEnd("MaskedApiKey", 14)} ${item.MaskedApiKey ?? "-"}`);
if (item.Description) {
emitBare(`${padEnd("Description", 14)} ${item.Description}`);
}
if (item.PlainApiKey) {
emitBare("");
emitBare(`PlainApiKey (shown once): ${item.PlainApiKey}`);
}
}
@@ -0,0 +1,152 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { padEnd } from "bailian-cli-runtime";
import type { GetSubscriptionSeatDetailsResponse, TokenPlanSeatDetail } from "./types.ts";
import {
TOKEN_PLAN_AK_OPTIONS,
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
appendCommonQueryParams,
callTokenPlanApi,
prepareTokenPlanRequest,
resolveTokenPlanCredentials,
type TokenPlanQueryParams,
} from "./utils.ts";
const API_ACTION = "GetSubscriptionSeatDetails";
const API_PATH = "/tokenplan/subscription/seat-detail";
export default defineCommand({
description: "List Token Plan subscription seat details",
usageArgs: "[flags]",
options: [
{ flag: "--page-no <n>", description: "Page number (default: 1)", type: "number" },
{ flag: "--page-size <n>", description: "Page size (default: 10)", type: "number" },
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--status <status>",
description:
"Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED",
type: "array",
},
{
flag: "--status-list-str <json>",
description: "StatusList as JSON string, e.g. '[\"NORMAL\"]'",
},
{ flag: "--seat-id <id>", description: "Filter by seat ID" },
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
},
{
flag: "--query-assigned <bool>",
description: "Filter by assignment: true=assigned, false=unassigned",
},
...TOKEN_PLAN_AK_OPTIONS,
],
exampleArgs: ["", "--page-size 20 --status NORMAL", "--query-assigned true --seat-type standard"],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const credentials = resolveTokenPlanCredentials(config, flags);
const queryParams = buildQueryParams(flags);
if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}
const data = await callTokenPlanApi<GetSubscriptionSeatDetailsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "GET",
queryParams,
});
const items = data.Data?.Items ?? [];
if (config.quiet || format === "text") {
emitTextSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize);
} else {
emitResult(data, format);
}
},
});
function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};
if (flags.pageNo !== undefined) params.PageNo = String(flags.pageNo as number);
if (flags.pageSize !== undefined) params.PageSize = String(flags.pageSize as number);
appendCommonQueryParams(params, flags);
if (flags.statusListStr) params.StatusListStr = flags.statusListStr as string;
const status = flags.status as string[] | undefined;
if (status && status.length > 0) {
params.StatusList = status;
}
if (flags.seatId) params.SeatId = flags.seatId as string;
if (flags.seatType) params.SeatType = flags.seatType as string;
if (typeof flags.queryAssigned === "string" && flags.queryAssigned.length > 0) {
const val = flags.queryAssigned.toLowerCase();
if (val !== "true" && val !== "false") {
throw new BailianError("--query-assigned must be 'true' or 'false'.", ExitCode.USAGE);
}
params.QueryAssigned = val;
}
return params;
}
function emitTextSeats(
items: TokenPlanSeatDetail[],
total?: number,
pageNo?: number,
pageSize?: number,
): void {
if (items.length === 0) {
emitBare("No seats found.");
return;
}
const header = [
padEnd("SeatId", 18),
padEnd("Type", 10),
padEnd("Status", 10),
padEnd("Assigned", 12),
padEnd("Account", 20),
].join(" ");
emitBare(header);
emitBare("-".repeat(header.length));
for (const item of items) {
const row = [
padEnd(item.SeatId ?? "-", 18),
padEnd(item.SpecType ?? "-", 10),
padEnd(item.Status ?? "-", 10),
padEnd(item.AssignedStatus ?? "-", 12),
padEnd(item.AccountName ?? item.AccountId ?? "-", 20),
].join(" ");
emitBare(row);
}
if (total !== undefined) {
emitBare("");
emitBare(
`Total: ${total}${pageNo !== undefined ? ` | Page: ${pageNo}` : ""}${pageSize !== undefined ? ` | PageSize: ${pageSize}` : ""}`,
);
}
}
@@ -0,0 +1,69 @@
// ---- Token Plan / ModelStudio POP (2026-02-10) ----
export interface TokenPlanSeatEquity {
EquityType?: string;
CycleInstanceId?: string;
CycleStartTime?: number;
CycleEndTime?: number;
CycleTotalValue?: number;
CycleSurplusValue?: number;
CycleVersion?: number;
}
export interface TokenPlanSeatDetail {
InstanceCode?: string;
EquityList?: TokenPlanSeatEquity[];
EndTime?: number;
SeatId?: string;
SpecType?: string;
StartTime?: number;
AssignedStatus?: string;
AccountId?: string;
AccountName?: string;
AccountEmail?: string;
Status?: string;
}
export interface GetSubscriptionSeatDetailsResponse {
Success?: boolean;
Code?: string;
Message?: string;
Data?: {
Items?: TokenPlanSeatDetail[];
Total?: number;
PageNo?: number;
PageSize?: number;
};
}
export interface CreateTokenPlanKeyResponse {
Success?: boolean;
Code?: string;
Message?: string;
Data?: {
ApiKeyId?: string;
PlainApiKey?: string;
MaskedApiKey?: string;
Description?: string;
CreatedAt?: string;
SourceId?: string;
};
}
export interface BatchAssignSeatsResponse {
Success?: boolean;
Code?: string;
Message?: string;
}
export interface AddOrganizationMemberResponse {
Success?: boolean;
Code?: string;
Message?: string;
RequestId?: string;
HttpStatusCode?: number;
Data?: {
AccountId?: string;
SeatAssigned?: boolean;
};
}
@@ -0,0 +1,161 @@
import {
REGIONS,
maskToken,
trackingHeaders,
type Config,
type GlobalFlags,
type OptionDef,
type Region,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { buildCanonicalQuery, signTokenPlanRequest } from "./ak-sign.ts";
export const TOKEN_PLAN_API_VERSION = "2026-02-10";
export const TOKEN_PLAN_AK_OPTIONS: OptionDef[] = [
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
{
flag: "--access-key-secret <key>",
description: "Alibaba Cloud Access Key Secret (deprecated)",
},
];
export const TOKEN_PLAN_COMMON_QUERY_OPTIONS: OptionDef[] = [
{
flag: "--caller-uac-account-id <id>",
description: "Caller UAC account ID",
},
{
flag: "--namespace-id <id>",
description: "Product namespace ID (Token Plan default: namespace-1)",
},
];
export const TOKEN_PLAN_WORKSPACE_OPTION: OptionDef = {
flag: "--workspace-id <id>",
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)",
};
const MODEL_STUDIO_HOSTS: Partial<Record<Region, string>> = {
cn: "modelstudio.cn-beijing.aliyuncs.com",
intl: "modelstudio.ap-southeast-1.aliyuncs.com",
};
function resolveRegion(baseUrl: string): Region {
for (const [region, url] of Object.entries(REGIONS) as Array<[Region, string]>) {
if (baseUrl === url || baseUrl.startsWith(`${url}/`)) return region;
}
return "cn";
}
/** ModelStudio POP OpenAPI host for the given DashScope base URL preset. */
function modelStudioHost(baseUrl: string): string {
const region = resolveRegion(baseUrl);
return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn!;
}
export interface TokenPlanApiResponse {
Success?: boolean;
Code?: string;
Message?: string;
}
export type TokenPlanQueryParams = Record<string, string | string[] | undefined>;
export function resolveTokenPlanCredentials(
config: Config,
flags: GlobalFlags,
): { accessKeyId: string; accessKeySecret: string } {
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
if (!accessKeyId || !accessKeySecret) {
throw new BailianError(
"No credentials found.\n" +
"Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
ExitCode.AUTH,
);
}
return { accessKeyId, accessKeySecret };
}
export function requireWorkspaceId(config: Config, flags: GlobalFlags): string {
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Missing workspace ID.\n" +
"Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
ExitCode.USAGE,
);
}
return workspaceId;
}
export function appendCommonQueryParams(params: TokenPlanQueryParams, flags: GlobalFlags): void {
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
}
export function prepareTokenPlanRequest(
config: Config,
path: string,
queryParams: TokenPlanQueryParams,
): { host: string; endpoint: string; queryString: string; queryParams: TokenPlanQueryParams } {
const queryString = buildCanonicalQuery(queryParams);
const host = modelStudioHost(config.baseUrl);
const endpoint = `https://${host}${path}${queryString ? `?${queryString}` : ""}`;
return { host, endpoint, queryString, queryParams };
}
export async function callTokenPlanApi<T extends TokenPlanApiResponse>(opts: {
config: Config;
credentials: { accessKeyId: string; accessKeySecret: string };
action: string;
path: string;
method: "GET" | "POST";
queryParams: TokenPlanQueryParams;
}): Promise<T> {
const { config, credentials, action, path, method, queryParams } = opts;
const { host, endpoint, queryString } = prepareTokenPlanRequest(config, path, queryParams);
const headers = signTokenPlanRequest({
accessKeyId: credentials.accessKeyId,
accessKeySecret: credentials.accessKeySecret,
action,
version: TOKEN_PLAN_API_VERSION,
body: "",
host,
pathname: path,
method,
queryString,
});
if (config.verbose) {
process.stderr.write(`> ${method} ${endpoint}\n`);
process.stderr.write(`> AK: ${maskToken(credentials.accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(endpoint, {
method,
headers: { ...headers, ...trackingHeaders() },
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as T;
if (!res.ok || data.Success === false) {
throw new BailianError(
`${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
return data;
}
@@ -27,12 +27,12 @@ import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailia
export default defineCommand({
description:
"Generate a video from text or image (happyhorse-1.0-t2v / happyhorse-1.0-i2v / wan2.6-t2v)",
"Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v)",
usageArgs: "--prompt <text> [--image <url>] [flags]",
options: [
{
flag: "--model <model>",
description: "Model ID (default: happyhorse-1.0-t2v, or happyhorse-1.0-i2v with --image)",
description: "Model ID (default: happyhorse-1.1-t2v, or happyhorse-1.1-i2v with --image)",
},
{ flag: "--prompt <text>", description: "Video description", required: true },
{ flag: "--image <url>", description: "Input image URL for image-to-video generation" },
@@ -94,7 +94,7 @@ export default defineCommand({
const model =
(flags.model as string) ||
config.defaultVideoModel ||
((flags.image as string) ? "happyhorse-1.0-i2v" : "happyhorse-1.0-t2v");
((flags.image as string) ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v");
const format = detectOutputFormat(config.output);
const imageUrl = flags.image as string | undefined;
@@ -114,7 +114,7 @@ export default defineCommand({
input: {
prompt: prompt!,
negative_prompt: (flags.negativePrompt as string) || undefined,
// i2v models (happyhorse-1.0-i2v) require input.media with type 'first_frame'
// i2v models (happyhorse-1.1-i2v) require input.media with type 'first_frame'
...(resolvedImageUrl
? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
+3 -3
View File
@@ -26,10 +26,10 @@ import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailia
export default defineCommand({
description:
"Reference-to-video generation (happyhorse-1.0-r2v / wan2.6-r2v): multi-subject, multi-shot with voice",
"Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice",
usageArgs: "--prompt <text> --image <url>... [--ref-video <url>...] [flags]",
options: [
{ flag: "--model <model>", description: "Model ID (default: happyhorse-1.0-r2v)" },
{ flag: "--model <model>", description: "Model ID (default: happyhorse-1.1-r2v)" },
{
flag: "--prompt <text>",
description: "Video description with reference markers (image1, video1, etc.)",
@@ -122,7 +122,7 @@ export default defineCommand({
const imageVoices = (flags.imageVoice as string[] | undefined) || [];
const videoVoices = (flags.videoVoice as string[] | undefined) || [];
const model = (flags.model as string) || "happyhorse-1.0-r2v";
const model = (flags.model as string) || "happyhorse-1.1-r2v";
const format = detectOutputFormat(config.output);
// --- Resolve file URLs (auto-upload local files) ---
+26
View File
@@ -48,3 +48,29 @@ export { default as quotaList } from "./commands/quota/list.ts";
export { default as quotaRequest } from "./commands/quota/request.ts";
export { default as quotaHistory } from "./commands/quota/history.ts";
export { default as quotaCheck } from "./commands/quota/check.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";
export { default as datasetDelete } from "./commands/dataset/delete.ts";
export { default as datasetValidate } from "./commands/dataset/validate.ts";
export { default as finetuneCreate } from "./commands/finetune/create.ts";
export { default as finetuneList } from "./commands/finetune/list.ts";
export { default as finetuneGet } from "./commands/finetune/get.ts";
export { default as finetuneCancel } from "./commands/finetune/cancel.ts";
export { default as finetuneDelete } from "./commands/finetune/delete.ts";
export { default as finetuneLogs } from "./commands/finetune/logs.ts";
export { default as finetuneCheckpoints } from "./commands/finetune/checkpoints.ts";
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 deployCreate } from "./commands/deploy/create.ts";
export { default as deployList } from "./commands/deploy/list.ts";
export { default as deployGet } from "./commands/deploy/get.ts";
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 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";
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.4.0",
"version": "1.4.2",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+1 -1
View File
@@ -23,7 +23,7 @@ export interface EmbeddingsData {
}
function skillDataDir(): string {
return join(getConfigDir(), "skills/doc-llm-wiki");
return join(getConfigDir(), "skills/bailian-docs-llm-wiki");
}
function embeddingsPath(): string {
+2 -2
View File
@@ -5,7 +5,7 @@ import { getConfigDir } from "../../config/paths.ts";
import type { ModelPrice, ModelProfile, QpmLimit } from "../types.ts";
import type { ModelSource } from "./types.ts";
const SKILL_DIR_NAME = "skills/doc-llm-wiki";
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
const MODELS_FILE = "models.jsonl";
function getCatalogDir(): string {
@@ -18,7 +18,7 @@ function getCatalogPath(): string {
function getMonorepoModelsDir(): string {
const coreDir = dirname(fileURLToPath(import.meta.url));
return join(coreDir, "../../../../../skills/doc-llm-wiki/models");
return join(coreDir, "../../../../../skills/bailian-docs-llm-wiki/models");
}
function fromJsonlRecord(raw: Record<string, unknown>): ModelProfile | null {
+98
View File
@@ -84,3 +84,101 @@ export function knowledgeRetrieveEndpoint(baseUrl: string): string {
export function mcpWebSearchEndpoint(baseUrl: string): string {
return `${baseUrl}/api/v1/mcps/WebSearch/mcp`;
}
// ---- Datasets / Fine-tune Files ----
/**
* Upload endpoint — the OpenAI-compatible `/compatible-mode/v1/files`.
*
* We use the OpenAI-compatible path (not `/api/v1/files`) because it is the
* only one that persists the `purpose` field. The DashScope-native
* `/api/v1/files` silently drops `purpose`, so uploaded files show up in
* `list`/`get` with an empty purpose. Files uploaded here still appear in the
* `/api/v1/files` listing (with purpose intact), so list/get/delete keep using
* the native endpoint below.
*
* Form fields: `file` (singular) + `purpose`. `descriptions` is NOT accepted
* (the endpoint rejects unknown fields with HTTP 400).
*/
export function datasetUploadEndpoint(baseUrl: string): string {
return `${baseUrl}/compatible-mode/v1/files`;
}
/** List (GET) endpoint — DashScope-native `/api/v1/files`. */
export function datasetListEndpoint(baseUrl: string): string {
return `${baseUrl}/api/v1/files`;
}
/** Single-file get / delete endpoint. */
export function datasetFileEndpoint(baseUrl: string, fileId: string): string {
return `${baseUrl}/api/v1/files/${encodeURIComponent(fileId)}`;
}
// ---- Fine-tune Jobs (DashScope /api/v1/fine-tunes) ----
/** Create (POST) and list (GET) endpoint. */
export function finetuneJobsEndpoint(baseUrl: string): string {
return `${baseUrl}/api/v1/fine-tunes`;
}
/** Single-job get / delete endpoint. */
export function finetuneJobEndpoint(baseUrl: string, jobId: string): string {
return `${baseUrl}/api/v1/fine-tunes/${encodeURIComponent(jobId)}`;
}
/** POST /api/v1/fine-tunes/{job_id}/cancel */
export function finetuneCancelEndpoint(baseUrl: string, jobId: string): string {
return `${baseUrl}/api/v1/fine-tunes/${encodeURIComponent(jobId)}/cancel`;
}
/** GET /api/v1/fine-tunes/{job_id}/logs */
export function finetuneLogsEndpoint(baseUrl: string, jobId: string): string {
return `${baseUrl}/api/v1/fine-tunes/${encodeURIComponent(jobId)}/logs`;
}
/** GET /api/v1/fine-tunes/{job_id}/checkpoints */
export function finetuneCheckpointsEndpoint(baseUrl: string, jobId: string): string {
return `${baseUrl}/api/v1/fine-tunes/${encodeURIComponent(jobId)}/checkpoints`;
}
/** GET /api/v1/fine-tunes/{job_id}/export/{checkpoint} */
export function finetuneExportEndpoint(baseUrl: string, jobId: string, checkpoint: string): string {
return `${baseUrl}/api/v1/fine-tunes/${encodeURIComponent(jobId)}/export/${encodeURIComponent(checkpoint)}`;
}
// ---- Model Deployments (DashScope /api/v1/deployments) ----
/** POST (create) and GET (list) endpoint. */
export function deploymentsEndpoint(baseUrl: string): string {
return `${baseUrl}/api/v1/deployments`;
}
/**
* Single-deployment endpoint:
* GET — describe
* DELETE — destroy (must be STOPPED/FAILED)
*
* Note: rate-limit update has its own `/update` suffix endpoint, NOT a PUT
* on this resource root. See `deploymentUpdateEndpoint`.
*/
export function deploymentEndpoint(baseUrl: string, deployedModel: string): string {
return `${baseUrl}/api/v1/deployments/${encodeURIComponent(deployedModel)}`;
}
/** PUT /api/v1/deployments/{deployed_model}/scale — capacity adjust. */
export function deploymentScaleEndpoint(baseUrl: string, deployedModel: string): string {
return `${baseUrl}/api/v1/deployments/${encodeURIComponent(deployedModel)}/scale`;
}
/**
* PUT /api/v1/deployments/{deployed_model}/update — rate-limit update.
* Body: at least one of `rpm_limit` / `tpm_limit`.
*/
export function deploymentUpdateEndpoint(baseUrl: string, deployedModel: string): string {
return `${baseUrl}/api/v1/deployments/${encodeURIComponent(deployedModel)}/update`;
}
/** GET /api/v1/deployments/models — deployable models catalog. */
export function deploymentsModelsEndpoint(baseUrl: string): string {
return `${baseUrl}/api/v1/deployments/models`;
}
+3 -1
View File
@@ -142,7 +142,9 @@ export async function callConsoleGateway(
const innerData = json.data as Record<string, unknown> | undefined;
if (innerData?.success === false && innerData.errorCode) {
const errorCode = String(innerData.errorCode);
const rawErrorCode = innerData.errorCode;
const errorCode =
typeof rawErrorCode === "string" ? rawErrorCode : JSON.stringify(rawErrorCode);
const notLogined = errorCode.includes("NotLogined");
const errorMsg = typeof innerData.errorMsg === "string" ? innerData.errorMsg : undefined;
throw new BailianError(
+157
View File
@@ -0,0 +1,157 @@
/**
* Dataset HTTP API wrappers.
*
* Thin functions over `request` / `requestJson`. Upload goes through the
* OpenAI-compatible endpoint (the only path that persists `purpose`); list /
* get / delete use the DashScope-native `/api/v1/files` (uploaded files appear
* there too, with purpose intact). All client-side validation lives in
* `validate/`; this file only does I/O.
*/
import { createReadStream, statSync } from "fs";
import { basename } from "path";
import { Readable } from "stream";
import { request, requestJson } from "../client/http.ts";
import {
datasetUploadEndpoint,
datasetListEndpoint,
datasetFileEndpoint,
} from "../client/endpoints.ts";
import type { Config } from "../config/schema.ts";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type {
DatasetFile,
DatasetUploadResponse,
DatasetListResponse,
DatasetGetResponse,
DatasetDeleteResponse,
} from "./types.ts";
export interface DatasetUploadParams {
filePath: string;
/**
* Purpose tag forwarded to the platform. Defaults to "fine-tune" because
* the API requires the field, but callers should set this explicitly when
* uploading evaluation or other dataset kinds.
*/
purpose?: string;
signal?: AbortSignal;
}
/**
* POST /compatible-mode/v1/files (multipart/form-data)
*
* Streams the file from disk so we don't buffer 300MB into memory. Node's
* `fetch` accepts a `Blob` produced from a Readable stream via `Response`'s
* body shim, but the simplest portable approach (and the one used in
* `files/upload.ts`) is to wrap the buffer in a Blob. Here we use `Blob`
* with a stream-backed lazy `arrayBuffer()` for >50MB files via
* `Response`'s helper to avoid the buffer doubling. Fall back to readFileSync
* for small files where streaming overhead isn't worth it.
*/
export async function uploadDataset(
config: Config,
params: DatasetUploadParams,
): Promise<DatasetFile> {
const { filePath, purpose = "fine-tune", signal } = params;
const stat = statSync(filePath);
const fileName = basename(filePath);
// Use a streaming Blob via Response wrapper to avoid loading the whole file.
const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream;
const blob = await new Response(stream).blob();
const form = new FormData();
form.append("file", blob, fileName);
form.append("purpose", purpose);
const url = datasetUploadEndpoint(config.baseUrl);
const body = await requestJson<DatasetUploadResponse>(config, {
url,
method: "POST",
body: form,
signal,
});
// OpenAI-compatible response is flat: { id, filename, bytes, purpose, ... }.
if (body.id) {
return {
file_id: body.id,
name: body.filename ?? fileName,
size: body.bytes ?? stat.size,
purpose: body.purpose ?? purpose,
gmt_create: body.created_at ? new Date(body.created_at * 1000).toISOString() : undefined,
};
}
// No id in response → upload reported HTTP 200 but produced no usable record
// (the platform sometimes returns 200 + a business-failure body, e.g.
// `data.failed_uploads[].{code,message}`). Surface this loudly instead of
// synthesizing a fake-success record with file_id="" that the caller would
// then forward to `finetune create` as a phantom training file.
const failedUploads = body.data?.failed_uploads;
if (Array.isArray(failedUploads) && failedUploads.length > 0) {
const first = failedUploads[0] ?? {};
const code = first.code ? ` [${first.code}]` : "";
throw new BailianError(
`Dataset upload failed${code}: ${first.message ?? "no message returned"}`,
ExitCode.GENERAL,
`Server reported failure for ${fileName}. Re-run with --verbose to see the raw response.`,
);
}
throw new BailianError(
`Dataset upload of ${fileName} returned no file_id (HTTP 200 with empty payload).`,
ExitCode.GENERAL,
"The platform accepted the request but did not allocate a file_id. Retry the upload; if it recurs, contact platform support with the request id.",
);
}
export interface DatasetListParams {
pageNo?: number;
pageSize?: number;
purpose?: string;
signal?: AbortSignal;
}
/** GET /api/v1/files */
export async function listDatasets(
config: Config,
params: DatasetListParams = {},
): Promise<DatasetListResponse> {
const qs = new URLSearchParams();
if (params.pageNo !== undefined) qs.set("page_no", String(params.pageNo));
if (params.pageSize !== undefined) qs.set("page_size", String(params.pageSize));
if (params.purpose) qs.set("purpose", params.purpose);
const base = datasetListEndpoint(config.baseUrl);
const url = qs.toString() ? `${base}?${qs.toString()}` : base;
return requestJson<DatasetListResponse>(config, {
url,
method: "GET",
signal: params.signal,
});
}
/** GET /api/v1/files/{file_id} */
export async function getDataset(
config: Config,
fileId: string,
signal?: AbortSignal,
): Promise<DatasetGetResponse> {
const url = datasetFileEndpoint(config.baseUrl, fileId);
return requestJson<DatasetGetResponse>(config, { url, method: "GET", signal });
}
/** DELETE /api/v1/files/{file_id} */
export async function deleteDataset(
config: Config,
fileId: string,
signal?: AbortSignal,
): Promise<DatasetDeleteResponse> {
const url = datasetFileEndpoint(config.baseUrl, fileId);
// The platform sometimes returns 200 with a non-JSON body for DELETE; tolerate that.
const res = await request(config, { url, method: "DELETE", signal });
try {
return (await res.json()) as DatasetDeleteResponse;
} catch {
return { data: { deleted: true, file_id: fileId } };
}
}
+20
View File
@@ -0,0 +1,20 @@
export * from "./types.ts";
export * from "./api.ts";
export {
validateDataset,
pickValidator,
registerValidator,
listSupportedFormats,
MAX_DATASET_BYTES,
parseDatasetSchemaFlag,
formatIssue,
} from "./validate/index.ts";
export type {
ValidatorSpec,
ValidateOpts,
DatasetSchema,
ValidationResult,
ValidationIssue,
ValidationSeverity,
ValidationStats,
} from "./validate/index.ts";
+93
View File
@@ -0,0 +1,93 @@
/**
* Dataset API types.
*
* Maps DashScope `/api/v1/files` responses. The same endpoint backs every
* dataset purpose the platform supports today (fine-tune training,
* evaluation, etc.) — these types are deliberately purpose-agnostic so new
* purposes can be plugged in without schema changes.
*/
/** A single uploaded dataset file as returned by the platform. */
export interface DatasetFile {
/** File ID — the only stable handle for downstream consumers. */
file_id: string;
/** Original filename uploaded by the user. */
name: string;
/** Bytes. */
size?: number;
/** Content hash (server-computed). */
md5?: string;
/** Free-form purpose tag, e.g. "fine-tune", "evaluation". */
purpose?: string;
/** Optional internal/external URL (kept for parity with the API). */
url?: string;
/** Free-form description if the user supplied one at upload time. */
description?: string;
/** Server-side creation timestamp (string, format per platform). */
gmt_create?: string;
}
/** GET /api/v1/files response. */
export interface DatasetListResponse {
request_id?: string;
data?: {
files?: DatasetFile[];
total?: number;
page_no?: number;
page_size?: number;
};
}
/** GET /api/v1/files/{file_id} response. */
export interface DatasetGetResponse {
request_id?: string;
data?: DatasetFile;
}
/**
* POST /compatible-mode/v1/files response (OpenAI-compatible).
*
* Flat shape — there is no `data` envelope on success. `id` is the file handle
* to pass to fine-tune jobs; `purpose` is echoed back so callers can confirm
* it landed. On business-level failure (HTTP 200 + `data.failed_uploads`)
* `id` is absent and `data.failed_uploads[]` carries the platform's reason.
*/
export interface DatasetUploadResponse {
request_id?: string;
/** File ID — the handle returned to callers (e.g. `file-ft-…`). */
id?: string;
/** Always `"file"` for this endpoint. */
object?: string;
/** Bytes. */
bytes?: number;
/** Original filename uploaded by the user. */
filename?: string;
/** Purpose tag, e.g. `"fine-tune"`, `"file-extract"`, `"batch"`. */
purpose?: string;
/** Platform processing state, e.g. `"processed"`. */
status?: string;
/** Creation timestamp (Unix seconds). */
created_at?: number;
/**
* Failure envelope: HTTP 200 + business failure. When present the upload
* did NOT produce a file_id; callers must treat this as an error. Common
* cause: server-side schema rejection (e.g. malformed JSONL slipped past
* the local pre-flight).
*/
data?: {
failed_uploads?: Array<{
code?: string;
message?: string;
file_name?: string;
}>;
};
}
/** DELETE /api/v1/files/{file_id} response. */
export interface DatasetDeleteResponse {
request_id?: string;
data?: {
deleted?: boolean;
file_id?: string;
};
}
@@ -0,0 +1,102 @@
/**
* Common pre-flight guards shared by every dataset validator.
*
* Keeping these here means new format validators only worry about structural
* concerns — they don't have to redo existence / size / extension checks,
* and we get one place to tune limits if the platform changes them.
*/
import { existsSync, statSync } from "fs";
import { extname } from "path";
import { BailianError } from "../../errors/base.ts";
import { ExitCode } from "../../errors/codes.ts";
import type { DatasetSchema, ValidationIssue, ValidationStats } from "./types.ts";
/**
* The platform caps dataset uploads at 300MB per file. `bl dataset upload`
* enforces this client-side so users learn early. Update if the platform
* raises the cap or differentiates per-purpose limits.
*/
export const MAX_DATASET_BYTES = 300 * 1024 * 1024;
export interface PreflightResult {
bytes: number;
ext: string;
}
/**
* Validate that the path exists, is a file, and (optionally) within the size
* cap. Throws a USAGE-coded BailianError on user-visible problems so callers
* fail fast with a clean exit code.
*/
export function preflight(filePath: string, maxBytes = MAX_DATASET_BYTES): PreflightResult {
if (!existsSync(filePath)) {
throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE);
}
const stat = statSync(filePath);
if (!stat.isFile()) {
throw new BailianError(`Not a regular file: ${filePath}`, ExitCode.USAGE);
}
if (stat.size === 0) {
throw new BailianError(`File is empty: ${filePath}`, ExitCode.USAGE);
}
if (stat.size > maxBytes) {
const mb = (stat.size / (1024 * 1024)).toFixed(1);
const cap = (maxBytes / (1024 * 1024)).toFixed(0);
throw new BailianError(
`File too large: ${mb}MB exceeds the ${cap}MB dataset upload cap.`,
ExitCode.USAGE,
);
}
return {
bytes: stat.size,
ext: extname(filePath).toLowerCase(),
};
}
export function makeIssue(
severity: ValidationIssue["severity"],
code: string,
message: string,
extra: Partial<Pick<ValidationIssue, "line" | "path">> = {},
): ValidationIssue {
return { severity, code, message, ...extra };
}
export function emptyStats(): ValidationStats {
return {};
}
/**
* Parse a `--schema` CLI value into a `DatasetSchema` (or `undefined` for
* auto-detect). Single source of truth for the schema vocabulary so `dataset
* validate`, `dataset upload`, and any future caller agree on accepted values
* and error wording. Throws USAGE for anything unrecognized.
*/
export function parseDatasetSchemaFlag(value: string | undefined): DatasetSchema | undefined {
if (value === undefined || value.trim() === "") return undefined;
const v = value.trim();
if (v === "chatml" || v === "dpo" || v === "cpt") return v;
throw new BailianError(
`Unsupported --schema "${value}". Supported: chatml, dpo, cpt.`,
ExitCode.USAGE,
`Omit --schema to auto-detect per record (chosen/rejected → DPO, text → CPT, else ChatML).`,
);
}
/** Produce a deterministic set of sample line indices for deep checking.
* Indices are 1-based to match what users see in editors / error messages.
*
* Strategy: front 50 + ~100 evenly spaced + last 10. Capped, deduped, sorted.
*/
export function pickSampleLines(totalLines: number, frontN = 50, midN = 100, tailN = 10): number[] {
if (totalLines <= 0) return [];
if (totalLines <= frontN + tailN) {
return Array.from({ length: totalLines }, (_, i) => i + 1);
}
const set = new Set<number>();
for (let i = 1; i <= Math.min(frontN, totalLines); i++) set.add(i);
for (let i = 0; i < tailN; i++) set.add(totalLines - i);
const step = Math.max(1, Math.ceil(totalLines / midN));
for (let i = frontN + 1; i <= totalLines - tailN; i += step) set.add(i);
return [...set].filter((n) => n >= 1 && n <= totalLines).sort((a, b) => a - b);
}
@@ -0,0 +1,16 @@
import type { ValidationIssue } from "./types.ts";
/**
* Format a single validation issue as a one-line string.
*
* Shared across every entry point that surfaces dataset validation results
* (`dataset validate`, `dataset upload`, `finetune create`) so the error
* presentation stays consistent regardless of which command ran the validator.
*/
export function formatIssue(issue: ValidationIssue): string {
const where: string[] = [];
if (issue.line !== undefined) where.push(`line ${issue.line}`);
if (issue.path) where.push(issue.path);
const tag = where.length ? ` [${where.join(" · ")}]` : "";
return ` ${issue.severity.toUpperCase()} ${issue.code}${tag}: ${issue.message}`;
}
@@ -0,0 +1,17 @@
export {
validateDataset,
pickValidator,
registerValidator,
listSupportedFormats,
} from "./registry.ts";
export { MAX_DATASET_BYTES, parseDatasetSchemaFlag } from "./common.ts";
export { formatIssue } from "./format.ts";
export type {
ValidatorSpec,
ValidateOpts,
DatasetSchema,
ValidationResult,
ValidationIssue,
ValidationSeverity,
ValidationStats,
} from "./types.ts";
+202
View File
@@ -0,0 +1,202 @@
/**
* JSONL validator — file-level scaffolding for the ChatML family.
*
* Per-record schema dispatch lives in `./schemas/` (`RecordSchemaSpec`). This
* module is only responsible for the two file-level passes:
* 1. Quick scan — readline pass over the entire file checking only that
* every non-empty line begins with '{' and ends with '}'. No JSON.parse.
* Catches the most common mistake: a pretty-printed JSON dumped under a
* .jsonl extension.
* 2. Sampled deep check — JSON.parse the first 50 lines, ~100 evenly spaced
* interior lines, and the last 10 lines, then hand each parsed record to
* the schema registry. `--full-validate` lifts the sampling cap.
*
* Schema scope: today the only registered schemas are ChatML (SFT) and DPO
* (preference pairs). Both share the `{messages: [...]}` core. A future
* non-ChatML JSONL purpose (e.g. an evaluation dataset with a different
* shape) ships its own `RecordSchemaSpec` and registers it — no change here.
*/
import { createReadStream } from "fs";
import { createInterface } from "readline";
import type {
ValidatorSpec,
ValidateOpts,
ValidationResult,
ValidationIssue,
DatasetSchema,
} from "./types.ts";
import { makeIssue, pickSampleLines } from "./common.ts";
import { pickRecordSchema } from "./schemas/index.ts";
interface QuickScanResult {
totalLines: number;
blankLines: number;
/** Issues from the structural pass (first non-{...} line, etc.). */
issues: ValidationIssue[];
}
async function quickScan(filePath: string, signal?: AbortSignal): Promise<QuickScanResult> {
const stream = createReadStream(filePath, { encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
const issues: ValidationIssue[] = [];
let totalLines = 0;
let blankLines = 0;
// Cap reported structural issues so a totally broken file doesn't flood
// the report; we still keep counting to report accurate stats.
const MAX_ISSUES = 20;
for await (const raw of rl) {
if (signal?.aborted) break;
totalLines++;
const line = raw.trim();
if (line.length === 0) {
blankLines++;
continue;
}
if (issues.length >= MAX_ISSUES) continue;
if (line[0] !== "{" || line[line.length - 1] !== "}") {
issues.push(
makeIssue(
"error",
"MALFORMED_LINE",
`Line does not start with '{' and end with '}'. JSONL requires one minified JSON object per line — pretty-printed JSON or arrays are not accepted here.`,
{ line: totalLines },
),
);
}
}
return { totalLines, blankLines, issues };
}
interface DeepCheckResult {
sampled: number;
issues: ValidationIssue[];
}
async function deepCheck(
filePath: string,
totalLines: number,
fullValidate: boolean,
schema: DatasetSchema | undefined,
signal?: AbortSignal,
): Promise<DeepCheckResult> {
const targetSet = fullValidate ? null : new Set(pickSampleLines(totalLines));
const issues: ValidationIssue[] = [];
let sampled = 0;
const MAX_ISSUES = 30;
const stream = createReadStream(filePath, { encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
let lineNo = 0;
for await (const raw of rl) {
if (signal?.aborted) break;
lineNo++;
if (targetSet && !targetSet.has(lineNo)) continue;
const line = raw.trim();
if (line.length === 0) continue;
sampled++;
if (issues.length >= MAX_ISSUES) continue;
let obj: unknown;
try {
obj = JSON.parse(line);
} catch (err) {
issues.push(
makeIssue("error", "MALFORMED_JSON", `JSON.parse failed: ${(err as Error).message}`, {
line: lineNo,
}),
);
continue;
}
issues.push(...inspectRecord(obj, lineNo, schema));
}
return { sampled, issues };
}
/**
* Dispatch one record to the right schema inspector via the schema registry.
* The registry decides whether the record is DPO, ChatML, or some future
* shape — this function only owns the "is this even an object?" guard so the
* downstream specs can assume a real object.
*/
function inspectRecord(obj: unknown, lineNo: number, schema?: DatasetSchema): ValidationIssue[] {
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
return [
makeIssue(
"error",
"RECORD_NOT_OBJECT",
`Each line must be a JSON object, got ${Array.isArray(obj) ? "array" : typeof obj}.`,
{ line: lineNo },
),
];
}
const record = obj as Record<string, unknown>;
const spec = pickRecordSchema(record, schema);
return spec.inspect(record, lineNo);
}
export const jsonlValidator: ValidatorSpec = {
format: "jsonl",
extensions: [".jsonl"],
async validate(filePath: string, opts: ValidateOpts): Promise<ValidationResult> {
const start = Date.now();
const quick = await quickScan(filePath, opts.signal);
if (quick.totalLines === 0 || quick.totalLines === quick.blankLines) {
return {
valid: false,
format: "jsonl",
filePath,
errors: [makeIssue("error", "EMPTY_FILE", `File contains no non-blank lines.`)],
warnings: [],
stats: {
totalRecords: 0,
sampledRecords: 0,
durationMs: Date.now() - start,
},
};
}
// Stage-1 errors (structural). If any fatal MALFORMED_LINE was emitted,
// skip the deep parse to give a focused message.
if (quick.issues.length > 0) {
return {
valid: false,
format: "jsonl",
filePath,
errors: quick.issues,
warnings: [],
stats: {
totalRecords: quick.totalLines - quick.blankLines,
sampledRecords: 0,
durationMs: Date.now() - start,
},
};
}
const deep = await deepCheck(
filePath,
quick.totalLines,
Boolean(opts.fullValidate),
opts.schema,
opts.signal,
);
const errors = deep.issues.filter((i) => i.severity === "error");
const warnings = deep.issues.filter((i) => i.severity === "warning");
return {
valid: errors.length === 0,
format: "jsonl",
filePath,
errors,
warnings,
stats: {
totalRecords: quick.totalLines - quick.blankLines,
sampledRecords: deep.sampled,
durationMs: Date.now() - start,
},
};
},
};
@@ -0,0 +1,67 @@
/**
* Validator registry — single point of truth for which formats are supported.
*
* Routing today is "extension → spec". If a future dataset purpose introduces
* a different schema under the same extension (e.g. a non-ChatML evaluation
* .jsonl), extend `pickValidator` to also accept a `purpose` discriminator
* and add purpose-specific specs to the registry — no other call site needs
* to change.
*
* To add a new format:
* 1. Create `<format>.ts` exporting a `ValidatorSpec` constant.
* 2. Import it here and append to `REGISTRY`.
* That's it. Nothing else in this folder needs to change.
*/
import { extname } from "path";
import { BailianError } from "../../errors/base.ts";
import { ExitCode } from "../../errors/codes.ts";
import { jsonlValidator } from "./jsonl.ts";
import { preflight, MAX_DATASET_BYTES } from "./common.ts";
import type { ValidatorSpec, ValidateOpts, ValidationResult } from "./types.ts";
const REGISTRY: ValidatorSpec[] = [jsonlValidator];
/** Lookup the validator that handles a given file extension. */
export function pickValidator(filePath: string): ValidatorSpec {
const ext = extname(filePath).toLowerCase();
const v = REGISTRY.find((s) => s.extensions.includes(ext));
if (!v) {
const supported = REGISTRY.flatMap((s) => s.extensions).join(", ");
throw new BailianError(
`Unsupported dataset format "${ext || "(none)"}". Supported: ${supported}`,
ExitCode.USAGE,
`Convert your data to one of the supported formats and re-run.`,
);
}
return v;
}
/** Allow tests / future plugins to inject extra validators. Idempotent. */
export function registerValidator(spec: ValidatorSpec): void {
if (REGISTRY.some((s) => s.format === spec.format)) return;
REGISTRY.push(spec);
}
/**
* Top-level entry point. Applies common pre-flight (existence/size/extension)
* then defers to the format-specific validator.
*/
export async function validateDataset(
filePath: string,
opts: ValidateOpts = {},
): Promise<ValidationResult> {
const maxBytes = opts.maxBytes ?? MAX_DATASET_BYTES;
const { bytes } = preflight(filePath, maxBytes);
const spec = pickValidator(filePath);
const result = await spec.validate(filePath, opts);
// Stitch the file size into stats if the validator didn't.
if (result.stats.bytes === undefined) {
result.stats.bytes = bytes;
}
return result;
}
/** Read-only view of the active registry — handy for tests / `--help`. */
export function listSupportedFormats(): { format: string; extensions: string[] }[] {
return REGISTRY.map((s) => ({ format: s.format, extensions: [...s.extensions] }));
}
@@ -0,0 +1,155 @@
/**
* ChatML record schema — `{"messages": [{role, content}, ...]}` (SFT).
*
* Also acts as the registry's fallback / catch-all: when auto-detect runs
* and no more specific schema matches, ChatML is selected. `inspectMessageObject`
* lives here because it is the canonical per-message check; the DPO schema
* imports it to validate `chosen` / `rejected` preference messages.
*/
import { makeIssue } from "../common.ts";
import type { ValidationIssue } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
const VALID_ROLES = new Set(["system", "user", "assistant"]);
/**
* Structural checks for a single message object `{role, content}`. Shared by
* the `messages[]` entries and the DPO `chosen` / `rejected` preference fields
* (which are each a single assistant message). Caller-supplied `path` scopes
* the issue location (e.g. `messages[2]` vs `chosen`).
*/
export function inspectMessageObject(
msg: unknown,
lineNo: number,
path: string,
): ValidationIssue[] {
const out: ValidationIssue[] = [];
if (msg === null || typeof msg !== "object" || Array.isArray(msg)) {
out.push(
makeIssue("error", "MESSAGE_NOT_OBJECT", `Message must be an object.`, {
line: lineNo,
path,
}),
);
return out;
}
const record = msg as Record<string, unknown>;
const role = record.role;
const content = record.content;
if (typeof role !== "string" || !VALID_ROLES.has(role)) {
out.push(
makeIssue(
"error",
"INVALID_ROLE",
`Invalid role "${String(role)}". Expected one of: system, user, assistant.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
if (typeof content !== "string") {
out.push(
makeIssue("error", "INVALID_CONTENT", `"content" must be a string (got ${typeof content}).`, {
line: lineNo,
path: `${path}.content`,
}),
);
}
return out;
}
/**
* Validate the ChatML core (`messages[]`) of a record. DPO calls this
* delegate for the prompt portion of its records. Hard errors are emitted as
* "error"; role-ordering / role-presence advisories are "warning".
*/
export function inspectChatMLRecord(
record: Record<string, unknown>,
lineNo: number,
): ValidationIssue[] {
const out: ValidationIssue[] = [];
const messages = record.messages;
if (!Array.isArray(messages)) {
out.push(
makeIssue(
"error",
"MISSING_MESSAGES",
`Required field "messages" is missing or not an array.`,
{ line: lineNo, path: "messages" },
),
);
return out;
}
if (messages.length === 0) {
out.push(
makeIssue("error", "EMPTY_MESSAGES", `"messages" must contain at least one entry.`, {
line: lineNo,
path: "messages",
}),
);
return out;
}
let sawSystem = false;
let lastRole: string | undefined;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const path = `messages[${i}]`;
out.push(...inspectMessageObject(msg, lineNo, path));
const role = (msg as Record<string, unknown> | null)?.role;
if (role === "system") {
if (i !== 0) {
out.push(
makeIssue(
"warning",
"SYSTEM_NOT_FIRST",
`"system" message should appear at index 0; found at index ${i}.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
sawSystem = true;
}
if (lastRole === role && (role === "user" || role === "assistant")) {
out.push(
makeIssue(
"warning",
"ROLE_NOT_ALTERNATING",
`Consecutive ${role} messages — user/assistant turns should typically alternate.`,
{ line: lineNo, path: `${path}.role` },
),
);
}
if (typeof role === "string") lastRole = role;
}
// Soft check: messages without any user role almost certainly indicate a bug.
if (!messages.some((m) => (m as Record<string, unknown>).role === "user")) {
out.push(
makeIssue("warning", "NO_USER_ROLE", `No "user" message found in this sample.`, {
line: lineNo,
path: "messages",
}),
);
}
if (sawSystem && messages.length === 1) {
out.push(
makeIssue("warning", "SYSTEM_ONLY", `Sample only contains a "system" message.`, {
line: lineNo,
path: "messages",
}),
);
}
return out;
}
/**
* ChatML / SFT schema. The auto-detect predicate is `true` so it acts as the
* registry fallback — any record that isn't picked up by a more specific
* schema (DPO etc.) falls through to ChatML.
*/
export const chatmlSchema: RecordSchemaSpec = {
name: "chatml",
detect: () => true,
inspect: inspectChatMLRecord,
};
@@ -0,0 +1,62 @@
/**
* CPT record schema — `{"text": "..."}` (continual pre-training).
*
* Unlike ChatML/DPO, CPT feeds raw continuation text rather than a
* `messages[]` conversation. The platform's CPT format is one JSON object per
* line carrying a single `text` field. This spec enforces exactly that shape
* so a CPT job (`--training-type cpt`) fails fast at validate time instead of
* being forced through the ChatML inspector and rejected for a missing
* `messages` field it was never meant to carry.
*
* Auto-detect deliberately matches only when `text` is present AND `messages`
* is absent — so an SFT record that happens to carry a `text` field still
* routes to ChatML, and a mixed record (both `text` and `messages`) is left
* for the ChatML catch-all rather than silently swallowed as CPT.
*/
import { makeIssue } from "../common.ts";
import type { ValidationIssue } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
function inspectCPTRecord(record: Record<string, unknown>, lineNo: number): ValidationIssue[] {
const out: ValidationIssue[] = [];
if (!("text" in record)) {
out.push(
makeIssue("error", "MISSING_TEXT", `Required field "text" is missing.`, {
line: lineNo,
path: "text",
}),
);
return out;
}
const text = record.text;
if (typeof text !== "string") {
out.push(
makeIssue("error", "INVALID_TEXT", `"text" must be a string (got ${typeof text}).`, {
line: lineNo,
path: "text",
}),
);
return out;
}
if (text.trim().length === 0) {
out.push(
makeIssue("error", "EMPTY_TEXT", `"text" must not be empty / whitespace-only.`, {
line: lineNo,
path: "text",
}),
);
}
return out;
}
/**
* CPT schema. Auto-detect: a record is treated as CPT if it carries a `text`
* field and no `messages` field. Placed after DPO (which keys off
* chosen/rejected) and before ChatML (the catch-all), so the three schemas
* partition cleanly by their distinguishing field.
*/
export const cptSchema: RecordSchemaSpec = {
name: "cpt",
detect: (record) => "text" in record && !("messages" in record),
inspect: inspectCPTRecord,
};
@@ -0,0 +1,82 @@
/**
* DPO record schema — `{"messages": [...], "chosen": {role,content}, "rejected": {...}}`.
*
* DPO is a superset of ChatML: it carries the same `messages[]` prompt plus
* a preference pair. So this spec delegates the prompt validation to the
* ChatML inspector and only adds the chosen / rejected checks on top. If the
* prompt is too broken to inspect (no `messages[]`), the preference checks
* are skipped to keep the report focused — matching the original early-return
* semantics.
*/
import { makeIssue } from "../common.ts";
import type { ValidationIssue } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
import { inspectChatMLRecord, inspectMessageObject } from "./chatml.ts";
function inspectDPORecord(record: Record<string, unknown>, lineNo: number): ValidationIssue[] {
const out = inspectChatMLRecord(record, lineNo);
const messages = record.messages;
if (!Array.isArray(messages) || messages.length === 0) return out;
const hasChosen = "chosen" in record;
const hasRejected = "rejected" in record;
if (!hasChosen) {
out.push(
makeIssue("error", "MISSING_CHOSEN", `DPO record is missing the "chosen" preference.`, {
line: lineNo,
path: "chosen",
}),
);
}
if (!hasRejected) {
out.push(
makeIssue("error", "MISSING_REJECTED", `DPO record is missing the "rejected" preference.`, {
line: lineNo,
path: "rejected",
}),
);
}
if (hasChosen) {
out.push(...inspectMessageObject(record.chosen, lineNo, "chosen"));
const role = (record.chosen as Record<string, unknown> | null)?.role;
if (typeof role === "string" && role !== "assistant") {
out.push(
makeIssue(
"warning",
"PREFERENCE_ROLE_NOT_ASSISTANT",
`"chosen" role should be "assistant" (got "${role}").`,
{ line: lineNo, path: "chosen.role" },
),
);
}
}
if (hasRejected) {
out.push(...inspectMessageObject(record.rejected, lineNo, "rejected"));
const role = (record.rejected as Record<string, unknown> | null)?.role;
if (typeof role === "string" && role !== "assistant") {
out.push(
makeIssue(
"warning",
"PREFERENCE_ROLE_NOT_ASSISTANT",
`"rejected" role should be "assistant" (got "${role}").`,
{ line: lineNo, path: "rejected.role" },
),
);
}
}
return out;
}
/**
* DPO schema. Auto-detect: a record is treated as DPO if it carries either
* `chosen` or `rejected` — we deliberately match on EITHER (not both) so a
* record that has only one of the pair still hits the DPO inspector and gets
* a precise "missing rejected" / "missing chosen" error instead of falling
* through to ChatML where the preference fields would be silently ignored.
*/
export const dpoSchema: RecordSchemaSpec = {
name: "dpo",
detect: (record) => "chosen" in record || "rejected" in record,
inspect: inspectDPORecord,
};
@@ -0,0 +1,46 @@
/**
* Record-schema registry — single point of truth for "which schemas can a
* `.jsonl` record carry, and how do we dispatch to the right one?"
*
* Routing:
* - When `--schema <name>` is given, dispatch by exact name.
* - When `--schema` is omitted, walk the registry in declared order and
* pick the first entry whose `detect()` returns true. ChatML is the
* catch-all fallback (its detect is `true`), so place more specific
* schemas BEFORE it.
*
* Adding a new schema: see `types.ts` for the recipe.
*/
import type { DatasetSchema } from "../types.ts";
import type { RecordSchemaSpec } from "./types.ts";
import { chatmlSchema } from "./chatml.ts";
import { cptSchema } from "./cpt.ts";
import { dpoSchema } from "./dpo.ts";
// Order matters: DPO (chosen/rejected) and CPT (text) before ChatML (the
// catch-all fallback). Each keys off a distinguishing field so the three
// partition cleanly — DPO never looks like CPT, etc.
export const RECORD_SCHEMAS: RecordSchemaSpec[] = [dpoSchema, cptSchema, chatmlSchema];
/**
* Pick the right schema for a single parsed record.
* - explicit `schema` → exact-name lookup (USAGE-safe: the CLI parser already
* rejects unknown values via `parseDatasetSchemaFlag`, so an unknown name
* here is an internal bug and falls back to ChatML).
* - auto (`schema === undefined`) → first `detect()` match in registry order;
* falls back to ChatML when no more specific schema claims the record.
*/
export function pickRecordSchema(
record: Record<string, unknown>,
schema?: DatasetSchema,
): RecordSchemaSpec {
if (schema !== undefined) {
const found = RECORD_SCHEMAS.find((s) => s.name === schema);
if (found) return found;
// Should not happen — CLI vocabulary is enforced before we get here.
return chatmlSchema;
}
return RECORD_SCHEMAS.find((s) => s.detect(record)) ?? chatmlSchema;
}
export type { RecordSchemaSpec } from "./types.ts";
@@ -0,0 +1,30 @@
/**
* Record-schema spec — the per-record dispatcher contract for `.jsonl`.
*
* The file format registry in `registry.ts` routes "which validator owns this
* extension" (today only `jsonl.ts`). Within a single .jsonl file there can
* still be multiple *record* schemas — e.g. SFT (ChatML) vs DPO. This sub-
* registry handles that finer-grained dispatch.
*
* Adding a new record schema:
* 1. Create `<schema>.ts` exporting a `RecordSchemaSpec` constant.
* 2. Append it to `RECORD_SCHEMAS` (more specific schemas FIRST so auto-
* detect picks them before the fallback).
* 3. Add the schema id to the `DatasetSchema` union in `../types.ts` and to
* `parseDatasetSchemaFlag` in `../common.ts`.
* That's it — `jsonl.ts` only knows about the dispatch interface.
*/
import type { DatasetSchema, ValidationIssue } from "../types.ts";
export interface RecordSchemaSpec {
/** Schema id — must match a value in the `DatasetSchema` union. */
name: DatasetSchema;
/**
* Auto-detect this schema for an arbitrary record when no `--schema` is
* given. The registry walks entries in declared order and picks the first
* match, so place more specific schemas before more general ones.
*/
detect(record: Record<string, unknown>): boolean;
/** Run schema-specific structural checks on the parsed record. */
inspect(record: Record<string, unknown>, lineNo: number): ValidationIssue[];
}
@@ -0,0 +1,81 @@
/**
* Validator types — the registry contract that every format adheres to.
*
* Design (Plan B from the architecture review):
* - A `ValidatorSpec` is a plain object, not a class. Adding a new format =
* one new file exporting one constant + one line in the registry.
* - Common pre-flight checks (existence, size, extension) live in `common.ts`
* and are applied by `validateDataset` before the format-specific validator
* runs, so individual specs only handle structural concerns.
*/
export interface ValidateOpts {
/** When true, validators should do exhaustive checks (e.g. parse every line). */
fullValidate?: boolean;
/** Optional max bytes override (defaults to 300MB at the registry level). */
maxBytes?: number;
/** Optional abort signal for long-running scans. */
signal?: AbortSignal;
/**
* Record-schema selector for formats that carry more than one schema under
* the same extension. Today only the `.jsonl` ChatML family honors it:
* - `"chatml"` — `{messages: [...]}` (SFT). `chosen`/`rejected` ignored.
* - `"dpo"` — `{messages: [...], chosen: {role,content}, rejected: {...}}`.
* Every record MUST carry `chosen` + `rejected`.
* - `"cpt"` — `{text: "..."}` (continual pre-training). Raw text only,
* no `messages[]`.
* - `undefined` — auto-detect per record: a record with `chosen` or
* `rejected` is validated as DPO, one with `text` (and no
* `messages`) as CPT, otherwise as ChatML.
* `finetune create` sets this from `--training-type` (dpo* → "dpo",
* cpt → "cpt") so a malformed dataset fails at validate time, not on the
* platform ten minutes in.
*/
schema?: DatasetSchema;
}
/** The schemas a `.jsonl` record can be validated against. */
export type DatasetSchema = "chatml" | "dpo" | "cpt";
export type ValidationSeverity = "error" | "warning";
export interface ValidationIssue {
severity: ValidationSeverity;
/** Stable machine-readable key, e.g. "EMPTY_FILE", "MALFORMED_JSON". */
code: string;
/** Human-readable message. */
message: string;
/** 1-indexed line number for line-oriented formats. */
line?: number;
/** Optional path inside the offending row, e.g. "messages[2].role". */
path?: string;
}
export interface ValidationStats {
/** Total observed records (rows / samples / messages, depending on format). */
totalRecords?: number;
/** Records actually deep-checked (sampled). */
sampledRecords?: number;
/** Total file bytes. */
bytes?: number;
/** Wall time spent in the scan (ms). */
durationMs?: number;
}
export interface ValidationResult {
valid: boolean;
format: string;
filePath: string;
errors: ValidationIssue[];
warnings: ValidationIssue[];
stats: ValidationStats;
}
export interface ValidatorSpec {
/** Human-readable format identifier, e.g. "jsonl". */
format: string;
/** Lower-cased file extensions handled by this validator (include dot). */
extensions: string[];
/** Format-specific check. Pre-flight (existence/size) is applied by the registry. */
validate(filePath: string, opts: ValidateOpts): Promise<ValidationResult>;
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Model deployment HTTP API wrappers.
*
* Thin functions over `requestJson`. They return the parsed body verbatim
* (snake_case) so callers can decide how to surface fields.
*/
import { requestJson } from "../client/http.ts";
import {
deploymentsEndpoint,
deploymentEndpoint,
deploymentScaleEndpoint,
deploymentUpdateEndpoint,
deploymentsModelsEndpoint,
} from "../client/endpoints.ts";
import type { Config } from "../config/schema.ts";
import type {
CreateDeploymentRequest,
CreateDeploymentResponse,
ListDeploymentsResponse,
GetDeploymentResponse,
DeleteDeploymentResponse,
ListDeployableModelsResponse,
ScaleDeploymentRequest,
ScaleDeploymentResponse,
UpdateDeploymentRequest,
UpdateDeploymentResponse,
} from "./types.ts";
/** POST /api/v1/deployments */
export async function createDeployment(
config: Config,
body: CreateDeploymentRequest,
signal?: AbortSignal,
): Promise<CreateDeploymentResponse> {
const url = deploymentsEndpoint(config.baseUrl);
return requestJson<CreateDeploymentResponse>(config, {
url,
method: "POST",
body,
signal,
});
}
export interface ListDeploymentsParams {
pageNo?: number;
pageSize?: number;
status?: string;
signal?: AbortSignal;
}
/** GET /api/v1/deployments */
export async function listDeployments(
config: Config,
params: ListDeploymentsParams = {},
): Promise<ListDeploymentsResponse> {
const qs = new URLSearchParams();
if (params.pageNo !== undefined) qs.set("page_no", String(params.pageNo));
if (params.pageSize !== undefined) qs.set("page_size", String(params.pageSize));
if (params.status) qs.set("status", params.status);
const base = deploymentsEndpoint(config.baseUrl);
const url = qs.toString() ? `${base}?${qs.toString()}` : base;
return requestJson<ListDeploymentsResponse>(config, {
url,
method: "GET",
signal: params.signal,
});
}
/** GET /api/v1/deployments/{deployed_model} */
export async function getDeployment(
config: Config,
deployedModel: string,
signal?: AbortSignal,
): Promise<GetDeploymentResponse> {
const url = deploymentEndpoint(config.baseUrl, deployedModel);
return requestJson<GetDeploymentResponse>(config, {
url,
method: "GET",
signal,
});
}
/** DELETE /api/v1/deployments/{deployed_model} */
export async function deleteDeployment(
config: Config,
deployedModel: string,
signal?: AbortSignal,
): Promise<DeleteDeploymentResponse> {
const url = deploymentEndpoint(config.baseUrl, deployedModel);
return requestJson<DeleteDeploymentResponse>(config, {
url,
method: "DELETE",
signal,
});
}
export interface ListDeployableModelsParams {
pageNo?: number;
pageSize?: number;
/** Catalog version filter, e.g. "v1.0". */
version?: string;
/** Source filter: "custom" (fine-tuned outputs) | "public" | …. */
modelSource?: string;
signal?: AbortSignal;
}
/** GET /api/v1/deployments/models */
export async function listDeployableModels(
config: Config,
params: ListDeployableModelsParams = {},
): Promise<ListDeployableModelsResponse> {
const qs = new URLSearchParams();
if (params.pageNo !== undefined) qs.set("page_no", String(params.pageNo));
if (params.pageSize !== undefined) qs.set("page_size", String(params.pageSize));
if (params.version) qs.set("version", params.version);
if (params.modelSource) qs.set("model_source", params.modelSource);
const base = deploymentsModelsEndpoint(config.baseUrl);
const url = qs.toString() ? `${base}?${qs.toString()}` : base;
return requestJson<ListDeployableModelsResponse>(config, {
url,
method: "GET",
signal: params.signal,
});
}
/** PUT /api/v1/deployments/{deployed_model}/scale */
export async function scaleDeployment(
config: Config,
deployedModel: string,
body: ScaleDeploymentRequest,
signal?: AbortSignal,
): Promise<ScaleDeploymentResponse> {
const url = deploymentScaleEndpoint(config.baseUrl, deployedModel);
return requestJson<ScaleDeploymentResponse>(config, {
url,
method: "PUT",
body,
signal,
});
}
/**
* PUT /api/v1/deployments/{deployed_model}/update
*
* Update rate limits. At least one of `rpm_limit` / `tpm_limit` must be set.
*/
export async function updateDeployment(
config: Config,
deployedModel: string,
body: UpdateDeploymentRequest,
signal?: AbortSignal,
): Promise<UpdateDeploymentResponse> {
const url = deploymentUpdateEndpoint(config.baseUrl, deployedModel);
return requestJson<UpdateDeploymentResponse>(config, {
url,
method: "PUT",
body,
signal,
});
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./api.ts";
export * from "./types.ts";
+239
View File
@@ -0,0 +1,239 @@
/**
* Model-deployment API types.
*
* Maps DashScope `/api/v1/deployments` request/response shapes (snake_case
* preserved verbatim — callers decide how to surface fields).
*/
/** A single deployment record as returned by the platform. */
export interface Deployment {
/** Unique deployed-model identifier — used as the `model` parameter when invoking the deployed model. */
deployed_model?: string;
/** Human-friendly display name set at creation time. */
name?: string;
/** Underlying model identifier (e.g. fine-tuned output or catalog model). */
model_name?: string;
/** Catalog base model. */
base_model?: string;
/** PENDING | RUNNING | STOPPED | FAILED */
status?: string;
/** Billing plan: mu | cu | ptu | lora (Token-billed). */
plan?: string;
/** Spec descriptor for MU plan, e.g. "MU1". */
model_unit_spec?: string;
/** Charge type, e.g. "post_paid". */
charge_type?: string;
/** Capacity in plan units. */
capacity?: number;
base_capacity?: number;
ready_capacity?: number;
/** Rate limits (per minute). */
rpm_limit?: number;
tpm_limit?: number;
/** PTU-only token-rate limits. */
input_tpm?: number;
output_tpm?: number;
enable_thinking?: boolean;
max_context_length?: number;
workspace_id?: string;
creator?: string;
modifier?: string;
gmt_create?: string;
gmt_modified?: string;
/** Free-form additional fields are preserved by callers. */
[k: string]: unknown;
}
/** A single deployable model record (GET /deployments/models). */
export interface DeployableModel {
model_name?: string;
base_model?: string;
/** custom | public | base | … */
model_source?: string;
/** Supported plans for `custom` (fine-tuned) models, e.g. ["mu","lora"]. */
supported_plans?: string[];
/**
* Nested plan info for `base` (catalog) models. Each entry describes one
* plan and (when applicable) its deployment templates.
* - plan: "mu" | "ptu_v2" | "cu" | …
* - templates: required when plan="mu" — picks deploy_spec / charge_type / role configs
* - cu_specs: required when plan="cu" — light/basic etc
*/
plans?: Array<{
plan?: string;
templates?: Array<DeployableTemplate>;
cu_specs?: string[];
[k: string]: unknown;
}>;
display_name?: string;
description?: string;
version?: string;
status?: string;
gmt_create?: string;
gmt_modified?: string;
[k: string]: unknown;
}
/** A single deployment template (only used by `plan=mu` base models). */
export interface DeployableTemplate {
template_id?: string;
template_name?: string;
template_desc?: string;
/** pre_paid | post_paid */
charge_type?: string;
/** SYSTEM | CUSTOM */
template_source?: string;
/** COUPLED | SEPERATED */
template_type?: string;
template_version?: string;
deploy_spec?: string;
/** Role-specific resource specs. Either `unified` (COUPLED) or `prefill` + `decode` (SEPERATED). */
roles?: {
unified?: {
model_unit_spec?: string;
capacity_unit_per_instance?: number;
capacity_unit_init?: number;
};
prefill?: {
model_unit_spec?: string;
capacity_unit_per_instance?: number;
capacity_unit_init?: number;
};
decode?: {
model_unit_spec?: string;
capacity_unit_per_instance?: number;
capacity_unit_init?: number;
};
[k: string]: unknown;
};
[k: string]: unknown;
}
/** POST /api/v1/deployments request body. */
export interface CreateDeploymentRequest {
/** Required. The catalog or fine-tuned model identifier. */
model_name: string;
/** Required. Display name shown in the console. */
name: string;
/** Required. Billing plan: mu | cu | ptu | lora. CLI defaults to "lora". */
plan: string;
/** Required by API even for token-billed (lora) plans where it is ignored — CLI injects 1. */
capacity?: number;
/** Optional template id for advanced configurations. */
template_id?: string;
/**
* PTU capacity (provisioned throughput limits). Only effective when
* `plan === "ptu"`. The doc says this defaults to 10000/1000 when omitted,
* but the platform currently rejects creation without it ("Miss ptu capacity
* info"), so the CLI treats it as required for ptu.
*/
ptu_capacity?: PtuCapacity;
/** Future-compat: arbitrary additional fields are forwarded as-is. */
[k: string]: unknown;
}
/** PTU throughput limits — only used when `plan === "ptu"`. */
export interface PtuCapacity {
/** Max input tokens per minute (all models). */
input_tpm?: number;
/** Max output tokens per minute (all models). */
output_tpm?: number;
/** Max thinking-output tokens per minute (some models only). */
thinking_output_tpm?: number;
}
/** POST /api/v1/deployments response. */
export interface CreateDeploymentResponse {
request_id?: string;
output?: Deployment;
data?: Deployment;
}
/** GET /api/v1/deployments response. */
export interface ListDeploymentsResponse {
request_id?: string;
output?: {
deployments?: Deployment[];
total?: number;
page_no?: number;
page_size?: number;
[k: string]: unknown;
};
data?: {
deployments?: Deployment[];
total?: number;
page_no?: number;
page_size?: number;
[k: string]: unknown;
};
}
/** GET /api/v1/deployments/{deployed_model} response. */
export interface GetDeploymentResponse {
request_id?: string;
output?: Deployment;
data?: Deployment;
}
/** DELETE /api/v1/deployments/{deployed_model} response. */
export interface DeleteDeploymentResponse {
request_id?: string;
output?: { deleted?: boolean; deployed_model?: string; [k: string]: unknown };
data?: { deleted?: boolean; deployed_model?: string; [k: string]: unknown };
}
/** GET /api/v1/deployments/models response. */
export interface ListDeployableModelsResponse {
request_id?: string;
output?: {
models?: DeployableModel[];
total?: number;
page_no?: number;
page_size?: number;
[k: string]: unknown;
};
data?: {
models?: DeployableModel[];
total?: number;
page_no?: number;
page_size?: number;
[k: string]: unknown;
};
}
/** PUT /api/v1/deployments/{deployed_model}/scale request body. */
export interface ScaleDeploymentRequest {
/** New capacity in plan units. Server-side constraint: integer multiple of `base_capacity`, < 1000. */
capacity?: number;
/** PTU-only token-rate adjustments. */
input_tpm?: number;
output_tpm?: number;
[k: string]: unknown;
}
/** PUT /api/v1/deployments/{deployed_model}/scale response. */
export interface ScaleDeploymentResponse {
request_id?: string;
output?: Deployment;
data?: Deployment;
}
/**
* PUT /api/v1/deployments/{deployed_model} request body.
*
* Update rate limits — at least one of `rpm_limit` / `tpm_limit` is required.
* - rpm_limit: requests per minute
* - tpm_limit: tokens per minute
*/
export interface UpdateDeploymentRequest {
rpm_limit?: number;
tpm_limit?: number;
[k: string]: unknown;
}
/** PUT /api/v1/deployments/{deployed_model} response. */
export interface UpdateDeploymentResponse {
request_id?: string;
output?: Deployment;
data?: Deployment;
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Fine-tune job HTTP API wrappers.
*
* Thin functions over `requestJson`. They return the parsed body verbatim
* (snake_case) so callers can decide how to surface fields.
*/
import { requestJson } from "../client/http.ts";
import {
finetuneJobsEndpoint,
finetuneJobEndpoint,
finetuneCancelEndpoint,
finetuneLogsEndpoint,
finetuneCheckpointsEndpoint,
finetuneExportEndpoint,
} from "../client/endpoints.ts";
import type { Config } from "../config/schema.ts";
import type {
CreateFineTuneRequest,
CreateFineTuneResponse,
ListFineTunesResponse,
GetFineTuneResponse,
CancelFineTuneResponse,
DeleteFineTuneResponse,
GetFineTuneLogsResponse,
ListCheckpointsResponse,
ExportCheckpointResponse,
} from "./types.ts";
/** POST /api/v1/fine-tunes */
export async function createFineTune(
config: Config,
body: CreateFineTuneRequest,
signal?: AbortSignal,
): Promise<CreateFineTuneResponse> {
const url = finetuneJobsEndpoint(config.baseUrl);
return requestJson<CreateFineTuneResponse>(config, {
url,
method: "POST",
body,
signal,
});
}
export interface ListFineTunesParams {
pageNo?: number;
pageSize?: number;
status?: string;
signal?: AbortSignal;
}
/** GET /api/v1/fine-tunes */
export async function listFineTunes(
config: Config,
params: ListFineTunesParams = {},
): Promise<ListFineTunesResponse> {
const qs = new URLSearchParams();
if (params.pageNo !== undefined) qs.set("page_no", String(params.pageNo));
if (params.pageSize !== undefined) qs.set("page_size", String(params.pageSize));
if (params.status) qs.set("status", params.status);
const base = finetuneJobsEndpoint(config.baseUrl);
const url = qs.toString() ? `${base}?${qs.toString()}` : base;
return requestJson<ListFineTunesResponse>(config, {
url,
method: "GET",
signal: params.signal,
});
}
/** GET /api/v1/fine-tunes/{job_id} */
export async function getFineTune(
config: Config,
jobId: string,
signal?: AbortSignal,
): Promise<GetFineTuneResponse> {
const url = finetuneJobEndpoint(config.baseUrl, jobId);
return requestJson<GetFineTuneResponse>(config, { url, method: "GET", signal });
}
/** POST /api/v1/fine-tunes/{job_id}/cancel */
export async function cancelFineTune(
config: Config,
jobId: string,
signal?: AbortSignal,
): Promise<CancelFineTuneResponse> {
const url = finetuneCancelEndpoint(config.baseUrl, jobId);
return requestJson<CancelFineTuneResponse>(config, { url, method: "POST", signal });
}
/** DELETE /api/v1/fine-tunes/{job_id} */
export async function deleteFineTune(
config: Config,
jobId: string,
signal?: AbortSignal,
): Promise<DeleteFineTuneResponse> {
const url = finetuneJobEndpoint(config.baseUrl, jobId);
return requestJson<DeleteFineTuneResponse>(config, { url, method: "DELETE", signal });
}
export interface GetFineTuneLogsParams {
pageNo?: number;
pageSize?: number;
signal?: AbortSignal;
}
/** GET /api/v1/fine-tunes/{job_id}/logs */
export async function getFineTuneLogs(
config: Config,
jobId: string,
params: GetFineTuneLogsParams = {},
): Promise<GetFineTuneLogsResponse> {
const qs = new URLSearchParams();
if (params.pageNo !== undefined) qs.set("page_no", String(params.pageNo));
if (params.pageSize !== undefined) qs.set("page_size", String(params.pageSize));
const base = finetuneLogsEndpoint(config.baseUrl, jobId);
const url = qs.toString() ? `${base}?${qs.toString()}` : base;
return requestJson<GetFineTuneLogsResponse>(config, {
url,
method: "GET",
signal: params.signal,
});
}
/** GET /api/v1/fine-tunes/{job_id}/checkpoints */
export async function listCheckpoints(
config: Config,
jobId: string,
signal?: AbortSignal,
): Promise<ListCheckpointsResponse> {
const url = finetuneCheckpointsEndpoint(config.baseUrl, jobId);
return requestJson<ListCheckpointsResponse>(config, { url, method: "GET", signal });
}
/**
* GET /api/v1/fine-tunes/{job_id}/export/{checkpoint}?model_name={name}
*
* Publishes a training checkpoint as a deployable model — required before
* `bl deploy create` can target it. The platform may auto-export the best
* checkpoint on SUCCEEDED, but explicit export is the canonical path.
*/
export async function exportCheckpoint(
config: Config,
jobId: string,
checkpoint: string,
modelName: string,
signal?: AbortSignal,
): Promise<ExportCheckpointResponse> {
const qs = new URLSearchParams();
qs.set("model_name", modelName);
const base = finetuneExportEndpoint(config.baseUrl, jobId, checkpoint);
const url = `${base}?${qs.toString()}`;
return requestJson<ExportCheckpointResponse>(config, { url, method: "GET", signal });
}
+120
View File
@@ -0,0 +1,120 @@
import type { Config } from "../config/schema.ts";
import { fetchModelList } from "../console/models.ts";
/**
* Training-type vocabulary exposed to users.
*
* Convention: the bare method name is **full-parameter** tuning; the `-lora`
* suffix is the LoRA variant. This holds for `sft` and `dpo` (both have a
* full + lora pair). `cpt` is the exception — the platform only supports
* full-parameter CPT (no `cpt-lora` exists server-side), so it has no lora
* sibling.
*
* Each CLI value maps 1:1 to a server `training_type`. The mapping happens at
* the interface boundary (request body), so the rest of the CLI never sees the
* raw server strings (`efficient_sft`, `dpo_full`, ...).
*/
export const TRAINING_TYPE_MAP = {
sft: { server: "sft", method: "sft", variant: "full" },
"sft-lora": { server: "efficient_sft", method: "sft", variant: "lora" },
dpo: { server: "dpo_full", method: "dpo", variant: "full" },
"dpo-lora": { server: "dpo_lora", method: "dpo", variant: "lora" },
cpt: { server: "cpt", method: "cpt", variant: "full" },
} as const satisfies Record<string, { server: string; method: string; variant: string }>;
export type TrainingTypeCli = keyof typeof TRAINING_TYPE_MAP;
/** All accepted CLI training-type values (for whitelisting / help text). */
export const TRAINING_TYPES_CLI: readonly TrainingTypeCli[] = Object.keys(
TRAINING_TYPE_MAP,
) as TrainingTypeCli[];
/** Default training type when `--training-type` is omitted. */
export const DEFAULT_TRAINING_TYPE: TrainingTypeCli = "sft-lora";
/** Subset of `supports` relevant to training capability. */
interface ModelSupports {
sft?: boolean;
dpo?: boolean;
cpt?: boolean;
[key: string]: unknown;
}
/**
* A model record's training-capability fields. The full listFoundationModels
* item carries many more fields; only these are consulted here.
*/
export interface ModelCapability {
model?: string;
supports?: ModelSupports;
trainingTypes?: Record<string, string[]>;
[key: string]: unknown;
}
/** True when `value` is one of the accepted CLI training types. */
export function isTrainingTypeCli(value: string): value is TrainingTypeCli {
return value in TRAINING_TYPE_MAP;
}
/** Map a CLI training type to the server `training_type` for the request body. */
export function toServerTrainingType(value: TrainingTypeCli): string {
return TRAINING_TYPE_MAP[value].server;
}
/** The (method, variant) pair a CLI training type resolves to. */
export function trainingTypeMethodVariant(value: TrainingTypeCli): {
method: string;
variant: string;
} {
const { method, variant } = TRAINING_TYPE_MAP[value];
return { method, variant };
}
/**
* Whether a model supports the given CLI training type.
*
* A model supports `<method>[-lora]` when both:
* 1. `supports.<method> === true` (the high-level capability gate), and
* 2. `trainingTypes.<method>` includes the corresponding variant
* (`full` for the bare name, `lora` for the `-lora` suffix).
*/
export function modelSupportsTrainingType(
model: ModelCapability | undefined | null,
value: TrainingTypeCli,
): boolean {
if (!model) return false;
const { method, variant } = TRAINING_TYPE_MAP[value];
if (model.supports?.[method] !== true) return false;
const variants = model.trainingTypes?.[method];
return Array.isArray(variants) && variants.includes(variant);
}
/**
* Every CLI training type a model supports, in canonical order
* (sft, sft-lora, dpo, dpo-lora, cpt). Empty when the model carries no
* capability metadata or supports none.
*/
export function listSupportedTrainingTypes(
model: ModelCapability | undefined | null,
): TrainingTypeCli[] {
if (!model) return [];
return TRAINING_TYPES_CLI.filter((value) => modelSupportsTrainingType(model, value));
}
/**
* Fetch a single model's foundation metadata by name (console gateway
* `listFoundationModels` with a `name` filter). No console login required —
* `listFoundationModels` is a public API, so only a DashScope API key is needed.
*
* Returns the first exact-model match, or `null` when nothing matches (the
* server's `name` filter is a substring match, so we additionally require an
* exact `model` equality to avoid e.g. `qwen3-8b` matching `qwen3-8b-v2`).
*/
export async function fetchModelCapability(
config: Config,
modelName: string,
): Promise<ModelCapability | null> {
const result = await fetchModelList(config, "", { name: modelName, pageSize: 20 });
const match = result.models.find((item) => (item.model as string | undefined) === modelName);
return (match as ModelCapability | undefined) ?? null;
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./types.ts";
export * from "./api.ts";
export * from "./capability.ts";
export * from "./preflight.ts";

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