Compare commits

...

108 Commits

Author SHA1 Message Date
Gong Shiqi fac2b2d18b Merge pull request #121 from modelstudioai/fix/install-doc-non-interactive
docs: fix installation guide flags
2026-07-24 11:19:40 +08:00
若麒 3e249279bc docs: fix installation guide flags 2026-07-24 11:08:06 +08:00
Gong Shiqi f9012a6330 Merge pull request #119 from modelstudioai/fix/base-url-origin-only
fix(core): normalize model base URL to origin
2026-07-23 19:35:24 +08:00
若麒 92ee845bdd fix(core): normalize model base URL to origin 2026-07-23 19:31:02 +08:00
Gong Shiqi 678f60be75 Merge pull request #115 from modelstudioai/release/1.10.1
chore(release): prepare 1.10.1
2026-07-22 11:35:36 +08:00
若麒 d11b55b956 chore(release): prepare 1.10.1 2026-07-22 11:30:43 +08:00
Gong Shiqi e4e3f069e1 Merge pull request #112 from modelstudioai/chore/optimize-skill
docs: optimize bailian-cli skill routing and consent rules
2026-07-22 10:56:15 +08:00
Gong Shiqi 440cbfe6ae Merge pull request #114 from modelstudioai/feat/token-plan-default-models
feat: update Token Plan defaults and support local image inputs
2026-07-22 10:52:35 +08:00
若麒 1adfe797bd docs: simplify Bailian skill consent rules 2026-07-22 10:50:25 +08:00
Gong Shiqi d04012b0a4 Merge pull request #113 from modelstudioai/feat/node-engines-limit
Feat/node engines limit
2026-07-22 10:33:07 +08:00
若麒 81539005cc Merge branch 'main' into feat/token-plan-default-models 2026-07-22 09:55:39 +08:00
若麒 4c566fd60e feat(token-plan): support local images with base64 data URIs
- convert local images to Base64 for Token Plan image and video commands
- preserve the existing OSS upload flow for standard API Key profiles
- use wan2.7-image as the default image model with the sync endpoint
- hide full Base64 image content in dry-run output
- add Token Plan compatibility tests and update related docs
2026-07-22 09:54:54 +08:00
rendianmeng 853ce3caae docs: update README.zh.md 2026-07-21 14:10:49 +08:00
rendianmeng 3b779a708d feat: node engines limit change 2026-07-21 13:55:38 +08:00
clh02467605 b4a2a1c42d docs: optimize bailian-cli skill 2026-07-20 18:26:08 +08:00
若麒 4ca3e2de80 feat(config): update token-plan default models
- switch the default text model to qwen3.8-max-preview
- add dedicated T2V, I2V, and R2V model defaults
- persist and consume per-mode video model settings
- enable thinking when validating the qwen3.8 preview model
2026-07-20 16:44:53 +08:00
Gong Shiqi 1f91fa42fa Merge pull request #109 from modelstudioai/feat/cli-access-token
chore(release): prepare 1.10.0
2026-07-19 16:55:33 +08:00
若麒 39f12e1a78 chore(release): prepare 1.10.0 2026-07-19 16:47:29 +08:00
若麒 a853319dd0 feat(onboarding): add Token Plan setup guidance
- add Token Plan subscription and login entry to CLI, README, INSTALL, and skill
- document built-in Base URL and automatic key validation
- remove the completed Token Plan integration design document
2026-07-19 16:05:35 +08:00
若麒 bc162f4678 Merge branch 'main' into feat/cli-access-token 2026-07-19 15:32:20 +08:00
Gong Shiqi 12ecac4d96 Merge pull request #107 from modelstudioai/feat/config-agent
feat: add `bl config agent` command for one-click coding agent configuration
2026-07-19 11:48:36 +08:00
Gong Shiqi 52f6e267e6 Merge pull request #108 from modelstudioai/fix/bailian-cli-skill
fix(skill): refine Bailian provider routing and consent
2026-07-19 11:09:43 +08:00
若麒 c9e5913034 Merge branch 'main' into fix/bailian-cli-skill 2026-07-19 11:03:25 +08:00
lisheng.lisheng ba062c1a71 feat: add bl config agent command for one-click coding agent configuration
Add `bl config agent` to configure a coding agent (Claude Code, Qwen Code,
OpenCode, OpenClaw, Hermes, Codex) to use a DashScope/ModelStudio endpoint
with a single command. Writers non-destructively merge into each agent's
local config with a timestamped backup and atomic writes.

- Field structures aligned to farion1231/cc-switch; qwen-code aligned to
  QwenLM/qwen-code source (modelProviders/security.auth keyed by protocol).
- provider id unified as `bailian-cli` (qwen-code brands via model entry
  name + BAILIAN_CLI_API_KEY, since it keys by protocol).
- Codex config.toml merged via smol-toml to preserve unrelated settings.
- Add writer unit tests + config e2e cases; regenerate skill reference.
2026-07-18 19:29:53 +08:00
若麒 a03ba673be fix(auth): clear model base URL on full logout 2026-07-17 15:57:23 +08:00
Gong Shiqi 8196f67300 Merge pull request #95 from modelstudioai/feat/cli-access-token
feat: CLI access token 自动化 + bootstrap 一键开通 + 多命名配置 + config ui
2026-07-17 15:10:48 +08:00
若麒 4b504a1a52 docs(changelog): refine 1.9.0 release notes 2026-07-17 15:04:50 +08:00
lisheng.lisheng 00b4d095ed refactor(cli): rename bootstrap command to workspace init
- 将原有的 `bl bootstrap` 命令重命名为 `bl workspace init`
- 更新相关文档,包括中文和英文变更日志
- 修改命令导出和引用,调整文件与变量命名对应新命令
- 保持初始化 Bailian 工作空间及开通后付费服务功能不变
- 删除旧的 `bl bootstrap` 文档,新增 `bl workspace init` 命令帮助文档
- 更新 CLI 命令索引,替换旧命令为新命令
- 优化命令实现细节,增强代码规范和异常处理一致性
2026-07-17 10:59:46 +08:00
若麒 92719a1a21 Merge branch 'main' into feat/cli-access-token 2026-07-17 10:36:26 +08:00
若麒 310e6ead33 chore(release): prepare 1.9.0 2026-07-17 10:15:47 +08:00
若麒 ece0c8dd1c feat(config): activate explicit profile after successful login 2026-07-17 09:59:14 +08:00
Gong Shiqi 39f92567d3 Merge pull request #106 from modelstudioai/fix/windows-stdin
fix(text): support piped messages on Windows
2026-07-16 17:07:56 +08:00
若麒 8b91b9f35f feat: release 1.8.3 2026-07-16 16:43:22 +08:00
若麒 1fbafa8b1d fix(text): support piped messages on Windows
- use the standard input file descriptor instead of /dev/stdin
- reuse the shared file-or-stdin reader in text chat
2026-07-16 16:42:32 +08:00
若麒 052960e269 fix(config): mask all secret fields in config show 2026-07-16 15:27:18 +08:00
若麒 a8f45e93af fix(config): preserve unmanaged fields when saving profiles 2026-07-16 14:48:29 +08:00
若麒 84805f287c fix(core): normalize model base URLs across all sources
- preserve custom gateway path prefixes
- strip query, fragment, trailing slash, and known SDK suffixes
- normalize flag, environment, config, and fallback sources
- normalize auth and config writes before persistence
- add resolver, login, config, and UI coverage
2026-07-16 14:43:26 +08:00
若麒 196b2a1f51 fix(e2e): avoid live OpenAPI login with placeholder credentials 2026-07-16 11:53:09 +08:00
若麒 de9f1a3889 feat(config): add active profile selection
- persist the active profile in config.json
- resolve config with --config > active_config > default
- add config list and config use commands
- make auth and config writes target the selected profile
- reset activation to default when deleting the active profile
- update config UI with profile activation controls
- keep token refresh and pipeline execution profile-aware
- add loader, UI, auth, and CLI interaction coverage
2026-07-16 11:05:59 +08:00
若麒 75a45e1a2a fix(auth): clear STS credentials on logout 2026-07-16 10:02:00 +08:00
若麒 64ff057fe5 feat(auth): support token-plan model profile login
- add the built-in Token Plan profile preset
- validate and persist model API keys atomically
- materialize the default Base URL and models on login
- preserve flag > env > config precedence
2026-07-15 16:26:43 +08:00
gujieye 88fcda62aa Merge pull request #105 from modelstudioai/feat/change-log
feat: release 1.8.2
2026-07-15 11:42:06 +08:00
故璃 68336d41a2 feat: release 1.8.2 2026-07-15 11:29:06 +08:00
gujieye e246cb96d4 Merge pull request #104 from modelstudioai/feat/change-log
feat: add change log
2026-07-15 11:16:11 +08:00
故璃 569057f4d0 feat: add change log 2026-07-15 11:04:30 +08:00
gujieye f7dbafc6d2 Merge pull request #102 from modelstudioai/feat/model-info-query
feat: model info query & skill.md update
2026-07-15 10:29:31 +08:00
故璃 f45d0e3a71 Merge branch 'main' into feat/model-info-query 2026-07-15 10:22:44 +08:00
若麒 6d588a57b4 Merge branch 'main' into feat/cli-access-token 2026-07-14 19:23:47 +08:00
故璃 52aab33a3b feat: update SKILL.md 2026-07-14 16:14:06 +08:00
Gong Shiqi cc23b4be37 Merge pull request #101 from modelstudioai/fix/fail-stable-publish-on-existing-version
fix(release): fail when stable version is already published
2026-07-14 16:12:44 +08:00
若麒 91cd68b46e fix(release): fail when stable version is already published 2026-07-14 16:05:45 +08:00
故璃 054d4deb26 feat: update SKILL.md 2026-07-14 15:22:24 +08:00
ls cbf871c8d6 Merge pull request #100 from modelstudioai/feat/add-inner-console
chore(version): 发布1.8.1版本并更新白名单
2026-07-14 10:18:01 +08:00
lisheng.lisheng 20b67a6628 chore(version): 发布1.8.1版本并更新白名单
- 将所有相关包的版本号从1.8.0更新到1.8.1
- 在中英文CHANGELOG中添加1.8.1版本的变更记录
- 扩展Command Pack白名单,允许加载额外的内部命令扩展
2026-07-14 10:14:19 +08:00
ls 0956695f28 Merge pull request #99 from modelstudioai/feat/add-inner-console
feat(cli): 添加 @ali/bailian-plugin-inner-console-call 插件的命令前缀配置
2026-07-14 09:23:08 +08:00
lisheng.lisheng 96fcb1892c feat(cli): 添加 @ali/bailian-plugin-inner-console-call 插件的命令前缀配置
- 为插件 @ali/bailian-plugin-inner-console-call 添加命令前缀 inner-console
- 扩展了命令包策略以支持新的插件命令调用
- 确保命令前缀统一管理,提高插件识别准确性
2026-07-14 09:22:19 +08:00
lisheng.lisheng 84383f1c83 feat(bootstrap): 支持使用AK/SK生成CLI访问令牌并重构用户创建流程
- 新增命令行参数,支持通过--access-key-id和--access-key-secret传入阿里云凭证
- 集成generateCLIAccessToken接口实现AK/SK转CLI访问令牌
- 调用BailianControl OpenAPI完成用户创建,实现与控制台用户同步
- 新增获取工作空间列表接口,解析agentId以便权限管理
- 调用ResetPolicies4Agent接口完成用户权限授权
- 优化命令步骤日志输出,更详细展示执行流程
- 将轮询次数由120次减少至20次,缩短等待激活时长
- 移除旧有测试代码,适配新实现
- core包新增bailian-control客户端支持对应OpenAPI调用
- client模块添加openApiJson通用方法支持ROA风格接口调用
- console模块导出类型扩展,涵盖新的网关目标类型
- 修改acs请求签名类型支持number类型参数,增强签名兼容性
2026-07-14 08:47:16 +08:00
若麒 fc8351f136 fix(skill): refine provider routing and consent
- scope bl preference to matched Bailian and multimodal tasks
- ask once before provider-neutral remote or billable calls
- avoid routing ordinary text, generic search, and ambiguous usage requests to bl
2026-07-13 19:38:04 +08:00
Gong Shiqi de78a281c6 Merge pull request #98 from modelstudioai/feat/model-usage-and-detail
feat: model usage / recommend / model list / finetune / deploy
2026-07-13 17:07:17 +08:00
若麒 86aec0b723 chore: prepare 1.8.0 release 2026-07-13 17:01:24 +08:00
若麒 06f932c1bd Merge branch 'main' into feat/model-usage-and-detail 2026-07-13 16:23:19 +08:00
Gong Shiqi 1c5d1a8fcd Merge pull request #97 from modelstudioai/feat/command-pack
feat: add command pack protocol and plugin management
2026-07-13 16:10:33 +08:00
若麒 6388fd11e2 docs: sync command context capability usage 2026-07-13 16:05:08 +08:00
故璃 eca5403e71 feat: sync README 2026-07-13 16:02:51 +08:00
故璃 153e176c13 feat: sync README 2026-07-13 16:00:42 +08:00
故璃 16893b3f00 Merge branch 'main' into feat/model-usage-and-detail 2026-07-13 15:56:54 +08:00
故璃 78977dbe4e feat: update changelog 2026-07-13 15:55:36 +08:00
Gong Shiqi 7dd5431a22 Merge pull request #96 from modelstudioai/chore/commit-hooks
chore: speed up pre-commit skill asset sync
2026-07-13 15:55:10 +08:00
若麒 63edde3588 refactor: simplify command context capabilities
- replace lazy store and manager accessors with direct properties
- enforce command capability boundaries through lint rules
2026-07-13 15:47:57 +08:00
若麒 e932495560 Merge branch 'main' into feat/command-pack 2026-07-13 15:38:45 +08:00
clh02467605 2ae76e2013 fix: keep reference formatting in generate:reference for release check
release/check.mjs calls generate:reference directly then git diff --exit-code
on skills/bailian-cli/reference/. Formatting must stay on that script; root
sync:skill-assets should not duplicate it.
2026-07-13 15:34:47 +08:00
故璃 f3b85914ab Merge branch 'main' into feat/model-usage-and-detail 2026-07-13 15:34:04 +08:00
故璃 8b99617716 feat: fix code review issue 2026-07-13 15:23:35 +08:00
若麒 b2ca78512f feat: add command pack protocol and plugin management
- define the Command Pack API and integrate pack loading into the CLI runtime
- add product-specific package, command prefix, and credential access policies
- add plugin install, link, list, and remove commands
- add validation, tests, documentation, and generated command references
2026-07-13 15:02:35 +08:00
clh02467605 7004e58866 fix: format skill reference during sync:skill-assets for CI
CI runs sync:skill-assets before vp check without pre-commit's vp staged,
so reference markdown must be formatted in the shared sync script.
2026-07-13 14:45:25 +08:00
clh02467605 97e47f7054 refactor: update sync:skill-assets script to read from source directly
- Modified the `sync:skill-assets` script in `package.json` to remove the dependency on building `bailian-cli-core` before generating references and syncing skill versions.
2026-07-13 14:38:35 +08:00
lisheng.lisheng e0f3d450ae feat(config): add "config ui" local web UI to manage config profiles
启动绑定 127.0.0.1 的本地 HTTP server + 内嵌单页 WebUI,可视化查看/
新建/切换/删除全部命名 profile 并编辑键值与凭证。

- core: readConfigProfiles / deleteConfigProfile 全量配置读写 API
- commands/config/shared.ts: 抽出 VALID_KEYS/别名/校验,set.ts 复用
- commands/shared/local-server.ts: 抽出 listen/openInBrowser,login-console 复用
- config ui: token + Host 校验,--config 决定初始聚焦,密钥明文可编辑
2026-07-13 14:20:32 +08:00
故璃 d37f4c07eb feat: update monitor data & quato list 2026-07-13 13:43:03 +08:00
lisheng.lisheng ac4dbb9e88 Merge branch 'main' of github.com:modelstudioai/cli into feat/cli-access-token 2026-07-13 13:13:29 +08:00
lisheng.lisheng 155c9dc883 feat(config): 支持命名配置功能并隔离默认配置数据
- 新增 `--config <name>` 参数支持读取与写入命名的配置块
- 命名配置与默认配置完全隔离,互不影响
- 规范命名配置名称格式,禁止路径穿越及顶层字段冲突
- 配置文件读取写入逻辑改为维护原始完整对象,支持多配置块共存
- AuthStore 和 ConfigStore 均支持命名配置,登录登出只影响指定配置块
- CLI 命令增加对 `--config` 标志的支持,包括 config set/show/auth status 等
- 鉴权状态输出带上配置名和配置文件路径信息
- 提示和报错信息包含配置相关上下文,增强用户体验
- 完善相关单元测试覆盖命名配置行为
2026-07-13 13:04:50 +08:00
gujieye 0e857775fe Merge pull request #94 from modelstudioai/feat/recommend-new
feat: remove intent-detect-v3 model
2026-07-12 22:12:50 +08:00
故璃 1a63fcdb5c feat: add model list / opt usage 2026-07-12 22:11:32 +08:00
顾捷晔 58ab622e11 feat: remove intent-detect-v3 model 2026-07-12 13:44:00 +08:00
lisheng.lisheng e1532bf35c feat(bootstrap): 新增创建控制台用户步骤,完善服务激活流程
- 在 bootstrap 命令流程中添加第 3 步,调用 createUser 接口创建控制台账号用户
- 修改 callApi 函数支持传入请求体参数
- 增强错误日志输出,显示更完整的错误响应内容
- 在服务启动时校验登录信息包含 aliyun.uid,否则抛出错误
- 优化商品检查与激活逻辑,更清晰的状态输出及错误处理
- 在 CLI-core 控制台网关请求中添加日志,打印结构化请求负载,支持 verbose 模式
- BailianError 新增 rawResponse 字段,保存原始错误响应数据
- 增加 bootstrap 命令单元测试,覆盖用户创建及不同初始化场景
- 调整测试框架用例,增加调用控制台网关及错误处理的测试覆盖
2026-07-10 17:57:06 +08:00
Gong Shiqi 6e095a6ce5 Merge pull request #93 from modelstudioai/feat/migrate-e2e
refactor(e2e): decouple E2E tests into commands layer and shared e2e package
2026-07-10 17:55:45 +08:00
clh02467605 94adb919b1 refactor(e2e): enhance auth login tests and remove unused dependencies
- Removed the `bailian-cli-commands` dependency from `pnpm-lock.yaml` and `package.json` as it was no longer needed.
- Expanded the E2E tests in `auth.e2e.test.ts` to cover additional scenarios for the `auth login` command, ensuring proper error handling for conflicting flags and missing parameters.
- Improved test assertions for better clarity and coverage of authentication flows.
2026-07-10 17:46:17 +08:00
clh02467605 a0b4666940 refactor(e2e): remove outdated token-plan tests and add new route exports
- Deleted the `token-plan.e2e.test.ts` file as it contained outdated test cases.
- Introduced `TOKEN_PLAN_ROUTES` in `topic-routes.ts` to define new command routes for token-plan operations.
- This update streamlines the E2E testing structure and prepares for future enhancements.
2026-07-10 17:11:20 +08:00
clh02467605 e8666b33ae merge: merge main to feat/migtate-e2e 2026-07-10 16:54:14 +08:00
clh02467605 d646a4e780 refactor(e2e): update test routes and streamline command handling
- Replaced hardcoded command paths with dynamic route mappings in E2E tests.
- Enhanced documentation for command testing, specifying minimum route requirements.
- Consolidated command path management into `topic-routes.ts` for better maintainability.
- Updated various E2E test cases to utilize the new routing structure, ensuring consistency across tests.
- Removed redundant helper functions and improved test clarity by directly referencing route exports.
2026-07-10 15:55:53 +08:00
clh02467605 91a7106bf4 fix(e2e): update CLI version to match package.json 2026-07-10 14:04:21 +08:00
gujieye 03405d5cc1 Merge pull request #91 from modelstudioai/feat/model-deploy-update
feat: add tts/i2i/t2i model deploy
2026-07-10 13:55:48 +08:00
故璃 1870500f97 feat: code review fixture 2026-07-10 13:50:11 +08:00
lisheng.lisheng 3fb0c7211c feat(auth): add support for optional security token in CLI access token generation 2026-07-10 13:04:05 +08:00
clh02467605 7b77bf6a6d fix(e2e): run subprocess harness and proxy probe via tsx
Node's strip-only TypeScript mode cannot execute workspace source that
uses parameter properties. Use the monorepo tsx binary for runNodeMain and
the proxy e2e probe (.mts for top-level await).
2026-07-10 13:01:10 +08:00
clh02467605 6e5ecf8923 chore: drop redundant helpers.d.ts fmt/gitignore guards
Runtime tsconfig already limits pack to src, so tests/**/*.d.ts is never
emitted during build; the fmt ignore and gitignore entries are unnecessary.
2026-07-10 12:32:30 +08:00
clh02467605 301986b669 fix(ci): stop emitting and checking generated tests helpers.d.ts
Runtime pack build was pulling in e2e test imports and tsgo emitted
helpers.d.ts beside commands/tests; restrict runtime tsconfig to src and
ignore tests/**/*.d.ts in fmt/gitignore as a safety net.
2026-07-10 12:26:41 +08:00
clh02467605 0d420b6e5a chore: fix lint error 2026-07-10 12:10:30 +08:00
clh02467605 9602209113 feat(e2e): integrate e2e package and update test configurations
- Added e2e package as a workspace dependency in pnpm-lock.yaml.
- Updated globalSetup path in vite.config.ts to point to the new e2e package location.
- Enhanced documentation for CLI E2E testing, including architecture layers and triggering conditions.
- Refactored test paths and commands in the documentation to align with the new structure.
- Removed outdated dataset and auth E2E tests to streamline the test suite.
- Updated command references and validation checks in the documentation to reflect recent changes.
2026-07-10 11:46:05 +08:00
故璃 19b7aacb2e Merge branch 'main' into feat/model-deploy-update 2026-07-10 10:15:25 +08:00
lisheng.lisheng 049eecd991 feat(bootstrap): add command to initialize Bailian workspace and activate postpaid services 2026-07-10 10:06:32 +08:00
lisheng.lisheng 2907ad2625 feat(auth): add command to generate CLI access token 2026-07-10 00:09:10 +08:00
lisheng.lisheng 0f23527bfc feat(core): move generateCLIAccessToken to core and auto-refresh on NotLogined
Move the GenerateCLIAccessToken API call logic into core/auth/refresh-token.ts
so it can be reused across packages. Add refreshAccessToken() which reads
AK/SK from config, calls the API, and persists the new access_token.

Client.console() now catches NotLogined errors and automatically retries
with a refreshed token when AK/SK are available in config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-10 00:00:57 +08:00
lisheng.lisheng 8e3f8586b0 feat(auth): update access token retrieval in login command 2026-07-09 23:43:31 +08:00
lisheng.lisheng efb5243d0d feat(auth): call GenerateCLIAccessToken on open-api login
When logging in with --open-api, call the GenerateCLIAccessToken API
using the provided AK/SK to obtain an access token and persist it
alongside the credentials in config.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 23:40:47 +08:00
故璃 f66889c939 feat: update model deploy 2026-07-09 20:19:27 +08:00
故璃 068ec0bfd3 Merge branch 'feat/self-built-framework' into feat/model-deploy-update 2026-07-09 15:37:21 +08:00
故璃 d8aa89dc6c feat: add image/audio finetune 2026-07-09 15:36:32 +08:00
故璃 8fd072bcd1 feat: merge self-built-framework 2026-07-09 10:02:56 +08:00
268 changed files with 15479 additions and 4104 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ body:
attributes:
label: Node version
description: "Output of node --version"
placeholder: "v22.12.0"
placeholder: "v18.17.0"
validations:
required: true
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
set -eu
# Regenerate skill reference + SKILL metadata (needs bailian-cli-core dist).
# Regenerate skill reference + SKILL metadata from source (no package build).
pnpm run sync:skill-assets
# Stage generator output so it is included in this commit.
+32 -16
View File
@@ -7,7 +7,7 @@
monorepo 现在按"纯逻辑 → 运行时框架 → 命令库 → 产品入口"分层:
- `packages/core``bailian-cli-core`,纯逻辑层:鉴权、配置、HTTP client、错误、类型、文件工具
- `packages/runtime``bailian-cli-runtime`,通用 CLI 运行时:`createCli`、参数解析、registry/help、middleware、error handler、输出、pipeline
- `packages/runtime``bailian-cli-runtime`,通用 CLI 运行时:`createCli`、参数解析、registry/help、middleware、error handler、输出、pipeline、Command Pack host
- `packages/commands``bailian-cli-commands`,可复用命令实现库,只导出 command,不决定产品路径
- `packages/cli``bailian-cli`,完整 `bl` 产品入口;`src/commands.ts` 组装 `bl` 暴露的命令路径
- `packages/kscli``knowledge-studio-cli`,Knowledge Studio 专用入口;`src/main.ts` 复用 commands 并重映射为 `kscli` 路径
@@ -17,14 +17,16 @@ monorepo 现在按"纯逻辑 → 运行时框架 → 命令库 → 产品入口"
```
packages/cli/src/main.ts # bl 入口,注入 binName/version/clientName/npmPackage
packages/cli/src/commands.ts # bl 产品命令 map,tools/generate-reference.ts 也读它
packages/cli/src/command-pack-policy.ts # bl 的 Command Pack policy
packages/kscli/src/main.ts # kscli 入口和命令 map
packages/commands/src/index.ts # re-export 单个命令实现
packages/commands/src/commands/ # defineCommand({ auth, flags, usageArgs, exampleArgs, run })
packages/runtime/src/create-cli.ts # createCli(commands, identity)
packages/runtime/src/create-cli.ts # createCli(commands, options)
packages/runtime/src/registry.ts # 命令树解析 + 动态 help
packages/runtime/src/middleware.ts # auth / telemetry / update / run command
packages/runtime/src/command-packs/ # 通用 Command Pack 加载、校验、隔离安装目录和管理命令
packages/runtime/src/urls.ts # 用户面控制台 URL
packages/core/src/types/command.ts # Command / flags / auth 类型
@@ -54,20 +56,23 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
按当前任务从下表挑一条进入对应文档:
| 场景 | 何时进入 | 详见 |
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------ |
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| 场景 | 何时进入 | 详见 |
| -------------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
@@ -104,6 +109,17 @@ CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的
如果命令调用 Console Gateway,`defineCommand` 必须设置 `auth: "console"`。runtime 会基于 `CONSOLE_AUTH_FLAGS` 自动在 help 中展示 `--console-region``--console-site``--console-switch-agent``--workspace-id`,并由 `authStage` 解析/注入 console credential。命令不要重复声明这些凭证域 flag,也不要手动从 env/config 解析 token。
### 5. 禁止单字母变量命名
所有变量、参数、回调形参必须使用有语义的命名,不允许单字母(如 `i``m``p``t``e``s`)。具体表现:
- 回调参数: `.map((m) => ...)``.map((model) => ...)`, `.find((t) => ...)``.find((template) => ...)`
- catch 变量: `catch (e)``catch (error)`
- for-of 循环: `for (const i of items)``for (const item of items)`
- 临时变量: `const s = ...``const strategy = ...`
例外: 仅当作用域极小(≤3 行)且语义从上下文完全明确时,可使用 `k`/`v`(Object.entries 的 key/value)。
## 完成改动后的快速验证
```sh
+91
View File
@@ -6,6 +6,97 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.10.1] - 2026-07-22
### Changed
- Token Plan defaults now use the current text, image, and dedicated text-to-video, image-to-video, and reference-to-video models.
- The Bailian CLI Skill now distinguishes Bailian-specific tasks from ordinary host-agent work more accurately and avoids repeated consent prompts within an approved workflow.
- Published CLI packages now support Node.js 18.17 and later, lowering the previous minimum requirement from Node.js 22.12.
### Fixed
- Token Plan now handles local images correctly for image editing, image-to-video, reference-to-video, and vision understanding without requiring a separately hosted URL.
## [1.10.0] - 2026-07-19
### Added
- **`bl config agent`** — configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope in one command.
### Changed
- The Bailian CLI Skill now routes only matching Bailian and multimodal tasks to `bl`, and asks for consent before provider-neutral remote or billable calls.
### Fixed
- Full `bl auth logout` now clears the model Base URL so later logins cannot inherit a stale custom or Token Plan endpoint.
## [1.9.0] - 2026-07-17
### Added
- **Token Plan support** — log in and call supported models directly without manually configuring the endpoint.
- **Named Config Profiles** — create, switch, and manage isolated configurations; logging in to a named Profile activates it automatically.
- **Console Access Token automation** — generate and automatically refresh Console Access Tokens.
- **`bl workspace init`** — initialize a Bailian workspace and activate the required services in one workflow.
### Fixed
- Improved configuration safety and consistency, including secret masking and preservation of custom configuration fields.
## [1.8.3] - 2026-07-16
### Fixed
- Fixed `bl text chat --messages-file -` failing on Windows by treating standard input as a `/dev/stdin` file path; piped JSON messages are now read from standard input correctly. (#103)
## [1.8.2] - 2026-07-15
### Changed
- `bl model list` now defaults to JSON output; pass `--output text` for the table view.
### Fixed
- `bl model list --enrich` now returns each model's input parameter schema (predictConfig); it was previously always empty because the console gateway response envelope was not unwrapped.
## [1.8.1] - 2026-07-14
### Changed
- Expanded the Command Pack allowlist to accept an additional internal command extension.
## [1.8.0] - 2026-07-13
### Added
- **`bl model list`** — browse the Bailian model marketplace: list model families or show full details for a single family (`--model`), with filters for provider, capability, feature, and context-window, pagination (`--page` / `--page-size`), pricing, and `--enrich` for richer metadata.
- **`bl usage summary`** — a unified usage view combining free-tier quota and a recent usage overview; `--days` sets the overview window (default 7).
- **Command Pack host support** — added support for allowlisted internal command extensions.
- **Audio & image fine-tuning** — `bl finetune audio create` (CosyVoice TTS) and `bl finetune image create` (Wan image generation) join the existing text flow. `bl finetune image create` supports `--generation-type t2i|i2i` to select text-to-image or image-to-image training.
- **Audio & image deployment** — `bl deploy audio create` and `bl deploy image create` deploy fine-tuned TTS and image models as endpoints.
- **Multimodal dataset validation** — `bl dataset upload` and `bl dataset validate` now accept `.zip` archives with `tts` and `image` schemas, validate referenced media files, and allow image archives up to 1 GB.
### Changed
- **Fine-tune and deploy commands are now split by modality (BREAKING)**: `bl finetune create``bl finetune text create`, and `bl deploy create``bl deploy text create`. Update any scripts that use the old paths.
- **Deployment option renamed (BREAKING)**: `--template-id``--deploy-spec` on deployment creation commands.
- **Fine-tune status exit behavior changed (BREAKING)**: `bl finetune watch` no longer reserves exit code 3 for running jobs. Running and succeeded jobs return 0; failed and canceled jobs use normal CLI errors.
- `bl deploy audio create` now defaults to `--plan mu` (model-unit billing, per the CosyVoice deployment contract); text and image continue to default to `lora`.
- `bl finetune audio create` now validates CosyVoice training data: audio files must be `.wav`, each `wav_fn` must start with `train/`, and exactly one training file is accepted.
- `bl quota list` and `bl quota check` now report real RPM/TPM usage against limits, adding `RPM Left` / `TPM Left` columns with remaining-quota progress bars sourced from monitoring data.
- `bl usage free` output now shares its rendering with `bl usage summary` for consistent free-tier tables.
- `bl advisor recommend` no longer depends on a dedicated intent-detection model to analyze your request.
### Removed
- **Removed the `tongyi-intent-detect-v3` integration (BREAKING)** used by `bl advisor recommend`, along with the `intent_detect_base_url` config field and the `DASHSCOPE_INTENT_DETECT_BASE_URL` environment variable.
### Fixed
- Skill command-reference generation now reads product command maps directly from source and produces stable formatting during release checks.
## [1.7.0] - 2026-07-09
### Added
+91
View File
@@ -6,6 +6,97 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.10.1] - 2026-07-22
### 变更
- Token Plan 默认模型已更新为当前文本、图片,以及文生视频、图生视频和参考生视频的专用模型。
- 百炼 CLI Skill 现在能更准确地区分百炼专属任务与普通宿主 Agent 任务,并避免在已授权的工作流中重复征求同意。
- 已发布的 CLI 包现在支持 Node.js 18.17 及以上版本,最低版本要求由 Node.js 22.12 下调至 18.17。
### 修复
- Token Plan 现在能在图片编辑、图生视频、参考生视频和视觉理解中正确处理本地图片,无需另行托管为 URL。
## [1.10.0] - 2026-07-19
### 新增
- **`bl config agent`** —— 一键配置 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 和 Codex 接入百炼模型服务。
### 变更
- 百炼 CLI Skill 现在只将匹配的百炼任务与多模态任务路由到 `bl`,并会在调用与平台无关的远程或计费能力前征求同意。
### 修复
- 完整执行 `bl auth logout` 时会同时清除模型 Base URL避免后续登录继承失效的自定义或 Token Plan 接入地址。
## [1.9.0] - 2026-07-17
### 新增
- **支持 Token Plan** —— 登录后即可直接调用支持的模型,无需手动配置接入地址。
- **命名 Config Profile** —— 支持创建、切换和管理相互隔离的配置,登录后会自动激活当前 Profile。
- **Console Access Token 自动化** —— 支持生成并自动刷新 Console Access Token。
- **`bl workspace init`** —— 一站式完成百炼工作空间初始化和所需服务开通。
### 修复
- 提升配置安全性与一致性,包括密钥脱敏和自定义配置字段保留。
## [1.8.3] - 2026-07-16
### 修复
- 修复 Windows 上 `bl text chat --messages-file -` 将标准输入当作 `/dev/stdin` 文件路径读取的问题;通过管道传入的 JSON 消息现在可以从标准输入正常读取。(#103
## [1.8.2] - 2026-07-15
### 变更
- `bl model list` 现在默认以 JSON 输出;需要表格视图请传 `--output text`
### 修复
- `bl model list --enrich` 现在能正确返回每个模型的输入参数 schemapredictConfig此前因未解包控制台网关响应信封而始终为空。
## [1.8.1] - 2026-07-14
### 变更
- 扩展 Command Pack 白名单,允许加载额外的内部命令扩展。
## [1.8.0] - 2026-07-13
### 新增
- **`bl model list`** —— 浏览百炼模型市场:列出模型家族,或用 `--model` 查看单个家族的完整详情;支持按 provider、能力、特性、上下文窗口过滤分页`--page` / `--page-size`)、价格展示,以及 `--enrich` 获取更丰富的元数据。
- **`bl usage summary`** —— 统一用量视图,一屏合并免费额度与近期用量概览;`--days` 设置概览时间窗口(默认 7 天)。
- **Command Pack 宿主支持** —— 新增面向白名单内部命令扩展包的加载能力。
- **音频与图像精调** —— 在原有文本流程之外新增 `bl finetune audio create`CosyVoice 语音合成)与 `bl finetune image create`(万相图像生成)。`bl finetune image create` 支持 `--generation-type t2i|i2i` 显式选择文生图或图生图训练。
- **音频与图像部署** —— `bl deploy audio create``bl deploy image create` 可将精调后的语音合成与图像模型部署为推理接入点。
- **多模态数据集校验** —— `bl dataset upload``bl dataset validate` 现在支持使用 `tts``image` schema 的 `.zip` 压缩包,可校验包内引用的媒体文件,图像数据压缩包上限提升至 1 GB。
### 变更
- **精调与部署命令按模态拆分BREAKING**`bl finetune create``bl finetune text create``bl deploy create``bl deploy text create`。请更新使用旧路径的脚本。
- **部署参数重命名BREAKING**:部署创建命令的 `--template-id` 更名为 `--deploy-spec`
- **精调状态退出行为变更BREAKING**`bl finetune watch` 不再使用退出码 3 表示任务运行中;运行中与成功均返回 0失败与取消使用 CLI 的常规错误流程。
- `bl deploy audio create` 默认使用 `--plan mu`(按模型单元计费,符合 CosyVoice 部署契约);文本与图像仍默认 `lora`
- `bl finetune audio create` 现在会校验 CosyVoice 训练数据:音频必须为 `.wav`,每条 `wav_fn` 必须以 `train/` 开头,且只接受一个训练文件。
- `bl quota list``bl quota check` 现在会基于监控数据展示真实的 RPM/TPM 用量与限额,新增 `RPM Left` / `TPM Left` 列及剩余额度进度条。
- `bl usage free` 的输出现在与 `bl usage summary` 共用渲染逻辑,免费额度表格更一致。
- `bl advisor recommend` 不再依赖独立的意图识别模型来分析你的需求。
### 已移除
- **移除 `bl advisor recommend` 使用的 `tongyi-intent-detect-v3` 集成BREAKING**,同时移除 `intent_detect_base_url` 配置字段与 `DASHSCOPE_INTENT_DETECT_BASE_URL` 环境变量。
### 修复
- Skill 命令参考文档生成现在直接读取产品命令源码,并在发布检查中保持稳定格式。
## [1.7.0] - 2026-07-09
### 新增
+2 -1
View File
@@ -6,7 +6,8 @@ Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (
## Prerequisites
- Node.js ≥ 22.12
- Node.js ≥ 22.12 (required for developing this repo)
- End users installing the published CLI only need Node.js >= 18.17
- pnpm 10.33.2 (`npm i -g pnpm@10.33.2`)
- A DashScope API key for running e2e tests
+3 -2
View File
@@ -6,8 +6,9 @@
## 环境要求
- Node.js ≥ 22.12
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
- 参与本仓库开发:Node.js ≥ 22.12
- 仅安装/使用已发布的 CLINode.js >= 18.17
- pnpm 10.33.2`npm i -g pnpm@10.33.2`
- 跑 e2e 需要一个百炼 API Key
## 仓库结构
+17 -9
View File
@@ -7,7 +7,7 @@
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
1. **Node.js**
- 要求 **22.12.0**,执行 `node -v` 确认。
- 要求 **18.17.0**,执行 `node -v` 确认。
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
2. **npm****唯一**允许的包管理器)
@@ -69,7 +69,7 @@ npx skills add modelstudioai/cli --all -g
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
### 备选:由 Agent 引导用户输入 API Key 后登录
### 备选:由 Agent 引导用户输入普通 API Key 后登录
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
@@ -80,6 +80,15 @@ npx skills add modelstudioai/cli --all -g
3. 用户提供了 Key 之后,在**用户本机终端**执行Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>`
4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。
### 备选二:使用 Token Plan API Key
- 获取入口:[Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview)
1. 请用户从订阅详情页获取或复制 Token Plan API Key勿要求用户发到公开渠道。
2. 在用户本机终端执行:`bl auth login --config token-plan --api-key <用户提供的_Key>`
3. `token-plan` Profile 已内置默认 Base URL登录命令会先测试 Key通过后才保存并激活该 Profile无需另行配置或重复测试。
4. 执行 `bl auth status --config token-plan --output json` 确认;汇报时只使用 masked 字段。
### 其他方式
- **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。
@@ -89,20 +98,19 @@ npx skills add modelstudioai/cli --all -g
### Agent 安全约束
- **禁止**把真实 API Key 写入仓库、日志、Skill、聊天记录的可公开部分。
- CI / 非交互环境:使用 `bl ... --non-interactive`通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
- CI / 非交互环境:显式传入必填参数并使用 `--output json` 获取机器可读结果;如需纯文本输出,设置 `NO_COLOR=1`通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
---
## 4. 最小功能验证
## 4. 配置验证
在鉴权配置完成后执行
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态
```bash
bl auth status --output json
bl text chat --message "ping" --non-interactive --output json
```
失败根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、`base_url`。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`
无需再执行重复的模型调用测试。若登录失败根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、`base_url`。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`
---
@@ -111,7 +119,7 @@ bl text chat --message "ping" --non-interactive --output json
| 现象 | 可能原因 | 建议动作 |
| ----------------------- | -------------------- | --------------------------------------------------------------- |
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 22.12 |
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 引导用户更新 Key 并 `bl auth login --api-key` |
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 |
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
+28 -9
View File
@@ -5,7 +5,7 @@
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![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)
@@ -30,6 +30,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **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
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
> **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.
@@ -38,8 +39,8 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Fine-tuning & deployment** — Upload datasets, create SFT/LoRA/DPO/CPT jobs (`finetune create`), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy create`)
- **Console capabilities** — Browse Bailian apps (`app list`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), 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
@@ -80,7 +81,7 @@ npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 22.12.
> Requires Node.js >= 18.17.
## Quick Start
@@ -91,6 +92,12 @@ bl auth login --console
# Or authenticate with an API key
bl auth login --api-key sk-xxxxx
# Or use Token Plan (Base URL built in; the key is tested during login)
bl auth login --config token-plan --api-key sk-sp-xxxxx
# Configure a coding agent to use DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# Chat with Qwen
bl text chat --message "What is DashScope?"
@@ -114,13 +121,15 @@ bl auth login --console
# Fine-tune & deploy — a one-shot train-to-serve workflow
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
bl finetune watch --job-id ft-xxx --output json # Non-blocking status probe (exit 0/1/3 = done/failed/running)
bl finetune text 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 probe (running/succeeded return 0; failed/canceled report an error)
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
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
# Browse apps / free-tier quota / usage statistics / workspaces
# Browse models / apps / free-tier quota / usage statistics / workspaces
bl model list # Browse model families and pricing
bl app list
bl usage summary # Unified view: free-tier quota + recent usage overview
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
@@ -157,9 +166,18 @@ bl auth login --api-key sk-xxxxx
bl text chat --api-key sk-xxxxx --message "Hello"
```
### Token Plan API Key
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
```
### Console Login (OAuth)
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.
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
@@ -207,6 +225,7 @@ Config file location: `~/.bailian/config.json`
| 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 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
+28 -9
View File
@@ -5,7 +5,7 @@
**阿里云百炼 (DashScope) AI 平台命令行工具**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![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)
@@ -30,6 +30,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
@@ -38,8 +39,8 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建 SFT/LoRA/DPO/CPT 调优任务(`finetune create`)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy create`
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
## 示例:一句话生成一部电影短片
@@ -78,7 +79,7 @@ npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 22.12
> 需要预先安装 Node.js >= 18.17
## 快速开始
@@ -89,6 +90,12 @@ bl auth login --console
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 或使用 Token Plan已内置 Base URL登录时自动测试 Key
bl auth login --config token-plan --api-key sk-sp-xxxxx
# 配置 Coding Agent 使用 DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# 和通义千问对话
bl text chat --message "你好,介绍一下阿里云百炼平台"
@@ -112,13 +119,15 @@ 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 text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0失败/取消报错
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
bl deploy create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
bl model list # 浏览模型系列与价格信息
bl app list
bl usage summary # 统一视图:免费额度 + 近期用量概览
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
bl workspace list # 列出所有业务空间
@@ -155,9 +164,18 @@ bl auth login --api-key sk-xxxxx
bl text chat --api-key sk-xxxxx --message "你好"
```
### Token Plan API Key
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
CLI 已内置 Token Plan 的默认 Base URL登录命令会先测试 Key通过后才保存并激活 `token-plan` 配置。
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
```
### 控制台登录OAuth
控制台能力命令(`app list``usage free``usage stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
@@ -205,6 +223,7 @@ bl update
| 通义千问模型列表 | 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 |
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
+8 -5
View File
@@ -37,18 +37,21 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
- `bl auth login --open-api ...` 只更新 `access_key_id` / `access_key_secret`
- `bl auth logout --console` 只清 `access_token`
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret`
- `bl auth logout``api_key` + `access_token` + `access_key_*`
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token`
- `bl auth logout``api_key` + `base_url` + `access_token` + `access_key_*`
解析分工:
- `resolveApiKey()``auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin仅保留协议、host 和显式端口,去除 path、query、fragment
- `--config` 只选择 config 文件 block不提升该 block 的字段优先级;内置套餐 Profile当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile未传
`--config` 时继续写当前激活项,失败和 dry-run 不切换
- `resolveConsole()``auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
- `resolveOpenApi()``auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段
- `describeAuthState()``auth status` / banner / telemetry 使用的只读快照
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore()` / `ctx.configStore()` 的窄接口操作落盘。
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
## 必查清单
@@ -87,7 +90,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- [ ] `packages/commands/src/commands/auth/login.ts`:
- 新增/调整登录 flag 与流程
- 持久化只走 `ctx.authStore().login(...)`
- 持久化只走 `ctx.authStore.login(...)`
- [ ] `packages/commands/src/commands/auth/status.ts`:
- 分别显示 model / console / openapi 鉴权状态,并 mask token
- [ ] `packages/commands/src/commands/auth/logout.ts`:
+73 -28
View File
@@ -1,31 +1,58 @@
# CLI E2E 测试规范
## 架构分层
| 层级 | 路径 | 测什么 |
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup`private`,不发布) |
| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、livegated每用例最小路由 |
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help |
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组identity`--version``search --help` path |
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
**依赖边界**`e2e``core``commands/tests``e2e` + `commands/src`;产品 tests → `e2e` + 各自 `src`。**禁止**产品 import `commands/tests/**`(子进程 spawn harness 路径除外)。
## 触发条件
- 新增/修改 `packages/commands/src/commands` 下的 command 实现
- 新增/修改 `packages/cli/src/commands.ts``bl` 命令路径 map
- 新建或扩展 `packages/cli/tests/e2e/*.e2e.test.ts` 用例
- 为命令补 help / 缺参 / dry-run / 真实集成测试
- 新建或扩展 `packages/commands/tests/e2e/<topic>.e2e.test.ts`
- 新增 bl 产品 path → `registry.smoke` 自动覆盖 leaf pathcommands topic 测试在 `topic-routes.ts` 补最小路由
以上情况必须同步维护 `packages/cli/tests/e2e/<topic>.e2e.test.ts`跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`
跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`
> **规则**:共享 command 行为在 `commands/tests/e2e`;产品 map、identity、CLI-only 命令留在对应产品 `tests/e2e`。
## 文件与工具
- 路径:`packages/cli/tests/e2e/<kebab-topic>.e2e.test.ts`
- 框架:`vite-plus/test`;子进程跑 CLI`runCli` from `./helpers.ts`
### commands E2E
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`
- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`spawn `harness/main.ts``routes` 为本 topic 最小 path → export 映射)
- fixtures`packages/commands/tests/e2e/fixtures/`
- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map
### 产品 smoke
- bl`runCli` from `packages/cli/tests/e2e/helpers.ts`
- kscli`runKscli` from `packages/kscli/tests/e2e/helpers.ts`
### 共享
- gating / output / runner`e2e/gating``e2e/output``e2e/runner`
- globalSetup`vite.config.ts``packages/e2e/src/global-setup.ts`
- 解析 JSON stdout`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))`
- 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时
## 双层 describe固定结构
```ts
// 1) 不 skip分组 + --help无密钥、无真实 API
// 1) 不 skip--help无密钥、无真实 API(分组 help 由 bl registry.smoke 覆盖)
describe("e2e: <topic>", () => {
test("<group> 分组展示子命令帮助且成功退出", ...);
test("<subcommand> --help 正常退出", ...);
});
// 2) skipIf缺参 / dry-run / 真实集成;原有集成用例放最后、勿改逻辑
// 2) skipIf缺参 / dry-run / 真实集成
describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
test("缺少 --<flag> 时退出为用法错误 (2)", ...);
test("<cmd> --dry-run ...", ...); // 若适用
@@ -33,51 +60,69 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
});
```
## skip 条件(helpers.ts
## skip 条件(`e2e/gating`commands helpers re-export
| 场景 | 条件 |
| ------------------- | ----------------------------------------------------- |
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
| 知识库 | `isKnowledgeE2EReady()` |
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
| 场景 | 条件 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
| OpenAPI AK/SK | `isOpenApiE2EReady()``.env` 中必须同时提供完整 AK/SK |
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()``knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent ID |
## 用例类型
1. **分组 help**`runCli(["image"])``exitCode === 0`stdout+stderr 含子命令名
2. **--help**`runCli([..., "--help"])` → stderr 含主要 flags
3. **缺参**:带一个无害全局 flag`--quiet`)且不传 required flag → `exitCode === 2`stderr 匹配 `--flag|Missing required argument`
4. **--dry-run**:仅当实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本,不入网
5. **真实集成**:保留既有用例名称与断言;放在 skip 块**末尾**
1. **--help**`runCommandE2e(ROUTES, [..., "--help"])` → stderr 含主要 flags
2. **缺参**:带无害全局 flag`--quiet`)且不传 required flag → `exitCode === 2`
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
4. **真实集成**:放在 skip 块**末尾**
## 增删命令同步
- **commands export** + **topic 路由**`topic-routes.ts` 或测试文件内 `ROUTES`+ **产品 map**`cli/commands.ts` / `kscli/commands.ts`
- 分组 help 由产品 `registry.smoke` 负责,无需在 commands 重复
## 安全与例外
- **禁止真实破坏性操作**`auth logout` 只用 `--dry-run``config set` 只用 `--dry-run`
- **禁止破坏真实用户配置**`auth logout` 默认只用 `--dry-run`需要验证实际落盘时,必须通过
`BAILIAN_CONFIG_DIR` 指向隔离 fixture`config set` 只用 `--dry-run`
- **不加 dry-run**`dryRun``resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit``speech recognize``--url`
- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag缺参用例勿带该 flag
- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言
## 新增 command 检查清单
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `topic-routes.ts` 补最小路由
- [ ] `packages/commands/tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
- [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交
- [ ] 顶层:分组 help + 子命令 `--help`多子命令则各一条 help
- [ ] 子命令 `--help`分组 help 由 bl `registry.smoke` 覆盖
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
- [ ] 至少一条真实集成(或说明为何仅 smoke不破坏已有集成用例顺序
- [ ] `pnpm test packages/cli/tests/e2e/<file>` 通过
- [ ] `vp test packages/commands/tests/e2e/<file>` 通过
## 调试命令
```sh
pnpm --filter bailian-cli-commands exec vp test packages/commands/tests/e2e/text-chat.e2e.test.ts
pnpm --filter bailian-cli exec vp test packages/cli/tests/e2e/registry.smoke.e2e.test.ts
pnpm --filter knowledge-studio-cli exec vp test packages/kscli/tests/e2e/registry.smoke.e2e.test.ts
pnpm --filter bailian-cli-runtime exec vp test packages/runtime/tests/proxy.e2e.test.ts
```
## 示例片段
```ts
import { FOO_ROUTES } from "./topic-routes.ts";
test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["foo", "bar", "--quiet"]);
const { stderr, exitCode } = await runCommandE2e(FOO_ROUTES, ["foo", "bar", "--quiet"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prompt|Missing required argument/i);
});
test("foo bar --dry-run 仅输出计划", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(FOO_ROUTES, [
"foo",
"bar",
"--dry-run",
@@ -97,4 +142,4 @@ test("foo bar --dry-run 仅输出计划", async () => {
- **E2E**:单条/少量调用、断言固定、可进 `vp test`(见上文 skip 条件)
- **批量压测**`packages/cli/tests/stress/run.mjs` + `targets/*.mjs`,并发 + 报告,**仅手动** `pnpm run test:stress -- <target>`
勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md)
勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md).
+9 -6
View File
@@ -72,7 +72,8 @@ packages/commands/src/index.ts
- `exampleArgs`(不含 bin/path 前缀)
- `validate`(跨 flag 校验)
- 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client`
- `commands/auth/**` 可用 `ctx.authStore()`,`commands/config/**` 可用 `ctx.configStore()`;不要把这些 store accessor 扩散到普通业务命令
- `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令
- `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口
- [ ] `packages/commands/src/index.ts`:新增或移除对应 export
- [ ] 如果命令调用 Console Gateway,设置 `auth: "console"`;不要重复声明 console 凭证域 flags
- [ ] 如果命令不需要网络或自己管理配置/登录,设置 `auth: "none"`;不要绕过 runtime auth stage
@@ -92,15 +93,17 @@ packages/commands/src/index.ts
### D. 测试层
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/cli/tests/e2e/<topic>.e2e.test.ts`
- [ ] 删除命令时一并删对应 e2e / README 示例 / reference 生成结果
- [ ] 如果 shared command 在不同入口路径下复用,至少确保 `bl` 入口 e2e 覆盖;`kscli` 入口改动需补对应入口测试或手工 smoke
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/commands/tests/e2e/<topic>.e2e.test.ts`
- [ ] 同步 `packages/commands/tests/e2e/topic-routes.ts`(该 topic 的最小 path → export 映射)
- [ ] bl 产品 path 变更由 `registry.smoke` 自动覆盖kscli 变更同步 `kscli/src/commands.ts``registry.smoke`
- [ ] 删除命令时一并删对应 commands e2e / README 示例 / reference / topic 路由条目
- [ ] 如果 shared command 在不同入口路径下复用,至少确保 commands e2e 覆盖 `bl` path`kscli` 入口改动需补对应 smoke 或说明不测 flat path live
### E. 重命名特殊处理
- [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新:
- `packages/cli/src/commands.ts` map key
- `packages/kscli/src/main.ts` map key(如适用)
- `packages/kscli/src/commands.ts` map key(如适用)
- 用户可见 hint / README / tests
- `skills/bailian-cli/reference/`(重建后检查并提交)
- [ ] 检查 `usageArgs` / `exampleArgs` 没有硬编码旧的 `bl <path>` 前缀
@@ -111,7 +114,7 @@ packages/commands/src/index.ts
pnpm run sync:skill-assets
pnpm -F bailian-cli exec tsx src/main.ts <new-command> --help
pnpm -F bailian-cli exec tsx src/main.ts
vp test packages/cli/tests/e2e/<topic>.e2e.test.ts
vp test packages/commands/tests/e2e/<topic>.e2e.test.ts
```
如改了 `kscli` 入口:
+56
View File
@@ -0,0 +1,56 @@
# Command Pack 维护
## 触发条件
- 新增或移除 Command Pack 包
- 调整包白名单、允许的命令前缀或协议字段
- 修改 `plugin install/link/list/remove`
- 修改 Command Pack 加载、隔离、兼容性或独立安装目录
## 分层边界
- `packages/core/src/types/command-pack.ts`:稳定的协议元数据和导出类型,不知道具体产品或白名单。
- `packages/runtime/src/command-packs/`:所有 CLI 共用的加载、校验、API 适配、产品隔离安装目录和 manager 实现。
- `packages/runtime/src/create-cli.ts`:始终接收静态 command map`CliOptions.commandPacks` 统一合并 pack并把已绑定产品 identity/policy 的 manager 注入 `ctx.commandPacks`
- `packages/commands/src/commands/plugin/`:普通共享管理命令,只依赖 `ctx.commandPacks`,不 import 任何产品 policy。
- `packages/cli/src/command-pack-policy.ts``bl` 支持的包、命令前缀和凭据授权。
- `kscli` 当前不传 `commandPacks`,使用 runtime 的默认空 policy。
- 当前只有 `bl``bailian-cli-commands` 导入并登记 `plugin *`;使用默认空 policy 的产品不提前暴露管理命令。
不要把产品白名单写进 core/runtime也不要通过扫描全局 `node_modules` 自动发现包。通用机制放 runtime产品差异只由 policy 表达。
## 安全与兼容性清单
- [ ] 包名必须精确命中当前产品 policy 的 `supported`,命令路径必须位于该包允许的前缀。
- [ ] 正式安装只接受包名加 version/tag本地目录只走 `plugin link`
- [ ] npm 使用独立安装目录和 `--ignore-scripts`,不污染 CLI 自身依赖树。
- [ ] npm 子进程只继承明确允许的 registry/config/cache/proxy/TLS 配置,不通配透传 pnpm 注入的 `npm_config_*`
- [ ] 安装目录按 `identity.npmPackage` 隔离,不能让一个产品安装/删除另一个产品的 pack。
- [ ] 安装目录只隔离依赖位置不隔离执行权限Command Pack 必须视为 CLI 进程内的完全可信代码。
- [ ] 入口 realpath 不能逃逸包根目录。
- [ ] 加载前检查 `type``apiVersion``minCliVersion`;报告状态只使用 `loaded/failed`,具体原因写入 `error`
- [ ] Command Pack 不能覆盖内置命令、其他 pack 命令或重声明保留 flag。
- [ ] 普通网络请求走 `ctx.client`;基础 Context 提供 `identity/settings/flags/client/output/errors`,不提供原始凭据。
- [ ] `ctx.credentials.apiKey()` 仅限 policy 显式声明 `credentialAccess: ["apiKey"]`,且命令自身为 `auth: "apiKey"`
- [ ] 不向 Command Pack 暴露原始 Console Token、OpenAPI AK/SK、`authStore``configStore`
- [ ] 不向 Command Pack 暴露宿主的 `commandPacks` manager避免 pack 安装或删除其他 pack。
- [ ] 单包失败必须 fail-open保留内置命令和其他合法 pack。
- [ ] 破坏协议前优先在适配层兼容;确实无法兼容时才提升 `apiVersion`
## 测试与文档
- [ ] `packages/runtime/tests/command-packs.test.ts` 覆盖产品 policy、安装目录隔离、协议版本、前缀和导出契约。
- [ ] `packages/cli/tests/e2e/command-packs.e2e.test.ts` 覆盖 help、link、执行、output/errors、凭据授权、list、remove。
- [ ] `packages/kscli/tests/e2e/command-packs.e2e.test.ts` 覆盖统一 host 和 runtime 默认空 policy 下不暴露管理命令。
- [ ] fixture 的包名必须在测试白名单内,且构建入口不依赖工作区运行时解析。
- [ ] 更新生成的 `skills/bailian-cli/reference/plugin.md`;公开 `README.md` / `README.zh.md` 等正式对外发布时再补。
验证:
```sh
vp test packages/runtime/tests/command-packs.test.ts
vp test packages/cli/tests/e2e/command-packs.e2e.test.ts
vp test packages/kscli/tests/e2e/command-packs.e2e.test.ts
pnpm run sync:skill-assets
vp check
```
+75
View File
@@ -0,0 +1,75 @@
# Config Profile 与激活状态变更清单
适用于新增 Profile 预设、修改命名 Profile 选择规则、调整 `active_config`,或新增/修改 `bl config list/use/show/ui` 等 Profile 管理能力。
## 1. 保持存储边界
- Profile 业务字段继续由 `ConfigFile` / `CONFIG_FILE_KEYS` 管理。
- `active_config``config.json` 顶层元数据,不得进入命名 Profile block也不得被 `config set` 当作普通字段写入。
- 识别命名 Profile 时必须排除业务字段和顶层元数据。
- 旧配置缺少 `active_config` 时继续等价于激活 `default`
## 2. 保持选择语义
```text
显式 --config <name> > active_config > default
```
- 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`
- `--config default` 必须显式选择顶层配置并绕过命名激活项。
- 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是
`auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。
- 激活状态只选择配置 block不改变字段优先级字段仍为 flag > env > selected config > 默认值。
- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。
- Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。
## 3. 保持读写命令交互一致
- `auth login``config set` 等写命令未传 `--config` 时修改当前激活项。
- `auth login --config <name>` 显式指定不存在的 Profile 时,仅在凭证验证成功并实际落盘时
创建和激活;`config set --config <name>` 可创建但不自动激活。
- `config show``auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。
- `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。
- 按凭证域退出时必须清理该域的完整字段集合,例如 OpenAPI 同时清理 AK、SK 和 STS `security_token`
- 所有生产代码读取“当前配置”时优先经过 `buildSources()` 或携带解析后的 `configName`;直接调用无名称的 `readConfigFile()` / `writeConfigFile()` 只适用于明确操作顶层 default 的底层能力。
## 4. 保持状态一致性
- `config use` 只能激活已经存在的命名 Profile`default` 始终有效。
- 配置文件中的 `active_config` 指向不存在的 Profile 时返回 usage error不静默回退。
- 删除当前激活的命名 Profile 时,同一次落盘切回 `default`,不得留下悬空引用。
- 配置写入继续使用临时文件 + rename避免中断后留下半写文件。
## 5. 命令与展示联动
- 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`
- `config list` 标识所有 Profile 与当前激活项。
- `config show``auth status` 只输出本次最终选择的 `config``config_file`,不重复携带激活状态。
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`
- `config ui` 保存时只替换 UI 管理的字段Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
## 6. 最小测试矩阵
- 旧配置无 `active_config` -> `default`
- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。
- 显式命名 `--config``--config default` 均覆盖激活项且不修改磁盘状态。
- 激活不存在的 Profile 失败且不写盘。
- 悬空 `active_config` 明确失败。
- 删除激活 Profile 后切回 `default`
- 登录、退出、`config set` 分别覆盖“当前激活项”和“显式不存在名称成功后创建”。
- 显式 `auth login --config <name>` 成功后激活该 Profile失败或 dry-run 不创建、不切换;
`--config default` 成功后切回 `default`
- Console token 自动刷新不从其他 Profile 借用 AK/SK也不把新 token 写入其他 Profile。
- `config list/show/use/ui``auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
## 7. 完成检查
```sh
pnpm run sync:skill-assets
vp check
vp test
```
命令 E2E 会启动本地子进程Config UI 测试还会监听 `127.0.0.1` 临时端口;受限沙箱内出现 `EPERM` 时,需要在允许本地进程和端口的环境中复跑。
+42
View File
@@ -0,0 +1,42 @@
# 安装文档变更
## 触发条件
- 修改根目录 `INSTALL.md` 的安装、鉴权或验证流程
- 修改发布包 Node.js 要求、全局 flag 或安装文档引用的命令
- 同步或发布 `https://bailian.aliyun.com/cli/install.md`
## 必查清单
### A. CLI 契约
- [ ] `INSTALL.md` 中的 `bl` 命令路径存在于 `packages/cli/src/commands.ts`
- [ ] 示例 flag 属于 `GLOBAL_FLAGS`、命令鉴权域 flag 或命令自身 `flags`
- [ ] Node.js 用户安装要求与 `packages/cli/package.json``engines.node` 一致,不使用根 `package.json` 的开发环境要求
- [ ] 鉴权流程与 `packages/commands/src/commands/auth/` 的实际校验、保存和 Profile 激活行为一致
### B. 静态副本
- [ ]`INSTALL.md` 同步到 `bailian-cli-static-resources/public/install.txt`
- [ ] 使用 `cmp -s` 确认两份文档逐字节一致
- [ ] 静态资源仓库单独创建分支、提交和发布,不把跨仓库改动遗漏在 CLI PR 之外
### C. 线上验证
- [ ] 发布后读取 `https://bailian.aliyun.com/cli/install.md`,确认内容来自最新静态副本
- [ ] 带随机 query 参数复查,区分 CDN 缓存与源站未更新
- [ ] 验证线上文档中的安装命令、Node.js 要求和配置验证段落,不只检查页面可访问
## 完成后自查
```sh
pnpm -F bailian-cli test -- tests/install-doc.test.ts
cmp -s INSTALL.md ../bailian-cli-static-resources/public/install.txt
curl -L -s "https://bailian.aliyun.com/cli/install.md?verify=$(date +%s)"
```
## 常见漏点
- `--non-interactive` 已从 CLI 移除,但旧安装文档和静态副本仍把它当作全局 flag
-`package.json` 是开发工具链 Node.js 要求;用户安装要求以 `packages/cli/package.json` 为准
- 静态仓库文件名是 `public/install.txt`,线上稳定地址是 `/cli/install.md`;只更新其中一侧不会自动证明发布成功
+2 -2
View File
@@ -12,7 +12,7 @@
### A. 版本一致性
- [ ] `package.json``engines.node` 与 README 的 Node.js 徽章一致
- [ ] 发布包(`cli` 等)`engines.node` 与 README 的 Node.js 徽章一致;根/e2e 开发要求(`>=22.12`)与 CONTRIBUTING 一致
- [ ] `pnpm-lock.yaml` 同步生成(运行 `pnpm install`)
- [ ] 各源码包 `tsconfig.json`(根 + core + runtime + commands + cli + kscli)的 target / module 设置一致
@@ -42,7 +42,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`
- [ ] pre-commit 会跑 `pnpm run sync:skill-assets`(`generate:reference` 含格式化 + `sync:skill-version`,直接读源码、无需先 build)并 `git add` skill 资产,最后 `vp staged`
### F. CI / 发版工具
+15 -13
View File
@@ -29,8 +29,9 @@
1. 确保当前 release tooling 覆盖的包(`tools/release/lib/packages.mjs`)已升到目标版本且一致;当前基础集合为 `packages/core` / `packages/runtime` / `packages/commands` / `packages/cli``knowledge-studio-cli` 发布会额外包含 `packages/kscli`
2. 在 GitHub 触发 Publish workflowpackage 选目标包集合mode 选 `stable`
3. 需要 production environment 审批人批准
4. CI 自动:自检 → 构建 → 发布到 latest → 打 git tag
5. 对应脚本:`tools/release/publish-stable.mjs`
4. CI 自动:自检 → 构建 → 检查 npm 已发布版本 → 发布到 latest → 打 git tag
5. 如果所选发布集合的当前版本已全部存在于 npmstable 发布会失败并提示先升级版本号如果只有部分包已发布CI 会继续补发缺失包
6. 对应脚本:`tools/release/publish-stable.mjs`
## 自检(`tools/release/check.mjs`
@@ -92,14 +93,15 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
## 常见漏点(基于历史踩坑)
| 漏点 | 后果 |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
| 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 |
| 漏点 | 后果 |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
| cli 升版号但 core 没升 | check.mjs 会拦下 |
| 发版漏更 CHANGELOG或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag所有用户被强升撤回成本极高 |
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
| Node 徽章 `cli/package.json.engines` 不一致(当前应为 `>=18.17` | 用户在声明外的 Node 上 `npm i` 被 engine 警告或直接失败 |
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npmCI 明确报错并要求先升级版本号 |
+1
View File
@@ -19,6 +19,7 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
BAILIAN_CONSOLE_ROOT bailian.console.aliyun.com
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
+1 -1
View File
@@ -16,7 +16,7 @@
"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",
"sync:skill-assets": "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",
+28 -9
View File
@@ -5,7 +5,7 @@
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![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)
@@ -30,6 +30,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **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
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
> **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.
@@ -38,8 +39,8 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Fine-tuning & deployment** — Upload datasets, create SFT/LoRA/DPO/CPT jobs (`finetune create`), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy create`)
- **Console capabilities** — Browse Bailian apps (`app list`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), 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
@@ -80,7 +81,7 @@ npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 22.12.
> Requires Node.js >= 18.17.
## Quick Start
@@ -91,6 +92,12 @@ bl auth login --console
# Or authenticate with an API key
bl auth login --api-key sk-xxxxx
# Or use Token Plan (Base URL built in; the key is tested during login)
bl auth login --config token-plan --api-key sk-sp-xxxxx
# Configure a coding agent to use DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# Chat with Qwen
bl text chat --message "What is DashScope?"
@@ -114,13 +121,15 @@ bl auth login --console
# Fine-tune & deploy — a one-shot train-to-serve workflow
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
bl finetune create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
bl finetune watch --job-id ft-xxx --output json # Non-blocking status probe (exit 0/1/3 = done/failed/running)
bl finetune text 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 probe (running/succeeded return 0; failed/canceled report an error)
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
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
# Browse apps / free-tier quota / usage statistics / workspaces
# Browse models / apps / free-tier quota / usage statistics / workspaces
bl model list # Browse model families and pricing
bl app list
bl usage summary # Unified view: free-tier quota + recent usage overview
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
@@ -157,9 +166,18 @@ bl auth login --api-key sk-xxxxx
bl text chat --api-key sk-xxxxx --message "Hello"
```
### Token Plan API Key
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
```
### Console Login (OAuth)
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.
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
@@ -207,6 +225,7 @@ Config file location: `~/.bailian/config.json`
| 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 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
+28 -9
View File
@@ -5,7 +5,7 @@
**阿里云百炼 (DashScope) AI 平台命令行工具**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![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)
@@ -30,6 +30,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
@@ -38,8 +39,8 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建 SFT/LoRA/DPO/CPT 调优任务(`finetune create`)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy create`
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
## 示例:一句话生成一部电影短片
@@ -78,7 +79,7 @@ npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 22.12
> 需要预先安装 Node.js >= 18.17
## 快速开始
@@ -89,6 +90,12 @@ bl auth login --console
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 或使用 Token Plan已内置 Base URL登录时自动测试 Key
bl auth login --config token-plan --api-key sk-sp-xxxxx
# 配置 Coding Agent 使用 DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# 和通义千问对话
bl text chat --message "你好,介绍一下阿里云百炼平台"
@@ -112,13 +119,15 @@ 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 text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0失败/取消报错
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
bl deploy create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
bl model list # 浏览模型系列与价格信息
bl app list
bl usage summary # 统一视图:免费额度 + 近期用量概览
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
bl workspace list # 列出所有业务空间
@@ -155,9 +164,18 @@ bl auth login --api-key sk-xxxxx
bl text chat --api-key sk-xxxxx --message "你好"
```
### Token Plan API Key
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
CLI 已内置 Token Plan 的默认 Base URL登录命令会先测试 Key通过后才保存并激活 `token-plan` 配置。
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
```
### 控制台登录OAuth
控制台能力命令(`app list``usage free``usage stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
@@ -205,6 +223,7 @@ bl update
| 通义千问模型列表 | 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 |
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.7.0",
"version": "1.10.1",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
@@ -59,12 +59,13 @@
"ajv": "catalog:",
"boxen": "catalog:",
"chalk": "catalog:",
"e2e": "workspace:*",
"typescript": "^6.0.2",
"undici": "catalog:",
"vite-plus": "0.1.22",
"yaml": "catalog:"
},
"engines": {
"node": ">=22.12.0"
"node": ">=18.17.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { CommandPackPolicy } from "bailian-cli-runtime";
/** Command Packs accepted by the bl product. */
export const commandPackPolicy = {
supported: {
"@ali/bailian-plugin-agent": {
commandPrefixes: ["agent"],
credentialAccess: ["apiKey"],
},
"@ali/bailian-plugin-inner-console-call": {
commandPrefixes: ["inner-console"],
},
},
} as const satisfies CommandPackPolicy;
+36 -4
View File
@@ -3,6 +3,7 @@ import {
authLogin,
authStatus,
authLogout,
authGenerateAccessToken,
textChat,
textOmni,
imageGenerate,
@@ -15,6 +16,10 @@ import {
visionDescribe,
configShow,
configSet,
configList,
configUse,
configUi,
configAgent,
update,
appCall,
appList,
@@ -39,9 +44,11 @@ import {
usageFree,
usageFreetier,
usageStats,
usageSummary,
pipelineRun,
pipelineValidate,
advisorRecommend,
modelList,
workspaceList,
quotaList,
quotaRequest,
@@ -52,7 +59,9 @@ import {
datasetGet,
datasetDelete,
datasetValidate,
finetuneCreate,
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
finetuneList,
finetuneGet,
finetuneCancel,
@@ -62,7 +71,9 @@ import {
finetuneExport,
finetuneWatch,
finetuneCapability,
deployCreate,
deployTextCreate,
deployAudioCreate,
deployImageCreate,
deployList,
deployGet,
deployModels,
@@ -73,6 +84,11 @@ import {
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
workspaceInit,
pluginInstall,
pluginLink,
pluginList,
pluginRemove,
} from "bailian-cli-commands";
// Full bailian-cli product: every command, exposed under the `bl` binary.
@@ -84,6 +100,7 @@ export const commands: Record<string, AnyCommand> = {
"auth login": authLogin,
"auth status": authStatus,
"auth logout": authLogout,
"auth generate-access-token": authGenerateAccessToken,
"text chat": textChat,
omni: textOmni,
"image generate": imageGenerate,
@@ -96,6 +113,10 @@ export const commands: Record<string, AnyCommand> = {
"vision describe": visionDescribe,
"config show": configShow,
"config set": configSet,
"config list": configList,
"config use": configUse,
"config ui": configUi,
"config agent": configAgent,
update,
"app call": appCall,
"app list": appList,
@@ -120,9 +141,11 @@ export const commands: Record<string, AnyCommand> = {
"usage free": usageFree,
"usage freetier": usageFreetier,
"usage stats": usageStats,
"usage summary": usageSummary,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"advisor recommend": advisorRecommend,
"model list": modelList,
"workspace list": workspaceList,
"quota list": quotaList,
"quota request": quotaRequest,
@@ -133,7 +156,9 @@ export const commands: Record<string, AnyCommand> = {
"dataset get": datasetGet,
"dataset delete": datasetDelete,
"dataset validate": datasetValidate,
"finetune create": finetuneCreate,
"finetune text create": finetuneTextCreate,
"finetune audio create": finetuneAudioCreate,
"finetune image create": finetuneImageCreate,
"finetune list": finetuneList,
"finetune get": finetuneGet,
"finetune cancel": finetuneCancel,
@@ -143,7 +168,9 @@ export const commands: Record<string, AnyCommand> = {
"finetune export": finetuneExport,
"finetune watch": finetuneWatch,
"finetune capability": finetuneCapability,
"deploy create": deployCreate,
"deploy text create": deployTextCreate,
"deploy audio create": deployAudioCreate,
"deploy image create": deployImageCreate,
"deploy list": deployList,
"deploy get": deployGet,
"deploy models": deployModels,
@@ -154,4 +181,9 @@ export const commands: Record<string, AnyCommand> = {
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
"token-plan add-member": tokenPlanAddMember,
"workspace init": workspaceInit,
"plugin install": pluginInstall,
"plugin link": pluginLink,
"plugin list": pluginList,
"plugin remove": pluginRemove,
};
+2
View File
@@ -1,5 +1,6 @@
import { createCli } from "bailian-cli-runtime";
import { commands } from "./commands.ts";
import { commandPackPolicy } from "./command-pack-policy.ts";
import pkg from "../package.json" with { type: "json" };
const quickStartTasks = [
@@ -15,4 +16,5 @@ void createCli(commands, {
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
}).run();
-268
View File
@@ -1,268 +0,0 @@
import { readFileSync } from "fs";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, makeE2eOutputDir, parseStdoutJson, runCli } from "./helpers.ts";
/**
* Auth 相关 E2E只验证 CLI 进程能正常解析参数并退出。
*/
describe("e2e: auth", () => {
test("auth 分组展示子命令帮助且退出码为 0", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/auth|Authentication|login|logout|status/i);
});
test("auth login --help 正常退出", async () => {
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/);
expect(stderr).toMatch(/--open-api/);
});
test("auth logout --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["auth", "logout", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/logout|dry-run|yes/i);
});
test("auth status --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["auth", "status", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/status|output/i);
});
test("auth login 缺少 --api-key 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/Choose exactly one login mode/);
});
test("auth login 一次只能选择一种登录模式", async () => {
const { stderr, exitCode } = await runCli([
"auth",
"login",
"--console",
"--api-key",
"sk-e2e-placeholder",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Choose exactly one login mode/);
});
test("auth login 模式专属参数不能脱离对应模式", async () => {
const openApiFlagOnly = await runCli(["auth", "login", "--access-key-id", "LTAI-e2e"]);
expect(openApiFlagOnly.exitCode).toBe(2);
expect(openApiFlagOnly.stderr).toMatch(/Use --open-api with --access-key-id/);
const baseUrlWithoutApiKey = await runCli([
"auth",
"login",
"--console",
"--base-url",
"https://dashscope.aliyuncs.com",
]);
expect(baseUrlWithoutApiKey.exitCode).toBe(2);
expect(baseUrlWithoutApiKey.stderr).toMatch(/Use --base-url only with --api-key/);
const consoleSiteWithoutConsole = await runCli([
"auth",
"login",
"--api-key",
"sk-e2e-placeholder",
"--console-site",
"international",
]);
expect(consoleSiteWithoutConsole.exitCode).toBe(2);
expect(consoleSiteWithoutConsole.stderr).toMatch(/Use --console-site only with --console/);
});
test("auth login --open-api 要求 AK/SK 成对输入", async () => {
const { stderr, exitCode } = await runCli([
"auth",
"login",
"--open-api",
"--access-key-id",
"LTAI-e2e",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/);
});
test("auth login --dry-run --api-key 不发起校验与落盘", async () => {
const { stdout, stderr, exitCode } = await runCli([
"auth",
"login",
"--dry-run",
"--api-key",
"sk-e2e-dry-run-placeholder",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Would validate and save API key.");
});
test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => {
const { stdout, stderr, exitCode } = await runCli([
"auth",
"login",
"--dry-run",
"--api-key",
"sk-e2e-dry-run-placeholder",
"--output",
"json",
"--timeout",
"120",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Would validate and save API key.");
});
test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--output", "json"]);
expect(exitCode).toBe(2);
const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } };
expect(err.error?.code).toBe(2);
expect(err.error?.message).toMatch(/Choose exactly one login mode/);
});
test("auth logout --dry-run 不写入配置", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run"]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No changes made.");
expect(stderr).not.toContain("Cleared api_key");
});
test("auth logout --dry-run --quiet", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run", "--quiet"]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No changes made.");
});
test("auth logout --dry-run --output json不清除密钥", async () => {
const { stdout, stderr, exitCode } = await runCli([
"auth",
"logout",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No changes made.");
expect(stderr).not.toContain("Cleared api_key");
});
test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "text"]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(
/Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/,
);
});
test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "json"]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
api_key?: { source?: string; masked?: string; base_url?: string };
}>(stdout);
expect(data.authenticated).toBe(true);
expect(data.api_key?.source).toBeDefined();
});
test.skipIf(!isDashScopeE2EReady())(
"auth status --output json --quiet(base_url 经 env 指定;凭证域 flag 对 status 不可见)",
async () => {
const { stdout, stderr, exitCode } = await runCli(
["auth", "status", "--output", "json", "--quiet"],
{ DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ authenticated?: boolean; api_key?: unknown }>(stdout);
expect(data.authenticated).toBe(true);
expect(data.api_key).toBeDefined();
},
);
test("auth status 不接受凭证域覆盖 flag(--base-url 报 Unknown flag)", async () => {
const { stderr, exitCode } = await runCli(["auth", "status", "--base-url", "https://x.test"]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--base-url/);
});
test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => {
const { stdout, stderr, exitCode } = await runCli(["auth", "status", "--output", "json"], {
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder",
});
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
}>(stdout);
expect(data.authenticated).toBe(true);
expect(data.openapi?.source).toBe("env");
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-placeholder");
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder");
const denied = await runCli(["auth", "status", "--access-key-id", "ak"]);
expect(denied.exitCode).not.toBe(0);
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
});
test("auth login --open-api 持久化 OpenAPI AK/SK 并支持单独 logout", async () => {
const configDir = makeE2eOutputDir("auth-openapi-login");
const env = {
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
};
const login = await runCli(
[
"auth",
"login",
"--open-api",
"--access-key-id",
"LTAI-e2e-login-placeholder",
"--access-key-secret",
"secret-e2e-login-placeholder",
],
env,
);
expect(login.exitCode, login.stderr).toBe(0);
expect(login.stderr).toMatch(/OpenAPI credentials saved/);
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config.access_key_id).toBe("LTAI-e2e-login-placeholder");
expect(config.access_key_secret).toBe("secret-e2e-login-placeholder");
expect(config.openapi_access_key_id).toBeUndefined();
expect(config.openapi_access_key_secret).toBeUndefined();
const status = await runCli(["auth", "status", "--output", "json"], env);
expect(status.exitCode, status.stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
}>(status.stdout);
expect(data.authenticated).toBe(true);
expect(data.openapi?.source).toBe("config");
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-login-placeholder");
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-login-placeholder");
const logout = await runCli(["auth", "logout", "--open-api"], env);
expect(logout.exitCode, logout.stderr).toBe(0);
expect(logout.stderr).toMatch(/Cleared access_key_id/);
const after = await runCli(["auth", "status", "--output", "json"], env);
expect(after.exitCode, after.stderr).toBe(0);
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(after.stdout);
expect(afterData.openapi).toBeUndefined();
});
});
@@ -0,0 +1,134 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
const fixtureRoot = join(fileURLToPath(import.meta.url), "..", "..", "fixtures", "command-pack");
let configDir: string;
function env(): NodeJS.ProcessEnv {
return { BAILIAN_CONFIG_DIR: configDir, DO_NOT_TRACK: "1" };
}
describe("e2e: Command Pack", () => {
beforeAll(async () => {
configDir = await mkdtemp(join(tmpdir(), "bl-command-pack-e2e-"));
});
afterAll(async () => {
await rm(configDir, { recursive: true, force: true });
});
test("plugin 分组和管理命令 help 正常", async () => {
const group = await runCli(["plugin"], env());
expect(group.exitCode, group.stderr).toBe(0);
expect(group.stderr).toContain("plugin install");
expect(group.stderr).toContain("plugin link");
expect(group.stderr).toContain("plugin list");
expect(group.stderr).toContain("plugin remove");
const install = await runCli(["plugin", "install", "--help"], env());
expect(install.exitCode, install.stderr).toBe(0);
expect(install.stderr).toContain("--package");
});
test("未 link 时插件命令不存在", async () => {
const result = await runCli(["agent", "ping", "--message", "before"], env());
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toMatch(/Unknown command/i);
});
test("link 后命令进入原生 registry/help/执行链路", async () => {
const linked = await runCli(
["plugin", "link", "--path", fixtureRoot, "--output", "json"],
env(),
);
expect(linked.exitCode, linked.stderr).toBe(0);
const linkedJson = parseStdoutJson<{ linked: { name: string; commands: string[] } }>(
linked.stdout,
);
expect(linkedJson.linked.name).toBe("@ali/bailian-plugin-agent");
expect(linkedJson.linked.commands).toEqual([
"agent credential",
"agent credential-denied",
"agent fail",
"agent output",
"agent ping",
]);
const rootHelp = await runCli(["--help"], env());
expect(rootHelp.exitCode, rootHelp.stderr).toBe(0);
expect(rootHelp.stderr).toContain("agent ping");
const commandHelp = await runCli(["agent", "ping", "--help"], env());
expect(commandHelp.exitCode, commandHelp.stderr).toBe(0);
expect(commandHelp.stderr).toContain("Ping the Command Pack fixture");
expect(commandHelp.stderr).toContain("--message");
const executed = await runCli(["agent", "ping", "--message", "hello"], env());
expect(executed.exitCode, executed.stderr).toBe(0);
expect(executed.stdout).toContain("command-pack:hello");
const credential = await runCli(["agent", "credential", "--api-key", "fixture-key"], env());
expect(credential.exitCode, credential.stderr).toBe(0);
expect(credential.stdout).toContain("credential-source:flag");
const denied = await runCli(["agent", "credential-denied"], {
...env(),
DASHSCOPE_API_KEY: "fixture-key",
});
expect(denied.exitCode).toBe(1);
expect(denied.stderr).toContain('must declare auth="apiKey"');
const outputText = await runCli(["agent", "output"], env());
expect(outputText.exitCode, outputText.stderr).toBe(0);
expect(outputText.stdout).toBe("command-pack-output\n");
const outputJson = await runCli(["agent", "output", "--output", "json"], env());
expect(outputJson.exitCode, outputJson.stderr).toBe(0);
expect(parseStdoutJson(outputJson.stdout)).toEqual({ source: "command-pack", ok: true });
const failed = await runCli(["agent", "fail", "--output", "text"], env());
expect(failed.exitCode).toBe(2);
expect(failed.stderr).toContain("Command Pack fixture usage error.");
expect(failed.stderr).toContain("Use agent fail only in tests.");
});
test("plugin list 输出加载状态", async () => {
const result = await runCli(["plugin", "list", "--output", "json"], env());
expect(result.exitCode, result.stderr).toBe(0);
const json = parseStdoutJson<{
command_packs: Array<{ name: string; status: string; commands: string[] }>;
}>(result.stdout);
expect(json.command_packs).toEqual([
expect.objectContaining({
name: "@ali/bailian-plugin-agent",
status: "loaded",
commands: [
"agent credential",
"agent credential-denied",
"agent fail",
"agent output",
"agent ping",
],
}),
]);
});
test("remove 后命令从下一进程消失", async () => {
const removed = await runCli(
["plugin", "remove", "--name", "@ali/bailian-plugin-agent", "--output", "json"],
env(),
);
expect(removed.exitCode, removed.stderr).toBe(0);
expect(parseStdoutJson<{ removed: string }>(removed.stdout).removed).toBe(
"@ali/bailian-plugin-agent",
);
const result = await runCli(["agent", "ping", "--message", "after"], env());
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toMatch(/Unknown command/i);
});
});
@@ -0,0 +1,164 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
function withTempConfigDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-"));
return fn(dir).finally(() => {
rmSync(dir, { recursive: true, force: true });
});
}
function writeConfig(dir: string, data: Record<string, unknown>): void {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "config.json"), JSON.stringify(data, null, 2) + "\n");
}
describe("e2e: named config", () => {
test("根帮助展示 --config 全局标志", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--config <name>/);
});
test("config set --config 写入命名 block 且不影响默认配置", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, { output: "text", api_key: "sk-default" });
const setResult = await runCli(
[
"config",
"set",
"--config",
"dev",
"--key",
"output",
"--value",
"json",
"--output",
"json",
],
{ BAILIAN_CONFIG_DIR: dir },
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
const setData = parseStdoutJson<{
output?: string;
config?: string;
config_file?: string;
}>(setResult.stdout);
expect(setData.output).toBe("json");
expect(setData.config).toBe("dev");
expect(setData.config_file).toBe(join(dir, "config.json"));
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(raw.output).toBe("text");
expect((raw.dev as Record<string, unknown>).output).toBe("json");
});
});
test("config show --config 只展示命名 block", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, {
output: "text",
api_key: "sk-default",
dev: { output: "json", access_token: "tok-dev" },
});
const { stdout, stderr, exitCode } = await runCli(
["config", "show", "--config", "dev", "--output", "json"],
{ BAILIAN_CONFIG_DIR: dir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<Record<string, unknown>>(stdout);
expect(data.config).toBe("dev");
expect(data.config_file).toBe(join(dir, "config.json"));
expect(data.output).toBe("json");
expect(data.access_token).toBeDefined();
expect(data.api_key).toBeUndefined();
});
});
test("auth status --config 不继承默认凭证", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, { api_key: "sk-default", dev: { output: "json" } });
const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], {
BAILIAN_CONFIG_DIR: dir,
DASHSCOPE_API_KEY: "",
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
});
expect(devStatus.exitCode, devStatus.stderr).toBe(0);
const devData = parseStdoutJson<Record<string, unknown>>(devStatus.stdout);
expect(devData.authenticated).toBe(false);
expect(devData.config).toBe("dev");
const defaultStatus = await runCli(["auth", "status", "--output", "json"], {
BAILIAN_CONFIG_DIR: dir,
DASHSCOPE_API_KEY: "",
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
});
expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0);
const defaultData = parseStdoutJson<Record<string, unknown>>(defaultStatus.stdout);
expect(defaultData.authenticated).toBe(true);
expect(defaultData.config).toBe("default");
});
});
test("--config default 等价默认配置", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, {
active_config: "token-plan",
output: "json",
api_key: "sk-default",
"token-plan": { output: "text", api_key: "sk-token" },
});
const { stdout, stderr, exitCode } = await runCli(
["config", "show", "--config", "default", "--output", "json"],
{ BAILIAN_CONFIG_DIR: dir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<Record<string, unknown>>(stdout);
expect(data.config).toBe("default");
expect(data.active).toBeUndefined();
expect(data.api_key).toBeDefined();
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(raw.active_config).toBe("token-plan");
});
});
test("非法 --config 名称报 usage error", async () => {
const { stderr, exitCode } = await runCli(["auth", "status", "--config", "../evil"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid config name/);
});
test("auth status 文本输出分行展示选中 Config 和配置文件", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, {
active_config: "token-plan",
"token-plan": { api_key: "sk-token" },
});
const result = await runCli(["auth", "status", "--output", "text"], {
BAILIAN_CONFIG_DIR: dir,
DASHSCOPE_API_KEY: "",
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain("Config: token-plan\n");
expect(result.stdout).toContain(`Config file: ${join(dir, "config.json")}\n`);
expect(result.stdout).not.toContain("Active config:");
});
});
});
-155
View File
@@ -1,155 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
/**
* Config 相关 E2E
*/
describe("e2e: config", () => {
test("config 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["config"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/config|show|set/i);
});
test("config show --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["config", "show", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/show|config/i);
});
test("config set --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["config", "set", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/set|--key|--value/i);
});
test("config show --output json", async () => {
const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "json"]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
config_file?: string;
base_url?: string;
timeout?: number;
}>(stdout);
expect(data.config_file).toBeDefined();
expect(data.base_url).toBeDefined();
expect(data.timeout).toBeDefined();
});
test("config show --output text", async () => {
const { stdout, stderr, exitCode } = await runCli(["config", "show", "--output", "text"]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/config_file|timeout|base_url/i);
});
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["config", "set", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--key|--value|Usage:/i);
});
test("config set 非法 key 时退出为用法错误", async () => {
const { stderr, exitCode } = await runCli([
"config",
"set",
"--key",
"not-a-real-key",
"--value",
"x",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid config key|not-a-real-key/i);
});
test("config set 非法 output", async () => {
const { stderr, exitCode } = await runCli([
"config",
"set",
"--key",
"output",
"--value",
"yaml",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid output|text, json/i);
});
test("config set 非法 timeout", async () => {
const { stderr, exitCode } = await runCli([
"config",
"set",
"--key",
"timeout",
"--value",
"0",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid timeout|positive/i);
});
test("config set --dry-run 不落盘(仅输出 would_set", async () => {
const { stdout, stderr, exitCode } = await runCli([
"config",
"set",
"--dry-run",
"--key",
"output",
"--value",
"json",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { output?: string } }>(stdout);
expect(data.would_set?.output).toBe("json");
});
test("config set --dry-run 支持连字符别名 key", async () => {
const { stdout, stderr, exitCode } = await runCli([
"config",
"set",
"--dry-run",
"--key",
"default-text-model",
"--value",
"qwen3.7-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
});
test("config set --dry-run 支持 AccessKey 短字段别名", async () => {
const { stdout, stderr, exitCode } = await runCli([
"config",
"set",
"--dry-run",
"--key",
"access-key-id",
"--value",
"LTAI-config-placeholder",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(stdout);
expect(data.would_set?.access_key_id).toBe("LTAI-config-placeholder");
});
test("config set 不接受旧 OpenAPI AccessKey 字段名", async () => {
const { stderr, exitCode } = await runCli([
"config",
"set",
"--key",
"openapi_access_key_id",
"--value",
"LTAI-config-placeholder",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid config key|openapi_access_key_id/);
});
});
+40 -203
View File
@@ -1,215 +1,52 @@
import { execFile } from "child_process";
import { mkdirSync, readFileSync } from "fs";
import { promisify } from "util";
import { basename, dirname, join } from "path";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { readConfigFile } from "bailian-cli-core";
import {
cliTimeoutPrefix,
cliTimeoutSeconds,
e2eLabelFromMetaUrl,
isConsoleAuthFailure,
makeE2eOutputDir,
parseStdoutJson,
} from "e2e/output";
import { runNodeMain, type RunCliResult } from "e2e/runner";
import {
isBailianE2EEnabled,
isBailianE2EMediaEnabled,
isBailianE2EVideoEnabled,
isChatE2EReady,
isConsoleE2EReady,
isDashScopeE2EReady,
isSearchE2EReady,
} from "e2e/gating";
import { monorepoRoot } from "e2e/monorepo-root";
const execFileAsync = promisify(execFile);
export {
cliTimeoutPrefix,
cliTimeoutSeconds,
e2eLabelFromMetaUrl,
isBailianE2EEnabled,
isBailianE2EMediaEnabled,
isBailianE2EVideoEnabled,
isChatE2EReady,
isConsoleAuthFailure,
isConsoleE2EReady,
isDashScopeE2EReady,
isSearchE2EReady,
makeE2eOutputDir,
monorepoRoot,
parseStdoutJson,
};
export type { RunCliResult };
/**
* Vitest `global-setup.ts` 写入 `test/output/` 下本文件名,供各 worker 进程读取同一会话 id。
* (仅模块内变量无法跨 Vitest 多进程 worker 共享。)
*/
export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session";
/**
* 单次 `vp test` / Vitest 运行共用的 E2E 输出会话目录名(惰性缓存于当前进程)。
*/
let e2eOutputSessionId: string | undefined;
/** `packages/cli` 根目录(含 `src/main.ts` */
/** `packages/cli` 根目录 */
export const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const mainTs = join(cliPackageRoot, "src", "main.ts");
/** Monorepo 根(含根 `package.json` */
export function monorepoRoot(): string {
return join(cliPackageRoot, "..", "..");
}
export function localBin(name: string): string {
return join(
monorepoRoot(),
"node_modules",
".bin",
process.platform === "win32" ? `${name}.cmd` : name,
);
}
function readE2eRunSessionFromOutputDir(): string | undefined {
try {
const p = join(monorepoRoot(), "test", "output", E2E_RUN_SESSION_FILENAME);
const t = readFileSync(p, "utf8").trim();
return t.length > 0 ? t : undefined;
} catch {
return undefined;
}
}
function getE2eOutputSessionId(): string {
if (!e2eOutputSessionId) {
const fromEnv = process.env.BAILIAN_E2E_RUN_ID?.trim();
if (fromEnv) {
e2eOutputSessionId = fromEnv.replace(/[^a-zA-Z0-9._-]+/g, "-");
} else {
const fromFile = readE2eRunSessionFromOutputDir();
if (fromFile) {
e2eOutputSessionId = fromFile.replace(/[^a-zA-Z0-9._-]+/g, "-");
} else {
e2eOutputSessionId = `e2e-run-${Date.now()}-${process.pid}`;
}
}
}
return e2eOutputSessionId;
}
/**
* 在 `test/output/<会话>/` 下创建用例子目录。
* 会话 id 优先 `BAILIAN_E2E_RUN_ID`,否则读 Vitest globalSetup 写入的 `test/output/.e2e-run-session`
* 再否则回退为单进程 id非 Vitest 直接跑用例时)。
* 若已设 `BAILIAN_E2E_OUT` 则直接使用(不再套会话目录)。
*/
export function makeE2eOutputDir(label: string): string {
const fromEnv = process.env.BAILIAN_E2E_OUT?.trim();
if (fromEnv) {
mkdirSync(fromEnv, { recursive: true });
return fromEnv;
}
const safe = label.replace(/[^a-zA-Z0-9._-]+/g, "-");
const sessionDir = join(monorepoRoot(), "test", "output", getE2eOutputSessionId());
mkdirSync(sessionDir, { recursive: true });
const dir = join(sessionDir, `e2e-vp-${safe}-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
/** 全局 `--timeout` 秒数(视频等长任务) */
export function cliTimeoutSeconds(): string {
return process.env.BAILIAN_E2E_TIMEOUT_SEC?.trim() || "3600";
}
export function cliTimeoutPrefix(): string[] {
return ["--timeout", cliTimeoutSeconds()];
}
/** 显式开启后才跑真实网络 E2E避免默认 `vp test` 依赖密钥或打外网 */
export function isBailianE2EEnabled(): boolean {
return process.env.BAILIAN_E2E === "1";
}
/** 可调 DashScope 的 API Key环境变量优先否则读 ~/.bailian/config.json */
export function isDashScopeE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_API_KEY?.trim()) return true;
try {
const f = readConfigFile();
return typeof f.api_key === "string" && f.api_key.length > 0;
} catch {
return false;
}
}
/**
* Console-gateway 命令quota / usage free / usage stats的 E2E 就绪检查:
* 需 `BAILIAN_E2E=1` 且存在 console access_token`~/.bailian/config.json` 的
* `access_token`;凭证解析已集中到 authStage不再读环境变量
*
* 仅检查 token 是否存在——无法本地判断是否过期。token 过期时 gated 用例仍会执行,
* 但用 `isConsoleAuthFailure` 把“session 未登录/已过期”的优雅报错视为通过,保持
* 与 deploy/dataset “无 key / 有效 key / 失效 key 均绿”的一致策略。
*/
export function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
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;
return isBailianE2EEnabled();
}
/** 文生视频 / 图生视频 / 参考视频 / 视频编辑(耗时长,默认关闭) */
export function isBailianE2EVideoEnabled(): boolean {
return isBailianE2EEnabled() && process.env.BAILIAN_E2E_VIDEO === "1";
}
/** 从 `import.meta.url` 生成 OUT 子目录标签,避免并行用例目录冲突 */
export function e2eLabelFromMetaUrl(metaUrl: string): string {
return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, "");
}
/** 知识库用例:须显式索引 ID + API-KEY */
export function isKnowledgeE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (!process.env.BAILIAN_E2E_INDEX_ID) return false;
return isDashScopeE2EReady();
}
export interface RunCliResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* 子进程执行 CLI等价于在 `packages/cli` 下 `tsx src/main.ts ...`)。
* request_id 等诊断信息在 stderr`--output json` 时 JSON 在 stdout。
*/
/** 子进程执行 bl CLI */
export async function runCli(
args: string[],
envOverrides: NodeJS.ProcessEnv = {},
): Promise<RunCliResult> {
try {
const { stdout, stderr } = await execFileAsync(localBin("tsx"), [mainTs, ...args], {
cwd: cliPackageRoot,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
env: {
...process.env,
NODE_NO_WARNINGS: "1",
DO_NOT_TRACK: "1",
...envOverrides,
},
});
return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 };
} catch (err: unknown) {
const e = err as {
stdout?: string;
stderr?: string;
code?: number;
};
return {
stdout: e.stdout ?? "",
stderr: e.stderr ?? "",
exitCode: typeof e.code === "number" ? e.code : 1,
};
}
}
export function parseStdoutJson<T = unknown>(stdout: string): T {
const t = stdout.trim();
// Extract JSON object — stdout may contain [perf] console.time lines before JSON
const jsonMatch = t.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error(`No JSON object found in stdout: ${t.slice(0, 200)}`);
return JSON.parse(jsonMatch[0]) 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);
return runNodeMain(mainTs, args, { cwd: cliPackageRoot, env: envOverrides });
}
@@ -1,149 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
interface DryRunBody {
endpoint?: string;
request?: {
query?: string;
agent_id?: string;
images?: string[];
query_history?: Array<{ role: string; content: string }>;
};
}
describe("e2e: knowledge search", () => {
test("knowledge search --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "search", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--query/i);
expect(stderr).toMatch(/--agent-id/i);
expect(stderr).toMatch(/--workspace-id/i);
expect(stderr).toMatch(/--image/i);
expect(stderr).toMatch(/--query-history/i);
});
test("缺少 --query 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "search", "--agent-id", "aid_test"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--query|Usage:/i);
});
test("缺少 --agent-id 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "search", "--query", "test"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--agent-id|Usage:/i);
});
test("缺少 --workspace-id 时非零退出并提示", async () => {
const { stderr, exitCode } = await runCli(
// 假 key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
[
"knowledge",
"search",
"--query",
"test",
"--agent-id",
"aid_test",
"--api-key",
"sk-fake",
"--output",
"json",
],
{ BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" },
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/workspace.*required/i);
});
test("--dry-run 输出 endpoint 和 request body", async () => {
const { stdout, stderr, exitCode } = await runCli([
"knowledge",
"search",
"--dry-run",
"--query",
"什么是RAG",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/);
expect(data.endpoint).toMatch(/api\/v1\/indices\/knowledge\/search/);
expect(data.request?.query).toBe("什么是RAG");
expect(data.request?.agent_id).toBe("aid_test");
});
test("--dry-run + --image 输出 images", async () => {
const { stdout, stderr, exitCode } = await runCli([
"knowledge",
"search",
"--dry-run",
"--query",
"test",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--image",
"https://example.com/a.jpg",
"--image",
"https://example.com/b.jpg",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.images).toEqual([
"https://example.com/a.jpg",
"https://example.com/b.jpg",
]);
});
test("--dry-run + --query-history 输出用户对话历史", async () => {
const { stdout, stderr, exitCode } = await runCli([
"knowledge",
"search",
"--dry-run",
"--query",
"它怎么工作",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--query-history",
'[{"role":"user","content":"什么是RAG"},{"role":"assistant","content":"RAG是检索增强生成"}]',
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.query_history).toEqual([
{ role: "user", content: "什么是RAG" },
{ role: "assistant", content: "RAG是检索增强生成" },
]);
});
test("--dry-run + --query-history 无效 JSON 非零退出", async () => {
const { stderr, exitCode } = await runCli([
"knowledge",
"search",
"--dry-run",
"--query",
"test",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--query-history",
"not-valid-json",
"--output",
"json",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/query-history.*valid JSON/i);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, test } from "vite-plus/test";
import { deriveGroupPaths } from "e2e/registry-smoke";
import { commands } from "../../src/commands.ts";
import { runCli } from "./helpers.ts";
const commandPaths = Object.keys(commands).sort();
const groupPaths = deriveGroupPaths(commandPaths);
describe("e2e: bl registry smoke", () => {
test("根帮助展示 bl 与全局 flag", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/\bbl\b/i);
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:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Global Flags:/);
expect(stderr).toMatch(/--console-region <region>/);
expect(stderr).toMatch(/--model <model>/);
expect(stderr).toMatch(/--period <minutes>/);
expect(stderr).toMatch(/--output <format>/);
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
});
test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => {
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
expect(exitCode, stderr).toBe(0);
});
test.each(groupPaths)("命令分组 %s --help 成功", async (path) => {
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
expect(exitCode, stderr).toBe(0);
});
});
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: usage summary", () => {
test("usage summary --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["usage", "summary", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--days|summary|usage/i);
});
test("usage summary --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["usage", "summary", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage summary");
expect(stderr).toContain("bl usage summary --days 30");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage summaryConsole", () => {
test("usage summary --dry-run 输出 free-tier 计划请求", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"summary",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ freeTier?: { api?: string }; usage?: unknown }>(stdout);
expect(data.freeTier?.api).toContain("queryFreeTierQuota");
});
test("usage summary --dry-run --workspace-id 附带用量概览计划请求", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"summary",
"--dry-run",
"--workspace-id",
"ws-e2e-dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
usage?: { api?: string; data?: { reqDTO?: { filterWorkspaceId?: string } } };
}>(stdout);
expect(data.usage?.api).toContain("getModelUsageStatistic");
expect(data.usage?.data?.reqDTO?.filterWorkspaceId).toBe("ws-e2e-dry-run");
});
test("usage summary 文本输出正常返回", async () => {
const result = await runCli(["usage", "summary", "--output", "text"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("usage summary JSON 输出包含 freeTier 字段", async () => {
const result = await runCli(["usage", "summary", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
const data = parseStdoutJson<{ freeTier?: unknown; period?: unknown }>(result.stdout);
expect(data.period).toBeTypeOf("object");
expect(Array.isArray(data.freeTier)).toBe(true);
});
});
@@ -1,111 +0,0 @@
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import {
cliTimeoutPrefix,
e2eLabelFromMetaUrl,
isBailianE2EVideoEnabled,
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
} from "./helpers.ts";
/**
* Video generate (i2v)help / 分组不依赖密钥;长任务需视频 E2E + DashScope。
*/
describe("e2e: video generate (i2v)", () => {
test("video 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["video"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
});
test("video generate --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["video", "generate", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/generate|--prompt|--image|model/i);
});
});
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"e2e: video generate (i2v)DashScope 视频)",
() => {
test("video generate 缺少 --prompt 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCli([
"video",
"generate",
...cliTimeoutPrefix(),
"--model",
"happyhorse-1.1-i2v",
"--image",
"https://example.com/placeholder.png",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prompt|Usage:/i);
});
test("video generate --dry-run无 --image仅输出 requestt2v 路径不调上传)", async () => {
const { stdout, stderr, exitCode } = await runCli([
"video",
"generate",
...cliTimeoutPrefix(),
"--dry-run",
"--model",
"happyhorse-1.1-t2v",
"--prompt",
"干跑无图",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { input?: { prompt?: string; media?: unknown } } }>(
stdout,
);
expect(data.request?.input?.prompt).toBe("干跑无图");
expect(data.request?.input?.media).toBeUndefined();
});
test("【happyhorse-1.1-i2v】图片生成视频", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const png = join(outDir, "e2e-gen.png");
const gen = await runCli([
"image",
"generate",
"--model",
"qwen-image-2.0",
"--prompt",
"一只简笔画小猫,白底",
"--out-dir",
outDir,
"--out-prefix",
"e2e-gen",
"--output",
"json",
]);
expect(gen.exitCode, gen.stderr).toBe(0);
const genData = parseStdoutJson<{ saved?: string[] }>(gen.stdout);
const imagePath = genData.saved?.[0] ?? png;
const { stdout, stderr, exitCode } = await runCli([
"video",
"generate",
...cliTimeoutPrefix(),
"--model",
"happyhorse-1.1-i2v",
"--image",
imagePath,
"--prompt",
"镜头缓慢推进,小猫微微动一下",
"--download",
join(outDir, "e2e-video-i2v.mp4"),
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ status?: string; video_url?: string; saved?: string }>(stdout);
expect(data.status).toBe("SUCCEEDED");
expect(data.video_url?.startsWith("https://")).toBe(true);
}, 3_600_000);
},
);
+58
View File
@@ -0,0 +1,58 @@
const ping = {
description: "Ping the Command Pack fixture",
auth: "none",
flags: {
message: {
type: "string",
valueHint: "<text>",
required: true,
description: "Message returned by the fixture",
},
},
usageArgs: "--message <text>",
exampleArgs: ['--message "hello"'],
async run(ctx) {
process.stdout.write(`command-pack:${ctx.flags.message}\n`);
},
};
const credential = {
description: "Read an API key through the Command Pack host adapter",
auth: "apiKey",
async run(ctx) {
const apiKey = ctx.credentials.apiKey();
process.stdout.write(`credential-source:${apiKey.source}\n`);
},
};
const credentialDenied = {
description: "Verify credential access also requires command auth",
auth: "none",
async run(ctx) {
ctx.credentials.apiKey();
},
};
const output = {
description: "Exercise the Command Pack output helper",
auth: "none",
async run(ctx) {
ctx.output.result({ source: "command-pack", ok: true }, { text: "command-pack-output" });
},
};
const fail = {
description: "Exercise the Command Pack semantic error helper",
auth: "none",
async run(ctx) {
throw ctx.errors.usage("Command Pack fixture usage error.", "Use agent fail only in tests.");
},
};
export default {
"agent credential": credential,
"agent credential-denied": credentialDenied,
"agent fail": fail,
"agent output": output,
"agent ping": ping,
};
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@ali/bailian-plugin-agent",
"version": "0.0.0-test",
"private": true,
"type": "module",
"bailianCli": {
"type": "command-pack",
"apiVersion": 1,
"entry": "./commands.mjs",
"minCliVersion": "1.7.0"
}
}
+73
View File
@@ -0,0 +1,73 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { credentialFlagDefs, GLOBAL_FLAGS, type AnyCommand } from "bailian-cli-core";
import { monorepoRoot } from "e2e/monorepo-root";
import { describe, expect, test } from "vite-plus/test";
import { commands } from "../src/commands.ts";
const repositoryRoot = monorepoRoot();
const installGuide = readFileSync(join(repositoryRoot, "INSTALL.md"), "utf8");
const cliPackage = JSON.parse(
readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"),
) as {
engines?: { node?: string };
};
function toFlagName(key: string): string {
return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
}
function findDocumentedCommand(snippet: string): {
commandPath?: string;
command?: AnyCommand;
} {
const argumentText = snippet.slice("bl ".length).trim();
const commandPath = Object.keys(commands)
.sort((leftPath, rightPath) => rightPath.length - leftPath.length)
.find((candidatePath) => {
return argumentText === candidatePath || argumentText.startsWith(`${candidatePath} `);
});
return commandPath ? { commandPath, command: commands[commandPath] } : {};
}
function documentedCommandSnippets(): string[] {
const fencedCommands = installGuide
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("bl "));
const inlineCommands = Array.from(installGuide.matchAll(/`(bl [^`\n]+)`/g), (match) => match[1]);
return [...new Set([...fencedCommands, ...inlineCommands])];
}
describe("INSTALL.md", () => {
test("发布包 Node.js 要求与安装文档一致", () => {
const nodeEngine = cliPackage.engines?.node;
expect(nodeEngine).toMatch(/^>=\d+\.\d+\.\d+$/);
expect(installGuide).toContain(`要求 **≥ ${nodeEngine?.slice(2)}**`);
});
test("示例只使用当前命令支持的 flags", () => {
for (const snippet of documentedCommandSnippets()) {
const { commandPath, command } = findDocumentedCommand(snippet);
const argumentText = snippet.slice("bl ".length).trim();
if (!commandPath || !command) {
expect(argumentText, `INSTALL.md 中存在未知命令:${snippet}`).toMatch(/^--/);
}
const supportedFlags = {
...GLOBAL_FLAGS,
...(command ? credentialFlagDefs(command) : {}),
...command?.flags,
};
const supportedFlagNames = new Set(Object.keys(supportedFlags).map(toFlagName));
const usedFlagNames = Array.from(snippet.matchAll(/--[a-z0-9-]+/g), (match) => match[0]);
const unsupportedFlagNames = usedFlagNames.filter(
(flagName) => !supportedFlagNames.has(flagName),
);
expect(unsupportedFlagNames, `INSTALL.md 命令使用了未声明的 flag${snippet}`).toEqual([]);
}
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus";
export default defineConfig({
test: {
globalSetup: "./tests/e2e/global-setup.ts",
globalSetup: "../e2e/src/global-setup.ts",
testTimeout: 60_000,
hookTimeout: 60_000,
},
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.7.0",
"version": "1.10.1",
"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": {
@@ -44,15 +44,17 @@
"bailian-cli-runtime": "workspace:*",
"boxen": "catalog:",
"chalk": "catalog:",
"smol-toml": "catalog:",
"yaml": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20260328.1",
"e2e": "workspace:*",
"typescript": "^6.0.2",
"vite-plus": "0.1.22"
},
"engines": {
"node": ">=22.12.0"
"node": ">=18.17.0"
}
}
@@ -272,9 +272,7 @@ export default defineCommand({
return result;
});
const analyzeIntentPromise = analyzeIntent(ctx.client, userInput, {
intentDetectBaseUrl: settings.intentDetectBaseUrl,
}).then((result) => {
const analyzeIntentPromise = analyzeIntent(ctx.client, userInput).then((result) => {
intentReady = true;
if (!modelsReady) {
spinner.update("Agent: Intent analyzed, loading model data...");
@@ -0,0 +1,50 @@
import {
defineCommand,
detectOutputFormat,
generateCLIAccessToken,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const FLAGS = {
accessKeyId: {
type: "string",
valueHint: "<id>",
description: "Alibaba Cloud Access Key ID",
required: true,
},
accessKeySecret: {
type: "string",
valueHint: "<secret>",
description: "Alibaba Cloud Access Key Secret",
required: true,
},
securityToken: {
type: "string",
valueHint: "<token>",
description: "Alibaba Cloud STS Security Token to store (optional)",
},
} satisfies FlagsDef;
export default defineCommand({
description: "Generate a CLI access token using OpenAPI AK/SK",
auth: "none",
usageArgs: "--access-key-id <id> --access-key-secret <secret> --security-token <token>",
flags: FLAGS,
exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx --security-token <token>"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const resp = await generateCLIAccessToken({
identity,
settings,
baseUrl: ctx.client.baseUrl,
accessKeyId: flags.accessKeyId,
accessKeySecret: flags.accessKeySecret,
securityToken: flags.securityToken || undefined,
});
emitResult(resp, format);
},
});
@@ -0,0 +1,100 @@
import {
BailianError,
ExitCode,
chatPath,
requestJson,
normalizeModelBaseUrl,
type AuthPersistPatch,
type AuthStore,
type Identity,
type Settings,
} from "bailian-cli-core";
interface ApiKeyLoginDeps {
identity: Identity;
settings: Settings;
authStore: AuthStore;
}
interface ApiKeyLoginProfile {
baseUrl: string;
persistBaseUrl?: string;
defaultTextModel?: string;
defaultVideoModel?: string;
defaultImageToVideoModel?: string;
defaultReferenceToVideoModel?: string;
defaultImageModel?: string;
persistPatch?: AuthPersistPatch;
}
const RETRY_DELAY_BASE_MS = 500;
function canRetry(error: unknown): boolean {
if (error instanceof BailianError) {
if (error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT) return true;
const status = error.api?.httpStatus;
return status === 401 || (status !== undefined && status >= 500);
}
if (error instanceof Error) {
return (
error.name === "AbortError" ||
error.name === "TimeoutError" ||
error.message.includes("timed out") ||
error.message === "fetch failed"
);
}
return false;
}
export async function validateAndPersistApiKey(
deps: ApiKeyLoginDeps,
key: string,
profile: ApiKeyLoginProfile,
): Promise<void> {
process.stderr.write("Testing key... ");
const httpDeps = { identity: deps.identity, settings: deps.settings };
const baseUrl = normalizeModelBaseUrl(profile.baseUrl);
const persistBaseUrl = profile.persistBaseUrl
? normalizeModelBaseUrl(profile.persistBaseUrl)
: undefined;
const validationModel = profile.defaultTextModel || "qwen3.7-max";
const requestOpts = {
url: baseUrl + chatPath(),
method: "POST",
headers: { Authorization: `Bearer ${key}` },
timeout: Math.min(deps.settings.timeout, 30),
body: {
model: validationModel,
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
stream: false,
enable_thinking: validationModel === "qwen3.8-max-preview",
},
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(httpDeps, requestOpts);
break;
} catch (error) {
if (attempt >= 3 || !canRetry(error)) {
process.stderr.write("Failed\n");
throw error;
}
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
process.stderr.write("Valid\n");
await deps.authStore.login({
...profile.persistPatch,
api_key: key,
base_url: persistBaseUrl,
default_text_model: profile.defaultTextModel,
default_video_model: profile.defaultVideoModel,
default_image_to_video_model: profile.defaultImageToVideoModel,
default_reference_to_video_model: profile.defaultReferenceToVideoModel,
default_image_model: profile.defaultImageModel,
});
}
@@ -1,18 +1,17 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import http from "node:http";
import {
BailianError,
ExitCode,
chatPath,
getConfigPath,
requestJson,
type AuthPersistPatch,
type AuthStore,
type ConfigFile,
type Identity,
type Settings,
} from "bailian-cli-core";
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
import { validateAndPersistApiKey } from "./login-api-key.ts";
/** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */
export interface LoginDeps {
@@ -23,6 +22,9 @@ export interface LoginDeps {
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
const MAX_AUTH_CALLBACK_BODY = 65536;
// Regex for double newline (\r\n\r\n or \n\n); built via RegExp to avoid
// literal multi-line splitting in source.
const REGEX_DOUBLE_NEWLINE = new RegExp("\r\n\r\n|\n\n");
const CONSOLE_ORIGINS: Record<string, string> = {
domestic: "https://bailian.console.aliyun.com",
@@ -76,7 +78,7 @@ function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): stri
for (let i = 1; i < segments.length; i++) {
const part = segments[i]!;
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
const sep = part.match(/\r\n\r\n|\n\n/);
const sep = part.match(REGEX_DOUBLE_NEWLINE);
if (!sep || sep.index === undefined) continue;
let value = part.slice(sep.index + sep[0].length);
value = value
@@ -359,90 +361,7 @@ async function extractCredentialsFromRequest(
}
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
return new Promise((resolve, reject) => {
const onErr = (e: Error) => reject(e);
server.once("error", onErr);
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
server.off("error", onErr);
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("Expected TCP socket address"));
return;
}
resolve(addr.port);
});
});
}
function openInBrowser(url: string): Promise<void> {
const platform = process.platform;
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
return new Promise((resolve, reject) => {
execFile(cmd, args, { windowsHide: true }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
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(
deps: LoginDeps,
key: string,
baseUrl: string,
): Promise<void> {
process.stderr.write("Testing key... ");
const httpDeps = { identity: deps.identity, settings: deps.settings };
const requestOpts = {
url: baseUrl + chatPath(),
method: "POST",
headers: { Authorization: `Bearer ${key}` },
timeout: Math.min(deps.settings.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>(httpDeps, 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");
await deps.authStore.login({ api_key: key });
return listenLocalServer(server);
}
export async function runConsoleLogin(
@@ -486,20 +405,27 @@ export async function runConsoleLogin(
if (hasConfig || apiKey) {
try {
if (hasConfig) {
await deps.authStore.login({
access_token: accessToken || undefined,
base_url: baseUrl || undefined,
console_site: (consoleSite || undefined) as ConfigFile["console_site"],
console_region: consoleRegion || undefined,
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
workspace_id: workspaceId || undefined,
});
process.stderr.write(`Config saved to ${getConfigPath()}\n`);
}
const callbackPatch: AuthPersistPatch = {
access_token: accessToken || undefined,
console_site: (consoleSite || undefined) as ConfigFile["console_site"],
console_region: consoleRegion || undefined,
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
workspace_id: workspaceId || undefined,
};
if (apiKey) {
const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl();
await validateAndPersistApiKey(deps, apiKey, testBaseUrl);
await validateAndPersistApiKey(deps, apiKey, {
baseUrl: testBaseUrl,
persistBaseUrl: baseUrl || undefined,
persistPatch: callbackPatch,
});
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
} else if (hasConfig) {
await deps.authStore.login({
...callbackPatch,
base_url: baseUrl || undefined,
});
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
}
} catch (err: unknown) {
callbackError = err;
+39 -16
View File
@@ -1,10 +1,12 @@
import { defineCommand, getConfigPath } from "bailian-cli-core";
import { emitBare } from "bailian-cli-runtime";
import {
resolveConsoleOrigin,
runConsoleLogin,
validateAndPersistApiKey,
} from "./login-console.ts";
defineCommand,
generateCLIAccessToken,
getModelProfilePreset,
normalizeModelBaseUrl,
} from "bailian-cli-core";
import { emitBare } from "bailian-cli-runtime";
import { validateAndPersistApiKey } from "./login-api-key.ts";
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
const LOGIN_MODE_HINT = "Choose exactly one login mode: --api-key, --console, or --open-api";
@@ -19,11 +21,11 @@ export default defineCommand({
usageArgs:
"--api-key <key> | --console | --open-api --access-key-id <id> --access-key-secret <secret>",
flags: {
apiKey: { type: "string", valueHint: "<key>", description: "DashScope API key to store" },
apiKey: { type: "string", valueHint: "<key>", description: "Model API key to store" },
baseUrl: {
type: "string",
valueHint: "<url>",
description: "DashScope API base URL (used with --api-key for validation)",
description: "Model API base URL (used with --api-key for validation)",
},
console: {
type: "switch",
@@ -52,6 +54,7 @@ export default defineCommand({
},
exampleArgs: [
"--api-key sk-xxxxx",
"--config token-plan --api-key sk-sp-xxxxx",
"--console",
"--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx",
],
@@ -87,10 +90,10 @@ export default defineCommand({
},
async run(ctx) {
const { identity, settings, flags } = ctx;
const store = ctx.authStore();
const store = ctx.authStore;
const deps = { identity, settings, authStore: store };
const key = flags.apiKey;
const baseUrl = flags.baseUrl || undefined;
const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined;
if (flags.console) {
if (settings.dryRun) {
@@ -110,14 +113,25 @@ export default defineCommand({
if (flags.openApi) {
if (settings.dryRun) {
emitBare("Would save OpenAPI AK/SK credentials.");
emitBare("Would save OpenAPI AK/SK credentials and generate CLI access token.");
return;
}
const resolvedBaseUrl = store.resolveBaseUrl();
process.stderr.write("Generating CLI access token... ");
const resp = await generateCLIAccessToken({
identity,
settings,
baseUrl: resolvedBaseUrl,
accessKeyId: flags.accessKeyId!,
accessKeySecret: flags.accessKeySecret!,
});
const accessToken = resp.cliAccessToken;
await store.login({
access_key_id: flags.accessKeyId,
access_key_secret: flags.accessKeySecret,
access_token: accessToken,
});
process.stderr.write(`OpenAPI credentials saved to ${getConfigPath()}\n`);
process.stderr.write(`OpenAPI credentials saved to ${store.path}\n`);
return;
}
@@ -128,9 +142,18 @@ export default defineCommand({
emitBare("Would validate and save API key.");
return;
}
if (baseUrl) {
await store.login({ base_url: baseUrl });
}
await validateAndPersistApiKey(deps, key, baseUrl || store.resolveBaseUrl());
const profilePreset = getModelProfilePreset(settings.configName);
const storedBaseUrl = store.stored().baseUrl;
const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl);
const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined);
await validateAndPersistApiKey(deps, key, {
baseUrl: resolvedBaseUrl,
persistBaseUrl,
defaultTextModel: profilePreset?.defaultTextModel,
defaultVideoModel: profilePreset?.defaultVideoModel,
defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel,
defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel,
defaultImageModel: profilePreset?.defaultImageModel,
});
},
});
+18 -14
View File
@@ -1,8 +1,8 @@
import { defineCommand, getConfigPath } from "bailian-cli-core";
import { defineCommand } from "bailian-cli-core";
import { emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Clear stored credentials",
description: "Clear stored credentials; full logout also clears the model Base URL",
auth: "none",
usageArgs: "[--console | --open-api] [--dry-run]",
flags: {
@@ -12,7 +12,7 @@ export default defineCommand({
},
openApi: {
type: "switch",
description: "Only clear OpenAPI AK/SK credentials, keep other credentials intact",
description: "Only clear OpenAPI AK/SK/STS credentials, keep other credentials intact",
},
},
exampleArgs: ["", "--console", "--open-api", "--dry-run"],
@@ -20,18 +20,18 @@ export default defineCommand({
f.console && f.openApi ? "Use only one scope: --console or --open-api" : undefined,
async run(ctx) {
const { settings, flags } = ctx;
const store = ctx.authStore();
const store = ctx.authStore;
const stored = store.stored();
if (flags.console) {
if (settings.dryRun) {
if (stored.console) emitBare("Would clear access_token from ~/.bailian/config.json");
if (stored.console) emitBare(`Would clear access_token from ${store.path}`);
else emitBare("No console access_token to clear.");
emitBare("No changes made.");
return;
}
if (await store.logout("console")) {
process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`);
process.stderr.write(`Cleared access_token from ${store.path}\n`);
if (stored.apiKey) {
process.stderr.write(
"api_key is still configured and will be used for authentication.\n",
@@ -46,13 +46,17 @@ export default defineCommand({
if (flags.openApi) {
if (settings.dryRun) {
if (stored.openapi)
emitBare("Would clear access_key_id / access_key_secret from ~/.bailian/config.json");
emitBare(
`Would clear access_key_id / access_key_secret / security_token from ${store.path}`,
);
else emitBare("No OpenAPI AK/SK credentials to clear.");
emitBare("No changes made.");
return;
}
if (await store.logout("openapi")) {
process.stderr.write(`Cleared access_key_id / access_key_secret from ${getConfigPath()}\n`);
process.stderr.write(
`Cleared access_key_id / access_key_secret / security_token from ${store.path}\n`,
);
if (stored.apiKey || stored.console) {
process.stderr.write(
"Other credentials are still configured and will be used for authentication.\n",
@@ -64,24 +68,24 @@ export default defineCommand({
return;
}
const hasKey = stored.apiKey || stored.console || stored.openapi;
const hasStoredAuth = stored.apiKey || stored.console || stored.openapi || !!stored.baseUrl;
if (settings.dryRun) {
if (hasKey)
if (hasStoredAuth)
emitBare(
"Would clear api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json",
`Would clear api_key / base_url / access_token / access_key_id / access_key_secret / security_token from ${store.path}`,
);
else emitBare("No credentials to clear.");
else emitBare("No credentials or model Base URL to clear.");
emitBare("No changes made.");
return;
}
if (await store.logout("all")) {
process.stderr.write(
"Cleared api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json\n",
`Cleared api_key / base_url / access_token / access_key_id / access_key_secret / security_token from ${store.path}\n`,
);
} else {
process.stderr.write("No credentials to clear.\n");
process.stderr.write("No credentials or model Base URL to clear.\n");
}
},
});
+18 -2
View File
@@ -9,7 +9,7 @@ export default defineCommand({
async run(ctx) {
const { identity, settings } = ctx;
const format = detectOutputFormat(settings.output);
const auth = ctx.authStore().describe();
const auth = ctx.authStore.describe();
const apiKey = auth.apiKey
? {
@@ -35,11 +35,15 @@ export default defineCommand({
: undefined;
const authenticated = !!(apiKey || consoleCred || openapi);
const configName = settings.configName ?? "default";
const configFile = ctx.authStore.path;
if (!authenticated) {
emitResult(
{
authenticated: false,
config: configName,
config_file: configFile,
message: "Not authenticated.",
hint: [
`API key (model): ${identity.binName} auth login --api-key <key> or DASHSCOPE_API_KEY`,
@@ -54,10 +58,22 @@ export default defineCommand({
}
if (format !== "text") {
emitResult({ authenticated: true, api_key: apiKey, console: consoleCred, openapi }, format);
emitResult(
{
authenticated: true,
config: configName,
config_file: configFile,
api_key: apiKey,
console: consoleCred,
openapi,
},
format,
);
return;
}
emitBare(`Config: ${configName}`);
emitBare(`Config file: ${configFile}`);
emitBare("Authentication Status:");
if (apiKey) {
emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`);
@@ -0,0 +1,71 @@
import { platform } from "os";
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
const FLAGS = {
agent: {
type: "string",
valueHint: "<name>",
description: `Target agent: ${VALID_AGENT_NAMES.join(", ")}`,
required: true,
choices: VALID_AGENT_NAMES,
},
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
model: {
type: "string",
valueHint: "<model>",
description: "Default model name",
required: true,
},
} satisfies FlagsDef;
export default defineCommand({
description: "Configure a coding agent to use DashScope API",
auth: "none",
usageArgs: "--agent <name> --base-url <url> --api-key <key> --model <model>",
flags: FLAGS,
exampleArgs: [
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
],
async run(ctx) {
const { settings, flags } = ctx;
const agentName = flags.agent;
const { baseUrl, apiKey, model } = flags;
const agentDef = AGENTS[agentName];
const format = detectOutputFormat(settings.output);
// Hermes has no native Windows support.
if (agentName === "hermes" && platform() === "win32") {
process.stderr.write(
"Warning: Hermes Agent does not support native Windows. Please use WSL2.\n",
);
}
if (settings.dryRun) {
emitResult(
{
agent: agentName,
label: agentDef.label,
base_url: baseUrl,
api_key: maskToken(apiKey),
model,
},
format,
);
return;
}
const params: WriteParams = { baseUrl, apiKey, model };
const summary = agentDef.write(params);
if (!settings.quiet) {
emitBare(`${agentDef.label} configured successfully.`);
for (const path of summary.paths) emitBare(` Written: ${path}`);
emitBare(` ${summary.nextStep}`);
}
},
});
@@ -0,0 +1,20 @@
export type { WriteParams, WriteSummary, AgentDef } from "./writers/utils.ts";
import type { AgentDef } from "./writers/utils.ts";
import claudeCode from "./writers/claude-code.ts";
import qwenCode from "./writers/qwen-code.ts";
import opencode from "./writers/opencode.ts";
import openclaw from "./writers/openclaw.ts";
import hermes from "./writers/hermes.ts";
import codex from "./writers/codex.ts";
export const AGENTS: Record<string, AgentDef> = {
"claude-code": claudeCode,
"qwen-code": qwenCode,
opencode,
openclaw,
hermes,
codex,
};
export const VALID_AGENT_NAMES = Object.keys(AGENTS) as [string, ...string[]];
@@ -0,0 +1,37 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
export default {
label: "Claude Code",
write({ baseUrl, apiKey, model }) {
const settingsPath = join(homedir(), ".claude", "settings.json");
const onboardingPath = join(homedir(), ".claude.json");
// settings.json — merge env. Base URL + auth token connect Claude Code to
// the endpoint; the model tier vars force every tier onto the chosen model.
backup(settingsPath);
const settings = readJson(settingsPath);
const env = (settings.env ?? {}) as Record<string, string>;
env.ANTHROPIC_BASE_URL = baseUrl;
env.ANTHROPIC_AUTH_TOKEN = apiKey;
env.ANTHROPIC_MODEL = model;
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
settings.env = env;
writeJsonAtomic(settingsPath, settings);
// .claude.json — skip the onboarding prompt on first launch.
backup(onboardingPath);
const onboarding = readJson(onboardingPath);
onboarding.hasCompletedOnboarding = true;
writeJsonAtomic(onboardingPath, onboarding);
return {
paths: [settingsPath, onboardingPath],
nextStep: "Run `claude` to start using Claude Code with DashScope.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,56 @@
import { homedir } from "os";
import { join } from "path";
import { existsSync, readFileSync } from "fs";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts";
const PROVIDER_KEY = "bailian-cli";
export default {
label: "Codex",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".codex", "config.toml");
// config.toml — merge into existing config so unrelated settings
// (mcp_servers, approval_policy, other providers, ...) are preserved.
backup(configPath);
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
} catch {
config = {};
}
}
config.model_provider = PROVIDER_KEY;
config.model = model;
config.model_reasoning_effort = "high";
config.disable_response_storage = true;
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
providers[PROVIDER_KEY] = {
...existing,
name: PROVIDER_KEY,
base_url: baseUrl,
wire_api: "responses",
requires_openai_auth: true,
};
config.model_providers = providers;
writeTextAtomic(configPath, stringifyToml(config) + "\n");
// auth.json — Codex reads OPENAI_API_KEY from here.
const authPath = join(homedir(), ".codex", "auth.json");
backup(authPath);
const auth = readJson(authPath);
auth.OPENAI_API_KEY = apiKey;
writeJsonAtomic(authPath, auth);
return {
paths: [configPath, authPath],
nextStep: "Run `codex` to start using Codex with DashScope.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,53 @@
import { homedir } from "os";
import { join } from "path";
import { existsSync, readFileSync } from "fs";
import yaml from "yaml";
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
const PROVIDER_NAME = "bailian-cli";
export default {
label: "Hermes Agent",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".hermes", "config.yaml");
backup(configPath);
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
} catch {
config = {};
}
}
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
const providerEntry = {
name: PROVIDER_NAME,
base_url: baseUrl,
api_key: apiKey,
api_mode: apiMode,
models: [{ id: model, name: model }],
};
// custom_providers — upsert the bailian-cli entry by name.
const providers = Array.isArray(config.custom_providers)
? (config.custom_providers as Array<Record<string, unknown>>)
: [];
const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME);
if (index >= 0) providers[index] = providerEntry;
else providers.push(providerEntry);
config.custom_providers = providers;
// model — select the bailian-cli provider and default model.
config.model = { default: model, provider: PROVIDER_NAME };
writeTextAtomic(configPath, yaml.stringify(config));
return {
paths: [configPath],
nextStep: 'Run `hermes chat -q "hello"` to verify.',
};
},
} satisfies AgentDef;
@@ -0,0 +1,48 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
export default {
label: "OpenClaw",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".openclaw", "openclaw.json");
backup(configPath);
const config = readJson(configPath);
// models.providers["bailian-cli"]
const models = (config.models ?? {}) as Record<string, unknown>;
models.mode = "merge";
const providers = (models.providers ?? {}) as Record<string, unknown>;
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
providers["bailian-cli"] = {
baseUrl,
apiKey,
api,
models: [
{
id: model,
name: model,
contextWindow: 1000000,
cost: { input: 0, output: 0 },
},
],
};
models.providers = providers;
config.models = models;
// agents.defaults
const agents = (config.agents ?? {}) as Record<string, unknown>;
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
defaults.model = { primary: `bailian-cli/${model}` };
agents.defaults = defaults;
config.agents = agents;
writeJsonAtomic(configPath, config);
return {
paths: [configPath],
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,32 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
export default {
label: "OpenCode",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
backup(configPath);
const config = readJson(configPath);
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
const provider = (config.provider ?? {}) as Record<string, unknown>;
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
provider["bailian-cli"] = {
npm,
name: "Alibaba Cloud Model Studio",
options: { baseURL: baseUrl, apiKey, setCacheKey: true },
models: { [model]: { name: model } },
};
config.provider = provider;
writeJsonAtomic(configPath, config);
return {
paths: [configPath],
nextStep: "Run `opencode` then type `/models` to select your model.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,61 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
const ENV_KEY = "BAILIAN_CLI_API_KEY";
/**
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
* protocol (an AuthType string), not by a free-form provider id — the runtime
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
* therefore lives in the model entry `name` and the env var name.
*/
export default {
label: "Qwen Code",
write({ baseUrl, apiKey, model }) {
const settingsPath = join(homedir(), ".qwen", "settings.json");
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
backup(settingsPath);
const settings = readJson(settingsPath);
// env — API key read by the provider entry's envKey.
const env = (settings.env ?? {}) as Record<string, string>;
env[ENV_KEY] = apiKey;
settings.env = env;
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
const providers = (settings.modelProviders ?? {}) as Record<
string,
Array<Record<string, unknown>>
>;
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
const existing = entries.find(
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
);
if (existing) {
existing.name = "bailian-cli";
existing.baseUrl = baseUrl;
existing.envKey = ENV_KEY;
} else {
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
}
providers[protocol] = entries;
settings.modelProviders = providers;
// security.auth — select the protocol and carry the OpenAI-compatible creds.
const security = (settings.security ?? {}) as Record<string, unknown>;
security.auth = { selectedType: protocol, apiKey, baseUrl };
settings.security = security;
// model — active model, disambiguated by baseUrl.
settings.model = { name: model, baseUrl };
writeJsonAtomic(settingsPath, settings);
return {
paths: [settingsPath],
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,59 @@
import { dirname } from "path";
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
/** Parameters shared by every agent writer. */
export interface WriteParams {
baseUrl: string;
apiKey: string;
model: string;
}
/** What a writer reports back after configuring an agent. */
export interface WriteSummary {
paths: string[];
nextStep: string;
}
/** An agent configuration writer: a human label plus a `write` that applies it. */
export interface AgentDef {
label: string;
write(params: WriteParams): WriteSummary;
}
/** Read a JSON object file, returning `{}` when missing or unparseable. */
export function readJson(path: string): Record<string, unknown> {
if (!existsSync(path)) return {};
try {
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
} catch {
return {};
}
}
/** Atomically write `data` as pretty JSON with owner-only permissions. */
export function writeJsonAtomic(path: string, data: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = path + ".tmp";
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, path);
}
/** Atomically write raw text with owner-only permissions. */
export function writeTextAtomic(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = path + ".tmp";
writeFileSync(tmp, content, { mode: 0o600 });
renameSync(tmp, path);
}
/** Copy an existing file to a timestamped `.bak.<epoch>` sibling. No-op if absent. */
export function backup(path: string): void {
if (!existsSync(path)) return;
const timestamp = Math.floor(Date.now() / 1000);
copyFileSync(path, `${path}.bak.${timestamp}`);
}
/** Whether a base URL targets the Anthropic-messages compatible endpoint. */
export function isAnthropicEndpoint(baseUrl: string): boolean {
return baseUrl.includes("/apps/anthropic");
}
@@ -0,0 +1,31 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "List config profiles and show the active profile",
auth: "none",
exampleArgs: ["", "--output json"],
async run(ctx) {
const profiles = ctx.configStore.profiles();
const names = ["default", ...Object.keys(profiles.named).sort()];
const format = detectOutputFormat(ctx.settings.output);
if (format === "json") {
emitResult(
{
active_config: profiles.active,
profiles: names,
config_file: ctx.configStore.path,
},
format,
);
return;
}
const nameWidth = Math.max("NAME".length, ...names.map((name) => name.length));
emitBare(`${"NAME".padEnd(nameWidth)} ACTIVE`);
for (const name of names) {
emitBare(`${name.padEnd(nameWidth)} ${name === profiles.active ? "*" : ""}`);
}
},
});
+23 -78
View File
@@ -1,50 +1,6 @@
import {
defineCommand,
detectOutputFormat,
maskToken,
BailianError,
ExitCode,
type ConfigFile,
} from "bailian-cli-core";
import { defineCommand, detectOutputFormat, maskToken, type ConfigFile } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const VALID_KEYS = [
"base_url",
"output",
"output_dir",
"timeout",
"api_key",
"access_token",
"access_key_id",
"access_key_secret",
"default_text_model",
"default_video_model",
"default_image_model",
"default_speech_model",
"default_omni_model",
"workspace_id",
];
// Keys whose values are secrets. Their stored value must never be echoed back in
// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the
// same policy `config show` and `auth status` already follow.
const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]);
// Allow hyphen-style keys (e.g. default-text-model → default_text_model)
const KEY_ALIASES: Record<string, string> = {
"base-url": "base_url",
"output-dir": "output_dir",
"api-key": "api_key",
"access-token": "access_token",
"access-key-id": "access_key_id",
"access-key-secret": "access_key_secret",
"default-text-model": "default_text_model",
"default-video-model": "default_video_model",
"default-image-model": "default_image_model",
"default-speech-model": "default_speech_model",
"default-omni-model": "default_omni_model",
"workspace-id": "workspace_id",
};
import { SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
export default defineCommand({
description: "Set a config value",
@@ -55,7 +11,7 @@ export default defineCommand({
type: "string",
valueHint: "<key>",
description:
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, default_*_model, workspace_id)",
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
required: true,
},
value: { type: "string", valueHint: "<value>", description: "Value to set", required: true },
@@ -70,47 +26,36 @@ export default defineCommand({
const key = flags.key;
const value = flags.value;
// Resolve hyphen aliases to underscore keys
const resolvedKey: string = KEY_ALIASES[key] || key;
if (!VALID_KEYS.includes(resolvedKey)) {
throw new BailianError(
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
ExitCode.USAGE,
);
}
// Validate specific values
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
throw new BailianError(
`Invalid output format "${value}". Valid values: text, json`,
ExitCode.USAGE,
);
}
if (resolvedKey === "timeout") {
const num = Number(value);
if (isNaN(num) || num <= 0) {
throw new BailianError(
`Invalid timeout "${value}". Must be a positive number.`,
ExitCode.USAGE,
);
}
}
// Resolve hyphen aliases to underscore keys and validate/coerce the value.
const resolvedKey: string = resolveKey(key);
const coerced = validateAndCoerce(key, value);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ would_set: { [resolvedKey]: value } }, format);
emitResult(
{
would_set: { [resolvedKey]: coerced },
config: settings.configName ?? "default",
config_file: ctx.configStore.path,
},
format,
);
return;
}
const coerced = resolvedKey === "timeout" ? Number(value) : value;
await ctx.configStore().write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
await ctx.configStore.write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
if (!settings.quiet) {
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
emitResult({ [resolvedKey]: shown }, format);
emitResult(
{
[resolvedKey]: shown,
config: settings.configName ?? "default",
config_file: ctx.configStore.path,
},
format,
);
}
},
});
@@ -0,0 +1,94 @@
import { BailianError, ExitCode, normalizeModelBaseUrl } from "bailian-cli-core";
/** Config keys that `config set` / `config ui` accept for read/write. */
export const VALID_KEYS = [
"base_url",
"output",
"output_dir",
"timeout",
"api_key",
"access_token",
"access_key_id",
"access_key_secret",
"security_token",
"default_text_model",
"default_video_model",
"default_image_to_video_model",
"default_reference_to_video_model",
"default_image_model",
"default_speech_model",
"default_omni_model",
"workspace_id",
] as const;
// Keys whose values are secrets. `config set` / `config show` mask these; the
// web UI renders them as password fields (values are still sent in cleartext
// over the token-gated localhost socket).
export const SECRET_KEYS = new Set<string>([
"api_key",
"access_token",
"access_key_id",
"access_key_secret",
"security_token",
]);
// Allow hyphen-style keys (e.g. default-text-model → default_text_model).
export const KEY_ALIASES: Record<string, string> = {
"base-url": "base_url",
"output-dir": "output_dir",
"api-key": "api_key",
"access-token": "access_token",
"access-key-id": "access_key_id",
"access-key-secret": "access_key_secret",
"security-token": "security_token",
"default-text-model": "default_text_model",
"default-video-model": "default_video_model",
"default-image-to-video-model": "default_image_to_video_model",
"default-reference-to-video-model": "default_reference_to_video_model",
"default-image-model": "default_image_model",
"default-speech-model": "default_speech_model",
"default-omni-model": "default_omni_model",
"workspace-id": "workspace_id",
};
/** Resolve a hyphen alias to its underscore config key. */
export function resolveKey(key: string): string {
return KEY_ALIASES[key] || key;
}
/**
* Validate a single config entry and coerce its value to the stored type.
* Throws BailianError(USAGE) for unknown keys or invalid values.
*/
export function validateAndCoerce(key: string, value: string): string | number {
const resolvedKey = resolveKey(key);
if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) {
throw new BailianError(
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
ExitCode.USAGE,
);
}
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
throw new BailianError(
`Invalid output format "${value}". Valid values: text, json`,
ExitCode.USAGE,
);
}
if (resolvedKey === "timeout") {
const num = Number(value);
if (isNaN(num) || num <= 0) {
throw new BailianError(
`Invalid timeout "${value}". Must be a positive number.`,
ExitCode.USAGE,
);
}
return num;
}
if (resolvedKey === "base_url") return normalizeModelBaseUrl(value);
return value;
}
@@ -1,5 +1,6 @@
import { defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { SECRET_KEYS } from "./shared.ts";
export default defineCommand({
description: "Display current configuration",
@@ -7,7 +8,7 @@ export default defineCommand({
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings, client } = ctx;
const store = ctx.configStore();
const store = ctx.configStore;
const file = store.read();
const format = detectOutputFormat(settings.output);
@@ -16,16 +17,13 @@ export default defineCommand({
base_url: client.baseUrl,
output: settings.output,
timeout: settings.timeout,
config: settings.configName ?? "default",
config_file: store.path,
};
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);
for (const key of SECRET_KEYS) {
if (typeof result[key] === "string") result[key] = maskToken(result[key]);
}
emitResult(result, format);
},
@@ -0,0 +1,266 @@
// Self-contained single-page web UI for managing config profiles. Served as a
// string by `config ui`; no build step, no client dependencies. All fetches
// carry the session token from the page URL.
export const PAGE_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>bailian-cli config</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1f2328; background: #f6f8fa; }
#app { display: flex; min-height: 100vh; }
#sidebar { width: 240px; background: #fff; border-right: 1px solid #d0d7de; padding: 16px; }
#sidebar h1 { font-size: 15px; margin: 0 0 12px; }
#profileList { list-style: none; margin: 0 0 12px; padding: 0; }
#profileList li { padding: 8px 10px; border-radius: 6px; cursor: pointer; word-break: break-all; }
#profileList li:hover { background: #f0f3f6; }
#profileList li.selected { background: #0969da; color: #fff; }
main { flex: 1; padding: 24px 32px; max-width: 720px; }
#editorHead { display: flex; align-items: center; justify-content: space-between; }
h2 { font-size: 18px; margin: 0 0 4px; }
.row { display: flex; flex-direction: column; margin: 12px 0; }
.row label { font-weight: 600; margin-bottom: 4px; }
.inputwrap { display: flex; gap: 6px; }
input { flex: 1; padding: 7px 9px; border: 1px solid #d0d7de; border-radius: 6px; font: inherit; width: 100%; }
button { padding: 7px 12px; border: 1px solid #d0d7de; border-radius: 6px; background: #f6f8fa; cursor: pointer; font: inherit; }
button:hover { background: #eef1f4; }
#saveBtn { background: #1f883d; color: #fff; border-color: #1f883d; }
#saveBtn:hover { background: #1a7f37; }
.danger { color: #cf222e; }
.toggle { flex: none; }
.actions { margin-top: 20px; display: flex; align-items: center; gap: 12px; }
.muted { color: #656d76; font-size: 12px; word-break: break-all; }
.err { color: #cf222e; font-size: 12px; }
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<h1>Config Profiles</h1>
<ul id="profileList"></ul>
<button id="newBtn">+ New profile</button>
<p id="cfgFile" class="muted"></p>
</aside>
<main id="editor">
<div id="editorHead">
<h2 id="currentName"></h2>
<button id="deleteBtn" class="danger">Delete</button>
</div>
<form id="form" onsubmit="return false"></form>
<div class="actions">
<button id="saveBtn">Save</button>
<button id="useBtn">Save &amp; Activate</button>
<span id="status" class="muted"></span>
</div>
</main>
</div>
<script>
var token = new URLSearchParams(location.search).get('token') || '';
var KEYS = [], SECRETS = [], DATA = { default: {}, named: {} }, CURRENT = '', ACTIVE = 'default';
function api(path, opts) {
var sep = path.indexOf('?') >= 0 ? '&' : '?';
return fetch(path + sep + 'token=' + encodeURIComponent(token), opts || {});
}
function setStatus(msg, isErr) {
var el = document.getElementById('status');
el.textContent = msg || '';
el.className = isErr ? 'err' : 'muted';
}
function profileData(name) {
return name === '' ? DATA.default : (DATA.named[name] || {});
}
function load() {
api('/api/config').then(function (r) { return r.json(); }).then(function (j) {
KEYS = j.keys || [];
SECRETS = j.secretKeys || [];
DATA = { default: j.default || {}, named: j.named || {} };
ACTIVE = j.activeProfile || 'default';
document.getElementById('cfgFile').textContent = j.configFile || '';
CURRENT = ACTIVE === 'default' ? '' : ACTIVE;
renderProfiles();
renderForm();
}).catch(function (e) { setStatus('Load failed: ' + e, true); });
}
function renderProfiles() {
var ul = document.getElementById('profileList');
ul.innerHTML = '';
var names = [''].concat(Object.keys(DATA.named));
names.forEach(function (name) {
var li = document.createElement('li');
var displayName = name === '' ? 'default' : name;
li.textContent = displayName + (displayName === ACTIVE ? ' *' : '');
if (name === CURRENT) li.className = 'selected';
li.onclick = function () { CURRENT = name; renderProfiles(); renderForm(); setStatus(''); };
ul.appendChild(li);
});
}
function renderForm() {
var form = document.getElementById('form');
form.innerHTML = '';
document.getElementById('currentName').textContent = CURRENT === '' ? 'default (top-level)' : CURRENT;
document.getElementById('deleteBtn').style.display = CURRENT === '' ? 'none' : '';
var selectedName = CURRENT === '' ? 'default' : CURRENT;
var useBtn = document.getElementById('useBtn');
useBtn.disabled = selectedName === ACTIVE;
useBtn.textContent = selectedName === ACTIVE ? 'Active' : 'Save & Activate';
var data = profileData(CURRENT);
KEYS.forEach(function (key) {
var row = document.createElement('div');
row.className = 'row';
var label = document.createElement('label');
label.textContent = key;
label.htmlFor = 'f_' + key;
var input = document.createElement('input');
input.id = 'f_' + key;
input.name = key;
var val = data[key];
input.value = (val === undefined || val === null) ? '' : String(val);
if (SECRETS.indexOf(key) >= 0) {
input.type = 'password';
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'toggle';
toggle.textContent = 'show';
toggle.onclick = function () {
if (input.type === 'password') { input.type = 'text'; toggle.textContent = 'hide'; }
else { input.type = 'password'; toggle.textContent = 'show'; }
};
var wrap = document.createElement('div');
wrap.className = 'inputwrap';
wrap.appendChild(input);
wrap.appendChild(toggle);
row.appendChild(label);
row.appendChild(wrap);
} else {
input.type = 'text';
row.appendChild(label);
row.appendChild(input);
}
form.appendChild(row);
});
}
function collect() {
var data = {};
KEYS.forEach(function (key) {
var el = document.getElementById('f_' + key);
data[key] = el ? el.value : '';
});
return data;
}
function saveProfile(name, data) {
var profileData = data === undefined ? collect() : data;
var body = JSON.stringify({ name: name, data: profileData });
return api('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
})
.then(function (response) {
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
})
.then(function (result) {
if (!result.ok) {
throw new Error((result.json && result.json.error) || 'error');
}
var saved = result.json.saved || {};
if (name === '') DATA.default = saved; else DATA.named[name] = saved;
return saved;
});
}
function save() {
var name = CURRENT;
saveProfile(name)
.then(function () {
renderForm();
setStatus('Saved.');
})
.catch(function (error) { setStatus('Save failed: ' + error.message, true); });
}
function newProfile() {
var name = prompt('New profile name (letters, numbers, - or _):');
if (!name) return;
if (DATA.named[name] !== undefined) {
CURRENT = name;
renderProfiles();
renderForm();
setStatus('Profile already exists.');
return;
}
setStatus('Creating profile...');
saveProfile(name, {})
.then(function () {
CURRENT = name;
renderProfiles();
renderForm();
setStatus('Profile created and saved.');
})
.catch(function (error) { setStatus('Create failed: ' + error.message, true); });
}
function saveAndActivateProfile() {
var name = CURRENT === '' ? 'default' : CURRENT;
var profileName = CURRENT;
setStatus('Saving...');
saveProfile(profileName)
.then(function () {
return api('/api/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
})
.then(function (response) {
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
})
.then(function (result) {
if (!result.ok) {
throw new Error('Saved, but activation failed: ' + ((result.json && result.json.error) || 'error'));
}
ACTIVE = result.json.activeProfile || 'default';
renderProfiles();
renderForm();
setStatus('Saved and activated.');
})
.catch(function (error) { setStatus(error.message || String(error), true); });
}
function deleteProfile() {
if (CURRENT === '') return;
if (!confirm('Delete profile "' + CURRENT + '"?')) return;
api('/api/profile?name=' + encodeURIComponent(CURRENT), { method: 'DELETE' })
.then(function (r) {
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'error'); });
return r.json();
})
.then(function (result) {
delete DATA.named[CURRENT];
ACTIVE = result.activeProfile || 'default';
CURRENT = '';
renderProfiles();
renderForm();
setStatus('Deleted.');
})
.catch(function (e) { setStatus('Delete failed: ' + e, true); });
}
document.getElementById('saveBtn').onclick = save;
document.getElementById('newBtn').onclick = newProfile;
document.getElementById('useBtn').onclick = saveAndActivateProfile;
document.getElementById('deleteBtn').onclick = deleteProfile;
load();
</script>
</body>
</html>
`;
+264
View File
@@ -0,0 +1,264 @@
import http from "node:http";
import { randomBytes } from "node:crypto";
import {
defineCommand,
detectOutputFormat,
BailianError,
ExitCode,
normalizeConfigName,
readConfigFile,
writeConfigFile,
deleteConfigProfile,
type ConfigStore,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
import { PAGE_HTML } from "./ui-html.ts";
import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
const FLAGS = {
port: {
type: "number",
valueHint: "<port>",
description: "Port to listen on (default: random free port)",
},
noOpen: { type: "switch", description: "Do not open the browser automatically" },
} satisfies FlagsDef;
const MAX_BODY = 1 << 20; // 1 MiB
function errMessage(err: unknown): string {
return err instanceof BailianError
? err.message
: err instanceof Error
? err.message
: String(err);
}
function sendJson(res: http.ServerResponse, status: number, obj: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(obj));
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY) {
reject(new Error("payload too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
/** Build the request cleaned/validated config block from a posted `data` map. */
function buildProfilePatch(data: Record<string, unknown>): Record<string, string | number> {
const cleaned: Record<string, string | number> = {};
for (const [k, v] of Object.entries(data)) {
let value = "";
if (typeof v === "string") value = v;
else if (typeof v === "number" || typeof v === "boolean") value = String(v);
// null/undefined/objects fall through as "" and clear the key
if (value === "") continue;
cleaned[resolveKey(k)] = validateAndCoerce(k, value);
}
return cleaned;
}
/** Preserve valid Config fields that the UI does not expose or manage. */
function mergeUnmanagedProfileFields(
existing: Record<string, unknown>,
managedPatch: Record<string, string | number>,
): Record<string, unknown> {
const managedKeys = new Set<string>(VALID_KEYS);
const merged: Record<string, unknown> = {};
for (const [key, value] of Object.entries(existing)) {
if (!managedKeys.has(key)) merged[key] = value;
}
return { ...merged, ...managedPatch };
}
/**
* Build the config-UI http server. Exported for tests. The handler enforces:
* - Host header must be a loopback name (anti DNS-rebinding).
* - every request must carry `?token=` matching the session token.
*/
export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server {
return http.createServer(async (req, res) => {
try {
const host = (req.headers.host || "").split(":")[0];
if (host !== "127.0.0.1" && host !== "localhost") {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end("forbidden host\n");
return;
}
const u = new URL(req.url ?? "/", "http://127.0.0.1");
if (u.searchParams.get("token") !== token) {
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
res.end("unauthorized\n");
return;
}
const method = req.method ?? "GET";
const path = u.pathname;
if (path === "/" && method === "GET") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE_HTML);
return;
}
if (path === "/api/config" && method === "GET") {
const profiles = configStore.profiles();
sendJson(res, 200, {
configFile: configStore.path,
keys: VALID_KEYS,
secretKeys: [...SECRET_KEYS],
activeProfile: profiles.active,
default: profiles.default,
named: profiles.named,
});
return;
}
if (path === "/api/active" && method === "POST") {
const raw = await readBody(req);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
sendJson(res, 400, { error: "invalid JSON body" });
return;
}
const body = parsed as { name?: unknown };
try {
const activeProfile = await configStore.activate(body.name);
sendJson(res, 200, { activeProfile });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/profile" && method === "POST") {
const raw = await readBody(req);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
sendJson(res, 400, { error: "invalid JSON body" });
return;
}
const body = parsed as { name?: unknown; data?: unknown };
if (!body.data || typeof body.data !== "object" || Array.isArray(body.data)) {
sendJson(res, 400, { error: "missing or invalid 'data'" });
return;
}
let normalized: string | undefined;
let cleaned: Record<string, string | number>;
try {
normalized = normalizeConfigName(body.name);
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
return;
}
const existing = readConfigFile(normalized) as Record<string, unknown>;
const saved = mergeUnmanagedProfileFields(existing, cleaned);
await writeConfigFile(saved, normalized);
sendJson(res, 200, { saved });
return;
}
if (path === "/api/profile" && method === "DELETE") {
try {
const deleted = await deleteConfigProfile(u.searchParams.get("name") ?? undefined);
sendJson(res, 200, { deleted, activeProfile: configStore.profiles().active });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found\n");
} catch {
if (!res.headersSent) res.writeHead(500);
res.end();
}
});
}
export default defineCommand({
description: "Open a local web UI to manage config profiles",
auth: "none",
usageArgs: "[--port <port>] [--no-open]",
flags: FLAGS,
exampleArgs: ["", "--port 8787", "--no-open"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
host: "127.0.0.1",
port: flags.port ?? "random free port",
config_file: ctx.configStore.path,
routes: [
"GET / -> web UI",
"GET /api/config -> read all profiles",
"POST /api/profile -> save a profile",
"POST /api/active -> activate a profile",
"DELETE /api/profile -> delete a named profile",
],
},
format,
);
return;
}
const token = randomBytes(16).toString("hex");
const server = createConfigUiServer(token, ctx.configStore);
let port: number;
try {
port = await listenLocalServer(server, flags.port ?? 0);
} catch (err) {
throw new BailianError(
`Could not bind to 127.0.0.1 (no free port or permission denied): ${errMessage(err)}`,
ExitCode.USAGE,
);
}
const url = `http://127.0.0.1:${port}/?token=${token}`;
if (!flags.noOpen) {
try {
await openInBrowser(url);
emitBare("Opened the config UI in your default browser.");
} catch {
emitBare("Could not open the browser automatically. Open the URL below manually.");
}
}
emitBare(`Config UI running at ${url}`);
emitBare("Note: credentials are shown in cleartext in the browser (localhost only).");
emitBare("Press Ctrl+C to stop.");
await new Promise<void>((resolve) => {
const shutdown = () => server.close(() => resolve());
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
server.once("close", () => resolve());
});
},
});
@@ -0,0 +1,42 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Set the active config profile",
auth: "none",
usageArgs: "--name <name>",
flags: {
name: {
type: "string",
valueHint: "<name>",
description: "Existing profile name, or default",
required: true,
},
},
exampleArgs: ["--name token-plan", "--name default"],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
if (ctx.settings.dryRun) {
const activeConfig = ctx.configStore.validateActivation(ctx.flags.name);
emitResult(
{
would_activate: activeConfig,
config_file: ctx.configStore.path,
},
format,
);
return;
}
const activeConfig = await ctx.configStore.activate(ctx.flags.name);
if (!ctx.settings.quiet) {
emitResult(
{
active_config: activeConfig,
config_file: ctx.configStore.path,
},
format,
);
}
},
});
@@ -6,6 +6,7 @@ import {
parseDatasetSchemaFlag,
formatIssue,
MAX_DATASET_BYTES,
MAX_MEDIA_ZIP_BYTES,
BailianError,
ExitCode,
type DatasetFile,
@@ -17,7 +18,7 @@ const UPLOAD_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: "Local .jsonl dataset file (≤300MB)",
description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image)",
required: true,
},
purpose: {
@@ -29,7 +30,7 @@ const UPLOAD_FLAGS = {
type: "string",
valueHint: "<s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
},
noValidate: {
type: "switch",
@@ -42,42 +43,55 @@ const UPLOAD_FLAGS = {
} satisfies FlagsDef;
export default defineCommand({
description: "Upload a dataset file (.jsonl) to Bailian",
description: "Upload a dataset file (.jsonl or .zip) to Bailian",
auth: "apiKey",
usageArgs:
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt>] [--no-validate] [--full-validate]",
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image>] [--no-validate] [--full-validate]",
flags: UPLOAD_FLAGS,
exampleArgs: [
"--file train.jsonl",
"--file dpo.jsonl --schema dpo",
"--file cpt.jsonl --schema cpt",
"--file audio.zip --schema tts",
"--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).",
"Supports .jsonl (text) and .zip (audio/image archives with a data.jsonl",
"manifest). Five record schemas are recognized: chatml = {messages:[...]}",
'(SFT); dpo = {messages:[...], chosen, rejected}; cpt = {text:"..."}',
'(continual pre-training, raw text); tts = {wav_fn:"train/xxx.wav",',
'text:"..."} (audio fine-tuning); image = {img_path:"..."} (image',
"generation). With no --schema, a record carrying wav_fn is validated as",
"TTS, img_path as image, chosen/rejected as DPO, text (no messages) as CPT,",
"otherwise ChatML. Upload cap: 300MB text, 1GB image. Upload uses the",
"OpenAI-compatible /compatible-mode/v1/files endpoint so the purpose tag is",
"persisted (the DashScope-native /api/v1/files drops it).",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
const filePath = flags.file;
const purpose = flags.purpose || "fine-tune";
const schema = parseDatasetSchemaFlag(flags.schema);
if (schema === "video") {
throw new BailianError(
`--schema video is not supported.`,
ExitCode.USAGE,
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
// Image schema allows larger ZIPs (1 GB vs 300 MB for text).
const isMediaSchema = schema === "image";
if (!flags.noValidate) {
const result = await validateDataset(filePath, { fullValidate: flags.fullValidate, schema });
const maxBytes = isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES;
const result = await validateDataset(filePath, {
fullValidate: flags.fullValidate,
schema,
maxBytes,
});
if (!result.valid) {
const lines = [
`Dataset validation failed for ${filePath}`,
@@ -112,7 +126,7 @@ export default defineCommand({
action: "dataset.upload",
file: filePath,
purpose,
max_bytes: MAX_DATASET_BYTES,
max_bytes: isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES,
validate: !flags.noValidate,
schema: schema ?? "auto",
},
@@ -25,7 +25,7 @@ const VALIDATE_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: "Local .jsonl dataset file",
description: "Local dataset file (.jsonl or .zip)",
required: true,
},
fullValidate: {
@@ -36,20 +36,21 @@ const VALIDATE_FLAGS = {
type: "string",
valueHint: "<s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), or "cpt" (raw text). Default auto-detects per record.',
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
},
} satisfies FlagsDef;
export default defineCommand({
description: "Locally validate a dataset file (.jsonl) without uploading",
description: "Locally validate a dataset file (.jsonl or .zip) without uploading",
// 纯本地校验,不触网、不需 API key与 `pipeline validate` 一致)。
auth: "none",
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt>]",
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image>]",
flags: VALIDATE_FLAGS,
exampleArgs: [
"--file train.jsonl",
"--file dpo.jsonl --schema dpo",
"--file cpt.jsonl --schema cpt",
"--file audio.zip --schema tts",
"--file eval.jsonl --full-validate",
"--file train.jsonl --output json",
],
@@ -57,17 +58,27 @@ export default defineCommand({
"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.",
'rejected}; cpt = {text:"..."} (continual pre-training, raw text);',
'tts = {wav_fn:"train/xxx.wav", text:"..."} (audio fine-tuning);',
'image = {img_path:"..."} (image generation). With no --schema, a record',
"carrying wav_fn is validated as TTS, img_path as image, chosen/rejected",
"as DPO, text (no messages) as CPT, otherwise ChatML. Pass --schema to",
"require a specific shape on every record. ZIP archives (.zip) are",
"validated structurally (data.jsonl present, media references resolve) in",
"addition to per-record content checks. Use --full-validate to JSON.parse",
"every line.",
],
async run(ctx) {
const { settings, flags } = ctx;
const filePath = flags.file;
const schema = parseDatasetSchemaFlag(flags.schema);
if (schema === "video") {
throw new BailianError(
`--schema video is not supported.`,
ExitCode.USAGE,
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
+151 -94
View File
@@ -2,11 +2,16 @@ import {
defineCommand,
detectOutputFormat,
createDeployment,
pickPlanStrategy,
STRATEGIES,
defaultDeployPlan,
type DeployModality,
type CreateDeploymentRequest,
type CreatePlanFlags,
type CommandContext,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { pickPlanStrategy, STRATEGIES } from "./plans.ts";
const CREATE_FLAGS = {
model: {
@@ -26,10 +31,10 @@ const CREATE_FLAGS = {
valueHint: "<plan>",
description: "Billing plan: lora (default, Token-billed) | ptu (Token-billed) | mu",
},
templateId: {
deploySpec: {
type: "string",
valueHint: "<id>",
description: "Template id (only used by plan=mu; auto-picked if omitted)",
description: "Deploy spec (only used by plan=mu; auto-picked if omitted)",
},
capacity: {
type: "number",
@@ -58,104 +63,156 @@ const CREATE_FLAGS = {
},
} satisfies FlagsDef;
const CREATE_USAGE =
"--model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
const CREATE_NOTES = [
"Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-",
"billed) for audio (CosyVoice TTS). 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 `deploy_spec` are required.",
"billing_method defaults to POST_PAY (only supported value); deploy_spec",
"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 <modality> create --model` takes the exported model_name",
"(e.g. `qwen3-8b-ft-...`), but the create response also returns a",
"`deployed_model` field (the deployment instance id, e.g.",
"`qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use",
"the `deployed_model` from the create response — NOT the `model_name` you",
"passed to `deploy <modality> create`. Do not reuse the value across the two",
"commands.",
];
/**
* `bl deploy create` — create a model deployment.
*
* Plan-specific behaviour (required flags / body assembly / auto-pick) lives
* in `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the
* shared envelope: flag validation, dispatch, dry-run, 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.
* Shared `deploy <modality> create` flag validation. Plan support is
* server-catalog-driven, so validation is identical for every modality: resolve
* the effective plan (modality-specific default when --plan is omitted), reject
* an unknown --plan, then defer to the plan strategy's required-flag check.
*/
export default defineCommand({
description: "Create a model deployment",
function validateCreate(modality: DeployModality, flags: CreatePlanFlags): string | undefined {
const plan = flags.plan || defaultDeployPlan(modality);
const strategy = STRATEGIES[plan];
if (!strategy) {
return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`;
}
return strategy.validateFlags(flags);
}
/**
* Shared `deploy <modality> create` implementation. deploy create takes a model
* by name and a billing plan — it does NOT inspect data modality for the request
* body, so the run logic is identical across text / audio / image. The modality
* only fixes the default plan (audio → mu, text/image → lora) and the command
* path / description / examples.
*
* Plan-specific behaviour (required flags / body assembly / auto-pick) lives in
* core `plans.ts` (`PlanStrategy` + `STRATEGIES`). This file only handles the
* shared envelope: dispatch, dry-run, and result formatting.
*/
async function runCreate(
modality: DeployModality,
ctx: CommandContext<typeof CREATE_FLAGS>,
): Promise<void> {
const { identity, settings, flags } = ctx;
const model = flags.model as string;
const name = flags.name as string;
const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality);
const format = detectOutputFormat(settings.output);
// Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves
// the plan-specific body fragment (mu may auto-pick a template from the
// deployable-models catalog). Anything outside the strategy table was
// already rejected by `validate` above.
const strategy = pickPlanStrategy(plan);
const resolved = await strategy.resolve({
client: ctx.client,
dryRun: settings.dryRun,
binName: identity.binName,
flags: flags as CreatePlanFlags,
model,
name,
});
const body: Record<string, unknown> = {
model_name: model,
name,
plan,
...resolved.body,
};
if (settings.dryRun) {
emitResult({ action: "deploy.create", body }, format);
return;
}
const response = await createDeployment(ctx.client, body as CreateDeploymentRequest);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployment?.deployed_model ?? "");
} else if (format === "text") {
emitBare(`Created deployment.`);
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
if (deployment?.status) emitBare(` status: ${deployment.status}`);
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
emitBare(
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
} else {
emitResult(response, format);
}
}
/** `bl deploy text create` — deploy a text model. */
export const deployTextCreate = defineCommand({
description: "Create a text model deployment",
auth: "apiKey",
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>]",
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-qwen-sft --name my-sft-test",
"--model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
"--model qwen3-8b --name my-qwen3-mu --plan mu",
"--model qwen3-8b --name my-qwen3 --plan mu --template-id MU1 --capacity 2",
"--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
],
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.",
],
validate: (flags) => {
const plan = flags.plan || "lora";
const strategy = STRATEGIES[plan];
if (!strategy) {
return `Unsupported plan "${plan}". Supported plans: ${Object.keys(STRATEGIES).join(", ")}.`;
}
return strategy.validateFlags(flags);
},
async run(ctx) {
const { identity, settings, flags } = ctx;
const model = flags.model;
const name = flags.name;
const plan = flags.plan || "lora";
const format = detectOutputFormat(settings.output);
// Plan-specific behaviour is owned by `plans.ts`. The strategy resolves
// the plan-specific body fragment (mu may auto-pick a template from the
// deployable-models catalog). Anything outside the strategy table was
// already rejected by `validate` above.
const strategy = pickPlanStrategy(plan);
const resolved = await strategy.resolve({
client: ctx.client,
dryRun: settings.dryRun,
binName: identity.binName,
flags,
model,
name,
});
const body: Record<string, unknown> = {
model_name: model,
name,
plan,
...resolved.body,
};
if (settings.dryRun) {
emitResult({ action: "deploy.create", body }, format);
return;
}
const response = await createDeployment(ctx.client, body as CreateDeploymentRequest);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployment?.deployed_model ?? "");
} else if (format === "text") {
emitBare(`Created deployment.`);
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
if (deployment?.status) emitBare(` status: ${deployment.status}`);
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
emitBare(
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
} else {
emitResult(response, format);
}
},
notes: CREATE_NOTES,
validate: (flags) => validateCreate("text", flags),
run: (ctx) => runCreate("text", ctx),
});
/** `bl deploy audio create` — deploy an audio (TTS) model. Defaults to plan=mu. */
export const deployAudioCreate = defineCommand({
description: "Create an audio (TTS) model deployment",
auth: "apiKey",
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-cosyvoice-ft --name my-tts",
"--model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1",
"--model my-cosyvoice-ft --name my-tts --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("audio", flags),
run: (ctx) => runCreate("audio", ctx),
});
/** `bl deploy image create` — deploy an image generation model. */
export const deployImageCreate = defineCommand({
description: "Create an image generation model deployment",
auth: "apiKey",
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-wan-ft --name my-wan",
"--model my-wan-ft --name my-wan-mu --plan mu",
"--model my-wan-ft --name my-wan --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("image", flags),
run: (ctx) => runCreate("image", ctx),
});
@@ -59,8 +59,8 @@ export default defineCommand({
ExitCode.USAGE,
);
}
} catch (e) {
if (e instanceof BailianError) throw e;
} catch (error) {
if (error instanceof BailianError) throw error;
// If the get itself failed (e.g. not found), let the DELETE call surface the real error.
}
}
@@ -68,13 +68,13 @@ export default defineCommand({
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,
const rows = items.map((item) => [
item.deployed_model,
item.model_name,
item.status,
item.plan,
item.capacity,
item.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
+44 -39
View File
@@ -73,47 +73,47 @@ export default defineCommand({
// - 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.
// downstream tooling can drive `bl deploy <modality> create --deploy-spec <…>`
// without a second round-trip. For text: keep the compact one-line summary.
if (format === "json") {
const items = models.map((m) => {
const items = models.map((model) => {
const out: Record<string, unknown> = {
model_name: m.model_name ?? "",
model_name: model.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 (model.base_model) out.base_model = model.base_model;
if (model.model_source) out.model_source = model.model_source;
if (model.supported_plans && model.supported_plans.length > 0) {
out.supported_plans = model.supported_plans;
}
if (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 (model.plans && model.plans.length > 0) {
out.plans = model.plans.map((plan) => {
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
if (plan.cu_specs && plan.cu_specs.length > 0) {
planEntry.cu_specs = plan.cu_specs;
}
if (p.templates && p.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy create`.
if (plan.templates && plan.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = p.templates.map((t) => {
planEntry.templates = plan.templates.map((template) => {
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;
if (template.template_id) tpl.template_id = template.template_id;
if (template.template_name) tpl.template_name = template.template_name;
if (template.charge_type) tpl.charge_type = template.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = t.roles?.unified;
const unified = template.roles?.unified;
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
if (unified?.capacity_unit_per_instance !== undefined)
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
// Preserve split-role configs (SEPERATED) as-is so callers
// can still drive prefill/decode sizing.
if (t.roles?.prefill || t.roles?.decode) {
if (template.roles?.prefill || template.roles?.decode) {
tpl.roles = {
prefill: t.roles?.prefill,
decode: t.roles?.decode,
prefill: template.roles?.prefill,
decode: template.roles?.decode,
};
}
if (t.template_desc) tpl.template_desc = t.template_desc;
if (template.template_desc) tpl.template_desc = template.template_desc;
return tpl;
});
}
@@ -127,19 +127,19 @@ export default defineCommand({
}
// text / quiet — keep the compact single-line summary table.
const textItems = models.map((m) => {
const textItems = models.map((model) => {
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 (model.supported_plans && model.supported_plans.length > 0) {
plansSummary = model.supported_plans.join(",");
} else if (model.plans && model.plans.length > 0) {
plansSummary = model.plans
.map((plan) => {
const planName = plan.plan ?? "?";
if (plan.templates && plan.templates.length > 0) {
return `${planName}(${plan.templates.length}t)`;
}
if (p.cu_specs && p.cu_specs.length > 0) {
return `${planName}(${p.cu_specs.join("/")})`;
if (plan.cu_specs && plan.cu_specs.length > 0) {
return `${planName}(${plan.cu_specs.join("/")})`;
}
return planName;
})
@@ -148,9 +148,9 @@ export default defineCommand({
plansSummary = "-";
}
return {
model_name: m.model_name ?? "",
base_model: m.base_model ?? "",
source: m.model_source ?? "",
model_name: model.model_name ?? "",
base_model: model.base_model ?? "",
source: model.model_source ?? "",
plans: plansSummary,
};
});
@@ -160,7 +160,12 @@ export default defineCommand({
return;
}
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
const rows = textItems.map((i) => [i.model_name, i.base_model, i.source, i.plans]);
const rows = textItems.map((item) => [
item.model_name,
item.base_model,
item.source,
item.plans,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
},
+442 -245
View File
@@ -4,12 +4,12 @@ import {
createFineTune,
getDataset,
uploadDataset,
validateDataset,
detectModality,
getProfile,
fetchModelCapability,
listSupportedTrainingTypes,
preflightBatchSizeGate,
isTrainingTypeCli,
toServerTrainingType,
TRAINING_TYPES_CLI,
DEFAULT_TRAINING_TYPE,
formatIssue,
@@ -17,10 +17,12 @@ import {
ExitCode,
type Client,
type Settings,
type CommandContext,
type CreateFineTuneRequest,
type FineTuneHyperParameters,
type DatasetFile,
type DatasetSchema,
type TrainingProfile,
type DataModality,
type FlagsDef,
} from "bailian-cli-core";
import { existsSync, statSync } from "fs";
@@ -77,7 +79,11 @@ async function analyzeDatasetTokens(
binName: string,
raw: string,
label: string,
schema?: DatasetSchema,
profile: TrainingProfile,
modality: DataModality,
model: string,
/** Pre-detected modality for a known path (avoids re-opening the file). */
knownModality?: { path: string; modality: DataModality },
): Promise<ResolvedDataset> {
const tokens = raw
.split(",")
@@ -109,11 +115,18 @@ async function analyzeDatasetTokens(
if (settings.dryRun) continue;
// Local path → validate (same checks as `dataset upload`). Upload is
// deferred to `uploadResolvedLocal` so the gate can run first. The schema
// (SFT vs DPO) is derived from --training-type so a DPO job validates the
// chosen/rejected preference pairs here, not on the platform.
const result = await validateDataset(token, { schema });
// The command's modality is authoritative; each local file is validated
// under that modality's schema. Reuse the caller's pre-detected modality
// when available to avoid opening the same file twice (matters for large
// ZIPs).
const tokenModality =
knownModality && knownModality.path === token ? knownModality.modality : modality;
// Local path → validate through the profile. The profile internally routes
// to the correct validator based on modality. Upload is deferred to
// `uploadResolvedLocal` so the gate can run first. `model` is forwarded for
// schema-agnostic cross-checks.
const result = await profile.validate(token, tokenModality, { model });
if (!result.valid) {
const lines = [
`Dataset validation failed for ${token}`,
@@ -194,25 +207,33 @@ async function uploadResolvedLocal(
return uploaded;
}
const CREATE_FLAGS = {
/** The modality a `finetune <modality> create` subcommand is bound to. */
type CommandModality = "text" | "audio" | "image";
/**
* Flags shared by every `finetune <modality> create` subcommand: what to train
* (model), what data to train on (datasets / validations), and how to name the
* output. Every modality's model consumes these.
*/
const COMMON_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: "Base model to fine-tune (e.g. qwen3-8b, qwen3-14b)",
description: "Base model to fine-tune",
required: true,
},
datasets: {
type: "string",
valueHint: "<ids|paths>",
description:
"Comma-separated dataset file IDs or local .jsonl paths. Local paths are uploaded (validated) first, then their file-ids are used.",
"Comma-separated dataset file IDs or local paths (.jsonl for text, .zip for audio/image). Local paths are uploaded (validated) first, then their file-ids are used.",
required: true,
},
validations: {
type: "string",
valueHint: "<ids|paths>",
description:
"Comma-separated validation dataset file IDs or local .jsonl paths (auto-uploaded like --datasets).",
"Comma-separated validation dataset file IDs or local paths (auto-uploaded like --datasets).",
},
modelName: {
type: "string",
@@ -224,6 +245,16 @@ const CREATE_FLAGS = {
valueHint: "<text>",
description: "Output suffix appended by the platform (finetuned_output_suffix)",
},
} satisfies FlagsDef;
/**
* Text flags: text models consume the full hyper-parameter surface — training
* type selection plus n_epochs / batch_size / learning_rate / max_length (see
* resolveTextHyperParameters). Only text exposes --training-type because only
* text models support types other than the sft-lora default.
*/
const TEXT_FLAGS = {
...COMMON_FLAGS,
trainingType: {
type: "string",
valueHint: "<t>",
@@ -252,12 +283,368 @@ const CREATE_FLAGS = {
},
} satisfies FlagsDef;
export default defineCommand({
description: "Create a fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
/**
* Audio (CosyVoice TTS) flags: the audio model runs sft-lora with a fully fixed
* hyper-parameter set (AUDIO_HYPER_PARAMS). No --training-type or hyper-parameter
* flag is honored by resolveHyperParameters, so none are exposed.
*/
const AUDIO_FLAGS = {
...COMMON_FLAGS,
} satisfies FlagsDef;
/**
* Image (Wan generation) flags: the image model runs sft-lora with fixed
* defaults; resolveHyperParameters only honors learning_rate, so --learning-rate
* is the sole extra numeric knob. --generation-type declares T2I vs I2I
* explicitly (the platform expects generation_type as a request field); it is
* required to reach I2I from a bare file-id or in --dry-run, where the data
* cannot be inspected.
*/
const IMAGE_FLAGS = {
...COMMON_FLAGS,
generationType: {
type: "string",
choices: ["t2i", "i2i"] as const,
valueHint: "<t2i|i2i>",
description:
"Generation type: t2i (default) | i2i. Sets generation_type/max_pixels. Required to train I2I from a file-id or with --dry-run (local data auto-detects input_img).",
},
learningRate: {
type: "string",
valueHint: "<str>",
description: 'Learning rate as a string to preserve precision (e.g. "3e-5")',
},
} satisfies FlagsDef;
const TEXT_USAGE =
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
const AUDIO_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
const IMAGE_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
const COMMON_NOTES = [
"Creating a job uploads any local datasets and consumes training quota.",
"Use --dry-run to preview the request body without submitting.",
"--datasets / --validations accept either file-ids (from `dataset upload`)",
"or local paths. Local paths are validated and uploaded first, then their",
"file-ids are submitted — a one-step upload-and-train.",
];
const TEXT_NOTES = [
...COMMON_NOTES,
"Training-type values use the `<method>` / `<method>-lora` convention:",
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
"to the server's training_type at the interface boundary, so the rest of the",
"CLI never sees the raw server strings.",
"Before submitting (non dry-run) the job, the model's training capability is",
"checked via listFoundationModels (no console login required); an unsupported",
"training type fails fast with the list the model actually supports.",
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
"Pre-submit gate: if the training dataset's sample count is not greater",
"than batch_size, the job is rejected before upload or quota consumption",
"(the platform would otherwise fail ~10 min in, after data processing).",
];
const AUDIO_NOTES = [
...COMMON_NOTES,
"Audio TTS training runs sft-lora (efficient_sft) with fixed CosyVoice",
"hyper-parameter defaults; there are no training-type or hyper-parameter",
"knobs to set.",
];
const IMAGE_NOTES = [
...COMMON_NOTES,
"Image generation training runs sft-lora (efficient_sft) with fixed defaults;",
"only --learning-rate is overridable. T2I vs I2I is declared with",
"--generation-type (default t2i), which sets generation_type/max_pixels. For",
"local data the type is auto-detected (records with input_img train I2I);",
"pass --generation-type explicitly to train I2I from a file-id or in --dry-run.",
];
/**
* Shared `finetune <modality> create` implementation. The parameter surface and
* run logic are identical to the previous single `finetune create`; the ONLY
* change is that the data modality is fixed by the subcommand instead of being
* detected from data content. This is what lets file-id datasets (which have no
* local file to inspect) train the correct model — the old command silently
* defaulted a file-id to "text".
*
* Image is the only modality with a sub-variant (T2I vs I2I). It is upgraded to
* `image-i2i` only when a local file's first record carries `input_img`; a bare
* file-id defaults to plain "image" (T2I), matching the old detection fallback.
*/
async function runCreate<F extends FlagsDef>(
commandModality: CommandModality,
ctx: CommandContext<F>,
): Promise<void> {
const { identity, settings } = ctx;
const flags = ctx.flags as Record<string, unknown>;
const model = flags.model as string;
const datasetsRaw = flags.datasets as string;
// CosyVoice audio fine-tuning accepts exactly one training file
// (`training_file_ids` supports a single ID per the speech-synthesis
// contract). Reject a multi-token --datasets up-front so the job isn't
// rejected server-side after an upload.
if (commandModality === "audio") {
const audioTokens = datasetsRaw
.split(",")
.map((token) => token.trim())
.filter(Boolean);
if (audioTokens.length > 1) {
throw new BailianError(
`Audio (TTS) fine-tuning accepts exactly one training file, got ${audioTokens.length}.`,
ExitCode.USAGE,
"Merge your recordings into a single .zip (or pass one file-id).",
);
}
}
// Resolve the training type before analyzing datasets so the validator can
// enforce the right record schema (DPO jobs require chosen/rejected on
// every record). Whitelist is the single source of truth in core
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
const trainingType = (flags.trainingType as string | undefined) || DEFAULT_TRAINING_TYPE;
if (!isTrainingTypeCli(trainingType)) {
throw new BailianError(
`--training-type "${trainingType}" is not supported.`,
ExitCode.USAGE,
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
);
}
// Profile: single source of truth for how this training type behaves
// (validation rules, hyper-parameters, gates, capability check).
const profile = getProfile(trainingType);
// Modality is fixed by the subcommand (no content-based detection) — this is
// the sole behavioural change of the modality split. Image alone has a T2I/I2I
// sub-variant: an explicit --generation-type is authoritative (the only way to
// reach I2I from a bare file-id or in --dry-run, where data can't be
// inspected); otherwise a local file is probed to upgrade T2I → I2I, and a
// bare file-id stays "image" (T2I).
const firstLocalPath = datasetsRaw
.split(",")
.map((token) => token.trim())
.find((token) => isLocalPath(token));
let modality: DataModality = commandModality;
if (commandModality === "image") {
const generationType = flags.generationType as "t2i" | "i2i" | undefined;
if (generationType === "i2i") {
modality = "image-i2i";
} else if (!generationType && firstLocalPath && !settings.dryRun) {
const detected = await detectModality(firstLocalPath);
if (detected === "image-i2i") modality = "image-i2i";
}
}
const training = await analyzeDatasetTokens(
settings,
identity.binName,
datasetsRaw,
"datasets",
profile,
modality,
model,
firstLocalPath ? { path: firstLocalPath, modality } : undefined,
);
const trainingFileIds = training.fileIds;
const validation = flags.validations
? await analyzeDatasetTokens(
settings,
identity.binName,
flags.validations as string,
"validations",
profile,
modality,
model,
)
: undefined;
const validationFileIds = validation?.fileIds;
const modelName = flags.modelName as string | undefined;
const suffix = flags.suffix as string | undefined;
// Hyper-parameters: the profile resolves modality-specific defaults
// (text: n_epochs/batch_size/learning_rate; audio: lm_max_epoch/fm_max_epoch/...).
const hp = profile.resolveHyperParameters(
modality,
flags as Record<string, unknown>,
) as FineTuneHyperParameters;
// Restore the batch-size clamping warning that was lost when the logic moved
// into profiles. The profile silently clamps to [8, 1024]; surface it here
// so the user has an audit trail. Skip modalities that bypass the batch_size
// gate (image): their batch_size is a fixed model-family default, not a
// clamp of the user's value, so the [8, 1024] "clamped" message would be
// self-contradictory and misleading.
if (
flags.batchSize !== undefined &&
hp.batch_size !== undefined &&
!settings.quiet &&
!profile.shouldSkipGate("batch_size", modality)
) {
const requested = flags.batchSize as number;
if (hp.batch_size !== requested) {
process.stderr.write(
`warning: --batch-size ${requested} clamped to ${hp.batch_size} ` +
`(server range [8, 1024] for the common training types).\n`,
);
}
}
// For modalities that skip the batch_size gate, warn the user that their
// explicit --batch-size was discarded (model uses a fixed batch_size).
if (
flags.batchSize !== undefined &&
!settings.quiet &&
profile.shouldSkipGate("batch_size", modality)
) {
const requested = flags.batchSize as number;
if (hp.batch_size !== undefined && hp.batch_size !== requested) {
process.stderr.write(
`warning: --batch-size ${requested} ignored for ${modality} training ` +
`(model uses a fixed batch_size of ${hp.batch_size}).\n`,
);
}
}
// Auto batch_size for small datasets — only for text data. Audio/image
// profiles already set their own batch parameters.
if (modality === "text" && hp.batch_size === undefined && !settings.dryRun) {
let sizeBytes = training.firstSize ?? 0;
if (sizeBytes === 0) {
try {
const fileInfo = await getDataset(ctx.client, trainingFileIds[0]);
sizeBytes = fileInfo.data?.size ?? 0;
} catch {
// If we can't fetch file info, skip auto-adjustment; platform will use default.
}
}
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
hp.batch_size = 8;
}
}
// Pre-submit batch-size gate: the platform rejects a job whose number of
// training samples is not greater than batch_size, but only surfaces that
// ~10 minutes into the run (after data processing). Fail fast here, before
// burning quota. `recordCount` is only known when every --datasets token
// was a local file we validated; file-id tokens fall through to the
// platform rather than risk a false positive from an undercount.
if (
!settings.dryRun &&
training.recordCount !== undefined &&
!profile.shouldSkipGate("batch_size", modality)
) {
// 16 is the platform default when neither the user nor the small-file
// auto-adjust set a batch_size (see the auto-adjust comment above).
const effectiveBatchSize = hp.batch_size ?? 16;
const gate = preflightBatchSizeGate({
recordCount: training.recordCount,
batchSize: effectiveBatchSize,
});
if (!gate.ok && gate.issue) {
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
}
}
// Pre-flight capability check: confirm the model actually supports the
// requested training type BEFORE any upload, so a wrong --model /
// --training-type combo doesn't burn storage on datasets that will never
// be trained against. listFoundationModels is a public API (no console
// login required); on lookup failure (network / 401 / etc.) we fall back
// to letting the server decide rather than blocking the submit.
if (!settings.dryRun && !profile.shouldSkipCapabilityCheck(modality)) {
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
try {
capability = await fetchModelCapability(settings, model);
} catch (error) {
if (!settings.quiet) {
process.stderr.write(
`warning: model capability lookup failed (${(error as Error).message}); ` +
"proceeding without local pre-flight.\n",
);
}
}
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
const supported = listSupportedTrainingTypes(capability);
throw new BailianError(
`Model "${model}" does not support training type "${trainingType}".`,
ExitCode.USAGE,
supported.length
? `This model supports: ${supported.join(", ")}.`
: "This model reports no supported training types.",
);
}
}
// Upload local paths now that pre-flight (validation, batch-size gate,
// capability check) has cleared them. This swaps the placeholder path
// entries in `training.fileIds` / `validation?.fileIds` for real file-ids.
if (!settings.dryRun) {
await uploadResolvedLocal(ctx.client, settings, training, "fine-tune", "datasets");
if (validation) {
await uploadResolvedLocal(ctx.client, settings, validation, "fine-tune", "validations");
}
}
const body: CreateFineTuneRequest = {
model,
training_file_ids: trainingFileIds,
// Profile maps the CLI training type to the server value at the boundary.
training_type: profile.serverTrainingType,
hyper_parameters: hp,
};
if (validationFileIds && validationFileIds.length > 0) {
body.validation_file_ids = validationFileIds;
}
if (modelName) body.model_name = modelName;
if (suffix) body.finetuned_output_suffix = suffix;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const pending = [
...training.localPaths.map((path) => ({ field: "datasets", path })),
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
];
emitResult(
pending.length > 0
? { action: "finetune.create", body, pending_uploads: pending }
: { action: "finetune.create", body },
format,
);
return;
}
const response = await createFineTune(ctx.client, body);
const job = response.output ?? response.data;
if (settings.quiet) {
if (job?.job_id) emitBare(job.job_id);
} else if (format === "text") {
if (job?.job_id) {
emitBare(`Created fine-tune job: ${job.job_id}`);
if (job.status) emitBare(`Status: ${job.status}`);
} else {
emitResult(response, format);
}
} else {
emitResult(response, format);
}
}
/** `bl finetune text create` — fine-tune a text model. Datasets are `.jsonl`. */
export const finetuneTextCreate = defineCommand({
description: "Create a text model fine-tune job (sft | sft-lora | dpo | dpo-lora | cpt)",
auth: "apiKey",
usageArgs:
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]",
flags: CREATE_FLAGS,
usageArgs: TEXT_USAGE,
flags: TEXT_FLAGS,
exampleArgs: [
"--model qwen3-8b --datasets file-xxx",
"--model qwen3-8b --datasets ./train.jsonl",
@@ -268,231 +655,41 @@ export default defineCommand({
"--model qwen3-8b --datasets file-xxx --output json",
"--model qwen3-8b --datasets file-xxx --dry-run",
],
notes: [
"Creating a job uploads any local datasets and consumes training quota.",
"Use --dry-run to preview the request body without submitting.",
"Training-type values use the `<method>` / `<method>-lora` convention:",
"sft (full) | sft-lora (LoRA) | dpo (full) | dpo-lora (LoRA) | cpt. These map",
"to the server's training_type at the interface boundary, so the rest of the",
"CLI never sees the raw server strings.",
"Before submitting (non dry-run) the job, the model's training capability is",
"checked via listFoundationModels (no console login required); an unsupported",
"training type fails fast with the list the model actually supports.",
"n_epochs defaults to 3. Other hyper-parameters are platform defaults unless set.",
"Learning rate is forwarded as a string to avoid JSON-number precision loss.",
"--datasets / --validations accept either file-ids (from `dataset upload`)",
"or local .jsonl paths. Local paths are validated and uploaded first, then",
"their file-ids are submitted — a one-step upload-and-train.",
"Dataset record schema is chosen from --training-type: dpo* → {messages,",
"chosen, rejected}; cpt → {text} (raw pre-training text); else {messages}.",
"Pre-submit gate: if the training dataset's sample count is not greater",
"than batch_size, the job is rejected before upload or quota consumption",
"(the platform would otherwise fail ~10 min in, after data processing).",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
const model = flags.model;
const datasetsRaw = flags.datasets;
// Resolve the training type before analyzing datasets so the validator can
// enforce the right record schema (DPO jobs require chosen/rejected on
// every record). Whitelist is the single source of truth in core
// (TRAINING_TYPES_CLI); any other value is rejected up-front.
const trainingType = flags.trainingType || DEFAULT_TRAINING_TYPE;
if (!isTrainingTypeCli(trainingType)) {
throw new BailianError(
`--training-type "${trainingType}" is not supported.`,
ExitCode.USAGE,
`Supported values: ${TRAINING_TYPES_CLI.join(", ")} (default: ${DEFAULT_TRAINING_TYPE}).`,
);
}
// dpo / dpo-lora → "dpo" schema (strict chosen/rejected); cpt → "cpt"
// (raw {text} records); else ChatML ({messages}).
const datasetSchema: DatasetSchema = trainingType.startsWith("dpo")
? "dpo"
: trainingType === "cpt"
? "cpt"
: "chatml";
const training = await analyzeDatasetTokens(
settings,
identity.binName,
datasetsRaw,
"datasets",
datasetSchema,
);
const trainingFileIds = training.fileIds;
const validation = flags.validations
? await analyzeDatasetTokens(
settings,
identity.binName,
flags.validations,
"validations",
datasetSchema,
)
: undefined;
const validationFileIds = validation?.fileIds;
const modelName = flags.modelName;
const suffix = flags.suffix;
// Hyper-parameters: inject n_epochs=3 default unless overridden.
const hp: FineTuneHyperParameters = {};
hp.n_epochs = flags.nEpochs ?? 3;
if (flags.learningRate !== undefined) hp.learning_rate = flags.learningRate;
if (flags.maxLength !== undefined) hp.max_length = flags.maxLength;
// batch_size: clamp to [8, 1024] (server hard constraint, undocumented).
// Surface the clamp on stderr instead of silently rewriting the user's
// value — otherwise the submitted body would carry a number the user never
// typed, with no audit trail. (Range observed on common SFT / SFT-LoRA
// training types; some bases like qwen3.6-flash report a wider range, so
// the warning explicitly mentions "server range".)
if (flags.batchSize !== undefined) {
const requested = flags.batchSize;
let batchSize = requested;
if (batchSize < 8) batchSize = 8;
if (batchSize > 1024) batchSize = 1024;
if (batchSize !== requested && !settings.quiet) {
process.stderr.write(
`warning: --batch-size ${requested} clamped to ${batchSize} ` +
`(server range [8, 1024] for the common training types).\n`,
);
}
hp.batch_size = batchSize;
}
// Auto batch_size for small datasets: fetch first training file size.
// With default split=0.9, validation_set = 0.1 * rows.
// Platform default batch_size=16 needs rows > 160; batch_size=8 needs rows > 80.
// Files < 100KB are conservatively estimated to have < 200 rows.
// If the first file was just uploaded we already hold its size; otherwise
// fall back to getDataset.
if (hp.batch_size === undefined && !settings.dryRun) {
let sizeBytes = training.firstSize ?? 0;
if (sizeBytes === 0) {
try {
const fileInfo = await getDataset(ctx.client, trainingFileIds[0]);
sizeBytes = fileInfo.data?.size ?? 0;
} catch {
// If we can't fetch file info, skip auto-adjustment; platform will use default.
}
}
if (sizeBytes > 0 && sizeBytes < 100 * 1024) {
hp.batch_size = 8;
}
}
// Pre-submit batch-size gate: the platform rejects a job whose number of
// training samples is not greater than batch_size, but only surfaces that
// ~10 minutes into the run (after data processing). Fail fast here, before
// burning quota. `recordCount` is only known when every --datasets token
// was a local file we validated; file-id tokens fall through to the
// platform rather than risk a false positive from an undercount.
//
// The decision lives in core (`preflightBatchSizeGate`) — a structured,
// job-level pre-flight that returns a `ValidationIssue` (same shape / stable
// code as `validateDataset`) so the failure surfaces through the same
// `BailianError` + issue convention used by `dataset upload`/`validate`.
// ExitCode.GENERAL matches the existing validation-failed exit code.
if (!settings.dryRun && training.recordCount !== undefined) {
// 16 is the platform default when neither the user nor the small-file
// auto-adjust set a batch_size (see the auto-adjust comment above).
const effectiveBatchSize = hp.batch_size ?? 16;
const gate = preflightBatchSizeGate({
recordCount: training.recordCount,
batchSize: effectiveBatchSize,
});
if (!gate.ok && gate.issue) {
throw new BailianError(gate.issue.message, ExitCode.GENERAL, gate.hint);
}
}
// Pre-flight capability check: confirm the model actually supports the
// requested training type BEFORE any upload, so a wrong --model /
// --training-type combo doesn't burn storage on datasets that will never
// be trained against. listFoundationModels is a public API (no console
// login required); on lookup failure (network / 401 / etc.) we fall back
// to letting the server decide rather than blocking the submit.
if (!settings.dryRun) {
let capability: Awaited<ReturnType<typeof fetchModelCapability>> | undefined;
try {
capability = await fetchModelCapability(settings, model);
} catch (error) {
if (!settings.quiet) {
process.stderr.write(
`warning: model capability lookup failed (${(error as Error).message}); ` +
"proceeding without local pre-flight.\n",
);
}
}
if (capability && !listSupportedTrainingTypes(capability).includes(trainingType)) {
const supported = listSupportedTrainingTypes(capability);
throw new BailianError(
`Model "${model}" does not support training type "${trainingType}".`,
ExitCode.USAGE,
supported.length
? `This model supports: ${supported.join(", ")}.`
: "This model reports no supported training types.",
);
}
}
// Upload local paths now that pre-flight (validation, batch-size gate,
// capability check) has cleared them. This swaps the
// placeholder path entries in `training.fileIds` / `validation?.fileIds`
// for real file-ids, so the body below sees ids.
if (!settings.dryRun) {
await uploadResolvedLocal(ctx.client, settings, training, "fine-tune", "datasets");
if (validation) {
await uploadResolvedLocal(ctx.client, settings, validation, "fine-tune", "validations");
}
}
const body: CreateFineTuneRequest = {
model,
training_file_ids: trainingFileIds,
// Map the CLI training type to the server value at the interface boundary.
training_type: toServerTrainingType(trainingType),
hyper_parameters: hp,
};
if (validationFileIds && validationFileIds.length > 0) {
body.validation_file_ids = validationFileIds;
}
if (modelName) body.model_name = modelName;
if (suffix) body.finetuned_output_suffix = suffix;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const pending = [
...training.localPaths.map((path) => ({ field: "datasets", path })),
...(validation?.localPaths ?? []).map((path) => ({ field: "validations", path })),
];
emitResult(
pending.length > 0
? { action: "finetune.create", body, pending_uploads: pending }
: { action: "finetune.create", body },
format,
);
return;
}
const response = await createFineTune(ctx.client, body);
const job = response.output ?? response.data;
if (settings.quiet) {
if (job?.job_id) emitBare(job.job_id);
} else if (format === "text") {
if (job?.job_id) {
emitBare(`Created fine-tune job: ${job.job_id}`);
if (job.status) emitBare(`Status: ${job.status}`);
} else {
emitResult(response, format);
}
} else {
emitResult(response, format);
}
},
notes: TEXT_NOTES,
run: (ctx) => runCreate("text", ctx),
});
/** `bl finetune audio create` — fine-tune an audio TTS model. Datasets are `.zip`. */
export const finetuneAudioCreate = defineCommand({
description: "Create an audio TTS model fine-tune job (sft-lora)",
auth: "apiKey",
usageArgs: AUDIO_USAGE,
flags: AUDIO_FLAGS,
exampleArgs: [
"--model cosyvoice-v3-flash --datasets ./audio.zip",
"--model cosyvoice-v3-flash --datasets file-xxx",
"--model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
"--model cosyvoice-v3-flash --datasets file-xxx --output json",
"--model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
],
notes: AUDIO_NOTES,
run: (ctx) => runCreate("audio", ctx),
});
/** `bl finetune image create` — fine-tune an image generation model. Datasets are `.zip`. */
export const finetuneImageCreate = defineCommand({
description: "Create an image generation model fine-tune job (sft-lora)",
auth: "apiKey",
usageArgs: IMAGE_USAGE,
flags: IMAGE_FLAGS,
exampleArgs: [
"--model wan2.7-image-pro --datasets ./images.zip",
"--model wan2.7-image-pro --datasets file-xxx",
"--model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
"--model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
"--model wan2.7-image-pro --datasets file-xxx --output json",
"--model wan2.7-image-pro --datasets ./images.zip --dry-run",
],
notes: IMAGE_NOTES,
run: (ctx) => runCreate("image", ctx),
});
@@ -34,9 +34,9 @@ export default defineCommand({
flags: EXPORT_FLAGS,
exampleArgs: ["--job-id ft-xxx --checkpoint ckpt-3 --model-name my-qwen-sft"],
notes: [
"Required before `deploy create` can target a checkpoint. The platform",
"may auto-export the best checkpoint when a job reaches SUCCEEDED — explicit",
"export is the canonical path for non-best checkpoints.",
"Required before `deploy <modality> create` can target a checkpoint. The",
"platform may auto-export the best checkpoint when a job reaches SUCCEEDED —",
"explicit export is the canonical path for non-best checkpoints.",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
@@ -66,7 +66,9 @@ export default defineCommand({
emitBare(exported);
} else if (format === "text") {
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
emitBare(`Next: ${identity.binName} deploy create --model ${exported} --name <display-name>`);
emitBare(
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
);
} else {
emitResult(response, format);
}
@@ -71,7 +71,7 @@ export default defineCommand({
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
if (item.output_model)
emitBare(
`output_model: ${item.output_model} (→ ${identity.binName} deploy create --model)`,
`output_model: ${item.output_model} (→ ${identity.binName} deploy text create --model)`,
);
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
@@ -75,6 +75,8 @@ export default defineCommand({
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitBare(`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy create --model\``);
emitBare(
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
);
},
});
@@ -1,15 +1,16 @@
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
import {
defineCommand,
detectOutputFormat,
getFineTune,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DEFAULT_INTERVAL_SEC = 10;
const MIN_INTERVAL_SEC = 1;
const TERMINAL_STATUSES = new Set(["SUCCEEDED", "FAILED", "CANCELED"]);
/** SIGINT exit code (128 + signal 2). */
const EXIT_INTERRUPTED = 130;
const EXIT_FAILED = 1;
const EXIT_TIMEOUT = 2;
/** Non-terminal status: the job is still running. Distinct from failure. */
const EXIT_RUNNING = 3;
function nowStamp(): string {
const date = new Date();
@@ -25,18 +26,6 @@ function formatElapsed(milliseconds: number): string {
return `${minutes}m ${seconds}s`;
}
/**
* Exit code for a status value:
* SUCCEEDED -> 0
* FAILED / CANCELED -> 1
* anything else -> 3 (still running)
*/
function exitCodeForStatus(status: string): number {
if (status === "SUCCEEDED") return 0;
if (TERMINAL_STATUSES.has(status)) return EXIT_FAILED;
return EXIT_RUNNING;
}
/**
* Resolve after `milliseconds`, rejecting early if `signal` aborts (Ctrl-C).
* Cleans up its timer + listener so nothing leaks between polls.
@@ -101,9 +90,9 @@ export default defineCommand({
"Default (no --follow) is a NON-BLOCKING single status probe: one fetch, then",
"return immediately. This is the mode meant for agents / scripts — the caller",
"owns the polling cadence, so the CLI never holds the terminal.",
"Exit codes (both modes): 0 SUCCEEDED | 1 FAILED/CANCELED | 2 --poll-timeout",
"exceeded (--follow) | 3 still running (non-terminal, default mode) | 130",
"interrupted (Ctrl-C).",
"A terminal FAILED/CANCELED status raises a normal CLI error (non-zero exit);",
"a SUCCEEDED or still-running status returns 0. With --follow, exceeding",
"--poll-timeout raises a timeout error.",
"Use --follow for the blocking, human-terminal-follow experience; use the",
"default mode when driving the loop yourself (e.g. from an agent).",
"For per-step training output (not status), use `finetune logs`.",
@@ -130,32 +119,34 @@ export default defineCommand({
return;
}
// Exit codes here are a public probe contract (0 succeeded / 1 failed / 2
// timeout / 3 still running / 130 interrupted) — deliberately routed via
// process.exit instead of the central error handler.
// ---- Default: non-blocking single status probe -------------------------
// A terminal FAILED/CANCELED status is surfaced as a BailianError (the
// central handler prints it and exits non-zero); SUCCEEDED and still-running
// both return normally. No process.exit / custom exit-code contract.
if (!follow) {
const response = await getFineTune(ctx.client, jobId);
const job = response.output ?? response.data;
const status = String(job?.status ?? "").toUpperCase();
const terminal = TERMINAL_STATUSES.has(status);
const code = exitCodeForStatus(status);
if (settings.quiet) {
// Just the status word — ideal for `status=$(... finetune watch ... --quiet)`.
emitBare(status || "UNKNOWN");
} else if (format === "text") {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (terminal) {
const mark = status === "SUCCEEDED" ? "✓" : "✗";
emitBare(`${mark} ${jobId} ${status}`);
}
if (status === "SUCCEEDED") emitBare(`${jobId} ${status}`);
} else {
// json: a compact, purpose-built status probe.
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
}
process.exit(code);
if (terminal && status !== "SUCCEEDED") {
throw new BailianError(
`Fine-tune job ${jobId} ended in status ${status}.`,
ExitCode.GENERAL,
);
}
return;
}
// ---- --follow: blocking poll loop (legacy behavior) -------------------
@@ -182,28 +173,35 @@ export default defineCommand({
const elapsed = Date.now() - startedAt;
if (format !== "text" || settings.quiet) {
emitResult(response, format);
} else {
const mark = status === "SUCCEEDED" ? "✓" : "✗";
emitBare(`\n${mark} ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
} else if (status === "SUCCEEDED") {
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
}
process.exit(exitCodeForStatus(status));
if (status !== "SUCCEEDED") {
throw new BailianError(
`Fine-tune job ${jobId} ended in status ${status} (elapsed ${formatElapsed(elapsed)}).`,
ExitCode.GENERAL,
);
}
return;
}
if (pollTimeoutSec !== undefined && (Date.now() - startedAt) / 1000 >= pollTimeoutSec) {
if (format === "text" && !settings.quiet) {
emitBare(
`\n⏼ ${jobId} timed out after ${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"})`,
);
}
process.exit(EXIT_TIMEOUT);
throw new BailianError(
`Watching fine-tune job ${jobId} timed out after ` +
`${formatElapsed(Date.now() - startedAt)} (last status: ${status || "UNKNOWN"}).`,
ExitCode.TIMEOUT,
);
}
await sleep(intervalSec * 1000, controller.signal);
}
} catch (error) {
// Ctrl-C aborts the poll loop: report and return normally (no custom code).
// Any other error (including the BailianError thrown above) propagates to
// the central handler.
if (controller.signal.aborted) {
emitBare("\nInterrupted.");
process.exit(EXIT_INTERRUPTED);
return;
}
throw error;
} finally {
+24 -6
View File
@@ -22,6 +22,7 @@ import {
resolveWatermark,
ASYNC_FLAG,
CONCURRENT_FLAG,
redactDataUri,
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
import { downloadFile } from "bailian-cli-runtime";
@@ -31,10 +32,15 @@ import { resolveImageSize } from "bailian-cli-runtime";
import { join } from "path";
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
function isSyncModel(model: string): boolean {
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
}
function enablesPromptExtendByDefault(model: string): boolean {
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
}
const EDIT_FLAGS = {
@@ -98,7 +104,7 @@ const EDIT_FLAGS = {
type EditFlags = ParsedFlags<typeof EDIT_FLAGS>;
export default defineCommand({
description: "Edit an existing image with text instructions (Qwen-Image)",
description: "Edit an existing image with text instructions (Qwen-Image / Wan 2.7)",
auth: "apiKey",
usageArgs: "--image <url> --prompt <text> [flags]",
flags: EDIT_FLAGS,
@@ -107,6 +113,7 @@ export default defineCommand({
'--image https://example.com/logo.png --prompt "Change color to blue" --n 3',
'--image ./a.png --image ./b.png --prompt "Merge two images into one collage"',
'--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
'--image ./photo.png --prompt "Change the style" --model wan2.7-image',
'--image ./photo.png --prompt "Replace the background with a beach" --watermark false',
],
async run(ctx) {
@@ -125,13 +132,13 @@ export default defineCommand({
// Auto-upload local files (resolve all images in parallel)
const resolvedImages = await Promise.all(
rawImages.map((img) => ctx.client.uploadFile(img, model)),
rawImages.map((image) => ctx.client.resolveImageInput(image, model)),
);
const n = flags.n ?? 1;
const promptExtend = resolveBooleanFlag(
flags.promptExtend,
useSync ? true : undefined,
enablesPromptExtendByDefault(model) ? true : undefined,
"prompt-extend",
);
@@ -169,7 +176,18 @@ export default defineCommand({
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
const previewBody = {
...body,
input: {
messages: body.input.messages.map((message) => ({
...message,
content: message.content.map((item) =>
item.image ? { ...item, image: redactDataUri(item.image) } : item,
),
})),
},
};
emitResult({ request: previewBody, mode: useSync ? "sync" : "async" }, format);
return;
}
@@ -31,11 +31,16 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai
import { join } from "path";
// qwen-image-2.0 series uses the sync multimodal-generation endpoint
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
// Qwen-Image 2.0 and Wan 2.7 use the sync multimodal-generation endpoint.
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
function isSyncModel(model: string): boolean {
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
}
function enablesPromptExtendByDefault(model: string): boolean {
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
}
const GENERATE_FLAGS = {
@@ -121,7 +126,7 @@ export default defineCommand({
const promptExtend = resolveBooleanFlag(
flags.promptExtend,
useSync ? true : undefined,
enablesPromptExtendByDefault(model) ? true : undefined,
"prompt-extend",
);
@@ -0,0 +1,373 @@
import {
defineCommand,
detectOutputFormat,
fetchModelDetail,
fetchModelGroups,
fetchPredictConfig,
type FlagsDef,
type ModelGroup,
type ModelGroupItem,
type ModelPriceInfo,
type PredictConfigEntry,
} from "bailian-cli-core";
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
const DATE_SUFFIX_RE = /-\d{4}-\d{2}-\d{2}$/;
const LIST_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: "Show full details of a specific model family (switches to detail mode)",
},
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 10)" },
provider: {
type: "array",
valueHint: "<p>",
description: "Filter by provider (repeatable, e.g. --provider alibaba --provider deepseek)",
},
capability: {
type: "array",
valueHint: "<c>",
description: "Filter by capability code (TG, Reasoning, VU, IG, VG, TTS, ASR, …)",
},
feature: {
type: "array",
valueHint: "<f>",
description: "Filter by feature (function-calling, web-search, structured-outputs, …)",
},
contextWindow: {
type: "array",
valueHint: "<w>",
description: "Filter by context window range bucket",
},
enrich: {
type: "switch",
description:
"Also fetch input parameter schema (predictConfig) for trunk models (detail mode only)",
},
} satisfies FlagsDef;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Strip group- prefix from the family model key. */
function familySlug(group: ModelGroup): string {
return (group.model ?? "").replace(/^group-/, "") || group.name || "unknown";
}
/** Format a single price entry compactly for table display. */
function formatPriceCompact(prices: ModelPriceInfo[] | undefined): string {
if (!prices?.length) return "-";
const inputPrice =
prices.find(
(priceEntry) => typeof priceEntry.type === "string" && priceEntry.type.includes("input"),
) ?? prices[0];
const typeLabel = inputPrice.type ?? "";
const priceStr = String(inputPrice.price ?? "?");
const unit = inputPrice.priceUnit ?? "";
return `${typeLabel}:${priceStr}/${unit}`;
}
/** Aggregate metadata from a group's items. */
function aggregateGroupMeta(group: ModelGroup) {
const providers = new Set<string>();
const capabilities = new Set<string>();
let modelCount = 0;
let maxContext = 0;
let representativePrice: ModelPriceInfo | undefined;
const allPrices: ModelPriceInfo[] = [];
for (const item of group.items ?? []) {
if (item.provider) providers.add(item.provider);
for (const cap of item.capabilities ?? []) capabilities.add(cap);
modelCount++;
if (typeof item.contextWindow === "number" && item.contextWindow > maxContext) {
maxContext = item.contextWindow;
}
if (item.prices?.length) {
allPrices.push(...item.prices);
if (!representativePrice) {
representativePrice =
item.prices.find(
(priceEntry) =>
typeof priceEntry.type === "string" && priceEntry.type.includes("input"),
) ?? item.prices[0];
}
}
}
return {
providers: [...providers].join(","),
capabilities: [...capabilities].join(","),
modelCount,
maxContext: maxContext > 0 ? maxContext : undefined,
price: representativePrice ? formatPriceCompact([representativePrice]) : undefined,
prices: allPrices,
};
}
/** Pick trunk items: exclude date-suffixed snapshots; if all snapshots, keep the latest. */
function pickTrunkItems(items: ModelGroupItem[]): ModelGroupItem[] {
if (items.length === 0) return [];
const trunk = items.filter((item) => !DATE_SUFFIX_RE.test(item.model ?? ""));
if (trunk.length > 0) return trunk;
return [...items]
.sort((first, second) => String(second.model).localeCompare(String(first.model)))
.slice(0, 1);
}
// ---------------------------------------------------------------------------
// Browse mode — family-level paginated listing
// ---------------------------------------------------------------------------
function printBrowseText(groups: ModelGroup[], total: number): void {
if (groups.length === 0) {
emitBare("No model families found.");
return;
}
const headers = ["NAME", "PROVIDER", "CAPABILITIES", "MODELS", "MAX_CONTEXT", "PRICE"];
const rows = groups.map((group) => {
const meta = aggregateGroupMeta(group);
return [
group.name || familySlug(group),
meta.providers || "-",
meta.capabilities || "-",
String(meta.modelCount),
meta.maxContext ? String(meta.maxContext) : "-",
meta.price ?? "-",
];
});
for (const line of formatTable(headers, rows)) emitBare(line);
emitBare(`\nTotal: ${total}`);
}
function formatBrowseJson(groups: ModelGroup[], total: number) {
const items = groups.map((group) => {
const meta = aggregateGroupMeta(group);
const firstItem = group.items?.[0];
return {
model: familySlug(group),
name: group.name,
description: group.description,
provider: meta.providers,
capabilities: [...new Set((group.items ?? []).flatMap((item) => item.capabilities ?? []))],
modelCount: meta.modelCount,
maxContextWindow: meta.maxContext,
prices: meta.prices.length > 0 ? meta.prices : undefined,
qpmInfo: firstItem?.qpmInfo,
};
});
return { items, total };
}
// ---------------------------------------------------------------------------
// Detail mode — single family with full enrichment
// ---------------------------------------------------------------------------
function printDetailText(detail: ModelGroup, includePredictConfig: boolean): void {
const items = detail.items ?? [];
const trunkItems = pickTrunkItems(items);
const snapshotCount = items.length - trunkItems.length;
emitBare(detail.name ?? detail.model);
if (detail.description) emitBare(` ${detail.description}`);
if (detail.updateAt) emitBare(` Updated: ${detail.updateAt}`);
emitBare("");
// Items table
const headers = ["MODEL", "PROVIDER", "CAPABILITIES", "CONTEXT", "OUTPUT", "CATEGORY", "PRICE"];
const rows = items.map((item) => [
item.model ?? "-",
item.provider ?? "-",
(item.capabilities ?? []).join(",") || "-",
item.contextWindow ? String(item.contextWindow) : "-",
item.maxOutputTokens ? String(item.maxOutputTokens) : "-",
item.category ?? "-",
formatPriceCompact(item.prices),
]);
for (const line of formatTable(headers, rows)) emitBare(line);
emitBare(
`\nTotal: ${items.length} models (${trunkItems.length} trunk, ${snapshotCount} snapshots)`,
);
// Pricing details per trunk item
for (const item of trunkItems) {
if (!item.prices?.length) continue;
emitBare(`\n── Pricing: ${item.model} ──`);
const priceHeaders = ["TYPE", "PRICE", "UNIT"];
const priceRows = item.prices.map((priceEntry) => [
priceEntry.type ?? "-",
String(priceEntry.price ?? "-"),
priceEntry.priceUnit ?? "-",
]);
for (const line of formatTable(priceHeaders, priceRows)) emitBare(line);
}
// QPM details per trunk item
for (const item of trunkItems) {
if (!item.qpmInfo || Object.keys(item.qpmInfo).length === 0) continue;
emitBare(`\n── Rate Limits: ${item.model} ──`);
const qpmHeaders = ["TIER", "RPM", "TPM", "TPM_FIELD"];
const qpmRows = Object.entries(item.qpmInfo).map(([tier, limits]) => {
const period = (limits.count_limit_period as number) || 60;
const rpm = period ? Math.floor((((limits.count_limit as number) || 0) * 60) / period) : 0;
const usagePeriod = (limits.usage_limit_period as number) || 60;
const tpm = usagePeriod
? Math.floor((((limits.usage_limit as number) || 0) * 60) / usagePeriod)
: 0;
return [
tier,
rpm > 0 ? String(rpm) : "-",
tpm > 0 ? String(tpm) : "-",
(limits.usage_limit_field as string) ?? "-",
];
});
for (const line of formatTable(qpmHeaders, qpmRows)) emitBare(line);
}
// PredictConfig sections
if (includePredictConfig) {
for (const item of trunkItems) {
if (!item.predictConfig || item.predictConfig.length === 0) continue;
emitBare(`\n── predictConfig: ${item.model} ──`);
printPredictConfigTable(item.predictConfig);
}
}
}
function formatDetailJson(detail: ModelGroup, includePredictConfig: boolean) {
const items = detail.items ?? [];
return {
model: detail.model,
name: detail.name,
description: detail.description,
updateAt: detail.updateAt,
items: items.map((item) => {
const entry: Record<string, unknown> = {
model: item.model,
name: item.name,
provider: item.provider,
capabilities: item.capabilities,
features: item.features,
contextWindow: item.contextWindow,
maxOutputTokens: item.maxOutputTokens,
maxInputTokens: item.maxInputTokens,
category: item.category,
openSource: item.openSource,
docUrl: item.docUrl,
versionTag: item.versionTag,
};
if (item.prices) entry.prices = item.prices;
if (item.qpmInfo) entry.qpmInfo = item.qpmInfo;
if (includePredictConfig && item.predictConfig) {
entry.predictConfig = item.predictConfig;
}
return entry;
}),
};
}
function printPredictConfigTable(entries: PredictConfigEntry[]): void {
const headers = ["PARAM", "KEY", "DEFAULT", "RANGE"];
const rows = entries.map((entry) => [
entry.name ?? "-",
entry.key ?? "-",
entry.default !== undefined ? JSON.stringify(entry.default) : "-",
entry.range !== undefined ? JSON.stringify(entry.range) : "-",
]);
for (const line of formatTable(headers, rows)) emitBare(line);
}
// ---------------------------------------------------------------------------
// Command definition
// ---------------------------------------------------------------------------
export default defineCommand({
description: "Browse model families or show detailed model info in the Bailian model marketplace",
auth: "console",
usageArgs:
"[--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]",
flags: LIST_FLAGS,
exampleArgs: [
"",
"--provider alibaba",
"--capability TG --capability Reasoning",
"--model qwen-max",
"--model qwen-max --enrich --output json",
"--feature function-calling --output json",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
const modelKey = flags.model;
// ── Detail mode ──
if (modelKey) {
const shouldEnrich = Boolean(flags.enrich);
if (settings.dryRun) {
emitResult({ action: "model.detail", model: modelKey, enrich: shouldEnrich }, format);
return;
}
const detail = await fetchModelDetail(ctx.client.console.bind(ctx.client), modelKey);
if (!detail) {
emitBare(`Model "${modelKey}" not found.`);
return;
}
if (shouldEnrich) {
const trunkItems = pickTrunkItems(detail.items ?? []);
await Promise.all(
trunkItems.map(async (item) => {
if (!item.model) return;
const config = await fetchPredictConfig(
ctx.client.console.bind(ctx.client),
item.model,
);
if (config) item.predictConfig = config;
}),
);
}
if (format === "json") {
emitResult(formatDetailJson(detail, shouldEnrich), format);
return;
}
printDetailText(detail, shouldEnrich);
return;
}
// ── Browse mode ──
const params = {
pageNo: flags.page,
pageSize: flags.pageSize ?? 10,
providers: flags.provider?.length ? flags.provider : undefined,
capabilities: flags.capability?.length ? flags.capability : undefined,
features: flags.feature?.length ? flags.feature : undefined,
contextWindows: flags.contextWindow?.length ? flags.contextWindow : undefined,
};
if (settings.dryRun) {
emitResult({ action: "model.list", ...params }, format);
return;
}
const { total, groups } = await fetchModelGroups(ctx.client.console.bind(ctx.client), params);
if (format === "json") {
emitResult(formatBrowseJson(groups, total), format);
return;
}
printBrowseText(groups, total);
},
});
@@ -0,0 +1,21 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Install or upgrade an allowlisted Command Pack",
auth: "none",
usageArgs: "--package <name[@version]>",
flags: {
package: {
type: "string",
valueHint: "<name[@version]>",
description: "Allowlisted Command Pack package and optional version or tag",
required: true,
},
},
exampleArgs: ["--package @ali/bailian-plugin-agent", "--package @ali/bailian-plugin-agent@beta"],
async run(ctx) {
const installed = await ctx.commandPacks.install(ctx.flags.package);
emitResult({ installed }, detectOutputFormat(ctx.settings.output));
},
});
@@ -0,0 +1,21 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Link an allowlisted local Command Pack for development",
auth: "none",
usageArgs: "--path <directory>",
flags: {
path: {
type: "string",
valueHint: "<directory>",
description: "Local Command Pack package directory",
required: true,
},
},
exampleArgs: ["--path ../bailian-plugin-agent"],
async run(ctx) {
const linked = await ctx.commandPacks.link(ctx.flags.path);
emitResult({ linked }, detectOutputFormat(ctx.settings.output));
},
});
@@ -0,0 +1,33 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
export default defineCommand({
description: "List installed Command Packs and their load status",
auth: "none",
exampleArgs: ["", "--output json"],
async run(ctx) {
const reports = await ctx.commandPacks.list();
const format = detectOutputFormat(ctx.settings.output);
if (format === "json") {
emitResult({ command_packs: reports }, format);
return;
}
if (reports.length === 0) {
emitBare("No Command Packs installed.");
return;
}
const rows = reports.map((report) => [
report.name,
report.version ?? "-",
report.source,
report.status,
report.commands.join(", ") || report.error || "-",
]);
for (const line of formatTable(
["NAME", "VERSION", "SOURCE", "STATUS", "COMMANDS / ERROR"],
rows,
)) {
emitBare(line);
}
},
});
@@ -0,0 +1,21 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Remove an installed Command Pack",
auth: "none",
usageArgs: "--name <package>",
flags: {
name: {
type: "string",
valueHint: "<package>",
description: "Allowlisted Command Pack package name",
required: true,
},
},
exampleArgs: ["--name @ali/bailian-plugin-agent"],
async run(ctx) {
await ctx.commandPacks.remove(ctx.flags.name);
emitResult({ removed: ctx.flags.name }, detectOutputFormat(ctx.settings.output));
},
});
+73 -80
View File
@@ -1,13 +1,15 @@
import {
defineCommand,
BailianError,
ExitCode,
effectiveConsoleGatewayConfig,
detectOutputFormat,
unwrapResponse,
MODEL_LIST_API,
type Client,
} from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
interface QpmInfoItem {
@@ -49,46 +51,6 @@ function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): n
return Math.floor((item.usage_limit * 60) / period);
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
function formatRatio(usage: number, limit: number): string {
if (limit <= 0) return "-";
const pct = Math.round((usage / limit) * 100);
return `${formatNumber(usage)}/${formatNumber(limit)} (${pct}%)`;
}
function getStatus(usage: number, limit: number): string {
if (limit <= 0) return "-";
const pct = (usage / limit) * 100;
if (pct >= 100) return "Rate Limited";
if (pct >= 80) return "Near limit";
return "Normal";
}
function getNestedRecord(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
return undefined;
}
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
const data = getNestedRecord(result, "data");
if (!data) return result;
const dataV2 = getNestedRecord(data, "DataV2");
if (dataV2) {
const inner = getNestedRecord(dataV2, "data");
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
return innerData ?? inner ?? dataV2;
}
const direct = getNestedRecord(data, "data");
return direct ?? data;
}
async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
const allModels: ModelWithQpm[] = [];
let pageNo = 1;
@@ -105,7 +67,7 @@ async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
},
});
const resp = extractResponseData(raw as Record<string, unknown>);
const resp = unwrapResponse(raw as Record<string, unknown>);
const list = (resp.list as ModelWithQpm[]) ?? [];
const total = (resp.total as number) ?? 0;
@@ -142,7 +104,7 @@ async function fetchMonitorData(
},
});
const resp = extractResponseData(raw as Record<string, unknown>);
const resp = unwrapResponse(raw as Record<string, unknown>);
const metrics = (resp.data ?? resp) as MonitorMetric[] | Record<string, unknown>;
if (!Array.isArray(metrics)) return { rpm: 0, tpm: 0 };
@@ -157,7 +119,12 @@ async function fetchMonitorData(
}
return { rpm, tpm };
} catch {
} catch (error) {
// Re-throw authentication errors (BailianError with ExitCode.AUTH);
// other errors are treated as "no data" and show "-" in the table.
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
throw error;
}
return { rpm: -1, tpm: -1 };
}
}
@@ -168,16 +135,24 @@ interface CheckRow {
rpmLimit: number;
tpmUsage: number;
tpmLimit: number;
rpmQuotaLeft: number | null;
tpmQuotaLeft: number | null;
rpmQuotaLabel: string | null;
tpmQuotaLabel: string | null;
}
function printTable(rows: CheckRow[]): void {
const color = ansi(process.stdout);
const headers = ["Model", "RPM Used/Limit", "TPM Used/Limit", "Status", "RPM Left", "TPM Left"];
const headers = ["Model", "RPM Usage/Limit", "TPM Usage/Limit", "Status"];
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
const tableRows = rows.map((r) => {
const rpmStr = r.rpmUsage < 0 ? "-" : formatRatio(r.rpmUsage, r.rpmLimit);
const tpmStr = r.tpmUsage < 0 ? "-" : formatRatio(r.tpmUsage, r.tpmLimit);
const rpmStr = r.rpmUsage < 0 ? "-" : `${r.rpmUsage}/${r.rpmLimit}`;
const tpmStr = r.tpmUsage < 0 ? "-" : `${r.tpmUsage}/${r.tpmLimit}`;
const maxPct = Math.max(
r.rpmLimit > 0 ? (r.rpmUsage / r.rpmLimit) * 100 : 0,
r.tpmLimit > 0 ? (r.tpmUsage / r.tpmLimit) * 100 : 0,
@@ -185,39 +160,34 @@ function printTable(rows: CheckRow[]): void {
const status =
r.rpmUsage < 0
? "-"
: getStatus(Math.max(r.rpmUsage, r.tpmUsage), Math.max(r.rpmLimit, r.tpmLimit));
return { cells: [r.model, rpmStr, tpmStr, status], maxPct };
: maxPct >= 100
? "Rate Limited"
: maxPct >= 80
? "Near limit"
: "Normal";
return [r.model, rpmStr, tpmStr, status, "", ""];
});
if (tableRows.length === 0) {
process.stdout.write("No models found.\n");
return;
}
const widths = headers.map((label, col) =>
Math.max(displayWidth(label), ...tableRows.map((r) => displayWidth(r.cells[col]))),
);
const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((w) => color.dim("─".repeat(w))).join("──");
process.stdout.write(headerLine + "\n");
process.stdout.write(separator + "\n");
const statusCol = 3;
for (const r of tableRows) {
const cells = r.cells.map((cell, col) => {
if (col === statusCol) {
if (cell === "Rate Limited") return color.red(padEnd(cell, widths[col]));
if (cell === "Near limit") return color.yellow(padEnd(cell, widths[col]));
if (cell === "Normal") return color.green(padEnd(cell, widths[col]));
const lines = renderBoxTable({
headers,
rows: tableRows,
align: ["left", "right", "right", "left", "left", "left"],
barColumns: [
{ index: 4, percents: rpmPercents, labels: rpmLabels, width: 15 },
{ index: 5, percents: tpmPercents, labels: tpmLabels, width: 15 },
],
cellColor: (rowIndex, colIndex, value) => {
if (colIndex === 3) {
// Status 列着色
if (value === "Rate Limited") return color.red(value);
if (value === "Near limit") return color.yellow(value);
if (value === "Normal") return color.green(value);
}
return padEnd(cell, widths[col]);
});
process.stdout.write(cells.join(" ") + "\n");
}
return undefined;
},
});
process.stdout.write(color.dim(`\nTotal: ${rows.length} models`) + "\n");
for (const line of lines) process.stdout.write(line + "\n");
}
export default defineCommand({
@@ -295,12 +265,35 @@ export default defineCommand({
const tpmLimit =
calculateTPM(userSpec, modelDefault?.usage_limit_period) || calculateTPM(modelDefault);
const rpmUsage = monitorResults[idx].rpm;
const tpmUsage = monitorResults[idx].tpm;
// RPM Quota Left = 1 - (rpmUsage / rpmLimit) in percentage
let rpmQuotaPercent: number | null = null;
let rpmQuotaLabel: string | null = null;
if (rpmUsage >= 0 && rpmLimit > 0) {
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / rpmLimit) * 100);
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
}
// TPM Quota Left = 1 - (tpmUsage / tpmLimit) in percentage
let tpmQuotaPercent: number | null = null;
let tpmQuotaLabel: string | null = null;
if (tpmUsage >= 0 && tpmLimit > 0) {
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / tpmLimit) * 100);
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
}
return {
model: m.model,
rpmUsage: monitorResults[idx].rpm,
rpmUsage,
rpmLimit,
tpmUsage: monitorResults[idx].tpm,
tpmUsage,
tpmLimit,
rpmQuotaLeft: rpmQuotaPercent,
tpmQuotaLeft: tpmQuotaPercent,
rpmQuotaLabel,
tpmQuotaLabel,
};
});
+165 -89
View File
@@ -1,8 +1,15 @@
import { defineCommand, BailianError, detectOutputFormat, type Client } from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
unwrapResponse,
MODEL_LIST_API,
type Client,
} from "bailian-cli-core";
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
interface QpmInfoItem {
count_limit: number;
@@ -18,6 +25,17 @@ interface ModelWithQpm {
qpmInfo?: Record<string, QpmInfoItem>;
}
interface MonitorPoint {
value: number;
timestamp: number;
}
interface MonitorMetric {
aggMethod: string;
metricName: string;
points: MonitorPoint[];
}
function calculateRPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.count_limit_period || fallbackPeriod;
@@ -36,32 +54,59 @@ function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
function getNestedRecord(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
return undefined;
}
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
const data = getNestedRecord(result, "data");
if (!data) return result;
const dataV2 = getNestedRecord(data, "DataV2");
if (dataV2) {
const inner = getNestedRecord(dataV2, "data");
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
return innerData ?? inner ?? dataV2;
}
const direct = getNestedRecord(data, "data");
return direct ?? data;
}
async function fetchAllModelsWithQpm(
async function fetchMonitorData(
client: Client,
onlySelfService: boolean,
): Promise<ModelWithQpm[]> {
modelName: string,
windowMinutes: number,
): Promise<{ rpm: number; tpm: number }> {
const now = Date.now();
const startTime = now - windowMinutes * 60 * 1000;
try {
const raw = await client.console(MONITOR_API, {
reqDTO: {
monitorType: "Advanced",
metricFilters: [
{ aggMethod: "sum_pm", metricName: "model_total_amount" },
{ aggMethod: "sum_pm", metricName: "model_call_count" },
],
labelFilters: {
resourceId: modelName,
resourceType: "model",
},
startTime,
endTime: now,
},
});
const resp = unwrapResponse(raw as Record<string, unknown>);
const metrics = (resp.data ?? resp) as MonitorMetric[] | Record<string, unknown>;
if (!Array.isArray(metrics)) {
return { rpm: 0, tpm: 0 };
}
let rpm = 0;
let tpm = 0;
for (const metric of metrics) {
if (metric.aggMethod !== "sum_pm" || !metric.points?.length) continue;
const lastValue = metric.points[metric.points.length - 1].value ?? 0;
if (metric.metricName === "model_call_count") rpm = Math.round(lastValue);
if (metric.metricName === "model_total_amount") tpm = Math.round(lastValue);
}
return { rpm, tpm };
} catch (error) {
// Re-throw authentication errors (BailianError with ExitCode.AUTH);
// other errors are treated as "no data" and show "-" in the table.
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
throw error;
}
return { rpm: -1, tpm: -1 };
}
}
async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
const allModels: ModelWithQpm[] = [];
let pageNo = 1;
@@ -72,14 +117,12 @@ async function fetchAllModelsWithQpm(
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
};
if (onlySelfService) {
input.supports = { selfServiceLimitIncrease: true };
}
const raw = await client.console(MODEL_LIST_API, { input });
const resp = extractResponseData(raw as Record<string, unknown>);
const resp = unwrapResponse(raw as Record<string, unknown>);
const list = (resp.list as ModelWithQpm[]) ?? [];
const total = (resp.total as number) ?? 0;
@@ -91,50 +134,37 @@ async function fetchAllModelsWithQpm(
return allModels;
}
function printTable(models: ModelWithQpm[]): void {
const color = ansi(process.stdout);
interface ListRow {
model: string;
rpm: string;
tpm: string;
rpmQuotaLeft: number | null;
tpmQuotaLeft: number | null;
rpmQuotaLabel: string | null;
tpmQuotaLabel: string | null;
}
const headers = ["Model", "Req/min", "Token/min", "Max TPM"];
function printTable(rows: ListRow[]): void {
const headers = ["Model", "Req/min", "Token/min", "RPM Left", "TPM Left"];
const rows = models.map((m) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
const maxTPM = defaultTPM * 2;
const tableRows = rows.map((r) => [r.model, r.rpm, r.tpm, "", ""]);
return [
m.model,
currentRPM > 0 ? formatNumber(currentRPM) : "-",
currentTPM > 0 ? formatNumber(currentTPM) : "-",
maxTPM > 0 ? formatNumber(maxTPM) : "-",
];
const lines = renderBoxTable({
headers,
rows: tableRows,
align: ["left", "right", "right", "left", "left"],
barColumns: [
{ index: 3, percents: rpmPercents, labels: rpmLabels, width: 15 },
{ index: 4, percents: tpmPercents, labels: tpmLabels, width: 15 },
],
});
if (rows.length === 0) {
process.stdout.write("No models found.\n");
return;
}
const widths = headers.map((label, col) =>
Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))),
);
const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((w) => color.dim("─".repeat(w))).join("──");
process.stdout.write(headerLine + "\n");
process.stdout.write(separator + "\n");
for (const row of rows) {
process.stdout.write(row.map((cell, col) => padEnd(cell, widths[col])).join(" ") + "\n");
}
process.stdout.write(color.dim(`\nTotal: ${models.length} models`) + "\n");
for (const line of lines) process.stdout.write(line + "\n");
}
export default defineCommand({
@@ -147,22 +177,11 @@ export default defineCommand({
valueHint: "<model>",
description: "Model name(s), comma-separated",
},
all: {
type: "switch",
description: "Show all models, not just self-service ones",
},
},
exampleArgs: [
"",
"--model qwen3.6-plus",
"--model qwen3.6-plus,qwen-turbo",
"--all",
"--output json",
],
exampleArgs: ["", "--model qwen3.6-plus", "--model qwen3.6-plus,qwen-turbo", "--output json"],
async run(ctx) {
const { settings, flags } = ctx;
const modelFlag = flags.model || undefined;
const showAll = Boolean(flags.all);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
@@ -172,13 +191,22 @@ export default defineCommand({
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
};
if (!showAll) input.supports = { selfServiceLimitIncrease: true };
emitResult({ api: MODEL_LIST_API, data: { input } }, format);
emitResult(
{
apis: [
MODEL_LIST_API,
{ api: MONITOR_API, note: "called per-model for text output with gauges" },
],
modelListInput: { input },
},
format,
);
return;
}
let models = await fetchAllModelsWithQpm(ctx.client, !showAll);
let models = await fetchAllModelsWithQpm(ctx.client);
if (modelFlag) {
const names = new Set(
@@ -203,19 +231,67 @@ export default defineCommand({
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
const maxTPM = defaultTPM * 2;
return {
model: m.model,
rpm: currentRPM > 0 ? currentRPM : null,
tpm: currentTPM > 0 ? currentTPM : null,
maxTPM: maxTPM > 0 ? maxTPM : null,
};
});
emitResult(items, format);
return;
}
printTable(models);
// For text output with gauges, we need monitor data
const monitorResults = await Promise.all(
models.map((m) => fetchMonitorData(ctx.client, m.model, 2)),
);
const rows: ListRow[] = models.map((m, idx) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
const rpmUsage = monitorResults[idx].rpm;
const tpmUsage = monitorResults[idx].tpm;
// RPM Quota Left = 1 - (rpmUsage / currentRPM) in percentage
let rpmQuotaPercent: number | null = null;
let rpmQuotaLabel: string | null = null;
if (rpmUsage >= 0 && currentRPM > 0) {
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / currentRPM) * 100);
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
}
// TPM Quota Left = 1 - (tpmUsage / currentTPM) in percentage
let tpmQuotaPercent: number | null = null;
let tpmQuotaLabel: string | null = null;
if (tpmUsage >= 0 && currentTPM > 0) {
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / currentTPM) * 100);
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
}
return {
model: m.model,
rpm: currentRPM > 0 ? formatNumber(currentRPM) : "-",
tpm: currentTPM > 0 ? formatNumber(currentTPM) : "-",
rpmQuotaLeft: rpmQuotaPercent,
tpmQuotaLeft: tpmQuotaPercent,
rpmQuotaLabel,
tpmQuotaLabel,
};
});
if (rows.length === 0) {
process.stdout.write("No models found.\n");
return;
}
printTable(rows);
},
});
@@ -0,0 +1,37 @@
import { execFile } from "node:child_process";
import http from "node:http";
/**
* Bind an http server to a loopback-only TCP port and resolve the chosen port.
* `port = 0` (default) lets the OS pick a free port. Always binds 127.0.0.1 so
* the server is never reachable off the local machine.
*/
export function listenLocalServer(server: http.Server, port = 0): Promise<number> {
return new Promise((resolve, reject) => {
const onErr = (e: Error) => reject(e);
server.once("error", onErr);
server.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
server.off("error", onErr);
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("Expected TCP socket address"));
return;
}
resolve(addr.port);
});
});
}
/** Open a URL in the user's default browser (best-effort, cross-platform). */
export function openInBrowser(url: string): Promise<void> {
const platform = process.platform;
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
return new Promise((resolve, reject) => {
execFile(cmd, args, { windowsHide: true }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
+2 -3
View File
@@ -3,6 +3,7 @@ import {
chatPath,
parseSSE,
detectOutputFormat,
readTextFromPathOrStdin,
type ChatMessage,
type ChatRequest,
type ChatResponse,
@@ -69,9 +70,7 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
}
if (flags.messagesFile) {
const filePath = flags.messagesFile;
const raw =
filePath === "-" ? readFileSync("/dev/stdin", "utf-8") : readFileSync(filePath, "utf-8");
const raw = readTextFromPathOrStdin(flags.messagesFile);
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
for (const m of parsed) {
if (m.role === "system") {
+55 -191
View File
@@ -1,177 +1,20 @@
import { defineCommand, detectOutputFormat, fetchModelList, type Client } from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
import { defineCommand, detectOutputFormat, fetchModelList } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
FREE_TIER_API,
FREE_TIER_ONLY_STATUS_API,
extractQuotas,
extractFreeTierOnlyStatuses,
fetchAllModels,
resolveModelType,
formatDate,
printFreeTierTable,
quotaRemainingPercent,
type FreeTierQuota,
} from "./shared.ts";
const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
const FREE_TIER_ONLY_STATUS_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
interface FreeTierQuota {
model: string;
quotaInitTotal: number;
quotaTotal: number;
quotaValidityPeriod: number;
quotaStatus: string;
}
interface FreeTierOnlyStatus {
model: string;
freeTierOnly: boolean;
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
function formatDate(ts: number): string {
const date = new Date(ts);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatUsage(quota: FreeTierQuota): string {
if (!quota.quotaInitTotal) return "-";
const used = quota.quotaInitTotal - quota.quotaTotal;
const percent = (used / quota.quotaInitTotal) * 100;
return `${percent.toFixed(1)}%`;
}
const CAPABILITY_TO_TYPE: Record<string, string> = {
Reasoning: "Text",
TG: "Text",
VU: "Text",
IG: "Vision",
VG: "Vision",
"Realtime-Omni": "Multimodal",
"Multimodal-Omni": "Multimodal",
ASR: "Audio",
TTS: "Audio",
"Voice-Replication": "Audio",
"Realtime-Text-to-Speech": "Audio",
"Realtime-Voice-Replication": "Audio",
"Realtime-ASR": "Audio",
"Realtime-Audio-Translate": "Audio",
ME: "Embedding",
TR: "Embedding",
};
function resolveModelType(capabilities: string[]): string {
for (const cap of capabilities) {
const type = CAPABILITY_TO_TYPE[cap];
if (type) return type;
}
return "-";
}
function printTable(
quotas: FreeTierQuota[],
stopMap: Map<string, boolean>,
typeMap: Map<string, string>,
): void {
const color = ansi(process.stdout);
const headers = ["Model", "Type", "Remaining/Total", "Usage", "Expires", "Auto-Stop"];
const rows = quotas.map((quota) => {
const hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null;
const remaining = hasQuota ? formatNumber(quota.quotaTotal) : "-";
const total = hasQuota ? formatNumber(quota.quotaInitTotal) : "-";
const stopStatus = stopMap.get(quota.model);
return [
quota.model,
typeMap.get(quota.model) || "-",
hasQuota ? `${remaining} / ${total}` : "-",
formatUsage(quota),
quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : "-",
quota.quotaStatus === "UNKNOWN"
? "Unsupported"
: stopStatus === true
? "ON"
: stopStatus === false
? "OFF"
: "-",
];
});
const widths = headers.map((label, col) =>
Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))),
);
const autoStopCol = headers.length - 1;
const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((width) => color.dim("─".repeat(width))).join("──");
process.stdout.write(headerLine + "\n");
process.stdout.write(separator + "\n");
for (const row of rows) {
const cells = row.map((cell, col) => {
if (col === autoStopCol) {
if (cell === "ON") return color.green(padEnd(cell, widths[col]));
if (cell === "OFF") return color.yellow(padEnd(cell, widths[col]));
}
return padEnd(cell, widths[col]);
});
process.stdout.write(cells.join(" ") + "\n");
}
}
function extractQuotas(result: unknown): FreeTierQuota[] {
const root = result as Record<string, unknown>;
const data = root.data as Record<string, unknown> | undefined;
if (!data) return [];
const dataV2 = data.DataV2 as Record<string, unknown> | undefined;
if (dataV2) {
const inner = dataV2.data as Record<string, unknown> | undefined;
const innerData = inner?.data as Record<string, unknown> | undefined;
return (innerData?.freeTierQuotas as FreeTierQuota[]) || [];
}
const direct = data.data as Record<string, unknown> | undefined;
return (direct?.freeTierQuotas as FreeTierQuota[]) || [];
}
function extractFreeTierOnlyStatuses(result: unknown): FreeTierOnlyStatus[] {
const root = result as Record<string, unknown>;
const data = root.data as Record<string, unknown> | undefined;
if (!data) return [];
const dataV2 = data.DataV2 as Record<string, unknown> | undefined;
if (dataV2) {
const inner = dataV2.data as Record<string, unknown> | undefined;
const innerData = inner?.data as Record<string, unknown> | undefined;
return (innerData?.freeTierOnlyStatuses as FreeTierOnlyStatus[]) || [];
}
const direct = data.data as Record<string, unknown> | undefined;
return (direct?.freeTierOnlyStatuses as FreeTierOnlyStatus[]) || [];
}
interface ModelInfo {
name: string;
type: string;
}
async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList((api, data) => client.console(api, data), {
pageNo: page,
pageSize: 50,
});
allModels.push(...result.models);
if (allModels.length >= result.total) break;
page++;
}
return allModels
.filter((item) => typeof item.model === "string" && item.model)
.map((item) => ({
name: item.model as string,
type: resolveModelType((item.capabilities as string[]) || []),
}));
}
/** Rows shown by default when listing all models; the rest go behind a hint. */
const TOP_N = 15;
export default defineCommand({
description: "Query free-tier quota for models (all models if --model is omitted)",
@@ -194,6 +37,10 @@ export default defineCommand({
description: "Sort by: remaining (ascending), expires (ascending)",
choices: ["remaining", "expires"] as const,
},
all: {
type: "switch",
description: "Show all models instead of the top rows",
},
},
exampleArgs: [
"",
@@ -201,14 +48,16 @@ export default defineCommand({
"--model qwen3-max,qwen-turbo",
"--expiring 30",
"--sort remaining",
"--all",
"--model qwen-turbo --output json",
"--model qwen3-max --console-region cn-beijing",
],
async run(ctx) {
const { settings, flags } = ctx;
const { identity, settings, flags } = ctx;
const modelFlag = flags.model || undefined;
const expiringDays = Number(flags.expiring) || 0;
const sortField = flags.sort || undefined;
const showAll = Boolean(flags.all);
const format = detectOutputFormat(settings.output);
let models: string[];
@@ -232,13 +81,7 @@ export default defineCommand({
};
if (settings.dryRun) {
emitResult(
{
api: FREE_TIER_API,
data: requestData,
},
format,
);
emitResult({ api: FREE_TIER_API, data: requestData }, format);
return;
}
@@ -277,24 +120,30 @@ export default defineCommand({
if (expiringDays > 0) {
const cutoff = Date.now() + expiringDays * 24 * 60 * 60 * 1000;
quotas = quotas.filter((q) => q.quotaValidityPeriod > 0 && q.quotaValidityPeriod <= cutoff);
quotas = quotas.filter(
(quota) => quota.quotaValidityPeriod > 0 && quota.quotaValidityPeriod <= cutoff,
);
}
if (sortField === "remaining") {
quotas.sort((a, b) => {
const pctA = a.quotaInitTotal ? a.quotaTotal / a.quotaInitTotal : 0;
const pctB = b.quotaInitTotal ? b.quotaTotal / b.quotaInitTotal : 0;
return pctA - pctB;
// Default to urgency ordering (least remaining first) when browsing all models.
const effectiveSort = sortField ?? (modelFlag ? undefined : "remaining");
if (effectiveSort === "remaining") {
quotas.sort((left, right) => {
const pctLeft = left.quotaInitTotal ? left.quotaTotal / left.quotaInitTotal : 0;
const pctRight = right.quotaInitTotal ? right.quotaTotal / right.quotaInitTotal : 0;
return pctLeft - pctRight;
});
} else if (sortField === "expires") {
quotas.sort((a, b) => (a.quotaValidityPeriod ?? 0) - (b.quotaValidityPeriod ?? 0));
} else if (effectiveSort === "expires") {
quotas.sort(
(left, right) => (left.quotaValidityPeriod ?? 0) - (right.quotaValidityPeriod ?? 0),
);
}
const stopStatuses = extractFreeTierOnlyStatuses(stopResult);
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
if (format === "json") {
const items = quotas.map((quota) => {
const items = quotas.map((quota: FreeTierQuota) => {
const hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null;
const used = hasQuota ? quota.quotaInitTotal - quota.quotaTotal : 0;
const stopStatus = stopMap.get(quota.model);
@@ -315,6 +164,7 @@ export default defineCommand({
hasQuota && quota.quotaInitTotal > 0
? Math.round((used / quota.quotaInitTotal) * 1000) / 10
: null,
remainingPercent: quotaRemainingPercent(quota),
expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null,
autoStop,
};
@@ -328,6 +178,20 @@ export default defineCommand({
return;
}
printTable(quotas, stopMap, typeMap);
const browseAll = !modelFlag && !showAll;
const visible = browseAll ? quotas.slice(0, TOP_N) : quotas;
const remaining = quotas.length - visible.length;
const titleSuffix = effectiveSort === "remaining" ? " · sorted by urgency" : "";
printFreeTierTable({
quotas: visible,
stopMap,
typeMap,
title: `Free Tier Quota · ${quotas.length} models${titleSuffix}`,
moreHint:
remaining > 0
? `+ ${remaining} more · ${identity.binName} usage free --all to browse all`
: undefined,
});
},
});
@@ -0,0 +1,376 @@
import {
fetchModelList,
BailianError,
ExitCode,
unwrapResponse,
type Client,
type Settings,
} from "bailian-cli-core";
import { ansi, renderBoxTable, displayWidth, padEnd } from "bailian-cli-runtime";
// ---------------------------------------------------------------------------
// Common formatters
// ---------------------------------------------------------------------------
export function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
export function formatDate(ts: number): string {
const date = new Date(ts);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function requireWorkspaceId(settings: Settings, binName: string): string {
if (settings.workspaceId) return settings.workspaceId;
throw new BailianError(
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id <id>\`.`,
ExitCode.GENERAL,
`Run \`${binName} workspace list\` to view available workspaces.`,
);
}
/**
* @deprecated Use `unwrapResponse` from bailian-cli-core instead.
* Kept here only for internal backward-compat with callers that already import this.
*/
export function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
return unwrapResponse(result);
}
// ---------------------------------------------------------------------------
// Model catalog + capability → type mapping
// ---------------------------------------------------------------------------
const CAPABILITY_TO_TYPE: Record<string, string> = {
Reasoning: "Text",
TG: "Text",
VU: "Text",
IG: "Vision",
VG: "Vision",
"Realtime-Omni": "Multimodal",
"Multimodal-Omni": "Multimodal",
ASR: "Audio",
TTS: "Audio",
"Voice-Replication": "Audio",
"Realtime-Text-to-Speech": "Audio",
"Realtime-Voice-Replication": "Audio",
"Realtime-ASR": "Audio",
"Realtime-Audio-Translate": "Audio",
ME: "Embedding",
TR: "Embedding",
};
export function resolveModelType(capabilities: string[]): string {
for (const capability of capabilities) {
const type = CAPABILITY_TO_TYPE[capability];
if (type) return type;
}
return "-";
}
export interface ModelInfo {
name: string;
type: string;
}
export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList((api, data) => client.console(api, data), {
pageNo: page,
pageSize: 50,
});
allModels.push(...result.models);
if (allModels.length >= result.total) break;
page++;
}
return allModels
.filter((item) => typeof item.model === "string" && item.model)
.map((item) => ({
name: item.model as string,
type: resolveModelType((item.capabilities as string[]) || []),
}));
}
// ---------------------------------------------------------------------------
// Free-tier quota
// ---------------------------------------------------------------------------
export const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
export const FREE_TIER_ONLY_STATUS_API =
"zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
export interface FreeTierQuota {
model: string;
quotaInitTotal: number;
quotaTotal: number;
quotaValidityPeriod: number;
quotaStatus: string;
}
export interface FreeTierOnlyStatus {
model: string;
freeTierOnly: boolean;
}
function extractFreeTierField<T>(result: unknown, field: string): T[] {
const root = result as Record<string, unknown>;
const data = root.data as Record<string, unknown> | undefined;
if (!data) return [];
const dataV2 = data.DataV2 as Record<string, unknown> | undefined;
if (dataV2) {
const inner = dataV2.data as Record<string, unknown> | undefined;
const innerData = inner?.data as Record<string, unknown> | undefined;
return (innerData?.[field] as T[]) || [];
}
const direct = data.data as Record<string, unknown> | undefined;
return (direct?.[field] as T[]) || [];
}
export function extractQuotas(result: unknown): FreeTierQuota[] {
return extractFreeTierField<FreeTierQuota>(result, "freeTierQuotas");
}
export function extractFreeTierOnlyStatuses(result: unknown): FreeTierOnlyStatus[] {
return extractFreeTierField<FreeTierOnlyStatus>(result, "freeTierOnlyStatuses");
}
/** Remaining percentage for a quota row, or null when it is unavailable/expired. */
export function quotaRemainingPercent(quota: FreeTierQuota): number | null {
if (isExpired(quota)) return null;
if (quota.quotaInitTotal == null || quota.quotaInitTotal <= 0) return null;
return Math.round((quota.quotaTotal / quota.quotaInitTotal) * 1000) / 10;
}
function isExpired(quota: FreeTierQuota): boolean {
return quota.quotaValidityPeriod > 0 && quota.quotaValidityPeriod < Date.now();
}
export interface FreeTierTableOptions {
quotas: FreeTierQuota[];
stopMap: Map<string, boolean>;
typeMap: Map<string, string>;
/** Optional section title, e.g. "Free Tier Quota · 225 models". */
title?: string;
/** Optional dim hint printed below the table, e.g. "+ 215 more · bl usage free". */
moreHint?: string;
}
/** Print a free-tier quota table with a highlighted header and Quota Left gauge. */
export function printFreeTierTable(options: FreeTierTableOptions): void {
const { quotas, stopMap, typeMap } = options;
const color = ansi(process.stdout);
if (options.title) {
process.stdout.write(color.purple(options.title) + "\n");
}
const headers = ["Model", "Type", "Remaining", "Total", "Quota Left", "Auto-Stop"];
const percents: (number | null)[] = [];
const barLabels: (string | null)[] = [];
const autoStopCol = headers.length - 1;
const rows = quotas.map((quota) => {
const hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null;
const expired = isExpired(quota);
const remaining = hasQuota
? `${formatNumber(quota.quotaTotal)}${expired ? " (expired)" : ""}`
: "-";
const total = hasQuota ? formatNumber(quota.quotaInitTotal) : "-";
const percent = quotaRemainingPercent(quota);
percents.push(percent);
barLabels.push(expired ? "expired" : percent == null ? "-" : null);
const stopStatus = stopMap.get(quota.model);
const autoStop =
quota.quotaStatus === "UNKNOWN"
? "Unsupported"
: stopStatus === true
? "ON"
: stopStatus === false
? "OFF"
: "-";
return [quota.model, typeMap.get(quota.model) || "-", remaining, total, "", autoStop];
});
const lines = renderBoxTable({
headers,
rows,
align: ["left", "left", "right", "right", "left", "left"],
barColumns: [{ index: 4, percents, labels: barLabels }],
cellColor: (_rowIndex, colIndex, value) => {
if (colIndex !== autoStopCol) return undefined;
if (value === "ON") return color.green(value);
if (value === "OFF") return color.yellow(value);
return undefined;
},
});
for (const line of lines) process.stdout.write(line + "\n");
if (options.moreHint) {
process.stdout.write(color.dim(options.moreHint) + "\n");
}
}
// ---------------------------------------------------------------------------
// Usage statistics (telemetry)
// ---------------------------------------------------------------------------
export const OVERVIEW_API = "zeldaEasy.bailian-telemetry.model.getModelUsageStatistic";
export const LIST_API = "zeldaEasy.bailian-telemetry.model.listModelUsageStatisticData";
export interface UsageItem {
key: string;
value: number;
unit: string;
}
export interface OverviewStatistic {
callCount: number;
modelCount: number;
callSuccessCount: number;
usages: UsageItem[];
}
export interface ModelStatisticItem {
model: string;
callSuccessCount: number;
usages?: UsageItem[];
usage?: Record<string, number | undefined>;
}
export interface ListStatisticResponse {
list: ModelStatisticItem[];
totalCount: number;
maxResults: number;
}
const POLL_INTERVAL_MS = 500;
const MAX_POLLS = 30;
export async function pollTelemetryApi(
client: Client,
api: string,
reqDTO: Record<string, unknown>,
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
const requestData = nextTaskId
? { reqDTO: { ...reqDTO, asyncTaskId: nextTaskId } }
: { reqDTO };
const raw = await client.console(api, requestData);
const resp = extractResponseData(raw as Record<string, unknown>);
if (resp.taskId && Object.keys(resp).length === 1) {
nextTaskId = resp.taskId as string;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
return raw;
}
return null;
}
export function extractOverviewData(result: unknown): OverviewStatistic | undefined {
const resp = extractResponseData(result as Record<string, unknown>);
if (resp.callSuccessCount !== undefined || resp.usages !== undefined) {
return resp as unknown as OverviewStatistic;
}
return undefined;
}
export function extractListData(result: unknown): ListStatisticResponse {
const resp = extractResponseData(result as Record<string, unknown>);
const list = (resp.list as ModelStatisticItem[]) ?? [];
const totalCount = (resp.totalCount as number) ?? 0;
const maxResults = (resp.maxResults as number) ?? 0;
return { list, totalCount, maxResults };
}
export function resolveUsageMap(item: ModelStatisticItem): Record<string, number> {
const out: Record<string, number> = {};
if (item.usages && Array.isArray(item.usages)) {
for (const entry of item.usages) {
if (entry.key && entry.value != null) out[entry.key] = entry.value;
}
}
if (item.usage && typeof item.usage === "object") {
for (const [key, val] of Object.entries(item.usage)) {
if (val != null) out[key] = val;
}
}
return out;
}
interface UsageLabel {
en: string;
unit?: string;
}
export const USAGE_KEY_LABELS: Record<string, UsageLabel> = {
total_token: { en: "Total Tokens", unit: "tokens" },
input_token: { en: "Input Tokens", unit: "tokens" },
output_token: { en: "Output Tokens", unit: "tokens" },
input_token_cache: { en: "Cached Tokens", unit: "tokens" },
input_token_cache_read: { en: "Cache Read", unit: "tokens" },
input_token_cache_creation: { en: "Cache Creation", unit: "tokens" },
thinking_input_token: { en: "Thinking Input", unit: "tokens" },
thinking_output_token: { en: "Thinking Output", unit: "tokens" },
text_input_token: { en: "Text Input", unit: "tokens" },
purein_text_output_token: { en: "Text Output", unit: "tokens" },
embedding_token: { en: "Embedding", unit: "tokens" },
image_number: { en: "Images", unit: "images" },
video_duration: { en: "Video Duration", unit: "sec" },
content_duration: { en: "Audio Duration", unit: "sec" },
tts_text_number: { en: "TTS Chars", unit: "chars" },
total_token_avg: { en: "Avg Tokens/Req" },
};
export function formatUsageLabel(key: string): string {
const label = USAGE_KEY_LABELS[key];
if (!label) return key;
const unitSuffix = label.unit ? ` [${label.unit}]` : "";
return `${label.en}${unitSuffix}`;
}
/** Print the account usage overview as an aligned key-value block. */
export function printUsageOverview(
stat: OverviewStatistic,
startTime: number,
endTime: number,
days: number,
title = "Usage Overview",
): void {
const color = ansi(process.stdout);
process.stdout.write(
`${color.purple(title)} ${color.dim("·")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${color.dim(`(${days} days)`)}\n`,
);
const rows: [string, string][] = [
["Models Called", formatNumber(stat.modelCount ?? 0)],
["Successful Calls", formatNumber(stat.callSuccessCount ?? 0)],
];
for (const usage of stat.usages ?? []) {
rows.push([formatUsageLabel(usage.key), formatNumber(usage.value)]);
}
const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label)));
for (const [label, value] of rows) {
process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`);
}
}
@@ -0,0 +1,171 @@
import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import {
FREE_TIER_API,
FREE_TIER_ONLY_STATUS_API,
OVERVIEW_API,
USAGE_KEY_LABELS,
extractFreeTierOnlyStatuses,
extractOverviewData,
extractQuotas,
fetchAllModels,
formatDate,
pollTelemetryApi,
printFreeTierTable,
printUsageOverview,
quotaRemainingPercent,
type FreeTierQuota,
type OverviewStatistic,
} from "./shared.ts";
/** Free-tier rows shown in the summary before the "+N more" hint. */
const TOP_N = 10;
export default defineCommand({
description: "Show a unified usage summary: free-tier quota and recent usage overview",
auth: "console",
usageArgs: "[--days <days>] [flags]",
flags: {
days: {
type: "string",
valueHint: "<days>",
description: "Number of days for the usage overview (default: 7)",
},
},
exampleArgs: ["", "--days 30", "--output json"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const daysFlag = Number(flags.days) || 7;
const format = detectOutputFormat(settings.output);
const workspaceId = settings.workspaceId;
const endTime = Date.now();
const startTime = endTime - daysFlag * 24 * 60 * 60 * 1000;
if (settings.dryRun) {
emitResult(
{
freeTier: { api: FREE_TIER_API },
usage: workspaceId
? {
api: OVERVIEW_API,
data: {
reqDTO: {
startTime,
endTime,
modelCallSource: "Online",
filterWorkspaceId: workspaceId,
},
},
}
: null,
},
format,
);
return;
}
// --- Free-tier quota (all models, urgency-sorted) ---
const modelInfos = await fetchAllModels(ctx.client);
const models = modelInfos.map((info) => info.name);
const typeMap = new Map(modelInfos.map((info) => [info.name, info.type]));
const [quotaResult, stopResult] = await Promise.all([
ctx.client.console(FREE_TIER_API, { queryFreeTierQuotaRequest: { models } }),
ctx.client.console(FREE_TIER_ONLY_STATUS_API, {
queryFreeTierOnlyStatusRequest: { models },
}),
]);
const quotas = extractQuotas(quotaResult)
.filter((quota) => quota.quotaStatus === "VALID" && quota.quotaInitTotal > 0)
.sort((left, right) => {
const pctLeft = left.quotaInitTotal ? left.quotaTotal / left.quotaInitTotal : 0;
const pctRight = right.quotaInitTotal ? right.quotaTotal / right.quotaInitTotal : 0;
return pctLeft - pctRight;
});
const stopStatuses = extractFreeTierOnlyStatuses(stopResult);
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
// --- Usage overview (requires workspace) ---
let overview: OverviewStatistic | undefined;
if (workspaceId) {
const result = await pollTelemetryApi(ctx.client, OVERVIEW_API, {
startTime,
endTime,
modelCallSource: "Online",
filterWorkspaceId: workspaceId,
});
if (!result) throw new BailianError("Request timed out.", ExitCode.TIMEOUT);
overview = extractOverviewData(result);
}
if (format === "json") {
const freeTier = quotas.map((quota: FreeTierQuota) => ({
model: quota.model,
type: typeMap.get(quota.model) || null,
remaining: quota.quotaTotal,
total: quota.quotaInitTotal,
remainingPercent: quotaRemainingPercent(quota),
expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null,
}));
emitResult(
{
period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag },
freeTier,
usage: overview
? {
modelsCalled: overview.modelCount ?? 0,
successfulCalls: overview.callSuccessCount ?? 0,
usages: (overview.usages ?? []).map((usage) => ({
key: usage.key,
value: usage.value,
unit: usage.unit,
label: USAGE_KEY_LABELS[usage.key]?.en ?? usage.key,
})),
}
: null,
},
format,
);
return;
}
const color = ansi(process.stdout);
process.stdout.write(
`${color.bold("Usage Summary")} ${color.dim("·")} ${formatDate(endTime)}\n\n`,
);
if (quotas.length === 0) {
process.stdout.write(color.dim("No active free-tier quota.") + "\n");
} else {
const visible = quotas.slice(0, TOP_N);
const remaining = quotas.length - visible.length;
printFreeTierTable({
quotas: visible,
stopMap,
typeMap,
title: `Free Tier Quota · ${quotas.length} models · sorted by urgency`,
moreHint:
remaining > 0
? `+ ${remaining} more · ${identity.binName} usage free to browse all`
: undefined,
});
}
process.stdout.write("\n");
if (!workspaceId) {
process.stdout.write(
color.dim(
`Usage overview skipped · set --workspace-id or \`${identity.binName} config set workspace_id <id>\` to include it.`,
) + "\n",
);
} else if (!overview) {
process.stdout.write(color.dim("No usage data in this period.") + "\n");
} else {
printUsageOverview(overview, startTime, endTime, daysFlag);
}
},
});
@@ -13,6 +13,7 @@ import {
resolveWatermark,
ASYNC_FLAG,
CONCURRENT_FLAG,
redactDataUri,
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
import { downloadFile, formatBytes } from "bailian-cli-runtime";
@@ -103,8 +104,9 @@ export default defineCommand({
const model =
flags.model ||
settings.defaultVideoModel ||
(flags.image ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v");
(flags.image
? settings.defaultImageToVideoModel || "happyhorse-1.1-i2v"
: settings.defaultVideoModel || "happyhorse-1.1-t2v");
const format = detectOutputFormat(settings.output);
const imageUrl = flags.image;
@@ -112,7 +114,7 @@ export default defineCommand({
// Auto-upload local image file for i2v
let resolvedImageUrl: string | undefined;
if (imageUrl) {
resolvedImageUrl = await ctx.client.uploadFile(imageUrl, model);
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
}
const watermark = resolveWatermark(flags.watermark);
@@ -139,7 +141,16 @@ export default defineCommand({
};
if (settings.dryRun) {
emitResult({ request: body }, format);
const previewBody = resolvedImageUrl
? {
...body,
input: {
...body.input,
media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }],
},
}
: body;
emitResult({ request: previewBody }, format);
return;
}
+25 -13
View File
@@ -13,6 +13,7 @@ import {
resolveWatermark,
ASYNC_FLAG,
CONCURRENT_FLAG,
redactDataUri,
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
import { downloadFile, formatBytes } from "bailian-cli-runtime";
@@ -117,23 +118,23 @@ export default defineCommand({
const imageVoices = flags.imageVoice || [];
const videoVoices = flags.videoVoice || [];
const model = flags.model || "happyhorse-1.1-r2v";
const model = flags.model || settings.defaultReferenceToVideoModel || "happyhorse-1.1-r2v";
const format = detectOutputFormat(settings.output);
// --- Resolve file URLs (auto-upload local files) ---
const media: DashScopeVideoRefRequest["input"]["media"] = [];
// Add reference images
for (let i = 0; i < images.length; i++) {
const resolved = await ctx.client.uploadFile(images[i]!, model);
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
const resolved = await ctx.client.resolveImageInput(images[imageIndex]!, model);
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
type: "reference_image",
url: resolved,
};
// Pair voice by position
if (imageVoices[i]) {
const resolvedVoice = await ctx.client.uploadFile(imageVoices[i]!, model);
if (imageVoices[imageIndex]) {
const resolvedVoice = await ctx.client.uploadFile(imageVoices[imageIndex]!, model);
entry.reference_voice = resolvedVoice;
}
@@ -141,16 +142,16 @@ export default defineCommand({
}
// Add reference videos
for (let i = 0; i < refVideos.length; i++) {
const resolved = await ctx.client.uploadFile(refVideos[i]!, model);
for (let videoIndex = 0; videoIndex < refVideos.length; videoIndex++) {
const resolved = await ctx.client.uploadFile(refVideos[videoIndex]!, model);
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
type: "reference_video",
url: resolved,
};
// Pair voice by position
if (videoVoices[i]) {
const resolvedVoice = await ctx.client.uploadFile(videoVoices[i]!, model);
if (videoVoices[videoIndex]) {
const resolvedVoice = await ctx.client.uploadFile(videoVoices[videoIndex]!, model);
entry.reference_voice = resolvedVoice;
}
@@ -178,7 +179,18 @@ export default defineCommand({
};
if (settings.dryRun) {
emitResult({ request: body }, format);
const previewBody = {
...body,
input: {
...body.input,
media: body.input.media.map((item) => ({
...item,
url: redactDataUri(item.url),
reference_voice: item.reference_voice ? redactDataUri(item.reference_voice) : undefined,
})),
},
};
emitResult({ request: previewBody }, format);
return;
}
@@ -233,11 +245,11 @@ export default defineCommand({
);
const videos: Array<{ taskId: string; videoUrl: string }> = [];
for (let i = 0; i < results.length; i++) {
const result = results[i]!;
for (let resultIndex = 0; resultIndex < results.length; resultIndex++) {
const result = results[resultIndex]!;
const videoUrl =
result.output.video_url || (result.output.results && result.output.results[0]?.url);
if (videoUrl) videos.push({ taskId: taskIds[i]!, videoUrl });
if (videoUrl) videos.push({ taskId: taskIds[resultIndex]!, videoUrl });
}
if (videos.length === 0) {
@@ -8,18 +8,13 @@ import {
BailianError,
ExitCode,
isLocalFile,
imageFileToDataUri,
redactDataUri,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { readFileSync, existsSync } from "fs";
import { existsSync, statSync } from "fs";
import { extname } from "path";
const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]);
function isVideoInput(input: string): boolean {
@@ -35,18 +30,7 @@ async function toImageUrl(image: string): Promise<string> {
if (image.startsWith("data:")) return image;
if (image.startsWith("http://") || image.startsWith("https://")) return image;
if (image.startsWith("oss://")) return image;
// Local file → data URI (for small files < 10MB, fallback)
if (!existsSync(image)) throw new BailianError(`File not found: ${image}`, ExitCode.USAGE);
const ext = extname(image).toLowerCase();
const mime = IMAGE_MIME_TYPES[ext];
if (!mime)
throw new BailianError(
`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`,
ExitCode.USAGE,
);
const buf = readFileSync(image);
return `data:${mime};base64,${buf.toString("base64")}`;
return imageFileToDataUri(image);
}
export default defineCommand({
@@ -86,7 +70,10 @@ export default defineCommand({
const { settings, flags } = ctx;
let image = flags.image;
const videoInputs = flags.video ?? [];
const model = flags.model || "qwen3-vl-plus";
const model =
flags.model ||
(ctx.client.usesTokenPlanEndpoint() ? settings.defaultTextModel : undefined) ||
"qwen3-vl-plus";
// Auto-detect: if --image was given a video file, treat it as --video
if (image && isVideoInput(image)) {
@@ -102,7 +89,14 @@ export default defineCommand({
if (settings.dryRun) {
emitResult(
{ request: { prompt, image, video: videoInputs.length ? videoInputs : undefined, model } },
{
request: {
prompt,
image: image ? redactDataUri(image) : undefined,
video: videoInputs.length ? videoInputs.map(redactDataUri) : undefined,
model,
},
},
format,
);
return;
@@ -132,10 +126,9 @@ export default defineCommand({
let finalImageUrl = imageUrl;
if (isLocalFile(image) && imageUrl.startsWith("data:")) {
const { statSync } = await import("fs");
const fileSize = statSync(image).size;
if (fileSize > 5 * 1024 * 1024) {
finalImageUrl = await ctx.client.uploadFile(image, model);
finalImageUrl = await ctx.client.resolveImageInput(image, model);
}
}
@@ -0,0 +1,334 @@
import {
defineCommand,
detectOutputFormat,
BailianError,
ExitCode,
generateCLIAccessToken,
callConsoleGateway,
effectiveConsoleGatewayConfig,
createBailianControlUser,
listBailianControlWorkspaces,
resetBailianControlPolicies4Agent,
type ConsoleGatewayTarget,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const API = {
loginInfo: "zeldaEasy.cornerstone-portal.cs-console.loginInfo",
initSpace: "zeldaEasy.bailian-dash-workspace.space.initSpace",
queryBuyResult: "zeldaEasy.bailian-commerce.bill.queryBuyPostpaidResult",
commodityOrderInfo: "zeldaEasy.bailian-commerce.bill.postpaidCommodityOrderInfo",
buyCommodity: "zeldaEasy.bailian-commerce.bill.buyPostpaidCommodity",
} as const;
const FLAGS = {
accessKeyId: {
type: "string",
valueHint: "<id>",
description: "Alibaba Cloud Access Key ID",
},
accessKeySecret: {
type: "string",
valueHint: "<secret>",
description: "Alibaba Cloud Access Key Secret",
},
securityToken: {
type: "string",
valueHint: "<token>",
description: "Alibaba Cloud STS Security Token (optional)",
},
} satisfies FlagsDef;
const POLL_INTERVAL_MS = 1000;
const MAX_POLL_ATTEMPTS = 20;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function extractData(resp: any): any {
return resp?.data?.DataV2?.data?.data;
}
/**
* Resolve the agent id from a ListWorkspaces response. Workspaces live under
* `data.data`; the agent id is the workspace's `tenantId`. Prefer the default
* workspace (`defaultAgent`), else fall back to the first one.
*/
function extractAgentId(resp: any): number | undefined {
const workspaces = resp?.data?.data;
if (!Array.isArray(workspaces) || workspaces.length === 0) return undefined;
const chosen = workspaces.find((workspace) => workspace?.defaultAgent === true) ?? workspaces[0];
const tenantId = chosen?.tenantId;
const agentId = typeof tenantId === "string" ? Number(tenantId) : tenantId;
return typeof agentId === "number" && Number.isFinite(agentId) ? agentId : undefined;
}
interface CommodityItem {
commodityCode?: string;
status?: number;
}
export default defineCommand({
description: "Initialize Bailian workspace and activate postpaid services",
auth: "none",
usageArgs: "--access-key-id <id> --access-key-secret <secret> [--security-token <token>]",
flags: FLAGS,
exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
apis: [
{
step: 0,
api: "GenerateCLIAccessToken",
description: "Generate CLI access token from AK/SK",
},
{
step: 1,
api: API.loginInfo,
description: "Check login & workspace status",
},
{
step: 2,
api: API.initSpace,
description: "Initialize workspace (if needed)",
},
{
step: 3,
api: "CreateUser",
description: "Create console user via BailianControl OpenAPI (CreateUser)",
},
{
step: 4,
api: "ListWorkspaces",
description: "List workspaces to resolve agentId",
},
{
step: 5,
api: "ResetPolicies4Agent",
description: "Authorize user permissions",
},
{
step: 6,
api: API.queryBuyResult,
description: "Query postpaid order status",
},
{
step: 7,
api: API.commodityOrderInfo,
description: "Query commodity activation status",
},
{
step: 8,
api: API.buyCommodity,
description: "Activate postpaid commodities (if needed)",
},
],
},
format,
);
return;
}
const { accessKeyId, accessKeySecret } = flags;
if (!accessKeyId || !accessKeySecret) {
throw new BailianError(
"workspace init requires --access-key-id and --access-key-secret.",
ExitCode.USAGE,
);
}
const securityToken = flags.securityToken || undefined;
// Step 0: Exchange AK/SK for a temporary CLI access token used by console calls.
const tokenResp = await generateCLIAccessToken({
identity: ctx.identity,
settings,
baseUrl: ctx.client.baseUrl,
accessKeyId,
accessKeySecret,
securityToken,
});
const accessToken: string | undefined = tokenResp.cliAccessToken;
if (!accessToken) {
throw new BailianError("Failed to generate CLI access token from AK/SK.", ExitCode.GENERAL);
}
const gateway = effectiveConsoleGatewayConfig(settings);
const target: ConsoleGatewayTarget = {
region: gateway.consoleRegion,
site: gateway.consoleSite,
...(gateway.consoleSwitchAgent != null ? { switchAgent: gateway.consoleSwitchAgent } : {}),
token: accessToken,
};
const verbose = settings.verbose;
const callApi = async (api: string, data: Record<string, unknown> = {}) => {
if (verbose) process.stderr.write(`> ${api}\n`);
try {
const resp = await callConsoleGateway(target, settings.timeout, { api, data }, settings);
if (verbose) process.stderr.write(`< ${JSON.stringify(resp)}\n`);
return resp;
} catch (err) {
if (verbose) {
const message =
err instanceof BailianError ? (err.rawResponse ?? err.message) : String(err);
process.stderr.write(`< ERROR: ${message}\n`);
}
throw err;
}
};
// Step 1: Check login info
emitBare("Checking workspace status...");
const loginResp = await callApi(API.loginInfo);
const loginData = extractData(loginResp);
const spaceInited = loginData?.spaceInited === true;
// Step 2: Init space if needed
if (!spaceInited) {
emitBare("Initializing workspace...");
await callApi(API.initSpace);
emitBare("Workspace initialized.");
} else {
emitBare("Workspace already initialized.");
}
// Step 3: Create console user via BailianControl OpenAPI (AK/SK signed)
const uid = loginData?.aliyun?.uid;
if (typeof uid !== "string" || uid.length === 0) {
throw new BailianError("Console login info did not include aliyun.uid.", ExitCode.GENERAL);
}
const bailianControlAuth = {
identity: ctx.identity,
settings,
baseUrl: ctx.client.baseUrl,
regionId: gateway.consoleRegion,
accessKeyId,
accessKeySecret,
securityToken,
};
try {
await createBailianControlUser({
...bailianControlAuth,
reqDTO: {
outerKey: uid,
nickName: uid,
userName: uid,
},
});
} catch (err) {
// Re-running workspace init is idempotent: an already-existing user is
// not fatal, so swallow it and continue with the remaining steps.
if (!(err instanceof BailianError) || !/already exists/i.test(err.message)) {
throw err;
}
emitBare("Console user already exists, continuing.");
}
// Step 4-5: Resolve the workspace agent id, then authorize user permissions.
emitBare("Resolving workspace agent...");
const workspacesResp = await listBailianControlWorkspaces(bailianControlAuth);
const agentId = extractAgentId(workspacesResp);
if (agentId == null) {
throw new BailianError(
"Could not resolve agentId from ListWorkspaces response.",
ExitCode.GENERAL,
"Re-run with --verbose to inspect the ListWorkspaces response body.",
);
}
emitBare("Authorizing user permissions...");
await resetBailianControlPolicies4Agent({
...bailianControlAuth,
outerKey: uid,
agentId,
policyIndexList: [1],
});
// Step 6-8: Order & commodity flow
await ensureCommoditiesActive(callApi, format);
},
});
type ApiCall = (api: string, data?: Record<string, unknown>) => Promise<any>;
async function ensureCommoditiesActive(call: ApiCall, format: "text" | "json"): Promise<void> {
emitBare("Checking service activation status...");
let buyResult: string | undefined;
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
const resp = await call(API.queryBuyResult);
const data = extractData(resp);
buyResult = typeof data === "string" ? data : data?.result;
if (buyResult !== "buying") break;
if (i === 0) emitBare("Service activation in progress, polling...");
await sleep(POLL_INTERVAL_MS);
}
if (buyResult === "fail") {
throw new BailianError("Service activation failed.", ExitCode.GENERAL);
}
if (buyResult === "success") {
await pollCommoditiesUntilActive(call, format);
return;
}
await checkAndActivateCommodities(call, format);
}
function extractCommodities(resp: any): CommodityItem[] {
const data = extractData(resp);
return Array.isArray(data) ? data : [];
}
async function checkAndActivateCommodities(call: ApiCall, format: "text" | "json"): Promise<void> {
const resp = await call(API.commodityOrderInfo);
const items = extractCommodities(resp);
const overdue = items.filter((c) => c.status === 11);
if (overdue.length > 0) {
emitBare("Warning: Some services are overdue:");
for (const c of overdue) emitBare(` - ${c.commodityCode}`);
}
const notActivated = items.filter((c) => c.status === 1);
if (notActivated.length > 0) {
emitBare(`Activating ${notActivated.length} postpaid services...`);
await call(API.buyCommodity);
await pollCommoditiesUntilActive(call, format);
return;
}
const active = items.filter((c) => c.status === 10);
emitResult({ status: "ready", activeServices: active.map((c) => c.commodityCode) }, format);
}
async function pollCommoditiesUntilActive(call: ApiCall, format: "text" | "json"): Promise<void> {
emitBare("Waiting for services to activate...");
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
const resp = await call(API.commodityOrderInfo);
const items = extractCommodities(resp);
const pending = items.filter((c) => c.status !== 10 && c.status !== 11);
if (pending.length === 0) {
const overdue = items.filter((c) => c.status === 11);
if (overdue.length > 0) {
emitBare("Warning: Some services are overdue:");
for (const c of overdue) emitBare(` - ${c.commodityCode}`);
}
const active = items.filter((c) => c.status === 10);
emitResult({ status: "ready", activeServices: active.map((c) => c.commodityCode) }, format);
return;
}
await sleep(POLL_INTERVAL_MS);
}
throw new BailianError("Timed out waiting for services to activate.", ExitCode.TIMEOUT);
}
+22 -2
View File
@@ -6,6 +6,7 @@
export { default as authLogin } from "./commands/auth/login.ts";
export { default as authStatus } from "./commands/auth/status.ts";
export { default as authLogout } from "./commands/auth/logout.ts";
export { default as authGenerateAccessToken } from "./commands/auth/generate-access-token.ts";
export { default as textChat } from "./commands/text/chat.ts";
export { default as textOmni } from "./commands/omni/chat.ts";
export { default as imageGenerate } from "./commands/image/generate.ts";
@@ -18,6 +19,10 @@ export { default as videoDownload } from "./commands/video/download.ts";
export { default as visionDescribe } from "./commands/vision/describe.ts";
export { default as configShow } from "./commands/config/show.ts";
export { default as configSet } from "./commands/config/set.ts";
export { default as configList } from "./commands/config/list.ts";
export { default as configUse } from "./commands/config/use.ts";
export { default as configUi } from "./commands/config/ui.ts";
export { default as configAgent } from "./commands/config/agent/index.ts";
export { default as update } from "./commands/update.ts";
export { default as appCall } from "./commands/app/call.ts";
export { default as appList } from "./commands/app/list.ts";
@@ -42,9 +47,11 @@ export { default as consoleCall } from "./commands/console/call.ts";
export { default as usageFree } from "./commands/usage/free.ts";
export { default as usageFreetier } from "./commands/usage/freetier.ts";
export { default as usageStats } from "./commands/usage/stats.ts";
export { default as usageSummary } from "./commands/usage/summary.ts";
export { default as pipelineRun } from "./commands/pipeline/run.ts";
export { default as pipelineValidate } from "./commands/pipeline/validate.ts";
export { default as advisorRecommend } from "./commands/advisor/recommend.ts";
export { default as modelList } from "./commands/model/list.ts";
export { default as workspaceList } from "./commands/workspace/list.ts";
export { default as quotaList } from "./commands/quota/list.ts";
export { default as quotaRequest } from "./commands/quota/request.ts";
@@ -55,7 +62,11 @@ export { default as datasetList } from "./commands/dataset/list.ts";
export { default as datasetGet } from "./commands/dataset/get.ts";
export { default as datasetDelete } from "./commands/dataset/delete.ts";
export { default as datasetValidate } from "./commands/dataset/validate.ts";
export { default as finetuneCreate } from "./commands/finetune/create.ts";
export {
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
} from "./commands/finetune/create.ts";
export { default as finetuneList } from "./commands/finetune/list.ts";
export { default as finetuneGet } from "./commands/finetune/get.ts";
export { default as finetuneCancel } from "./commands/finetune/cancel.ts";
@@ -65,7 +76,11 @@ export { default as finetuneCheckpoints } from "./commands/finetune/checkpoints.
export { default as finetuneExport } from "./commands/finetune/export.ts";
export { default as finetuneWatch } from "./commands/finetune/watch.ts";
export { default as finetuneCapability } from "./commands/finetune/capability.ts";
export { default as deployCreate } from "./commands/deploy/create.ts";
export {
deployTextCreate,
deployAudioCreate,
deployImageCreate,
} from "./commands/deploy/create.ts";
export { default as deployList } from "./commands/deploy/list.ts";
export { default as deployGet } from "./commands/deploy/get.ts";
export { default as deployModels } from "./commands/deploy/models.ts";
@@ -76,3 +91,8 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as workspaceInit } from "./commands/workspace/init.ts";
export { default as pluginInstall } from "./commands/plugin/install.ts";
export { default as pluginLink } from "./commands/plugin/link.ts";
export { default as pluginList } from "./commands/plugin/list.ts";
export { default as pluginRemove } from "./commands/plugin/remove.ts";
@@ -1,32 +0,0 @@
import { readdirSync, readFileSync, statSync } from "fs";
import { join } from "path";
import { expect, test } from "vite-plus/test";
// 能力面边界(重构 §6 的 lint 规则):configStore() 仅 config 命令族、authStore()
// 仅 auth 命令族可用;业务命令只依赖 settings/flags/client。
const ROOT = join(import.meta.dirname, "../src/commands");
function walk(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (p.endsWith(".ts")) out.push(p);
}
return out;
}
test("configStore() 仅在 commands/config/** 使用", () => {
for (const file of walk(ROOT)) {
if (file.includes("/config/")) continue;
expect(readFileSync(file, "utf8").includes("configStore("), file).toBe(false);
}
});
test("authStore() 仅在 commands/auth/** 使用", () => {
for (const file of walk(ROOT)) {
if (file.includes("/auth/")) continue;
expect(readFileSync(file, "utf8").includes("authStore("), file).toBe(false);
}
});
@@ -0,0 +1,239 @@
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
import { tmpdir, homedir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
import claudeCode from "../src/commands/config/agent/writers/claude-code.ts";
import qwenCode from "../src/commands/config/agent/writers/qwen-code.ts";
import opencode from "../src/commands/config/agent/writers/opencode.ts";
import openclaw from "../src/commands/config/agent/writers/openclaw.ts";
import hermes from "../src/commands/config/agent/writers/hermes.ts";
import codex from "../src/commands/config/agent/writers/codex.ts";
import yaml from "yaml";
/**
* Agent writer 单元测试:直接调用 writer用临时 HOME 隔离文件系统。
* writer 是纯文件 I/O在进程内测试比 e2e 子进程更快、覆盖更全。
*/
const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic";
let home = "";
let prevHome: string | undefined;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "bl-agent-writer-"));
prevHome = process.env.HOME;
process.env.HOME = home;
// homedir() 在 POSIX 读 $HOME断言隔离生效。
expect(homedir()).toBe(home);
});
afterEach(() => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(home, { recursive: true, force: true });
});
function readJsonAt(...segments: string[]): Record<string, unknown> {
return JSON.parse(readFileSync(join(home, ...segments), "utf8"));
}
describe("config agent writers", () => {
test("claude-code 写入 env 与 onboarding并合并已有 env", () => {
// 预置一个无关 env 键,验证合并保留
mkdirSync(join(home, ".claude"), { recursive: true });
writeFileSync(
join(home, ".claude", "settings.json"),
JSON.stringify({ env: { KEEP_ME: "1" }, other: true }),
);
const summary = claudeCode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-a",
model: "qwen3-max",
});
expect(summary.paths).toHaveLength(2);
const settings = readJsonAt(".claude", "settings.json");
const env = settings.env as Record<string, string>;
expect(env.KEEP_ME).toBe("1");
expect(settings.other).toBe(true);
expect(env.ANTHROPIC_BASE_URL).toBe(ANTHROPIC_URL);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
expect(env.ANTHROPIC_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("qwen3-max");
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3-max");
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
});
test("qwen-code compatible-mode 走 openai 协议", () => {
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" });
const settings = readJsonAt(".qwen", "settings.json");
const security = settings.security as { auth: Record<string, string> };
expect(security.auth.selectedType).toBe("openai");
expect(security.auth.apiKey).toBe("sk-q");
expect(security.auth.baseUrl).toBe(OAI_URL);
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
expect((settings.model as Record<string, string>).name).toBe("qwen3-coder-plus");
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
expect(providers.openai[0]).toMatchObject({
id: "qwen3-coder-plus",
name: "bailian-cli",
baseUrl: OAI_URL,
envKey: "BAILIAN_CLI_API_KEY",
});
});
test("qwen-code anthropic 端点走 anthropic 协议", () => {
qwenCode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-q", model: "qwen3-max" });
const settings = readJsonAt(".qwen", "settings.json");
expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe(
"anthropic",
);
const providers = settings.modelProviders as Record<string, unknown>;
expect(Array.isArray(providers.anthropic)).toBe(true);
expect(providers.openai).toBeUndefined();
});
test("qwen-code 对相同 id+baseUrl 的 provider 项做 upsert 而非追加", () => {
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" });
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" });
const settings = readJsonAt(".qwen", "settings.json");
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>).openai;
expect(openaiEntries).toHaveLength(1);
});
test("opencode 按端点选 npm含 setCacheKey合并保留其它 provider", () => {
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
writeFileSync(
join(home, ".config", "opencode", "opencode.json"),
JSON.stringify({ provider: { other: { name: "Other" } } }),
);
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
const config = readJsonAt(".config", "opencode", "opencode.json");
const provider = config.provider as Record<string, Record<string, unknown>>;
expect(provider.other).toBeDefined();
expect(provider["bailian-cli"].npm).toBe("@ai-sdk/anthropic");
const options = provider["bailian-cli"].options as Record<string, unknown>;
expect(options.baseURL).toBe(ANTHROPIC_URL);
expect(options.apiKey).toBe("sk-o");
expect(options.setCacheKey).toBe(true);
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
// 非 anthropic 端点用 openai-compatible
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
expect(
(
readJsonAt(".config", "opencode", "opencode.json").provider as Record<
string,
{ npm: string }
>
)["bailian-cli"].npm,
).toBe("@ai-sdk/openai-compatible");
});
test("openclaw 写入 provider、api 与 primary", () => {
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
const config = readJsonAt(".openclaw", "openclaw.json");
const models = config.models as Record<string, unknown>;
expect(models.mode).toBe("merge");
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
expect(bailian.api).toBe("openai-completions");
expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus");
const agents = config.agents as { defaults: { model: { primary: string } } };
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
// anthropic 端点用 anthropic-messages
openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" });
const config2 = readJsonAt(".openclaw", "openclaw.json");
expect(
((config2.models as Record<string, unknown>).providers as Record<string, { api: string }>)[
"bailian-cli"
].api,
).toBe("anthropic-messages");
});
test("hermes 写入 custom_providers 与 model合并保留其它 provider", () => {
mkdirSync(join(home, ".hermes"), { recursive: true });
writeFileSync(
join(home, ".hermes", "config.yaml"),
yaml.stringify({ custom_providers: [{ name: "other", base_url: "https://x" }] }),
);
hermes.write({ baseUrl: OAI_URL, apiKey: "sk-h", model: "qwen3-coder-plus" });
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" });
const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name);
expect(names).toContain("other");
const entry = (config.custom_providers as Array<Record<string, unknown>>).find(
(provider) => provider.name === "bailian-cli",
)!;
expect(entry.base_url).toBe(OAI_URL);
expect(entry.api_key).toBe("sk-h");
expect(entry.api_mode).toBe("chat_completions");
// anthropic 端点用 anthropic_messages
hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" });
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
const entry2 = (config2.custom_providers as Array<Record<string, unknown>>).find(
(provider) => provider.name === "bailian-cli",
)!;
expect(entry2.api_mode).toBe("anthropic_messages");
// upsertbailian-cli 项不重复
expect(
(config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"),
).toHaveLength(1);
});
test("codex 写入 config.toml 与 auth.jsoncc-switch 对齐结构,合并保留)", () => {
// 预置 config.toml 无关顶层键与另一个 provider验证非破坏性合并
mkdirSync(join(home, ".codex"), { recursive: true });
writeFileSync(
join(home, ".codex", "config.toml"),
[
'approval_policy = "on-request"',
"",
"[model_providers.other]",
'name = "other"',
'base_url = "https://other.example.com/v1"',
"",
].join("\n"),
);
// 预置 auth.json 无关键,验证合并保留
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" });
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml).toContain('model_provider = "bailian-cli"');
expect(toml).toContain('model = "qwen3-coder-plus"');
expect(toml).toContain('model_reasoning_effort = "high"');
expect(toml).toContain("disable_response_storage = true");
expect(toml).toContain("[model_providers.bailian-cli]");
expect(toml).toContain(`base_url = "${OAI_URL}"`);
expect(toml).toContain('wire_api = "responses"');
expect(toml).toContain("requires_openai_auth = true");
// 合并:保留用户已有的无关配置
expect(toml).toContain('approval_policy = "on-request"');
expect(toml).toContain("[model_providers.other]");
const auth = readJsonAt(".codex", "auth.json");
expect(auth.OPENAI_API_KEY).toBe("sk-x");
expect(auth.EXISTING).toBe("keep");
});
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 }));
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" });
const backups = readdirSync(join(home, ".openclaw")).filter((name) =>
name.startsWith("openclaw.json.bak."),
);
expect(backups).toHaveLength(1);
});
});
+237
View File
@@ -0,0 +1,237 @@
import http from "node:http";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vite-plus/test";
import {
activateConfigProfile,
getConfigPath,
makeConfigStore,
writeConfigFile,
readConfigFile,
readConfigProfiles,
} from "bailian-cli-core";
import { createConfigUiServer } from "../src/commands/config/ui.ts";
const TOKEN = "test-token";
interface HttpResult {
status: number;
json: any;
text: string;
}
function httpJson(
port: number,
method: string,
path: string,
opts?: { body?: unknown; headers?: Record<string, string> },
): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const payload = opts?.body !== undefined ? JSON.stringify(opts.body) : undefined;
const headers: Record<string, string> = { ...opts?.headers };
if (payload) headers["Content-Type"] = "application/json";
const req = http.request({ host: "127.0.0.1", port, method, path, headers }, (res) => {
let d = "";
res.on("data", (c) => (d += c));
res.on("end", () => {
let json: unknown = null;
try {
json = d ? JSON.parse(d) : null;
} catch {
json = null;
}
resolve({ status: res.statusCode ?? 0, json, text: d });
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
/** 隔离临时配置目录 + 启动 UI server跑完清理。 */
async function withServer(fn: (port: number) => Promise<void>): Promise<void> {
const saved = process.env.BAILIAN_CONFIG_DIR;
const dir = mkdtempSync(join(tmpdir(), "bl-ui-"));
process.env.BAILIAN_CONFIG_DIR = dir;
const server = createConfigUiServer(TOKEN, makeConfigStore());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const addr = server.address();
const port = addr && typeof addr === "object" ? addr.port : 0;
try {
await fn(port);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
else process.env.BAILIAN_CONFIG_DIR = saved;
rmSync(dir, { recursive: true, force: true });
}
}
test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", async () => {
await withServer(async (port) => {
await writeConfigFile({ api_key: "sk-default", output: "json" });
await writeConfigFile({ api_key: "sk-dev", access_token: "tok-dev" }, "dev");
await activateConfigProfile("dev");
const res = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
expect(res.status).toBe(200);
expect(res.json.activeProfile).toBe("dev");
expect(res.json.default).toMatchObject({ api_key: "sk-default", output: "json" });
expect(res.json.named.dev).toMatchObject({ api_key: "sk-dev", access_token: "tok-dev" });
expect(res.json.secretKeys).toContain("api_key");
expect(res.json.keys).toContain("default_image_to_video_model");
expect(res.json.keys).toContain("default_reference_to_video_model");
});
});
test("鉴权:错误 token 401、非 loopback Host 403", async () => {
await withServer(async (port) => {
const bad = await httpJson(port, "GET", `/api/config?token=wrong`);
expect(bad.status).toBe(401);
const badHost = await httpJson(port, "GET", `/api/config?token=${TOKEN}`, {
headers: { Host: "evil.com" },
});
expect(badHost.status).toBe(403);
});
});
test("POST /api/profile 写命名 profiletimeout 强制为 number空串清除键", async () => {
await withServer(async (port) => {
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: {
name: "stage",
data: {
api_key: "sk-stage",
timeout: "90",
base_url: "https://proxy.example.com/team/compatible-mode/v1/?x=1#fragment",
},
},
});
expect(save.status).toBe(200);
expect(readConfigFile("stage")).toMatchObject({
api_key: "sk-stage",
timeout: 90,
base_url: "https://proxy.example.com",
});
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
expect(rawConfig.stage.base_url).toBe("https://proxy.example.com");
// 空串清除 api_key整块替换
const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "", timeout: "120" } },
});
expect(clear.status).toBe(200);
const after = readConfigFile("stage");
expect(after.api_key).toBeUndefined();
expect(after.timeout).toBe(120);
});
});
test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => {
await withServer(async (port) => {
await writeConfigFile(
{
api_key: "sk-old",
output: "json",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
},
"stage",
);
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "sk-new" } },
});
expect(save.status).toBe(200);
const profile = readConfigFile("stage");
expect(profile).toMatchObject({
api_key: "sk-new",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
});
expect(profile.output).toBeUndefined();
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
expect(rawConfig.stage).toMatchObject({
api_key: "sk-new",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
});
expect(rawConfig.stage.output).toBeUndefined();
});
});
test("New profile 立即保存空 Profile其他配置读取可以看到", async () => {
await withServer(async (port) => {
const create = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "new-profile", data: {} },
});
expect(create.status).toBe(200);
expect(create.json.saved).toEqual({});
expect(readConfigProfiles().named["new-profile"]).toEqual({});
const list = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
expect(list.status).toBe(200);
expect(list.json.named["new-profile"]).toEqual({});
});
});
test("POST /api/profile 非法 key 返回 400", async () => {
await withServer(async (port) => {
const res = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { not_a_key: "x" } },
});
expect(res.status).toBe(400);
expect(String(res.json.error)).toMatch(/Invalid config key/);
});
});
test("DELETE /api/profile 删命名 profile缺 name 返回 400", async () => {
await withServer(async (port) => {
await writeConfigFile({ api_key: "sk-stage" }, "stage");
const del = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
expect(del.status).toBe(200);
expect(del.json.deleted).toBe(true);
expect(readConfigProfiles().named.stage).toBeUndefined();
const noName = await httpJson(port, "DELETE", `/api/profile?token=${TOKEN}`);
expect(noName.status).toBe(400);
});
});
test("Save & Activate 创建并激活 Profile删除激活项后切回 default", async () => {
await withServer(async (port) => {
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "sk-stage" } },
});
expect(save.status).toBe(200);
const activate = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
body: { name: "stage" },
});
expect(activate.status).toBe(200);
expect(activate.json.activeProfile).toBe("stage");
expect(readConfigProfiles().active).toBe("stage");
const missing = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
body: { name: "missing" },
});
expect(missing.status).toBe(400);
expect(readConfigProfiles().active).toBe("stage");
const deleted = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
expect(deleted.status).toBe(200);
expect(deleted.json.activeProfile).toBe("default");
expect(readConfigProfiles().active).toBe("default");
});
});
@@ -1,15 +1,14 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { ADVISOR_ROUTES } from "./topic-routes.ts";
describe("e2e: advisor recommend", () => {
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 exits successfully", async () => {
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
const { stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recommend|--message|dry-run/i);
});
@@ -17,13 +16,17 @@ describe("e2e: advisor recommend", () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => {
test("advisor recommend without --message errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCli(["advisor", "recommend", "--quiet"]);
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
});
test("advisor recommend --dry-run outputs intent analysis and candidates", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
@@ -63,7 +66,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
}, 60_000);
test("advisor recommend full flow returns results", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--message",
@@ -93,10 +96,10 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
expect(data.result?.recommendations?.[0]?.highlights?.length).toBeGreaterThan(0);
}, 120_000);
// ---- Model preference: positive cases ----
// ---- Mode coverage: all 4 modes ----
test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("mode: scoped — family-scoped query sets mode=scoped with targets", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
@@ -109,19 +112,20 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
const data = parseStdoutJson<{
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
}>(stdout);
// Model preference detection depends on LLM interpretation
// Accept either "scoped" or "unconstrained" as valid
const mode = data.intent?.modelPreference?.mode;
expect(mode === "scoped" || mode === "unconstrained" || mode === undefined).toBe(true);
const pref = data.intent?.modelPreference;
expect(pref?.mode).toBe("scoped");
expect(pref?.targets?.length).toBeGreaterThan(0);
// Should contain "deepseek" (case-insensitive substring)
expect(pref?.targets?.some((t) => t.toLowerCase().includes("deepseek"))).toBe(true);
}, 60_000);
test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("mode: comparison — comparing two models sets mode=comparison with both targets", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
"--message",
"Which is better for code generation, qwen-max or deepseek-v3?",
"Compare qwen-max and deepseek-v3 for legal contract review, high precision required",
"--output",
"json",
]);
@@ -129,19 +133,41 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
const data = parseStdoutJson<{
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
}>(stdout);
// Model preference detection depends on LLM interpretation
// Accept either "comparison" or "unconstrained" as valid
const mode = data.intent?.modelPreference?.mode;
expect(mode === "comparison" || mode === "unconstrained" || mode === undefined).toBe(true);
const pref = data.intent?.modelPreference;
expect(pref?.mode).toBe("comparison");
expect(pref?.targets?.length).toBeGreaterThanOrEqual(2);
const targetsLower = pref?.targets?.map((t) => t.toLowerCase()) ?? [];
expect(targetsLower.some((t) => t.includes("qwen"))).toBe(true);
expect(targetsLower.some((t) => t.includes("deepseek"))).toBe(true);
}, 60_000);
test("excludes preference — intent detects modelPreference when excluding models", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("mode: alternative — reference model query sets mode=alternative with target", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
"--message",
"Not qwen, recommend a model suitable for text generation",
"Something like qwen-max but cheaper, for text summarization",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
}>(stdout);
const pref = data.intent?.modelPreference;
expect(pref?.mode).toBe("alternative");
expect(pref?.targets?.length).toBeGreaterThan(0);
expect(pref?.targets?.some((t) => t.toLowerCase().includes("qwen"))).toBe(true);
}, 60_000);
test("mode: excludes — excluding a family populates excludes array", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
"--message",
"Recommend a model for text generation, but not qwen",
"--output",
"json",
]);
@@ -154,19 +180,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", ()
};
};
}>(stdout);
// Model preference detection depends on LLM interpretation
// If excludes is detected, verify it contains qwen; otherwise accept as valid
const pref = data.intent?.modelPreference;
if (pref?.excludes && pref.excludes.length > 0) {
expect(pref.excludes.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
}
// Test passes if exit code is 0, regardless of whether excludes was detected
expect(pref?.excludes?.length).toBeGreaterThan(0);
expect(pref?.excludes?.some((e) => e.toLowerCase().includes("qwen"))).toBe(true);
}, 60_000);
// ---- Model preference: negative cases ----
test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => {
const { stdout, stderr, exitCode } = await runCli([
test("mode: unconstrained — generic query has no modelPreference", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(ADVISOR_ROUTES, [
"advisor",
"recommend",
"--dry-run",
@@ -0,0 +1,670 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "os";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
import {
isDashScopeE2EReady,
isOpenApiE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { AUTH_ROUTES } from "./topic-routes.ts";
interface ValidationServer {
baseUrl: string;
requests: Array<{
path: string;
body: Record<string, unknown>;
authorization?: string;
sourceConfig?: string;
}>;
close(): Promise<void>;
}
async function startValidationServer(statusCode = 200): Promise<ValidationServer> {
const requests: ValidationServer["requests"] = [];
const server = http.createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
requests.push({
path: request.url ?? "",
body: rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {},
authorization: request.headers.authorization,
sourceConfig: request.headers["x-dashscope-source-config"] as string | undefined,
});
response.writeHead(statusCode, { "Content-Type": "application/json" });
if (statusCode >= 400) {
response.end(JSON.stringify({ code: "InvalidApiKey", message: "invalid key" }));
return;
}
response.end(
JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
/** Auth E2E本地参数/持久化契约默认执行;真实鉴权请求按对应 readiness gate 执行。 */
describe("e2e: auth", () => {
test("auth login --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/login|api-key/i);
expect(stderr).toMatch(/--console-site/);
expect(stderr).toMatch(/--open-api/);
});
test("auth login 一次只能选择一种登录模式", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--console",
"--api-key",
"sk-e2e-placeholder",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Choose exactly one login mode/);
});
test("auth login 模式专属参数不能脱离对应模式", async () => {
const openApiFlagOnly = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--access-key-id",
"LTAI-e2e",
]);
expect(openApiFlagOnly.exitCode).toBe(2);
expect(openApiFlagOnly.stderr).toMatch(/Use --open-api with --access-key-id/);
const baseUrlWithoutApiKey = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--console",
"--base-url",
"https://dashscope.aliyuncs.com",
]);
expect(baseUrlWithoutApiKey.exitCode).toBe(2);
expect(baseUrlWithoutApiKey.stderr).toMatch(/Use --base-url only with --api-key/);
const consoleSiteWithoutConsole = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--api-key",
"sk-e2e-placeholder",
"--console-site",
"international",
]);
expect(consoleSiteWithoutConsole.exitCode).toBe(2);
expect(consoleSiteWithoutConsole.stderr).toMatch(/Use --console-site only with --console/);
});
test("auth login --open-api 要求 AK/SK 成对输入", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--open-api",
"--access-key-id",
"LTAI-e2e",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/);
});
test("auth login --open-api --dry-run 使用 placeholder 时不请求服务端、不写配置", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-dry-run-"));
try {
const { stdout, stderr, exitCode } = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--open-api",
"--access-key-id",
"LTAI-e2e-placeholder",
"--access-key-secret",
"secret-e2e-placeholder",
"--dry-run",
],
{
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
},
);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Would save OpenAPI AK/SK credentials");
expect(existsSync(join(configDir, "config.json"))).toBe(false);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("auth logout --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/logout|dry-run|yes/i);
});
test("auth status --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/status|output/i);
});
test("auth login 缺少 --api-key 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "login", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/Choose exactly one login mode/);
});
test("auth login --dry-run --api-key 不发起校验与落盘", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--dry-run",
"--api-key",
"sk-e2e-dry-run-placeholder",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Would validate and save API key.");
});
test("auth login --dry-run 仍校验显式 Base URL", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--dry-run",
"--api-key",
"sk-e2e-dry-run-placeholder",
"--base-url",
"ftp://example.com/models",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid model base URL/);
});
test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => {
const validationServer = await startValidationServer();
const configDir = makeE2eOutputDir("auth-api-key-login");
const sdkBaseUrl = `${validationServer.baseUrl}/compatible-mode/v1/?source=login#fragment`;
try {
const login = await runCommandE2e(
AUTH_ROUTES,
["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(login.exitCode, login.stderr).toBe(0);
expect(validationServer.requests).toHaveLength(1);
expect(validationServer.requests[0]).toMatchObject({
path: "/compatible-mode/v1/chat/completions",
authorization: "Bearer sk-e2e-placeholder",
sourceConfig: expect.any(String),
body: {
model: "qwen3.7-max",
stream: false,
enable_thinking: false,
},
});
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config.api_key).toBe("sk-e2e-placeholder");
expect(config.base_url).toBe(validationServer.baseUrl);
} finally {
await validationServer.close();
}
});
test("auth login --config token-plan 接受 Anthropic SDK Base URL", async () => {
const validationServer = await startValidationServer();
const configDir = makeE2eOutputDir("auth-token-plan-anthropic-base-url");
try {
const login = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--config",
"token-plan",
"--api-key",
"sk-sp-e2e-placeholder",
"--base-url",
`${validationServer.baseUrl}/apps/anthropic?source=sdk#fragment`,
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(login.exitCode, login.stderr).toBe(0);
expect(validationServer.requests).toHaveLength(1);
expect(validationServer.requests[0].path).toBe("/compatible-mode/v1/chat/completions");
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config["token-plan"]).toMatchObject({
api_key: "sk-sp-e2e-placeholder",
base_url: validationServer.baseUrl,
default_text_model: "qwen3.8-max-preview",
default_video_model: "happyhorse-1.1-t2v",
default_image_to_video_model: "happyhorse-1.1-i2v",
default_reference_to_video_model: "happyhorse-1.1-r2v",
default_image_model: "wan2.7-image",
});
} finally {
await validationServer.close();
}
});
test("auth login --config token-plan 物化并重置内置预设", async () => {
const validationServer = await startValidationServer();
const configDir = makeE2eOutputDir("auth-token-plan-preset-login");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify(
{
"token-plan": {
default_text_model: "custom-text-model",
default_video_model: "custom-video-model",
default_image_to_video_model: "custom-image-to-video-model",
default_reference_to_video_model: "custom-reference-to-video-model",
default_image_model: "custom-image-model",
},
},
null,
2,
) + "\n",
);
try {
const login = await runCommandE2e(
AUTH_ROUTES,
["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted",
DASHSCOPE_BASE_URL: validationServer.baseUrl,
},
);
expect(login.exitCode, login.stderr).toBe(0);
expect(validationServer.requests).toHaveLength(1);
expect(validationServer.requests[0]).toMatchObject({
path: "/compatible-mode/v1/chat/completions",
authorization: "Bearer sk-sp-e2e-placeholder",
sourceConfig: expect.any(String),
body: {
model: "qwen3.8-max-preview",
stream: false,
enable_thinking: true,
},
});
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config.api_key).toBeUndefined();
expect(config.active_config).toBe("token-plan");
expect(config["token-plan"]).toMatchObject({
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_text_model: "qwen3.8-max-preview",
default_video_model: "happyhorse-1.1-t2v",
default_image_to_video_model: "happyhorse-1.1-i2v",
default_reference_to_video_model: "happyhorse-1.1-r2v",
default_image_model: "wan2.7-image",
});
expect((config["token-plan"] as Record<string, unknown>).base_url).not.toBe(
validationServer.baseUrl,
);
expect((config["token-plan"] as Record<string, unknown>).api_key).not.toBe(
"sk-env-must-not-be-persisted",
);
} finally {
await validationServer.close();
}
});
test("auth login 未传 --config 时写当前激活 Config", async () => {
const validationServer = await startValidationServer();
const configDir = makeE2eOutputDir("auth-active-profile-login");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify(
{
active_config: "dev",
dev: { base_url: validationServer.baseUrl },
},
null,
2,
) + "\n",
);
const env = {
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
};
try {
const activeLogin = await runCommandE2e(
AUTH_ROUTES,
["auth", "login", "--api-key", "sk-active-placeholder"],
env,
);
expect(activeLogin.exitCode, activeLogin.stderr).toBe(0);
expect(validationServer.requests).toHaveLength(1);
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config.api_key).toBeUndefined();
expect(config.active_config).toBe("dev");
expect(config.dev).toMatchObject({
api_key: "sk-active-placeholder",
base_url: validationServer.baseUrl,
});
} finally {
await validationServer.close();
}
});
test("auth login --api-key 验证失败不留下半配置", async () => {
const validationServer = await startValidationServer(400);
const configDir = makeE2eOutputDir("auth-api-key-login-failure");
try {
const login = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--config",
"failed-profile",
"--api-key",
"sk-invalid",
"--base-url",
validationServer.baseUrl,
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(login.exitCode).not.toBe(0);
expect(login.stderr).toMatch(/invalid key/);
expect(login.stderr).not.toMatch(/API key validation failed|Invalid API key/);
expect(existsSync(join(configDir, "config.json"))).toBe(false);
} finally {
await validationServer.close();
}
});
test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--dry-run",
"--api-key",
"sk-e2e-dry-run-placeholder",
"--output",
"json",
"--timeout",
"120",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Would validate and save API key.");
});
test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--output",
"json",
]);
expect(exitCode).toBe(2);
const err = JSON.parse(stderr.trim()) as { error?: { code?: number; message?: string } };
expect(err.error?.code).toBe(2);
expect(err.error?.message).toMatch(/Choose exactly one login mode/);
});
test("auth logout --dry-run 不写入配置", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"logout",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No changes made.");
expect(stderr).not.toContain("Cleared api_key");
});
test("auth logout --dry-run --quiet", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"logout",
"--dry-run",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No changes made.");
});
test("auth logout --dry-run --output json不清除密钥", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"logout",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No changes made.");
expect(stderr).not.toContain("Cleared api_key");
});
test("auth logout 清除当前 Config 的全部凭证和 Base URL保留普通配置", async () => {
const configDir = makeE2eOutputDir("auth-logout-all");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify(
{
api_key: "sk-e2e-placeholder",
base_url: "https://model.example.com",
access_token: "console-token-placeholder",
access_key_id: "LTAI-e2e-placeholder",
access_key_secret: "secret-e2e-placeholder",
security_token: "sts-e2e-placeholder",
output: "json",
},
null,
2,
) + "\n",
);
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout"], {
BAILIAN_CONFIG_DIR: configDir,
});
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("api_key / base_url / access_token");
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config).toEqual({ output: "json" });
});
test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"status",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(
/Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/,
);
});
test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"status",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
api_key?: { source?: string; masked?: string; base_url?: string };
}>(stdout);
expect(data.authenticated).toBe(true);
expect(data.api_key?.source).toBeDefined();
});
test.skipIf(!isDashScopeE2EReady())(
"auth status --output json --quiet(base_url 经 env 指定;凭证域 flag 对 status 不可见)",
async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(
AUTH_ROUTES,
["auth", "status", "--output", "json", "--quiet"],
{ DASHSCOPE_BASE_URL: "https://dashscope.aliyuncs.com" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ authenticated?: boolean; api_key?: unknown }>(stdout);
expect(data.authenticated).toBe(true);
expect(data.api_key).toBeDefined();
},
);
test("auth status 不接受凭证域覆盖 flag(--base-url 报 Unknown flag)", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"status",
"--base-url",
"https://x.test",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--base-url/);
});
test("auth status 展示 env OpenAPI AK/SK 且不接受 OpenAPI flag 覆盖", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(
AUTH_ROUTES,
["auth", "status", "--output", "json"],
{
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-e2e-placeholder",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "secret-e2e-placeholder",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
}>(stdout);
expect(data.authenticated).toBe(true);
expect(data.openapi?.source).toBe("env");
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-placeholder");
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-placeholder");
const denied = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--access-key-id", "ak"]);
expect(denied.exitCode).not.toBe(0);
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
});
test.skipIf(!isOpenApiE2EReady())(
"auth login --open-api 使用环境中的真实 AK/SK持久化后支持单独 logout",
async () => {
const accessKeyId = process.env.ALIBABA_CLOUD_ACCESS_KEY_ID!.trim();
const accessKeySecret = process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET!.trim();
const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-login-"));
const env = {
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
};
try {
const login = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--open-api",
"--access-key-id",
accessKeyId,
"--access-key-secret",
accessKeySecret,
],
env,
);
expect(login.exitCode, login.stderr).toBe(0);
expect(login.stderr).toMatch(/OpenAPI credentials saved/);
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
// 只断言布尔结果,避免失败 diff 把真实凭证打印到测试日志。
expect(config.access_key_id === accessKeyId).toBe(true);
expect(config.access_key_secret === accessKeySecret).toBe(true);
expect(config.openapi_access_key_id).toBeUndefined();
expect(config.openapi_access_key_secret).toBeUndefined();
const status = await runCommandE2e(
AUTH_ROUTES,
["auth", "status", "--output", "json"],
env,
);
expect(status.exitCode, status.stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
}>(status.stdout);
expect(data.authenticated).toBe(true);
expect(data.openapi?.source).toBe("config");
expect(data.openapi?.access_key_id === accessKeyId).toBe(false);
expect(data.openapi?.access_key_secret === accessKeySecret).toBe(false);
const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env);
expect(logout.exitCode, logout.stderr).toBe(0);
expect(logout.stderr).toMatch(/Cleared access_key_id/);
const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
expect(after.exitCode, after.stderr).toBe(0);
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(
after.stdout,
);
expect(afterData.openapi).toBeUndefined();
} finally {
rmSync(configDir, { recursive: true, force: true });
}
},
);
});
@@ -0,0 +1,523 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { CONFIG_ROUTES } from "./topic-routes.ts";
/**
* Config 相关 E2E
*/
describe("e2e: config", () => {
test("config show --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "show", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/show|config/i);
});
test("config set --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/set|--key|--value/i);
});
test("config list/use --help 正常退出", async () => {
const listResult = await runCommandE2e(CONFIG_ROUTES, ["config", "list", "--help"]);
expect(listResult.exitCode, listResult.stderr).toBe(0);
expect(listResult.stderr).toMatch(/list|active|profile/i);
const useResult = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--help"]);
expect(useResult.exitCode, useResult.stderr).toBe(0);
expect(useResult.stderr).toMatch(/use|--name|active/i);
});
test("config ui --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/ui|--port|--no-open|web/i);
});
test("config ui --dry-run 打印计划不起服务", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"ui",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ host?: string; routes?: string[] }>(stdout);
expect(data.host).toBe("127.0.0.1");
expect(Array.isArray(data.routes)).toBe(true);
});
test("config show --output json", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"show",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
config_file?: string;
base_url?: string;
timeout?: number;
}>(stdout);
expect(data.config_file).toBeDefined();
expect(data.base_url).toBeDefined();
expect(data.timeout).toBeDefined();
});
test("config show --output text", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"show",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/config_file|timeout|base_url/i);
});
test("config show 脱敏 security_token", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-secret-"));
try {
const securityToken = "sts-sensitive-token";
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({ security_token: securityToken }, null, 2) + "\n",
);
const { stdout, stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
["config", "show", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ security_token?: string }>(stdout);
expect(data.security_token).toBe("sts-...oken");
expect(stdout).not.toContain(securityToken);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--key|--value|Usage:/i);
});
test("config use 缺少 --name 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--name|Usage:/i);
});
test("config use 持久化激活项config list 展示激活状态", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
const env = { BAILIAN_CONFIG_DIR: configDir };
const useResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "use", "--name", "dev", "--output", "json"],
env,
);
expect(useResult.exitCode, useResult.stderr).toBe(0);
expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe(
"dev",
);
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev");
const listResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "list", "--output", "json"],
env,
);
expect(listResult.exitCode, listResult.stderr).toBe(0);
const listData = parseStdoutJson<{
active_config?: string;
profiles?: string[];
}>(listResult.stdout);
expect(listData.active_config).toBe("dev");
expect(listData.profiles).toEqual(["default", "dev"]);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config use --dry-run 校验目标但不修改激活项", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-dry-run-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
const result = await runCommandE2e(
CONFIG_ROUTES,
["config", "use", "--name", "dev", "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson<{ would_activate?: string }>(result.stdout).would_activate).toBe(
"dev",
);
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config use 拒绝不存在的 Profile 且不写入状态", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-missing-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(configPath, JSON.stringify({ output: "text" }, null, 2) + "\n");
const result = await runCommandE2e(
CONFIG_ROUTES,
["config", "use", "--name", "missing", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(result.exitCode).toBe(2);
expect(result.stderr).toMatch(/does not exist/);
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config set 非法 key 时退出为用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
"not-a-real-key",
"--value",
"x",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid config key|not-a-real-key/i);
});
test("config set 非法 output", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
"output",
"--value",
"yaml",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid output|text, json/i);
});
test("config set 非法 timeout", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
"timeout",
"--value",
"0",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid timeout|positive/i);
});
test("config set 归一化 Base URL 并拒绝非法协议", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-base-url-"));
try {
const setResult = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"set",
"--key",
"base_url",
"--value",
"https://proxy.example.com/bailian/compatible-mode/v1/?x=1#fragment",
"--output",
"json",
],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
"https://proxy.example.com",
);
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
"https://proxy.example.com",
);
const invalidResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(invalidResult.exitCode).toBe(2);
expect(invalidResult.stderr).toMatch(/Invalid model base URL/);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config set --dry-run 不落盘(仅输出 would_set", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"output",
"--value",
"json",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { output?: string } }>(stdout);
expect(data.would_set?.output).toBe("json");
});
test("config set --dry-run 支持连字符别名 key", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"default-text-model",
"--value",
"qwen3.7-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
});
test("config set --dry-run 支持图生视频默认模型别名", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"default-image-to-video-model",
"--value",
"happyhorse-1.1-i2v",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_set?: { default_image_to_video_model?: string };
}>(stdout);
expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v");
});
test("config set --dry-run 支持参考生视频默认模型别名", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"default-reference-to-video-model",
"--value",
"happyhorse-1.1-r2v",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_set?: { default_reference_to_video_model?: string };
}>(stdout);
expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v");
});
test("config set --dry-run 展示归一化后的 Base URL", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"base-url",
"--value",
"https://proxy.example.com/apps/anthropic/?x=1#fragment",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { base_url?: string } }>(stdout);
expect(data.would_set?.base_url).toBe("https://proxy.example.com");
});
test("config set --dry-run 支持 AccessKey 短字段别名", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"access-key-id",
"--value",
"LTAI-config-placeholder",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(stdout);
expect(data.would_set?.access_key_id).toBe("LTAI-config-placeholder");
});
test("config set 不接受旧 OpenAPI AccessKey 字段名", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--key",
"openapi_access_key_id",
"--value",
"LTAI-config-placeholder",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid config key|openapi_access_key_id/);
});
test("config agent --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "agent", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/agent|--base-url|--model/i);
});
test("config agent 缺少 --api-key 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"claude-code",
"--base-url",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--model",
"qwen3-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--api-key|Usage:/i);
});
test("config agent 非法 --agent 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"not-an-agent",
"--base-url",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"--api-key",
"sk-placeholder",
"--model",
"qwen3-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not-an-agent|claude-code|agent/i);
});
test("config agent --dry-run 输出脱敏信息且不写盘", async () => {
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-dry-"));
try {
const { stdout, stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"agent",
"--agent",
"claude-code",
"--base-url",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--api-key",
"sk-secret-placeholder",
"--model",
"qwen3-max",
"--dry-run",
"--output",
"json",
],
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
agent?: string;
base_url?: string;
model?: string;
api_key?: string;
}>(stdout);
expect(data.agent).toBe("claude-code");
expect(data.base_url).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
expect(data.model).toBe("qwen3-max");
expect(stdout).not.toContain("sk-secret-placeholder");
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("config agent codex 写入 config.toml 与 auth.jsoncc-switch 对齐结构)", async () => {
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-codex-"));
try {
const { stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"agent",
"--agent",
"codex",
"--base-url",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"--api-key",
"sk-codex-placeholder",
"--model",
"qwen3-coder-plus",
],
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml).toContain('model_provider = "bailian-cli"');
expect(toml).toContain("requires_openai_auth = true");
expect(toml).toContain('wire_api = "responses"');
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("config agent hermes 写入 custom_providers 结构", async () => {
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-"));
try {
const { stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"agent",
"--agent",
"hermes",
"--base-url",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"--api-key",
"sk-hermes-placeholder",
"--model",
"qwen3-coder-plus",
],
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8");
expect(yamlText).toContain("custom_providers");
expect(yamlText).toContain("bailian-cli");
expect(yamlText).toContain("api_mode: chat_completions");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { CONSOLE_FLAGS_DRY_RUN_ROUTES } from "./topic-routes.ts";
type ConsoleDryRunMeta = {
consoleRegion?: string;
@@ -7,55 +8,13 @@ type ConsoleDryRunMeta = {
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:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Global Flags:/);
expect(stderr).toMatch(/--console-region <region>/);
expect(stderr).toMatch(/--model <model>/);
expect(stderr).toMatch(/--period <minutes>/);
expect(stderr).toMatch(/--output <format>/);
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
});
test("跨域 flag 拒绝:model 命令传 --console-region 报 Unknown flag", async () => {
const { stderr, exitCode } = await runCli([
"text",
"chat",
"--message",
"hi",
"--console-region",
"cn-hangzhou",
"--dry-run",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--console-region/);
});
test("跨域 flag 拒绝:console 命令传 --api-key 报 Unknown flag", async () => {
const { stderr, exitCode } = await runCli(["mcp", "list", "--api-key", "sk-test", "--dry-run"]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--api-key/);
});
describe("e2e: console global flags (dry-run)", () => {
test("console call --help 不暴露命令级 region/site示例使用 --console-region", async () => {
const { stderr, exitCode } = await runCli(["console", "call", "--help"]);
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"console",
"call",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--api <api>/);
expect(stderr).toMatch(/--data <json>/);
@@ -65,7 +24,11 @@ describe("e2e: console global flags", () => {
});
test("auth login --help:自有 flag 含 --console-site,不含其余 console 域 flag", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"auth",
"login",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--api-key <key>/);
expect(stderr).toMatch(/--base-url <url>/);
@@ -75,7 +38,7 @@ describe("e2e: console global flags", () => {
});
test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"console",
"call",
"--api",
@@ -93,7 +56,7 @@ describe("e2e: console global flags", () => {
});
test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"console",
"call",
"--api",
@@ -118,7 +81,7 @@ describe("e2e: console global flags", () => {
});
test("console call 拒绝未知全局 flag --region", async () => {
const { stderr, exitCode } = await runCli([
const { stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"console",
"call",
"--api",
@@ -134,7 +97,7 @@ describe("e2e: console global flags", () => {
});
test("mcp list --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"mcp",
"list",
"--dry-run",
@@ -149,7 +112,7 @@ describe("e2e: console global flags", () => {
});
test("quota check --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
const { stdout, stderr, exitCode } = await runCommandE2e(CONSOLE_FLAGS_DRY_RUN_ROUTES, [
"quota",
"check",
"--dry-run",

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