Compare commits

...

200 Commits

Author SHA1 Message Date
Gong Shiqi a8652f350d Merge pull request #84 from modelstudioai/chore/kscli-version-1.5.0
chore(release): align knowledge-studio-cli to 1.5.0
2026-07-01 16:08:40 +08:00
若麒 b1a0c0005d chore(release): align knowledge-studio-cli to 1.5.0
kscli joins the family lockstep version so the --knowledge stable publish
passes (validate.mjs asserts every package in ALL_PACKAGES matches
bailian-cli-core). Was 0.0.1, which blocked publish-stable --knowledge.
2026-07-01 16:06:20 +08:00
Gong Shiqi f597b94c46 Merge pull request #83 from modelstudioai/feat/composable-cli
Release 1.5.0: finetune / deploy / dataset / token-plan + composable CLI
2026-07-01 15:54:13 +08:00
若麒 750641dd0e chore(release): 1.5.0 2026-07-01 14:22:17 +08:00
若麒 11ed19723a fix(kscli): build entry key rag→kscli to fix publint path mismatch 2026-06-30 23:23:32 +08:00
若麒 848e44eb44 Merge branch 'main' into feat/composable-cli 2026-06-30 23:07:18 +08:00
gujieye 07875c309e Merge pull request #77 from modelstudioai/feat/auto-update
feat: auto-update CLI on major version gap
2026-06-30 14:15:45 +08:00
故璃 7d05649f8d Merge branch 'main' into feat/auto-update 2026-06-30 14:13:29 +08:00
gujieye fd9bba77a9 Merge pull request #80 from modelstudioai/feat/model-train
feat: add model finetune/deploy/dataset commend to support one step model training by Agent
2026-06-30 14:12:40 +08:00
故璃 0593c7eb28 Merge branch 'main' into feat/model-train 2026-06-30 14:10:47 +08:00
clark-fc c303b51b9e Merge pull request #82 from modelstudioai/feat/knowledge-publish-ci
refactor(ci): 合并知识库发布流程并支持多包发布
2026-06-26 18:04:07 +08:00
zeyu.fz 780ca6addb refactor(ci): 合并知识库发布流程并支持多包发布
- 删除了单独的 publish-knowledge.yml 工作流
- 在 publish.yml 中添加 package 输入以支持多包发布
- 根据 package 选择性传递 --knowledge 标志给发布脚本
- 更新并重命名发布任务以反映 package 区别
- 修改并扩展并发组以包含 package 维度
- 注释更新,说明 knowledge-studio-cli 通过主工作流发布并共享依赖
2026-06-26 18:01:58 +08:00
clark-fc 3375fca2f8 Merge pull request #81 from modelstudioai/feat/knowledge-publish-ci
feat(release): add --knowledge flag and publish-knowledge.yml workflow
2026-06-26 17:23:33 +08:00
zeyu.fz a966b4077f feat(release): add --knowledge flag and publish-knowledge.yml workflow
- packages.mjs: export KSCLI_PACKAGE and ALL_PACKAGES for knowledge-studio-cli
- check.mjs: support knowledge option to build/validate kscli
- validate.mjs: accept packages param, validate all packages in lockstep
- pack-scan.mjs: accept packages param
- publish-stable.mjs: refactor to iterate PACKAGES array; add --knowledge flag
- publish-channel.mjs: refactor to iterate PACKAGES array; add --knowledge flag
- New workflow publish-knowledge.yml: triggers publish with --knowledge flag

The original publish.yml (without --knowledge) publishes only core + cli.
The new publish-knowledge.yml publishes core + cli + knowledge-studio-cli.
2026-06-26 17:20:25 +08:00
若麒 9c8fe96a1f build(release): publish runtime + commands alongside core/cli; minify library builds 2026-06-26 14:17:27 +08:00
故璃 18d5c420df feat: add cpt dataset type 2026-06-25 19:59:17 +08:00
故璃 9ad85b6278 fix: test issue 2026-06-25 19:26:55 +08:00
故璃 82bdf9ed78 fix: resolve cr issue 2026-06-25 17:48:52 +08:00
故璃 4383eeb416 fix: fix variable name 2026-06-25 16:16:55 +08:00
zeyu.fz 46d8474ec1 feat(kscli): 新增 Knowledge Studio CLI 轻量级 RAG 命令行工具
- 用于阿里云 Model Studio 的知识库检索,支持 RAG(检索增强生成)场景
- 提供配置查看与设置、知识库检索、自更新功能
- 替换原 rag 子包,移除 rag 相关代码及配置
- 新增独立 package,包含完整的构建、启动和发布配置
- 添加详细的中英文 README 文档说明安装、使用与认证方式
- 配置 TypeScript 和 Vite 构建支持,确保开发体验和构建质量
- 更新根 package.json 脚本,将 rag dev 命令替换为 kscli dev
- 新增 Git 忽略文件,排除日志、构建输出等无关文件
2026-06-25 15:08:35 +08:00
故璃 1851ec85f0 feat: sync readme 2026-06-25 14:22:26 +08:00
故璃 0797b0767f Merge branch 'main' into feat/model-train 2026-06-25 14:02:23 +08:00
故璃 17f4454df4 feat: refact validator to support dpo dataset 2026-06-25 13:56:15 +08:00
故璃 e67615eabd feat: auto update 2026-06-24 20:50:30 +08:00
ls 39513200bc Merge pull request #79 from modelstudioai/token-plan-openapi
Token plan openapi
2026-06-24 17:39:00 +08:00
ls 7b5bb1c341 Merge pull request #76 from budiga/token-plan-openapi
Token plan openapi
2026-06-24 17:38:25 +08:00
故璃 e0a7c86f05 feat: update doc 2026-06-24 17:29:12 +08:00
ls 908439e3f9 Merge pull request #75 from modelstudioai/token-plan-openapi
feat(token-plan): add Token Plan organization & seats commands
2026-06-24 17:28:22 +08:00
若麒 61689ec0da build: jump to source across packages via @bailian-cli/source export condition 2026-06-24 16:14:20 +08:00
雷骏 ba78d13a52 feat: add auto update cli 2026-06-24 15:31:24 +08:00
若麒 24abdbf450 refactor(commands): decouple command paths from binary name; drop path presets
Commands no longer hardcode "bl" or their path — the runtime renders the
`<bin> <path>` prefix from each product's registry key, so shared commands
show `bl knowledge retrieve` / `rag retrieve` from one codebase. commands
package now exports only individual commands (no groups/catalog); bl and rag
each spell out their own path map. Also removes the unused export-schema command.
2026-06-24 14:40:08 +08:00
故璃 cbffe6c541 Merge branch 'main' into feat/model-train 2026-06-24 14:19:58 +08:00
故璃 d567d6af0c feat: setup model train/deploy cli commend 2026-06-24 14:01:28 +08:00
wb-liuxuehuan 33b1df01cc Merge remote-tracking branch 'upstream/token-plan-openapi' into token-plan-openapi 2026-06-24 13:02:35 +08:00
lisheng.lisheng 0ba705f194 refactor(token-plan): rename top-level command tokenplan -> token-plan
Rename the public command group from `bl tokenplan` to `bl token-plan`
for kebab-case consistency. Source directory and reference doc renamed
accordingly; remote API paths (/tokenplan/...) and internal TS
identifiers are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 12:48:19 +08:00
wb-liuxuehuan 23d409f9ff Merge remote-tracking branch 'upstream/token-plan-openapi' into token-plan-openapi 2026-06-24 12:46:53 +08:00
wb-liuxuehuan 70ccc6a447 feat(tokenplan): 修改Token Plan 命令名称及相关优化
新增 `token-plan` 相关命令,包括 `add-member`、`assign-seats`、`create-key` 和 `list-seats`,支持管理 Token Plan 组织成员和 API 密钥。更新了命令的参数处理逻辑,确保对输入参数的验证更加严格,提升了代码的可读性和健壮性。同时,更新了相关文档,提供使用示例和参数说明。
2026-06-24 12:44:14 +08:00
lisheng.lisheng b7fba7679e chore: update Node.js version to 24 2026-06-24 12:34:43 +08:00
ls 88e5b903bc Merge pull request #74 from budiga/token-plan-openapi
Token plan openapi
2026-06-24 12:33:51 +08:00
wb-liuxuehuan ba1661356f feat(tokenplan): 重构 Token Plan 命令以支持新功能
对 `tokenplan` 相关命令进行了重构,新增了 `ak-sign` 模块以支持 ACS3-HMAC-SHA256 签名,优化了参数处理逻辑,简化了对凭证的处理。更新了 `add-member`、`assign-seats`、`create-key` 和 `seats` 命令,增强了对参数的验证和处理,确保代码的可读性和健壮性。同时,新增了类型定义和工具函数以支持更好的代码结构。
2026-06-24 11:14:57 +08:00
Gong Shiqi 60c49ec1ac Merge pull request #72 from modelstudioai/chore/list-voices
feat(omni,speech): add --list-voices and fix cosyvoice voice ID
2026-06-24 10:40:10 +08:00
若麒 07c71412cf chore(release): 1.4.2 2026-06-24 10:35:26 +08:00
若麒 e2c4935e84 Merge remote-tracking branch 'origin/main' into chore/list-voices 2026-06-24 10:29:15 +08:00
Gong Shiqi 4f10b7f50c Merge pull request #71 from modelstudioai/feat/console-login-site
feat: Add default login site selection for agent
2026-06-24 10:16:20 +08:00
wb-liuxuehuan dc5a535bf3 Merge remote-tracking branch 'upstream/main' into token-plan-openapi 2026-06-24 10:12:18 +08:00
若麒 ad236e9b11 test(runtime): move unit tests from cli to runtime package 2026-06-23 19:32:21 +08:00
wb-liuxuehuan 14105547e8 fix(tokenplan): 优化参数处理逻辑
更新 `assign-seats` 和 `seats` 命令中的参数处理逻辑,简化对 AccountIds 和 StatusList 的检查,确保在缺少必要参数时抛出相应错误。同时,增强对 `--query-assigned` 参数的验证,确保其值为 'true' 或 'false'。此更改提高了代码的可读性和健壮性。
2026-06-23 18:27:12 +08:00
若麒 d971a04fb8 refactor(cli): split into runtime / commands packages for composable CLIs
Decompose the monolithic `cli` package into three layers so multiple
products can be assembled from a shared base:

- bailian-cli-runtime: framework infra (createCli, registry, args,
  output, pipeline, utils) — product-agnostic
- bailian-cli-commands: command library, grouped (base/knowledge/text/
  media/memory/misc) so each product picks the sets it needs
- packages/cli (bl): full command set; packages/rag (rag): base +
  knowledge only

Product identity (binName / clientName / npmPackage) is injected at the
createCli boundary and required there, with no per-consumer defaults.
2026-06-23 17:51:14 +08:00
wb-liuxuehuan 1590e69d67 feat(tokenplan): 更新 AccountIds 参数处理逻辑
修改 `assign-seats` 命令中的 AccountIds 参数处理,将字符串类型的 AccountIds 转换为数组。同时,更新相关的测试用例以验证新逻辑的正确性。
2026-06-23 17:42:10 +08:00
clh02467605 fd36db5cab fix: remove unsupport voice 2026-06-23 17:08:37 +08:00
wb-liuxuehuan d74686f09f feat(tokenplan): 添加 Token Plan 相关命令
新增 `tokenplan add-member`、`tokenplan assign-seats` 和 `tokenplan create-key` 命令,支持管理 Token Plan 组织成员和 API 密钥。相关文档已更新,提供使用示例和参数说明。
2026-06-23 16:51:54 +08:00
clh02467605 30a0bbbc87 fix: fixed omni e2e 2026-06-23 15:34:52 +08:00
clh02467605 9761932b4c feat(omni): add voice listing functionality and update voice options 2026-06-23 14:50:09 +08:00
wb-liuxuehuan c0d30fee3d feat(tokenplan): 添加 tokenplan seats 命令以列出订阅座位详情
新增 tokenplan seats 命令,支持分页和状态过滤,提供详细的座位信息查询功能。相关文档已更新。
2026-06-23 14:05:11 +08:00
qcq01083097 9cbd4aab85 feat: Add default login site selection for agent 2026-06-22 17:09:17 +08:00
Gong Shiqi 19c4f5f2ab Merge pull request #70 from modelstudioai/feat/switch-model
feat(video): upgrade happyhorse model from 1.0 to 1.1, video-edit has not been updated and is still 1.0.
2026-06-22 17:08:13 +08:00
若麒 af524e5487 chore(release): 1.4.1 2026-06-22 17:03:14 +08:00
clh02467605 4e025dda8d feat(video): upgrade happyhorse model from 1.0 to 1.1, video-edit has not been updated and is still 1.0.
- Update default models in bl-api pipeline from happyhorse-1.0 to 1.1
- Replace happyhorse-1.0-t2v/i2v/r2v references with 1.1 versions in commands
2026-06-22 14:30:48 +08:00
Gong Shiqi 1ffcbdd80c Merge pull request #65 from modelstudioai/feat/optimize-skill
Feat/optimize skill
2026-06-18 18:05:07 +08:00
clh02467605 8aedeca4ac chore(skill): bump skill version to 1.3.4 2026-06-18 17:50:43 +08:00
clh02467605 f5a7dd494b docs(bailian-cli): Update SKILL.md to reference new pre-flight checklist procedure 2026-06-18 15:25:27 +08:00
qcq01083097 07dafc0fd4 feat: The method to modify and update skills 2026-06-18 14:07:06 +08:00
Gong Shiqi 0cd0daa18d Merge pull request #68 from modelstudioai/release/1.4.0
Release/1.4.0
2026-06-17 21:12:36 +08:00
若麒 5e39d1abc3 docs(changelog): note video resolution/ratio flag fix 2026-06-17 21:10:40 +08:00
若麒 c3df659ef0 Merge remote-tracking branch 'origin/main' into release/1.4.0 2026-06-17 21:07:39 +08:00
若麒 1d803bb4b9 fix(skill): require version check before any bl command, ask user before upgrading 2026-06-17 20:54:36 +08:00
Gong Shiqi 847b291ccc Merge pull request #67 from modelstudioai/fix/video-params-accuracy-v2
fix(video): correct resolution/ratio flag descriptions
2026-06-17 20:29:15 +08:00
若麒 0ceb15b0be fix(video): correct resolution/ratio flag descriptions 2026-06-17 20:26:55 +08:00
故璃 dc3c02f68c fix: e2e test update logic 2026-06-17 19:51:21 +08:00
clh02467605 f1eeeff682 chore(skill): opt bailian-cli skill 2026-06-17 18:12:47 +08:00
clh02467605 f184c60357 chore(skill): opt bailian-cli skill 2026-06-17 18:10:38 +08:00
若麒 8906af8ad1 docs(readme): sync China-site-only notice to zh and cli-package READMEs 2026-06-17 17:21:25 +08:00
xxlaura 3b705f2b0d docs(readme): add China-site-only notice to Features section
Separate globally available features from China-site-only features
with a blockquote note clarifying that Knowledge base, App calls,
MCP integration, Web search, Model recommendation, Console
capabilities, and Local file auto-upload are currently exclusive
to China site (aliyun.com) account holders.
2026-06-17 16:46:49 +08:00
若麒 2260c51c7e docs(changelog): add advisor model upgrade and JSON output changes to 1.4.0 2026-06-17 16:14:17 +08:00
若麒 8c893a59ef Merge remote-tracking branch 'origin/main' into release/1.4.0 2026-06-17 16:06:14 +08:00
若麒 611ecc1e68 chore(release): 1.4.0 2026-06-17 15:58:08 +08:00
gujieye 17c1f5f7b2 Merge pull request #59 from modelstudioai/feat/update-intent-model
feat: change output & table name into English
2026-06-17 15:24:20 +08:00
故璃 7593baf4f6 Merge branch 'feat/update-intent-model' of https://github.com/modelstudioai/cli into feat/update-intent-model 2026-06-17 15:20:46 +08:00
故璃 edb34658a1 feat: temp save 2026-06-17 15:16:54 +08:00
gujieye cbeb2bf071 Merge branch 'main' into feat/update-intent-model 2026-06-17 15:03:51 +08:00
ls 1fd08fe1c8 Merge pull request #56 from modelstudioai/feat/console-gateway-region-site
feat(console): resolve gateway URL from region + site, add switchAgent
2026-06-17 14:49:28 +08:00
故璃 8cb24b719b feat: merge main 2026-06-17 14:43:13 +08:00
qcq01083097 d4c0809951 feat: resolve conflict 2026-06-17 14:43:01 +08:00
故璃 b01d35c246 Merge branch 'main' into feat/update-intent-model 2026-06-17 14:35:14 +08:00
Gong Shiqi 67ff4d2811 Merge pull request #63 from modelstudioai/feat/i18n-english-only
feat(cli): standardize user-facing CLI text to English
2026-06-17 14:27:27 +08:00
clh02467605 a830965feb feat(omni): add new voice option to omni chat command 2026-06-17 14:19:47 +08:00
故璃 14583cf6d1 feat: update access token logic 2026-06-17 11:58:09 +08:00
Gong Shiqi c32f03e8dc Merge pull request #62 from modelstudioai/feat/project-optimization
Feat/project optimization
2026-06-17 11:53:02 +08:00
lishengzxc e9feb380f5 Merge branch 'main' of github.com:modelstudioai/cli into feat/console-gateway-region-site 2026-06-17 11:45:49 +08:00
clh02467605 62ad8768e3 merge: merge main into feat/i18n-english-only 2026-06-17 11:28:24 +08:00
clh02467605 e8a40cdad0 Merge remote-tracking branch 'refs/remotes/origin/main' into feat/i18n-english-only
# Conflicts:
#	packages/cli/src/commands/knowledge/retrieve.ts
#	skills/bailian-cli/reference/knowledge.md
2026-06-17 11:26:04 +08:00
qcq01083097 9bcb66b1ff feat: Resolve conflicts 2026-06-17 10:02:00 +08:00
lishengzxc 60cad1001c chore: remove redundant "(global flag)" from option descriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-16 19:44:08 +08:00
qcq01083097 a5d078b8a0 Merge branch 'feat/console-gateway-region-site' of github.com:modelstudioai/cli into feat/console-gateway-region-site 2026-06-16 18:23:09 +08:00
qcq01083097 685c0176ff feat: clean up region remnants 2026-06-16 18:22:18 +08:00
qcq01083097 d24b41d452 feat: Remove the logic related to region 2026-06-16 18:16:10 +08:00
lishengzxc d320d36ba7 docs: add console gateway flags convention to AGENTS.md and command help
Add console global flags (--console-region, --console-site, --console-switch-agent)
to the options of all 12 commands that depend on callConsoleGateway, so they appear
in --help output. Document this as convention #4 in AGENTS.md for future commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-16 17:50:58 +08:00
故璃 f816d5cb1f Merge branch 'main' into feat/update-intent-model 2026-06-16 17:38:10 +08:00
故璃 93cecc35de feat: model recommend use english output 2026-06-16 17:37:40 +08:00
qcq01083097 a5d055f45a feat: Adjust the priority of base_url in config to be higher than that of the environment variable DASHSCOPE_BASE_URL 2026-06-16 17:16:25 +08:00
qcq01083097 631a9c1818 feat: Complete the missing changes for E2E Test 2026-06-16 16:51:04 +08:00
qcq01083097 682321247b feat: Delete invalid code 2026-06-16 16:39:05 +08:00
Gong Shiqi d82f334a99 Merge pull request #58 from modelstudioai/feat/knowledge-api-key
docs(agents): add CHANGELOG checklist to publish guide
2026-06-16 15:57:00 +08:00
若麒 f7c18276bc docs(agents): add CHANGELOG checklist to publish guide 2026-06-16 15:54:15 +08:00
故璃 c54f6a64d7 feat: model recommend use english prompt & output 2026-06-16 15:49:01 +08:00
Gong Shiqi 73143dbae2 Merge pull request #57 from modelstudioai/feat/knowledge-api-key
feat(knowledge): update deprecation notices for access key options in CLI and documentation
2026-06-16 15:11:58 +08:00
qcq01083097 cb25bc4149 feat: When console login is not performed, throw more explicit errors and prompts 2026-06-16 15:11:10 +08:00
若麒 35d681f0c7 chore(release): prepare 1.3.3 2026-06-16 15:09:32 +08:00
zeyu.fz a16afb3f0b chore(changelog): update to version 1.3.3 with improvements to CLI help output and command notes 2026-06-16 15:01:57 +08:00
qcq01083097 2ed513124e feat: Use command.skipDefaultApiKeySetup instead of NO_AUTH_SETUP to determine whether an API key is required 2026-06-16 14:59:14 +08:00
clh02467605 062bbd4052 feat(cli): standardize user-facing CLI text to English 2026-06-16 14:41:33 +08:00
lishengzxc f847476016 refactor(auth): 修改 --console 标志以简化登录命令 2026-06-16 14:15:58 +08:00
qcq01083097 83ea0dfd03 feat: Clear invalid remnants of the command "model list" 2026-06-16 13:58:34 +08:00
lishengzxc 9a5797da1e Merge branch 'feat/console-gateway-region-site' of github.com:modelstudioai/cli into feat/console-gateway-region-site 2026-06-16 13:58:07 +08:00
lishengzxc 8c398bae57 refactor(console): promote --console-region, --console-site, --console-switch-agent to global flags
Eliminate per-command --region/--site/--switch-agent duplication across 11 console gateway commands.
These values now flow through config (CLI flags → config file → defaults) and are consumed by
callConsoleGateway automatically. Also wire consoleSite into resolveConsoleOrigin so --console-site
selects the correct login URL (domestic vs international).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-16 13:56:20 +08:00
故璃 14fc293ef6 feat: opt json output & reduce Chinese table name 2026-06-16 11:39:16 +08:00
qcq01083097 2bcbf56282 feat: Fix lint errors in mcp.ts 2026-06-16 11:27:29 +08:00
qcq01083097 ce64d628bb feat: The command "config show" does not display the "region" field, but displays all fields in the "config.json" file 2026-06-16 11:08:33 +08:00
zeyu.fz 3ca8da8e75 refactor(knowledge): update deprecation notices for access key options in CLI and documentation 2026-06-15 19:57:20 +08:00
zeyu.fz 6c4ac80882 feat(cli): add support for displaying command notes in help output 2026-06-15 19:49:09 +08:00
zeyu.fz bd4644448a chore(core): 更新核心包版本至1.3.3
- 将版本号从1.3.2提升至1.3.3
- 保持其他核心包配置不变
2026-06-15 19:41:30 +08:00
zeyu.fz a0ab35acf1 refactor(knowledge): update authentication options and documentation 2026-06-15 19:22:35 +08:00
故璃 3f29f93ef5 feat: replace model 2026-06-15 14:45:38 +08:00
lishengzxc e5abd1b554 feat(auth): 更新默认控制台登录页为正式中国站地址 2026-06-15 13:53:41 +08:00
lishengzxc 4749b493da feat(auth): 更新默认控制台登录页为中国站地址 2026-06-15 13:11:49 +08:00
lishengzxc 7a65fb850c feat(auth): parse and persist workspace_id from console login callback
Adapts to bailian-cli-login af06baf which added workspace_id to the
notifyToken payload. The field is parsed from query/body and written
to config.json as workspace_id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-15 01:05:00 +08:00
lishengzxc f45b19c261 chore: remove console_gateway_url remnants from schema and tests
Field was replaced by region+site gateway resolution but ConfigFile
definition, parseConfigFile logic, and test case were left behind.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-15 01:01:26 +08:00
lishengzxc 270412d146 refactor(auth): deduplicate canRetry/validateKey between login.ts and login-console.ts
Export validateAndPersistApiKey from login-console.ts and reuse in
login.ts. Remove duplicated canRetry, RETRY_DELAY_BASE_MS, and
validateKeyAndPersist from login.ts. Unify validation model to
qwen3.7-max. Reorder base_url write before apiKey validation in
the --api-key path to avoid double read-modify-write.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-15 00:26:57 +08:00
lishengzxc 36a60a2848 chore(pnpm): 移除 vite 和 vitest 的 overrides 配置 2026-06-14 23:46:35 +08:00
lishengzxc 6c716e5120 feat(auth): add --base-url flag to bl auth login
When used with --api-key, validates the key against the specified
base URL and persists it to config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-14 23:44:17 +08:00
lishengzxc 2182a2239f refactor(auth): unify console callback persistence — validate apiKey with callback's baseUrl
Move apiKey validation into login-console.ts so it uses the baseUrl
from the same callback (not stale config). All fields are now persisted
in one place: config fields first, then apiKey validated + written.

Remove onApiKey callback indirection from runConsoleLogin signature.
Clean up debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-14 23:20:30 +08:00
lishengzxc 51d9833a3d fix(auth): parse baseUrl/consoleSite/consoleRegion/consoleSwitchAgent from POST body
These fields were only extracted from query params but the console
sends them in the JSON POST body. Add parseExtrasFromRawBody() to
handle JSON and form-urlencoded bodies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-14 22:56:54 +08:00
lishengzxc e68abb6975 refactor(auth): simplify login-console config persistence logic
Extract hasConfig variable to deduplicate the multi-field condition check,
remove the changed flag pattern in favor of direct write-through.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-14 22:35:33 +08:00
lishengzxc 067e96689a feat(console): resolve gateway URL from region + site, add switchAgent support
Console gateway URL and action are now resolved from a region + site
mapping table instead of a single hardcoded config value. Supports
cn-beijing and ap-southeast-1 with domestic/international site variants.

- Add ConsoleSite type, REGION_GATEWAYS mapping, and resolveGateway()
- Add switchAgent to cornerstoneParam for delegated access
- Add console_site, console_region, console_switch_agent to config
- Remove consoleGatewayUrl from Config (replaced by region+site resolution)
- login-console callback now persists baseUrl, site, region, switchAgent
- bl console call gains --site and --switch-agent flags
- All callers delegate region default to callConsoleGateway (no more hardcoded cn-beijing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-14 20:17:46 +08:00
Gong Shiqi 173e5a7e45 Merge pull request #55 from modelstudioai/fix/omni-audio-always400
fix(omni): use input_audio for --audio on OpenAI-compatible endpoint
2026-06-12 18:35:36 +08:00
若麒 ef7aa493e0 chore: release 1.3.2
Bump bailian-cli / bailian-cli-core to 1.3.2, sync skill version, and
document the omni --audio HTTP 400 fix (#54) in CHANGELOG. Also add the
.ogg extension to the --audio help text and reference doc.
2026-06-12 18:33:36 +08:00
clh02467605 e67acc118f fix(omni): use input_audio instead of audio_url
Fixes #54
2026-06-12 17:34:41 +08:00
Gong Shiqi a90a35ddef Merge pull request #51 from modelstudioai/fix/proxy-env-support-v2
fix: honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#35)
2026-06-12 16:15:24 +08:00
若麒 d36bc5a82f chore: release 1.3.1
Bump bailian-cli / bailian-cli-core to 1.3.1, sync skill version, and
document the HTTP_PROXY / HTTPS_PROXY / NO_PROXY fix (#35) in CHANGELOG.
2026-06-12 16:12:58 +08:00
若麒 ada7ed32fb Merge remote-tracking branch 'origin/main' into fix/proxy-env-support-v2 2026-06-12 16:07:43 +08:00
Gong Shiqi 7c1be39067 Merge pull request #53 from modelstudioai/feat/delete-apiDocs
feat: No longer expose API documentation
2026-06-12 16:05:55 +08:00
若麒 a96f3a2adf refactor: remove now-unused region threading in help printing
After dropping the API Reference line, printCommandHelp no longer reads
region, so the --region/DASHSCOPE_REGION resolution done solely for help
output is dead code. Endpoint selection via loadConfig is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:03:00 +08:00
Gong Shiqi b730a26336 Merge pull request #52 from modelstudioai/fix/e2e-test
fix: e2e test
2026-06-12 15:45:56 +08:00
qcq01083097 489ba4f843 feat: No longer expose API documentation 2026-06-12 15:35:00 +08:00
故璃 c6afc21b11 fix: e2e test 2026-06-12 14:55:52 +08:00
若麒 cc63e1ec3c chore: stop tracking .claude/scheduled_tasks.lock
It's a machine-local runtime lock file that shouldn't be in the repo;
remove it and add it to .gitignore.
2026-06-12 14:53:44 +08:00
若麒 d5fb2bfaf8 test: use leaf image prompt in video-ref r2v e2e
Switch the seed-image prompt from a cat sketch to a green leaf for a
simpler, more reliably-generated reference frame.
2026-06-12 14:53:42 +08:00
若麒 8a0de83c24 fix: honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#35)
Node's built-in fetch (undici) ignores proxy environment variables, so
bl always connected directly and failed with ECONNRESET behind a VPN or
corporate proxy. Install an EnvHttpProxyAgent as the global dispatcher at
startup, but only when a proxy variable is actually set — behavior is
unchanged otherwise. Lowercase variables take precedence over uppercase
(curl convention) and NO_PROXY is honored.

Values are trimmed and passed explicitly to work around undici reading
env vars with ??, where an empty lowercase variable (https_proxy="")
masks a configured uppercase one. Invalid proxy URLs fail with a clear
usage error instead of a stack trace, and the ECONNRESET hint now
suggests exporting HTTPS_PROXY.

Tests are fully offline and need no credentials: unit tests cover env
parsing, and the e2e test runs a minimal probe (setupProxyFromEnv + a
bare fetch) against a .invalid host through a local CONNECT proxy to
verify traffic routes through the proxy, NO_PROXY is honored, no
dispatcher is installed when unset, and invalid values error clearly.
2026-06-12 14:53:22 +08:00
clark-fc b36eaf34be Merge pull request #49 from modelstudioai/feat/knowledge-api-key
fix(core): 修复 Rerank 字段类型用于请求体中
2026-06-12 10:53:53 +08:00
Gong Shiqi abe29d16b6 Merge pull request #43 from modelstudioai/feat/auto-issue
feat: add agent-guided issue reporting workflow and bug report template
2026-06-12 10:27:07 +08:00
Gong Shiqi 017ab86b33 Merge pull request #46 from modelstudioai/feat/model-usage
feat: add model usage\quota\workspace command
2026-06-12 10:26:24 +08:00
zeyu.fz d20eea5c1c fix(core): 修复 Rerank 字段类型用于请求体中
- 将 Rerank 字段从单对象修改为对象数组以支持多重重排序配置
- 更新 API 类型定义中 Rerank 为数组类型
- 修正 CLI 命令中构造请求体时将单一 Rerank 包装为数组
- 确保传递给后端的 Rerank 参数格式正确匹配接口要求
2026-06-12 10:21:53 +08:00
故璃 b9e2d75ea0 fix: fix changelog issue 2026-06-11 14:42:55 +08:00
Gong Shiqi 2f22b333fa Merge pull request #48 from modelstudioai/feat/staged-lint-add-md
feat: Pre-commit verification to add the md file type
2026-06-11 14:26:55 +08:00
qcq01083097 8023809666 feat: Pre-commit verification to add the md file type 2026-06-11 13:45:32 +08:00
故璃 240ce9ae3e fix: fix changelog issue 2026-06-11 12:35:41 +08:00
故璃 386ff0fdc0 feat: update changelog 2026-06-11 12:29:17 +08:00
故璃 a72f0508c3 Merge branch 'main' into feat/model-usage 2026-06-11 12:26:06 +08:00
故璃 65c6a358ef feat: update changelog 2026-06-11 11:52:27 +08:00
clark-fc 3734a6e8b9 Merge pull request #45 from modelstudioai/feat/knowledge-api-key
Feat/knowledge api key
2026-06-11 11:51:45 +08:00
zeyu.fz c070699fb2 test(knowledge): 移除 API-KEY 与 AK/SK 测试相关代码 2026-06-11 11:45:13 +08:00
zeyu.fz 8604567ce4 chore(core): 更新版本号至 1.3.0 并修正文档格式 2026-06-11 11:37:04 +08:00
故璃 8b4dceafab feat: update doc 2026-06-10 19:47:17 +08:00
故璃 00b1bfe7a7 feat: update doc 2026-06-10 19:46:27 +08:00
故璃 94120d8a2b feat: sync README.ZH 2026-06-10 17:14:42 +08:00
故璃 fabf8e761d feat: sync readme 2026-06-10 17:11:13 +08:00
故璃 da0ae26120 Merge branch 'main' into feat/model-usage 2026-06-10 17:07:32 +08:00
Gong Shiqi ffc4aecce1 Merge pull request #44 from modelstudioai/fix/auto-check-version
feat: Change the version in SKILL to an optional verification
2026-06-10 17:05:47 +08:00
qcq01083097 c167bba32c style: align README links tables for vp check 2026-06-10 17:03:39 +08:00
qcq01083097 1544af1f44 feat: Change the version in SKILL to an optional verification 2026-06-10 16:54:17 +08:00
故璃 c56c394527 feat: sync README 2026-06-10 16:10:45 +08:00
故璃 418596b960 Merge branch 'main' into feat/model-usage 2026-06-10 16:05:37 +08:00
故璃 dd56b04569 feat: add usage/quota/workspace cli command 2026-06-10 16:04:39 +08:00
zeyu.fz db6ee7a5f0 feat(core): 增加CHANGELOG 2026-06-10 15:50:15 +08:00
clh02467605 6a0d39c726 refactor(docs): clean up issue reporting guidelines formatting 2026-06-10 14:45:49 +08:00
zeyu.fz 822c4e6bfe fix(cli): 更新知识检索参数兼容性提示 2026-06-10 14:32:40 +08:00
clh02467605 5137257421 docs: move issue reporting documentation to assets folder 2026-06-10 14:29:55 +08:00
zeyu.fz f90ed8a0cc feat(cli): 优化知识检索命令的rerank参数支持和请求构造 2026-06-10 14:19:17 +08:00
clh02467605 f68717527a docs: add comprehensive issue reporting documentation 2026-06-10 14:18:00 +08:00
zeyu.fz 3395858c96 fix(cli): 修复检索命令中的 rerank 参数字段名 2026-06-10 13:59:55 +08:00
qcq01083097 a3c985c84e feat: Change the version in SKILL to an optional verification 2026-06-09 16:42:15 +08:00
qcq01083097 c9f7e0b6b8 feat: update version from SKILL.md 2026-06-09 16:36:46 +08:00
zeyu.fz b5abcaefd9 docs(cli): 更新 API Key 和相关链接地址 2026-06-09 15:56:29 +08:00
zeyu.fz d93b951d92 docs(cli): 更新 API Key 和相关链接地址 2026-06-09 15:53:53 +08:00
zeyu.fz d5407ae39b Merge remote-tracking branch 'origin/main' into feat/knowledge-api-key 2026-06-09 15:29:50 +08:00
zeyu.fz 20704ff1c6 fix(cli): 优化鉴权逻辑以支持显式API-Key和AK/SK优先级
- 优先使用显式提供的API-Key进行鉴权
- 在无显式API-Key时优先采用显式AK/SK鉴权
- 保持对无显式鉴权信息情况下的自动鉴权兼容
- 重构鉴权判断逻辑以提高代码清晰度和可维护性
2026-06-09 15:25:15 +08:00
TreeLin 9742209c4c fix: add source_channel to console bare link in Links table (#42)
* fix: add source_channel to console link in README.md

* fix: add source_channel to console link in README.zh.md
2026-06-09 14:02:12 +08:00
TreeLin 3689c2644f fix: update API Key links to direct key management page (#41)
* fix: update API Key links to direct key management page

Replace /cli?source_channel=key_github& with /cn-beijing/?source_channel=key_github&tab=app#/api-key
so users land directly on the API Key management page.

* fix: update API Key links in Chinese README

Same change as English README - direct to API Key management page.
2026-06-09 13:48:17 +08:00
Gong Shiqi efa624da2b Merge pull request #38 from modelstudioai/chore/release-1.2.1
chore(release): bump version to 1.2.1
2026-06-09 00:37:05 +08:00
若麒 04e7f30dc9 chore(release): bump version to 1.2.1 2026-06-09 00:35:26 +08:00
Gong Shiqi bfb02927d1 Merge pull request #36 from modelstudioai/feat/skills-in-self-repo
feat: Migrate official skills back to this repository
2026-06-08 20:35:13 +08:00
qcq01083097 6154004e00 Merge branch 'feat/skills-in-self-repo' of github.com:modelstudioai/cli into feat/skills-in-self-repo 2026-06-08 20:31:34 +08:00
qcq01083097 a02ab374d1 feat: Add version verification before using skills 2026-06-08 20:24:44 +08:00
若麒 fbf86887b0 docs: remove redundant SKILL.md link from INSTALL.md 2026-06-08 18:59:29 +08:00
zeyu.fz 6317da8454 feat(cli): 重构知识库检索命令,支持API-KEY和AK/SK鉴权
- 增加API-KEY鉴权路径,采用DashScope协议(snake_case)请求后端接口
- 保留AK/SK鉴权路径,但打印废弃警告,采用PascalCase请求后端
- 命令参数调整,新增dense-similarity-top-k、sparse-similarity-top-k等API-KEY专用选项
- 废弃部分旧参数如顶层top-k,提醒用户改用rerank-top-n
- 统一输出格式以及静默模式下文本结果的打印逻辑优化
- 添加相关类型定义,完善请求与响应结构的类型支持
- CLI端增加dry-run模式,展示实际请求参数与地址
- E2E测试覆盖API-KEY和AK/SK两条路径,包含帮助提示、错误场景及关键参数测试
- 更新依赖的核心包导出与接口,新增knowledgeRetrieveEndpoint方法接口调用
2026-06-08 18:43:50 +08:00
若麒 ab0cf8c78e docs: rename README_CN.md to README.zh.md and add bailian-cli skill READMEs 2026-06-08 18:38:15 +08:00
qcq01083097 00934973e9 feat: The version synchronization and skills generation are executed in the pre-commit hook 2026-06-08 17:41:33 +08:00
qcq01083097 d75ddb407a feat: Version synchronization & automatic build generation skills 2026-06-08 17:12:25 +08:00
qcq01083097 252f85c717 feat: Fix the generation format of skills 2026-06-08 16:32:07 +08:00
qcq01083097 db5a96158d feat: Migrate official skills back to this repository 2026-06-08 15:29:51 +08:00
303 changed files with 19908 additions and 2555 deletions
+185
View File
@@ -0,0 +1,185 @@
name: Bug Report
description: Report a bug in bailian-cli (bl)
title: "[bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug.
**Before submitting:** search [open issues](https://github.com/modelstudioai/cli/issues?q=is%3Aissue+is%3Aopen) for duplicates.
**Security:** redact API keys (`sk-...`), console tokens, internal URLs, and business prompts before pasting output.
- type: markdown
attributes:
value: |
## Environment
- type: input
id: cli-version
attributes:
label: CLI version
description: "Output of bl --version (use only X.Y.Z, without the bl prefix)"
placeholder: "1.2.1"
validations:
required: true
- type: input
id: skill-version
attributes:
label: Skill version (optional)
description: "metadata.version from the installed bailian-cli skill, if applicable"
placeholder: "1.2.1"
- type: input
id: node-version
attributes:
label: Node version
description: "Output of node --version"
placeholder: "v22.12.0"
validations:
required: true
- type: input
id: os
attributes:
label: OS
description: "e.g. darwin 24.5.0, Ubuntu 22.04"
placeholder: "darwin 24.5.0"
validations:
required: true
- type: dropdown
id: region
attributes:
label: Region
description: "From bl auth status or bl config show"
options:
- cn
- us
- intl
- unknown
validations:
required: true
- type: markdown
attributes:
value: |
## Reproduction
- type: textarea
id: reproduce-command
attributes:
label: Command to reproduce
description: "Exact command that failed. Redact --api-key, sk-..., and sensitive prompts."
render: shell
placeholder: |
bl video generate --prompt "sunset" --download out.mp4 --verbose
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should have happened?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead?
validations:
required: true
- type: markdown
attributes:
value: |
## Error output
Paste stderr as printed by `bl`. Include `Request ID` when present — it helps us trace logs.
- type: textarea
id: full-output
attributes:
label: Full output
description: Error, Hint, Status, Request ID, Exit code, etc.
render: shell
placeholder: |
Error: Generation completed but no images returned.
Hint: ...
Status: HTTP 200 (...)
Request ID: ...
Exit code: 1
validations:
required: true
- type: textarea
id: json-error
attributes:
label: JSON error (optional)
description: "Re-run with --output json and paste the error object if available"
render: json
placeholder: |
{
"error": {
"code": 1,
"message": "...",
"http_status": 200,
"api_code": "...",
"request_id": "..."
}
}
- type: markdown
attributes:
value: |
## Troubleshooting already tried
- type: checkboxes
id: already-tried
attributes:
label: Already tried
options:
- label: "bl update and skill version aligned with CLI"
- label: "bl auth status OK for this command"
- label: "Different network / region — still reproduces"
- type: markdown
attributes:
value: |
## Additional context
- type: dropdown
id: frequency
attributes:
label: How often does this happen?
options:
- Always
- Intermittent
- Once
validations:
required: true
- type: dropdown
id: invoked-via
attributes:
label: How was bl invoked?
options:
- Terminal (manual)
- Agent (Cursor, Claude, etc.)
- CI / script
- Other
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes (optional)
description: Anything else that might help — related issues, screenshots, minimal repro repo, etc.
+2
View File
@@ -25,6 +25,8 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm run sync:skill-assets
- run: pnpm -r --filter "./packages/*" build
- run: pnpm run check
+12 -5
View File
@@ -3,6 +3,13 @@ name: Publish
on:
workflow_dispatch:
inputs:
package:
description: "Which package set to publish"
required: true
type: choice
options:
- bailian-cli
- knowledge-studio-cli
mode:
description: "Publish mode"
required: true
@@ -16,13 +23,13 @@ on:
type: string
concurrency:
group: publish-${{ inputs.mode }}-${{ inputs.channel }}
group: publish-${{ inputs.package }}-${{ inputs.mode }}-${{ inputs.channel }}
cancel-in-progress: false
jobs:
publish-stable:
if: inputs.mode == 'stable'
name: publish stable to npm + tag
name: publish stable (${{ inputs.package }}) to npm + tag
runs-on: ubuntu-latest
environment: production # Required Reviewers gate
permissions:
@@ -51,11 +58,11 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: publish-stable
run: node tools/release/publish-stable.mjs
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
publish-channel:
if: inputs.mode == 'channel'
name: publish beta to npm
name: publish channel (${{ inputs.package }}) to npm
runs-on: ubuntu-latest
permissions:
contents: read # no tag, no Release; just publish
@@ -83,4 +90,4 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: publish-channel
run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}"
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
@@ -1,143 +0,0 @@
# When CLI command definitions or the reference generator change, regenerate skill
# reference markdown, push to modelstudioai/skills, and open a PR against main.
#
# Required repository secret (Settings → Secrets and variables → Actions):
# SKILLS_SYNC_TOKEN — PAT with repo scope on modelstudioai/skills:
# Contents: Read and write
# Pull requests: Read and write
# Prefer a bot / machine user PAT if your org restricts personal PATs.
name: Sync bailian-cli skill reference
on:
push:
branches: [main]
paths:
- "packages/cli/src/commands/**"
- "tools/generate-reference.ts"
- "packages/core/src/types/command.ts"
paths-ignore:
# Tests and fixtures under commands/ do not affect generated reference.
- "packages/cli/src/commands/**/*.test.ts"
- "packages/cli/src/commands/**/*.spec.ts"
- "packages/cli/src/commands/**/__fixtures__/**"
- "packages/cli/src/commands/**/__tests__/**"
schedule:
- cron: "0 19 * * *"
workflow_dispatch:
concurrency:
group: sync-bailian-cli-skill-reference
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-latest
if: github.repository == 'modelstudioai/cli'
permissions:
contents: read
steps:
- name: Checkout cli
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build bailian-cli-core (required by generate-reference)
run: pnpm --filter bailian-cli-core run build
- name: Generate reference markdown
run: pnpm --filter bailian-cli run generate:reference
- name: Checkout skills repo
uses: actions/checkout@v4
with:
repository: modelstudioai/skills
path: skills-repo
token: ${{ secrets.SKILLS_SYNC_TOKEN }}
fetch-depth: 0
- name: Sync reference into skills and open PR
env:
GH_TOKEN: ${{ secrets.SKILLS_SYNC_TOKEN }}
CLI_SHA: ${{ github.sha }}
CLI_RUN: ${{ github.run_id }}
run: |
set -euo pipefail
cd "${GITHUB_WORKSPACE}/skills-repo"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout main
git pull origin main
SHORT_SHA="${CLI_SHA:0:7}"
BRANCH="sync/bailian-cli-reference-${SHORT_SHA}"
REFERENCE_PATH="skills/bailian-cli/reference"
git checkout -B "$BRANCH"
SRC="${GITHUB_WORKSPACE}/tools/generated/reference"
DEST="${GITHUB_WORKSPACE}/skills-repo/${REFERENCE_PATH}"
mkdir -p "$DEST"
rsync -a --delete "$SRC/" "$DEST/"
# Untracked new files are invisible to `git diff` until added.
git add -A -- "$REFERENCE_PATH"
if git diff --cached --quiet origin/main -- "$REFERENCE_PATH"; then
echo "No diff vs origin/main under ${REFERENCE_PATH}; exiting."
exit 0
fi
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${CLI_RUN}"
git commit \
-m "chore(bailian-cli): sync reference from cli" \
-m "Synced from modelstudioai/cli@${CLI_SHA}" \
-m "Workflow run: ${RUN_URL}"
# Re-fetch before push: main may have been updated with the same reference content.
git fetch origin main
if git diff --quiet origin/main HEAD -- "$REFERENCE_PATH"; then
echo "Committed tree matches origin/main; skipping push and PR."
exit 0
fi
git push -u origin "$BRANCH" --force-with-lease
EXISTING=$(gh pr list --repo modelstudioai/skills --head "$BRANCH" --state open --json number --jq 'length')
if [ "${EXISTING}" -eq 0 ]; then
BODY_FILE="$(mktemp)"
{
echo "## Summary"
echo ""
echo "- Regenerated \`skills/bailian-cli/reference/*.md\` from CLI command definitions at [\`modelstudioai/cli\`](https://github.com/modelstudioai/cli) commit \`${SHORT_SHA}\`."
echo ""
echo "## Test plan"
echo ""
echo "- [ ] Spot-check \`reference/index.md\` links and a sample group file under \`skills/bailian-cli/reference/\`."
echo "- [ ] Merge if docs only."
} >"$BODY_FILE"
gh pr create \
--repo modelstudioai/skills \
--base main \
--head "$BRANCH" \
--title "chore(bailian-cli): sync CLI command reference" \
--body-file "$BODY_FILE"
rm -f "$BODY_FILE"
else
echo "Open PR already exists for head ${BRANCH}."
fi
+2
View File
@@ -12,6 +12,7 @@ node_modules
dist
dist-ssr
tools/generated
.node-version
*.local
@@ -33,6 +34,7 @@ tools/generated
.claude/worktrees/
.claude/settings.json
.claude/settings.local.json
.claude/scheduled_tasks.lock
.cursor/
.qwen/
.playwright-mcp/
+1
View File
@@ -0,0 +1 @@
24
+9
View File
@@ -1 +1,10 @@
#!/usr/bin/env sh
set -eu
# Regenerate skill reference + SKILL metadata (needs bailian-cli-core dist).
pnpm run sync:skill-assets
# Stage generator output so it is included in this commit.
git add skills/bailian-cli/reference skills/bailian-cli/SKILL.md
vp staged
+16 -3
View File
@@ -25,13 +25,14 @@ packages/cli/
└── tests/e2e/
```
Skill / 命令手册不再随 npm 包发布,改由独立的 `npx add skills` 机制安装。`tools/generate-reference.ts` 仍然`catalog.ts` 生成命令手册到 `tools/generated/reference/`(gitignore,临时),等新机制接入后再迁走
Skill / 命令手册`skills/bailian-cli/``npx skills add modelstudioai/cli` 安装。`tools/generate-reference.ts``catalog.ts` 生成命令手册到 `skills/bailian-cli/reference/`(纳入 git);与 `tools/sync-skill-metadata.ts` 一起在 **pre-commit**`.vite-hooks/pre-commit`)及根脚本 `pnpm run sync:skill-assets` 中执行
非代码资产:
- `tools/release/` — 发版自动化CI 驱动,见 `.github/workflows/publish.yml`
- `tools/generate-reference.ts` — 从 `catalog.ts` 生成命令手册(临时输出到 `tools/generated/reference/`)
- `README.md` / `README_CN.md` — npm 和 GitHub 主页
- `tools/generate-reference.ts` — 从 `catalog.ts` 生成命令手册`skills/bailian-cli/reference/`
- `tools/sync-skill-metadata.ts` — 从 `packages/cli/package.json` 同步 `skills/bailian-cli/SKILL.md``metadata.version`(与 `generate:reference` 一并由根目录 `pnpm run sync:skill-assets` 及 pre-commit 执行)
- `README.md` / `README.zh.md` — npm 和 GitHub 主页
约定:
@@ -92,6 +93,18 @@ CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的
不要扮演服务端错误的翻译官——我们没有最新的错误码体系认知,二次包装只会撒谎(详见 `docs/agents/error-hint-change.md` 中的反面 case)。
### 4. Console Gateway 命令必须声明 console 全局 flags
如果新命令使用了 `callConsoleGateway`,必须在 `options` 中添加以下三个全局 flag 的说明,以便 `--help` 中展示:
```ts
{ flag: "--console-region <region>", description: "Console region" },
{ flag: "--console-site <site>", description: "Console site: domestic, international" },
{ flag: "--console-switch-agent <uid>", description: "Switch agent UID", type: "number" },
```
这些 flag 已在 `GLOBAL_OPTIONS``packages/core/src/types/command.ts`)中注册,由 `loadConfig` 写入 `config.consoleRegion` / `config.consoleSite` / `config.consoleSwitchAgent``callConsoleGateway` 自动读取——命令无需手动提取或传递。
## 完成改动后的快速验证
```sh
+138 -2
View File
@@ -2,9 +2,145 @@
All notable changes to `bailian-cli` and `bailian-cli-core` are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The two packages share a single version number — they are always released together.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The `bailian-cli`, `bailian-cli-core`, `bailian-cli-runtime`, and `bailian-cli-commands` packages share a single version number — they are always released together.
[中文版](CHANGELOG_CN.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.5.0] - 2026-07-01
### Added
- Model fine-tuning — `bl finetune`: create, list, get, watch, and cancel jobs; fetch training logs; list checkpoints; export a checkpoint as a deployable model; and query training capability (by model or by training type). Supports `sft`, `sft-lora`, `dpo`, `dpo-lora`, and `cpt` training types.
- Model deployment — `bl deploy`: create, list, get, update (rate limits), scale, and delete deployments; list deployable models and plans.
- Dataset management — `bl dataset`: upload, list, get, and delete dataset files, plus `bl dataset validate` to check a local `.jsonl` before uploading (ChatML / DPO / CPT formats).
- Token Plan management — `bl token-plan`: list subscription seats, add members, batch-assign seats, and create a per-seat API key.
- Automatic update check: after a command finishes, the CLI checks npm for a newer release (throttled) and shows an `Update available` hint; a major stable-version gap upgrades itself automatically. Skipped with `--quiet` or when running `bl update`.
- Composable packages: `bailian-cli-runtime` (CLI framework) and `bailian-cli-commands` (command library) are now published alongside `bailian-cli-core`, and a new sibling CLI `knowledge-studio-cli` (`kscli`) ships on top of them. `bl` behavior is unchanged.
### Removed
- `bl config export-schema` (exported CLI commands as Anthropic/OpenAI-compatible JSON tool schemas) has been removed.
### Fixed
- Console gateway commands (`bl console call`, etc.) now surface a readable message when the gateway returns a non-string `errorCode`, instead of `[object Object]`.
## [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
- Console gateway now supports multiple regions and sites: `cn-beijing` and `ap-southeast-1`, each with domestic and international variants, plus `switchAgent` for delegated access.
- New global flags `--console-region`, `--console-site`, and `--console-switch-agent`; `bl console call` also gains `--site` and `--switch-agent`.
- `bl auth login --base-url <url>` to specify the base URL when logging in with an API key.
- `bl omni` gains a `--voice` option (Chelsie, Cherry, Ethan, Serena, Sunny, Tina; default Cherry).
### Changed
- All user-facing CLI text is now standardized to English.
- `bl advisor recommend` internal intent/ranking model upgraded from `qwen-turbo` to `qwen-flash`.
- Cleaner JSON output for `usage`, `quota`, and `workspace` commands.
- `base_url` from the config file now takes priority over the `DASHSCOPE_BASE_URL` environment variable.
- `bl config show` now displays all fields from `config.json`, with sensitive values masked.
### Removed
- The legacy `region` config field and its related options.
- Invalid leftover code for the removed `model list` command.
### Fixed
- When the console session is not logged in or has expired, the CLI now shows a clear sign-in prompt instead of a generic gateway error.
- Corrected `--resolution` / `--ratio` / `--duration` flag descriptions for `bl video` commands.
## [1.3.3] - 2026-06-16
### Changed
- `bl knowledge retrieve --help` now clearly indicates that `--api-key` is the recommended authentication method; AK/SK flags are explicitly marked as deprecated with guidance to use `--api-key` instead.
### Added
- `notes` field for command definitions — commands can now include contextual notes (auth requirements, deprecation notices, etc.) that are displayed in both `--help` output and the generated reference docs.
## [1.3.2] - 2026-06-12
### Fixed
- Fixed `bl omni --audio` always returning HTTP 400 (#54); audio inputs are now understood correctly.
## [1.3.1] - 2026-06-12
### Fixed
- `bl` now honors `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables (#35). Node's built-in `fetch` (undici) ignores proxy env vars by default, causing `ECONNRESET` for users behind a VPN or corporate proxy. A global proxy dispatcher is now installed at startup when these variables are set, and the `ECONNRESET` error hint points to `export HTTPS_PROXY=http://127.0.0.1:<port>`.
## [1.3.0] - 2026-06-10
### Added
- `bl knowledge retrieve` now supports API-Key authentication (DashScope gateway), in addition to AK/SK. API-Key is auto-detected and preferred when available.
- New retrieval options: `--dense-similarity-top-k`, `--sparse-similarity-top-k`, `--rerank-model`, `--rerank-mode`, `--rerank-instruct` — supported on both API-Key and AK/SK paths.
- `DashScopeKnowledgeRetrieveRequest` / `DashScopeKnowledgeRetrieveResponse` types and `knowledgeRetrieveEndpoint` added to `bailian-cli-core`.
- Comprehensive E2E tests for knowledge retrieve covering both auth paths, dry-run, rerank flags, and error cases.
- `bl usage` command group:
- `bl usage free` — query free-tier quota for all models (or a specific model with `--model`).
- `bl usage freetier` — enable (`--on`) or disable (`--off`) auto-stop for free-tier models.
- `bl usage stats` — query model usage statistics (requires `--workspace-id`).
- `bl quota` command group:
- `bl quota list` — view model RPM/TPM rate limits (filter with `--model`, show all with `--all`).
- `bl quota check` — check current RPM/TPM usage against rate limits.
- `bl quota history` — view quota change history with pagination.
- `bl quota request` — request a temporary quota increase for a model.
- `bl workspace list` — list all workspaces with region and endpoint details.
### Changed
- Credential resolution priority: explicit API-Key → explicit AK/SK flags → auto-detected API-Key → fallback AK/SK from config/env.
- `--workspace-id` is now only required for AK/SK auth, no longer mandatory for API-Key mode.
- `--top-k` deprecated in favor of `--rerank-top-n`; emits a warning and maps to `--rerank-top-n` when used.
- `--access-key-id` / `--access-key-secret` flags marked as deprecated (API-Key is recommended).
- API Key and console links updated to direct key management pages across all docs.
### Fixed
- `--rerank` flag in AK/SK path now correctly sets `EnableReranking` instead of the non-functional `Rerank: true` boolean.
## [1.2.1] - 2026-06-09
### Changed
- Skill install command updated from `npx skills add modelstudioai/skills` to `npx skills add modelstudioai/cli --all -g` across all READMEs and docs.
- `bl update` now automatically updates the `bailian-cli` agent skill after CLI upgrade.
- Renamed `README_CN.md` to `README.zh.md` (ISO 639 convention) across the entire repo.
### Added
- Official skill (`skills/bailian-cli/`) now ships in this repository with pre-commit auto-generation of reference docs and SKILL.md version sync.
- Bilingual READMEs (EN + CN) for the `bailian-cli` skill.
## [1.2.0] - 2026-06-05
+250
View File
@@ -0,0 +1,250 @@
# 更新日志
`bailian-cli``bailian-cli-core` 的所有重要变更都记录在此。
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。`bailian-cli``bailian-cli-core``bailian-cli-runtime``bailian-cli-commands` 共享一个版本号,总是一起发布。
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.5.0] - 2026-07-01
### 新增
- 模型精调 —— `bl finetune`:创建、列出、查询、观察、取消训练任务;拉取训练日志;列出 checkpoint;将 checkpoint 导出为可部署模型;查询训练能力(按模型或按训练类型)。支持 `sft``sft-lora``dpo``dpo-lora``cpt` 训练类型。
- 模型部署 —— `bl deploy`:创建、列出、查询、更新(限流)、扩缩容、删除部署;列出可部署模型与套餐。
- 数据集管理 —— `bl dataset`:上传、列出、查询、删除数据集文件,并新增 `bl dataset validate` 在上传前本地校验 `.jsonl`(ChatML / DPO / CPT 格式)。
- Token Plan 管理 —— `bl token-plan`:列出订阅座位、添加成员、批量分配座位、为座位创建 API Key。
- 自动更新检查:命令执行完成后,CLI 会(节流地)检查 npm 上是否有新版本并提示 `Update available`;若与稳定版存在大版本差距则自动升级。`--quiet` 或执行 `bl update` 时跳过。
- 可组合包:`bailian-cli-runtime`(CLI 框架)与 `bailian-cli-commands`(命令库)现在与 `bailian-cli-core` 一起发布,并在其之上新增了同家族 CLI `knowledge-studio-cli`(`kscli`)。`bl` 行为保持不变。
### 已移除
- 移除 `bl config export-schema` 命令(原用于把 CLI 命令导出为 Anthropic/OpenAI 兼容的 JSON tool schema)。
### 修复
- 控制台网关类命令(`bl console call` 等)在网关返回非字符串 `errorCode` 时,现在会给出可读的错误信息,而不是 `[object Object]`
## [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
### 新增
- 控制台网关支持多 region 与多站点:`cn-beijing``ap-southeast-1`,各含国内站 / 国际站变体,并新增 `switchAgent` 委托访问。
- 新增全局标志 `--console-region``--console-site``--console-switch-agent``bl console call` 另外新增 `--site``--switch-agent`
- `bl auth login --base-url <url>`:使用 API Key 登录时可指定 base URL。
- `bl omni` 新增 `--voice` 选项Chelsie、Cherry、Ethan、Serena、Sunny、Tina默认 Cherry
### 变更
- 所有面向用户的 CLI 文案统一为英文。
- `bl advisor recommend` 内部意图 / 排序模型由 `qwen-turbo` 升级为 `qwen-flash`
- 优化 `usage``quota``workspace` 命令的 JSON 输出。
- 配置文件中的 `base_url` 现在优先级高于环境变量 `DASHSCOPE_BASE_URL`
- `bl config show` 现在展示 `config.json` 中的全部字段(敏感值已脱敏)。
### 移除
- 移除遗留的 `region` 配置字段及其相关选项。
- 清理 `model list` 命令移除后遗留的无效代码。
### 修复
- 当控制台会话未登录或已过期时CLI 现在会给出明确的登录提示,不再是笼统的网关错误。
- 修正 `bl video` 命令 `--resolution` / `--ratio` / `--duration` 的帮助文案。
## [1.3.3] - 2026-06-16
### 变更
- `bl knowledge retrieve --help` 现在明确指出 `--api-key` 是推荐的鉴权方式AK/SK 相关选项已标注废弃并引导用户使用 `--api-key`
### 新增
- 命令定义新增 `notes` 字段 — 命令可以附带上下文说明(鉴权要求、废弃提示等),同时展示在 `--help` 输出和生成的命令手册中。
## [1.3.2] - 2026-06-12
### 修复
- 修复 `bl omni --audio` 始终返回 HTTP 400 的问题(#54),音频输入现已能正常理解。
## [1.3.1] - 2026-06-12
### 修复
- `bl` 现在会读取 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` 环境变量(#35)。Node 内置的 `fetch`(undici)默认忽略代理环境变量,导致 VPN 或公司代理下出现 `ECONNRESET`。现已在启动时根据这些变量安装全局代理 dispatcher,并在 `ECONNRESET` 报错提示中给出 `export HTTPS_PROXY=http://127.0.0.1:<port>` 的指引。
## [1.3.0] - 2026-06-11
### 新增
- `bl usage` 命令组:
- `bl usage free` — 查询所有模型的免费额度(可通过 `--model` 指定模型)。
- `bl usage freetier` — 启用(`--on`)或禁用(`--off`)免费额度模型的自动停服。
- `bl usage stats` — 查询模型用量统计(需指定 `--workspace-id`)。
- `bl quota` 命令组:
- `bl quota list` — 查看模型 RPM/TPM 速率限制(支持 `--model` 过滤,`--all` 展示全部)。
- `bl quota check` — 查看当前 RPM/TPM 用量与速率限制。
- `bl quota history` — 查看配额变更记录,支持分页。
- `bl quota request` — 申请模型临时配额提升。
- `bl workspace list` — 列出所有业务空间,包含地域和 endpoint 信息。
- `bl knowledge retrieve` 新增 API-Key 鉴权DashScope 网关),与原有 AK/SK 并存,可用时自动优先使用 API-Key。
- 新增检索参数:`--dense-similarity-top-k``--sparse-similarity-top-k``--rerank-model``--rerank-mode``--rerank-instruct`API-Key 与 AK/SK 两条链路均支持。
- `bailian-cli-core` 新增 `DashScopeKnowledgeRetrieveRequest` / `DashScopeKnowledgeRetrieveResponse` 类型及 `knowledgeRetrieveEndpoint` 端点。
- 知识库检索全面 E2E 测试覆盖两种鉴权路径、dry-run、rerank 参数及错误场景。
### 变更
- 凭据解析优先级:显式 API-Key → 显式 AK/SK flag → 自动检测 API-Key → 回退至配置/环境变量中的 AK/SK。
- `--workspace-id` 仅在 AK/SK 鉴权时必填API-Key 模式下不再强制要求。
- `--top-k` 标记为废弃,改用 `--rerank-top-n`;使用时输出警告并自动映射。
- `--access-key-id` / `--access-key-secret` 标记为废弃(推荐使用 API-Key
- 全部文档中的 API Key 和控制台链接更新为直达密钥管理页面。
### 修复
- AK/SK 链路 `--rerank` 现在正确设置 `EnableReranking`,而非之前无效的 `Rerank: true` 布尔值。
## [1.2.1] - 2026-06-09
### 变更
- Skill 安装命令从 `npx skills add modelstudioai/skills` 更新为 `npx skills add modelstudioai/cli --all -g`,所有 README 和文档已同步。
- `bl update` 现在会在 CLI 升级后自动更新 `bailian-cli` agent skill。
- 全仓库 `README_CN.md` 统一重命名为 `README.zh.md`ISO 639 命名规范)。
### 新增
- 官方 skill`skills/bailian-cli/`迁入本仓库pre-commit 自动生成 reference 文档并同步 SKILL.md 版本号。
- `bailian-cli` skill 新增中英文双语 README。
## [1.2.0] - 2026-06-05
### 新增
- `bl mcp` 命令组:`bl mcp list` 列出 MCP 服务器,`bl mcp tools <server>` 查看可用工具,`bl mcp call <server>.<tool>` 通过 `--arg k=v``--json` 调用工具。
- `bl advisor recommend` — 用自然语言描述任务需求,智能推荐最合适的模型,展示上下文窗口、定价及能力详情。
### 修复
- 图片/视频水印始终开启的问题,现在正确遵守 `bl config set watermark false` 配置。
- 成对 flag`--watermark` / `--no-watermark`)现已正确互斥。
- 可选参数为空时 flag 校验不再崩溃。
- **安全**:凭据不再泄漏到磁盘日志,文件权限已收紧。
- **安全**:校验 `base_url` / `console_gateway_url` 为合法 HTTP(S) URL。
- **安全**script/JS `code` 字段强制为字符串字面量(阻止不可信代码 RCE
- **安全**URL 路径段已百分号编码SSE 缓冲区设上限。
- **安全**:流水线规划、指针遍历及并发安全加固。
- MCP 命令现在在参数校验和 dry-run 检查之后才处理鉴权。
### 变更
- 所有命令的 flag 默认值文案统一并去重。
- 非法/未知 flag 名称现在会报明确错误,而非静默忽略。
## [1.1.3] - 2026-06-02
### 新增
- `bl auth login --console` 在未配置 DashScope API Key 时会自动获取并保存,一次浏览器登录即可完成 OAuth 与 API Key 配置。
### 变更
- API Key 校验更稳健:网络 / 401 / 5xx 等瞬时错误会自动重试,单次请求超时上限收紧为 30 秒。
## [1.1.2] - 2026-05-29
### 变更
- 默认视觉模型由 `qwen-vl` 升级为 `qwen3-vl-plus`,视觉推理与图表/文档解析能力更强。
### 修复
- 修复 1.1.0 开源切换后暴露的 TypeScript / lint 问题。
## [1.1.1] - 2026-05-29
仅文档更新CLI 与 SDK 行为无变化。
### 新增
- 新增 `INSTALL.md`,提供面向 AI Agent 的安装指引。
### 变更
- 同步根目录与 `packages/cli` 的 README 互链;中文 README 与英文版对齐。
- 移除 README 中的 unpkg 链接,改用官方来源。
- `tools/release.mjs` 在发布前会校验根目录与 `packages/cli` 的 README 保持同步。
### 修复
- `tools/release.mjs check` 现在会先构建包再执行类型检查,确保 `bailian-cli-core` 在干净检出环境下能正确解析(此前会级联出约 80 个虚假的 TS 错误)。
## [1.1.0] - 2026-05-28
GitHub 上的首次公开发布。本项目此前在内部开发,这是首个以 Apache-2.0 协议开源的版本。
### 新增
让您的 AI Agent 开箱就具备以下能力,并可在复杂任务中自动组合调用:
**模型服务**
| 能力 | 默认服务 | 简介 |
| ---------- | --------------------------- | ---------------------------------------------------------------- |
| 文本生成 | `qwen3.7-max` | 面向智能体时代的旗舰 Max 模型,编程、办公与长周期自主执行能力出色 |
| 语音生成 | `cosyvoice-v3-flash` | 多音色实时流式合成,自然度/情感增强,5-20s 样本即可克隆 |
| 语音识别 | `fun-asr` | 汉语七大方言 + 20+ 口音官话,覆盖 30 种语种 |
| 图像生成 | `qwen-image-2.0` | 图片生成与编辑融合,专业文字渲染、真实质感、强语义遵循 |
| 图像编辑 | `qwen-image-2.0` | 智能编辑,支持多图合成 |
| 图生视频 | `happyhorse-1.0-i2v` | 精准理解文本语义,输出流畅自然的高质量视频 |
| 文生视频 | `happyhorse-1.0-t2v` | 高度还原动态画面,细节丰富 |
| 参考生视频 | `happyhorse-1.0-r2v` | 支持最多 9 张图片参考,稳定主体与场景保持 |
| 视频编辑 | `happyhorse-1.0-video-edit` | 自然语言指令编辑视频,支持最多 5 张图片参考 |
| 视觉理解 | `qwen-vl` | 长视频分析、图表/文档解析、视觉推理、多语言 OCR |
**应用数据**
| 能力 | 默认服务 | 简介 |
| ------ | ---------------- | ---------------------------------------------- |
| 知识库 | 阿里云百炼知识库 | 多模态数据知识库增删改查检索,需 AccessKey 认证 |
| 记忆库 | 阿里云百炼记忆库 | 跨会话持久化存储,提供个性化连贯对话体验 |
**应用构建**
| 能力 | 默认服务 | 简介 |
| ---------- | ---------- | ------------------------ |
| 工作流调用 | 工作流服务 | 调用已有的工作流应用服务 |
| 智能体调用 | 智能体服务 | 调用已有的智能体应用服务 |
**工具能力**
| 能力 | 默认服务 | 简介 |
| ------------ | ----------------------------------- | ----------------------------------------------------------------- |
| 联网搜索 | `bailian_web_search` | 实时互联网全栈信息检索,提升回答准确性及时效性 |
| 临时文件上传 | 临时文件上传服务 | 免费临时存储空间,上传本地文件获得 URL(有效期 48 小时) |
| 模型额度查询 | 模型额度查询 | 根据模型 id 查询可以使用的免费额度 |
| 接口文档 | 阿里云百炼模型应用 API 调用参考文档 | 在构建应用的过程中,自动为您的应用集成阿里云百炼模型和应用能力 API |
-115
View File
@@ -1,115 +0,0 @@
# 更新日志
`bailian-cli``bailian-cli-core` 的所有重要变更都记录在此。
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。两个包共享一个版本号,总是一起发布。
[English](CHANGELOG.md) · [README](README_CN.md) · [参与贡献](CONTRIBUTING_CN.md)
## [1.2.0] - 2026-06-05
### 新增
- `bl mcp` 命令组:`bl mcp list` 列出 MCP 服务器,`bl mcp tools <server>` 查看可用工具,`bl mcp call <server>.<tool>` 通过 `--arg k=v``--json` 调用工具。
- `bl advisor recommend` — 用自然语言描述任务需求,智能推荐最合适的模型,展示上下文窗口、定价及能力详情。
### 修复
- 图片/视频水印始终开启的问题,现在正确遵守 `bl config set watermark false` 配置。
- 成对 flag`--watermark` / `--no-watermark`)现已正确互斥。
- 可选参数为空时 flag 校验不再崩溃。
- **安全**:凭据不再泄漏到磁盘日志,文件权限已收紧。
- **安全**:校验 `base_url` / `console_gateway_url` 为合法 HTTP(S) URL。
- **安全**script/JS `code` 字段强制为字符串字面量(阻止不可信代码 RCE
- **安全**URL 路径段已百分号编码SSE 缓冲区设上限。
- **安全**:流水线规划、指针遍历及并发安全加固。
- MCP 命令现在在参数校验和 dry-run 检查之后才处理鉴权。
### 变更
- 所有命令的 flag 默认值文案统一并去重。
- 非法/未知 flag 名称现在会报明确错误,而非静默忽略。
## [1.1.3] - 2026-06-02
### 新增
- `bl auth login --console` 在未配置 DashScope API Key 时会自动获取并保存,一次浏览器登录即可完成 OAuth 与 API Key 配置。
### 变更
- API Key 校验更稳健:网络 / 401 / 5xx 等瞬时错误会自动重试,单次请求超时上限收紧为 30 秒。
## [1.1.2] - 2026-05-29
### 变更
- 默认视觉模型由 `qwen-vl` 升级为 `qwen3-vl-plus`,视觉推理与图表/文档解析能力更强。
### 修复
- 修复 1.1.0 开源切换后暴露的 TypeScript / lint 问题。
## [1.1.1] - 2026-05-29
仅文档更新CLI 与 SDK 行为无变化。
### 新增
- 新增 `INSTALL.md`,提供面向 AI Agent 的安装指引。
### 变更
- 同步根目录与 `packages/cli` 的 README 互链;中文 README 与英文版对齐。
- 移除 README 中的 unpkg 链接,改用官方来源。
- `tools/release.mjs` 在发布前会校验根目录与 `packages/cli` 的 README 保持同步。
### 修复
- `tools/release.mjs check` 现在会先构建包再执行类型检查,确保 `bailian-cli-core` 在干净检出环境下能正确解析(此前会级联出约 80 个虚假的 TS 错误)。
## [1.1.0] - 2026-05-28
GitHub 上的首次公开发布。本项目此前在内部开发,这是首个以 Apache-2.0 协议开源的版本。
### 新增
让您的 AI Agent 开箱就具备以下能力,并可在复杂任务中自动组合调用:
**模型服务**
| 能力 | 默认服务 | 简介 |
| ---------- | --------------------------- | ---------------------------------------------------------------- |
| 文本生成 | `qwen3.7-max` | 面向智能体时代的旗舰 Max 模型,编程、办公与长周期自主执行能力出色 |
| 语音生成 | `cosyvoice-v3-flash` | 多音色实时流式合成,自然度/情感增强,5-20s 样本即可克隆 |
| 语音识别 | `fun-asr` | 汉语七大方言 + 20+ 口音官话,覆盖 30 种语种 |
| 图像生成 | `qwen-image-2.0` | 图片生成与编辑融合,专业文字渲染、真实质感、强语义遵循 |
| 图像编辑 | `qwen-image-2.0` | 智能编辑,支持多图合成 |
| 图生视频 | `happyhorse-1.0-i2v` | 精准理解文本语义,输出流畅自然的高质量视频 |
| 文生视频 | `happyhorse-1.0-t2v` | 高度还原动态画面,细节丰富 |
| 参考生视频 | `happyhorse-1.0-r2v` | 支持最多 9 张图片参考,稳定主体与场景保持 |
| 视频编辑 | `happyhorse-1.0-video-edit` | 自然语言指令编辑视频,支持最多 5 张图片参考 |
| 视觉理解 | `qwen-vl` | 长视频分析、图表/文档解析、视觉推理、多语言 OCR |
**应用数据**
| 能力 | 默认服务 | 简介 |
| ------ | ---------------- | ---------------------------------------------- |
| 知识库 | 阿里云百炼知识库 | 多模态数据知识库增删改查检索,需 AccessKey 认证 |
| 记忆库 | 阿里云百炼记忆库 | 跨会话持久化存储,提供个性化连贯对话体验 |
**应用构建**
| 能力 | 默认服务 | 简介 |
| ---------- | ---------- | ------------------------ |
| 工作流调用 | 工作流服务 | 调用已有的工作流应用服务 |
| 智能体调用 | 智能体服务 | 调用已有的智能体应用服务 |
**工具能力**
| 能力 | 默认服务 | 简介 |
| ------------ | ----------------------------------- | ----------------------------------------------------------------- |
| 联网搜索 | `bailian_web_search` | 实时互联网全栈信息检索,提升回答准确性及时效性 |
| 临时文件上传 | 临时文件上传服务 | 免费临时存储空间,上传本地文件获得 URL(有效期 48 小时) |
| 模型额度查询 | 模型额度查询 | 根据模型 id 查询可以使用的免费额度 |
| 接口文档 | 阿里云百炼模型应用 API 调用参考文档 | 在构建应用的过程中,自动为您的应用集成阿里云百炼模型和应用能力 API |
+1 -1
View File
@@ -2,7 +2,7 @@
Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (DashScope). For end-user usage, see [README.md](README.md).
[中文版](CONTRIBUTING_CN.md) · [README](README.md) · [Changelog](CHANGELOG.md)
[中文版](CONTRIBUTING.zh.md) · [README](README.md) · [Changelog](CHANGELOG.md)
## Prerequisites
+2 -2
View File
@@ -1,8 +1,8 @@
# 参与贡献 bailian-cli
`bailian-cli` 是阿里云百炼(DashScope)的官方 CLI。本文是面向**开发者**的指南;终端用户请看 [README_CN.md](README_CN.md)。
`bailian-cli` 是阿里云百炼(DashScope)的官方 CLI。本文是面向**开发者**的指南;终端用户请看 [README.zh.md](README.zh.md)。
[English](CONTRIBUTING.md) · [README](README_CN.md) · [更新日志](CHANGELOG_CN.md)
[English](CONTRIBUTING.md) · [README](README.zh.md) · [更新日志](CHANGELOG.zh.md)
## 环境要求
+3 -3
View File
@@ -50,10 +50,10 @@ which bl # Windows 可用 where bl
CLI 校验通过后,在本机终端执行:
```bash
npx skills add modelstudioai/skills --all -g
npx skills add modelstudioai/cli --all -g
```
完整说明见 [百炼Skills](https://github.com/modelstudioai/skills)。安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
---
@@ -102,7 +102,7 @@ bl auth status --output json
bl text chat --message "ping" --non-interactive --output json
```
若失败:根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、region 等)。全局 region`--region cn|us|intl`,默认 `cn`
若失败:根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、`base_url`。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`
---
+46 -21
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README_CN.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
---
@@ -27,15 +27,19 @@ 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 520s 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
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
- **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
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
- **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
## Showcase: One-Sentence Cinematic Video
@@ -51,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
@@ -64,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.
@@ -73,7 +77,7 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
```bash
npm install -g bailian-cli
npx skills add modelstudioai/skills --all -g
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 22.12.
@@ -108,9 +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
# Browse apps / free-tier quota
# 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 # 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 (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
# 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&)
@@ -119,7 +144,7 @@ bl usage free --model qwen3-max
### DashScope API Key
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cli?source_channel=key_github&).
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
```bash
# Option 1: Environment variable
@@ -134,15 +159,15 @@ bl text chat --api-key sk-xxxxx --message "Hello"
### Console Login (OAuth)
Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in.
Required for console capability commands (`app list`, `usage free`, `usage stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
```bash
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.
@@ -159,7 +184,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
bl config show
# Set defaults
bl config set --key region --value us
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
@@ -171,14 +196,14 @@ Config file location: `~/.bailian/config.json`
## Links
| Resource | URL |
| :--------------------------- | :---------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/ |
| Get API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
| Resource | URL |
| :--------------------------- | :---------------------------------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
+52 -24
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
---
@@ -27,15 +27,19 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **文本对话** — Qwen3.7-maxAgentic 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
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **控制台能力**浏览百炼应用(`app list`),查询模型免费额度(`usage free`
- **微调与部署**上传数据集、创建 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 小时
## 示例:一句话生成一部电影短片
@@ -51,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)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
### 唯一的提示词
@@ -62,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 分钟成片。
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
@@ -71,7 +75,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
```bash
npm install -g bailian-cli
npx skills add modelstudioai/skills --all -g
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 22.12。
@@ -79,7 +83,10 @@ npx skills add modelstudioai/skills --all -g
## 快速开始
```bash
# 认证
# 认证(推荐浏览器登录)
bl auth login --console
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 和通义千问对话
@@ -103,9 +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 # 各模型免费额度(可加 --model/--expiring/--sort
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
bl workspace list # 列出所有业务空间
# 限流管理与提额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&)
@@ -114,7 +142,7 @@ bl usage free --model qwen3-max
### DashScope API Key
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
```bash
# 方式一:环境变量
@@ -129,15 +157,15 @@ bl text chat --api-key sk-xxxxx --message "你好"
### 控制台登录OAuth
控制台能力命令(`app list``usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`app list``usage free``usage stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
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。
@@ -154,7 +182,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
bl config show
# 设置默认值
bl config set --key region --value us
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
@@ -166,19 +194,19 @@ bl update
## 相关链接
| 资源 | 地址 |
| :---------------------- | :---------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
| 资源 | 地址 |
| :---------------------- | :---------------------------------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
每个版本的变更详情记录在 [CHANGELOG_CN.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG_CN.md)。
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
## 参与贡献
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING_CN.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING_CN.md)。
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
+2 -4
View File
@@ -78,9 +78,7 @@ flag 优先 ─→ config 文件 ─→ env var
### D. main 启动逻辑
- [ ] `packages/cli/src/main.ts:NO_AUTH_SETUP` 列表:
- 如果新增的命令"自己管鉴权或不需要鉴权",加进去绕开 ensureApiKey 拦截
- 当前清单以 `main.ts:NO_AUTH_SETUP` 为准
- [ ] 若新增命令**自行处理鉴权**或**不应在入口触发默认 API key 引导**,在对应 `defineCommand` 上设 `skipDefaultApiKeySetup: true`(见 `packages/core/src/types/command.ts`;`packages/cli/src/main.ts``registry.resolve` 后读取 `command.skipDefaultApiKeySetup`)
### E. 错误文案
@@ -89,7 +87,7 @@ flag 优先 ─→ config 文件 ─→ env var
### F. 用户面文档
- [ ] `README.md` / `README_CN.md` "Authentication" 段落
- [ ] `README.md` / `README.zh.md` "Authentication" 段落
### G. 测试
+13 -13
View File
@@ -50,14 +50,14 @@ git diff --name-only <base>...<head>
- [ ] **`package.json` 没破坏发布元数据**:`bin` / `exports` / `files` / `inlinedDependencies` 字段任何删除或改名都要单独评估
- [ ] **公共依赖没被悄悄升级**:catalog / 根 lockfile 改动要列出来
- [ ] **`package.json` version 没倒退**:目标分支已经更高时(如 main 1.0.3 vs head 1.0.0-beta.1),手动对齐版本号,不要被 head 覆盖
- [ ] **全局表没冲突**:`registry.ts``NO_AUTH_SETUP`(`packages/cli/src/main.ts`)、`ExitCode`个全局表新增项不和现有项冲突
- [ ] **全局表没冲突**:`registry.ts``defineCommand``skipDefaultApiKeySetup`(`packages/core/src/types/command.ts`)、`ExitCode`新增项不和现有项冲突
## 清单 B:用户透出(用户可见的新东西必看)
- [ ] **新命令 / 新 flag** 已同步到用户面文档:
- [README.md](README.md) + [README_CN.md](README_CN.md)(中英文都要,常漏 `_CN`)
- [README.md](README.md) + [README.zh.md](README.zh.md)(中英文都要,常漏 `_CN`)
- (SKILL.md 已迁出本仓库,由 `npx add skills` 机制独立维护,不在本仓库 review 范围)
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` / `apiDocs` 都填了
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` 都填了
- [ ] **demo / quickstart**:用户可调用的新命令至少有一个示例
- [ ] **行为变化的老命令**:在 commit message / CHANGELOG 注明用户感知的差异
- [ ] **错误信息 / 提示文案**:面向用户的字符串通顺、双语(项目主体是中文场景)
@@ -67,7 +67,7 @@ git diff --name-only <base>...<head>
- [ ] **改了文件但没补测试**:`git diff --stat <base>...<head> -- '*test*' '*spec*'` 与改动文件清单对照
- [ ] **新功能埋点同步**:遥测事件名 + 参数 allowlist(参考 main 上的 `feat(telemetry): track console gateway api name in params allowlist` commit)
- [ ] **环境变量**:新增 / 重命名的 env var 进 README,旧的有没有兼容
- [ ] **i18n**:`README.md` 改了,`README_CN.md` 同步了吗
- [ ] **i18n**:`README.md` 改了,`README.zh.md` 同步了吗
## 输出报告(照模板填)
@@ -80,7 +80,7 @@ git diff --name-only <base>...<head>
解冲突要点(merge 时不要漏):
- <冲突文件> + <字段/段落> + <怎么取舍>
↑ 放"合并那一刻才会出现"的细节,例如 package.json 的 files/scripts/devDependencies 各取并集、
NO_AUTH_SETUP 这种全局表两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
`skipDefaultApiKeySetup` 这类命令元数据两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
建议修(可后置):
- ...
仅信息(无需动作,告知即可):
@@ -94,11 +94,11 @@ git diff --name-only <base>...<head>
## 常见漏点(基于历史踩坑)
| 漏点 | 后果 |
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `pnpm-workspace.yaml``packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README_CN](README_CN.md) | 用户完全感知不到新功能 |
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
| `NO_AUTH_SETUP` 加了不该免登录的命令 | 安全风险,用户没登录也能调付费 API |
| `NO_AUTH_SETUP` / `registry.ts` 这类全局表两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
| 漏点 | 后果 |
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `pnpm-workspace.yaml``packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README.zh](README.zh.md) | 用户完全感知不到新功能 |
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
| 不该跳过默认 API key 引导的命令误设 `skipDefaultApiKeySetup: true` | 安全风险,用户没配置 key 也能调付费 API |
| `catalog.ts` / `skipDefaultApiKeySetup` 这类元数据两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
+3 -3
View File
@@ -10,7 +10,7 @@
更新仓库内的两份文件,英文优先,中文同步:
- [`CHANGELOG.md`](../../CHANGELOG.md)
- [`CHANGELOG_CN.md`](../../CHANGELOG_CN.md)
- [`CHANGELOG.zh.md`](../../CHANGELOG.zh.md)
新版本条目插在文件顶部"## [X.Y.Z] - YYYY-MM-DD"位置,旧版本依次向下保留。两份文件保持一一对应——任何条目只在一份里出现,另一份漏写,视为错误。
@@ -129,7 +129,7 @@ git show <commit> --stat
### 8. 写完后给用户过一遍再写入文件
**不要直接编辑 `CHANGELOG.md` / `CHANGELOG_CN.md`**。先把中英两份草稿都贴回对话里,让用户:
**不要直接编辑 `CHANGELOG.md` / `CHANGELOG.zh.md`**。先把中英两份草稿都贴回对话里,让用户:
- 增删条目
- 调整措辞(中英、术语)
@@ -183,4 +183,4 @@ git show <commit> --stat
| [publish.md](publish.md) | 发布流程:自检 / 构建 / npm publishCI 驱动) |
| 本文档 | 发版后写说明:面向用户的 release notes |
两者顺序:`publish.md` → npm publish → 本文档(更新 `CHANGELOG.md` + `CHANGELOG_CN.md`)→ 推到 GitHub。
两者顺序:`publish.md` → npm publish → 本文档(更新 `CHANGELOG.md` + `CHANGELOG.zh.md`)→ 推到 GitHub。
+1 -1
View File
@@ -60,7 +60,7 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
## 新增 command 检查清单
- [ ] `commands/catalog.ts` 登记 + `tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
- [ ] 若改了 `usage` / `options` / `examples`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `tools/generated/reference/`(本仓库 gitignore)
- [ ] 若改了 `usage` / `options` / `examples`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交
- [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
- [ ] 至少一条真实集成(或说明为何仅 smoke不破坏已有集成用例顺序
+10 -11
View File
@@ -27,20 +27,20 @@
命令元数据以 **`catalog.ts` 为单一登记处**;`registry.ts` 只负责解析与打印 help,不再内嵌命令表或手写 Resources 列表。
```
commands/<...>.ts defineCommand({ name, description, usage, options, examples, apiDocs?, run })
commands/<...>.ts defineCommand({ name, description, usage, options, examples, run })
commands/catalog.ts export const commands: Record<string, Command>
┌────┴────┬──────────────────────┬─────────────────────┐
↓ ↓ ↓ ↓
registry.ts main.ts tools/generate-reference.ts export-schema.ts
(解析/help) (入口) → tools/generated/reference/index.md + <group>.md
(解析/help) (入口) → skills/bailian-cli/reference/index.md + <group>.md
```
- **`packages/cli/src/commands/catalog.ts`**: `import` 命令模块 + `"<path>": handler` 映射;**不** `import registry.ts`(避免构建时循环依赖)
- **`packages/cli/src/commands/index.ts`**: `export { commands } from "./catalog.ts"`(给包内 re-export 用)
- **`packages/cli/src/registry.ts`**: `import { commands } from "./commands/catalog.ts"`,建树、`resolve``printHelp`;Commands / Global Flags 从 `Command` 元数据与 `GLOBAL_OPTIONS` **动态生成**
- **`tools/generate-reference.ts`**: build 前`catalog.ts`,写 `tools/generated/reference/index.md`(索引) + `tools/generated/reference/<一级命令>.md`(详情,勿手改)。该目录被 gitignore,产物供未来的 `npx add skills` 安装机制消费
- **`tools/generate-reference.ts`**: pre-commit / `pnpm run sync:skill-assets``catalog.ts`,写 `skills/bailian-cli/reference/index.md`(索引) + `skills/bailian-cli/reference/<一级命令>.md`(详情,勿手改)。该目录**纳入 git**,随 `npx skills add modelstudioai/cli` 分发
已删除、勿再引用:`commands/help.ts``registry.ts` 内联 `new CommandRegistry({...})``printRootHelp` 手写命令行。
@@ -53,15 +53,14 @@ registry.ts main.ts tools/generate-reference.ts export-schema.ts
- 增删 `import xxx from "./.../xxx.ts"`
-`export const commands` 里增删 `"<group> <action>": xxx`(key 与 `defineCommand({ name })` 一致)
- [ ] **不要**在 `registry.ts` 里重复登记命令(已从 catalog 读取)
- [ ] 命令需`bl help` / `reference/` 展示 API 文档链接时,在 `defineCommand` `apiDocs`(相对路径);help 与 reference 均从此字段生成
- [ ] 如果命令需要鉴权之外的特殊路径,看 `packages/cli/src/main.ts``NO_AUTH_SETUP`
- [ ] 如果命令需要跳过入口的默认 DashScope API key 引导(`ensureApiKey`),在对应 `defineCommand` `skipDefaultApiKeySetup: true`(字段定义见 `packages/core/src/types/command.ts`;`main.ts` 根据已解析的 `command` 读取)
- [ ] **`config/export-schema.ts`**: 若新命令不适合作为 agent tool,评估是否加入 `SKIP_PREFIXES`;该文件在 `run()``import("../catalog.ts")`,勿顶层 import catalog 以免循环依赖
### B. 文档层
- [ ] 运行 `pnpm --filter bailian-cli run generate:reference`(或 `build`),刷新 `tools/generated/reference/` 下生成文件(本仓库 gitignore,仅供本地校验和未来 skill 安装机制消费)
- [ ] `README.md` / `README_CN.md`: Quick Start、命令一览(用户向,与 help 对齐即可)
- [ ] SKILL.md 已搬出本仓库(由 `npx add skills` 机制分发),本仓库不再维护
- [ ] 运行 `pnpm run sync:skill-assets`(或正常 `git commit` 走 pre-commit),刷新 `skills/bailian-cli/reference/``SKILL.md``metadata.version` 并提交
- [ ] `README.md` / `README.zh.md`: Quick Start、命令一览(用户向,与 help 对齐即可)
- [ ] `skills/bailian-cli/SKILL.md`: 若安装说明或能力边界有变,同步更新
### C. 测试层
@@ -73,14 +72,14 @@ registry.ts main.ts tools/generate-reference.ts export-schema.ts
- [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新:
- `catalog.ts` 的 key
- error hints(cli 层)
- `tools/generated/reference/`(重建后检查;本仓库 gitignore)
- `skills/bailian-cli/reference/`(重建后检查并提交)
- README 示例
- 测试断言
## 完成后自查
```sh
pnpm --filter bailian-cli run generate:reference # reference/ 与 catalog 一致
pnpm run sync:skill-assets # reference/ + SKILL metadata.version 与 catalog / package.json 一致
node packages/cli/src/main.ts <new-command> --help
node packages/cli/src/main.ts # 根 help 列表含新命令
vp test packages/cli/tests/e2e/<topic>.e2e.test.ts # 相关 e2e
@@ -89,6 +88,6 @@ vp test packages/cli/tests/e2e/<topic>.e2e.test.ts # 相关 e2e
## 常见漏点
- ✗ 只改了命令文件,忘了 **`catalog.ts`** → 命令不存在或 help 里没有
- ✗ 手改 **`tools/generated/reference/*.md`** → 下次 build 被覆盖;应改 `defineCommand` 后重新 generate
- ✗ 手改 **`skills/bailian-cli/reference/*.md`** → 下次 generate 被覆盖;应改 `defineCommand` 后重新 generate 并提交
- ✗ 在 `export-schema.ts` 顶层 `import catalog` → 可能与 registry 循环依赖
- ✗ 单 action 的子组是反模式,新增时优先拍平为两级
+2 -2
View File
@@ -28,8 +28,8 @@
### C. 文档层
- [ ] `README.md` / `README_CN.md` 如果在示例里展示了相关命令,补充新 flag
- [ ]`pnpm --filter bailian-cli run generate:reference`,让 `tools/generated/reference/` 与命令一致(本仓库 gitignore,勿手改;SKILL.md 已迁出本仓库)
- [ ] `README.md` / `README.zh.md` 如果在示例里展示了相关命令,补充新 flag
- [ ]`pnpm --filter bailian-cli run generate:reference`,让 `skills/bailian-cli/reference/` 与命令一致(勿手改;改完提交)
### D. 测试层
+1 -1
View File
@@ -54,7 +54,7 @@ config 文件 ─┘
### E. 文档
- [ ] `README.md` / `README_CN.md` 的 env var 表格
- [ ] `README.md` / `README.zh.md` 的 env var 表格
### F. 测试
+1
View File
@@ -40,6 +40,7 @@
- [ ] `.vite-hooks/pre-commit` 改动后,`pnpm install` 重新软链(走 `prepare: vp config`)
- [ ] 增加 hook 时,确认在干净 clone 后能自动激活
- [ ] pre-commit 会跑 `pnpm run sync:skill-assets`(先 build core,再 `generate:reference` + `sync:skill-version`)并 `git add` skill 资产,最后 `vp staged`
### F. CI / 发版工具
+2 -2
View File
@@ -25,11 +25,11 @@
### C. 命令手册
- [ ]`--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新 `tools/generated/reference/<group>.md`(本仓库 gitignore;SKILL.md 由独立的 `npx add skills` 仓库维护,本仓库不再含)
- [ ]`--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/<group>.md` 并提交
### D. 用户面文档
- [ ] `README.md` / `README_CN.md`:
- [ ] `README.md` / `README.zh.md`:
- Quick Start 示例如使用了具体型号,确认仍可用
- 顶部 introduction 段落如提到"Qwen-Omni"等品牌名,无需变(模型代号变化不算品牌变)
+16 -9
View File
@@ -67,9 +67,15 @@ node tools/release/publish-channel.mjs --channel test --dry-run
- [ ] `packages/cli/package.json``packages/core/package.json` 已升到目标版本
- [ ] pre-release 格式正确(`1.0.0-beta.0` / `1.0.0-rc.1`**不要直接用 `1.0.0` 当 beta**
### CHANGELOG仅 stable
- [ ] `CHANGELOG.md``CHANGELOG.zh.md` 都已新增目标版本条目,中英文一一对应
- [ ] 分类标题用 Keep a Changelog 规范的 `Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`(中文版对应 `新增` / `变更` / `已弃用` / `已移除` / `修复` / `安全`**不要自创 `Improved` / `优化` 等规范外分类**
- [ ] 条目日期与发版日期一致
### 用户面文档
- [ ] `README.md` / `README_CN.md` 的 Quick Start 命令仍能跑通
- [ ] `README.md` / `README.zh.md` 的 Quick Start 命令仍能跑通
- [ ] README 的 Node.js 徽章版本与 `cli/package.json.engines.node` 一致
- [ ] README 宣传的 bin 名称在 `cli/package.json.bin` 都真的注册
- [ ] `LICENSE` 文件存在(根 + cli + core 各一份)
@@ -81,11 +87,12 @@ node tools/release/publish-channel.mjs --channel test --dry-run
## 常见漏点(基于历史踩坑)
| 漏点 | 后果 |
| ----------------------------------------------------- | -------------------------------------------------- |
| cli 升版号但 core 没升 | check.mjs 会拦下 |
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag所有用户被强升撤回成本极高 |
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
| 漏点 | 后果 |
| -------------------------------------------------------- | -------------------------------------------------- |
| cli 升版号但 core 没升 | check.mjs 会拦下 |
| 发版漏更 CHANGELOG或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag所有用户被强升撤回成本极高 |
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
+2 -2
View File
@@ -46,8 +46,8 @@ grep -rnE "https://dashscope[a-z-]*\.aliyuncs\.com" packages/ --include="*.ts" \
### B. 非 TS 文件(只能人工同步,无法 import)
- [ ] `tools/generated/reference/``<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对;本仓库 gitignore)
- [ ] `README.md` / `README_CN.md` 中所有 URL
- [ ] `skills/bailian-cli/reference/``<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对并提交)
- [ ] `README.md` / `README.zh.md` 中所有 URL
### C. 渠道追踪参数
+438
View File
@@ -0,0 +1,438 @@
# 模型训练 + 数据集 + 部署:最小闭环 CLI 设计
> 目标:一个 Qwen 文本模型 SFT 训练、数据集上传、模型部署的端到端最小链路。
---
## 一、命令概览
| 优先级 | 命令 | 映射 API | 用途 |
| ------ | ----------------------------------- | --------------------------------------------- | ------------------------------- |
| P0 | `bl dataset upload <path>` | `POST /api/v1/files` | 上传训练数据(含本地格式校验) |
| P0 | `bl finetune create` | `POST /api/v1/fine-tunes` | 创建 SFT 训练任务(预填默认超参) |
| P0 | `bl finetune status <job_id>` | `GET /api/v1/fine-tunes/{job_id}` | 查询训练状态 |
| P0 | `bl deploy create` | `POST /api/v1/deployments` | 部署训练好的模型 |
| P1 | `bl finetune logs <job_id>` | `GET /api/v1/fine-tunes/{job_id}/logs` | 拉取训练日志 |
| P1 | `bl finetune checkpoints <job_id>` | `GET /api/v1/fine-tunes/{job_id}/checkpoints` | 查看/挑选 Checkpoint |
| P1 | `bl deploy status <deployed_model>` | `GET /api/v1/deployments/{deployed_model}` | 查询部署状态 |
| P1 | `bl deploy delete <deployed_model>` | `DELETE /api/v1/deployments/{deployed_model}` | 下线部署 |
| P1 | `bl infer --model <deployed_model>` | 复用 `text chat` 通路 | 调用已部署模型 |
---
## 二、P0 命令详细设计
### 2.1 `bl dataset upload`
**定位:** 上传训练数据文件到百炼平台,获取 `file_id` 供训练任务引用。
#### CLI 签名
```
bl dataset upload <path> [--purpose fine-tune] [--validate] [--no-validate]
```
| Flag | 必填 | 默认值 | 说明 |
| --------------- | ---- | ----------- | ------------------------------ |
| `<path>` | 是 | — | 本地文件路径(.jsonl 或 .zip |
| `--purpose` | 否 | `fine-tune` | 文件用途标签 |
| `--validate` | 否 | `true` | 上传前执行本地格式校验 |
| `--no-validate` | 否 | — | 跳过本地校验 |
#### 本地格式校验规则(提交前拦截)
校验逻辑在 `packages/core` 实现纯函数CLI 调用后展示错误:
1. **文件格式检查**:仅允许 `.jsonl``.zip`zip 内根目录必须有 `data.jsonl`
2. **JSONL 逐行校验**
- 每行可被 `JSON.parse`
- 顶层必须包含 `messages` 数组
- `messages` 中每项必须包含 `role`(枚举:`system` | `user` | `assistant`)和 `content`(非空字符串)
- 至少包含一条 `user` + 一条 `assistant` 消息
3. **数量校验**SFT 训练至少需要上千条数据(给出 warning 而非 hard fail阈值建议 ≥ 10 条 hard fail
4. **文件体积**:≤ 300MB
#### 校验失败输出示例
```
✗ Validation failed:
Line 3: missing "messages" field
Line 7: role "bot" is not valid (expected: system | user | assistant)
Line 12: "content" is empty string
Fix 3 errors above and retry.
```
#### API 调用
```
POST https://dashscope.aliyuncs.com/api/v1/files
Content-Type: multipart/form-data
Authorization: Bearer <api-key>
Body:
files: <binary>
purpose: "fine-tune"
Response 200:
{
"id": "file-xxxx",
"bytes": 12345,
"filename": "train.jsonl",
"purpose": "fine-tune",
"created_at": 1700000000
}
```
#### 输出
- 默认 text`✓ Uploaded file-xxxx (12.3 KB) — use this ID in bl finetune create`
- `--output json`:完整 response body
- `--quiet`:仅输出 `file-xxxx`
---
### 2.2 `bl finetune create`
**定位:** 创建一个 SFT 训练任务。核心设计原则——**预填合理默认超参 + 提交前二次确认**,降低 OOM/超参不合理导致的训练失败率。
#### CLI 签名
```
bl finetune create --model <model> --data <file_id> [hyperparams...]
```
| Flag | 必填 | 默认值 | 说明 |
| ------------------- | ---- | ------------ | -------------------------------------------- |
| `--model` | 是 | — | 基座模型(如 `qwen3-8b`, `qwen3-14b` |
| `--data` | 是 | — | 训练数据 file_idbl dataset upload 返回值) |
| `--validation-data` | 否 | — | 验证数据 file_id |
| `--epochs` | 否 | 3 | 训练轮次 (n_epochs) |
| `--batch-size` | 否 | 按模型自动选 | 批大小 |
| `--lr` | 否 | 按模型自动选 | 学习率 (learning_rate_multiplier) |
| `--warmup-ratio` | 否 | 0.1 | warmup 比例 |
| `--suffix` | 否 | — | 输出模型后缀名 |
| `--yes` / `-y` | 否 | — | 跳过确认直接提交 |
#### 预填默认超参策略
| 基座模型 | batch_size | lr_multiplier | n_epochs | 备注 |
| ---------- | ---------- | ------------- | -------- | ---------------- |
| qwen3-8b | 4 | 1e-5 | 3 | 小模型可大 batch |
| qwen3-14b | 2 | 5e-6 | 3 | 中模型防 OOM |
| qwen3-32b+ | 1 | 2e-6 | 2 | 大模型保守设置 |
> 以上为建议默认值,用户显式传参时覆盖。具体映射表在 `packages/core/src/finetune/defaults.ts` 维护。
#### 提交前交互确认
`--yes` 模式下,显示任务摘要等待确认:
```
┌─ Fine-tune Job Summary ──────────────────────┐
│ Model: qwen3-8b │
│ Training: file-abc123 (2,048 samples) │
│ Validation: (none) │
│ Epochs: 3 │
│ Batch size: 4 │
│ LR: 1e-5 │
│ Warmup: 0.1 │
│ Suffix: my-assistant │
│ │
│ Estimated cost: ~¥XX (based on token count) │
└───────────────────────────────────────────────┘
Proceed? [Y/n]
```
#### API 调用
```
POST https://dashscope.aliyuncs.com/api/v1/fine-tunes
Authorization: Bearer <api-key>
Content-Type: application/json
{
"model": "qwen3-8b",
"training_file_ids": ["file-abc123"],
"validation_file_ids": [],
"hyper_parameters": {
"n_epochs": 3,
"batch_size": 4,
"learning_rate": "1e-5",
"warmup_ratio": 0.1
},
"suffix": "my-assistant"
}
Response 200:
{
"job_id": "ft-xxxx",
"status": "PENDING",
"model": "qwen3-8b",
"created_at": "2025-01-01T00:00:00Z",
"training_file_ids": ["file-abc123"],
"hyper_parameters": {...},
"trained_model": null
}
```
#### 输出
- text`✓ Fine-tune job ft-xxxx created (PENDING). Track with: bl finetune status ft-xxxx`
- json完整 response body
- quiet`ft-xxxx`
---
### 2.3 `bl finetune status`
**定位:** 查询训练任务状态,支持 `--wait` 轮询模式。
#### CLI 签名
```
bl finetune status <job_id> [--wait] [--interval <seconds>]
```
| Flag | 必填 | 默认值 | 说明 |
| ------------ | ---- | ------ | ---------------- |
| `<job_id>` | 是 | — | 任务 ID |
| `--wait` | 否 | — | 持续轮询直到终态 |
| `--interval` | 否 | 30 | 轮询间隔(秒) |
#### 状态机
```
PENDING → RUNNING → SUCCEEDED
↘ FAILED
```
#### 输出text 模式)
单次查询:
```
Job: ft-xxxx
Status: RUNNING (elapsed 12m)
Model: qwen3-8b
Output: (pending)
```
`--wait` 模式spinner + 实时刷新):
```
⠋ ft-xxxx RUNNING [14:32 elapsed]
✓ ft-xxxx SUCCEEDED — trained model: qwen3-8b:ft-xxxx-20250101
Deploy with: bl deploy create --model qwen3-8b:ft-xxxx-20250101
```
失败时:
```
✗ ft-xxxx FAILED
Error: OutOfMemory — try reducing --batch-size or using a smaller model
```
---
### 2.4 `bl deploy create`
**定位:** 将训练好的模型(或 checkpoint部署为可调用的推理服务。
#### CLI 签名
```
bl deploy create --model <model_name> [--plan <plan>] [--capacity <n>]
```
| Flag | 必填 | 默认值 | 说明 |
| ------------ | ---- | ---------- | ----------------------------------------------- |
| `--model` | 是 | — | 待部署模型名称finetune 产出的 trained_model |
| `--plan` | 否 | `standard` | 部署方案 |
| `--capacity` | 否 | 依 plan | 并发容量 |
| `--wait` | 否 | — | 等待部署就绪 |
#### API 调用
```
POST https://dashscope.aliyuncs.com/api/v1/deployments
Authorization: Bearer <api-key>
Content-Type: application/json
{
"model_name": "qwen3-8b:ft-xxxx-20250101",
"plan": "standard",
"capacity": 2
}
Response 200:
{
"deployed_model": "qwen3-8b-ft-xxxx",
"model_name": "qwen3-8b:ft-xxxx-20250101",
"status": "PENDING",
"created_at": "..."
}
```
#### 输出
```
✓ Deployment created: qwen3-8b-ft-xxxx (PENDING)
Once RUNNING, call with: bl text chat --model qwen3-8b-ft-xxxx
Check status: bl deploy status qwen3-8b-ft-xxxx
```
---
## 三、P1 命令简要设计
### 3.1 `bl finetune logs <job_id>`
流式输出训练日志,支持 `--follow`(类似 `tail -f`)。输出 loss/step/epoch 信息。
### 3.2 `bl finetune checkpoints <job_id>`
列出可选 checkpointstep, loss, eval metrics支持 `--output json` 供脚本使用。可配合 `bl deploy create --model <checkpoint_model>` 部署指定 checkpoint。
### 3.3 `bl deploy status <deployed_model>`
查询部署状态及资源信息PENDING → RUNNING → STOPPED/FAILED
### 3.4 `bl deploy delete <deployed_model>`
下线部署。需部署处于 RUNNING/STOPPED/FAILED 状态。交互确认或 `--yes` 跳过。
### 3.5 `bl infer --model <deployed_model>`
实际可复用已有 `bl text chat --model <deployed_model>` 通路,作为别名/快捷方式。P1 考虑是否有独立存在必要。
---
## 四、代码架构方案
按照 monorepo 分层约定core 纯逻辑 / cli 是 UI
### packages/core 新增模块
```
packages/core/src/
├── finetune/
│ ├── index.ts # re-export
│ ├── api.ts # createFineTune, getFineTune, getFineTuneLogs, getCheckpoints
│ ├── defaults.ts # 模型 → 默认超参映射表
│ └── types.ts # FineTuneJob, HyperParameters, CheckpointInfo 类型
├── dataset/
│ ├── index.ts
│ ├── upload.ts # uploadDataset (multipart)
│ ├── validate.ts # validateJsonl (纯函数,逐行校验)
│ └── types.ts # DatasetFile, ValidationError 类型
└── deploy/
├── index.ts
├── api.ts # createDeployment, getDeployment, deleteDeployment
└── types.ts # Deployment, DeploymentStatus 类型
```
### packages/cli 新增命令
```
packages/cli/src/commands/
├── dataset/
│ └── upload.ts # bl dataset upload
├── finetune/
│ ├── create.ts # bl finetune create
│ ├── status.ts # bl finetune status
│ ├── logs.ts # bl finetune logs
│ └── checkpoints.ts # bl finetune checkpoints
└── deploy/
├── create.ts # bl deploy create
├── status.ts # bl deploy status
└── delete.ts # bl deploy delete
```
---
## 五、关键设计决策
### 5.1 数据格式校验放在 CLI 侧(提交前拦截)
训练失败 TOP 原因中"数据格式错误"占比高。与其等服务端 10 分钟后返回 FAILED不如 CLI 本地秒级校验:
- **validate.ts** 是纯函数,接收 ReadableStream/Buffer返回 `ValidationError[]`
- CLI 在 `dataset upload` 默认执行校验,`--no-validate` 允许跳过
- 未来可扩展为独立命令 `bl dataset validate <path>`
### 5.2 超参预填 + 确认而非强制
- core 维护 `defaults.ts` 映射:`model → { batch_size, lr, epochs }`
- CLI `finetune create` 未指定超参时自动填入
- 提交前展示完整参数面板(非 --yes 模式),避免"我以为用了默认但其实没传"
### 5.3 费用感知P1+
- 图像/语音/视频训练费用远高于文本。MVP 阶段Qwen 文本 SFT费用可控
- 后续扩展多模态时,在 confirm panel 中强化费用估算提示
- `bl quota check` 已存在,可在 `finetune create` 内部集成余额预检
### 5.4 `bl infer` 是否独立存在
建议 P1 阶段**不新增** `bl infer`,而是让 `bl text chat --model <deployed_model>` 直接工作。部署完成后的引导文案中指明这个用法即可。减少命令膨胀。
---
## 六、最小闭环用户操作流
```bash
# 1. 准备数据 → 上传(含校验)
bl dataset upload ./train.jsonl
# ✓ Uploaded file-abc123 (5.2 MB)
# 2. 创建训练任务(自动预填超参)
bl finetune create --model qwen3-8b --data file-abc123
# Shows summary panel → confirm → ✓ Job ft-xxxx created
# 3. 等待训练完成
bl finetune status ft-xxxx --wait
# ⠋ RUNNING [23:15] → ✓ SUCCEEDED: qwen3-8b:ft-xxxx-20250601
# 4. 部署模型
bl deploy create --model qwen3-8b:ft-xxxx-20250601 --wait
# ✓ Deployed: qwen3-8b-ft-xxxx (RUNNING)
# 5. 调用模型
bl text chat --model qwen3-8b-ft-xxxx "你好,介绍一下你自己"
# (正常推理输出)
```
---
## 七、实现顺序建议
```
Phase 1 (P0 — 最小闭环):
core: dataset/validate.ts → dataset/upload.ts → finetune/api.ts → deploy/api.ts
cli: dataset upload → finetune create → finetune status → deploy create
测试: 单元测试 validate.ts + e2e dry-run + 真实 API 端到端一次
Phase 2 (P1 — 可观测性):
finetune logs → finetune checkpoints → deploy status → deploy delete
费用估算集成
Phase 3 (后续):
bl dataset validate (独立命令)
bl dataset list (查看已上传)
bl finetune list (查看历史任务)
多模态 SFT 支持(图像/视频数据格式校验扩展)
```
---
## 八、风险与 TODO
| 风险点 | 影响 | 缓解措施 |
| ----------------- | ----------------- | --------------------------------------------- |
| OOM 训练失败 | 用户浪费时间/金钱 | 保守默认超参 + batch_size 自适应模型大小 |
| 数据格式错误 | 训练启动后才失败 | 本地校验拦截,启动秒级反馈 |
| 部署等待时间长 | 用户困惑 | `--wait` + 预估时间提示 |
| 费用超预期 | 账号欠费 | confirm panel 预估费用P1 集成 quota check |
| API endpoint 变动 | 调用失败 | 端点集中管理在 core/client/endpoints.ts |
+2
View File
@@ -16,8 +16,10 @@
"ready": "vp check && vp run -r test && vp run -r build",
"prepare": "vp config",
"check": "vp check",
"sync:skill-assets": "pnpm --filter \"bailian-cli^...\" run build && pnpm --filter bailian-cli run generate:reference && pnpm --filter bailian-cli run sync:skill-version",
"dev": "pnpm -F bailian-cli-core dev",
"bl": "pnpm -F bailian-cli dev",
"kscli": "pnpm -F knowledge-studio-cli dev",
"test": "vp test",
"release:check": "node tools/release/check.mjs",
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
+46 -21
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README_CN.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
---
@@ -27,15 +27,19 @@ 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 520s 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
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
- **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
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
- **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
## Showcase: One-Sentence Cinematic Video
@@ -51,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
@@ -64,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.
@@ -73,7 +77,7 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
```bash
npm install -g bailian-cli
npx skills add modelstudioai/skills --all -g
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 22.12.
@@ -108,9 +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
# Browse apps / free-tier quota
# 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 # 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 (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
# 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&)
@@ -119,7 +144,7 @@ bl usage free --model qwen3-max
### DashScope API Key
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cli?source_channel=key_github&).
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
```bash
# Option 1: Environment variable
@@ -134,15 +159,15 @@ bl text chat --api-key sk-xxxxx --message "Hello"
### Console Login (OAuth)
Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in.
Required for console capability commands (`app list`, `usage free`, `usage stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
```bash
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.
@@ -159,7 +184,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
bl config show
# Set defaults
bl config set --key region --value us
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
@@ -171,14 +196,14 @@ Config file location: `~/.bailian/config.json`
## Links
| Resource | URL |
| :--------------------------- | :---------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/ |
| Get API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
| Resource | URL |
| :--------------------------- | :---------------------------------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
+52 -24
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
---
@@ -27,15 +27,19 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **文本对话** — Qwen3.7-maxAgentic 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
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **控制台能力**浏览百炼应用(`app list`),查询模型免费额度(`usage free`
- **微调与部署**上传数据集、创建 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 小时
## 示例:一句话生成一部电影短片
@@ -51,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)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
### 唯一的提示词
@@ -62,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 分钟成片。
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
@@ -71,7 +75,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
```bash
npm install -g bailian-cli
npx skills add modelstudioai/skills --all -g
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 22.12。
@@ -79,7 +83,10 @@ npx skills add modelstudioai/skills --all -g
## 快速开始
```bash
# 认证
# 认证(推荐浏览器登录)
bl auth login --console
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 和通义千问对话
@@ -103,9 +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 # 各模型免费额度(可加 --model/--expiring/--sort
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
bl workspace list # 列出所有业务空间
# 限流管理与提额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&)
@@ -114,7 +142,7 @@ bl usage free --model qwen3-max
### DashScope API Key
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
```bash
# 方式一:环境变量
@@ -129,15 +157,15 @@ bl text chat --api-key sk-xxxxx --message "你好"
### 控制台登录OAuth
控制台能力命令(`app list``usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`app list``usage free``usage stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
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。
@@ -154,7 +182,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
bl config show
# 设置默认值
bl config set --key region --value us
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
@@ -166,19 +194,19 @@ bl update
## 相关链接
| 资源 | 地址 |
| :---------------------- | :---------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
| 资源 | 地址 |
| :---------------------- | :---------------------------------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
每个版本的变更详情记录在 [CHANGELOG_CN.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG_CN.md)。
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
## 参与贡献
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING_CN.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING_CN.md)。
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
+15 -18
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.2.0",
"version": "1.5.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
@@ -25,7 +25,7 @@
},
"files": [
"dist",
"README_CN.md"
"README.zh.md"
],
"type": "module",
"exports": {
@@ -33,41 +33,38 @@
"./package.json": "./package.json"
},
"publishConfig": {
"exports": {
".": "./dist/bailian.mjs",
"./package.json": "./package.json"
},
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"generate:reference": "node --experimental-strip-types ../../tools/generate-reference.ts",
"build": "pnpm run generate:reference && vp pack",
"generate:reference": "node --experimental-strip-types ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference'",
"sync:skill-version": "node --experimental-strip-types ../../tools/sync-skill-metadata.ts",
"build": "vp pack",
"dev": "node src/main.ts",
"test": "vp test",
"check": "vp check"
},
"dependencies": {
"bailian-cli-commands": "workspace:*",
"bailian-cli-core": "workspace:*",
"boxen": "catalog:",
"chalk": "catalog:"
"bailian-cli-runtime": "workspace:*"
},
"devDependencies": {
"@clack/prompts": "^0.7.0",
"@types/node": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20260328.1",
"ajv": "catalog:",
"boxen": "catalog:",
"chalk": "catalog:",
"typescript": "^6.0.2",
"vite-plus": "catalog:",
"undici": "catalog:",
"vite-plus": "0.1.22",
"yaml": "catalog:"
},
"engines": {
"node": ">=22.12.0"
},
"inlinedDependencies": {
"@clack/core": "0.3.5",
"@clack/prompts": "0.7.0",
"ajv": "8.20.0",
"fast-deep-equal": "3.1.3",
"fast-uri": "3.1.2",
"json-schema-traverse": "1.0.0",
"picocolors": "1.1.1",
"sisteransi": "1.0.5",
"yaml": "2.8.3"
}
}
+153
View File
@@ -0,0 +1,153 @@
import type { Command } from "bailian-cli-core";
import {
authLogin,
authStatus,
authLogout,
textChat,
textOmni,
imageGenerate,
imageEdit,
videoGenerate,
videoEdit,
videoRef,
videoTaskGet,
videoDownload,
visionDescribe,
configShow,
configSet,
update,
appCall,
appList,
memoryAdd,
memorySearch,
memoryList,
memoryUpdate,
memoryDelete,
memoryProfileCreate,
memoryProfileGet,
knowledgeRetrieve,
mcpCall,
mcpList,
mcpTools,
searchWeb,
speechSynthesize,
speechRecognize,
fileUpload,
consoleCall,
usageFree,
usageFreetier,
usageStats,
pipelineRun,
pipelineValidate,
advisorRecommend,
workspaceList,
quotaList,
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.
// The command paths below are this product's decision — the command library
// ships no presets, so the map is spelled out here. Kept in its own module
// (no side effects) so tools like generate-reference.ts can import it without
// starting the CLI.
export const commands: Record<string, Command> = {
"auth login": authLogin,
"auth status": authStatus,
"auth logout": authLogout,
"text chat": textChat,
omni: textOmni,
"image generate": imageGenerate,
"image edit": imageEdit,
"video generate": videoGenerate,
"video edit": videoEdit,
"video ref": videoRef,
"video task get": videoTaskGet,
"video download": videoDownload,
"vision describe": visionDescribe,
"config show": configShow,
"config set": configSet,
update,
"app call": appCall,
"app list": appList,
"memory add": memoryAdd,
"memory search": memorySearch,
"memory list": memoryList,
"memory update": memoryUpdate,
"memory delete": memoryDelete,
"memory profile create": memoryProfileCreate,
"memory profile get": memoryProfileGet,
"knowledge retrieve": knowledgeRetrieve,
"mcp call": mcpCall,
"mcp list": mcpList,
"mcp tools": mcpTools,
"search web": searchWeb,
"speech synthesize": speechSynthesize,
"speech recognize": speechRecognize,
"file upload": fileUpload,
"console call": consoleCall,
"usage free": usageFree,
"usage freetier": usageFreetier,
"usage stats": usageStats,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"advisor recommend": advisorRecommend,
"workspace list": workspaceList,
"quota list": quotaList,
"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,
};
-140
View File
@@ -1,140 +0,0 @@
import {
BailianError,
ExitCode,
chatEndpoint,
defineCommand,
getConfigPath,
isInteractive,
maskToken,
readConfigFile,
requestJson,
writeConfigFile,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { printQuickStart } from "../../output/banner.ts";
import { emitBare } from "../../output/output.ts";
import { promptConfirm } from "../../output/prompt.ts";
import { printCurrentCommandHelp } from "../../utils/command-help.ts";
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
const RETRY_DELAY_BASE_MS = 500;
function canRetry(err: unknown): boolean {
if (err instanceof BailianError) {
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) {
return true;
}
const status = err.api?.httpStatus;
return status === 401 || (status !== undefined && status >= 500);
}
if (err instanceof Error) {
return (
err.name === "AbortError" ||
err.name === "TimeoutError" ||
err.message.includes("timed out") ||
err.message === "fetch failed"
);
}
return false;
}
async function validateKeyAndPersist(config: Config, key: string): Promise<void> {
process.stderr.write("Testing key... ");
const testConfig = { ...config, apiKey: key };
const requestOpts = {
url: chatEndpoint(testConfig.baseUrl),
method: "POST",
timeout: Math.min(config.timeout, 30),
body: {
model: "qwen3.7-max",
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
},
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(testConfig, requestOpts);
break;
} catch (err) {
if (attempt >= 3 || !canRetry(err)) {
process.stderr.write("\n");
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
cause: err,
});
}
// retry delay: 500ms, 1000ms, 2000ms
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
process.stderr.write("Valid\n");
const existing = readConfigFile() as Record<string, unknown>;
existing.api_key = key;
await writeConfigFile(existing);
process.stderr.write(`Saved to ${getConfigPath()}\n`);
}
export default defineCommand({
name: "auth login",
description: "Authenticate with API key or console browser login (credentials can coexist)",
usage: "bl auth login --api-key <key> | bl auth login --console",
options: [
{ flag: "--api-key <key>", description: "DashScope API key to store" },
{
flag: "--console",
description: "Sign in via browser; opens the console login URL in your default browser",
type: "boolean",
},
],
examples: ["bl auth login --api-key sk-xxxxx", "bl auth login --console"],
async run(config: Config, flags: GlobalFlags) {
if (flags.console) {
if (config.dryRun) {
emitBare(
"Would bind a free port on 127.0.0.1 and open the console login URL in your browser.",
);
return;
}
const hasApiKey = !!(config.apiKey || config.fileApiKey);
await runConsoleLogin(resolveConsoleOrigin(), {
needApiKey: !hasApiKey,
onApiKey: (key) => validateKeyAndPersist(config, key),
});
return;
}
const envKey = process.env.DASHSCOPE_API_KEY;
if (envKey && !flags.apiKey) {
const maskedEnvKey = maskToken(envKey);
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const proceed = await promptConfirm({
message: `Detected DASHSCOPE_API_KEY in environment (${maskedEnvKey}).\nYou are already authenticated via env.\nDo you still want to configure local persistent credentials?`,
initialValue: false,
});
if (!proceed) {
process.stdout.write("Login skipped. Using environment variables.\n");
process.exit(0);
}
} else {
process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`);
}
}
const key = (flags.apiKey as string) || config.apiKey;
if (!key) {
printCurrentCommandHelp(process.stderr);
process.exit(0);
}
if (!config.dryRun) {
await validateKeyAndPersist(config, key);
printQuickStart();
} else {
emitBare("Would validate and save API key.");
}
},
});
-84
View File
@@ -1,84 +0,0 @@
import type { Command } from "bailian-cli-core";
import authLogin from "./auth/login.ts";
import authStatus from "./auth/status.ts";
import authLogout from "./auth/logout.ts";
import textChat from "./text/chat.ts";
import textOmni from "./omni/chat.ts";
import imageGenerate from "./image/generate.ts";
import imageEdit from "./image/edit.ts";
import videoGenerate from "./video/generate.ts";
import videoEdit from "./video/edit.ts";
import videoRef from "./video/ref.ts";
import videoTaskGet from "./video/task-get.ts";
import videoDownload from "./video/download.ts";
import visionDescribe from "./vision/describe.ts";
import configShow from "./config/show.ts";
import configSet from "./config/set.ts";
import configExportSchema from "./config/export-schema.ts";
import update from "./update.ts";
import appCall from "./app/call.ts";
import appList from "./app/list.ts";
import memoryAdd from "./memory/add.ts";
import memorySearch from "./memory/search.ts";
import memoryList from "./memory/list.ts";
import memoryUpdate from "./memory/update.ts";
import memoryDelete from "./memory/delete.ts";
import memoryProfileCreate from "./memory/profile-create.ts";
import memoryProfileGet from "./memory/profile-get.ts";
import knowledgeRetrieve from "./knowledge/retrieve.ts";
import mcpCall from "./mcp/call.ts";
import mcpList from "./mcp/list.ts";
import mcpTools from "./mcp/tools.ts";
import searchWeb from "./search/web.ts";
import speechSynthesize from "./speech/synthesize.ts";
import speechRecognize from "./speech/recognize.ts";
import fileUpload from "./file/upload.ts";
import consoleCall from "./console/call.ts";
import usageFree from "./usage/free.ts";
import pipelineRun from "./pipeline/run.ts";
import pipelineValidate from "./pipeline/validate.ts";
import advisorRecommend from "./advisor/recommend.ts";
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
export const commands: Record<string, Command> = {
"auth login": authLogin,
"auth status": authStatus,
"auth logout": authLogout,
"text chat": textChat,
omni: textOmni,
"image generate": imageGenerate,
"image edit": imageEdit,
"video generate": videoGenerate,
"video edit": videoEdit,
"video ref": videoRef,
"video task get": videoTaskGet,
"video download": videoDownload,
"vision describe": visionDescribe,
"app call": appCall,
"app list": appList,
"memory add": memoryAdd,
"memory search": memorySearch,
"memory list": memoryList,
"memory update": memoryUpdate,
"memory delete": memoryDelete,
"memory profile create": memoryProfileCreate,
"memory profile get": memoryProfileGet,
"knowledge retrieve": knowledgeRetrieve,
"mcp list": mcpList,
"mcp tools": mcpTools,
"mcp call": mcpCall,
"search web": searchWeb,
"speech synthesize": speechSynthesize,
"speech recognize": speechRecognize,
"file upload": fileUpload,
"console call": consoleCall,
"usage free": usageFree,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"config show": configShow,
"config set": configSet,
"config export-schema": configExportSchema,
"advisor recommend": advisorRecommend,
update: update,
};
@@ -1,46 +0,0 @@
import { defineCommand, generateToolSchema } from "bailian-cli-core";
import type { Config } from "bailian-cli-core";
import type { GlobalFlags } from "bailian-cli-core";
import { BailianError } from "bailian-cli-core";
import { ExitCode } from "bailian-cli-core";
/**
* Commands that are infrastructure/auth-related and not suitable as Agent tools.
*/
const SKIP_PREFIXES = ["auth ", "config ", "update"];
export default defineCommand({
name: "config export-schema",
description:
"Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas",
usage: 'bl config export-schema [--command "<name>"]',
options: [
{
flag: "--command <name>",
description: 'Export schema for a specific command only (e.g. "image generate")',
},
],
examples: ["bl config export-schema", 'bl config export-schema --command "video generate"'],
async run(config: Config, flags: GlobalFlags) {
const { commands } = await import("../catalog.ts");
const targetCommand = flags.command as string | undefined;
if (targetCommand) {
const command = commands[targetCommand];
if (!command) {
throw new BailianError(`Command "${targetCommand}" not found.`, ExitCode.USAGE);
}
const schema = generateToolSchema(command);
process.stdout.write(JSON.stringify(schema, null, 2) + "\n");
return;
}
// Export all suitable commands
const allCommands = Object.values(commands);
const schemas = allCommands
.filter((c) => !SKIP_PREFIXES.some((p) => c.name.startsWith(p)))
.map((c) => generateToolSchema(c));
process.stdout.write(JSON.stringify(schemas, null, 2) + "\n");
},
});
-44
View File
@@ -1,44 +0,0 @@
import {
defineCommand,
readConfigFile as loadConfigFile,
getConfigPath,
detectOutputFormat,
maskToken,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
export default defineCommand({
name: "config show",
description: "Display current configuration",
usage: "bl config show",
examples: ["bl config show", "bl config show --output json"],
async run(config: Config, _flags: GlobalFlags) {
const file = loadConfigFile();
const format = detectOutputFormat(config.output);
const result: Record<string, unknown> = {
region: config.region,
base_url: config.baseUrl,
output: config.output,
timeout: config.timeout,
config_file: getConfigPath(),
};
// Mask API key if present
if (file.api_key) {
result.api_key = maskToken(file.api_key);
}
if (file.access_token) {
result.access_token = maskToken(file.access_token);
}
// Default models
if (file.default_text_model) result.default_text_model = file.default_text_model;
if (file.default_video_model) result.default_video_model = file.default_video_model;
if (file.default_image_model) result.default_image_model = file.default_image_model;
emitResult(result, format);
},
});
-1
View File
@@ -1 +0,0 @@
export { commands } from "./catalog.ts";
@@ -1,155 +0,0 @@
import {
defineCommand,
signRequest,
detectOutputFormat,
maskToken,
type Config,
type GlobalFlags,
type KnowledgeRetrieveRequest,
type KnowledgeRetrieveResponse,
BailianError,
ExitCode,
trackingHeaders,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
const BAILIAN_HOST = "bailian.cn-beijing.aliyuncs.com";
export default defineCommand({
name: "knowledge retrieve",
description: "Retrieve from a Bailian knowledge base (requires AK/SK)",
usage: "bl knowledge retrieve --index-id <id> --query <text> [flags]",
options: [
{ flag: "--index-id <id>", description: "Knowledge base index ID (required)", required: true },
{ flag: "--query <text>", description: "Search query (required)", required: true },
{
flag: "--workspace-id <id>",
description: "Bailian workspace ID (or env BAILIAN_WORKSPACE_ID)",
},
{ flag: "--top-k <n>", description: "Number of results (default: 10)", type: "number" },
{ flag: "--rerank", description: "Enable rerank" },
{ flag: "--rerank-top-n <n>", description: "Rerank top N results", type: "number" },
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (or env)" },
{ flag: "--access-key-secret <key>", description: "Alibaba Cloud Access Key Secret (or env)" },
],
examples: [
'bl knowledge retrieve --index-id idx_xxx --query "如何使用阿里云百炼" --workspace-id ws_xxx',
'bl knowledge retrieve --index-id idx_xxx --query "API限流" --top-k 5 --rerank',
],
async run(config: Config, flags: GlobalFlags) {
const indexId = flags.indexId as string;
if (!indexId) failIfMissing("index-id", "bl knowledge retrieve --index-id <id> --query <text>");
const query = flags.query as string;
if (!query) failIfMissing("query", "bl knowledge retrieve --index-id <id> --query <text>");
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
if (!accessKeyId || !accessKeySecret) {
throw new BailianError(
"Knowledge retrieve requires Alibaba Cloud AK/SK.\n" +
"Set via: --access-key-id / --access-key-secret flags,\n" +
" or env: ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET,\n" +
" or config: bl config set access_key_id <key>",
ExitCode.AUTH,
);
}
if (!workspaceId) {
throw new BailianError(
"Knowledge retrieve requires a workspace ID.\n" +
"Set via: --workspace-id flag, or env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
ExitCode.USAGE,
);
}
const body: KnowledgeRetrieveRequest = {
IndexId: indexId,
Query: query,
};
if (flags.topK !== undefined) body.TopK = flags.topK as number;
if (flags.rerank) body.Rerank = true;
if (flags.rerankTopN !== undefined) body.RerankTopN = flags.rerankTopN as number;
const format = detectOutputFormat(config.output);
const pathname = `/${workspaceId}/index/retrieve`;
if (config.dryRun) {
emitResult(
{
endpoint: `https://${BAILIAN_HOST}${pathname}`,
workspaceId,
request: body,
},
format,
);
return;
}
const bodyStr = JSON.stringify(body);
const headers = signRequest({
accessKeyId,
accessKeySecret,
action: "Retrieve",
version: "2023-12-29",
body: bodyStr,
host: BAILIAN_HOST,
pathname,
});
const url = `https://${BAILIAN_HOST}${pathname}`;
if (config.verbose) {
process.stderr.write(`> POST ${url}\n`);
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(url, {
method: "POST",
headers: {
...headers,
...trackingHeaders(),
},
body: bodyStr,
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as KnowledgeRetrieveResponse & {
Code?: string;
Message?: string;
};
if (!res.ok || (data.Code && data.Code !== "Success")) {
throw new BailianError(
`Knowledge retrieve failed: ${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
if (config.quiet || format === "text") {
const nodes = data.Data?.Nodes || [];
if (nodes.length === 0) {
emitBare("No results found.");
} else {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
emitBare(`[${i + 1}] (score: ${node.Score.toFixed(4)})`);
emitBare(node.Text);
emitBare("");
}
}
} else {
emitResult(data, format);
}
},
});
-70
View File
@@ -1,70 +0,0 @@
import { execSync } from "child_process";
import { writeFileSync } from "fs";
import { join } from "path";
import { defineCommand, getConfigDir } from "bailian-cli-core";
import { CLI_VERSION } from "../version.ts";
import { NPM_PACKAGE, fetchLatestVersion } from "../utils/update-checker.ts";
/** Build the install command */
function detectInstallCommand(): { cmd: string; label: string } {
return { cmd: `npm install -g ${NPM_PACKAGE}@latest`, label: "npm" };
}
export default defineCommand({
name: "update",
description: "Update bl to the latest version",
usage: "bl update",
examples: ["bl update"],
async run() {
const isTTY = process.stderr.isTTY;
const green = isTTY ? "\x1b[32m" : "";
const yellow = isTTY ? "\x1b[33m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`Current version: ${yellow}${CLI_VERSION}${reset}\n`);
// Check latest version first
process.stderr.write("Checking for updates...\n");
const latest = await fetchLatestVersion(5000);
if (latest && latest === CLI_VERSION) {
process.stderr.write(`${green}\u2713 Already up to date (${CLI_VERSION}).${reset}\n`);
return;
}
if (latest) {
process.stderr.write(`Latest version: ${green}${latest}${reset}\n\n`);
}
const { cmd, label } = detectInstallCommand();
process.stderr.write(`Updating ${NPM_PACKAGE} via ${label}...\n\n`);
try {
execSync(cmd, { stdio: "inherit" });
// Verify the installed version after update
try {
const rawVer = execSync("bl --version 2>/dev/null", { encoding: "utf-8" }).trim();
// bl --version outputs "bl X.Y.Z" — extract just the version number
const newVer = rawVer.replace(/^bl\s+/, "");
process.stderr.write(
`\n${green}\u2713 Update complete: ${CLI_VERSION} \u2192 ${newVer}${reset}\n`,
);
// Update the cached state so the post-run notification doesn't fire
try {
const stateFile = join(getConfigDir(), "update-state.json");
writeFileSync(
stateFile,
JSON.stringify({ lastChecked: Date.now(), latestVersion: newVer }),
);
} catch {
/* ignore */
}
} catch {
process.stderr.write(`\n${green}\u2713 Update complete.${reset}\n`);
}
} catch {
process.stderr.write("\nAutomatic update failed. Please run manually:\n");
process.stderr.write(` ${cmd}\n\n`);
}
},
});
-65
View File
@@ -1,65 +0,0 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
export default defineCommand({
name: "usage free",
description: "Query free-tier quota for a model",
usage: "bl usage free --model <model> [flags]",
options: [
{
flag: "--model <model>",
description: "Model name to query (e.g. qwen3-max, qwen-turbo)",
required: true,
},
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
},
],
examples: [
"bl usage free --model qwen3-max",
"bl usage free --model qwen-turbo --output json",
"bl usage free --model qwen3-max --region cn-beijing",
],
async run(config: Config, flags: GlobalFlags) {
const model = flags.model as string;
if (!model) failIfMissing("model", "bl usage free --model <model>");
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
const credential = await resolveConsoleGatewayCredential(config);
const data = {
queryFreeTierQuotaRequest: {
models: [model],
},
};
if (config.dryRun) {
emitResult(
{ api: FREE_TIER_API, data, region, token: credential.token.slice(0, 8) + "..." },
format,
);
return;
}
const result = await callConsoleGateway(config, credential.token, {
api: FREE_TIER_API,
data,
region,
});
emitResult(result, format);
},
});
+9 -180
View File
@@ -1,181 +1,10 @@
import { scanCommandPath, parseFlags } from "./args.ts";
import { registry } from "./registry.ts";
import {
GLOBAL_OPTIONS,
loadConfig,
readConfigFile,
resolveCredential,
trackCommandExecution,
flushTelemetry,
type Region,
} from "bailian-cli-core";
import { ensureApiKey } from "./utils/ensure-key.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
import {
printCurrentCommandHelp,
registerCommandHelpPrinter,
setExecutingCommandPath,
} from "./utils/command-help.ts";
import { createCli } from "bailian-cli-runtime";
import { commands } from "./commands.ts";
import pkg from "../package.json" with { type: "json" };
registerCommandHelpPrinter((commandPath, out) => {
const a = process.argv.slice(2);
const ri = a.indexOf("--region");
const region = ((ri >= 0 && a[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, out, region);
});
// 优雅处理 Ctrl+C
// 退出前尝试 best-effort 刷出埋点,让去抖队列中 / 在途的 fetch 请求有机会
// 落网络flush 与较短超时 race保证 SIGINT 仍然响应及时。
process.on("SIGINT", () => {
process.stderr.write("\nInterrupted. Exiting.\n");
void flushTelemetry(500).finally(() => process.exit(130));
});
// 优雅处理 stdout EPIPE例如管道到提前退出的 `mpv`
process.stdout.on("error", (e: NodeJS.ErrnoException) => {
if (e.code === "EPIPE") process.exit(0);
else throw e;
});
// 自己接管鉴权 或 根本不需要 API key 的命令
const NO_AUTH_SETUP = [
["auth", "login"],
["auth", "logout"],
["config", "show"],
["config", "set"],
["config", "export-schema"],
["update"],
["knowledge", "retrieve"],
["pipeline", "run"],
["pipeline", "validate"],
["model", "list"],
["app", "list"],
["console", "call"],
["usage", "free"],
["mcp", "list"],
["mcp", "tools"],
["mcp", "call"],
];
async function main() {
let argv = process.argv.slice(2);
if (argv[0] === "--") argv = argv.slice(1);
if (argv.includes("--version") || argv.includes("-v")) {
process.stdout.write(`bl ${CLI_VERSION}\n`);
process.exit(0);
}
const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS);
if (argv.includes("--help") || argv.includes("-h")) {
const ri = argv.indexOf("--region");
const region = ((ri >= 0 && argv[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, process.stderr, region);
process.exit(0);
}
// 未传任何命令:展示帮助信息与登录引导
if (commandPath.length === 0) {
registry.printHelp([], process.stderr);
const flags = parseFlags(argv, GLOBAL_OPTIONS);
const config = loadConfig(flags);
config.clientName = "bailian-cli";
config.clientVersion = CLI_VERSION;
const hasKey = !!(
config.apiKey ||
config.fileApiKey ||
config.fileAccessToken ||
config.accessTokenEnv
);
if (hasKey) printQuickStart();
else printWelcomeBanner();
process.exit(0);
}
// 组路径(例如 `bl speech` 未接子命令):展示帮助后干净退出
if (registry.isGroupPath(commandPath)) {
const ri = argv.indexOf("--region");
const region = ((ri >= 0 && argv[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, process.stderr, region);
process.exit(0);
}
const { command, extra } = registry.resolve(commandPath);
const flags = parseFlags(argv, [...GLOBAL_OPTIONS, ...(command.options ?? [])]);
if (extra.length > 0) (flags as Record<string, unknown>)._positional = extra;
const config = loadConfig(flags);
config.clientName = "bailian-cli";
config.clientVersion = CLI_VERSION;
const needsAuthSetup = !NO_AUTH_SETUP.some((cmd) => cmd.every((c, i) => commandPath[i] === c));
if (needsAuthSetup) {
await ensureApiKey(config);
try {
const credential = await resolveCredential(config);
maybeShowStatusBar(config, credential.token, credential);
} catch {
/* 没有凭证,不展示状态栏 */
}
}
const updateCheckPromise = checkForUpdate(CLI_VERSION).catch(() => {});
setExecutingCommandPath(commandPath);
if (
commandPath[0] === "auth" &&
commandPath[1] === "login" &&
!flags.console &&
!String((flags.apiKey as string | undefined) ?? "").trim() &&
!String(config.apiKey ?? "").trim() &&
!process.env.DASHSCOPE_API_KEY?.trim()
) {
printCurrentCommandHelp(process.stderr);
process.exit(0);
}
await trackCommandExecution(config, commandPath, flags, () => command.execute(config, flags));
await updateCheckPromise;
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.quiet && !isUpdateCommand) {
const isTTY = process.stderr.isTTY;
const yellow = isTTY ? "\x1b[33m" : "";
const cyan = isTTY ? "\x1b[36m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`\n ${yellow}Update available: ${CLI_VERSION}${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
// 进程退出前尽力等待在途的埋点完成。
// 使用较短超时兜底,避免慢网拖慢用户感知。
await flushTelemetry(1000);
}
main().catch((err) => {
// 在 handleError() 调用 process.exit() 之前刷出在途埋点。
// 命令抛出的错误已被 trackCommandExecution 的 finally 块记录,
// 但底层 tracker 有 ~500ms 的发送去抖。不主动 flush 的话,
// 错误事件会随进程退出丢掉。
void flushTelemetry(1000).finally(() => handleError(err));
});
void createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
}).run();
-97
View File
@@ -1,97 +0,0 @@
import { join } from "path";
import { readFileSync, writeFileSync } from "fs";
import { getConfigDir, trackingHeaders } from "bailian-cli-core";
export const NPM_REGISTRY = "https://registry.npmjs.org";
export const NPM_PACKAGE = "bailian-cli";
const STATE_FILE = () => join(getConfigDir(), "update-state.json");
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
const FETCH_TIMEOUT_MS = 3000;
/**
* Simple semver comparison: returns true if a > b.
* Supports standard x.y.z format.
*/
function isNewerVersion(a: string, b: string): boolean {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
}
return false; // equal
}
interface UpdateState {
lastChecked: number;
latestVersion: string;
}
function readState(): UpdateState | null {
try {
const raw = readFileSync(STATE_FILE(), "utf-8");
return JSON.parse(raw) as UpdateState;
} catch {
return null;
}
}
function writeState(state: UpdateState): void {
try {
writeFileSync(STATE_FILE(), JSON.stringify(state));
} catch {
/* ignore */
}
}
export async function fetchLatestVersion(
timeoutMs: number = FETCH_TIMEOUT_MS,
): Promise<string | null> {
try {
const encoded = NPM_PACKAGE.replace("/", "%2f");
const res = await fetch(`${NPM_REGISTRY}/${encoded}/latest`, {
headers: {
Accept: "application/json",
...trackingHeaders(),
},
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
const data = (await res.json()) as { version?: string };
return data.version ?? null;
} catch {
return null;
}
}
let pendingNotification: string | null = null;
export function getPendingUpdateNotification(): string | null {
return pendingNotification;
}
export async function checkForUpdate(currentVersion: string): Promise<void> {
// Skip in CI / non-TTY environments
if (process.env.CI || !process.stderr.isTTY) return;
const state = readState();
const now = Date.now();
// Throttle: skip if checked within the last 4 hours
if (state && now - state.lastChecked < CHECK_INTERVAL_MS) {
if (state.latestVersion && isNewerVersion(state.latestVersion, currentVersion)) {
pendingNotification = state.latestVersion;
}
return;
}
const latest = await fetchLatestVersion();
if (!latest) return;
writeState({ lastChecked: now, latestVersion: latest });
if (latest && isNewerVersion(latest, currentVersion)) {
pendingNotification = latest;
}
}
@@ -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."}]}
@@ -2,21 +2,21 @@ import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: advisor recommend", () => {
test("advisor 分组展示子命令帮助且成功退出", async () => {
test("advisor shows subcommand groups and exits successfully", async () => {
const { stdout, stderr, exitCode } = await runCli(["advisor"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/advisor|recommend/i);
});
test("advisor recommend --help 正常退出", async () => {
test("advisor recommend --help exits successfully", async () => {
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recommend|--message|dry-run/i);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope", () => {
test("advisor recommend 缺少 --message 时打印帮助并退出 (0)", async () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => {
test("advisor recommend without --message prints help and exits", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
@@ -26,13 +26,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
});
test("advisor recommend --dry-run 输出意图分析和候选列表", async () => {
test("advisor recommend --dry-run outputs intent analysis and candidates", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"我想做一个能理解图片的客服机器人",
"I want to build a customer service bot that understands images",
"--non-interactive",
"--output",
"json",
@@ -44,7 +44,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
candidateCount?: number;
candidates?: Array<{ model?: string; score?: number }>;
}>(stdout);
expect(data.userInput).toBe("我想做一个能理解图片的客服机器人");
expect(data.userInput).toBe("I want to build a customer service bot that understands images");
expect(data.intent?.requiredCapabilities).toContain("VU");
expect(data.intent?.inputModality).toContain("Image");
expect(data.candidateCount).toBeGreaterThan(0);
@@ -52,40 +52,44 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
expect(data.candidates?.[0]?.score).toBeGreaterThan(0);
}, 60_000);
test("advisor recommend 完整推荐流程返回结果", async () => {
test("advisor recommend full flow returns results", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--message",
"低成本高并发的在线客服",
"low-cost high-concurrency online customer service",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
type?: string;
recommendations?: Array<{
model?: string;
name?: string;
reason?: string;
}>;
intent?: { taskSummary?: string };
result?: {
type?: string;
recommendations?: Array<{
model?: string;
name?: string;
reason?: string;
}>;
};
candidates?: number;
}>(stdout);
expect(data.type).toBe("single");
expect(data.recommendations?.length).toBeGreaterThan(0);
expect(data.recommendations?.[0]?.model).toBeDefined();
expect(data.recommendations?.[0]?.reason).toBeDefined();
expect(data.result?.type).toBe("single");
expect(data.result?.recommendations?.length).toBeGreaterThan(0);
expect(data.result?.recommendations?.[0]?.model).toBeDefined();
expect(data.result?.recommendations?.[0]?.reason).toBeDefined();
}, 120_000);
// ---- 模型偏好:正例 ----
// ---- Model preference: positive cases ----
test("scoped 偏好 — 限定系列时 intent 含 modelPreference.mode=scoped", async () => {
test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"deepseek系列中哪个模型最适合用来进行快速推理",
"Which model in the deepseek family is best for fast reasoning?",
"--non-interactive",
"--output",
"json",
@@ -103,13 +107,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
).toBe(true);
}, 60_000);
test("comparison 偏好 — 对比模型时 intent 含 modelPreference.mode=comparison", async () => {
test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"qwen-maxdeepseek-v3哪个更适合做代码生成",
"Which is better for code generation, qwen-max or deepseek-v3?",
"--non-interactive",
"--output",
"json",
@@ -122,40 +126,29 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
expect(data.intent?.modelPreference?.targets?.length).toBeGreaterThanOrEqual(2);
}, 60_000);
test("excludes 偏好 — 排除模型时 intent 识别出 modelPreference", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("excludes preference — intent detects modelPreference when excluding models", async () => {
const { stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"不要qwen推荐一个适合文本生成的模型",
"Not qwen, recommend a model suitable for text generation",
"--non-interactive",
"--output",
"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 ----
test("无偏好 — 普通需求查询时 intent 不含 modelPreference mode=unconstrained", async () => {
test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"我要做一个能理解图片的客服机器人",
"I want to build a customer service bot that understands images",
"--non-interactive",
"--output",
"json",
+22 -16
View File
@@ -17,6 +17,7 @@ describe("e2e: auth", () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/login|api-key/i);
expect(stderr).toMatch(/--console-site/);
});
test("auth logout --help 正常退出", async () => {
@@ -159,20 +160,25 @@ describe("e2e: auth", () => {
expect(data.dashscope_commands?.method).toBeDefined();
});
test.skipIf(!isDashScopeE2EReady())("auth status --output json --quiet --region cn", async () => {
const { stdout, stderr, exitCode } = await runCli([
"auth",
"status",
"--non-interactive",
"--output",
"json",
"--quiet",
"--region",
"cn",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ authenticated?: boolean; dashscope_commands?: unknown }>(stdout);
expect(data.authenticated).toBe(true);
expect(data.dashscope_commands).toBeDefined();
});
test.skipIf(!isDashScopeE2EReady())(
"auth status --output json --quiet --base-url 国内",
async () => {
const { stdout, stderr, exitCode } = await runCli([
"auth",
"status",
"--non-interactive",
"--output",
"json",
"--quiet",
"--base-url",
"https://dashscope.aliyuncs.com",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ authenticated?: boolean; dashscope_commands?: unknown }>(
stdout,
);
expect(data.authenticated).toBe(true);
expect(data.dashscope_commands).toBeDefined();
},
);
});
+1 -65
View File
@@ -25,12 +25,6 @@ describe("e2e: config", () => {
expect(stderr).toMatch(/set|--key|--value/i);
});
test("config export-schema --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["config", "export-schema", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/export-schema|--command/i);
});
test("config show --output json", async () => {
const { stdout, stderr, exitCode } = await runCli([
"config",
@@ -41,12 +35,10 @@ describe("e2e: config", () => {
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
region?: string;
config_file?: string;
base_url?: string;
timeout?: number;
}>(stdout);
expect(data.region).toBeDefined();
expect(data.config_file).toBeDefined();
expect(data.base_url).toBeDefined();
expect(data.timeout).toBeDefined();
@@ -62,7 +54,7 @@ describe("e2e: config", () => {
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/region|config_file|timeout|base_url/i);
expect(stdout).toMatch(/config_file|timeout|base_url/i);
});
test("config set 缺少 --key / --value 时退出为用法错误 (2)", async () => {
@@ -85,20 +77,6 @@ describe("e2e: config", () => {
expect(stderr).toMatch(/Invalid config key|not-a-real-key/i);
});
test("config set 非法 region", async () => {
const { stderr, exitCode } = await runCli([
"config",
"set",
"--non-interactive",
"--key",
"region",
"--value",
"invalid-region",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid region|cn, us, intl/i);
});
test("config set 非法 output", async () => {
const { stderr, exitCode } = await runCli([
"config",
@@ -162,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,148 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
type ConsoleDryRunMeta = {
consoleRegion?: string;
consoleSite?: string;
consoleSwitchAgent?: number;
};
/**
* E2E for global console flags (`--console-region`, `--console-site`,
* `--console-switch-agent`) and DashScope `--base-url`.
*/
describe("e2e: console global flags", () => {
test("根帮助展示 --base-url 与 console 全局标志", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--base-url/);
expect(stderr).toMatch(/--console-region/);
expect(stderr).toMatch(/--console-site/);
expect(stderr).toMatch(/--console-switch-agent/);
expect(stderr).not.toMatch(/^\s*--region\s/m);
});
test("quota check --help 不重复命令级 region并提示全局 flags", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Global flags.*always available/i);
expect(stderr).toMatch(/--model <model>/);
expect(stderr).toMatch(/--period <minutes>/);
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
});
test("console call --help 不暴露命令级 region/site示例使用 --console-region", async () => {
const { stderr, exitCode } = await runCli(["console", "call", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--api <api>/);
expect(stderr).toMatch(/--data <json>/);
expect(stderr).not.toMatch(/^\s*--region\s/m);
expect(stderr).not.toMatch(/^\s*--site\s/m);
expect(stderr).toMatch(/--console-region cn-beijing/);
});
test("auth login --help 描述 --console 与 --console-site 配合", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--console-site/);
expect(stderr).toMatch(/--console.*console-site|console-site.*domestic|international/i);
});
test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => {
const { stdout, stderr, exitCode } = await runCli([
"console",
"call",
"--api",
"some.api.name",
"--data",
"{}",
"--dry-run",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
expect(data.consoleRegion).toBe("cn-beijing");
expect(data.consoleSite).toBe("domestic");
});
test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"console",
"call",
"--api",
"some.api.name",
"--data",
"{}",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--console-region",
"ap-southeast-1",
"--console-site",
"international",
"--console-switch-agent",
"12345",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
expect(data.consoleRegion).toBe("ap-southeast-1");
expect(data.consoleSite).toBe("international");
expect(data.consoleSwitchAgent).toBe(12345);
});
test("console call 拒绝未知全局 flag --region", async () => {
const { stderr, exitCode } = await runCli([
"console",
"call",
"--api",
"some.api.name",
"--data",
"{}",
"--dry-run",
"--non-interactive",
"--region",
"cn",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--region/);
});
test("mcp list --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"list",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--console-region",
"cn-hangzhou",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
expect(data.consoleRegion).toBe("cn-hangzhou");
});
test("quota check --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--console-region",
"cn-hangzhou",
"--console-site",
"international",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
expect(data.consoleRegion).toBe("cn-hangzhou");
expect(data.consoleSite).toBe("international");
});
});
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, test } from "vite-plus/test";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Dataset (fine-tune file) E2E.
*
* The suite exercises command discovery, help text, local dataset validation,
* and the `--dry-run` upload preview with no network dependency. Because
* `ensureApiKey` runs before every command (see main.ts), these cases are
* gated by isDashScopeE2EReady() — they are skipped when no DashScope
* credential is present (e.g. on CI) and run offline when one is. (`dataset
* validate` itself is keyless via skipDefaultApiKeySetup, but the rest of the
* suite needs a key, so the whole offline block is gated together.) The
* remote list test is also gated.
*/
describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (offline)", () => {
test("dataset --help 列出子命令", async () => {
const { stdout, stderr, exitCode } = await runCli(["dataset"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/upload|list|get|delete|validate/);
});
test("dataset upload --help 正常退出并展示 --file", async () => {
const { stderr, exitCode } = await runCli(["dataset", "upload", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--file|jsonl/i);
});
test("dataset validate 通过合法 JSONL", async () => {
const file = join(__dirname, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ valid: boolean; format: string }>(stdout);
expect(data.valid).toBe(true);
expect(data.format).toBe("jsonl");
});
test("dataset validate 拒绝 pretty-printed JSON 并以非零码退出", async () => {
const file = join(__dirname, ".dataset-invalid.jsonl");
const { stdout, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
// The structured result is still emitted to stdout before the error throws.
if (stdout.trim().length > 0) {
const data = parseStdoutJson<{ valid: boolean; errors: unknown[] }>(stdout);
expect(data.valid).toBe(false);
expect(Array.isArray(data.errors)).toBe(true);
}
});
test("dataset upload --no-validate --dry-run 跳过本地校验", async () => {
const file = join(__dirname, ".dataset-invalid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"upload",
"--file",
file,
"--no-validate",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string; validate: boolean }>(stdout);
expect(data.action).toBe("dataset.upload");
expect(data.validate).toBe(false);
});
test("dataset validate 自动识别 DPO 并校验 chosen/rejected", async () => {
// No --schema: a record carrying chosen/rejected is auto-detected as DPO
// and the valid fixture passes.
const file = join(__dirname, ".dataset-dpo-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ valid: boolean; stats: { totalRecords?: number } }>(stdout);
expect(data.valid).toBe(true);
expect(data.stats.totalRecords).toBe(2);
});
test("dataset validate 自动识别 CPT 并校验 {text} 记录", async () => {
// No --schema: a record carrying `text` (and no `messages`) is auto-detected
// as CPT and the valid fixture passes.
const file = join(__dirname, ".dataset-cpt-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ valid: boolean; stats: { totalRecords?: number } }>(stdout);
expect(data.valid).toBe(true);
expect(data.stats.totalRecords).toBe(2);
});
test("dataset validate --schema cpt 拒绝缺失 text 的记录", async () => {
const file = join(__dirname, ".dataset-valid.jsonl"); // SFT {messages}, no text
const { stdout, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--schema",
"cpt",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
const data = parseStdoutJson<{ valid: boolean; errors: { code: string; path?: string }[] }>(
stdout,
);
expect(data.valid).toBe(false);
expect(data.errors.map((e) => e.code)).toContain("MISSING_TEXT");
});
test("dataset validate --schema dpo 拒绝缺失 rejected 的记录", async () => {
const file = join(__dirname, ".dataset-dpo-invalid.jsonl");
const { stdout, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--schema",
"dpo",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
const data = parseStdoutJson<{ valid: boolean; errors: { code: string; path?: string }[] }>(
stdout,
);
expect(data.valid).toBe(false);
expect(data.errors.map((e) => e.code)).toContain("MISSING_REJECTED");
});
test("dataset validate --schema chatml 忽略 chosen/rejected不报 DPO 错误)", async () => {
// Same invalid-DPO file, but --schema chatml must not run DPO checks.
const file = join(__dirname, ".dataset-dpo-invalid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--schema",
"chatml",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ valid: boolean; errors: { code: string }[] }>(stdout);
expect(data.valid).toBe(true);
expect(data.errors.filter((c) => c.code.startsWith("MISSING_"))).toEqual([]);
});
test("dataset validate --schema <bad> 以非零码退出", async () => {
const file = join(__dirname, ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"validate",
"--file",
file,
"--schema",
"sft",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/Unsupported --schema/);
});
test("dataset upload --dry-run 转发 --schema", async () => {
const file = join(__dirname, ".dataset-dpo-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"upload",
"--file",
file,
"--schema",
"dpo",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string; schema: string }>(stdout);
expect(data.action).toBe("dataset.upload");
expect(data.schema).toBe("dpo");
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: dataset (DashScope)", () => {
test("dataset list --output json 返回结构化结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
"dataset",
"list",
"--page-size",
"5",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ data?: { files?: unknown[] } }>(stdout);
expect(data).toBeTruthy();
if (data.data?.files) {
expect(Array.isArray(data.data.files)).toBe(true);
}
}, 60_000);
});
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
/**
* Deploy E2E.
*
* The suite exercises command discovery, help text, and the `--dry-run`
* structured-output path (arg parsing + body construction) with no network
* dependency. Because `ensureApiKey` runs before every command (see main.ts),
* these cases are gated by isDashScopeE2EReady() — they are skipped when no
* DashScope credential is present (e.g. on CI) and run offline when one is.
* The remote list test is also gated and tolerates both empty accounts and
* auth/permission failures (see the test comment).
*/
describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
test("deploy 列出子命令", async () => {
const { stdout, stderr, exitCode } = await runCli(["deploy"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/create|list|get|delete|update|scale|models/);
});
test("deploy create --help 正常退出并展示必填项", async () => {
const { stderr, exitCode } = await runCli(["deploy", "create", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--name/i);
});
test("deploy create --dry-run 构造 lora 部署请求体", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"create",
"--model",
"qwen-plus-2025-12-01",
"--name",
"my-qwen-plus",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
model_name: string;
name: string;
plan: string;
capacity: number;
};
}>(stdout);
expect(data.action).toBe("deploy.create");
expect(data.body.model_name).toBe("qwen-plus-2025-12-01");
expect(data.body.name).toBe("my-qwen-plus");
expect(data.body.plan).toBe("lora");
expect(data.body.capacity).toBe(1);
});
test("deploy scale --dry-run 转发 capacity", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"scale",
"--deployed-model",
"dep-xxx",
"--capacity",
"8",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
deployed_model: string;
body: { capacity: number };
}>(stdout);
expect(data.action).toBe("deploy.scale");
expect(data.deployed_model).toBe("dep-xxx");
expect(data.body.capacity).toBe(8);
});
test("deploy update --dry-run 转发 rate limits", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"update",
"--deployed-model",
"dep-xxx",
"--rpm-limit",
"1000",
"--tpm-limit",
"200000",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: { rpm_limit: number; tpm_limit: number };
}>(stdout);
expect(data.action).toBe("deploy.update");
expect(data.body.rpm_limit).toBe(1000);
expect(data.body.tpm_limit).toBe(200000);
});
test("deploy scale --dry-run 缺少 capacity/input-tpm/output-tpm 时报错", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"scale",
"--deployed-model",
"dep-xxx",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).not.toBe(0);
// Nothing useful emitted to stdout on a usage error.
expect(stdout.trim()).toBe("");
});
test.each([
["list", ["--status", "RUNNING"]],
["get", ["--deployed-model", "dep-xxx"]],
["models", ["--source", "custom"]],
["delete", ["--deployed-model", "dep-xxx"]],
])("deploy %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
sub,
...extra,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string }>(stdout);
expect(data.action).toBe(`deploy.${sub}`);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (DashScope)", () => {
/**
* 不同开发者的 key 状态不一:可能鉴权失败、可能账号下没有任何部署记录、
* 也可能受区域/权限限制。因此本用例不假设"有数据"或"调用成功"
* - 成功exit 0响应必须可解析deployments 可能为空数组或不存在。
* - 失败(非零退出):只要 CLI 把服务端/鉴权错误优雅上抛stderr 有内容、
* 而非进程崩溃),即视为通过。
*/
test("deploy list --output json 优雅返回(空账号或鉴权失败均通过)", async () => {
const { stdout, stderr, exitCode } = await runCli([
"deploy",
"list",
"--page-size",
"5",
"--output",
"json",
]);
if (exitCode === 0) {
const data = parseStdoutJson<{ data?: { deployments?: unknown[] } }>(stdout);
expect(data).toBeTruthy();
if (data.data?.deployments) {
expect(Array.isArray(data.data.deployments)).toBe(true);
}
} else {
expect(stderr.length).toBeGreaterThan(0);
}
}, 60_000);
});
+296
View File
@@ -0,0 +1,296 @@
import { describe, expect, test } from "vite-plus/test";
import { join } from "path";
import { isDashScopeE2EReady, parseStdoutJson, runCli, cliPackageRoot } from "./helpers.ts";
/**
* Fine-tune E2E.
*
* The suite exercises command discovery, help text, and the `--dry-run`
* structured-output path (arg parsing + body construction) with no network
* dependency. Because `ensureApiKey` runs before every command (see main.ts),
* these cases are gated by isDashScopeE2EReady() — they are skipped when no
* DashScope credential is present (e.g. on CI) and run offline when one is.
* The remote list test is also gated and tolerates both empty accounts and
* auth/permission failures (see the test comment).
*/
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
test("finetune 列出子命令", async () => {
const { stdout, stderr, exitCode } = await runCli(["finetune"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/create|list|get|cancel|delete|logs|checkpoints|export|watch|capability/);
});
test("finetune create --help 正常退出并展示必填项", async () => {
const { stderr, exitCode } = await runCli(["finetune", "create", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
});
test("finetune create --dry-run 构造 SFT 默认请求体", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
"file-aaa,file-bbb",
"--validations",
"file-ccc",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
model: string;
training_file_ids: string[];
validation_file_ids: string[];
training_type: string;
hyper_parameters: { n_epochs: number };
};
}>(stdout);
expect(data.action).toBe("finetune.create");
expect(data.body.model).toBe("qwen3-8b");
expect(data.body.training_file_ids).toEqual(["file-aaa", "file-bbb"]);
expect(data.body.validation_file_ids).toEqual(["file-ccc"]);
expect(data.body.training_type).toBe("efficient_sft");
expect(data.body.hyper_parameters.n_epochs).toBe(3);
});
test("finetune create --dry-run 转发训练类型与超参", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
"file-aaa",
"--training-type",
"sft-lora",
"--n-epochs",
"5",
"--batch-size",
"16",
"--learning-rate",
"1.6e-5",
"--max-length",
"4096",
"--model-name",
"my-qwen-sft",
"--suffix",
"v1",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
training_type: string;
model_name: string;
finetuned_output_suffix: string;
hyper_parameters: {
n_epochs: number;
batch_size: number;
learning_rate: string;
max_length: number;
};
};
}>(stdout);
expect(data.body.training_type).toBe("efficient_sft");
expect(data.body.model_name).toBe("my-qwen-sft");
expect(data.body.finetuned_output_suffix).toBe("v1");
// batch_size is forwarded verbatim when within the [8, 1024] server range.
expect(data.body.hyper_parameters).toEqual({
n_epochs: 5,
batch_size: 16,
learning_rate: "1.6e-5",
max_length: 4096,
});
});
test("finetune create --training-type 拒绝不支持的训练类型值", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
"file-aaa",
"--training-type",
"cpt-lora",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
});
test("finetune create --dry-run 把本地路径标记为 pending 上传且不发起网络请求", async () => {
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
`${localPath},file-bbb`,
"--validations",
localPath,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: { training_file_ids: string[]; validation_file_ids: string[] };
pending_uploads: { field: string; path: string }[];
}>(stdout);
expect(data.action).toBe("finetune.create");
// Local path preserved verbatim in the body (no upload in dry-run).
expect(data.body.training_file_ids[0]).toBe(localPath);
expect(data.body.training_file_ids[1]).toBe("file-bbb");
expect(data.body.validation_file_ids).toEqual([localPath]);
// Two pending uploads: training (1 local) + validation (1 local).
expect(data.pending_uploads).toHaveLength(2);
expect(data.pending_uploads.map((p) => p.field).sort()).toEqual(["datasets", "validations"]);
});
test("finetune create --datasets 为空时拒绝", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
" , ",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
});
test("finetune create 样本数 <= batch_size 时提交前快速失败且不上传", async () => {
// The fixture has 3 records; the small-file auto-adjust sets batch_size=8,
// so 3 <= 8 trips the pre-submit gate. The gate fires before any upload,
// so this is fully offline (no key, no network) — the proof is that the
// error is the gate message AND no "Uploaded …" line ever appears.
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
localPath,
"--yes",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
const combined = `${stdout}\n${stderr}`;
expect(combined).toMatch(/not greater than batch_size/i);
// Crucially, no upload happened — the gate must fire before the upload step.
expect(combined).not.toMatch(/Uploaded .* → file-/);
});
test("finetune create --batch-size 过小仍按 8 下限比较(不绕过卡口)", async () => {
// Even with --batch-size 1 (server clamps to 8), 3 samples <= 8 still trips
// the gate — confirms the gate uses the clamped/effective batch, not the raw.
const localPath = join(cliPackageRoot, "tests", "e2e", ".dataset-valid.jsonl");
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
localPath,
"--batch-size",
"1",
"--yes",
"--output",
"json",
]);
expect(exitCode, stdout + stderr).not.toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/batch_size \(8\)/);
});
test.each([
["list", ["--status", "RUNNING"]],
["get", ["--job-id", "ft-xxx"]],
["checkpoints", ["--job-id", "ft-xxx"]],
["logs", ["--job-id", "ft-xxx", "--page-size", "50"]],
["export", ["--job-id", "ft-xxx", "--checkpoint", "ckpt-3", "--model-name", "m"]],
["cancel", ["--job-id", "ft-xxx"]],
["delete", ["--job-id", "ft-xxx"]],
["watch", ["--job-id", "ft-xxx"]],
["capability", ["--model", "qwen3-8b"]],
])("finetune %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
sub,
...extra,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ action: string }>(stdout);
expect(data.action).toBe(`finetune.${sub}`);
});
test("finetune create --dry-run 解析多 datasets 中的空白", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"create",
"--model",
"qwen3-8b",
"--datasets",
" file-a , ,file-b ",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
body: { training_file_ids: string[] };
}>(stdout);
expect(data.body.training_file_ids).toEqual(["file-a", "file-b"]);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {
/**
* 不同开发者的 key 状态不一:可能鉴权失败、可能账号下没有任何微调记录、
* 也可能受区域/权限限制。因此本用例不假设"有数据"或"调用成功"
* - 成功exit 0响应必须可解析jobs 可能为空数组或不存在。
* - 失败(非零退出):只要 CLI 把服务端/鉴权错误优雅上抛stderr 有内容、
* 而非进程崩溃),即视为通过。
*/
test("finetune list --output json 优雅返回(空账号或鉴权失败均通过)", async () => {
const { stdout, stderr, exitCode } = await runCli([
"finetune",
"list",
"--page-size",
"5",
"--output",
"json",
]);
if (exitCode === 0) {
const data = parseStdoutJson<{ data?: { jobs?: unknown[] } }>(stdout);
expect(data).toBeTruthy();
if (data.data?.jobs) {
expect(Array.isArray(data.data.jobs)).toBe(true);
}
} else {
expect(stderr.length).toBeGreaterThan(0);
}
}, 60_000);
});
+43 -1
View File
@@ -101,6 +101,26 @@ export function isDashScopeE2EReady(): boolean {
}
}
/**
* Console-gateway 命令quota / usage free / usage stats的 E2E 就绪检查:
* 需 `BAILIAN_E2E=1` 且存在 console access_token环境变量 `DASHSCOPE_ACCESS_TOKEN`
* 或 `~/.bailian/config.json` 的 `access_token`)。
*
* 仅检查 token 是否存在——无法本地判断是否过期。token 过期时 gated 用例仍会执行,
* 但用 `isConsoleAuthFailure` 把“session 未登录/已过期”的优雅报错视为通过,保持
* 与 deploy/dataset “无 key / 有效 key / 失效 key 均绿”的一致策略。
*/
export function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 在仅跑文本/记忆/知识库时跳过) */
export function isBailianE2EMediaEnabled(): boolean {
if (process.env.BAILIAN_E2E_MEDIA === "0") return false;
@@ -117,8 +137,17 @@ export function e2eLabelFromMetaUrl(metaUrl: string): string {
return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, "");
}
/** 知识库用例:须显式索引 ID + AK/SKworkspace 可读 config / env故不在此强制校验 */
/** 知识库用例:须显式索引 ID + API-KEY 或 AK/SK */
export function isKnowledgeE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (!process.env.BAILIAN_E2E_INDEX_ID) return false;
const hasApiKey = isDashScopeE2EReady();
const hasAkSk =
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID && !!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET;
return hasApiKey || hasAkSk;
}
export function isKnowledgeAkSkReady(): boolean {
return (
isBailianE2EEnabled() &&
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID &&
@@ -172,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);
}
+195 -44
View File
@@ -1,55 +1,206 @@
import { join } from "path";
import { tmpdir } from "os";
import { describe, expect, test } from "vite-plus/test";
import {
isBailianE2EEnabled,
isKnowledgeE2EReady,
monorepoRoot,
parseStdoutJson,
runCli,
} from "./helpers.ts";
import { parseStdoutJson, runCli } from "./helpers.ts";
// 已开启 E2E 但 AK/SK、索引等未齐时提醒配置根目录 .env否则本文件整组 describe 会被 skip
if (isBailianE2EEnabled() && !isKnowledgeE2EReady()) {
const envFile = join(monorepoRoot(), ".env");
console.warn(
[
"[e2e:knowledge] 知识库检索需要 RAM 的 AK/SK、索引 ID以及工作空间 ID当前未就绪本组用例将被跳过。",
`请在 monorepo 根目录的 .env 中配置(${envFile}`,
" ALIBABA_CLOUD_ACCESS_KEY_ID",
" ALIBABA_CLOUD_ACCESS_KEY_SECRET",
" BAILIAN_E2E_INDEX_ID",
" BAILIAN_WORKSPACE_ID也可执行: bl config set workspace_id <工作空间 id>",
].join("\n"),
);
// ---- Types ----
interface DryRunBody {
endpoint?: string;
request?: {
index_id?: string;
query?: string;
search_filters?: unknown[];
rerank_top_n?: number;
enable_reranking?: boolean;
dense_similarity_top_k?: number;
sparse_similarity_top_k?: number;
rerank?: Array<{ model_name?: string; rerank_mode?: string; rerank_instruct?: string }>;
};
}
interface KnowledgeRetrieveBody {
Success?: boolean;
Code?: string;
Data?: { Nodes?: unknown[] };
}
// ---- Help & missing args (no credentials needed) ----
/** 知识库检索(需 AK/SK + workspace + 索引;未就绪则整组跳过) */
describe.skipIf(!isKnowledgeE2EReady())("e2e: knowledge retrieve", () => {
test("知识库检索", async () => {
const indexId = process.env.BAILIAN_E2E_INDEX_ID!;
const { stdout, stderr, exitCode } = await runCli([
describe("e2e: knowledge retrieve", () => {
test("knowledge 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["knowledge"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/knowledge|retrieve/i);
});
test("knowledge retrieve --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--query/i);
expect(stderr).toMatch(/--rerank-top-n/i);
expect(stderr).toMatch(/deprecated/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺少 --index-id 时打印帮助并退出 (0)", async () => {
const { stderr, exitCode } = await runCli([
"knowledge",
"retrieve",
"--query",
"test",
"--non-interactive",
]);
expect(exitCode).toBe(0);
expect(stderr).toMatch(/--index-id|Usage:/i);
});
test("缺少 --query 时打印帮助并退出 (0)", async () => {
const { stderr, exitCode } = await runCli([
"knowledge",
"retrieve",
"--index-id",
indexId,
"--query",
"端到端检索测试",
"--top-k",
"3",
"idx_test",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<KnowledgeRetrieveBody>(stdout);
const ok = data.Success === true || data.Code === "Success";
expect(ok).toBe(true);
expect(Array.isArray(data.Data?.Nodes)).toBe(true);
}, 120_000);
expect(exitCode).toBe(0);
expect(stderr).toMatch(/--query|Usage:/i);
});
});
// ---- Error scenarios (no real credentials needed) ----
describe("e2e: knowledge retrieve errors", () => {
test("无任何凭证时提示 No credentials found 并非零退出", async () => {
const { stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--index-id",
"idx_test",
"--query",
"test",
"--non-interactive",
"--output",
"json",
],
{
DASHSCOPE_API_KEY: undefined,
DASHSCOPE_ACCESS_TOKEN: undefined,
ALIBABA_CLOUD_ACCESS_KEY_ID: undefined,
ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined,
BAILIAN_CONFIG_DIR: tmpdir(),
},
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/no credentials found/i);
});
});
// ---- Dry-run (no real credentials needed) ----
describe("e2e: knowledge retrieve dry-run", () => {
test("--dry-run 输出 endpoint 和 snake_case body", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--dry-run",
"--index-id",
"idx_test",
"--query",
"hello",
"--non-interactive",
"--output",
"json",
],
{ DASHSCOPE_API_KEY: "sk-fake-for-dryrun" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/retrieve/);
expect(data.request?.index_id).toBe("idx_test");
expect(data.request?.query).toBe("hello");
});
test("--dry-run + --top-k 转发到 rerank_top_n 并输出废弃警告", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--dry-run",
"--index-id",
"idx_test",
"--query",
"hello",
"--top-k",
"5",
"--non-interactive",
"--output",
"json",
],
{ DASHSCOPE_API_KEY: "sk-fake-for-dryrun" },
);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--top-k.*deprecated/i);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.rerank_top_n).toBe(5);
});
test("--dry-run + --rerank-top-n 优先于 --top-k", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--dry-run",
"--index-id",
"idx_test",
"--query",
"hello",
"--top-k",
"5",
"--rerank-top-n",
"10",
"--non-interactive",
"--output",
"json",
],
{ DASHSCOPE_API_KEY: "sk-fake-for-dryrun" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.rerank_top_n).toBe(10);
});
test("--dry-run + rerank 参数完整输出", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--dry-run",
"--index-id",
"idx_test",
"--query",
"hello",
"--rerank",
"--rerank-model",
"qwen3-rerank-hybrid",
"--rerank-mode",
"custom",
"--rerank-instruct",
"按相关性排序",
"--dense-similarity-top-k",
"100",
"--sparse-similarity-top-k",
"50",
"--non-interactive",
"--output",
"json",
],
{ DASHSCOPE_API_KEY: "sk-fake-for-dryrun" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.enable_reranking).toBe(true);
expect(data.request?.dense_similarity_top_k).toBe(100);
expect(data.request?.sparse_similarity_top_k).toBe(50);
expect(data.request?.rerank?.[0]?.model_name).toBe("qwen3-rerank-hybrid");
expect(data.request?.rerank?.[0]?.rerank_mode).toBe("custom");
expect(data.request?.rerank?.[0]?.rerank_instruct).toBe("按相关性排序");
});
});
+5 -6
View File
@@ -66,7 +66,7 @@ describe("e2e: mcp", () => {
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
region?: string;
consoleRegion?: string;
data?: {
reqDTO?: {
type?: string;
@@ -79,7 +79,6 @@ describe("e2e: mcp", () => {
};
}>(stdout);
expect(data.api).toBe("zeldaEasy.broadscope-bailian.mcp-server.PageList");
expect(data.region).toBe("cn-beijing");
expect(data.data?.reqDTO?.activated).toBe(1);
expect(data.data?.reqDTO?.displayTools).toBe(false);
expect(data.data?.reqDTO?.type).toBe("OFFICIAL");
@@ -88,7 +87,7 @@ describe("e2e: mcp", () => {
expect(data.data?.reqDTO?.pageSize).toBe(5);
});
test("mcp list --dry-run 自定义 --region 透传", async () => {
test("mcp list --dry-run 自定义 --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"list",
@@ -96,12 +95,12 @@ describe("e2e: mcp", () => {
"--non-interactive",
"--output",
"json",
"--region",
"--console-region",
"cn-hangzhou",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ region?: string }>(stdout);
expect(data.region).toBe("cn-hangzhou");
const data = parseStdoutJson<{ consoleRegion?: string }>(stdout);
expect(data.consoleRegion).toBe("cn-hangzhou");
});
test("mcp tools <server-code> --dry-run 输出 /api/v1/mcps/<code>/mcp 形态 URL", async () => {
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, test } from "vite-plus/test";
import { join } from "node:path";
import {
e2eLabelFromMetaUrl,
isBailianE2EMediaEnabled,
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
} from "./helpers.ts";
describe("e2e: omni", () => {
test("omni --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["omni", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/omni|--message|--audio|text-only/i);
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: omniDashScope 媒体)",
() => {
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",
"--model",
"qwen3.5-omni-flash",
"--non-interactive",
]);
expect(exitCode).toBe(0);
expect(stderr).toMatch(/--message|Usage:/i);
});
test("omni --audio 无法识别扩展名时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"omni",
"--model",
"qwen3.5-omni-flash",
"--audio",
"https://example.com/sample.flac",
"--text-only",
"--message",
"这段音频在说什么?",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Unsupported audio extension|Cannot infer audio format/i);
});
test("omni --dry-run --audio 构造 input_audio 而非 audio_url", async () => {
const { stdout, stderr, exitCode } = await runCli([
"omni",
"--dry-run",
"--model",
"qwen3.5-omni-flash",
"--audio",
"https://example.com/sample.wav",
"--text-only",
"--message",
"这段音频在说什么?",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: {
messages?: Array<{
content?: Array<{
type?: string;
audio_url?: unknown;
input_audio?: { data?: string; format?: string };
}>;
}>;
};
}>(stdout);
const parts = data.request?.messages?.flatMap((m) =>
Array.isArray(m.content) ? m.content : [],
);
const audioPart = parts?.find((p) => p.type === "input_audio" || p.type === "audio_url");
expect(audioPart?.type).toBe("input_audio");
expect(audioPart?.audio_url).toBeUndefined();
expect(audioPart?.input_audio?.data).toBe("https://example.com/sample.wav");
expect(audioPart?.input_audio?.format).toBe("wav");
});
test("【qwen3.5-omni-flash】本地音频理解", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const clipText = "端到端Omni音频测试";
const clipWav = join(outDir, "e2e-omni-input.wav");
const syn = await runCli([
"speech",
"synthesize",
"--model",
"cosyvoice-v3-flash",
"--voice",
"longxiaochun_v3",
"--text",
clipText,
"--format",
"wav",
"--out",
clipWav,
"--non-interactive",
"--output",
"json",
]);
expect(syn.exitCode, syn.stderr).toBe(0);
const omni = await runCli([
"omni",
"--model",
"qwen3.5-omni-flash",
"--audio",
clipWav,
"--text-only",
"--system",
"请逐字转写用户提供的音频内容,不要添加解释。",
"--message",
"请转写这段音频。",
"--non-interactive",
"--output",
"json",
]);
expect(omni.exitCode, omni.stderr).toBe(0);
const body = parseStdoutJson<{ content?: string }>(omni.stdout);
expect(body.content?.replace(/\s/g, "")).toMatch(/端到端Omni音频测试/);
}, 180_000);
},
);
+124
View File
@@ -0,0 +1,124 @@
import { execFile } from "child_process";
import { createServer, type Server } from "http";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import type { AddressInfo } from "net";
import { tmpdir } from "os";
import { join } from "path";
import { promisify } from "util";
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
import { cliPackageRoot } from "./helpers.ts";
const execFileAsync = promisify(execFile);
/**
* 代理支持 E2Eissue #35只验证 `setupProxyFromEnv()` 是否把代理 dispatcher
* 正确装到全局 fetch 上——设了 HTTPS_PROXY 后裸 `fetch()` 走代理,未设置时直连,
* NO_PROXY 命中时跳过,非法代理值给出明确报错。
*
* 不经过任何 CLI 命令(不解析凭证、不打 gateway因此 CI 上无需 api key /
* access token与既有 e2e 设计一致。全程离线:目标域名用 `.invalid`(保留顶级域,
* 必然无法解析),代理收到 CONNECT 后规范返回 502不产生真实外网请求。
*/
const FAKE_HOST = "bl-proxy-e2e.invalid";
const FAKE_URL = `https://${FAKE_HOST}/probe`;
/**
* 最小探针脚本:调用真实的 `setupProxyFromEnv()`,再对目标发一个普通 fetch。
* 代理行为由进程环境变量决定正是被测对象fetch 成败不重要,我们只看代理是否收到 CONNECT。
*/
const PROBE_SCRIPT = `
import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "..", "runtime", "src", "proxy.ts"))};
setupProxyFromEnv();
try {
await fetch(${JSON.stringify(FAKE_URL)}, { signal: AbortSignal.timeout(5000) });
} catch {
// 目标不可达/隧道被拒都正常——本测试只关心代理是否收到 CONNECT
}
`;
let proxy: Server;
let proxyUrl: string;
let scriptDir: string;
let scriptPath: string;
const connectTargets: string[] = [];
beforeAll(async () => {
proxy = createServer();
// 记录收到的 CONNECT 目标host:port并以 502 拒绝隧道
proxy.on("connect", (req, clientSocket) => {
connectTargets.push(req.url ?? "");
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
});
await new Promise<void>((resolve) => proxy.listen(0, "127.0.0.1", resolve));
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`;
scriptDir = mkdtempSync(join(tmpdir(), "bl-proxy-e2e-"));
scriptPath = join(scriptDir, "probe.ts");
writeFileSync(scriptPath, PROBE_SCRIPT);
});
afterAll(async () => {
await new Promise<void>((resolve) => proxy.close(() => resolve()));
rmSync(scriptDir, { recursive: true, force: true });
});
/** 清空所有代理相关环境变量,确保每个用例只受自身设置影响 */
const PROXY_ENV_CLEARED = {
HTTPS_PROXY: "",
https_proxy: "",
HTTP_PROXY: "",
http_proxy: "",
NO_PROXY: "",
no_proxy: "",
};
/** 以给定代理环境变量运行探针脚本,返回 { exitCode, stderr } */
async function runProbe(
envOverrides: NodeJS.ProcessEnv,
): Promise<{ exitCode: number; stderr: string }> {
try {
await execFileAsync("node", [scriptPath], {
cwd: cliPackageRoot,
encoding: "utf8",
env: { ...process.env, NODE_NO_WARNINGS: "1", ...PROXY_ENV_CLEARED, ...envOverrides },
});
return { exitCode: 0, stderr: "" };
} catch (err: unknown) {
const e = err as { stderr?: string; code?: number };
return { exitCode: typeof e.code === "number" ? e.code : 1, stderr: e.stderr ?? "" };
}
}
describe("e2e: proxy", () => {
test("设置 HTTPS_PROXY 后 fetch 经过代理CONNECT 到目标主机)", async () => {
connectTargets.length = 0;
await runProbe({ HTTPS_PROXY: proxyUrl });
expect(connectTargets).toContain(`${FAKE_HOST}:443`);
});
test("空字符串小写变量不屏蔽大写 HTTPS_PROXYundici ?? 取值回归)", async () => {
connectTargets.length = 0;
await runProbe({ https_proxy: "", HTTPS_PROXY: proxyUrl });
expect(connectTargets).toContain(`${FAKE_HOST}:443`);
});
test("NO_PROXY 命中目标主机时不走代理", async () => {
connectTargets.length = 0;
await runProbe({ HTTPS_PROXY: proxyUrl, NO_PROXY: FAKE_HOST });
expect(connectTargets.filter((t) => t.startsWith(FAKE_HOST))).toEqual([]);
});
test("未设置代理变量时保持直连(代理收不到任何流量)", async () => {
connectTargets.length = 0;
await runProbe({});
expect(connectTargets).toEqual([]);
});
test("代理 URL 非法时给出明确报错而非堆栈", async () => {
const { exitCode, stderr } = await runProbe({ HTTPS_PROXY: "::::not-a-url" });
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Invalid proxy configuration/);
expect(stderr).toMatch(/HTTPS_PROXY/);
});
});
+282
View File
@@ -0,0 +1,282 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: quota", () => {
test("quota list --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--all");
});
test("quota list --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl quota list");
expect(stderr).toContain("bl quota list --model qwen3.6-plus");
expect(stderr).toContain("bl quota list --all");
});
test("quota request --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "request", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--tpm");
expect(stderr).toContain("--yes");
});
test("quota history --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "history", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--page");
expect(stderr).toContain("--model");
});
test("quota check --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--period");
expect(stderr).toContain("bl quota check");
});
test("quota check --period 0 报错最小值", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--period", "0.5"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("at least 1 minute");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
test("quota list --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"list",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: {
input?: { queryQpmInfo?: boolean; supports?: { selfServiceLimitIncrease?: boolean } };
};
}>(stdout);
expect(data.api).toContain("listFoundationModels");
expect(data.data?.input?.queryQpmInfo).toBe(true);
expect(data.data?.input?.supports?.selfServiceLimitIncrease).toBe(true);
});
test("quota list --dry-run --all 不传 supports 过滤", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"list",
"--all",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { input?: { supports?: unknown } };
}>(stdout);
expect(data.data?.input?.supports).toBeUndefined();
});
test("quota list 文本输出包含英文表头", async () => {
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 result = await runCli([
"quota",
"list",
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 不存在的模型报错", async () => {
const result = await runCli([
"quota",
"list",
"--model",
"nonexistent-model-xyz-99999",
"--output",
"text",
]);
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 result = await runCli(["quota", "list", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota request --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"request",
"--model",
"qwen3.6-plus",
"--tpm",
"6000000",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { input?: { model?: string; limit?: { usage_limit?: number } } };
}>(stdout);
expect(data.api).toContain("updateFoundationModelLimits");
expect(data.data?.input?.model).toBe("qwen3.6-plus");
expect(data.data?.input?.limit?.usage_limit).toBeTypeOf("number");
});
test("quota request TPM 超范围报错", async () => {
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 result = await runCli([
"quota",
"request",
"--model",
"nonexistent-model-xyz-99999",
"--tpm",
"100000",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("not found");
});
test("quota history --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"history",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { input?: { pageNo?: number; pageSize?: number } };
}>(stdout);
expect(data.api).toContain("listModelLimitApplications");
expect(data.data?.input?.pageNo).toBe(1);
expect(data.data?.input?.pageSize).toBe(10);
});
test("quota check --dry-run 输出 API 信息", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ apis?: string[]; consoleRegion?: string }>(stdout);
expect(data.apis).toContain(
"zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels",
);
expect(data.apis).toContain("zeldaEasy.bailian-telemetry.monitor.getMonitorData");
expect(data.consoleRegion).toBe("cn-beijing");
});
test("quota check --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--console-region",
"cn-hangzhou",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ consoleRegion?: string }>(stdout);
expect(data.consoleRegion).toBe("cn-hangzhou");
});
test("quota check 文本输出包含英文表头", async () => {
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 result = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check --model 逗号分隔多模型", async () => {
const result = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus,qwen-plus",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota check JSON 输出包含用量和限额字段", async () => {
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 () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"history",
"--page",
"2",
"--page-size",
"20",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { input?: { pageNo?: number; pageSize?: number } };
}>(stdout);
expect(data.data?.input?.pageNo).toBe(2);
expect(data.data?.input?.pageSize).toBe(20);
});
});
@@ -0,0 +1,235 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: usage free", () => {
test("usage 分组展示子命令帮助且退出码为 0", async () => {
const { stdout, stderr, exitCode } = await runCli(["usage"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/usage|free|freetier/i);
});
test("usage free --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|quota|free-tier/i);
});
test("usage free --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage free");
expect(stderr).toContain("bl usage free --model qwen3-max");
expect(stderr).toContain("bl usage free --model qwen3-max,qwen-turbo");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage freeConsole", () => {
test("usage free --dry-run --model 输出请求参数不发起调用", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--dry-run",
"--model",
"qwen3-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
}>(stdout);
expect(data.api).toContain("queryFreeTierQuota");
expect(data.data?.queryFreeTierQuotaRequest?.models).toEqual(["qwen3-max"]);
});
test("usage free --dry-run --model 逗号分隔多个模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--dry-run",
"--model",
"qwen3-max,qwen-turbo",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
}>(stdout);
expect(data.data?.queryFreeTierQuotaRequest?.models).toEqual(["qwen3-max", "qwen-turbo"]);
});
test("usage free --dry-run --model 重复模型名自动去重", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--dry-run",
"--model",
"qwen3-max,qwen3-max,qwen-turbo",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
}>(stdout);
expect(data.data?.queryFreeTierQuotaRequest?.models).toEqual(["qwen3-max", "qwen-turbo"]);
});
test("usage free --dry-run --model 逗号间有空格也能正确解析", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--dry-run",
"--model",
"qwen3-max, qwen-turbo",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
}>(stdout);
expect(data.data?.queryFreeTierQuotaRequest?.models).toEqual(["qwen3-max", "qwen-turbo"]);
});
test("usage free --dry-run 不指定 --model 传全量模型列表", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--dry-run", "--output", "json"]);
expect(exitCode, stderr).toBe(0);
});
test("usage free --model 单模型查询返回 JSON 结果", async () => {
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 result = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 文本输出包含模型名", async () => {
const result = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => {
const result = await runCli([
"usage",
"free",
"--model",
"qwen3-max,qwen-turbo",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 文本输出包含正确的 Type 列", async () => {
const result = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => {
const result = await runCli([
"usage",
"free",
"--model",
"wan2.7-image",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => {
const result = await runCli([
"usage",
"free",
"--model",
"wan2.7-image",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model 不存在的模型仍返回表格行", async () => {
const result = await runCli([
"usage",
"free",
"--model",
"nonexistent-model-xyz-12345",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => {
const result = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage free --model --console-region cn-beijing 指定区域查询", async () => {
const result = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--console-region",
"cn-beijing",
"--output",
"json",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
});
@@ -0,0 +1,273 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function getStaticWorkspaceId(): string | undefined {
if (process.env.BAILIAN_WORKSPACE_ID?.trim()) return process.env.BAILIAN_WORKSPACE_ID.trim();
try {
const config = readConfigFile();
if (config.workspace_id) return config.workspace_id;
} catch {}
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 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", () => {
test("usage stats --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--days|stats/i);
});
test("usage stats --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage stats");
expect(stderr).toContain("bl usage stats --model qwen-turbo");
expect(stderr).toContain("bl usage stats --days 30");
});
test("usage stats --help 包含 --workspace-id 选项", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--workspace-id");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage statsConsole", () => {
let wsId: string;
test("获取默认 workspace-id", async () => {
wsId = await fetchDefaultWorkspaceId();
expect(wsId).toBeTypeOf("string");
expect(wsId.length).toBeGreaterThan(0);
});
test("usage stats --dry-run 概览模式输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: {
reqDTO?: {
startTime?: number;
endTime?: number;
modelCallSource?: string;
filterWorkspaceId?: string;
};
};
}>(stdout);
expect(data.api).toContain("getModelUsageStatistic");
expect(data.data?.reqDTO?.modelCallSource).toBe("Online");
expect(data.data?.reqDTO?.startTime).toBeTypeOf("number");
expect(data.data?.reqDTO?.endTime).toBeTypeOf("number");
expect(data.data?.reqDTO?.filterWorkspaceId).toBe(wsId);
});
test("usage stats --dry-run --days 30 时间跨度约 30 天", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--dry-run",
"--days",
"30",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { reqDTO?: { startTime?: number; endTime?: number } };
}>(stdout);
const span = (data.data?.reqDTO?.endTime ?? 0) - (data.data?.reqDTO?.startTime ?? 0);
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
expect(span).toBeGreaterThan(thirtyDaysMs - 5000);
expect(span).toBeLessThan(thirtyDaysMs + 5000);
});
test("usage stats --dry-run --model 指定模型使用 list API", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--dry-run",
"--model",
"qwen-turbo",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { reqDTO?: { model?: string; filterWorkspaceId?: string } };
}>(stdout);
expect(data.api).toContain("listModelUsageStatisticData");
expect(data.data?.reqDTO?.model).toBe("qwen-turbo");
expect(data.data?.reqDTO?.filterWorkspaceId).toBe(wsId);
});
test("usage stats --dry-run --type Text 传递 obsModelType", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--dry-run",
"--type",
"Text",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { reqDTO?: { obsModelType?: string } };
}>(stdout);
expect(data.data?.reqDTO?.obsModelType).toBe("Text");
});
test("usage stats 概览模式返回 JSON 结果", async () => {
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 result = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats 概览文本输出包含 Token 用量", async () => {
const result = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 单模型文本输出包含英文表头", async () => {
const result = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 逗号分隔多模型返回多行", async () => {
const result = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--model",
"qwen3.6-plus,deepseek-v4-pro",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --model 不存在的模型返回空表格", async () => {
const result = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--model",
"nonexistent-model-xyz-99999",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --days 1 短时间范围正常返回", async () => {
const result = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--days",
"1",
"--output",
"text",
"--no-color",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage stats --type Vision 按类型过滤", async () => {
const result = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--type",
"Vision",
"--output",
"text",
"--no-color",
]);
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",
@@ -69,7 +69,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"--model",
"qwen-image-2.0",
"--prompt",
"一只简笔画小猫,白底",
"一片绿色的树叶,白底",
"--out-dir",
outDir,
"--out-prefix",
@@ -88,7 +88,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"ref",
"--model",
"happyhorse-1.0-r2v",
"happyhorse-1.1-r2v",
"--prompt",
"图1在画面中心轻微晃动",
"--image",
+1 -1
View File
@@ -180,7 +180,7 @@ export async function ensurePrerequisites(ctx) {
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"压测前置短视频:海浪与静态远景,无明显人物。",
"--duration",
@@ -132,7 +132,7 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) {
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"压测前置短视频:海浪与静态远景,无明显人物。",
"--duration",
@@ -16,7 +16,7 @@ const motions = [
export const runStress = defineStressTarget({
canonical: "video-i2v",
defaultModel: "happyhorse-1.0-i2v",
defaultModel: "happyhorse-1.1-i2v",
batchDirPrefix: "video-i2v-batch",
helpText: "pnpm run test:stress -- video-i2v [--reuse-fixtures] -- --count 5 -c 2",
@@ -16,7 +16,7 @@ const prompts = [
export const runStress = defineStressTarget({
canonical: "video-ref",
defaultModel: "happyhorse-1.0-r2v",
defaultModel: "happyhorse-1.1-r2v",
batchDirPrefix: "video-ref-batch",
helpText: "pnpm run test:stress -- video-ref [--reuse-fixtures] -- --count 5 -c 2",
@@ -45,7 +45,7 @@ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
export const runStress = defineStressTarget({
canonical: "video-t2v",
defaultModel: "happyhorse-1.0-t2v",
defaultModel: "happyhorse-1.1-t2v",
batchDirPrefix: "video-t2v-batch",
helpText: `用法pnpm run test:stress -- video-t2v -- --concurrency 1 --count 3
详见 docs/agents/stress-batch-tests.md`,
+1
View File
@@ -5,6 +5,7 @@
"moduleDetection": "force",
"module": "nodenext",
"moduleResolution": "nodenext",
"customConditions": ["@bailian-cli/source"],
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.log
.DS_Store
+58
View File
@@ -0,0 +1,58 @@
{
"name": "bailian-cli-commands",
"version": "1.5.0",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
"url": "https://github.com/modelstudioai/cli/issues"
},
"license": "Apache-2.0",
"author": "Aliyun Model Studio",
"repository": {
"type": "git",
"url": "git+https://github.com/modelstudioai/cli.git",
"directory": "packages/commands"
},
"files": [
"dist"
],
"type": "module",
"types": "./dist/index.d.mts",
"exports": {
".": {
"@bailian-cli/source": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": "./dist/index.mjs",
"./package.json": "./package.json"
},
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "vp pack",
"dev": "vp pack --watch",
"test": "vp test",
"check": "vp check"
},
"dependencies": {
"bailian-cli-core": "workspace:*",
"bailian-cli-runtime": "workspace:*",
"boxen": "catalog:",
"chalk": "catalog:",
"yaml": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20260328.1",
"typescript": "^6.0.2",
"vite-plus": "0.1.22"
},
"engines": {
"node": ">=22.12.0"
}
}
@@ -17,9 +17,9 @@ import {
} from "bailian-cli-core";
import boxen from "boxen";
import chalk, { Chalk, type ChalkInstance } from "chalk";
import { emitBare, emitResult } from "../../output/output.ts";
import { createSpinner } from "../../output/progress.ts";
import { failIfMissing, promptText } from "../../output/prompt.ts";
import { emitBare, emitResult } from "bailian-cli-runtime";
import { createSpinner } from "bailian-cli-runtime";
import { failIfMissing, promptText, cmdUsage } from "bailian-cli-runtime";
function formatContextWindow(tokens: number): string {
if (tokens >= 1_000_000)
@@ -29,41 +29,41 @@ function formatContextWindow(tokens: number): string {
}
const MODALITY_LABELS: Record<string, string> = {
Text: "文本",
Image: "图片",
Video: "视频",
Audio: "音频",
Text: "Text",
Image: "Image",
Video: "Video",
Audio: "Audio",
};
const CAPABILITY_LABELS: Record<string, string> = {
TG: "文本生成",
VU: "视觉理解",
IG: "图像生成",
VG: "视频生成",
TTS: "语音合成",
ASR: "语音识别",
Reasoning: "推理",
TG: "Text Gen",
VU: "Vision",
IG: "Image Gen",
VG: "Video Gen",
TTS: "Text-to-Speech",
ASR: "Speech-to-Text",
Reasoning: "Reasoning",
};
const BUDGET_LABELS: Record<string, string> = {
low: "低成本优先",
medium: "适中",
high: "高投入",
low: "Cost-Effective",
medium: "Balanced",
high: "High Investment",
};
const QUALITY_LABELS: Record<string, string> = {
flagship: "旗舰优先",
balanced: "均衡",
"cost-optimized": "性价比优先",
flagship: "Flagship",
balanced: "Balanced",
"cost-optimized": "Value",
};
const PREFERENCE_MODE_LABELS: Record<string, string> = {
scoped: "限定范围",
comparison: "对比评估",
alternative: "替代推荐",
scoped: "Scoped",
comparison: "Comparison",
alternative: "Alternative",
};
function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
const lines: string[] = [];
lines.push(colorize.cyan.bold("需求理解"));
lines.push(colorize.cyan.bold("Intent Analysis"));
if (intent.taskSummary) {
lines.push("");
@@ -72,7 +72,7 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
if (intent.scenarioHints.length) {
lines.push("");
lines.push(`${colorize.dim("场景特征")} ${intent.scenarioHints.join(" · ")}`);
lines.push(`${colorize.dim("Scenario")} ${intent.scenarioHints.join(" · ")}`);
}
const inputLabels = intent.inputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
@@ -80,40 +80,40 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
if (inputLabels.length || outputLabels.length) {
lines.push("");
const parts: string[] = [];
if (inputLabels.length) parts.push(`${colorize.dim("输入")} ${inputLabels.join(", ")}`);
if (outputLabels.length) parts.push(`${colorize.dim("输出")} ${outputLabels.join(", ")}`);
if (inputLabels.length) parts.push(`${colorize.dim("Input")} ${inputLabels.join(", ")}`);
if (outputLabels.length) parts.push(`${colorize.dim("Output")} ${outputLabels.join(", ")}`);
lines.push(parts.join(" "));
}
const capLabels = intent.requiredCapabilities.map((cap) => CAPABILITY_LABELS[cap] ?? cap);
if (capLabels.length) {
lines.push(`${colorize.dim("所需能力")} ${capLabels.join(", ")}`);
lines.push(`${colorize.dim("Capabilities")} ${capLabels.join(", ")}`);
}
const budgetLabel = BUDGET_LABELS[intent.budget] ?? intent.budget;
const qualityLabel = QUALITY_LABELS[intent.qualityPreference] ?? intent.qualityPreference;
lines.push("");
lines.push(
`${colorize.dim("预算倾向")} ${budgetLabel} ${colorize.dim("质量偏好")} ${qualityLabel}`,
`${colorize.dim("Budget")} ${budgetLabel} ${colorize.dim("Quality")} ${qualityLabel}`,
);
const preference = intent.modelPreference;
if (preference && preference.mode !== "unconstrained") {
lines.push("");
const modeLabel = PREFERENCE_MODE_LABELS[preference.mode] ?? preference.mode;
const prefParts = [colorize.dim("推荐模式") + ` ${colorize.yellow(modeLabel)}`];
const prefParts = [colorize.dim("Mode") + ` ${colorize.yellow(modeLabel)}`];
if (preference.targets?.length) {
prefParts.push(colorize.dim("目标") + ` ${preference.targets.join(", ")}`);
prefParts.push(colorize.dim("Targets") + ` ${preference.targets.join(", ")}`);
}
if (preference.excludes?.length) {
prefParts.push(colorize.dim("排除") + ` ${preference.excludes.join(", ")}`);
prefParts.push(colorize.dim("Excludes") + ` ${preference.excludes.join(", ")}`);
}
lines.push(prefParts.join(" "));
}
if (intent.segments?.length) {
lines.push("");
lines.push(colorize.dim("任务拆解"));
lines.push(colorize.dim("Pipeline"));
for (const [idx, segment] of intent.segments.entries()) {
const outMods = segment.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod).join(", ");
lines.push(
@@ -131,19 +131,19 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
});
}
const RECOMMEND_LABELS = ["最佳推荐", "次优选择", "备选参考"];
const RECOMMEND_LABELS = ["Best Pick", "Runner-Up", "Alternative"];
function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstance): string {
const labelColors = [colorize.green.bold, colorize.blue.bold, colorize.magenta.bold];
const colorFn = labelColors[index] ?? colorize.white.bold;
const label = RECOMMEND_LABELS[index] ?? `推荐 #${index + 1}`;
const label = RECOMMEND_LABELS[index] ?? `#${index + 1}`;
const lines: string[] = [];
lines.push(colorFn(`推荐 #${index + 1}${label}`));
lines.push(colorFn(`⬢ #${index + 1}${label}`));
lines.push("");
lines.push(`${colorize.bold(rec.name)} ${colorize.dim(`(${rec.model})`)}`);
lines.push("");
lines.push(`${colorize.cyan("推荐理由")} ${rec.reason}`);
lines.push(`${colorize.cyan("Why")} ${rec.reason}`);
if (rec.highlights.length) {
lines.push("");
@@ -153,8 +153,8 @@ function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstanc
}
const meta: string[] = [];
if (rec.contextWindow) meta.push(`上下文 ${formatContextWindow(rec.contextWindow)}`);
if (rec.maxOutputTokens) meta.push(`最大输出 ${formatContextWindow(rec.maxOutputTokens)}`);
if (rec.contextWindow) meta.push(`Context ${formatContextWindow(rec.contextWindow)}`);
if (rec.maxOutputTokens) meta.push(`Max Output ${formatContextWindow(rec.maxOutputTokens)}`);
if (meta.length) {
lines.push("");
lines.push(colorize.dim(meta.join(" · ")));
@@ -163,7 +163,7 @@ function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstanc
const docLink = buildDocLink(rec.docUrl);
if (docLink) {
lines.push("");
lines.push(colorize.dim(`文档 ${docLink}`));
lines.push(colorize.dim(`Docs ${docLink}`));
}
return boxen(lines.join("\n"), {
@@ -183,7 +183,7 @@ function formatSingleResult(results: RecommendedModel[], noColor: boolean): stri
function formatPipelineResult(summary: string, steps: PipelineStep[], noColor: boolean): string {
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
const lines: string[] = [];
lines.push(` ${colorize.yellow.bold("⚡ 组合方案")} ${summary}`);
lines.push(` ${colorize.yellow.bold("⚡ Pipeline")} ${summary}`);
for (const [stepIdx, { step, recommendations, warnings }] of steps.entries()) {
lines.push("");
@@ -215,10 +215,9 @@ function isEmptyResult(result: RecommendResult): boolean {
}
export default defineCommand({
name: "advisor recommend",
description:
"Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking)",
usage: "bl advisor recommend <prompt> [flags]",
usageArgs: "<prompt> [flags]",
options: [
{
flag: "--message <text>",
@@ -233,13 +232,13 @@ export default defineCommand({
description: "Output format: text (default in TTY), json, yaml",
},
],
examples: [
'bl advisor recommend --message "我要做一个能理解图片的客服机器人"',
'bl advisor recommend --message "做一个Agent自动根据用户意图生成动画片"',
'bl advisor recommend --message "法律合同审查,要求高精准度"',
'bl advisor recommend --message "做一个低成本高并发的在线客服" --output json',
'bl advisor recommend --message "长文本摘要" --dry-run',
"bl advisor recommend # 交互式输入需求",
exampleArgs: [
'--message "I need a visual-understanding chatbot"',
'--message "Build an Agent that auto-generates animations"',
'--message "Legal contract review, high precision required"',
'--message "Low-cost high-concurrency online customer service" --output json',
'--message "Long document summarization" --dry-run',
" # Interactive input",
],
async run(config: Config, flags: GlobalFlags) {
const positional = ((flags as Record<string, unknown>)._positional as string[]) ?? [];
@@ -247,14 +246,14 @@ export default defineCommand({
if (!userInput.trim()) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: "描述你的需求:" });
const hint = await promptText({ message: "Describe your requirement:" });
if (!hint) {
process.stderr.write("已取消。\n");
process.stderr.write("Cancelled.\n");
process.exit(1);
}
userInput = hint;
} else {
failIfMissing("message", 'bl advisor recommend "你的需求"');
failIfMissing("message", cmdUsage(config, '"your requirement"'));
}
}
@@ -262,16 +261,16 @@ export default defineCommand({
const format = detectOutputFormat(config.output);
const modelsOptions: GetModelsOptions = {
onPrepareStart: () => process.stderr.write("初始化中...\n"),
onPrepareStart: () => process.stderr.write("Initializing model data...\n"),
};
process.stderr.write("正在分析需求...\n");
process.stderr.write("Analyzing your request...\n");
const [allModels, intent] = await Promise.all([
getModels(config, modelsOptions),
analyzeIntent(config, userInput),
]);
if (intent.confidence === 0) {
process.stderr.write("需求分析超时,使用默认参数继续...\n");
process.stderr.write("Intent analysis timed out, using defaults...\n");
} else {
process.stderr.write("\n");
}
@@ -297,7 +296,7 @@ export default defineCommand({
}
// Stage 3: LLM Ranking
const spinner = createSpinner("正在推荐最佳模型...");
const spinner = createSpinner("Recommending best models...");
spinner.start();
const result = await rankModels(config, candidates, intent, userInput, top);
@@ -305,12 +304,31 @@ export default defineCommand({
spinner.stop();
if (isEmptyResult(result)) {
emitBare("暂无满足该需求的模型。");
emitBare("No suitable models found for this request.");
return;
}
if (format !== "text") {
emitResult(result, format);
emitResult(
{
intent: {
taskSummary: intent.taskSummary,
scenarioHints: intent.scenarioHints,
complexity: intent.complexity,
inputModality: intent.inputModality,
outputModality: intent.outputModality,
requiredCapabilities: intent.requiredCapabilities,
budget: intent.budget,
qualityPreference: intent.qualityPreference,
modelPreference:
intent.modelPreference?.mode !== "unconstrained" ? intent.modelPreference : undefined,
segments: intent.segments,
},
result,
candidates: candidates.length,
},
format,
);
return;
}
@@ -11,13 +11,12 @@ import {
type AppStreamChunk,
type AppCompletionResponse,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
name: "app call",
description: "Call a Bailian application (agent or workflow)",
usage: "bl app call --app-id <id> --prompt <text> [flags]",
usageArgs: "--app-id <id> --prompt <text> [flags]",
options: [
{ flag: "--app-id <id>", description: "Application ID (required)", required: true },
{ flag: "--prompt <text>", description: "Input prompt text", required: true },
@@ -34,20 +33,20 @@ export default defineCommand({
{ flag: "--biz-params <json>", description: "Business parameters JSON (workflow variables)" },
{ flag: "--has-thoughts", description: "Show agent thinking process" },
],
examples: [
'bl app call --app-id abc123 --prompt "你好"',
'bl app call --app-id abc123 --prompt "描述这张图片" --image https://example.com/photo.jpg',
'bl app call --app-id abc123 --prompt "分析图片" --image img1.jpg --image img2.jpg',
'bl app call --app-id abc123 --prompt "继续" --session-id sess_xxx --stream',
'bl app call --app-id abc123 --prompt "搜索资料" --pipeline-ids pipe1,pipe2',
'bl app call --app-id abc123 --prompt "开始" --biz-params \'{"key":"value"}\'',
exampleArgs: [
'--app-id abc123 --prompt "Hello"',
'--app-id abc123 --prompt "Describe this image" --image https://example.com/photo.jpg',
'--app-id abc123 --prompt "Analyze the image" --image img1.jpg --image img2.jpg',
'--app-id abc123 --prompt "Continue" --session-id sess_xxx --stream',
'--app-id abc123 --prompt "Search for materials" --pipeline-ids pipe1,pipe2',
'--app-id abc123 --prompt "Start" --biz-params \'{"key":"value"}\'',
],
async run(config: Config, flags: GlobalFlags) {
const appId = flags.appId as string;
if (!appId) failIfMissing("app-id", "bl app call --app-id <id> --prompt <text>");
if (!appId) failIfMissing("app-id", cmdUsage(config, "--app-id <id> --prompt <text>"));
const prompt = flags.prompt as string;
if (!prompt) failIfMissing("prompt", "bl app call --app-id <id> --prompt <text>");
if (!prompt) failIfMissing("prompt", cmdUsage(config, "--app-id <id> --prompt <text>"));
const shouldStream =
flags.stream === true || (flags.stream === undefined && process.stdout.isTTY);
@@ -6,14 +6,14 @@ import {
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { emitResult } from "bailian-cli-runtime";
const APP_LIST_API = "zeldaEasy.broadscope-bailian.app-control.list";
export default defineCommand({
name: "app list",
description: "List Bailian applications",
usage: "bl app list [flags]",
skipDefaultApiKeySetup: true,
usageArgs: "[flags]",
options: [
{
flag: "--name <name>",
@@ -29,22 +29,22 @@ export default defineCommand({
description: "Results per page (default: 30)",
type: "number",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl app list",
"bl app list --name 客服",
"bl app list --page 2 --page-size 10",
"bl app list --output json",
],
exampleArgs: ["", "--name customer service", "--page 2 --page-size 10", "--output json"],
async run(config: Config, flags: GlobalFlags) {
const name = (flags.name as string) || "";
const pageNo = (flags.page as number) || 1;
const pageSize = (flags.pageSize as number) || 30;
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
const credential = await resolveConsoleGatewayCredential(config);
@@ -61,17 +61,13 @@ export default defineCommand({
};
if (config.dryRun) {
emitResult(
{ api: APP_LIST_API, data, region, token: credential.token.slice(0, 8) + "..." },
format,
);
emitResult({ api: APP_LIST_API, data, token: credential.token.slice(0, 8) + "..." }, format);
return;
}
const result = (await callConsoleGateway(config, credential.token, {
api: APP_LIST_API,
data,
region,
})) as any;
const list: unknown[] = result?.data?.DataV2?.data?.data?.list ?? [];
@@ -5,18 +5,24 @@ import http from "node:http";
import {
BailianError,
ExitCode,
chatEndpoint,
getConfigPath,
readConfigFile,
requestJson,
writeConfigFile,
type Config,
} from "bailian-cli-core";
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
const MAX_AUTH_CALLBACK_BODY = 65536;
const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com";
const CONSOLE_ORIGINS: Record<string, string> = {
domestic: "https://bailian.console.aliyun.com",
international: "https://modelstudio.console.alibabacloud.com",
};
export function resolveConsoleOrigin(): string {
return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN;
export function resolveConsoleOrigin(site?: string): string {
return (site && CONSOLE_ORIGINS[site]) || CONSOLE_ORIGINS.domestic!;
}
function readBodyBounded(req: http.IncomingMessage): Promise<string> {
@@ -210,9 +216,76 @@ function parseApiKeyFromRawBody(raw: string, contentType: string): string | null
return null;
}
type CallbackExtras = Pick<
CallbackCredentials,
"baseUrl" | "consoleSite" | "consoleRegion" | "consoleSwitchAgent" | "workspaceId"
>;
function stringField(o: Record<string, unknown>, ...keys: string[]): string | null {
for (const k of keys) {
const v = o[k];
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
function parseExtrasFromRawBody(raw: string, contentType: string): CallbackExtras {
const empty: CallbackExtras = {
baseUrl: null,
consoleSite: null,
consoleRegion: null,
consoleSwitchAgent: null,
workspaceId: null,
};
if (!raw.trim()) return empty;
let obj: Record<string, unknown> | null = null;
const ct = contentType.toLowerCase();
if (ct.includes("application/json") || ct.includes("text/json")) {
try {
const parsed = JSON.parse(raw.trim());
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) obj = parsed;
} catch {
/* */
}
}
if (!obj && ct.includes("application/x-www-form-urlencoded")) {
try {
const params = new URLSearchParams(raw.trim());
obj = Object.fromEntries(params);
} catch {
/* */
}
}
if (!obj) {
try {
const parsed = JSON.parse(raw.trim());
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) obj = parsed;
} catch {
/* */
}
}
if (!obj) return empty;
return {
baseUrl: stringField(obj, "base_url", "baseUrl"),
consoleSite: stringField(obj, "console_site", "consoleSite"),
consoleRegion: stringField(obj, "console_region", "consoleRegion"),
consoleSwitchAgent: stringField(obj, "console_switch_agent", "consoleSwitchAgent"),
workspaceId: stringField(obj, "workspace_id", "workspaceId"),
};
}
interface CallbackCredentials {
accessToken: string | null;
apiKey: string | null;
baseUrl: string | null;
consoleSite: string | null;
consoleRegion: string | null;
consoleSwitchAgent: string | null;
workspaceId: string | null;
}
async function extractCredentialsFromRequest(
@@ -222,12 +295,30 @@ async function extractCredentialsFromRequest(
const accessTokenFromQuery =
u.searchParams.get("access_token") ?? u.searchParams.get("accessToken");
const apiKeyFromQuery = u.searchParams.get("api_key") ?? u.searchParams.get("apiKey");
const baseUrlFromQuery = u.searchParams.get("base_url") ?? u.searchParams.get("baseUrl");
const consoleSiteFromQuery =
u.searchParams.get("console_site") ?? u.searchParams.get("consoleSite");
const consoleRegionFromQuery =
u.searchParams.get("console_region") ?? u.searchParams.get("consoleRegion");
const consoleSwitchAgentFromQuery =
u.searchParams.get("console_switch_agent") ?? u.searchParams.get("consoleSwitchAgent");
const workspaceIdFromQuery =
u.searchParams.get("workspace_id") ?? u.searchParams.get("workspaceId");
const extras = {
baseUrl: baseUrlFromQuery?.trim() || null,
consoleSite: consoleSiteFromQuery?.trim() || null,
consoleRegion: consoleRegionFromQuery?.trim() || null,
consoleSwitchAgent: consoleSwitchAgentFromQuery?.trim() || null,
workspaceId: workspaceIdFromQuery?.trim() || null,
};
const m = req.method ?? "GET";
if (m !== "POST" && m !== "PUT" && m !== "PATCH") {
return {
accessToken: accessTokenFromQuery?.trim() || null,
apiKey: apiKeyFromQuery?.trim() || null,
...extras,
};
}
@@ -239,12 +330,24 @@ async function extractCredentialsFromRequest(
return {
accessToken: accessTokenFromQuery?.trim() || null,
apiKey: apiKeyFromQuery?.trim() || null,
...extras,
};
}
const accessToken = accessTokenFromQuery?.trim() || parseAccessTokenFromRawBody(raw, contentType);
const apiKey = apiKeyFromQuery?.trim() || parseApiKeyFromRawBody(raw, contentType);
return { accessToken, apiKey };
const bodyExtras = parseExtrasFromRawBody(raw, contentType);
return {
accessToken,
apiKey,
baseUrl: extras.baseUrl || bodyExtras.baseUrl,
consoleSite: extras.consoleSite || bodyExtras.consoleSite,
consoleRegion: extras.consoleRegion || bodyExtras.consoleRegion,
consoleSwitchAgent: extras.consoleSwitchAgent || bodyExtras.consoleSwitchAgent,
workspaceId: extras.workspaceId || bodyExtras.workspaceId,
};
}
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
@@ -276,9 +379,69 @@ function openInBrowser(url: string): Promise<void> {
});
}
const RETRY_DELAY_BASE_MS = 500;
function canRetry(err: unknown): boolean {
if (err instanceof BailianError) {
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) return true;
const status = err.api?.httpStatus;
return status === 401 || (status !== undefined && status >= 500);
}
if (err instanceof Error) {
return (
err.name === "AbortError" ||
err.name === "TimeoutError" ||
err.message.includes("timed out") ||
err.message === "fetch failed"
);
}
return false;
}
export async function validateAndPersistApiKey(
config: Config,
key: string,
baseUrl: string,
): Promise<void> {
process.stderr.write("Testing key... ");
const testConfig = { ...config, apiKey: key, baseUrl };
const requestOpts = {
url: chatEndpoint(testConfig.baseUrl),
method: "POST",
timeout: Math.min(config.timeout, 30),
body: {
model: "qwen3.7-max",
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
},
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(testConfig, requestOpts);
break;
} catch (err) {
if (attempt >= 3 || !canRetry(err)) {
process.stderr.write("Failed\n");
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
cause: err,
});
}
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
process.stderr.write("Valid\n");
const existing = readConfigFile() as Record<string, unknown>;
existing.api_key = key;
await writeConfigFile(existing);
}
export async function runConsoleLogin(
consoleOrigin: string,
opts?: { needApiKey?: boolean; onApiKey?: (key: string) => Promise<void> },
config: Config,
opts?: { needApiKey?: boolean },
): Promise<void> {
const state = randomBytes(16).toString("hex");
let callbackError: unknown;
@@ -301,18 +464,35 @@ export async function runConsoleLogin(
return;
}
const { accessToken, apiKey } = await extractCredentialsFromRequest(req);
const {
accessToken,
apiKey,
baseUrl,
consoleSite,
consoleRegion,
consoleSwitchAgent,
workspaceId,
} = await extractCredentialsFromRequest(req);
if (accessToken || apiKey) {
const hasConfig =
accessToken || baseUrl || consoleSite || consoleRegion || consoleSwitchAgent || workspaceId;
if (hasConfig || apiKey) {
try {
if (accessToken) {
if (hasConfig) {
const existing = readConfigFile() as Record<string, unknown>;
existing.access_token = accessToken;
if (accessToken) existing.access_token = accessToken;
if (baseUrl) existing.base_url = baseUrl;
if (consoleSite) existing.console_site = consoleSite;
if (consoleRegion) existing.console_region = consoleRegion;
if (consoleSwitchAgent) existing.console_switch_agent = Number(consoleSwitchAgent);
if (workspaceId) existing.workspace_id = workspaceId;
await writeConfigFile(existing);
process.stderr.write(`access_token saved to ${getConfigPath()}\n`);
process.stderr.write(`Config saved to ${getConfigPath()}\n`);
}
if (apiKey && opts?.onApiKey) {
await opts.onApiKey(apiKey);
if (apiKey) {
const testBaseUrl = baseUrl || config.baseUrl;
await validateAndPersistApiKey(config, apiKey, testBaseUrl);
}
} catch (err: unknown) {
callbackError = err;
@@ -329,7 +509,7 @@ export async function runConsoleLogin(
});
res.end("OK\n");
if (accessToken || apiKey) {
if (hasConfig || apiKey) {
server.close();
}
} catch {
@@ -0,0 +1,90 @@
import {
defineCommand,
isInteractive,
maskToken,
readConfigFile,
writeConfigFile,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { printQuickStart } from "bailian-cli-runtime";
import { emitBare } from "bailian-cli-runtime";
import { promptConfirm } from "bailian-cli-runtime";
import { printCurrentCommandHelp } from "bailian-cli-runtime";
import {
resolveConsoleOrigin,
runConsoleLogin,
validateAndPersistApiKey,
} from "./login-console.ts";
export default defineCommand({
description: "Authenticate with API key or console browser login (credentials can coexist)",
skipDefaultApiKeySetup: true,
usageArgs: "--api-key <key> | --console",
options: [
{ flag: "--api-key <key>", description: "DashScope API key to store" },
{
flag: "--base-url <url>",
description: "DashScope API base URL (used with --api-key for validation)",
},
{
flag: "--console",
description:
"Sign in via browser; use --console-site to choose domestic (default) or international",
},
],
exampleArgs: ["--api-key sk-xxxxx", "--console"],
async run(config: Config, flags: GlobalFlags) {
if (flags.console) {
if (config.dryRun) {
emitBare(
"Would bind a free port on 127.0.0.1 and open the console login URL in your browser.",
);
return;
}
const hasApiKey = !!(config.apiKey || config.fileApiKey);
await runConsoleLogin(resolveConsoleOrigin(config.consoleSite || "domestic"), config, {
needApiKey: !hasApiKey,
});
return;
}
const envKey = process.env.DASHSCOPE_API_KEY;
if (envKey && !flags.apiKey) {
const maskedEnvKey = maskToken(envKey);
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const proceed = await promptConfirm({
message: `Detected DASHSCOPE_API_KEY in environment (${maskedEnvKey}).\nYou are already authenticated via env.\nDo you still want to configure local persistent credentials?`,
initialValue: false,
});
if (!proceed) {
process.stdout.write("Login skipped. Using environment variables.\n");
process.exit(0);
}
} else {
process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`);
}
}
const key = (flags.apiKey as string) || config.apiKey;
if (!key) {
printCurrentCommandHelp(process.stderr);
process.exit(0);
}
const baseUrl = (flags.baseUrl as string) || undefined;
const effectiveConfig = baseUrl ? { ...config, baseUrl } : config;
if (!config.dryRun) {
if (baseUrl) {
const existing = readConfigFile() as Record<string, unknown>;
existing.base_url = baseUrl;
await writeConfigFile(existing);
}
await validateAndPersistApiKey(effectiveConfig, key, effectiveConfig.baseUrl);
printQuickStart();
} else {
emitBare("Would validate and save API key.");
}
},
});
@@ -7,7 +7,7 @@ import {
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitBare } from "../../output/output.ts";
import { emitBare } from "bailian-cli-runtime";
async function clearConsoleToken(): Promise<boolean> {
const file = readConfigFile() as Record<string, unknown>;
@@ -18,9 +18,9 @@ async function clearConsoleToken(): Promise<boolean> {
}
export default defineCommand({
name: "auth logout",
description: "Clear stored credentials",
usage: "bl auth logout [--console] [--yes] [--dry-run]",
skipDefaultApiKeySetup: true,
usageArgs: "[--console] [--yes] [--dry-run]",
options: [
{
flag: "--console",
@@ -29,12 +29,7 @@ export default defineCommand({
},
{ flag: "--yes", description: "Skip confirmation prompt" },
],
examples: [
"bl auth logout",
"bl auth logout --console",
"bl auth logout --dry-run",
"bl auth logout --yes",
],
exampleArgs: ["", "--console", "--dry-run", "--yes"],
async run(config: Config, flags: GlobalFlags) {
const file = readConfigFile();
@@ -8,8 +8,8 @@ import {
type GlobalFlags,
type ResolvedCredential,
} from "bailian-cli-core";
import { emitResult, emitBare } from "../../output/output.ts";
import { API_KEY_PAGE } from "../../urls.ts";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { API_KEY_PAGE } from "bailian-cli-runtime";
interface StoredCredential {
configured: boolean;
@@ -108,7 +108,7 @@ function hasAnyAuth(status: AuthStatusPayload): boolean {
);
}
function emitTextStatus(status: AuthStatusPayload): void {
function emitTextStatus(status: AuthStatusPayload, config: Config): void {
emitBare("Authentication Status:");
emitBare(" Stored credentials (can coexist):");
if (status.api_key.configured) {
@@ -134,15 +134,25 @@ function emitTextStatus(status: AuthStatusPayload): void {
` Console gateway: ${status.console_gateway_commands.method} (${status.console_gateway_commands.source}) ${status.console_gateway_commands.masked}`,
);
} else {
emitBare(" Console gateway: unavailable (run bl auth login --console)");
emitBare(` Console gateway: unavailable (run ${config.binName} auth login --console)`);
}
}
export default defineCommand({
name: "auth status",
description: "Show current authentication state",
usage: "bl auth status",
examples: ["bl auth status", "bl auth status --output json"],
options: [
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
exampleArgs: ["", "--output json"],
async run(config: Config, _flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const status = await buildStatus(config);
@@ -152,8 +162,8 @@ export default defineCommand({
authenticated: false,
message: "Not authenticated.",
hint: [
"DashScope API: bl auth login --api-key <key> or DASHSCOPE_API_KEY",
"Console gateway: bl auth login --console or DASHSCOPE_ACCESS_TOKEN",
`DashScope API: ${config.binName} auth login --api-key <key> or DASHSCOPE_API_KEY`,
`Console gateway: ${config.binName} auth login --console or DASHSCOPE_ACCESS_TOKEN`,
`Get API Key: ${API_KEY_PAGE}`,
].join("\n"),
...status,
@@ -167,6 +177,6 @@ export default defineCommand({
return;
}
emitTextStatus(status);
emitTextStatus(status, config);
},
});
@@ -9,10 +9,9 @@ import {
type GlobalFlags,
ExitCode,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { emitResult, cmdUsage } from "bailian-cli-runtime";
const VALID_KEYS = [
"region",
"base_url",
"output",
"output_dir",
@@ -51,21 +50,21 @@ const KEY_ALIASES: Record<string, string> = {
};
export default defineCommand({
name: "config set",
description: "Set a config value",
usage: "bl config set --key <key> --value <value>",
skipDefaultApiKeySetup: true,
usageArgs: "--key <key> --value <value>",
options: [
{
flag: "--key <key>",
description:
"Config key (region, base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)",
"Config key (base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)",
},
{ flag: "--value <value>", description: "Value to set" },
],
examples: [
"bl config set --key output --value json",
"bl config set --key timeout --value 600",
"bl config set --key base_url --value https://dashscope.aliyuncs.com",
exampleArgs: [
"--key output --value json",
"--key timeout --value 600",
"--key base_url --value https://dashscope.aliyuncs.com",
],
async run(config: Config, flags: GlobalFlags) {
const key = flags.key as string | undefined;
@@ -75,7 +74,7 @@ export default defineCommand({
throw new BailianError(
"--key and --value are required.",
ExitCode.USAGE,
"bl config set --key <key> --value <value>",
cmdUsage(config, "--key <key> --value <value>"),
);
}
@@ -90,13 +89,6 @@ export default defineCommand({
}
// Validate specific values
if (resolvedKey === "region" && !["cn", "us", "intl"].includes(value)) {
throw new BailianError(
`Invalid region "${value}". Valid values: cn, us, intl`,
ExitCode.USAGE,
);
}
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
throw new BailianError(
`Invalid output format "${value}". Valid values: text, json`,
@@ -0,0 +1,39 @@
import {
defineCommand,
readConfigFile as loadConfigFile,
getConfigPath,
detectOutputFormat,
maskToken,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Display current configuration",
skipDefaultApiKeySetup: true,
exampleArgs: ["", "--output json"],
async run(config: Config, _flags: GlobalFlags) {
const file = loadConfigFile();
const format = detectOutputFormat(config.output);
const result: Record<string, unknown> = {
...file,
base_url: config.baseUrl,
output: config.output,
timeout: config.timeout,
config_file: getConfigPath(),
};
if (typeof result.api_key === "string") result.api_key = maskToken(result.api_key);
if (typeof result.access_token === "string")
result.access_token = maskToken(result.access_token);
if (typeof result.access_key_id === "string")
result.access_key_id = maskToken(result.access_key_id);
if (typeof result.access_key_secret === "string") {
result.access_key_secret = maskToken(result.access_key_secret);
}
emitResult(result, format);
},
});
@@ -1,6 +1,7 @@
import {
defineCommand,
callConsoleGateway,
effectiveConsoleGatewayConfig,
resolveConsoleGatewayCredential,
CONSOLE_GATEWAY_NO_TOKEN_MESSAGE,
BailianError,
@@ -8,13 +9,13 @@ import {
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
name: "console call",
description: "Call a Bailian console API via the CLI gateway",
usage: "bl console call --api <api> --data <json> [flags]",
skipDefaultApiKeySetup: true,
usageArgs: "--api <api> --data <json> [flags]",
options: [
{
flag: "--api <api>",
@@ -26,21 +27,27 @@ export default defineCommand({
description: "Request data as JSON string",
required: true,
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
`bl console call --api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`bl console call --api some.api.name --data '{"key":"value"}' --region cn-beijing`,
exampleArgs: [
`--api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`--api some.api.name --data '{"key":"value"}' --console-region cn-beijing`,
],
async run(config: Config, flags: GlobalFlags) {
const api = flags.api as string;
if (!api) failIfMissing("api", "bl console call --api <api> --data <json>");
if (!api) failIfMissing("api", cmdUsage(config, "--api <api> --data <json>"));
const dataRaw = flags.data as string;
if (!dataRaw) failIfMissing("data", "bl console call --api <api> --data <json>");
if (!dataRaw) failIfMissing("data", cmdUsage(config, "--api <api> --data <json>"));
let data: Record<string, unknown>;
try {
@@ -50,7 +57,6 @@ export default defineCommand({
process.exit(1);
}
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
let token: string | undefined;
@@ -63,14 +69,21 @@ export default defineCommand({
}
if (config.dryRun) {
emitResult({ api, data, region, token: token ? token.slice(0, 8) + "..." : null }, format);
emitResult(
{
api,
data,
token: token ? token.slice(0, 8) + "..." : null,
...effectiveConsoleGatewayConfig(config),
},
format,
);
return;
}
const result = await callConsoleGateway(config, token, {
api,
data,
region,
});
emitResult(result, format);
@@ -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);
}
},
});
@@ -6,14 +6,12 @@ import {
type GlobalFlags,
uploadFile,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
import { failIfMissing, cmdUsage } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
name: "file upload",
description: "Upload a local file to DashScope temporary storage (48h)",
apiDocs: "/developer-reference/get-temporary-file-url",
usage: "bl file upload --file <path> --model <model>",
usageArgs: "--file <path> --model <model>",
options: [
{
flag: "--file <path>",
@@ -26,21 +24,21 @@ export default defineCommand({
required: true,
},
],
examples: [
"bl file upload --file photo.jpg --model qwen3-vl-plus",
"bl file upload --file video.mp4 --model wan2.1-t2v-plus",
"bl file upload --file audio.wav --model qwen3-asr-flash",
"bl file upload --file cat.png --model qwen-image-2.0",
exampleArgs: [
"--file photo.jpg --model qwen3-vl-plus",
"--file video.mp4 --model wan2.1-t2v-plus",
"--file audio.wav --model qwen3-asr-flash",
"--file cat.png --model qwen-image-2.0",
],
async run(config: Config, flags: GlobalFlags) {
const filePath = flags.file as string | undefined;
if (!filePath) {
failIfMissing("file", "bl file upload --file <path> --model <model>");
failIfMissing("file", cmdUsage(config, "--file <path> --model <model>"));
}
const model = flags.model as string | undefined;
if (!model) {
failIfMissing("model", "bl file upload --file <path> --model <model>");
failIfMissing("model", cmdUsage(config, "--file <path> --model <model>"));
}
const format = detectOutputFormat(config.output);
@@ -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);
}
},
});

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