mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge branch 'main' into feat/composable-cli
This commit is contained in:
@@ -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 }}"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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_/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 }] }
|
||||
: {}),
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./api.ts";
|
||||
export * from "./types.ts";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user