Compare commits

..

142 Commits

Author SHA1 Message Date
Gong Shiqi 60c49ec1ac Merge pull request #72 from modelstudioai/chore/list-voices
feat(omni,speech): add --list-voices and fix cosyvoice voice ID
2026-06-24 10:40:10 +08:00
若麒 07c71412cf chore(release): 1.4.2 2026-06-24 10:35:26 +08:00
若麒 e2c4935e84 Merge remote-tracking branch 'origin/main' into chore/list-voices 2026-06-24 10:29:15 +08:00
Gong Shiqi 4f10b7f50c Merge pull request #71 from modelstudioai/feat/console-login-site
feat: Add default login site selection for agent
2026-06-24 10:16:20 +08:00
clh02467605 fd36db5cab fix: remove unsupport voice 2026-06-23 17:08:37 +08:00
clh02467605 30a0bbbc87 fix: fixed omni e2e 2026-06-23 15:34:52 +08:00
clh02467605 9761932b4c feat(omni): add voice listing functionality and update voice options 2026-06-23 14:50:09 +08:00
qcq01083097 9cbd4aab85 feat: Add default login site selection for agent 2026-06-22 17:09:17 +08:00
Gong Shiqi 19c4f5f2ab Merge pull request #70 from modelstudioai/feat/switch-model
feat(video): upgrade happyhorse model from 1.0 to 1.1, video-edit has not been updated and is still 1.0.
2026-06-22 17:08:13 +08:00
若麒 af524e5487 chore(release): 1.4.1 2026-06-22 17:03:14 +08:00
clh02467605 4e025dda8d feat(video): upgrade happyhorse model from 1.0 to 1.1, video-edit has not been updated and is still 1.0.
- Update default models in bl-api pipeline from happyhorse-1.0 to 1.1
- Replace happyhorse-1.0-t2v/i2v/r2v references with 1.1 versions in commands
2026-06-22 14:30:48 +08:00
Gong Shiqi 1ffcbdd80c Merge pull request #65 from modelstudioai/feat/optimize-skill
Feat/optimize skill
2026-06-18 18:05:07 +08:00
clh02467605 8aedeca4ac chore(skill): bump skill version to 1.3.4 2026-06-18 17:50:43 +08:00
clh02467605 f5a7dd494b docs(bailian-cli): Update SKILL.md to reference new pre-flight checklist procedure 2026-06-18 15:25:27 +08:00
qcq01083097 07dafc0fd4 feat: The method to modify and update skills 2026-06-18 14:07:06 +08:00
Gong Shiqi 0cd0daa18d Merge pull request #68 from modelstudioai/release/1.4.0
Release/1.4.0
2026-06-17 21:12:36 +08:00
若麒 5e39d1abc3 docs(changelog): note video resolution/ratio flag fix 2026-06-17 21:10:40 +08:00
若麒 c3df659ef0 Merge remote-tracking branch 'origin/main' into release/1.4.0 2026-06-17 21:07:39 +08:00
若麒 1d803bb4b9 fix(skill): require version check before any bl command, ask user before upgrading 2026-06-17 20:54:36 +08:00
Gong Shiqi 847b291ccc Merge pull request #67 from modelstudioai/fix/video-params-accuracy-v2
fix(video): correct resolution/ratio flag descriptions
2026-06-17 20:29:15 +08:00
若麒 0ceb15b0be fix(video): correct resolution/ratio flag descriptions 2026-06-17 20:26:55 +08:00
故璃 dc3c02f68c fix: e2e test update logic 2026-06-17 19:51:21 +08:00
clh02467605 f1eeeff682 chore(skill): opt bailian-cli skill 2026-06-17 18:12:47 +08:00
clh02467605 f184c60357 chore(skill): opt bailian-cli skill 2026-06-17 18:10:38 +08:00
若麒 8906af8ad1 docs(readme): sync China-site-only notice to zh and cli-package READMEs 2026-06-17 17:21:25 +08:00
xxlaura 3b705f2b0d docs(readme): add China-site-only notice to Features section
Separate globally available features from China-site-only features
with a blockquote note clarifying that Knowledge base, App calls,
MCP integration, Web search, Model recommendation, Console
capabilities, and Local file auto-upload are currently exclusive
to China site (aliyun.com) account holders.
2026-06-17 16:46:49 +08:00
若麒 2260c51c7e docs(changelog): add advisor model upgrade and JSON output changes to 1.4.0 2026-06-17 16:14:17 +08:00
若麒 8c893a59ef Merge remote-tracking branch 'origin/main' into release/1.4.0 2026-06-17 16:06:14 +08:00
若麒 611ecc1e68 chore(release): 1.4.0 2026-06-17 15:58:08 +08:00
gujieye 17c1f5f7b2 Merge pull request #59 from modelstudioai/feat/update-intent-model
feat: change output & table name into English
2026-06-17 15:24:20 +08:00
故璃 7593baf4f6 Merge branch 'feat/update-intent-model' of https://github.com/modelstudioai/cli into feat/update-intent-model 2026-06-17 15:20:46 +08:00
故璃 edb34658a1 feat: temp save 2026-06-17 15:16:54 +08:00
gujieye cbeb2bf071 Merge branch 'main' into feat/update-intent-model 2026-06-17 15:03:51 +08:00
ls 1fd08fe1c8 Merge pull request #56 from modelstudioai/feat/console-gateway-region-site
feat(console): resolve gateway URL from region + site, add switchAgent
2026-06-17 14:49:28 +08:00
故璃 8cb24b719b feat: merge main 2026-06-17 14:43:13 +08:00
qcq01083097 d4c0809951 feat: resolve conflict 2026-06-17 14:43:01 +08:00
故璃 b01d35c246 Merge branch 'main' into feat/update-intent-model 2026-06-17 14:35:14 +08:00
Gong Shiqi 67ff4d2811 Merge pull request #63 from modelstudioai/feat/i18n-english-only
feat(cli): standardize user-facing CLI text to English
2026-06-17 14:27:27 +08:00
clh02467605 a830965feb feat(omni): add new voice option to omni chat command 2026-06-17 14:19:47 +08:00
故璃 14583cf6d1 feat: update access token logic 2026-06-17 11:58:09 +08:00
Gong Shiqi c32f03e8dc Merge pull request #62 from modelstudioai/feat/project-optimization
Feat/project optimization
2026-06-17 11:53:02 +08:00
lishengzxc e9feb380f5 Merge branch 'main' of github.com:modelstudioai/cli into feat/console-gateway-region-site 2026-06-17 11:45:49 +08:00
clh02467605 62ad8768e3 merge: merge main into feat/i18n-english-only 2026-06-17 11:28:24 +08:00
clh02467605 e8a40cdad0 Merge remote-tracking branch 'refs/remotes/origin/main' into feat/i18n-english-only
# Conflicts:
#	packages/cli/src/commands/knowledge/retrieve.ts
#	skills/bailian-cli/reference/knowledge.md
2026-06-17 11:26:04 +08:00
qcq01083097 9bcb66b1ff feat: Resolve conflicts 2026-06-17 10:02:00 +08:00
lishengzxc 60cad1001c chore: remove redundant "(global flag)" from option descriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-16 19:44:08 +08:00
qcq01083097 a5d078b8a0 Merge branch 'feat/console-gateway-region-site' of github.com:modelstudioai/cli into feat/console-gateway-region-site 2026-06-16 18:23:09 +08:00
qcq01083097 685c0176ff feat: clean up region remnants 2026-06-16 18:22:18 +08:00
qcq01083097 d24b41d452 feat: Remove the logic related to region 2026-06-16 18:16:10 +08:00
lishengzxc d320d36ba7 docs: add console gateway flags convention to AGENTS.md and command help
Add console global flags (--console-region, --console-site, --console-switch-agent)
to the options of all 12 commands that depend on callConsoleGateway, so they appear
in --help output. Document this as convention #4 in AGENTS.md for future commands.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: update API Key links in Chinese README

Same change as English README - direct to API Key management page.
2026-06-09 13:48:17 +08:00
zeyu.fz 6317da8454 feat(cli): 重构知识库检索命令,支持API-KEY和AK/SK鉴权
- 增加API-KEY鉴权路径,采用DashScope协议(snake_case)请求后端接口
- 保留AK/SK鉴权路径,但打印废弃警告,采用PascalCase请求后端
- 命令参数调整,新增dense-similarity-top-k、sparse-similarity-top-k等API-KEY专用选项
- 废弃部分旧参数如顶层top-k,提醒用户改用rerank-top-n
- 统一输出格式以及静默模式下文本结果的打印逻辑优化
- 添加相关类型定义,完善请求与响应结构的类型支持
- CLI端增加dry-run模式,展示实际请求参数与地址
- E2E测试覆盖API-KEY和AK/SK两条路径,包含帮助提示、错误场景及关键参数测试
- 更新依赖的核心包导出与接口,新增knowledgeRetrieveEndpoint方法接口调用
2026-06-08 18:43:50 +08:00
139 changed files with 7462 additions and 1342 deletions
+185
View File
@@ -0,0 +1,185 @@
name: Bug Report
description: Report a bug in bailian-cli (bl)
title: "[bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug.
**Before submitting:** search [open issues](https://github.com/modelstudioai/cli/issues?q=is%3Aissue+is%3Aopen) for duplicates.
**Security:** redact API keys (`sk-...`), console tokens, internal URLs, and business prompts before pasting output.
- type: markdown
attributes:
value: |
## Environment
- type: input
id: cli-version
attributes:
label: CLI version
description: "Output of bl --version (use only X.Y.Z, without the bl prefix)"
placeholder: "1.2.1"
validations:
required: true
- type: input
id: skill-version
attributes:
label: Skill version (optional)
description: "metadata.version from the installed bailian-cli skill, if applicable"
placeholder: "1.2.1"
- type: input
id: node-version
attributes:
label: Node version
description: "Output of node --version"
placeholder: "v22.12.0"
validations:
required: true
- type: input
id: os
attributes:
label: OS
description: "e.g. darwin 24.5.0, Ubuntu 22.04"
placeholder: "darwin 24.5.0"
validations:
required: true
- type: dropdown
id: region
attributes:
label: Region
description: "From bl auth status or bl config show"
options:
- cn
- us
- intl
- unknown
validations:
required: true
- type: markdown
attributes:
value: |
## Reproduction
- type: textarea
id: reproduce-command
attributes:
label: Command to reproduce
description: "Exact command that failed. Redact --api-key, sk-..., and sensitive prompts."
render: shell
placeholder: |
bl video generate --prompt "sunset" --download out.mp4 --verbose
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should have happened?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead?
validations:
required: true
- type: markdown
attributes:
value: |
## Error output
Paste stderr as printed by `bl`. Include `Request ID` when present — it helps us trace logs.
- type: textarea
id: full-output
attributes:
label: Full output
description: Error, Hint, Status, Request ID, Exit code, etc.
render: shell
placeholder: |
Error: Generation completed but no images returned.
Hint: ...
Status: HTTP 200 (...)
Request ID: ...
Exit code: 1
validations:
required: true
- type: textarea
id: json-error
attributes:
label: JSON error (optional)
description: "Re-run with --output json and paste the error object if available"
render: json
placeholder: |
{
"error": {
"code": 1,
"message": "...",
"http_status": 200,
"api_code": "...",
"request_id": "..."
}
}
- type: markdown
attributes:
value: |
## Troubleshooting already tried
- type: checkboxes
id: already-tried
attributes:
label: Already tried
options:
- label: "bl update and skill version aligned with CLI"
- label: "bl auth status OK for this command"
- label: "Different network / region — still reproduces"
- type: markdown
attributes:
value: |
## Additional context
- type: dropdown
id: frequency
attributes:
label: How often does this happen?
options:
- Always
- Intermittent
- Once
validations:
required: true
- type: dropdown
id: invoked-via
attributes:
label: How was bl invoked?
options:
- Terminal (manual)
- Agent (Cursor, Claude, etc.)
- CI / script
- Other
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes (optional)
description: Anything else that might help — related issues, screenshots, minimal repro repo, etc.
+1
View File
@@ -33,6 +33,7 @@ tools/generated
.claude/worktrees/
.claude/settings.json
.claude/settings.local.json
.claude/scheduled_tasks.lock
.cursor/
.qwen/
.playwright-mcp/
+1
View File
@@ -0,0 +1 @@
24.16.0
+12
View File
@@ -93,6 +93,18 @@ CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的
不要扮演服务端错误的翻译官——我们没有最新的错误码体系认知,二次包装只会撒谎(详见 `docs/agents/error-hint-change.md` 中的反面 case)。
### 4. Console Gateway 命令必须声明 console 全局 flags
如果新命令使用了 `callConsoleGateway`,必须在 `options` 中添加以下三个全局 flag 的说明,以便 `--help` 中展示:
```ts
{ flag: "--console-region <region>", description: "Console region" },
{ flag: "--console-site <site>", description: "Console site: domestic, international" },
{ flag: "--console-switch-agent <uid>", description: "Switch agent UID", type: "number" },
```
这些 flag 已在 `GLOBAL_OPTIONS``packages/core/src/types/command.ts`)中注册,由 `loadConfig` 写入 `config.consoleRegion` / `config.consoleSite` / `config.consoleSwitchAgent``callConsoleGateway` 自动读取——命令无需手动提取或传递。
## 完成改动后的快速验证
```sh
+104
View File
@@ -6,6 +6,110 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.4.2] - 2026-06-24
### Added
- `bl omni --list-voices` prints the built-in output voices (ID, name, description, language) and exits without needing an API key. The built-in voice table is expanded from 6 to 17 voices, including dialect voices such as Dylan, Sunny, and Kiki.
### Changed
- `bl omni` default `--voice` is now `Tina` (previously `Cherry`). The `--voice` help points at `--list-voices` instead of listing every option inline.
- `bl speech synthesize --list-voices` and its missing-`--voice` hint now include a link to the official CosyVoice voice documentation.
- Agent skill setup guidance now covers console site selection (`--console-site domestic` / `international`) for console login and gateway commands.
### Fixed
- `bl speech synthesize` corrects the `cosyvoice-v3-flash` built-in voice ID from `longanhuan` to `longanhuan_v3`.
## [1.4.1] - 2026-06-22
### Changed
- Video generation now defaults to the upgraded HappyHorse 1.1 model for better quality. The 1.0 models are still available via `--model`.
- `bl update` now keeps the agent skill in sync across all your agent apps (Claude Code, Cursor, etc.), and refreshes it even when the CLI is already up to date.
## [1.4.0] - 2026-06-17
### Added
- Console gateway now supports multiple regions and sites: `cn-beijing` and `ap-southeast-1`, each with domestic and international variants, plus `switchAgent` for delegated access.
- New global flags `--console-region`, `--console-site`, and `--console-switch-agent`; `bl console call` also gains `--site` and `--switch-agent`.
- `bl auth login --base-url <url>` to specify the base URL when logging in with an API key.
- `bl omni` gains a `--voice` option (Chelsie, Cherry, Ethan, Serena, Sunny, Tina; default Cherry).
### Changed
- All user-facing CLI text is now standardized to English.
- `bl advisor recommend` internal intent/ranking model upgraded from `qwen-turbo` to `qwen-flash`.
- Cleaner JSON output for `usage`, `quota`, and `workspace` commands.
- `base_url` from the config file now takes priority over the `DASHSCOPE_BASE_URL` environment variable.
- `bl config show` now displays all fields from `config.json`, with sensitive values masked.
### Removed
- The legacy `region` config field and its related options.
- Invalid leftover code for the removed `model list` command.
### Fixed
- When the console session is not logged in or has expired, the CLI now shows a clear sign-in prompt instead of a generic gateway error.
- Corrected `--resolution` / `--ratio` / `--duration` flag descriptions for `bl video` commands.
## [1.3.3] - 2026-06-16
### Changed
- `bl knowledge retrieve --help` now clearly indicates that `--api-key` is the recommended authentication method; AK/SK flags are explicitly marked as deprecated with guidance to use `--api-key` instead.
### Added
- `notes` field for command definitions — commands can now include contextual notes (auth requirements, deprecation notices, etc.) that are displayed in both `--help` output and the generated reference docs.
## [1.3.2] - 2026-06-12
### Fixed
- Fixed `bl omni --audio` always returning HTTP 400 (#54); audio inputs are now understood correctly.
## [1.3.1] - 2026-06-12
### Fixed
- `bl` now honors `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables (#35). Node's built-in `fetch` (undici) ignores proxy env vars by default, causing `ECONNRESET` for users behind a VPN or corporate proxy. A global proxy dispatcher is now installed at startup when these variables are set, and the `ECONNRESET` error hint points to `export HTTPS_PROXY=http://127.0.0.1:<port>`.
## [1.3.0] - 2026-06-10
### Added
- `bl knowledge retrieve` now supports API-Key authentication (DashScope gateway), in addition to AK/SK. API-Key is auto-detected and preferred when available.
- New retrieval options: `--dense-similarity-top-k`, `--sparse-similarity-top-k`, `--rerank-model`, `--rerank-mode`, `--rerank-instruct` — supported on both API-Key and AK/SK paths.
- `DashScopeKnowledgeRetrieveRequest` / `DashScopeKnowledgeRetrieveResponse` types and `knowledgeRetrieveEndpoint` added to `bailian-cli-core`.
- Comprehensive E2E tests for knowledge retrieve covering both auth paths, dry-run, rerank flags, and error cases.
- `bl usage` command group:
- `bl usage free` — query free-tier quota for all models (or a specific model with `--model`).
- `bl usage freetier` — enable (`--on`) or disable (`--off`) auto-stop for free-tier models.
- `bl usage stats` — query model usage statistics (requires `--workspace-id`).
- `bl quota` command group:
- `bl quota list` — view model RPM/TPM rate limits (filter with `--model`, show all with `--all`).
- `bl quota check` — check current RPM/TPM usage against rate limits.
- `bl quota history` — view quota change history with pagination.
- `bl quota request` — request a temporary quota increase for a model.
- `bl workspace list` — list all workspaces with region and endpoint details.
### Changed
- Credential resolution priority: explicit API-Key → explicit AK/SK flags → auto-detected API-Key → fallback AK/SK from config/env.
- `--workspace-id` is now only required for AK/SK auth, no longer mandatory for API-Key mode.
- `--top-k` deprecated in favor of `--rerank-top-n`; emits a warning and maps to `--rerank-top-n` when used.
- `--access-key-id` / `--access-key-secret` flags marked as deprecated (API-Key is recommended).
- API Key and console links updated to direct key management pages across all docs.
### Fixed
- `--rerank` flag in AK/SK path now correctly sets `EnableReranking` instead of the non-functional `Rerank: true` boolean.
## [1.2.1] - 2026-06-09
### Changed
+103
View File
@@ -6,6 +6,109 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.4.2] - 2026-06-24
### 新增
- `bl omni --list-voices` 无需 API key 即可打印内置输出音色列表(ID、名称、描述、语言)并退出。内置音色表从 6 个扩展到 17 个,新增 Dylan、Sunny、Kiki 等方言音色。
### 变更
- `bl omni` 默认 `--voice` 改为 `Tina`(原为 `Cherry`)。`--voice` 帮助文案改为指向 `--list-voices`,不再内联列出全部音色。
- `bl speech synthesize --list-voices` 输出及缺少 `--voice` 时的提示中,新增官方 CosyVoice 音色文档链接。
- Agent skill 配置指引新增 console 站点选择说明(`--console-site domestic` / `international`),适用于 console 登录与网关类命令。
### 修复
- `bl speech synthesize` 修正 `cosyvoice-v3-flash` 内置音色 ID,由 `longanhuan` 改为 `longanhuan_v3`
## [1.4.1] - 2026-06-22
### 变更
- 视频生成默认升级到 HappyHorse 1.1 模型,画面质量更佳。如需使用 1.0 模型,可通过 `--model` 指定。
- `bl update` 现在会把 agent skill 同步更新到所有 agent 应用(Claude Code、Cursor 等),即使 CLI 已是最新版本也会刷新 skill。
## [1.4.0] - 2026-06-17
### 新增
- 控制台网关支持多 region 与多站点:`cn-beijing``ap-southeast-1`,各含国内站 / 国际站变体,并新增 `switchAgent` 委托访问。
- 新增全局标志 `--console-region``--console-site``--console-switch-agent``bl console call` 另外新增 `--site``--switch-agent`
- `bl auth login --base-url <url>`:使用 API Key 登录时可指定 base URL。
- `bl omni` 新增 `--voice` 选项Chelsie、Cherry、Ethan、Serena、Sunny、Tina默认 Cherry
### 变更
- 所有面向用户的 CLI 文案统一为英文。
- `bl advisor recommend` 内部意图 / 排序模型由 `qwen-turbo` 升级为 `qwen-flash`
- 优化 `usage``quota``workspace` 命令的 JSON 输出。
- 配置文件中的 `base_url` 现在优先级高于环境变量 `DASHSCOPE_BASE_URL`
- `bl config show` 现在展示 `config.json` 中的全部字段(敏感值已脱敏)。
### 移除
- 移除遗留的 `region` 配置字段及其相关选项。
- 清理 `model list` 命令移除后遗留的无效代码。
### 修复
- 当控制台会话未登录或已过期时CLI 现在会给出明确的登录提示,不再是笼统的网关错误。
- 修正 `bl video` 命令 `--resolution` / `--ratio` / `--duration` 的帮助文案。
## [1.3.3] - 2026-06-16
### 变更
- `bl knowledge retrieve --help` 现在明确指出 `--api-key` 是推荐的鉴权方式AK/SK 相关选项已标注废弃并引导用户使用 `--api-key`
### 新增
- 命令定义新增 `notes` 字段 — 命令可以附带上下文说明(鉴权要求、废弃提示等),同时展示在 `--help` 输出和生成的命令手册中。
## [1.3.2] - 2026-06-12
### 修复
- 修复 `bl omni --audio` 始终返回 HTTP 400 的问题(#54),音频输入现已能正常理解。
## [1.3.1] - 2026-06-12
### 修复
- `bl` 现在会读取 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` 环境变量(#35)。Node 内置的 `fetch`(undici)默认忽略代理环境变量,导致 VPN 或公司代理下出现 `ECONNRESET`。现已在启动时根据这些变量安装全局代理 dispatcher,并在 `ECONNRESET` 报错提示中给出 `export HTTPS_PROXY=http://127.0.0.1:<port>` 的指引。
## [1.3.0] - 2026-06-11
### 新增
- `bl usage` 命令组:
- `bl usage free` — 查询所有模型的免费额度(可通过 `--model` 指定模型)。
- `bl usage freetier` — 启用(`--on`)或禁用(`--off`)免费额度模型的自动停服。
- `bl usage stats` — 查询模型用量统计(需指定 `--workspace-id`)。
- `bl quota` 命令组:
- `bl quota list` — 查看模型 RPM/TPM 速率限制(支持 `--model` 过滤,`--all` 展示全部)。
- `bl quota check` — 查看当前 RPM/TPM 用量与速率限制。
- `bl quota history` — 查看配额变更记录,支持分页。
- `bl quota request` — 申请模型临时配额提升。
- `bl workspace list` — 列出所有业务空间,包含地域和 endpoint 信息。
- `bl knowledge retrieve` 新增 API-Key 鉴权DashScope 网关),与原有 AK/SK 并存,可用时自动优先使用 API-Key。
- 新增检索参数:`--dense-similarity-top-k``--sparse-similarity-top-k``--rerank-model``--rerank-mode``--rerank-instruct`API-Key 与 AK/SK 两条链路均支持。
- `bailian-cli-core` 新增 `DashScopeKnowledgeRetrieveRequest` / `DashScopeKnowledgeRetrieveResponse` 类型及 `knowledgeRetrieveEndpoint` 端点。
- 知识库检索全面 E2E 测试覆盖两种鉴权路径、dry-run、rerank 参数及错误场景。
### 变更
- 凭据解析优先级:显式 API-Key → 显式 AK/SK flag → 自动检测 API-Key → 回退至配置/环境变量中的 AK/SK。
- `--workspace-id` 仅在 AK/SK 鉴权时必填API-Key 模式下不再强制要求。
- `--top-k` 标记为废弃,改用 `--rerank-top-n`;使用时输出警告并自动映射。
- `--access-key-id` / `--access-key-secret` 标记为废弃(推荐使用 API-Key
- 全部文档中的 API Key 和控制台链接更新为直达密钥管理页面。
### 修复
- AK/SK 链路 `--rerank` 现在正确设置 `EnableReranking`,而非之前无效的 `Rerank: true` 布尔值。
## [1.2.1] - 2026-06-09
### 变更
+1 -1
View File
@@ -102,7 +102,7 @@ bl auth status --output json
bl text chat --message "ping" --non-interactive --output json
```
若失败:根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、region 等)。全局 region`--region cn|us|intl`,默认 `cn`
若失败:根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、`base_url`。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`
---
+33 -17
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
---
@@ -27,15 +27,18 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
- **Console capabilities** — Browse Bailian apps (`app list`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
## Showcase: One-Sentence Cinematic Video
@@ -51,7 +54,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.0**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
### The single prompt
@@ -64,7 +67,7 @@ A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.0** in parallel.
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
@@ -108,9 +111,22 @@ bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
# Browser login (required for console capability commands)
bl auth login --console
# Browse apps / free-tier quota
# Browse apps / free-tier quota / usage statistics / workspaces
bl app list
bl usage free --model qwen3-max
bl usage free --expiring 30 # Quotas expiring within 30 days
bl usage free --sort remaining # Sort by remaining % ascending
bl usage stats --workspace-id <id> # Usage overview for a workspace
bl usage stats --model qwen-turbo --workspace-id <id> # Per-model usage
bl workspace list # List all workspaces
# Rate limit management
bl quota list # View RPM/TPM limits for all models
bl quota list --model qwen3.6-plus # View limits for a specific model
bl quota check # Current usage vs rate limits
bl quota check --model qwen3.6-plus --period 5 # Check usage over last 5 minutes
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
bl quota history # View quota change history
```
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
@@ -119,7 +135,7 @@ bl usage free --model qwen3-max
### DashScope API Key
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cli?source_channel=key_github&).
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
```bash
# Option 1: Environment variable
@@ -134,7 +150,7 @@ bl text chat --api-key sk-xxxxx --message "Hello"
### Console Login (OAuth)
Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in.
Required for console capability commands (`app list`, `usage free`, `usage stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
@@ -159,7 +175,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
bl config show
# Set defaults
bl config set --key region --value us
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
@@ -171,14 +187,14 @@ Config file location: `~/.bailian/config.json`
## Links
| Resource | URL |
| :--------------------------- | :---------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/ |
| Get API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
| Resource | URL |
| :--------------------------- | :---------------------------------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
+33 -17
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
---
@@ -27,15 +27,18 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **文本对话** — Qwen3.7-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`,查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
## 示例:一句话生成一部电影短片
@@ -51,7 +54,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.0**,百炼的文生/图生/参考生视频模型
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
### 唯一的提示词
@@ -62,7 +65,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.0**
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
@@ -103,9 +106,22 @@ bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代
# 浏览器登录(控制台能力相关命令需要)
bl auth login --console
# 浏览应用 / 免费额度
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
bl app list
bl usage free --model qwen3-max
bl usage free --expiring 30 # 30 天内过期的额度
bl usage free --sort remaining # 按剩余百分比升序排列
bl usage stats --workspace-id <id> # 指定空间的用量概览
bl usage stats --model qwen-turbo --workspace-id <id> # 指定模型用量
bl workspace list # 列出所有业务空间
# 限流管理与提额
bl quota list # 查看所有模型的 RPM/TPM 限额
bl quota list --model qwen3.6-plus # 查看指定模型限额
bl quota check # 查看当前用量 vs 限流阈值
bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录
```
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
@@ -114,7 +130,7 @@ bl usage free --model qwen3-max
### DashScope API Key
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
```bash
# 方式一:环境变量
@@ -129,7 +145,7 @@ bl text chat --api-key sk-xxxxx --message "你好"
### 控制台登录OAuth
控制台能力命令(`app list``usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`app list``usage free``usage stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
@@ -154,7 +170,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
bl config show
# 设置默认值
bl config set --key region --value us
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
@@ -166,14 +182,14 @@ bl update
## 相关链接
| 资源 | 地址 |
| :---------------------- | :---------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
| 资源 | 地址 |
| :---------------------- | :---------------------------------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
+1 -3
View File
@@ -78,9 +78,7 @@ flag 优先 ─→ config 文件 ─→ env var
### D. main 启动逻辑
- [ ] `packages/cli/src/main.ts:NO_AUTH_SETUP` 列表:
- 如果新增的命令"自己管鉴权或不需要鉴权",加进去绕开 ensureApiKey 拦截
- 当前清单以 `main.ts:NO_AUTH_SETUP` 为准
- [ ] 若新增命令**自行处理鉴权**或**不应在入口触发默认 API key 引导**,在对应 `defineCommand` 上设 `skipDefaultApiKeySetup: true`(见 `packages/core/src/types/command.ts`;`packages/cli/src/main.ts``registry.resolve` 后读取 `command.skipDefaultApiKeySetup`)
### E. 错误文案
+11 -11
View File
@@ -50,14 +50,14 @@ git diff --name-only <base>...<head>
- [ ] **`package.json` 没破坏发布元数据**:`bin` / `exports` / `files` / `inlinedDependencies` 字段任何删除或改名都要单独评估
- [ ] **公共依赖没被悄悄升级**:catalog / 根 lockfile 改动要列出来
- [ ] **`package.json` version 没倒退**:目标分支已经更高时(如 main 1.0.3 vs head 1.0.0-beta.1),手动对齐版本号,不要被 head 覆盖
- [ ] **全局表没冲突**:`registry.ts``NO_AUTH_SETUP`(`packages/cli/src/main.ts`)、`ExitCode`个全局表新增项不和现有项冲突
- [ ] **全局表没冲突**:`registry.ts``defineCommand``skipDefaultApiKeySetup`(`packages/core/src/types/command.ts`)、`ExitCode`新增项不和现有项冲突
## 清单 B:用户透出(用户可见的新东西必看)
- [ ] **新命令 / 新 flag** 已同步到用户面文档:
- [README.md](README.md) + [README.zh.md](README.zh.md)(中英文都要,常漏 `_CN`)
- (SKILL.md 已迁出本仓库,由 `npx add skills` 机制独立维护,不在本仓库 review 范围)
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` / `apiDocs` 都填了
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` 都填了
- [ ] **demo / quickstart**:用户可调用的新命令至少有一个示例
- [ ] **行为变化的老命令**:在 commit message / CHANGELOG 注明用户感知的差异
- [ ] **错误信息 / 提示文案**:面向用户的字符串通顺、双语(项目主体是中文场景)
@@ -80,7 +80,7 @@ git diff --name-only <base>...<head>
解冲突要点(merge 时不要漏):
- <冲突文件> + <字段/段落> + <怎么取舍>
↑ 放"合并那一刻才会出现"的细节,例如 package.json 的 files/scripts/devDependencies 各取并集、
NO_AUTH_SETUP 这种全局表两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
`skipDefaultApiKeySetup` 这类命令元数据两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
建议修(可后置):
- ...
仅信息(无需动作,告知即可):
@@ -94,11 +94,11 @@ git diff --name-only <base>...<head>
## 常见漏点(基于历史踩坑)
| 漏点 | 后果 |
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `pnpm-workspace.yaml``packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README.zh](README.zh.md) | 用户完全感知不到新功能 |
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
| `NO_AUTH_SETUP` 加了不该免登录的命令 | 安全风险,用户没登录也能调付费 API |
| `NO_AUTH_SETUP` / `registry.ts` 这类全局表两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
| 漏点 | 后果 |
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `pnpm-workspace.yaml``packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README.zh](README.zh.md) | 用户完全感知不到新功能 |
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
| 不该跳过默认 API key 引导的命令误设 `skipDefaultApiKeySetup: true` | 安全风险,用户没配置 key 也能调付费 API |
| `catalog.ts` / `skipDefaultApiKeySetup` 这类元数据两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
+2 -3
View File
@@ -27,7 +27,7 @@
命令元数据以 **`catalog.ts` 为单一登记处**;`registry.ts` 只负责解析与打印 help,不再内嵌命令表或手写 Resources 列表。
```
commands/<...>.ts defineCommand({ name, description, usage, options, examples, apiDocs?, run })
commands/<...>.ts defineCommand({ name, description, usage, options, examples, run })
commands/catalog.ts export const commands: Record<string, Command>
@@ -53,8 +53,7 @@ registry.ts main.ts tools/generate-reference.ts export-schema.ts
- 增删 `import xxx from "./.../xxx.ts"`
-`export const commands` 里增删 `"<group> <action>": xxx`(key 与 `defineCommand({ name })` 一致)
- [ ] **不要**在 `registry.ts` 里重复登记命令(已从 catalog 读取)
- [ ] 命令需`bl help` / `reference/` 展示 API 文档链接时,在 `defineCommand` `apiDocs`(相对路径);help 与 reference 均从此字段生成
- [ ] 如果命令需要鉴权之外的特殊路径,看 `packages/cli/src/main.ts``NO_AUTH_SETUP`
- [ ] 如果命令需要跳过入口的默认 DashScope API key 引导(`ensureApiKey`),在对应 `defineCommand` `skipDefaultApiKeySetup: true`(字段定义见 `packages/core/src/types/command.ts`;`main.ts` 根据已解析的 `command` 读取)
- [ ] **`config/export-schema.ts`**: 若新命令不适合作为 agent tool,评估是否加入 `SKIP_PREFIXES`;该文件在 `run()``import("../catalog.ts")`,勿顶层 import catalog 以免循环依赖
### B. 文档层
+15 -8
View File
@@ -67,6 +67,12 @@ node tools/release/publish-channel.mjs --channel test --dry-run
- [ ] `packages/cli/package.json``packages/core/package.json` 已升到目标版本
- [ ] pre-release 格式正确(`1.0.0-beta.0` / `1.0.0-rc.1`**不要直接用 `1.0.0` 当 beta**
### CHANGELOG仅 stable
- [ ] `CHANGELOG.md``CHANGELOG.zh.md` 都已新增目标版本条目,中英文一一对应
- [ ] 分类标题用 Keep a Changelog 规范的 `Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`(中文版对应 `新增` / `变更` / `已弃用` / `已移除` / `修复` / `安全`**不要自创 `Improved` / `优化` 等规范外分类**
- [ ] 条目日期与发版日期一致
### 用户面文档
- [ ] `README.md` / `README.zh.md` 的 Quick Start 命令仍能跑通
@@ -81,11 +87,12 @@ node tools/release/publish-channel.mjs --channel test --dry-run
## 常见漏点(基于历史踩坑)
| 漏点 | 后果 |
| ----------------------------------------------------- | -------------------------------------------------- |
| cli 升版号但 core 没升 | check.mjs 会拦下 |
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag所有用户被强升撤回成本极高 |
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
| 漏点 | 后果 |
| -------------------------------------------------------- | -------------------------------------------------- |
| cli 升版号但 core 没升 | check.mjs 会拦下 |
| 发版漏更 CHANGELOG或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag所有用户被强升撤回成本极高 |
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
+438
View File
@@ -0,0 +1,438 @@
# 模型训练 + 数据集 + 部署:最小闭环 CLI 设计
> 目标:一个 Qwen 文本模型 SFT 训练、数据集上传、模型部署的端到端最小链路。
---
## 一、命令概览
| 优先级 | 命令 | 映射 API | 用途 |
| ------ | ----------------------------------- | --------------------------------------------- | ------------------------------- |
| P0 | `bl dataset upload <path>` | `POST /api/v1/files` | 上传训练数据(含本地格式校验) |
| P0 | `bl finetune create` | `POST /api/v1/fine-tunes` | 创建 SFT 训练任务(预填默认超参) |
| P0 | `bl finetune status <job_id>` | `GET /api/v1/fine-tunes/{job_id}` | 查询训练状态 |
| P0 | `bl deploy create` | `POST /api/v1/deployments` | 部署训练好的模型 |
| P1 | `bl finetune logs <job_id>` | `GET /api/v1/fine-tunes/{job_id}/logs` | 拉取训练日志 |
| P1 | `bl finetune checkpoints <job_id>` | `GET /api/v1/fine-tunes/{job_id}/checkpoints` | 查看/挑选 Checkpoint |
| P1 | `bl deploy status <deployed_model>` | `GET /api/v1/deployments/{deployed_model}` | 查询部署状态 |
| P1 | `bl deploy delete <deployed_model>` | `DELETE /api/v1/deployments/{deployed_model}` | 下线部署 |
| P1 | `bl infer --model <deployed_model>` | 复用 `text chat` 通路 | 调用已部署模型 |
---
## 二、P0 命令详细设计
### 2.1 `bl dataset upload`
**定位:** 上传训练数据文件到百炼平台,获取 `file_id` 供训练任务引用。
#### CLI 签名
```
bl dataset upload <path> [--purpose fine-tune] [--validate] [--no-validate]
```
| Flag | 必填 | 默认值 | 说明 |
| --------------- | ---- | ----------- | ------------------------------ |
| `<path>` | 是 | — | 本地文件路径(.jsonl 或 .zip |
| `--purpose` | 否 | `fine-tune` | 文件用途标签 |
| `--validate` | 否 | `true` | 上传前执行本地格式校验 |
| `--no-validate` | 否 | — | 跳过本地校验 |
#### 本地格式校验规则(提交前拦截)
校验逻辑在 `packages/core` 实现纯函数CLI 调用后展示错误:
1. **文件格式检查**:仅允许 `.jsonl``.zip`zip 内根目录必须有 `data.jsonl`
2. **JSONL 逐行校验**
- 每行可被 `JSON.parse`
- 顶层必须包含 `messages` 数组
- `messages` 中每项必须包含 `role`(枚举:`system` | `user` | `assistant`)和 `content`(非空字符串)
- 至少包含一条 `user` + 一条 `assistant` 消息
3. **数量校验**SFT 训练至少需要上千条数据(给出 warning 而非 hard fail阈值建议 ≥ 10 条 hard fail
4. **文件体积**:≤ 300MB
#### 校验失败输出示例
```
✗ Validation failed:
Line 3: missing "messages" field
Line 7: role "bot" is not valid (expected: system | user | assistant)
Line 12: "content" is empty string
Fix 3 errors above and retry.
```
#### API 调用
```
POST https://dashscope.aliyuncs.com/api/v1/files
Content-Type: multipart/form-data
Authorization: Bearer <api-key>
Body:
files: <binary>
purpose: "fine-tune"
Response 200:
{
"id": "file-xxxx",
"bytes": 12345,
"filename": "train.jsonl",
"purpose": "fine-tune",
"created_at": 1700000000
}
```
#### 输出
- 默认 text`✓ Uploaded file-xxxx (12.3 KB) — use this ID in bl finetune create`
- `--output json`:完整 response body
- `--quiet`:仅输出 `file-xxxx`
---
### 2.2 `bl finetune create`
**定位:** 创建一个 SFT 训练任务。核心设计原则——**预填合理默认超参 + 提交前二次确认**,降低 OOM/超参不合理导致的训练失败率。
#### CLI 签名
```
bl finetune create --model <model> --data <file_id> [hyperparams...]
```
| Flag | 必填 | 默认值 | 说明 |
| ------------------- | ---- | ------------ | -------------------------------------------- |
| `--model` | 是 | — | 基座模型(如 `qwen3-8b`, `qwen3-14b` |
| `--data` | 是 | — | 训练数据 file_idbl dataset upload 返回值) |
| `--validation-data` | 否 | — | 验证数据 file_id |
| `--epochs` | 否 | 3 | 训练轮次 (n_epochs) |
| `--batch-size` | 否 | 按模型自动选 | 批大小 |
| `--lr` | 否 | 按模型自动选 | 学习率 (learning_rate_multiplier) |
| `--warmup-ratio` | 否 | 0.1 | warmup 比例 |
| `--suffix` | 否 | — | 输出模型后缀名 |
| `--yes` / `-y` | 否 | — | 跳过确认直接提交 |
#### 预填默认超参策略
| 基座模型 | batch_size | lr_multiplier | n_epochs | 备注 |
| ---------- | ---------- | ------------- | -------- | ---------------- |
| qwen3-8b | 4 | 1e-5 | 3 | 小模型可大 batch |
| qwen3-14b | 2 | 5e-6 | 3 | 中模型防 OOM |
| qwen3-32b+ | 1 | 2e-6 | 2 | 大模型保守设置 |
> 以上为建议默认值,用户显式传参时覆盖。具体映射表在 `packages/core/src/finetune/defaults.ts` 维护。
#### 提交前交互确认
`--yes` 模式下,显示任务摘要等待确认:
```
┌─ Fine-tune Job Summary ──────────────────────┐
│ Model: qwen3-8b │
│ Training: file-abc123 (2,048 samples) │
│ Validation: (none) │
│ Epochs: 3 │
│ Batch size: 4 │
│ LR: 1e-5 │
│ Warmup: 0.1 │
│ Suffix: my-assistant │
│ │
│ Estimated cost: ~¥XX (based on token count) │
└───────────────────────────────────────────────┘
Proceed? [Y/n]
```
#### API 调用
```
POST https://dashscope.aliyuncs.com/api/v1/fine-tunes
Authorization: Bearer <api-key>
Content-Type: application/json
{
"model": "qwen3-8b",
"training_file_ids": ["file-abc123"],
"validation_file_ids": [],
"hyper_parameters": {
"n_epochs": 3,
"batch_size": 4,
"learning_rate": "1e-5",
"warmup_ratio": 0.1
},
"suffix": "my-assistant"
}
Response 200:
{
"job_id": "ft-xxxx",
"status": "PENDING",
"model": "qwen3-8b",
"created_at": "2025-01-01T00:00:00Z",
"training_file_ids": ["file-abc123"],
"hyper_parameters": {...},
"trained_model": null
}
```
#### 输出
- text`✓ Fine-tune job ft-xxxx created (PENDING). Track with: bl finetune status ft-xxxx`
- json完整 response body
- quiet`ft-xxxx`
---
### 2.3 `bl finetune status`
**定位:** 查询训练任务状态,支持 `--wait` 轮询模式。
#### CLI 签名
```
bl finetune status <job_id> [--wait] [--interval <seconds>]
```
| Flag | 必填 | 默认值 | 说明 |
| ------------ | ---- | ------ | ---------------- |
| `<job_id>` | 是 | — | 任务 ID |
| `--wait` | 否 | — | 持续轮询直到终态 |
| `--interval` | 否 | 30 | 轮询间隔(秒) |
#### 状态机
```
PENDING → RUNNING → SUCCEEDED
↘ FAILED
```
#### 输出text 模式)
单次查询:
```
Job: ft-xxxx
Status: RUNNING (elapsed 12m)
Model: qwen3-8b
Output: (pending)
```
`--wait` 模式spinner + 实时刷新):
```
⠋ ft-xxxx RUNNING [14:32 elapsed]
✓ ft-xxxx SUCCEEDED — trained model: qwen3-8b:ft-xxxx-20250101
Deploy with: bl deploy create --model qwen3-8b:ft-xxxx-20250101
```
失败时:
```
✗ ft-xxxx FAILED
Error: OutOfMemory — try reducing --batch-size or using a smaller model
```
---
### 2.4 `bl deploy create`
**定位:** 将训练好的模型(或 checkpoint部署为可调用的推理服务。
#### CLI 签名
```
bl deploy create --model <model_name> [--plan <plan>] [--capacity <n>]
```
| Flag | 必填 | 默认值 | 说明 |
| ------------ | ---- | ---------- | ----------------------------------------------- |
| `--model` | 是 | — | 待部署模型名称finetune 产出的 trained_model |
| `--plan` | 否 | `standard` | 部署方案 |
| `--capacity` | 否 | 依 plan | 并发容量 |
| `--wait` | 否 | — | 等待部署就绪 |
#### API 调用
```
POST https://dashscope.aliyuncs.com/api/v1/deployments
Authorization: Bearer <api-key>
Content-Type: application/json
{
"model_name": "qwen3-8b:ft-xxxx-20250101",
"plan": "standard",
"capacity": 2
}
Response 200:
{
"deployed_model": "qwen3-8b-ft-xxxx",
"model_name": "qwen3-8b:ft-xxxx-20250101",
"status": "PENDING",
"created_at": "..."
}
```
#### 输出
```
✓ Deployment created: qwen3-8b-ft-xxxx (PENDING)
Once RUNNING, call with: bl text chat --model qwen3-8b-ft-xxxx
Check status: bl deploy status qwen3-8b-ft-xxxx
```
---
## 三、P1 命令简要设计
### 3.1 `bl finetune logs <job_id>`
流式输出训练日志,支持 `--follow`(类似 `tail -f`)。输出 loss/step/epoch 信息。
### 3.2 `bl finetune checkpoints <job_id>`
列出可选 checkpointstep, loss, eval metrics支持 `--output json` 供脚本使用。可配合 `bl deploy create --model <checkpoint_model>` 部署指定 checkpoint。
### 3.3 `bl deploy status <deployed_model>`
查询部署状态及资源信息PENDING → RUNNING → STOPPED/FAILED
### 3.4 `bl deploy delete <deployed_model>`
下线部署。需部署处于 RUNNING/STOPPED/FAILED 状态。交互确认或 `--yes` 跳过。
### 3.5 `bl infer --model <deployed_model>`
实际可复用已有 `bl text chat --model <deployed_model>` 通路,作为别名/快捷方式。P1 考虑是否有独立存在必要。
---
## 四、代码架构方案
按照 monorepo 分层约定core 纯逻辑 / cli 是 UI
### packages/core 新增模块
```
packages/core/src/
├── finetune/
│ ├── index.ts # re-export
│ ├── api.ts # createFineTune, getFineTune, getFineTuneLogs, getCheckpoints
│ ├── defaults.ts # 模型 → 默认超参映射表
│ └── types.ts # FineTuneJob, HyperParameters, CheckpointInfo 类型
├── dataset/
│ ├── index.ts
│ ├── upload.ts # uploadDataset (multipart)
│ ├── validate.ts # validateJsonl (纯函数,逐行校验)
│ └── types.ts # DatasetFile, ValidationError 类型
└── deploy/
├── index.ts
├── api.ts # createDeployment, getDeployment, deleteDeployment
└── types.ts # Deployment, DeploymentStatus 类型
```
### packages/cli 新增命令
```
packages/cli/src/commands/
├── dataset/
│ └── upload.ts # bl dataset upload
├── finetune/
│ ├── create.ts # bl finetune create
│ ├── status.ts # bl finetune status
│ ├── logs.ts # bl finetune logs
│ └── checkpoints.ts # bl finetune checkpoints
└── deploy/
├── create.ts # bl deploy create
├── status.ts # bl deploy status
└── delete.ts # bl deploy delete
```
---
## 五、关键设计决策
### 5.1 数据格式校验放在 CLI 侧(提交前拦截)
训练失败 TOP 原因中"数据格式错误"占比高。与其等服务端 10 分钟后返回 FAILED不如 CLI 本地秒级校验:
- **validate.ts** 是纯函数,接收 ReadableStream/Buffer返回 `ValidationError[]`
- CLI 在 `dataset upload` 默认执行校验,`--no-validate` 允许跳过
- 未来可扩展为独立命令 `bl dataset validate <path>`
### 5.2 超参预填 + 确认而非强制
- core 维护 `defaults.ts` 映射:`model → { batch_size, lr, epochs }`
- CLI `finetune create` 未指定超参时自动填入
- 提交前展示完整参数面板(非 --yes 模式),避免"我以为用了默认但其实没传"
### 5.3 费用感知P1+
- 图像/语音/视频训练费用远高于文本。MVP 阶段Qwen 文本 SFT费用可控
- 后续扩展多模态时,在 confirm panel 中强化费用估算提示
- `bl quota check` 已存在,可在 `finetune create` 内部集成余额预检
### 5.4 `bl infer` 是否独立存在
建议 P1 阶段**不新增** `bl infer`,而是让 `bl text chat --model <deployed_model>` 直接工作。部署完成后的引导文案中指明这个用法即可。减少命令膨胀。
---
## 六、最小闭环用户操作流
```bash
# 1. 准备数据 → 上传(含校验)
bl dataset upload ./train.jsonl
# ✓ Uploaded file-abc123 (5.2 MB)
# 2. 创建训练任务(自动预填超参)
bl finetune create --model qwen3-8b --data file-abc123
# Shows summary panel → confirm → ✓ Job ft-xxxx created
# 3. 等待训练完成
bl finetune status ft-xxxx --wait
# ⠋ RUNNING [23:15] → ✓ SUCCEEDED: qwen3-8b:ft-xxxx-20250601
# 4. 部署模型
bl deploy create --model qwen3-8b:ft-xxxx-20250601 --wait
# ✓ Deployed: qwen3-8b-ft-xxxx (RUNNING)
# 5. 调用模型
bl text chat --model qwen3-8b-ft-xxxx "你好,介绍一下你自己"
# (正常推理输出)
```
---
## 七、实现顺序建议
```
Phase 1 (P0 — 最小闭环):
core: dataset/validate.ts → dataset/upload.ts → finetune/api.ts → deploy/api.ts
cli: dataset upload → finetune create → finetune status → deploy create
测试: 单元测试 validate.ts + e2e dry-run + 真实 API 端到端一次
Phase 2 (P1 — 可观测性):
finetune logs → finetune checkpoints → deploy status → deploy delete
费用估算集成
Phase 3 (后续):
bl dataset validate (独立命令)
bl dataset list (查看已上传)
bl finetune list (查看历史任务)
多模态 SFT 支持(图像/视频数据格式校验扩展)
```
---
## 八、风险与 TODO
| 风险点 | 影响 | 缓解措施 |
| ----------------- | ----------------- | --------------------------------------------- |
| OOM 训练失败 | 用户浪费时间/金钱 | 保守默认超参 + batch_size 自适应模型大小 |
| 数据格式错误 | 训练启动后才失败 | 本地校验拦截,启动秒级反馈 |
| 部署等待时间长 | 用户困惑 | `--wait` + 预估时间提示 |
| 费用超预期 | 账号欠费 | confirm panel 预估费用P1 集成 quota check |
| API endpoint 变动 | 调用失败 | 端点集中管理在 core/client/endpoints.ts |
+33 -17
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
---
@@ -27,15 +27,18 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
- **Console capabilities** — Browse Bailian apps (`app list`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
## Showcase: One-Sentence Cinematic Video
@@ -51,7 +54,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.0**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
### The single prompt
@@ -64,7 +67,7 @@ A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.0** in parallel.
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
@@ -108,9 +111,22 @@ bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
# Browser login (required for console capability commands)
bl auth login --console
# Browse apps / free-tier quota
# Browse apps / free-tier quota / usage statistics / workspaces
bl app list
bl usage free --model qwen3-max
bl usage free --expiring 30 # Quotas expiring within 30 days
bl usage free --sort remaining # Sort by remaining % ascending
bl usage stats --workspace-id <id> # Usage overview for a workspace
bl usage stats --model qwen-turbo --workspace-id <id> # Per-model usage
bl workspace list # List all workspaces
# Rate limit management
bl quota list # View RPM/TPM limits for all models
bl quota list --model qwen3.6-plus # View limits for a specific model
bl quota check # Current usage vs rate limits
bl quota check --model qwen3.6-plus --period 5 # Check usage over last 5 minutes
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
bl quota history # View quota change history
```
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
@@ -119,7 +135,7 @@ bl usage free --model qwen3-max
### DashScope API Key
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cli?source_channel=key_github&).
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
```bash
# Option 1: Environment variable
@@ -134,7 +150,7 @@ bl text chat --api-key sk-xxxxx --message "Hello"
### Console Login (OAuth)
Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in.
Required for console capability commands (`app list`, `usage free`, `usage stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
@@ -159,7 +175,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
bl config show
# Set defaults
bl config set --key region --value us
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
@@ -171,14 +187,14 @@ Config file location: `~/.bailian/config.json`
## Links
| Resource | URL |
| :--------------------------- | :---------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/ |
| Get API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
| Resource | URL |
| :--------------------------- | :---------------------------------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
+33 -17
View File
@@ -9,7 +9,7 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
---
@@ -27,15 +27,18 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **文本对话** — Qwen3.7-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`,查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
## 示例:一句话生成一部电影短片
@@ -51,7 +54,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.0**,百炼的文生/图生/参考生视频模型
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
### 唯一的提示词
@@ -62,7 +65,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.0**
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
@@ -103,9 +106,22 @@ bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代
# 浏览器登录(控制台能力相关命令需要)
bl auth login --console
# 浏览应用 / 免费额度
# 浏览应用 / 免费额度 / 用量统计 / 业务空间
bl app list
bl usage free --model qwen3-max
bl usage free --expiring 30 # 30 天内过期的额度
bl usage free --sort remaining # 按剩余百分比升序排列
bl usage stats --workspace-id <id> # 指定空间的用量概览
bl usage stats --model qwen-turbo --workspace-id <id> # 指定模型用量
bl workspace list # 列出所有业务空间
# 限流管理与提额
bl quota list # 查看所有模型的 RPM/TPM 限额
bl quota list --model qwen3.6-plus # 查看指定模型限额
bl quota check # 查看当前用量 vs 限流阈值
bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录
```
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
@@ -114,7 +130,7 @@ bl usage free --model qwen3-max
### DashScope API Key
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
```bash
# 方式一:环境变量
@@ -129,7 +145,7 @@ bl text chat --api-key sk-xxxxx --message "你好"
### 控制台登录OAuth
控制台能力命令(`app list``usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`app list``usage free``usage stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
@@ -154,7 +170,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
bl config show
# 设置默认值
bl config set --key region --value us
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
@@ -166,14 +182,14 @@ bl update
## 相关链接
| 资源 | 地址 |
| :---------------------- | :---------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
| 资源 | 地址 |
| :---------------------- | :---------------------------------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.2.1",
"version": "1.4.2",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
@@ -46,7 +46,8 @@
"dependencies": {
"bailian-cli-core": "workspace:*",
"boxen": "catalog:",
"chalk": "catalog:"
"chalk": "catalog:",
"undici": "catalog:"
},
"devDependencies": {
"@clack/prompts": "^0.7.0",
+72 -53
View File
@@ -29,41 +29,41 @@ function formatContextWindow(tokens: number): string {
}
const MODALITY_LABELS: Record<string, string> = {
Text: "文本",
Image: "图片",
Video: "视频",
Audio: "音频",
Text: "Text",
Image: "Image",
Video: "Video",
Audio: "Audio",
};
const CAPABILITY_LABELS: Record<string, string> = {
TG: "文本生成",
VU: "视觉理解",
IG: "图像生成",
VG: "视频生成",
TTS: "语音合成",
ASR: "语音识别",
Reasoning: "推理",
TG: "Text Gen",
VU: "Vision",
IG: "Image Gen",
VG: "Video Gen",
TTS: "Text-to-Speech",
ASR: "Speech-to-Text",
Reasoning: "Reasoning",
};
const BUDGET_LABELS: Record<string, string> = {
low: "低成本优先",
medium: "适中",
high: "高投入",
low: "Cost-Effective",
medium: "Balanced",
high: "High Investment",
};
const QUALITY_LABELS: Record<string, string> = {
flagship: "旗舰优先",
balanced: "均衡",
"cost-optimized": "性价比优先",
flagship: "Flagship",
balanced: "Balanced",
"cost-optimized": "Value",
};
const PREFERENCE_MODE_LABELS: Record<string, string> = {
scoped: "限定范围",
comparison: "对比评估",
alternative: "替代推荐",
scoped: "Scoped",
comparison: "Comparison",
alternative: "Alternative",
};
function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
const lines: string[] = [];
lines.push(colorize.cyan.bold("需求理解"));
lines.push(colorize.cyan.bold("Intent Analysis"));
if (intent.taskSummary) {
lines.push("");
@@ -72,7 +72,7 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
if (intent.scenarioHints.length) {
lines.push("");
lines.push(`${colorize.dim("场景特征")} ${intent.scenarioHints.join(" · ")}`);
lines.push(`${colorize.dim("Scenario")} ${intent.scenarioHints.join(" · ")}`);
}
const inputLabels = intent.inputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
@@ -80,40 +80,40 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
if (inputLabels.length || outputLabels.length) {
lines.push("");
const parts: string[] = [];
if (inputLabels.length) parts.push(`${colorize.dim("输入")} ${inputLabels.join(", ")}`);
if (outputLabels.length) parts.push(`${colorize.dim("输出")} ${outputLabels.join(", ")}`);
if (inputLabels.length) parts.push(`${colorize.dim("Input")} ${inputLabels.join(", ")}`);
if (outputLabels.length) parts.push(`${colorize.dim("Output")} ${outputLabels.join(", ")}`);
lines.push(parts.join(" "));
}
const capLabels = intent.requiredCapabilities.map((cap) => CAPABILITY_LABELS[cap] ?? cap);
if (capLabels.length) {
lines.push(`${colorize.dim("所需能力")} ${capLabels.join(", ")}`);
lines.push(`${colorize.dim("Capabilities")} ${capLabels.join(", ")}`);
}
const budgetLabel = BUDGET_LABELS[intent.budget] ?? intent.budget;
const qualityLabel = QUALITY_LABELS[intent.qualityPreference] ?? intent.qualityPreference;
lines.push("");
lines.push(
`${colorize.dim("预算倾向")} ${budgetLabel} ${colorize.dim("质量偏好")} ${qualityLabel}`,
`${colorize.dim("Budget")} ${budgetLabel} ${colorize.dim("Quality")} ${qualityLabel}`,
);
const preference = intent.modelPreference;
if (preference && preference.mode !== "unconstrained") {
lines.push("");
const modeLabel = PREFERENCE_MODE_LABELS[preference.mode] ?? preference.mode;
const prefParts = [colorize.dim("推荐模式") + ` ${colorize.yellow(modeLabel)}`];
const prefParts = [colorize.dim("Mode") + ` ${colorize.yellow(modeLabel)}`];
if (preference.targets?.length) {
prefParts.push(colorize.dim("目标") + ` ${preference.targets.join(", ")}`);
prefParts.push(colorize.dim("Targets") + ` ${preference.targets.join(", ")}`);
}
if (preference.excludes?.length) {
prefParts.push(colorize.dim("排除") + ` ${preference.excludes.join(", ")}`);
prefParts.push(colorize.dim("Excludes") + ` ${preference.excludes.join(", ")}`);
}
lines.push(prefParts.join(" "));
}
if (intent.segments?.length) {
lines.push("");
lines.push(colorize.dim("任务拆解"));
lines.push(colorize.dim("Pipeline"));
for (const [idx, segment] of intent.segments.entries()) {
const outMods = segment.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod).join(", ");
lines.push(
@@ -131,19 +131,19 @@ function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
});
}
const RECOMMEND_LABELS = ["最佳推荐", "次优选择", "备选参考"];
const RECOMMEND_LABELS = ["Best Pick", "Runner-Up", "Alternative"];
function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstance): string {
const labelColors = [colorize.green.bold, colorize.blue.bold, colorize.magenta.bold];
const colorFn = labelColors[index] ?? colorize.white.bold;
const label = RECOMMEND_LABELS[index] ?? `推荐 #${index + 1}`;
const label = RECOMMEND_LABELS[index] ?? `#${index + 1}`;
const lines: string[] = [];
lines.push(colorFn(`推荐 #${index + 1}${label}`));
lines.push(colorFn(`⬢ #${index + 1}${label}`));
lines.push("");
lines.push(`${colorize.bold(rec.name)} ${colorize.dim(`(${rec.model})`)}`);
lines.push("");
lines.push(`${colorize.cyan("推荐理由")} ${rec.reason}`);
lines.push(`${colorize.cyan("Why")} ${rec.reason}`);
if (rec.highlights.length) {
lines.push("");
@@ -153,8 +153,8 @@ function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstanc
}
const meta: string[] = [];
if (rec.contextWindow) meta.push(`上下文 ${formatContextWindow(rec.contextWindow)}`);
if (rec.maxOutputTokens) meta.push(`最大输出 ${formatContextWindow(rec.maxOutputTokens)}`);
if (rec.contextWindow) meta.push(`Context ${formatContextWindow(rec.contextWindow)}`);
if (rec.maxOutputTokens) meta.push(`Max Output ${formatContextWindow(rec.maxOutputTokens)}`);
if (meta.length) {
lines.push("");
lines.push(colorize.dim(meta.join(" · ")));
@@ -163,7 +163,7 @@ function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstanc
const docLink = buildDocLink(rec.docUrl);
if (docLink) {
lines.push("");
lines.push(colorize.dim(`文档 ${docLink}`));
lines.push(colorize.dim(`Docs ${docLink}`));
}
return boxen(lines.join("\n"), {
@@ -183,7 +183,7 @@ function formatSingleResult(results: RecommendedModel[], noColor: boolean): stri
function formatPipelineResult(summary: string, steps: PipelineStep[], noColor: boolean): string {
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
const lines: string[] = [];
lines.push(` ${colorize.yellow.bold("⚡ 组合方案")} ${summary}`);
lines.push(` ${colorize.yellow.bold("⚡ Pipeline")} ${summary}`);
for (const [stepIdx, { step, recommendations, warnings }] of steps.entries()) {
lines.push("");
@@ -234,12 +234,12 @@ export default defineCommand({
},
],
examples: [
'bl advisor recommend --message "我要做一个能理解图片的客服机器人"',
'bl advisor recommend --message "做一个Agent自动根据用户意图生成动画片"',
'bl advisor recommend --message "法律合同审查,要求高精准度"',
'bl advisor recommend --message "做一个低成本高并发的在线客服" --output json',
'bl advisor recommend --message "长文本摘要" --dry-run',
"bl advisor recommend # 交互式输入需求",
'bl advisor recommend --message "I need a visual-understanding chatbot"',
'bl advisor recommend --message "Build an Agent that auto-generates animations"',
'bl advisor recommend --message "Legal contract review, high precision required"',
'bl advisor recommend --message "Low-cost high-concurrency online customer service" --output json',
'bl advisor recommend --message "Long document summarization" --dry-run',
"bl advisor recommend # Interactive input",
],
async run(config: Config, flags: GlobalFlags) {
const positional = ((flags as Record<string, unknown>)._positional as string[]) ?? [];
@@ -247,14 +247,14 @@ export default defineCommand({
if (!userInput.trim()) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: "描述你的需求:" });
const hint = await promptText({ message: "Describe your requirement:" });
if (!hint) {
process.stderr.write("已取消。\n");
process.stderr.write("Cancelled.\n");
process.exit(1);
}
userInput = hint;
} else {
failIfMissing("message", 'bl advisor recommend "你的需求"');
failIfMissing("message", 'bl advisor recommend "your requirement"');
}
}
@@ -262,16 +262,16 @@ export default defineCommand({
const format = detectOutputFormat(config.output);
const modelsOptions: GetModelsOptions = {
onPrepareStart: () => process.stderr.write("初始化中...\n"),
onPrepareStart: () => process.stderr.write("Initializing model data...\n"),
};
process.stderr.write("正在分析需求...\n");
process.stderr.write("Analyzing your request...\n");
const [allModels, intent] = await Promise.all([
getModels(config, modelsOptions),
analyzeIntent(config, userInput),
]);
if (intent.confidence === 0) {
process.stderr.write("需求分析超时,使用默认参数继续...\n");
process.stderr.write("Intent analysis timed out, using defaults...\n");
} else {
process.stderr.write("\n");
}
@@ -297,7 +297,7 @@ export default defineCommand({
}
// Stage 3: LLM Ranking
const spinner = createSpinner("正在推荐最佳模型...");
const spinner = createSpinner("Recommending best models...");
spinner.start();
const result = await rankModels(config, candidates, intent, userInput, top);
@@ -305,12 +305,31 @@ export default defineCommand({
spinner.stop();
if (isEmptyResult(result)) {
emitBare("暂无满足该需求的模型。");
emitBare("No suitable models found for this request.");
return;
}
if (format !== "text") {
emitResult(result, format);
emitResult(
{
intent: {
taskSummary: intent.taskSummary,
scenarioHints: intent.scenarioHints,
complexity: intent.complexity,
inputModality: intent.inputModality,
outputModality: intent.outputModality,
requiredCapabilities: intent.requiredCapabilities,
budget: intent.budget,
qualityPreference: intent.qualityPreference,
modelPreference:
intent.modelPreference?.mode !== "unconstrained" ? intent.modelPreference : undefined,
segments: intent.segments,
},
result,
candidates: candidates.length,
},
format,
);
return;
}
+6 -6
View File
@@ -35,12 +35,12 @@ export default defineCommand({
{ flag: "--has-thoughts", description: "Show agent thinking process" },
],
examples: [
'bl app call --app-id abc123 --prompt "你好"',
'bl app call --app-id abc123 --prompt "描述这张图片" --image https://example.com/photo.jpg',
'bl app call --app-id abc123 --prompt "分析图片" --image img1.jpg --image img2.jpg',
'bl app call --app-id abc123 --prompt "继续" --session-id sess_xxx --stream',
'bl app call --app-id abc123 --prompt "搜索资料" --pipeline-ids pipe1,pipe2',
'bl app call --app-id abc123 --prompt "开始" --biz-params \'{"key":"value"}\'',
'bl app call --app-id abc123 --prompt "Hello"',
'bl app call --app-id abc123 --prompt "Describe this image" --image https://example.com/photo.jpg',
'bl app call --app-id abc123 --prompt "Analyze the image" --image img1.jpg --image img2.jpg',
'bl app call --app-id abc123 --prompt "Continue" --session-id sess_xxx --stream',
'bl app call --app-id abc123 --prompt "Search for materials" --pipeline-ids pipe1,pipe2',
'bl app call --app-id abc123 --prompt "Start" --biz-params \'{"key":"value"}\'',
],
async run(config: Config, flags: GlobalFlags) {
const appId = flags.appId as string;
+11 -9
View File
@@ -13,6 +13,7 @@ const APP_LIST_API = "zeldaEasy.broadscope-bailian.app-control.list";
export default defineCommand({
name: "app list",
description: "List Bailian applications",
skipDefaultApiKeySetup: true,
usage: "bl app list [flags]",
options: [
{
@@ -29,14 +30,20 @@ export default defineCommand({
description: "Results per page (default: 30)",
type: "number",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl app list",
"bl app list --name 客服",
"bl app list --name customer service",
"bl app list --page 2 --page-size 10",
"bl app list --output json",
],
@@ -44,7 +51,6 @@ export default defineCommand({
const name = (flags.name as string) || "";
const pageNo = (flags.page as number) || 1;
const pageSize = (flags.pageSize as number) || 30;
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
const credential = await resolveConsoleGatewayCredential(config);
@@ -61,17 +67,13 @@ export default defineCommand({
};
if (config.dryRun) {
emitResult(
{ api: APP_LIST_API, data, region, token: credential.token.slice(0, 8) + "..." },
format,
);
emitResult({ api: APP_LIST_API, data, token: credential.token.slice(0, 8) + "..." }, format);
return;
}
const result = (await callConsoleGateway(config, credential.token, {
api: APP_LIST_API,
data,
region,
})) as any;
const list: unknown[] = result?.data?.DataV2?.data?.data?.list ?? [];
+193 -13
View File
@@ -5,18 +5,24 @@ import http from "node:http";
import {
BailianError,
ExitCode,
chatEndpoint,
getConfigPath,
readConfigFile,
requestJson,
writeConfigFile,
type Config,
} from "bailian-cli-core";
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
const MAX_AUTH_CALLBACK_BODY = 65536;
const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com";
const CONSOLE_ORIGINS: Record<string, string> = {
domestic: "https://bailian.console.aliyun.com",
international: "https://modelstudio.console.alibabacloud.com",
};
export function resolveConsoleOrigin(): string {
return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN;
export function resolveConsoleOrigin(site?: string): string {
return (site && CONSOLE_ORIGINS[site]) || CONSOLE_ORIGINS.domestic!;
}
function readBodyBounded(req: http.IncomingMessage): Promise<string> {
@@ -210,9 +216,76 @@ function parseApiKeyFromRawBody(raw: string, contentType: string): string | null
return null;
}
type CallbackExtras = Pick<
CallbackCredentials,
"baseUrl" | "consoleSite" | "consoleRegion" | "consoleSwitchAgent" | "workspaceId"
>;
function stringField(o: Record<string, unknown>, ...keys: string[]): string | null {
for (const k of keys) {
const v = o[k];
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
function parseExtrasFromRawBody(raw: string, contentType: string): CallbackExtras {
const empty: CallbackExtras = {
baseUrl: null,
consoleSite: null,
consoleRegion: null,
consoleSwitchAgent: null,
workspaceId: null,
};
if (!raw.trim()) return empty;
let obj: Record<string, unknown> | null = null;
const ct = contentType.toLowerCase();
if (ct.includes("application/json") || ct.includes("text/json")) {
try {
const parsed = JSON.parse(raw.trim());
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) obj = parsed;
} catch {
/* */
}
}
if (!obj && ct.includes("application/x-www-form-urlencoded")) {
try {
const params = new URLSearchParams(raw.trim());
obj = Object.fromEntries(params);
} catch {
/* */
}
}
if (!obj) {
try {
const parsed = JSON.parse(raw.trim());
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) obj = parsed;
} catch {
/* */
}
}
if (!obj) return empty;
return {
baseUrl: stringField(obj, "base_url", "baseUrl"),
consoleSite: stringField(obj, "console_site", "consoleSite"),
consoleRegion: stringField(obj, "console_region", "consoleRegion"),
consoleSwitchAgent: stringField(obj, "console_switch_agent", "consoleSwitchAgent"),
workspaceId: stringField(obj, "workspace_id", "workspaceId"),
};
}
interface CallbackCredentials {
accessToken: string | null;
apiKey: string | null;
baseUrl: string | null;
consoleSite: string | null;
consoleRegion: string | null;
consoleSwitchAgent: string | null;
workspaceId: string | null;
}
async function extractCredentialsFromRequest(
@@ -222,12 +295,30 @@ async function extractCredentialsFromRequest(
const accessTokenFromQuery =
u.searchParams.get("access_token") ?? u.searchParams.get("accessToken");
const apiKeyFromQuery = u.searchParams.get("api_key") ?? u.searchParams.get("apiKey");
const baseUrlFromQuery = u.searchParams.get("base_url") ?? u.searchParams.get("baseUrl");
const consoleSiteFromQuery =
u.searchParams.get("console_site") ?? u.searchParams.get("consoleSite");
const consoleRegionFromQuery =
u.searchParams.get("console_region") ?? u.searchParams.get("consoleRegion");
const consoleSwitchAgentFromQuery =
u.searchParams.get("console_switch_agent") ?? u.searchParams.get("consoleSwitchAgent");
const workspaceIdFromQuery =
u.searchParams.get("workspace_id") ?? u.searchParams.get("workspaceId");
const extras = {
baseUrl: baseUrlFromQuery?.trim() || null,
consoleSite: consoleSiteFromQuery?.trim() || null,
consoleRegion: consoleRegionFromQuery?.trim() || null,
consoleSwitchAgent: consoleSwitchAgentFromQuery?.trim() || null,
workspaceId: workspaceIdFromQuery?.trim() || null,
};
const m = req.method ?? "GET";
if (m !== "POST" && m !== "PUT" && m !== "PATCH") {
return {
accessToken: accessTokenFromQuery?.trim() || null,
apiKey: apiKeyFromQuery?.trim() || null,
...extras,
};
}
@@ -239,12 +330,24 @@ async function extractCredentialsFromRequest(
return {
accessToken: accessTokenFromQuery?.trim() || null,
apiKey: apiKeyFromQuery?.trim() || null,
...extras,
};
}
const accessToken = accessTokenFromQuery?.trim() || parseAccessTokenFromRawBody(raw, contentType);
const apiKey = apiKeyFromQuery?.trim() || parseApiKeyFromRawBody(raw, contentType);
return { accessToken, apiKey };
const bodyExtras = parseExtrasFromRawBody(raw, contentType);
return {
accessToken,
apiKey,
baseUrl: extras.baseUrl || bodyExtras.baseUrl,
consoleSite: extras.consoleSite || bodyExtras.consoleSite,
consoleRegion: extras.consoleRegion || bodyExtras.consoleRegion,
consoleSwitchAgent: extras.consoleSwitchAgent || bodyExtras.consoleSwitchAgent,
workspaceId: extras.workspaceId || bodyExtras.workspaceId,
};
}
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
@@ -276,9 +379,69 @@ function openInBrowser(url: string): Promise<void> {
});
}
const RETRY_DELAY_BASE_MS = 500;
function canRetry(err: unknown): boolean {
if (err instanceof BailianError) {
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) return true;
const status = err.api?.httpStatus;
return status === 401 || (status !== undefined && status >= 500);
}
if (err instanceof Error) {
return (
err.name === "AbortError" ||
err.name === "TimeoutError" ||
err.message.includes("timed out") ||
err.message === "fetch failed"
);
}
return false;
}
export async function validateAndPersistApiKey(
config: Config,
key: string,
baseUrl: string,
): Promise<void> {
process.stderr.write("Testing key... ");
const testConfig = { ...config, apiKey: key, baseUrl };
const requestOpts = {
url: chatEndpoint(testConfig.baseUrl),
method: "POST",
timeout: Math.min(config.timeout, 30),
body: {
model: "qwen3.7-max",
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
},
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(testConfig, requestOpts);
break;
} catch (err) {
if (attempt >= 3 || !canRetry(err)) {
process.stderr.write("Failed\n");
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
cause: err,
});
}
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
process.stderr.write("Valid\n");
const existing = readConfigFile() as Record<string, unknown>;
existing.api_key = key;
await writeConfigFile(existing);
}
export async function runConsoleLogin(
consoleOrigin: string,
opts?: { needApiKey?: boolean; onApiKey?: (key: string) => Promise<void> },
config: Config,
opts?: { needApiKey?: boolean },
): Promise<void> {
const state = randomBytes(16).toString("hex");
let callbackError: unknown;
@@ -301,18 +464,35 @@ export async function runConsoleLogin(
return;
}
const { accessToken, apiKey } = await extractCredentialsFromRequest(req);
const {
accessToken,
apiKey,
baseUrl,
consoleSite,
consoleRegion,
consoleSwitchAgent,
workspaceId,
} = await extractCredentialsFromRequest(req);
if (accessToken || apiKey) {
const hasConfig =
accessToken || baseUrl || consoleSite || consoleRegion || consoleSwitchAgent || workspaceId;
if (hasConfig || apiKey) {
try {
if (accessToken) {
if (hasConfig) {
const existing = readConfigFile() as Record<string, unknown>;
existing.access_token = accessToken;
if (accessToken) existing.access_token = accessToken;
if (baseUrl) existing.base_url = baseUrl;
if (consoleSite) existing.console_site = consoleSite;
if (consoleRegion) existing.console_region = consoleRegion;
if (consoleSwitchAgent) existing.console_switch_agent = Number(consoleSwitchAgent);
if (workspaceId) existing.workspace_id = workspaceId;
await writeConfigFile(existing);
process.stderr.write(`access_token saved to ${getConfigPath()}\n`);
process.stderr.write(`Config saved to ${getConfigPath()}\n`);
}
if (apiKey && opts?.onApiKey) {
await opts.onApiKey(apiKey);
if (apiKey) {
const testBaseUrl = baseUrl || config.baseUrl;
await validateAndPersistApiKey(config, apiKey, testBaseUrl);
}
} catch (err: unknown) {
callbackError = err;
@@ -329,7 +509,7 @@ export async function runConsoleLogin(
});
res.end("OK\n");
if (accessToken || apiKey) {
if (hasConfig || apiKey) {
server.close();
}
} catch {
+22 -71
View File
@@ -1,13 +1,8 @@
import {
BailianError,
ExitCode,
chatEndpoint,
defineCommand,
getConfigPath,
isInteractive,
maskToken,
readConfigFile,
requestJson,
writeConfigFile,
type Config,
type GlobalFlags,
@@ -16,78 +11,27 @@ import { printQuickStart } from "../../output/banner.ts";
import { emitBare } from "../../output/output.ts";
import { promptConfirm } from "../../output/prompt.ts";
import { printCurrentCommandHelp } from "../../utils/command-help.ts";
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
const RETRY_DELAY_BASE_MS = 500;
function canRetry(err: unknown): boolean {
if (err instanceof BailianError) {
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) {
return true;
}
const status = err.api?.httpStatus;
return status === 401 || (status !== undefined && status >= 500);
}
if (err instanceof Error) {
return (
err.name === "AbortError" ||
err.name === "TimeoutError" ||
err.message.includes("timed out") ||
err.message === "fetch failed"
);
}
return false;
}
async function validateKeyAndPersist(config: Config, key: string): Promise<void> {
process.stderr.write("Testing key... ");
const testConfig = { ...config, apiKey: key };
const requestOpts = {
url: chatEndpoint(testConfig.baseUrl),
method: "POST",
timeout: Math.min(config.timeout, 30),
body: {
model: "qwen3.7-max",
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
},
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(testConfig, requestOpts);
break;
} catch (err) {
if (attempt >= 3 || !canRetry(err)) {
process.stderr.write("\n");
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
cause: err,
});
}
// retry delay: 500ms, 1000ms, 2000ms
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
process.stderr.write("Valid\n");
const existing = readConfigFile() as Record<string, unknown>;
existing.api_key = key;
await writeConfigFile(existing);
process.stderr.write(`Saved to ${getConfigPath()}\n`);
}
import {
resolveConsoleOrigin,
runConsoleLogin,
validateAndPersistApiKey,
} from "./login-console.ts";
export default defineCommand({
name: "auth login",
description: "Authenticate with API key or console browser login (credentials can coexist)",
skipDefaultApiKeySetup: true,
usage: "bl auth login --api-key <key> | bl auth login --console",
options: [
{ flag: "--api-key <key>", description: "DashScope API key to store" },
{
flag: "--base-url <url>",
description: "DashScope API base URL (used with --api-key for validation)",
},
{
flag: "--console",
description: "Sign in via browser; opens the console login URL in your default browser",
type: "boolean",
description:
"Sign in via browser; use --console-site to choose domestic (default) or international",
},
],
examples: ["bl auth login --api-key sk-xxxxx", "bl auth login --console"],
@@ -100,9 +44,8 @@ export default defineCommand({
return;
}
const hasApiKey = !!(config.apiKey || config.fileApiKey);
await runConsoleLogin(resolveConsoleOrigin(), {
await runConsoleLogin(resolveConsoleOrigin(config.consoleSite || "domestic"), config, {
needApiKey: !hasApiKey,
onApiKey: (key) => validateKeyAndPersist(config, key),
});
return;
}
@@ -130,8 +73,16 @@ export default defineCommand({
process.exit(0);
}
const baseUrl = (flags.baseUrl as string) || undefined;
const effectiveConfig = baseUrl ? { ...config, baseUrl } : config;
if (!config.dryRun) {
await validateKeyAndPersist(config, key);
if (baseUrl) {
const existing = readConfigFile() as Record<string, unknown>;
existing.base_url = baseUrl;
await writeConfigFile(existing);
}
await validateAndPersistApiKey(effectiveConfig, key, effectiveConfig.baseUrl);
printQuickStart();
} else {
emitBare("Would validate and save API key.");
+1
View File
@@ -20,6 +20,7 @@ async function clearConsoleToken(): Promise<boolean> {
export default defineCommand({
name: "auth logout",
description: "Clear stored credentials",
skipDefaultApiKeySetup: true,
usage: "bl auth logout [--console] [--yes] [--dry-run]",
options: [
{
+12
View File
@@ -142,6 +142,18 @@ export default defineCommand({
name: "auth status",
description: "Show current authentication state",
usage: "bl auth status",
options: [
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: ["bl auth status", "bl auth status --output json"],
async run(config: Config, _flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
+14
View File
@@ -36,9 +36,16 @@ import speechRecognize from "./speech/recognize.ts";
import fileUpload from "./file/upload.ts";
import consoleCall from "./console/call.ts";
import usageFree from "./usage/free.ts";
import usageFreetier from "./usage/freetier.ts";
import usageStats from "./usage/stats.ts";
import pipelineRun from "./pipeline/run.ts";
import pipelineValidate from "./pipeline/validate.ts";
import advisorRecommend from "./advisor/recommend.ts";
import workspaceList from "./workspace/list.ts";
import quotaList from "./quota/list.ts";
import quotaRequest from "./quota/request.ts";
import quotaHistory from "./quota/history.ts";
import quotaCheck from "./quota/check.ts";
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
export const commands: Record<string, Command> = {
@@ -74,11 +81,18 @@ export const commands: Record<string, Command> = {
"file upload": fileUpload,
"console call": consoleCall,
"usage free": usageFree,
"usage freetier": usageFreetier,
"usage stats": usageStats,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"config show": configShow,
"config set": configSet,
"config export-schema": configExportSchema,
"advisor recommend": advisorRecommend,
"workspace list": workspaceList,
"quota list": quotaList,
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
update: update,
};
@@ -13,6 +13,7 @@ export default defineCommand({
name: "config export-schema",
description:
"Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas",
skipDefaultApiKeySetup: true,
usage: 'bl config export-schema [--command "<name>"]',
options: [
{
+2 -9
View File
@@ -12,7 +12,6 @@ import {
import { emitResult } from "../../output/output.ts";
const VALID_KEYS = [
"region",
"base_url",
"output",
"output_dir",
@@ -53,12 +52,13 @@ const KEY_ALIASES: Record<string, string> = {
export default defineCommand({
name: "config set",
description: "Set a config value",
skipDefaultApiKeySetup: true,
usage: "bl config set --key <key> --value <value>",
options: [
{
flag: "--key <key>",
description:
"Config key (region, base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)",
"Config key (base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)",
},
{ flag: "--value <value>", description: "Value to set" },
],
@@ -90,13 +90,6 @@ export default defineCommand({
}
// Validate specific values
if (resolvedKey === "region" && !["cn", "us", "intl"].includes(value)) {
throw new BailianError(
`Invalid region "${value}". Valid values: cn, us, intl`,
ExitCode.USAGE,
);
}
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
throw new BailianError(
`Invalid output format "${value}". Valid values: text, json`,
+9 -12
View File
@@ -12,6 +12,7 @@ import { emitResult } from "../../output/output.ts";
export default defineCommand({
name: "config show",
description: "Display current configuration",
skipDefaultApiKeySetup: true,
usage: "bl config show",
examples: ["bl config show", "bl config show --output json"],
async run(config: Config, _flags: GlobalFlags) {
@@ -19,25 +20,21 @@ export default defineCommand({
const format = detectOutputFormat(config.output);
const result: Record<string, unknown> = {
region: config.region,
...file,
base_url: config.baseUrl,
output: config.output,
timeout: config.timeout,
config_file: getConfigPath(),
};
// Mask API key if present
if (file.api_key) {
result.api_key = maskToken(file.api_key);
if (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);
}
if (file.access_token) {
result.access_token = maskToken(file.access_token);
}
// Default models
if (file.default_text_model) result.default_text_model = file.default_text_model;
if (file.default_video_model) result.default_video_model = file.default_video_model;
if (file.default_image_model) result.default_image_model = file.default_image_model;
emitResult(result, format);
},
+20 -6
View File
@@ -1,6 +1,7 @@
import {
defineCommand,
callConsoleGateway,
effectiveConsoleGatewayConfig,
resolveConsoleGatewayCredential,
CONSOLE_GATEWAY_NO_TOKEN_MESSAGE,
BailianError,
@@ -14,6 +15,7 @@ import { emitResult } from "../../output/output.ts";
export default defineCommand({
name: "console call",
description: "Call a Bailian console API via the CLI gateway",
skipDefaultApiKeySetup: true,
usage: "bl console call --api <api> --data <json> [flags]",
options: [
{
@@ -26,14 +28,20 @@ export default defineCommand({
description: "Request data as JSON string",
required: true,
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
`bl console call --api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`bl console call --api some.api.name --data '{"key":"value"}' --region cn-beijing`,
`bl console call --api some.api.name --data '{"key":"value"}' --console-region cn-beijing`,
],
async run(config: Config, flags: GlobalFlags) {
const api = flags.api as string;
@@ -50,7 +58,6 @@ export default defineCommand({
process.exit(1);
}
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
let token: string | undefined;
@@ -63,14 +70,21 @@ export default defineCommand({
}
if (config.dryRun) {
emitResult({ api, data, region, token: token ? token.slice(0, 8) + "..." : null }, format);
emitResult(
{
api,
data,
token: token ? token.slice(0, 8) + "..." : null,
...effectiveConsoleGatewayConfig(config),
},
format,
);
return;
}
const result = await callConsoleGateway(config, token, {
api,
data,
region,
});
emitResult(result, format);
-1
View File
@@ -12,7 +12,6 @@ import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "file upload",
description: "Upload a local file to DashScope temporary storage (48h)",
apiDocs: "/developer-reference/get-temporary-file-url",
usage: "bl file upload --file <path> --model <model>",
options: [
{
+3 -4
View File
@@ -32,7 +32,6 @@ import {
export default defineCommand({
name: "image edit",
description: "Edit an existing image with text instructions (Qwen-Image)",
apiDocs: "/developer-reference/qwen-image-edit-api",
usage: "bl image edit --image <url> --prompt <text> [flags]",
options: [
{
@@ -65,11 +64,11 @@ export default defineCommand({
{ flag: "--out-prefix <prefix>", description: "Filename prefix (default: edited)" },
],
examples: [
'bl image edit --image ./photo.png --prompt "把背景换成海滩"',
'bl image edit --image ./photo.png --prompt "Replace the background with a beach"',
'bl image edit --image https://example.com/logo.png --prompt "Change color to blue" --n 3',
'bl image edit --image ./a.png --image ./b.png --prompt "把两张图合并成一张拼图"',
'bl image edit --image ./a.png --image ./b.png --prompt "Merge two images into one collage"',
'bl image edit --image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
'bl image edit --image ./photo.png --prompt "把背景换成海滩" --watermark false',
'bl image edit --image ./photo.png --prompt "Replace the background with a beach" --watermark false',
],
async run(config: Config, flags: GlobalFlags) {
// Normalize --image to string array (supports both single and repeated flags)
+1 -2
View File
@@ -43,7 +43,6 @@ function isSyncModel(model: string): boolean {
export default defineCommand({
name: "image generate",
description: "Generate images (Qwen-Image / wan2.x)",
apiDocs: "/best-practice/wanx/text-to-image",
usage: "bl image generate --prompt <text> [flags]",
options: [
{ flag: "--prompt <text>", description: "Image description", required: true },
@@ -83,7 +82,7 @@ export default defineCommand({
},
],
examples: [
'bl image generate --prompt "一只穿太空服的猫在火星上"',
'bl image generate --prompt "A cat in a spacesuit on Mars"',
'bl image generate --prompt "Logo design" --n 3 --out-dir ./generated/',
'bl image generate --prompt "Mountain landscape" --size 2688*1536',
'bl image generate --prompt "A castle" --seed 42 --prompt-extend false',
+269 -112
View File
@@ -1,15 +1,21 @@
import {
defineCommand,
knowledgeRetrieveEndpoint,
signRequest,
requestJson,
detectOutputFormat,
maskToken,
resolveCredential,
trackingHeaders,
type Config,
type GlobalFlags,
type KnowledgeRetrieveRequest,
type KnowledgeRetrieveResponse,
type DashScopeKnowledgeRetrieveRequest,
type DashScopeKnowledgeRetrieveResponse,
type OutputFormat,
BailianError,
ExitCode,
trackingHeaders,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
@@ -18,24 +24,61 @@ const BAILIAN_HOST = "bailian.cn-beijing.aliyuncs.com";
export default defineCommand({
name: "knowledge retrieve",
description: "Retrieve from a Bailian knowledge base (requires AK/SK)",
description: "Retrieve from a Bailian knowledge base",
skipDefaultApiKeySetup: true,
usage: "bl knowledge retrieve --index-id <id> --query <text> [flags]",
options: [
{ flag: "--index-id <id>", description: "Knowledge base index ID (required)", required: true },
{ flag: "--query <text>", description: "Search query (required)", required: true },
{
flag: "--workspace-id <id>",
description: "Bailian workspace ID (or env BAILIAN_WORKSPACE_ID)",
flag: "--dense-similarity-top-k <n>",
description: "Dense retrieval top K",
type: "number",
},
{ flag: "--top-k <n>", description: "Number of results (default: 10)", type: "number" },
{ flag: "--rerank", description: "Enable rerank" },
{
flag: "--sparse-similarity-top-k <n>",
description: "Sparse retrieval top K",
type: "number",
},
{ flag: "--rerank", description: "Enable reranking" },
{ flag: "--rerank-top-n <n>", description: "Rerank top N results", type: "number" },
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (or env)" },
{ flag: "--access-key-secret <key>", description: "Alibaba Cloud Access Key Secret (or env)" },
{
flag: "--rerank-model <name>",
description: "Rerank model, e.g. qwen3-rerank-hybrid",
},
{
flag: "--rerank-mode <mode>",
description: "Rerank mode: qa, similar, or custom",
},
{
flag: "--rerank-instruct <text>",
description: "Custom rerank instruction, when mode=custom",
},
{
flag: "--top-k <n>",
description: "Number of results (deprecated, use --rerank-top-n)",
type: "number",
},
{
flag: "--workspace-id <id>",
description: "Bailian workspace ID (only needed for deprecated AK/SK auth)",
},
{
flag: "--access-key-id <key>",
description: "Deprecated: use global --api-key instead",
},
{
flag: "--access-key-secret <key>",
description: "Deprecated: use global --api-key instead",
},
],
notes: [
"Authentication: pass `--api-key <key>`. AK/SK auth is deprecated and will be removed in a future version.",
"`--workspace-id` is NOT required when using --api-key.",
],
examples: [
'bl knowledge retrieve --index-id idx_xxx --query "如何使用阿里云百炼" --workspace-id ws_xxx',
'bl knowledge retrieve --index-id idx_xxx --query "API限流" --top-k 5 --rerank',
'bl knowledge retrieve --index-id idx_xxx --query "How to use Alibaba Cloud Bailian"',
'bl knowledge retrieve --api-key $DASHSCOPE_API_KEY --index-id idx_xxx --query "RAG retrieval" --rerank --rerank-model qwen3-rerank-hybrid',
],
async run(config: Config, flags: GlobalFlags) {
const indexId = flags.indexId as string;
@@ -44,112 +87,226 @@ export default defineCommand({
const query = flags.query as string;
if (!query) failIfMissing("query", "bl knowledge retrieve --index-id <id> --query <text>");
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
if (!accessKeyId || !accessKeySecret) {
throw new BailianError(
"Knowledge retrieve requires Alibaba Cloud AK/SK.\n" +
"Set via: --access-key-id / --access-key-secret flags,\n" +
" or env: ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET,\n" +
" or config: bl config set access_key_id <key>",
ExitCode.AUTH,
);
}
if (!workspaceId) {
throw new BailianError(
"Knowledge retrieve requires a workspace ID.\n" +
"Set via: --workspace-id flag, or env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
ExitCode.USAGE,
);
}
const body: KnowledgeRetrieveRequest = {
IndexId: indexId,
Query: query,
};
if (flags.topK !== undefined) body.TopK = flags.topK as number;
if (flags.rerank) body.Rerank = true;
if (flags.rerankTopN !== undefined) body.RerankTopN = flags.rerankTopN as number;
const format = detectOutputFormat(config.output);
const pathname = `/${workspaceId}/index/retrieve`;
if (config.dryRun) {
emitResult(
{
endpoint: `https://${BAILIAN_HOST}${pathname}`,
workspaceId,
request: body,
},
format,
);
return;
}
const hasExplicitApiKey = !!config.apiKey;
const hasExplicitAkSk = !!(flags.accessKeyId && flags.accessKeySecret);
const bodyStr = JSON.stringify(body);
const headers = signRequest({
accessKeyId,
accessKeySecret,
action: "Retrieve",
version: "2023-12-29",
body: bodyStr,
host: BAILIAN_HOST,
pathname,
});
const url = `https://${BAILIAN_HOST}${pathname}`;
if (config.verbose) {
process.stderr.write(`> POST ${url}\n`);
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(url, {
method: "POST",
headers: {
...headers,
...trackingHeaders(),
},
body: bodyStr,
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as KnowledgeRetrieveResponse & {
Code?: string;
Message?: string;
};
if (!res.ok || (data.Code && data.Code !== "Success")) {
throw new BailianError(
`Knowledge retrieve failed: ${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
if (config.quiet || format === "text") {
const nodes = data.Data?.Nodes || [];
if (nodes.length === 0) {
emitBare("No results found.");
} else {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
emitBare(`[${i + 1}] (score: ${node.Score.toFixed(4)})`);
emitBare(node.Text);
emitBare("");
}
}
if (hasExplicitApiKey) {
await runWithApiKey(config, flags, indexId, query, format);
} else if (hasExplicitAkSk) {
await runWithAkSk(config, flags, indexId, query, format);
} else {
emitResult(data, format);
let useApiKey = false;
try {
await resolveCredential(config);
useApiKey = true;
} catch {
// No API-KEY credential available
}
if (useApiKey) {
await runWithApiKey(config, flags, indexId, query, format);
} else {
await runWithAkSk(config, flags, indexId, query, format);
}
}
},
});
// ---- API-KEY path (DashScope gateway, snake_case) ----
async function runWithApiKey(
config: Config,
flags: GlobalFlags,
indexId: string,
query: string,
format: OutputFormat,
): Promise<void> {
if (flags.topK !== undefined && flags.rerankTopN === undefined) {
process.stderr.write("Warning: --top-k is deprecated. Use --rerank-top-n instead.\n");
flags.rerankTopN = flags.topK;
}
const body: DashScopeKnowledgeRetrieveRequest = {
index_id: indexId,
query,
search_filters: [],
};
if (flags.denseSimilarityTopK !== undefined)
body.dense_similarity_top_k = flags.denseSimilarityTopK as number;
if (flags.sparseSimilarityTopK !== undefined)
body.sparse_similarity_top_k = flags.sparseSimilarityTopK as number;
if (flags.rerank) body.enable_reranking = true;
if (flags.rerankTopN !== undefined) body.rerank_top_n = flags.rerankTopN as number;
if (flags.rerankModel) {
const rerankEntry: { model_name: string; rerank_mode?: string; rerank_instruct?: string } = {
model_name: flags.rerankModel as string,
};
if (flags.rerankMode) rerankEntry.rerank_mode = flags.rerankMode as string;
if (flags.rerankInstruct) rerankEntry.rerank_instruct = flags.rerankInstruct as string;
body.rerank = [rerankEntry];
}
const url = knowledgeRetrieveEndpoint(config.baseUrl);
if (config.dryRun) {
emitResult({ endpoint: url, request: body }, format);
return;
}
const response = await requestJson<DashScopeKnowledgeRetrieveResponse>(config, {
url,
method: "POST",
body,
});
const nodes = response.data?.nodes || [];
if (config.quiet || format === "text") {
emitTextNodes(nodes.map((n) => ({ text: n.text, score: n.score })));
} else {
emitResult(response, format);
}
}
// ---- AK/SK path (Bailian OpenAPI gateway, PascalCase) ----
async function runWithAkSk(
config: Config,
flags: GlobalFlags,
indexId: string,
query: string,
format: OutputFormat,
): Promise<void> {
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
if (!accessKeyId || !accessKeySecret) {
throw new BailianError(
"No credentials found.\n" +
"Preferred: set DASHSCOPE_API_KEY or pass --api-key.\n" +
"Legacy (deprecated): set ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
ExitCode.AUTH,
);
}
if (!workspaceId) {
throw new BailianError(
"Knowledge retrieve requires a workspace ID.\n" +
"Set via: --workspace-id flag, or env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
ExitCode.USAGE,
);
}
process.stderr.write(
"Warning: AK/SK auth for knowledge retrieve is deprecated. Prefer --api-key or DASHSCOPE_API_KEY.\n",
);
const body: KnowledgeRetrieveRequest = {
IndexId: indexId,
Query: query,
};
if (flags.topK !== undefined && flags.rerankTopN === undefined) {
process.stderr.write("Warning: --top-k is deprecated. Use --rerank-top-n instead.\n");
flags.rerankTopN = flags.topK;
}
if (flags.rerank) body.EnableReranking = true;
if (flags.rerankTopN !== undefined) body.RerankTopN = flags.rerankTopN as number;
if (flags.denseSimilarityTopK !== undefined)
body.DenseSimilarityTopK = flags.denseSimilarityTopK as number;
if (flags.sparseSimilarityTopK !== undefined)
body.SparseSimilarityTopK = flags.sparseSimilarityTopK as number;
if (flags.rerankModel) {
const rerank: { ModelName: string; RerankMode?: string; RerankInstruct?: string } = {
ModelName: flags.rerankModel as string,
};
if (flags.rerankMode) rerank.RerankMode = flags.rerankMode as string;
if (flags.rerankInstruct) rerank.RerankInstruct = flags.rerankInstruct as string;
body.Rerank = [rerank];
}
const pathname = `/${workspaceId}/index/retrieve`;
if (config.dryRun) {
emitResult(
{
endpoint: `https://${BAILIAN_HOST}${pathname}`,
workspaceId,
request: body,
},
format,
);
return;
}
const bodyStr = JSON.stringify(body);
const headers = signRequest({
accessKeyId,
accessKeySecret,
action: "Retrieve",
version: "2023-12-29",
body: bodyStr,
host: BAILIAN_HOST,
pathname,
});
const url = `https://${BAILIAN_HOST}${pathname}`;
if (config.verbose) {
process.stderr.write(`> POST ${url}\n`);
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(url, {
method: "POST",
headers: { ...headers, ...trackingHeaders() },
body: bodyStr,
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as KnowledgeRetrieveResponse & {
Code?: string;
Message?: string;
};
if (!res.ok || (data.Code && data.Code !== "Success")) {
throw new BailianError(
`Knowledge retrieve failed: ${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
const nodes = data.Data?.Nodes || [];
if (config.quiet || format === "text") {
emitTextNodes(nodes.map((n) => ({ text: n.Text, score: n.Score })));
} else {
emitResult(data, format);
}
}
// ---- Shared text output ----
function emitTextNodes(nodes: Array<{ text: string; score: number }>): void {
if (nodes.length === 0) {
emitBare("No results found.");
} else {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
emitBare(`[${i + 1}] (score: ${node.score.toFixed(4)})`);
emitBare(node.text);
emitBare("");
}
}
}
+3 -2
View File
@@ -32,6 +32,7 @@ function parseArgFlags(raw: string[]): Record<string, unknown> {
export default defineCommand({
name: "mcp call",
description: "Call a tool on an MCP server (tools/call)",
skipDefaultApiKeySetup: true,
usage: "bl mcp call <server-code>.<tool> [--arg k=v ...] [--json '{...}'] [--url <url>]",
options: [
{
@@ -56,8 +57,8 @@ export default defineCommand({
{ flag: "--url <url>", description: "Override the MCP endpoint URL (for non-Bailian servers)" },
],
examples: [
'bl mcp call market-cmapi00073529.SmartStockSelection --query "筛选ROE>15%的消费股"',
'bl mcp call market-cmapi00073529.FinQuery --json \'{"q":"贵州茅台","limit":5}\'',
'bl mcp call market-cmapi00073529.SmartStockSelection --query "Screen consumer stocks with ROE > 15%"',
'bl mcp call market-cmapi00073529.FinQuery --json \'{"q":"Guizhou Maotai","limit":5}\'',
"bl mcp call market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10",
],
async run(config: Config, flags: GlobalFlags) {
+14 -5
View File
@@ -1,6 +1,7 @@
import {
defineCommand,
callConsoleGateway,
effectiveConsoleGatewayConfig,
resolveConsoleGatewayCredential,
detectOutputFormat,
BailianError,
@@ -26,6 +27,7 @@ interface ServerSummary {
export default defineCommand({
name: "mcp list",
description: "List MCP servers activated under your Bailian account",
skipDefaultApiKeySetup: true,
usage: "bl mcp list [flags]",
options: [
{ flag: "--name <text>", description: "Filter by server name (substring match)" },
@@ -35,15 +37,23 @@ export default defineCommand({
},
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
{ flag: "--page-size <n>", description: "Results per page (default: 30)", type: "number" },
{ flag: "--region <region>", description: "API region (default: cn-beijing)" },
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: ["bl mcp list", "bl mcp list --name 金融", "bl mcp list --output json"],
examples: ["bl mcp list", "bl mcp list --name finance", "bl mcp list --output json"],
async run(config: Config, flags: GlobalFlags) {
const serverName = (flags.name as string) || "";
const type = (flags.type as string) || "OFFICIAL";
const pageNo = (flags.page as number) || 1;
const pageSize = (flags.pageSize as number) || 30;
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
const data = {
@@ -58,7 +68,7 @@ export default defineCommand({
};
if (config.dryRun) {
emitResult({ api: MCP_LIST_API, data, region }, format);
emitResult({ api: MCP_LIST_API, data, ...effectiveConsoleGatewayConfig(config) }, format);
return;
}
@@ -67,7 +77,6 @@ export default defineCommand({
const result = (await callConsoleGateway(config, credential.token, {
api: MCP_LIST_API,
data,
region,
})) as Record<string, unknown>;
const dataField = (result?.data as Record<string, unknown> | undefined) ?? {};
+1
View File
@@ -13,6 +13,7 @@ import { ensureApiKey } from "../../utils/ensure-key.ts";
export default defineCommand({
name: "mcp tools",
description: "List tools exposed by an MCP server (tools/list)",
skipDefaultApiKeySetup: true,
usage: "bl mcp tools <server-code> [--url <url>]",
options: [
{
+3 -3
View File
@@ -26,9 +26,9 @@ export default defineCommand({
{ flag: "--memory-library-id <id>", description: "Memory library ID (isolate memory space)" },
],
examples: [
'bl memory add --user-id user1 --content "用户喜欢Python编程"',
'bl memory add --user-id user1 --messages \'[{"role":"user","content":"我喜欢旅行"}]\'',
'bl memory add --user-id user1 --content "住在北京" --profile-schema schema_xxx',
'bl memory add --user-id user1 --content "The user likes Python programming"',
'bl memory add --user-id user1 --messages \'[{"role":"user","content":"I like traveling"}]\'',
'bl memory add --user-id user1 --content "Lives in Beijing" --profile-schema schema_xxx',
],
async run(config: Config, flags: GlobalFlags) {
const userId = flags.userId as string;
@@ -20,12 +20,12 @@ export default defineCommand({
{ flag: "--description <text>", description: "Schema description" },
{
flag: "--attributes <json>",
description: 'Attributes JSON array: [{"name":"age","description":"年龄"}]',
description: 'Attributes JSON array: [{"name":"age","description":"age"}]',
required: true,
},
],
examples: [
'bl memory profile create --name "user_basic" --attributes \'[{"name":"age","description":"年龄"},{"name":"hobby","description":"爱好"}]\'',
'bl memory profile create --name "user_basic" --attributes \'[{"name":"age","description":"age"},{"name":"hobby","description":"hobby"}]\'',
],
async run(config: Config, flags: GlobalFlags) {
const name = flags.name as string;
+2 -2
View File
@@ -27,8 +27,8 @@ export default defineCommand({
{ flag: "--memory-library-id <id>", description: "Memory library ID" },
],
examples: [
'bl memory search --user-id user1 --query "编程偏好"',
'bl memory search --user-id user1 --messages \'[{"role":"user","content":"推荐一本书"}]\' --top-k 5',
'bl memory search --user-id user1 --query "programming preferences"',
'bl memory search --user-id user1 --messages \'[{"role":"user","content":"recommend a book"}]\' --top-k 5',
],
async run(config: Config, flags: GlobalFlags) {
const userId = flags.userId as string;
+3 -1
View File
@@ -24,7 +24,9 @@ export default defineCommand({
},
{ flag: "--memory-library-id <id>", description: "Memory library ID (non-default library)" },
],
examples: ['bl memory update --node-id node_xxx --user-id user1 --content "更新后的记忆内容"'],
examples: [
'bl memory update --node-id node_xxx --user-id user1 --content "updated memory content"',
],
async run(config: Config, flags: GlobalFlags) {
const nodeId = flags.nodeId as string;
if (!nodeId)
+113 -19
View File
@@ -1,10 +1,13 @@
import { writeFileSync } from "fs";
import { extname } from "path";
import {
defineCommand,
request,
chatEndpoint,
parseSSE,
detectOutputFormat,
BailianError,
ExitCode,
type Config,
type GlobalFlags,
type ChatMessage,
@@ -13,12 +16,88 @@ import {
type StreamChunk,
isInteractive,
resolveFileUrl,
resolveOutputDir,
resolveCredential,
} from "bailian-cli-core";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
import { resolveOutputDir, resolveCredential } from "bailian-cli-core";
const OMNI_VOICES = ["Chelsie", "Cherry", "Ethan", "Serena", "Tina"];
interface VoiceEntry {
voice: string;
name: string;
desc: string;
lang: string;
}
// qwen-omni 系统音色
const OMNI_VOICES: VoiceEntry[] = [
{ voice: "Tina", name: "甜妹", desc: "甜美亲切", lang: "中文/英文" },
{ voice: "Dylan", name: "北京-晓东", desc: "胡同少年", lang: "中文/北京" },
{ voice: "Kiki", name: "粤语-阿清", desc: "甜美港妹", lang: "中文/英文" },
{ voice: "Li", name: "南京-老李", desc: "南京大叔", lang: "中文/英文" },
{ voice: "Sunny", name: "四川-晴儿", desc: "甜飒川妹", lang: "中文" },
{ voice: "Marcus", name: "陕西-秦川", desc: "陕北汉子", lang: "中文/英文" },
{ voice: "Eric", name: "四川-程川", desc: "成都大哥", lang: "中文/英文" },
{ voice: "Rocky", name: "粤语-阿强", desc: "幽默港仔", lang: "中文/英文" },
{ voice: "Jennifer", name: "詹妮弗", desc: "美剧大女主", lang: "中文/英文" },
{ voice: "Ryan", name: "甜茶", desc: "美剧张力男", lang: "中文/英文" },
{ voice: "Katerina", name: "卡捷琳娜", desc: "御姐深情女", lang: "中文/英文" },
{ voice: "Peter", name: "天津-李彼得", desc: "天津捧哏", lang: "中文/英文" },
{ voice: "Ethan", name: "晨煦", desc: "北方口音男", lang: "中文/英文" },
];
function printVoiceList(): void {
const col = (s: string, w: number) => s.padEnd(w);
process.stdout.write("\nOmni output voices:\n");
process.stdout.write(
`${col("VOICE ID", 12)} ${col("NAME", 14)} ${col("DESCRIPTION", 14)} LANGUAGE\n`,
);
process.stdout.write(`${"-".repeat(12)} ${"-".repeat(14)} ${"-".repeat(14)} ${"-".repeat(12)}\n`);
for (const v of OMNI_VOICES) {
process.stdout.write(`${col(v.voice, 12)} ${col(v.name, 14)} ${col(v.desc, 14)} ${v.lang}\n`);
}
process.stdout.write(`\nTotal: ${OMNI_VOICES.length} voices\n`);
}
/**
* Extension to input audio format.
*/
const OMNI_INPUT_AUDIO_EXT: Record<string, string> = {
wav: "wav",
mp3: "mp3",
amr: "amr",
aac: "aac",
m4a: "aac",
ogg: "ogg",
"3gp": "3gp",
"3gpp": "3gpp",
};
const audioExts = Object.keys(OMNI_INPUT_AUDIO_EXT);
/**
* Infer the input audio format from the source URL or local file path.
*/
function inferInputAudioFormat(source: string): string {
const pathPart = source.split("?")[0].split("#")[0];
const ext = extname(pathPart).slice(1).toLowerCase();
if (!ext) {
throw new BailianError(
`Cannot infer audio format from "${source}". ` +
`Use a file/URL whose path ends with: ${audioExts.join(", ")}.`,
ExitCode.USAGE,
);
}
const format = OMNI_INPUT_AUDIO_EXT[ext];
if (!format) {
throw new BailianError(
`Unsupported audio extension ".${ext}" for "${source}". ` +
`Supported extensions: ${audioExts.join(", ")}.`,
ExitCode.USAGE,
);
}
return format;
}
/**
* Build a standard WAV file header for PCM 16-bit mono 24kHz audio.
@@ -44,7 +123,6 @@ function buildWavHeader(dataLength: number): Buffer {
export default defineCommand({
name: "omni",
description: "Multimodal chat with text + audio output (Qwen-Omni)",
apiDocs: "/model-studio/qwen-omni",
usage: "bl omni --message <text> [flags]",
options: [
{
@@ -56,7 +134,11 @@ export default defineCommand({
{ flag: "--model <model>", description: "Model ID (default: qwen3.5-omni-plus)" },
{ flag: "--system <text>", description: "System prompt" },
{ flag: "--image <url>", description: "Image URL or local file (repeatable)", type: "array" },
{ flag: "--audio <url>", description: "Audio URL or local file (repeatable)", type: "array" },
{
flag: "--audio <url>",
description: "Audio URL or local file (.wav/.mp3/.amr/.aac/.m4a/.ogg/.3gp/.3gpp)",
type: "array",
},
{
flag: "--video <url>",
description: "Video file URL / local path, or comma-separated frame URLs",
@@ -64,7 +146,11 @@ export default defineCommand({
},
{
flag: "--voice <voice>",
description: `Output voice (default: Cherry). Options: ${OMNI_VOICES.join(", ")}`,
description: "Output voice ID (default: Tina). Use --list-voices to see all options",
},
{
flag: "--list-voices",
description: "List available output voices and exit",
},
{ flag: "--audio-format <fmt>", description: "Audio output format (default: wav)" },
{ flag: "--audio-out <path>", description: "Save audio to file (default: auto-generate)" },
@@ -73,16 +159,22 @@ export default defineCommand({
{ flag: "--temperature <n>", description: "Sampling temperature (0.0, 2.0]", type: "number" },
],
examples: [
'bl omni --message "你好,你是谁?"',
'bl omni --message "描述这张图片" --image ./photo.jpg',
'bl omni --message "这段音频在说什么?" --audio https://example.com/audio.wav',
'bl omni --message "总结这个视频" --video https://example.com/video.mp4',
'bl omni --message "这个视频讲了什么" --video ./local-video.mp4 --text-only',
'bl omni --message "用四川话回答:今天天气怎么样" --voice Serena',
"bl omni --list-voices",
'bl omni --message "Hello, who are you?"',
'bl omni --message "Describe this image" --image ./photo.jpg',
'bl omni --message "What is this audio saying?" --audio https://example.com/audio.wav',
'bl omni --message "Summarize this video" --video https://example.com/video.mp4',
'bl omni --message "What is this video about?" --video ./local-video.mp4 --text-only',
'bl omni --message "Answer in Sichuan dialect: How\'s the weather today?" --voice Sunny',
'bl omni --message "Hello" --text-only --output json',
'bl omni --message "朗读这段话" --audio-out greeting.wav',
'bl omni --message "Read this passage aloud" --audio-out greeting.wav',
],
async run(config: Config, flags: GlobalFlags) {
if (flags.listVoices) {
printVoiceList();
return;
}
// --- Parse messages ---
let userMessages: string[] = [];
if (flags.message) {
@@ -103,7 +195,7 @@ export default defineCommand({
}
const model = (flags.model as string) || config.defaultOmniModel || "qwen3.5-omni-plus";
const voice = (flags.voice as string) || "Cherry";
const voice = (flags.voice as string) || "Tina";
const audioFormat = (flags.audioFormat as string) || "wav";
const textOnly = flags.textOnly === true;
const format = detectOutputFormat(config.output);
@@ -139,7 +231,7 @@ export default defineCommand({
// Auto-upload local files
const imageUrls: string[] = [];
const audioUrls: string[] = [];
const audioInputs: Array<{ source: string; data: string }> = [];
const videoUrls: string[] = [];
const needsResolve =
@@ -152,7 +244,7 @@ export default defineCommand({
}
for (const u of rawAudioUrls) {
const resolved = await resolveFileUrl(u, credential.token, model);
audioUrls.push(resolved);
audioInputs.push({ source: u, data: resolved });
}
for (const u of rawVideoUrls) {
// Detect: comma-separated = frame list, otherwise single video URL/file
@@ -174,7 +266,7 @@ export default defineCommand({
}
}
if (imageUrls.length > 0 || audioUrls.length > 0 || videoUrls.length > 0) {
if (imageUrls.length > 0 || audioInputs.length > 0 || videoUrls.length > 0) {
// Find last user message and convert to multimodal content array
for (let i = allMessages.length - 1; i >= 0; i--) {
if (allMessages[i].role === "user") {
@@ -193,9 +285,11 @@ export default defineCommand({
contentArray.push({ type: "image_url", image_url: { url } });
}
// Add audio URLs
for (const url of audioUrls) {
contentArray.push({ type: "audio_url", audio_url: { url } });
for (const { source, data } of audioInputs) {
contentArray.push({
type: "input_audio",
input_audio: { data, format: inferInputAudioFormat(source) },
});
}
// Add video URLs: frame:xxx are frame list items, others are direct video URLs
@@ -10,6 +10,7 @@ import { loadPipelineFile } from "./load-file.ts";
export default defineCommand({
name: "pipeline run",
description: "Run a pipeline workflow definition",
skipDefaultApiKeySetup: true,
usage: "bl pipeline run <file> [flags]",
options: [
{ flag: "--input <json>", description: "Runtime input as inline JSON" },
@@ -8,6 +8,7 @@ import { loadPipelineFile } from "./load-file.ts";
export default defineCommand({
name: "pipeline validate",
description: "Validate a pipeline definition without executing",
skipDefaultApiKeySetup: true,
usage: "bl pipeline validate <file>",
options: [],
examples: [
+341
View File
@@ -0,0 +1,341 @@
import {
defineCommand,
callConsoleGateway,
effectiveConsoleGatewayConfig,
resolveConsoleGatewayCredential,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { displayWidth, padEnd } from "../../output/cjk-width.ts";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
interface QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
}
interface ModelWithQpm {
model: string;
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;
if (!period) return 0;
return Math.floor((item.count_limit * 60) / period);
}
function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.usage_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.usage_limit * 60) / period);
}
function 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(config: Config, token: string): Promise<ModelWithQpm[]> {
const allModels: ModelWithQpm[] = [];
let pageNo = 1;
while (true) {
const raw = await callConsoleGateway(config, token, {
api: MODEL_LIST_API,
data: {
input: {
pageNo,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
},
},
});
const resp = extractResponseData(raw as Record<string, unknown>);
const list = (resp.list as ModelWithQpm[]) ?? [];
const total = (resp.total as number) ?? 0;
allModels.push(...list);
if (allModels.length >= total || list.length === 0) break;
pageNo++;
}
return allModels;
}
async function fetchMonitorData(
config: Config,
token: string,
modelName: string,
windowMinutes: number,
): Promise<{ rpm: number; tpm: number }> {
const now = Date.now();
const startTime = now - windowMinutes * 60 * 1000;
try {
const raw = await callConsoleGateway(config, token, {
api: MONITOR_API,
data: {
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 = extractResponseData(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 {
return { rpm: -1, tpm: -1 };
}
}
interface CheckRow {
model: string;
rpmUsage: number;
rpmLimit: number;
tpmUsage: number;
tpmLimit: number;
}
function printTable(rows: CheckRow[], noColor: boolean): void {
const bold = noColor ? (t: string) => t : (t: string) => `\x1b[1m${t}\x1b[0m`;
const dim = noColor ? (t: string) => t : (t: string) => `\x1b[2m${t}\x1b[0m`;
const green = noColor ? (t: string) => t : (t: string) => `\x1b[32m${t}\x1b[0m`;
const yellow = noColor ? (t: string) => t : (t: string) => `\x1b[33m${t}\x1b[0m`;
const red = noColor ? (t: string) => t : (t: string) => `\x1b[31m${t}\x1b[0m`;
const headers = ["Model", "RPM Usage/Limit", "TPM Usage/Limit", "Status"];
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 maxPct = Math.max(
r.rpmLimit > 0 ? (r.rpmUsage / r.rpmLimit) * 100 : 0,
r.tpmLimit > 0 ? (r.tpmUsage / r.tpmLimit) * 100 : 0,
);
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 };
});
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) => bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((w) => 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 red(padEnd(cell, widths[col]));
if (cell === "Near limit") return yellow(padEnd(cell, widths[col]));
if (cell === "Normal") return green(padEnd(cell, widths[col]));
}
return padEnd(cell, widths[col]);
});
process.stdout.write(cells.join(" ") + "\n");
}
process.stdout.write(dim(`\nTotal: ${rows.length} models`) + "\n");
}
export default defineCommand({
name: "quota check",
description: "Check current usage against rate limits",
skipDefaultApiKeySetup: true,
usage: "bl quota check [--model <model>] [flags]",
options: [
{
flag: "--model <model>",
description: "Model name(s), comma-separated",
},
{
flag: "--period <minutes>",
description: "Query usage for the last N minutes (default: 2)",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl quota check",
"bl quota check --model qwen3.6-plus",
"bl quota check --period 5",
"bl quota check --model qwen3.6-plus,qwen-turbo",
"bl quota check --output json",
],
async run(config: Config, flags: GlobalFlags) {
const modelFlag = (flags.model as string) || undefined;
const rawPeriod = Number(flags.period) || 2;
if (rawPeriod < 1) {
process.stderr.write("Error: --period must be at least 1 minute.\n");
process.exit(1);
}
const windowMinutes = rawPeriod;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{
apis: [MODEL_LIST_API, MONITOR_API],
...effectiveConsoleGatewayConfig(config),
},
format,
);
return;
}
const credential = await resolveConsoleGatewayCredential(config);
let models = await fetchAllModelsWithQpm(config, credential.token);
if (modelFlag) {
const names = new Set(
modelFlag
.split(",")
.map((n) => n.trim())
.filter(Boolean),
);
models = models.filter((m) => names.has(m.model));
}
models = models.filter((m) => m.qpmInfo);
if (models.length === 0) {
process.stdout.write("No models found.\n");
return;
}
const monitorResults = await Promise.all(
models.map((m) => fetchMonitorData(config, credential.token, m.model, windowMinutes)),
);
const checkRows: CheckRow[] = models.map((m, idx) => {
const qpm = m.qpmInfo!;
const modelDefault = qpm["model-default"];
const userSpec = qpm["user-spec"];
const rpmLimit =
calculateRPM(userSpec, modelDefault?.count_limit_period) || calculateRPM(modelDefault);
const tpmLimit =
calculateTPM(userSpec, modelDefault?.usage_limit_period) || calculateTPM(modelDefault);
return {
model: m.model,
rpmUsage: monitorResults[idx].rpm,
rpmLimit,
tpmUsage: monitorResults[idx].tpm,
tpmLimit,
};
});
if (format === "json") {
emitResult(checkRows, format);
return;
}
printTable(checkRows, config.noColor);
},
});
+187
View File
@@ -0,0 +1,187 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
BailianError,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { displayWidth, padEnd } from "../../output/cjk-width.ts";
const HISTORY_API = "zeldaEasy.broadscope-platform.modelInstance.listModelLimitApplications";
interface LimitApplicationItem {
gmtCreate: string;
deployedModel: string;
usageLimit: number;
endTime?: string;
}
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;
}
function formatDateTime(ts: string | undefined): string {
if (!ts) return "-";
try {
const date = new Date(ts);
if (isNaN(date.getTime())) return ts;
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${y}-${mo}-${d} ${h}:${mi}`;
} catch {
return ts;
}
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
function printTable(records: LimitApplicationItem[], noColor: boolean, total: number): void {
const bold = noColor ? (t: string) => t : (t: string) => `\x1b[1m${t}\x1b[0m`;
const dim = noColor ? (t: string) => t : (t: string) => `\x1b[2m${t}\x1b[0m`;
const headers = ["Model", "Token Limit", "Applied At"];
const rows = records.map((r) => [
r.deployedModel,
formatNumber(r.usageLimit),
formatDateTime(r.gmtCreate),
]);
const widths = headers.map((label, col) =>
Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))),
);
const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((w) => 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(dim(`\nTotal: ${total} records`) + "\n");
}
export default defineCommand({
name: "quota history",
description: "View quota change history",
skipDefaultApiKeySetup: true,
usage: "bl quota history [flags]",
options: [
{
flag: "--page <n>",
description: "Page number (default: 1)",
},
{
flag: "--page-size <n>",
description: "Page size (default: 10)",
},
{
flag: "--model <model>",
description: "Filter by model name",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl quota history",
"bl quota history --page 2",
"bl quota history --page-size 20",
"bl quota history --model qwen-turbo",
"bl quota history --output json",
],
async run(config: Config, flags: GlobalFlags) {
const page = Number(flags.page) || 1;
const pageSize = Number(flags.pageSize) || 10;
const modelFilter = (flags.model as string) || undefined;
const format = detectOutputFormat(config.output);
const requestData = {
input: { pageNo: page, pageSize },
};
if (config.dryRun) {
emitResult({ api: HISTORY_API, data: requestData }, format);
return;
}
const credential = await resolveConsoleGatewayCredential(config);
let result: unknown;
try {
result = await callConsoleGateway(config, credential.token, {
api: HISTORY_API,
data: requestData,
});
} catch (err) {
if (err instanceof BailianError && err.message.includes("NotLogined")) {
process.stderr.write(
"Error: session expired. Run `bl auth login --console` to re-authenticate.\n",
);
process.exit(1);
}
throw err;
}
const resp = extractResponseData(result as Record<string, unknown>);
let records = (resp.records as LimitApplicationItem[]) ?? [];
const total = (resp.items as number) ?? records.length;
if (modelFilter) {
records = records.filter((r) => r.deployedModel === modelFilter);
}
if (format === "json") {
const items = records.map((r) => ({
model: r.deployedModel,
tokenLimit: r.usageLimit,
appliedAt: formatDateTime(r.gmtCreate),
}));
emitResult({ records: items, total: modelFilter ? records.length : total }, format);
return;
}
if (records.length === 0) {
process.stdout.write("No quota change history found.\n");
return;
}
printTable(records, config.noColor, modelFilter ? records.length : total);
},
});
+245
View File
@@ -0,0 +1,245 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { displayWidth, padEnd } from "../../output/cjk-width.ts";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
interface QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
}
interface ModelWithQpm {
model: string;
qpmInfo?: Record<string, QpmInfoItem>;
}
function calculateRPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.count_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.count_limit * 60) / period);
}
function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.usage_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.usage_limit * 60) / period);
}
function 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(
config: Config,
token: string,
onlySelfService: boolean,
): Promise<ModelWithQpm[]> {
const allModels: ModelWithQpm[] = [];
let pageNo = 1;
while (true) {
const input: Record<string, unknown> = {
pageNo,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
};
if (onlySelfService) {
input.supports = { selfServiceLimitIncrease: true };
}
const raw = await callConsoleGateway(config, token, {
api: MODEL_LIST_API,
data: { input },
});
const resp = extractResponseData(raw as Record<string, unknown>);
const list = (resp.list as ModelWithQpm[]) ?? [];
const total = (resp.total as number) ?? 0;
allModels.push(...list);
if (allModels.length >= total || list.length === 0) break;
pageNo++;
}
return allModels;
}
function printTable(models: ModelWithQpm[], noColor: boolean): void {
const bold = noColor ? (t: string) => t : (t: string) => `\x1b[1m${t}\x1b[0m`;
const dim = noColor ? (t: string) => t : (t: string) => `\x1b[2m${t}\x1b[0m`;
const headers = ["Model", "Req/min", "Token/min", "Max TPM"];
const rows = models.map((m) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
const maxTPM = defaultTPM * 2;
return [
m.model,
currentRPM > 0 ? formatNumber(currentRPM) : "-",
currentTPM > 0 ? formatNumber(currentTPM) : "-",
maxTPM > 0 ? formatNumber(maxTPM) : "-",
];
});
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) => bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((w) => 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(dim(`\nTotal: ${models.length} models`) + "\n");
}
export default defineCommand({
name: "quota list",
description: "View model RPM/TPM rate limits",
skipDefaultApiKeySetup: true,
usage: "bl quota list [--model <model>] [flags]",
options: [
{
flag: "--model <model>",
description: "Model name(s), comma-separated",
},
{
flag: "--all",
description: "Show all models, not just self-service ones",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl quota list",
"bl quota list --model qwen3.6-plus",
"bl quota list --model qwen3.6-plus,qwen-turbo",
"bl quota list --all",
"bl quota list --output json",
],
async run(config: Config, flags: GlobalFlags) {
const modelFlag = (flags.model as string) || undefined;
const showAll = Boolean(flags.all);
const format = detectOutputFormat(config.output);
if (config.dryRun) {
const input: Record<string, unknown> = {
pageNo: 1,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
};
if (!showAll) input.supports = { selfServiceLimitIncrease: true };
emitResult({ api: MODEL_LIST_API, data: { input } }, format);
return;
}
const credential = await resolveConsoleGatewayCredential(config);
let models = await fetchAllModelsWithQpm(config, credential.token, !showAll);
if (modelFlag) {
const names = new Set(
modelFlag
.split(",")
.map((n) => n.trim())
.filter(Boolean),
);
models = models.filter((m) => names.has(m.model));
if (models.length === 0) {
process.stderr.write(`Error: no matching models found for "${modelFlag}".\n`);
process.exit(1);
}
}
if (format === "json") {
const items = models.map((m) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
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, config.noColor);
},
});
+229
View File
@@ -0,0 +1,229 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
BailianError,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
const UPDATE_LIMITS_API = "zeldaEasy.broadscope-platform.modelInstance.updateFoundationModelLimits";
interface QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
}
function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.usage_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.usage_limit * 60) / period);
}
function getNestedRecord(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
return undefined;
}
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
const data = getNestedRecord(result, "data");
if (!data) return result;
const dataV2 = getNestedRecord(data, "DataV2");
if (dataV2) {
const inner = getNestedRecord(dataV2, "data");
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
return innerData ?? inner ?? dataV2;
}
const direct = getNestedRecord(data, "data");
return direct ?? data;
}
async function fetchModelQpmInfo(
config: Config,
token: string,
modelName: string,
): Promise<{ model: string; qpmInfo: Record<string, QpmInfoItem> } | undefined> {
const raw = await callConsoleGateway(config, token, {
api: MODEL_LIST_API,
data: {
input: {
pageNo: 1,
pageSize: 50,
name: modelName,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
},
},
});
const resp = extractResponseData(raw as Record<string, unknown>);
const list = (resp.list as Array<{ model: string; qpmInfo?: Record<string, QpmInfoItem> }>) ?? [];
return list.find((m) => m.model === modelName && m.qpmInfo) as
| { model: string; qpmInfo: Record<string, QpmInfoItem> }
| undefined;
}
export default defineCommand({
name: "quota request",
description: "Request a temporary quota increase",
skipDefaultApiKeySetup: true,
usage: "bl quota request --model <model> --tpm <value> [flags]",
options: [
{
flag: "--model <model>",
description: "Model name (required)",
required: true,
},
{
flag: "--tpm <value>",
description: "Target TPM value (required)",
required: true,
},
{
flag: "--yes",
description: "Skip downgrade confirmation",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl quota request --model qwen-turbo --tpm 100000",
"bl quota request --model qwen3.6-plus --tpm 8000000 --yes",
"bl quota request --model qwen-turbo --tpm 100000 --output json",
],
async run(config: Config, flags: GlobalFlags) {
const modelName = flags.model as string;
if (!modelName) {
process.stderr.write("Error: --model is required.\n");
process.exit(1);
}
const tpmValue = Number(flags.tpm);
if (!tpmValue || tpmValue <= 0) {
process.stderr.write("Error: --tpm must be a positive number.\n");
process.exit(1);
}
const autoConfirm = Boolean(flags.yes) || config.yes;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
const requestData = {
input: {
model: modelName,
limit: { usage_limit: tpmValue },
},
};
emitResult({ api: UPDATE_LIMITS_API, data: requestData }, format);
return;
}
const credential = await resolveConsoleGatewayCredential(config);
const modelInfo = await fetchModelQpmInfo(config, credential.token, modelName);
if (!modelInfo) {
process.stderr.write(
`Error: model "${modelName}" not found or does not support self-service quota increase.\n`,
);
process.stderr.write("Hint: run `bl quota list` to view available models.\n");
process.exit(1);
}
const modelDefault = modelInfo.qpmInfo["model-default"];
const userSpec = modelInfo.qpmInfo["user-spec"];
const minLimit = calculateTPM(modelDefault);
const currentLimit = calculateTPM(userSpec, modelDefault?.usage_limit_period) || minLimit;
const maxLimit = minLimit * 2;
if (tpmValue < minLimit || tpmValue > maxLimit) {
process.stderr.write(
`Error: TPM value ${tpmValue.toLocaleString()} is out of range.\n` +
` Current: ${currentLimit.toLocaleString()}\n` +
` Range: ${minLimit.toLocaleString()} ~ ${maxLimit.toLocaleString()}\n`,
);
process.exit(1);
}
const requestData = {
input: {
model: modelName,
limit: { usage_limit: tpmValue },
originalQpmInfo: modelInfo.qpmInfo,
} as Record<string, unknown>,
};
const submitRequest = async (confirmedDowngrade?: boolean): Promise<unknown> => {
if (confirmedDowngrade) {
requestData.input.confirmedDowngrade = true;
}
try {
return await callConsoleGateway(config, credential.token, {
api: UPDATE_LIMITS_API,
data: requestData,
});
} catch (err) {
if (err instanceof BailianError && err.message.includes("NotLogined")) {
process.stderr.write(
"Error: session expired. Run `bl auth login --console` to re-authenticate.\n",
);
process.exit(1);
}
throw err;
}
};
let result = await submitRequest();
const resp = extractResponseData(result as Record<string, unknown>);
if (resp.needConfirm) {
const confirmCode = resp.confirmCode as string;
if (confirmCode === "Refresh_Required") {
process.stderr.write("Error: rate limit has been updated externally. Please retry.\n");
process.exit(1);
}
if (confirmCode === "Downgrade") {
if (!autoConfirm) {
process.stderr.write(
`Warning: target TPM (${tpmValue.toLocaleString()}) is lower than current (${currentLimit.toLocaleString()}).\n` +
"Use --yes to confirm downgrade.\n",
);
process.exit(1);
}
result = await submitRequest(true);
}
}
if (format === "json") {
emitResult(result, format);
return;
}
process.stdout.write(
`Quota updated for "${modelName}": TPM ${currentLimit.toLocaleString()}${tpmValue.toLocaleString()}\n`,
);
},
});
+2 -2
View File
@@ -21,9 +21,9 @@ export default defineCommand({
{ flag: "--list-tools", description: "List available MCP tools and exit" },
],
examples: [
'bl search web --query "阿里云百炼最新功能"',
'bl search web --query "Alibaba Cloud Bailian latest features"',
'bl search web --query "TypeScript 5.9 new features" --count 5',
'bl search web --query "今日新闻"',
'bl search web --query "Today\'s news"',
"bl search web --list-tools",
],
async run(config: Config, flags: GlobalFlags) {
@@ -25,7 +25,6 @@ import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "speech recognize",
description: "Recognize speech from audio files (FunAudio-ASR)",
apiDocs: "/developer-reference/recording-file-recognition",
usage: "bl speech recognize --url <audio-url> [flags]",
options: [
{
+16 -10
View File
@@ -20,12 +20,14 @@ import {
DOCS_HOSTS,
} from "bailian-cli-core";
const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`;
import { VOICE_TTS_PAGE } from "../../urls.ts";
import { downloadFile } from "../../utils/download.ts";
import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts";
import { promptText, promptSelect, failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`;
interface VoiceEntry {
voice: string;
name: string;
@@ -37,7 +39,7 @@ interface VoiceEntry {
const COSYVOICE_V3_FLASH_VOICES: VoiceEntry[] = [
// 社交陪伴
{ voice: "longanyang", name: "龙安洋", desc: "阳光大男孩", lang: "中文/英文" },
{ voice: "longanhuan", name: "龙安欢", desc: "欢脱元气女", lang: "中文/英文" },
{ voice: "longanhuan_v3", name: "龙安欢", desc: "欢脱元气女", lang: "中文/英文" },
{ voice: "longantai_v3", name: "龙安台", desc: "嗲甜台湾女", lang: "中文/英文" },
{ voice: "longhua_v3", name: "龙华", desc: "元气甜美女", lang: "中文/英文" },
{ voice: "longcheng_v3", name: "龙橙", desc: "智慧青年男", lang: "中文/英文" },
@@ -121,12 +123,14 @@ function printVoiceList(model: string): void {
const voices = MODEL_VOICES[model];
if (!voices) {
process.stdout.write(`No built-in voice list available for model: ${model}\n`);
process.stdout.write(`Browse voices in the console: ${VOICE_TTS_PAGE}\n`);
return;
}
if (voices.length === 0) {
process.stdout.write(`Model ${model} has no system voices.\n`);
process.stdout.write("Use clone or design voices created via the CosyVoice API.\n");
process.stdout.write(`See: ${COSYVOICE_CLONE_DESIGN_DOC}\n`);
process.stdout.write(`Browse voices in the console: ${VOICE_TTS_PAGE}\n`);
return;
}
const col = (s: string, w: number) => s.padEnd(w);
@@ -139,12 +143,12 @@ function printVoiceList(model: string): void {
process.stdout.write(`${col(v.voice, 26)} ${col(v.name, 10)} ${col(v.desc, 16)} ${v.lang}\n`);
}
process.stdout.write(`\nTotal: ${voices.length} voices\n`);
process.stdout.write(`Preview and browse more voices in the console: \n${VOICE_TTS_PAGE}\n`);
}
export default defineCommand({
name: "speech synthesize",
description: "Synthesize speech from text (CosyVoice TTS)",
apiDocs: "/developer-reference/cosyvoice",
usage: "bl speech synthesize --text <text> [flags]",
options: [
{ flag: "--text <text>", description: "Text to synthesize into speech", required: true },
@@ -157,11 +161,12 @@ export default defineCommand({
{
flag: "--voice <voice>",
description:
"Voice ID. Use --list-voices to see system voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID",
"Voice ID. Use --list-voices to see built-in voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID",
},
{
flag: "--list-voices",
description: "List available system voices for the selected model and exit",
description:
"List built-in system voices for the selected model and exit (console link shown in output)",
},
{ flag: "--format <format>", description: "Audio format: mp3, pcm, wav, opus (default: mp3)" },
{ flag: "--sample-rate <rate>", description: "Audio sample rate in Hz (e.g. 24000)" },
@@ -172,7 +177,8 @@ export default defineCommand({
{ flag: "--language <lang>", description: "Language hint (e.g. zh, en, ja, ko, fr, de)" },
{
flag: "--instruction <text>",
description: 'Natural language instruction to control speech style (e.g. "请用温柔的语调"',
description:
'Natural language instruction to control speech style (e.g. "Use a gentle tone"',
},
{ flag: "--enable-ssml", description: "Enable SSML markup parsing in input text" },
{
@@ -183,13 +189,13 @@ export default defineCommand({
],
examples: [
"bl speech synthesize --list-voices --model cosyvoice-v3-flash",
'bl speech synthesize --text "你好,我是千问" --voice <voice_id>',
'bl speech synthesize --text "Hello, I am Qwen" --voice <voice_id>',
'bl speech synthesize --text "Hello world" --voice <voice_id> --language en',
"bl speech synthesize --text-file script.txt --out speech.wav --voice <voice_id>",
'bl speech synthesize --text "今天天气真好" --voice <voice_id> --instruction "请用温柔的语调说话"',
'bl speech synthesize --text "Today is a good day" --voice <voice_id> --instruction "Use a gentle tone"',
'bl speech synthesize --text "Hello" --voice <voice_id> --format wav --sample-rate 24000',
"# Stream to audio player (macOS)",
'bl speech synthesize --text "你好" --voice <voice_id> --stream | afplay -',
'bl speech synthesize --text "Hello" --voice <voice_id> --stream | afplay -',
"# Pipe to ffplay",
'bl speech synthesize --text "Hello" --voice <voice_id> --stream | ffplay -nodisp -autoexit -f s16le -ar 24000 -ac 1 -',
],
@@ -264,7 +270,7 @@ export default defineCommand({
const modelVoices = MODEL_VOICES[model];
if (modelVoices && modelVoices.length > 0) {
throw new BailianError(
`--voice is required.\nRun the following to see available voices:\n bl speech synthesize --list-voices --model ${model}`,
`--voice is required.\nRun the following to see available voices:\n bl speech synthesize --list-voices --model ${model}\nBrowse more voices: ${VOICE_TTS_PAGE}`,
ExitCode.USAGE,
);
} else {
-1
View File
@@ -70,7 +70,6 @@ function parseMessages(flags: GlobalFlags): ParsedMessages {
export default defineCommand({
name: "text chat",
description: "Send a chat completion (OpenAI compatible, DashScope)",
apiDocs: "/compatibility-of-openai-with-dashscope",
usage: "bl text chat --message <text> [flags]",
options: [
{ flag: "--model <model>", description: "Model ID (default: qwen3.7-max)" },
+8 -3
View File
@@ -5,7 +5,8 @@ import { defineCommand, getConfigDir } from "bailian-cli-core";
import { CLI_VERSION } from "../version.ts";
import { NPM_PACKAGE, fetchLatestVersion } from "../utils/update-checker.ts";
const SKILL_NAME = "bailian-cli";
const SKILL_SOURCE = "modelstudioai/cli";
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
/** Build the install command */
function detectInstallCommand(): { cmd: string; label: string } {
@@ -16,11 +17,13 @@ function updateAgentSkill(colors: { green: string; yellow: string; reset: string
const { green, yellow, reset } = colors;
process.stderr.write("\nUpdating agent skill...\n");
try {
execSync(`npx skills update ${SKILL_NAME} -g -y`, { stdio: "inherit" });
// Reinstall (not `skills update`) into ~/.agents/skills/ and sync to all agent apps.
// `--all` on `skills add` means --skill '*' --agent '*' -y (Cursor, Claude Code, etc.).
execSync(SKILL_INSTALL_CMD, { stdio: "inherit" });
process.stderr.write(`${green}\u2713 Agent skill updated.${reset}\n`);
} catch {
process.stderr.write(
`${yellow}Agent skill update skipped. Run manually: npx skills update ${SKILL_NAME} -g -y${reset}\n`,
`${yellow}Agent skill update skipped. Run manually: ${SKILL_INSTALL_CMD}${reset}\n`,
);
}
}
@@ -28,6 +31,7 @@ function updateAgentSkill(colors: { green: string; yellow: string; reset: string
export default defineCommand({
name: "update",
description: "Update bl to the latest version",
skipDefaultApiKeySetup: true,
usage: "bl update",
examples: ["bl update"],
async run() {
@@ -44,6 +48,7 @@ export default defineCommand({
if (latest && latest === CLI_VERSION) {
process.stderr.write(`${green}\u2713 Already up to date (${CLI_VERSION}).${reset}\n`);
updateAgentSkill({ green, yellow, reset });
return;
}
+319 -24
View File
@@ -2,64 +2,359 @@ import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
fetchModelList,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
import { displayWidth, padEnd } from "../../output/cjk-width.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>,
noColor: boolean,
): void {
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 dim = noColor ? (text: string) => text : (text: string) => `\x1b[2m${text}\x1b[0m`;
const bold = noColor ? (text: string) => text : (text: string) => `\x1b[1m${text}\x1b[0m`;
const green = noColor ? (text: string) => text : (text: string) => `\x1b[32m${text}\x1b[0m`;
const yellow = noColor ? (text: string) => text : (text: string) => `\x1b[33m${text}\x1b[0m`;
const autoStopCol = headers.length - 1;
const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((width) => 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 green(padEnd(cell, widths[col]));
if (cell === "OFF") return 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(config: Config, token: string): Promise<ModelInfo[]> {
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList(config, token, { 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[]) || []),
}));
}
export default defineCommand({
name: "usage free",
description: "Query free-tier quota for a model",
usage: "bl usage free --model <model> [flags]",
description: "Query free-tier quota for models (all models if --model is omitted)",
skipDefaultApiKeySetup: true,
usage: "bl usage free [--model <model>[,model2,...]] [flags]",
options: [
{
flag: "--model <model>",
description: "Model name to query (e.g. qwen3-max, qwen-turbo)",
required: true,
description: "Model name(s) to query, comma-separated for multiple; omit for all models",
},
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
flag: "--expiring <days>",
description: "Only show quotas expiring within N days",
},
{
flag: "--sort <field>",
description: "Sort by: remaining (ascending), expires (ascending)",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl usage free",
"bl usage free --model qwen3-max",
"bl usage free --model qwen3-max,qwen-turbo",
"bl usage free --expiring 30",
"bl usage free --sort remaining",
"bl usage free --model qwen-turbo --output json",
"bl usage free --model qwen3-max --region cn-beijing",
"bl usage free --model qwen3-max --console-region cn-beijing",
],
async run(config: Config, flags: GlobalFlags) {
const model = flags.model as string;
if (!model) failIfMissing("model", "bl usage free --model <model>");
const region = (flags.region as string) || "cn-beijing";
const modelFlag = (flags.model as string) || undefined;
const expiringDays = Number(flags.expiring) || 0;
const VALID_SORT_FIELDS = ["remaining", "expires"] as const;
const sortField = (flags.sort as string) || undefined;
if (sortField && !VALID_SORT_FIELDS.includes(sortField as (typeof VALID_SORT_FIELDS)[number])) {
process.stderr.write(
`Error: invalid --sort value "${sortField}". Must be one of: ${VALID_SORT_FIELDS.join(", ")}\n`,
);
process.exit(1);
}
const format = detectOutputFormat(config.output);
const credential = await resolveConsoleGatewayCredential(config);
let models: string[];
const typeMap = new Map<string, string>();
const data = {
queryFreeTierQuotaRequest: {
models: [model],
},
if (modelFlag) {
models = [
...new Set(
modelFlag
.split(",")
.map((name) => name.trim())
.filter(Boolean),
),
];
} else {
models = [];
}
const requestData = {
queryFreeTierQuotaRequest: { models },
};
if (config.dryRun) {
emitResult(
{ api: FREE_TIER_API, data, region, token: credential.token.slice(0, 8) + "..." },
{
api: FREE_TIER_API,
data: requestData,
},
format,
);
return;
}
const result = await callConsoleGateway(config, credential.token, {
api: FREE_TIER_API,
data,
region,
});
const credential = await resolveConsoleGatewayCredential(config);
emitResult(result, format);
if (!modelFlag) {
const modelInfos = await fetchAllModels(config, credential.token);
models = modelInfos.map((info) => info.name);
for (const info of modelInfos) {
typeMap.set(info.name, info.type);
}
requestData.queryFreeTierQuotaRequest.models = models;
} else {
const searchResults = await Promise.all(
models.map((name) => fetchModelList(config, credential.token, { name, pageSize: 50 })),
);
for (let idx = 0; idx < models.length; idx++) {
const matched = searchResults[idx].models.find((item) => item.model === models[idx]);
if (matched) {
typeMap.set(models[idx], resolveModelType((matched.capabilities as string[]) || []));
}
}
}
const [quotaResult, stopResult] = await Promise.all([
callConsoleGateway(config, credential.token, {
api: FREE_TIER_API,
data: requestData,
}),
callConsoleGateway(config, credential.token, {
api: FREE_TIER_ONLY_STATUS_API,
data: { queryFreeTierOnlyStatusRequest: { models } },
}),
]);
const allQuotas = extractQuotas(quotaResult);
let quotas = modelFlag
? allQuotas
: allQuotas.filter((quota) => quota.quotaStatus === "VALID" && quota.quotaInitTotal > 0);
if (expiringDays > 0) {
const cutoff = Date.now() + expiringDays * 24 * 60 * 60 * 1000;
quotas = quotas.filter((q) => q.quotaValidityPeriod > 0 && q.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;
});
} else if (sortField === "expires") {
quotas.sort((a, b) => (a.quotaValidityPeriod ?? 0) - (b.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 hasQuota = quota.quotaInitTotal != null && quota.quotaTotal != null;
const used = hasQuota ? quota.quotaInitTotal - quota.quotaTotal : 0;
const stopStatus = stopMap.get(quota.model);
const autoStop =
quota.quotaStatus === "UNKNOWN"
? "unsupported"
: stopStatus === true
? true
: stopStatus === false
? false
: null;
return {
model: quota.model,
type: typeMap.get(quota.model) || null,
remaining: hasQuota ? quota.quotaTotal : null,
total: hasQuota ? quota.quotaInitTotal : null,
usagePercent:
hasQuota && quota.quotaInitTotal > 0
? Math.round((used / quota.quotaInitTotal) * 1000) / 10
: null,
expires: quota.quotaValidityPeriod ? formatDate(quota.quotaValidityPeriod) : null,
autoStop,
};
});
emitResult(items, format);
return;
}
if (quotas.length === 0) {
process.stdout.write("No free-tier quota found.\n");
return;
}
printTable(quotas, stopMap, typeMap, config.noColor);
},
});
+256
View File
@@ -0,0 +1,256 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
fetchModelList,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
const ACTIVATE_API = "zeldaEasy.broadscope-bailian.freeTrial.batchActivateFreeTierOnly";
const DEACTIVATE_API = "zeldaEasy.broadscope-bailian.freeTrial.batchDeactivateFreeTierOnly";
const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
const FREE_TIER_ONLY_STATUS_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
interface FreeTierQuota {
model: string;
quotaTotal: number;
quotaInitTotal: number;
}
interface FreeTierOnlyStatus {
model: string;
freeTierOnly: boolean;
}
interface BatchResultFailure {
failureModelId: string;
errorCode: string;
}
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;
}
const POLL_INTERVAL_MS = 500;
const MAX_POLLS = 20;
async function pollUntilDone(
config: Config,
token: string,
api: string,
requestKey: string,
models: string[],
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
const requestData = {
[requestKey]: nextTaskId ? { taskId: nextTaskId } : { models },
};
const raw = await callConsoleGateway(config, token, {
api,
data: 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;
}
async function fetchAllModelNames(config: Config, token: string): Promise<string[]> {
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList(config, token, { pageNo: page, pageSize: 50 });
allModels.push(...result.models);
if (allModels.length >= result.total) break;
page++;
}
return allModels.map((item) => item.model as string).filter(Boolean);
}
export default defineCommand({
name: "usage freetier",
description:
"Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable",
skipDefaultApiKeySetup: true,
usage: "bl usage freetier <--model <model>[,model2,...] | --all> [--off] [flags]",
options: [
{
flag: "--model <model>",
description: "Model name(s), comma-separated for multiple",
},
{
flag: "--all",
description: "Apply to all free-tier models",
},
{
flag: "--on",
description: "Enable auto-stop (default behavior)",
},
{
flag: "--off",
description: "Disable auto-stop",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl usage freetier --model qwen3-max",
"bl usage freetier --model qwen3-max,qwen-turbo",
"bl usage freetier --all",
"bl usage freetier --on --model qwen3-max",
"bl usage freetier --off --model qwen3-max",
"bl usage freetier --off --all",
],
async run(config: Config, flags: GlobalFlags) {
const modelFlag = (flags.model as string) || undefined;
const all = Boolean(flags.all);
const off = Boolean(flags.off);
const format = detectOutputFormat(config.output);
if (!modelFlag && !all) {
process.stderr.write(
"Error: missing required flag. Specify --model <model>[,model2,...] or --all\n",
);
process.exit(1);
}
let models: string[];
if (modelFlag) {
models = [
...new Set(
modelFlag
.split(",")
.map((name) => name.trim())
.filter(Boolean),
),
];
} else {
models = [];
}
const api = off ? DEACTIVATE_API : ACTIVATE_API;
const requestKey = off
? "BatchDeactivateFreeTierOnlyRequest"
: "BatchActivateFreeTierOnlyRequest";
if (config.dryRun) {
emitResult(
{
api,
data: { [requestKey]: { models } },
},
format,
);
return;
}
const credential = await resolveConsoleGatewayCredential(config);
if (!modelFlag) {
models = await fetchAllModelNames(config, credential.token);
}
if (off) {
const [quotaResult, stopResult] = await Promise.all([
callConsoleGateway(config, credential.token, {
api: FREE_TIER_API,
data: { queryFreeTierQuotaRequest: { models } },
}),
callConsoleGateway(config, credential.token, {
api: FREE_TIER_ONLY_STATUS_API,
data: { queryFreeTierOnlyStatusRequest: { models } },
}),
]);
const quotaData = extractResponseData(quotaResult as Record<string, unknown>);
const quotas = (quotaData.freeTierQuotas ?? []) as FreeTierQuota[];
const quotaMap = new Map(quotas.map((quota) => [quota.model, quota]));
const stopData = extractResponseData(stopResult as Record<string, unknown>);
const stopStatuses = (stopData.freeTierOnlyStatuses ?? []) as FreeTierOnlyStatus[];
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
for (const name of models) {
if (stopMap.get(name) === false) {
process.stderr.write(`Auto-stop is already disabled for "${name}".\n`);
continue;
}
const quota = quotaMap.get(name);
if (quota && quota.quotaTotal > 0 && stopMap.get(name) === true) {
process.stderr.write(
`Cannot disable auto-stop for "${name}": free-tier quota has not been fully consumed. Please disable auto-stop after the quota is exhausted.\n`,
);
continue;
}
await pollUntilDone(config, credential.token, api, requestKey, [name]);
process.stdout.write(`Disabled auto-stop for "${name}".\n`);
}
return;
}
const jsonResults: unknown[] = [];
for (const name of models) {
const result = await pollUntilDone(config, credential.token, api, requestKey, [name]);
if (format === "json") {
jsonResults.push(result);
continue;
}
if (result) {
const resultData = extractResponseData(result as Record<string, unknown>);
const failureModels = (resultData.failureModels as BatchResultFailure[]) ?? [];
if (failureModels.length > 0) {
process.stderr.write(
`Failed to enable auto-stop for "${name}" (${failureModels[0].errorCode}).\n`,
);
} else {
process.stdout.write(`Enabled auto-stop for "${name}".\n`);
}
} else {
process.stderr.write(`Warning: operation timed out for "${name}".\n`);
}
}
if (format === "json") {
emitResult(jsonResults, format);
}
},
});
+468
View File
@@ -0,0 +1,468 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { displayWidth, padEnd } from "../../output/cjk-width.ts";
const OVERVIEW_API = "zeldaEasy.bailian-telemetry.model.getModelUsageStatistic";
const LIST_API = "zeldaEasy.bailian-telemetry.model.listModelUsageStatisticData";
interface UsageItem {
key: string;
value: number;
unit: string;
}
interface OverviewStatistic {
callCount: number;
modelCount: number;
callSuccessCount: number;
usages: UsageItem[];
}
interface ModelStatisticItem {
model: string;
callSuccessCount: number;
usages?: UsageItem[];
usage?: Record<string, number | undefined>;
}
interface ListStatisticResponse {
list: ModelStatisticItem[];
totalCount: number;
maxResults: number;
}
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;
}
const POLL_INTERVAL_MS = 500;
const MAX_POLLS = 30;
async function pollTelemetryApi(
config: Config,
token: string,
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 callConsoleGateway(config, token, {
api,
data: 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;
}
function resolveWorkspaceId(config: Config, flagWorkspaceId?: string): string {
if (flagWorkspaceId) return flagWorkspaceId;
if (config.workspaceId) return config.workspaceId;
process.stderr.write(
"Error: workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or `bl config set workspace_id <id>`.\n",
);
process.stderr.write("Hint: run `bl workspace list` to view available workspaces.\n");
process.exit(1);
}
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 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;
}
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 };
}
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;
}
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" },
};
function formatLabel(label: UsageLabel): string {
const unitSuffix = label.unit ? ` [${label.unit}]` : "";
return `${label.en}${unitSuffix}`;
}
function printOverview(
stat: OverviewStatistic,
startTime: number,
endTime: number,
days: number,
noColor: boolean,
): void {
const bold = noColor ? (text: string) => text : (text: string) => `\x1b[1m${text}\x1b[0m`;
const dim = noColor ? (text: string) => text : (text: string) => `\x1b[2m${text}\x1b[0m`;
process.stdout.write(
`${dim("Time Range Period:")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${dim(`(${days} days)`)}\n\n`,
);
const rows: [string, string][] = [
["Models Called", formatNumber(stat.modelCount ?? 0)],
["Successful Calls", formatNumber(stat.callSuccessCount ?? 0)],
];
for (const usage of stat.usages ?? []) {
const label = USAGE_KEY_LABELS[usage.key];
const text = label ? formatLabel(label) : usage.key;
rows.push([text, formatNumber(usage.value)]);
}
const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label)));
for (const [label, value] of rows) {
process.stdout.write(`${bold(padEnd(label, maxLabel + 2))}${value}\n`);
}
}
function printModelTable(
items: ModelStatisticItem[],
startTime: number,
endTime: number,
days: number,
noColor: boolean,
): void {
const bold = noColor ? (text: string) => text : (text: string) => `\x1b[1m${text}\x1b[0m`;
const dim = noColor ? (text: string) => text : (text: string) => `\x1b[2m${text}\x1b[0m`;
process.stdout.write(
`${dim("Time Range Period:")} ${formatDate(startTime)} ~ ${formatDate(endTime)} ${dim(`(${days} days)`)}\n\n`,
);
if (items.length === 0) {
process.stdout.write("No usage data found.\n");
return;
}
const usageKeys = new Set<string>();
const itemUsages = items.map((item) => {
const usage = resolveUsageMap(item);
for (const key of Object.keys(usage)) usageKeys.add(key);
return usage;
});
const orderedKeys = [...usageKeys].sort((keyA, keyB) => {
const order = [
"total_token",
"input_token",
"output_token",
"input_token_cache",
"image_number",
"video_duration",
"content_duration",
"tts_text_number",
];
const idxA = order.indexOf(keyA);
const idxB = order.indexOf(keyB);
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
});
const headers = ["Model", "Calls", ...orderedKeys.map((key) => USAGE_KEY_LABELS[key]?.en ?? key)];
const rows = items.map((item, idx) => [
item.model,
formatNumber(item.callSuccessCount ?? 0),
...orderedKeys.map((key) => {
const val = itemUsages[idx][key];
return val != null ? formatNumber(val) : "-";
}),
]);
const widths = headers.map((label, col) =>
Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))),
);
const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((width) => 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) => padEnd(cell, widths[col]));
process.stdout.write(cells.join(" ") + "\n");
}
process.stdout.write(dim(`\nTotal: ${items.length} models`) + "\n");
}
export default defineCommand({
name: "usage stats",
description: "Query model usage statistics",
skipDefaultApiKeySetup: true,
usage: "bl usage stats [--model <model>] [--days <days>] [flags]",
options: [
{
flag: "--model <model>",
description: "Model name(s), comma-separated; omit for overview",
},
{
flag: "--days <days>",
description: "Number of days (default: 7)",
},
{
flag: "--type <type>",
description: "Model type: Text, Vision, Multimodal, Audio, Embedding",
},
{
flag: "--workspace-id <id>",
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID)",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: [
"bl usage stats",
"bl usage stats --days 30",
"bl usage stats --model qwen-turbo",
"bl usage stats --model qwen-turbo --days 7",
"bl usage stats --model qwen3.6-plus,deepseek-v4-pro",
"bl usage stats --type Text --days 14",
"bl usage stats --output json",
],
async run(config: Config, flags: GlobalFlags) {
const modelFlag = (flags.model as string) || undefined;
const daysFlag = Number(flags.days) || 7;
const typeFlag = (flags.type as string) || undefined;
const format = detectOutputFormat(config.output);
const flagWorkspaceId = (flags.workspaceId as string) || undefined;
const workspaceId = resolveWorkspaceId(config, flagWorkspaceId);
const endTime = Date.now();
const startTime = endTime - daysFlag * 24 * 60 * 60 * 1000;
if (modelFlag) {
const models = [
...new Set(
modelFlag
.split(",")
.map((name) => name.trim())
.filter(Boolean),
),
];
const baseReqDTO: Record<string, unknown> = {
startTime,
endTime,
modelCallSource: "Online",
filterWorkspaceId: workspaceId,
maxResults: 50,
skip: 0,
sortField: "success_count",
sortOrder: "DESC",
};
if (typeFlag) baseReqDTO.obsModelType = typeFlag;
if (config.dryRun) {
emitResult(
{ api: LIST_API, data: { reqDTO: { ...baseReqDTO, model: models.join(",") } } },
format,
);
return;
}
const credential = await resolveConsoleGatewayCredential(config);
const results = await Promise.all(
models.map((model) =>
pollTelemetryApi(config, credential.token, LIST_API, { ...baseReqDTO, model }),
),
);
const allItems: ModelStatisticItem[] = [];
for (const result of results) {
if (!result) continue;
const listData = extractListData(result);
allItems.push(...listData.list);
}
if (format === "json") {
const items = allItems.map((item) => {
const usage = resolveUsageMap(item);
const clean: Record<string, unknown> = {
model: item.model,
successfulCalls: item.callSuccessCount ?? 0,
};
for (const [key, val] of Object.entries(usage)) {
clean[key] = val;
}
return clean;
});
emitResult(
{
period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag },
items,
},
format,
);
return;
}
printModelTable(allItems, startTime, endTime, daysFlag, config.noColor);
} else {
const reqDTO: Record<string, unknown> = {
startTime,
endTime,
modelCallSource: "Online",
filterWorkspaceId: workspaceId,
};
if (typeFlag) reqDTO.obsModelType = typeFlag;
if (config.dryRun) {
emitResult({ api: OVERVIEW_API, data: { reqDTO } }, format);
return;
}
const credential = await resolveConsoleGatewayCredential(config);
const result = await pollTelemetryApi(config, credential.token, OVERVIEW_API, reqDTO);
if (!result) {
process.stderr.write("Error: request timed out.\n");
process.exit(1);
}
const stat = extractOverviewData(result);
if (format === "json") {
if (!stat) {
emitResult(
{
period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag },
modelsCalled: 0,
successfulCalls: 0,
},
format,
);
return;
}
emitResult(
{
period: { start: formatDate(startTime), end: formatDate(endTime), days: daysFlag },
modelsCalled: stat.modelCount ?? 0,
successfulCalls: stat.callSuccessCount ?? 0,
usages: (stat.usages ?? []).map((u) => ({
key: u.key,
value: u.value,
unit: u.unit,
label: USAGE_KEY_LABELS[u.key]?.en ?? u.key,
})),
},
format,
);
return;
}
if (!stat) {
process.stdout.write("No usage data found.\n");
return;
}
printOverview(stat, startTime, endTime, daysFlag, config.noColor);
}
},
});
+7 -5
View File
@@ -31,7 +31,6 @@ export default defineCommand({
name: "video edit",
description:
"Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.)",
apiDocs: "/best-practice/wanx/video-edit",
usage: "bl video edit --video <url> --prompt <text> [flags]",
options: [
{ flag: "--model <model>", description: "Model ID (default: happyhorse-1.0-video-edit)" },
@@ -40,7 +39,10 @@ export default defineCommand({
description: "Input video URL or local file (mp4/mov, 2-10s)",
required: true,
},
{ flag: "--prompt <text>", description: 'Edit instruction (e.g. "将画面转换为黏土风格")' },
{
flag: "--prompt <text>",
description: 'Edit instruction (e.g. "Convert the scene to a claymation style")',
},
{ flag: "--ref-image <url>", description: "Reference image URL (up to 4, comma-separated)" },
{
flag: "--negative-prompt <text>",
@@ -79,10 +81,10 @@ export default defineCommand({
},
],
examples: [
'bl video edit --video https://example.com/input.mp4 --prompt "将整个画面转换为黏土风格"',
'bl video edit --video https://example.com/input.mp4 --prompt "替换衣服为图片中的款式" --ref-image https://example.com/clothes.png',
'bl video edit --video https://example.com/input.mp4 --prompt "Convert the entire scene to claymation style"',
'bl video edit --video https://example.com/input.mp4 --prompt "Replace the outfit with the style shown in the image" --ref-image https://example.com/clothes.png',
'bl video edit --video https://example.com/input.mp4 --prompt "Convert to anime style" --resolution 720P --download output.mp4',
'bl video edit --video https://example.com/input.mp4 --prompt "给视频里的小猫穿上衣服" --watermark false',
'bl video edit --video https://example.com/input.mp4 --prompt "Put clothes on the kitten in the video" --watermark false',
],
async run(config: Config, flags: GlobalFlags) {
// --- Validate video URL ---
+10 -23
View File
@@ -28,28 +28,15 @@ import {
BOOL_FLAG_WATERMARK,
} from "../../utils/flag-descriptions.ts";
// Normalize shorthand resolution (720P, 1080P) to pixel format for video generation models
const RESOLUTION_SHORTCUTS: Record<string, string> = {
"720p": "1280*720",
"1080p": "1920*1080",
"480p": "832*480",
};
function normalizeResolution(res: string | undefined): string | undefined {
if (!res) return undefined;
return RESOLUTION_SHORTCUTS[res.toLowerCase()] || res;
}
export default defineCommand({
name: "video generate",
description:
"Generate a video from text or image (happyhorse-1.0-t2v / happyhorse-1.0-i2v / wan2.6-t2v)",
apiDocs: "/best-practice/wanx/text-to-video",
"Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v)",
usage: "bl video generate --prompt <text> [--image <url>] [flags]",
options: [
{
flag: "--model <model>",
description: "Model ID (default: happyhorse-1.0-t2v, or happyhorse-1.0-i2v with --image)",
description: "Model ID (default: happyhorse-1.1-t2v, or happyhorse-1.1-i2v with --image)",
},
{ flag: "--prompt <text>", description: "Video description", required: true },
{ flag: "--image <url>", description: "Input image URL for image-to-video generation" },
@@ -57,8 +44,8 @@ export default defineCommand({
flag: "--negative-prompt <text>",
description: "Negative prompt to exclude unwanted content",
},
{ flag: "--resolution <res>", description: "Resolution (e.g. 1280*720, 960*960)" },
{ flag: "--ratio <ratio>", description: "Aspect ratio (e.g. 16:9, 1:1)" },
{ flag: "--resolution <res>", description: "Resolution: 720P or 1080P (default: 1080P)" },
{ flag: "--ratio <ratio>", description: "Aspect ratio (e.g. 16:9, 9:16, 1:1)" },
{
flag: "--duration <seconds>",
description: "Video duration in seconds (default: 5)",
@@ -86,10 +73,10 @@ export default defineCommand({
},
],
examples: [
'bl video generate --prompt "一个人在读书,静态镜头"',
'bl video generate --prompt "A person reading a book, static shot"',
'bl video generate --prompt "Ocean waves at sunset." --download sunset.mp4',
'bl video generate --image https://example.com/cat.png --prompt "让画面中的猫动起来"',
'bl video generate --prompt "Mountain landscape" --resolution 1280*720 --duration 5',
'bl video generate --image https://example.com/cat.png --prompt "Make the cat in the scene move"',
'bl video generate --prompt "Mountain landscape" --resolution 720P --duration 5',
'bl video generate --prompt "A cat playing with a ball" --watermark false',
],
async run(config: Config, flags: GlobalFlags) {
@@ -111,7 +98,7 @@ export default defineCommand({
const model =
(flags.model as string) ||
config.defaultVideoModel ||
((flags.image as string) ? "happyhorse-1.0-i2v" : "happyhorse-1.0-t2v");
((flags.image as string) ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v");
const format = detectOutputFormat(config.output);
const imageUrl = flags.image as string | undefined;
@@ -131,13 +118,13 @@ export default defineCommand({
input: {
prompt: prompt!,
negative_prompt: (flags.negativePrompt as string) || undefined,
// i2v models (happyhorse-1.0-i2v) require input.media with type 'first_frame'
// i2v models (happyhorse-1.1-i2v) require input.media with type 'first_frame'
...(resolvedImageUrl
? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
},
parameters: {
resolution: normalizeResolution(flags.resolution as string) || undefined,
resolution: (flags.resolution as string) || undefined,
ratio: (flags.ratio as string) || undefined,
duration: (flags.duration as number) || undefined,
prompt_extend: promptExtend,
+13 -14
View File
@@ -30,14 +30,13 @@ import {
export default defineCommand({
name: "video ref",
description:
"Reference-to-video generation (happyhorse-1.0-r2v / wan2.6-r2v): multi-subject, multi-shot with voice",
apiDocs: "/best-practice/wanx/video-reference",
"Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice",
usage: "bl video ref --prompt <text> --image <url>... [--ref-video <url>...] [flags]",
options: [
{ flag: "--model <model>", description: "Model ID (default: happyhorse-1.0-r2v)" },
{ flag: "--model <model>", description: "Model ID (default: happyhorse-1.1-r2v)" },
{
flag: "--prompt <text>",
description: "Video description with reference markers (图1, 视频1, etc.)",
description: "Video description with reference markers (image1, video1, etc.)",
required: true,
},
{
@@ -60,11 +59,11 @@ export default defineCommand({
description: "Voice URL for corresponding ref-video (pairs by position)",
type: "array",
},
{ flag: "--resolution <res>", description: "Resolution: 720P or 1080P (default: 720P)" },
{ flag: "--resolution <res>", description: "Resolution: 720P or 1080P (default: 1080P)" },
{ flag: "--ratio <ratio>", description: "Aspect ratio (16:9, 9:16, 1:1)" },
{
flag: "--duration <seconds>",
description: "Video duration in seconds (2-10, default: 5)",
description: "Video duration in seconds (default: 5)",
type: "number",
},
{
@@ -89,11 +88,11 @@ export default defineCommand({
},
],
examples: [
'bl video ref --prompt "图1在草地上奔跑" --image person.jpg',
'bl video ref --prompt "视频1在弹吉他图1走过来" --ref-video scene.mp4 --image person.jpg',
'bl video ref --prompt "图1说话" --image person.jpg --image-voice voice.mp3 --resolution 1080P',
'bl video ref --prompt "图1和图2在对话" --image a.jpg --image b.jpg --image-voice va.mp3 --image-voice vb.mp3',
'bl video ref --prompt "图1在喝水" --image person.jpg --watermark false',
'bl video ref --prompt "Image1 running on the grass" --image person.jpg',
'bl video ref --prompt "Video 1 plays guitar, Image 1 walks over" --ref-video scene.mp4 --image person.jpg',
'bl video ref --prompt "Image 1 speaks" --image person.jpg --image-voice voice.mp3 --resolution 1080P',
'bl video ref --prompt "Image 1 and Image 2 have a conversation" --image a.jpg --image b.jpg --image-voice va.mp3 --image-voice vb.mp3',
'bl video ref --prompt "Image 1 drinks water" --image person.jpg --watermark false',
],
async run(config: Config, flags: GlobalFlags) {
// --- Validate prompt ---
@@ -101,7 +100,7 @@ export default defineCommand({
if (!prompt) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({
message: "Enter your video prompt (use 图1, 视频1 to reference inputs):",
message: "Enter your video prompt (use Image1, Video1 to reference inputs):",
});
if (!hint) {
process.stderr.write("Video generation cancelled.\n");
@@ -120,14 +119,14 @@ export default defineCommand({
throw new BailianError(
"At least one --image or --ref-video is required.",
ExitCode.USAGE,
'bl video ref --prompt "描述" --image person.jpg',
'bl video ref --prompt "description" --image person.jpg',
);
}
const imageVoices = (flags.imageVoice as string[] | undefined) || [];
const videoVoices = (flags.videoVoice as string[] | undefined) || [];
const model = (flags.model as string) || "happyhorse-1.0-r2v";
const model = (flags.model as string) || "happyhorse-1.1-r2v";
const format = detectOutputFormat(config.output);
// --- Resolve file URLs (auto-upload local files) ---
+2 -2
View File
@@ -72,8 +72,8 @@ export default defineCommand({
],
examples: [
"bl vision describe --image photo.jpg",
'bl vision describe --image https://example.com/photo.jpg --prompt "这只狗是什么品种?"',
'bl vision describe --video https://example.com/video.mp4 --prompt "总结视频内容"',
'bl vision describe --image https://example.com/photo.jpg --prompt "What breed is this dog?"',
'bl vision describe --video https://example.com/video.mp4 --prompt "Summarize the video content"',
"bl vision describe --video ./local-video.mp4",
'bl vision describe --image photo.png --prompt "Extract the text" --model qwen-vl-plus',
],
+142
View File
@@ -0,0 +1,142 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { displayWidth, padEnd } from "../../output/cjk-width.ts";
const LIST_WORKSPACES_API = "zeldaEasy.bailian-dash-workspace.space.listWorkspaces";
interface WorkspaceInfo {
workspaceId: string;
agentName: string;
defaultAgent: boolean;
}
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;
}
function printTable(workspaces: WorkspaceInfo[], noColor: boolean): void {
const bold = noColor ? (text: string) => text : (text: string) => `\x1b[1m${text}\x1b[0m`;
const dim = noColor ? (text: string) => text : (text: string) => `\x1b[2m${text}\x1b[0m`;
const green = noColor ? (text: string) => text : (text: string) => `\x1b[32m${text}\x1b[0m`;
const headers = ["Name", "Workspace ID", "Default"];
const rows = workspaces.map((ws) => [
ws.agentName,
ws.workspaceId,
ws.defaultAgent ? "Yes" : "-",
]);
const widths = headers.map((label, col) =>
Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))),
);
const headerLine = headers.map((label, col) => bold(padEnd(label, widths[col]))).join(" ");
const separator = widths.map((width) => 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 === 2 && cell === "Yes") return green(padEnd(cell, widths[col]));
return padEnd(cell, widths[col]);
});
process.stdout.write(cells.join(" ") + "\n");
}
process.stdout.write(dim(`\nTotal: ${workspaces.length}`) + "\n");
}
export default defineCommand({
name: "workspace list",
description: "List all workspaces",
skipDefaultApiKeySetup: true,
usage: "bl workspace list [flags]",
options: [
{
flag: "--list <n>",
description: "Limit number of results",
},
{ flag: "--console-region <region>", description: "Console region" },
{
flag: "--console-site <site>",
description: "Console site: domestic, international",
},
{
flag: "--console-switch-agent <uid>",
description: "Switch agent UID",
type: "number",
},
],
examples: ["bl workspace list", "bl workspace list --list 5", "bl workspace list --output json"],
async run(config: Config, flags: GlobalFlags) {
const limit = Number(flags.list) || 0;
const format = detectOutputFormat(config.output);
const credential = await resolveConsoleGatewayCredential(config);
if (config.dryRun) {
emitResult({ api: LIST_WORKSPACES_API, data: {} }, format);
return;
}
const result = await callConsoleGateway(config, credential.token, {
api: LIST_WORKSPACES_API,
data: {},
});
const resp = extractResponseData(result as Record<string, unknown>);
const dataArr = resp.data as Record<string, unknown>[] | undefined;
if (!Array.isArray(dataArr) || dataArr.length === 0) {
if (format === "json") {
emitResult([], format);
} else {
process.stdout.write("No workspace found.\n");
}
return;
}
let workspaces = dataArr as unknown as WorkspaceInfo[];
if (limit > 0) workspaces = workspaces.slice(0, limit);
if (format === "json") {
const items = workspaces.map((ws) => ({
workspaceId: ws.workspaceId,
name: ws.agentName,
default: ws.defaultAgent,
}));
emitResult(items, format);
return;
}
printTable(workspaces, config.noColor);
},
});
+18 -8
View File
@@ -9,6 +9,10 @@ import { API_KEY_PAGE } from "./urls.ts";
const LABEL_WIDTH = 13;
/** Short reminder; full resolution order matches `loadConfig` in bailian-cli-core. */
const BASE_URL_HINT =
"If the DashScope host is wrong, check baseUrl (--base-url, bl config show, or DASHSCOPE_BASE_URL).";
function pad(label: string): string {
return label.padEnd(LABEL_WIDTH);
}
@@ -22,7 +26,10 @@ function alignContinuation(text: string): string {
function enhanceHint(err: BailianError): string | undefined {
if (err.exitCode === ExitCode.AUTH) {
if (err.message === CONSOLE_GATEWAY_NO_TOKEN_MESSAGE) {
if (
err.message === CONSOLE_GATEWAY_NO_TOKEN_MESSAGE ||
err.hint?.includes("auth login --console")
) {
return err.hint;
}
return [
@@ -76,19 +83,23 @@ function pickNetworkHint(code: string | undefined): string {
switch (code) {
case "ENOTFOUND":
case "EAI_AGAIN":
return "DNS resolution failed. Check DASHSCOPE_BASE_URL or your DNS / network.";
return `DNS resolution failed. Check DNS / network. ${BASE_URL_HINT}`;
case "ECONNREFUSED":
return "Connection refused. Check the target host/port and proxy settings.";
case "ECONNRESET":
return "Connection reset by peer. Retry, or check proxy / firewall.";
return (
"Connection reset by peer. Retry, or check proxy / firewall.\n" +
"If you are behind a VPN or corporate proxy, route bl through it:\n" +
"export HTTPS_PROXY=http://127.0.0.1:<proxy-port>"
);
case "ETIMEDOUT":
return "Connection timed out. Check your network or try a different region.";
return `Connection timed out. Check your network. ${BASE_URL_HINT}`;
case "CERT_HAS_EXPIRED":
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
case "DEPTH_ZERO_SELF_SIGNED_CERT":
return "TLS certificate error. Check system clock and CA bundle.";
default:
return "Check network connection, proxy settings (HTTP_PROXY / HTTPS_PROXY), and DASHSCOPE_BASE_URL.";
return `Check network and proxy (HTTP_PROXY / HTTPS_PROXY). ${BASE_URL_HINT}`;
}
}
@@ -162,9 +173,8 @@ export function handleError(err: unknown): never {
"Request timed out.",
ExitCode.TIMEOUT,
"Try increasing --timeout (e.g. --timeout 60).\n" +
"If this happens on every request with a valid API key, you may be hitting the wrong region.\n" +
"Run: bl auth status — to check your credentials and region.\n" +
"Run: bl config set --key region --value cn — to override the region.",
`${BASE_URL_HINT}\n` +
"Run: bl auth status — to check credentials.",
{ cause: err },
);
return handleError(timeout);
+13 -43
View File
@@ -3,13 +3,12 @@ import { registry } from "./registry.ts";
import {
GLOBAL_OPTIONS,
loadConfig,
readConfigFile,
resolveCredential,
trackCommandExecution,
flushTelemetry,
type Region,
} from "bailian-cli-core";
import { ensureApiKey } from "./utils/ensure-key.ts";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
@@ -21,14 +20,15 @@ import {
setExecutingCommandPath,
} from "./utils/command-help.ts";
// 必须在任何 fetch 发起前安装(含 update-checker / telemetry
try {
setupProxyFromEnv();
} catch (err) {
handleError(err);
}
registerCommandHelpPrinter((commandPath, out) => {
const a = process.argv.slice(2);
const ri = a.indexOf("--region");
const region = ((ri >= 0 && a[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, out, region);
registry.printHelp(commandPath, out);
});
// 优雅处理 Ctrl+C
@@ -45,26 +45,6 @@ process.stdout.on("error", (e: NodeJS.ErrnoException) => {
else throw e;
});
// 自己接管鉴权 或 根本不需要 API key 的命令
const NO_AUTH_SETUP = [
["auth", "login"],
["auth", "logout"],
["config", "show"],
["config", "set"],
["config", "export-schema"],
["update"],
["knowledge", "retrieve"],
["pipeline", "run"],
["pipeline", "validate"],
["model", "list"],
["app", "list"],
["console", "call"],
["usage", "free"],
["mcp", "list"],
["mcp", "tools"],
["mcp", "call"],
];
async function main() {
let argv = process.argv.slice(2);
if (argv[0] === "--") argv = argv.slice(1);
@@ -77,12 +57,7 @@ async function main() {
const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS);
if (argv.includes("--help") || argv.includes("-h")) {
const ri = argv.indexOf("--region");
const region = ((ri >= 0 && argv[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, process.stderr, region);
registry.printHelp(commandPath, process.stderr);
process.exit(0);
}
@@ -108,12 +83,7 @@ async function main() {
// 组路径(例如 `bl speech` 未接子命令):展示帮助后干净退出
if (registry.isGroupPath(commandPath)) {
const ri = argv.indexOf("--region");
const region = ((ri >= 0 && argv[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, process.stderr, region);
registry.printHelp(commandPath, process.stderr);
process.exit(0);
}
@@ -126,8 +96,8 @@ async function main() {
config.clientName = "bailian-cli";
config.clientVersion = CLI_VERSION;
const needsAuthSetup = !NO_AUTH_SETUP.some((cmd) => cmd.every((c, i) => commandPath[i] === c));
if (needsAuthSetup) {
// 默认执行 ensureApiKey自行处理鉴权或仅需 Console/AK-SK 等的命令在 defineCommand 上设 skipDefaultApiKeySetup
if (!command.skipDefaultApiKeySetup) {
await ensureApiKey(config);
try {
const credential = await resolveCredential(config);
+4 -4
View File
@@ -1,10 +1,10 @@
import { API_KEY_PAGE } from "../urls.ts";
const QUICK_START_TASKS = [
"帮我生成一套鸭舌帽的亚马逊电商主图(白底 + 场景图 + 模特上身图)",
"帮我生成一段 3 分钟的幽默相声音频",
"帮我生成一套小红帽故事绘本 PDF(含插图)",
"帮我分析这个视频的内容并写一篇小红书文案",
"Help me generate a set of Amazon e-commerce main images for baseball caps (white background + lifestyle shots + model wear shots)",
"Help me generate a 3-minute humorous crosstalk audio clip",
"Help me generate a Little Red Riding Hood picture-book PDF (with illustrations)",
"Help me analyze this video and write a Xiaohongshu-style post",
];
function colors() {
+24
View File
@@ -0,0 +1,24 @@
function isCjk(code: number): boolean {
return (
(code >= 0x2e80 && code <= 0x9fff) ||
(code >= 0xf900 && code <= 0xfaff) ||
(code >= 0xfe30 && code <= 0xfe4f) ||
(code >= 0xff00 && code <= 0xff60) ||
(code >= 0xffe0 && code <= 0xffe6) ||
(code >= 0x20000 && code <= 0x2fa1f)
);
}
export function displayWidth(text: string): number {
let width = 0;
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
width += isCjk(code) ? 2 : 1;
}
return width;
}
export function padEnd(text: string, targetWidth: number): string {
const gap = targetWidth - displayWidth(text);
return gap > 0 ? text + " ".repeat(gap) : text;
}
-4
View File
@@ -5,7 +5,6 @@ const reset = "\x1b[0m";
const dim = "\x1b[2m";
const bold = "\x1b[1m";
const mmBlue = "\x1b[38;2;43;82;255m";
const mmCyan = "\x1b[38;2;6;184;212m";
const mmPink = "\x1b[38;2;236;72;153m";
function tildePath(p: string): string {
@@ -20,7 +19,6 @@ export function maybeShowStatusBar(
if (config.quiet || !process.stderr.isTTY) return;
const filePath = config.configPath ? tildePath(config.configPath) : "~/.bailian/config.json";
const regionSrc = config.fileRegion ? `${config.fileRegion} (file)` : "cn (default)";
const authTag = resolved
? `${resolved.source} · ${resolved.method}`
: config.apiKey
@@ -32,8 +30,6 @@ export function maybeShowStatusBar(
`${bold}${mmBlue}BAILIAN${reset} ` +
`${dim}${filePath}${reset} ` +
`${dim}|${reset} ` +
`${dim}Region:${reset} ${mmCyan}${regionSrc}${reset} ` +
`${dim}|${reset} ` +
`${dim}Auth:${reset} ${mmPink}${maskedKey}${reset} ${dim}${authTag}${reset}\n`,
);
}
+1 -1
View File
@@ -391,7 +391,7 @@ export async function videoGenerate(
});
}
const model = input.model || (input.image ? "happyhorse-1.0-i2v" : "happyhorse-1.0-t2v");
const model = input.model || (input.image ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v");
let resolvedImageUrl: string | undefined;
if (input.image) {
+48
View File
@@ -0,0 +1,48 @@
import { setGlobalDispatcher, EnvHttpProxyAgent } from "undici";
import { BailianError, ExitCode } from "bailian-cli-core";
export interface ProxyEnv {
httpProxy?: string;
httpsProxy?: string;
noProxy?: string;
}
function pick(env: NodeJS.ProcessEnv, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = env[key]?.trim();
if (value) return value;
}
return undefined;
}
/**
* 读取代理环境变量(小写优先,与 curl 约定一致)。
* 空白值视为未设置——undici 自身用 `??` 取值,空字符串的小写变量会屏蔽
* 已设置的大写变量,这里统一清洗后显式传入,绕开该坑。
*/
export function readProxyEnv(env: NodeJS.ProcessEnv = process.env): ProxyEnv {
return {
httpProxy: pick(env, "http_proxy", "HTTP_PROXY"),
httpsProxy: pick(env, "https_proxy", "HTTPS_PROXY"),
noProxy: pick(env, "no_proxy", "NO_PROXY"),
};
}
// Node 内置 fetchundici默认不读取代理环境变量VPN / 公司代理环境下会
// 绕过代理直连而被拦截(见 issue #35。仅当用户显式设置了 HTTP_PROXY /
// HTTPS_PROXY 时才安装代理 dispatcher同时支持 NO_PROXY未设置时不触碰
// 全局 dispatcher行为与之前完全一致。
export function setupProxyFromEnv(): void {
const { httpProxy, httpsProxy, noProxy } = readProxyEnv();
if (!httpProxy && !httpsProxy) return;
try {
setGlobalDispatcher(new EnvHttpProxyAgent({ httpProxy, httpsProxy, noProxy }));
} catch (err) {
throw new BailianError(
`Invalid proxy configuration: ${err instanceof Error ? err.message : String(err)}`,
ExitCode.USAGE,
"Check HTTP_PROXY / HTTPS_PROXY values, e.g. export HTTPS_PROXY=http://127.0.0.1:7890",
{ cause: err },
);
}
}
+10 -11
View File
@@ -1,7 +1,7 @@
import type { Command } from "bailian-cli-core";
import { BailianError } from "bailian-cli-core";
import { ExitCode } from "bailian-cli-core";
import { DOCS_HOSTS, GLOBAL_OPTIONS, type Region } from "bailian-cli-core";
import { GLOBAL_OPTIONS } from "bailian-cli-core";
import { commands } from "./commands/catalog.ts";
export type { Command, OptionDef } from "bailian-cli-core";
@@ -133,11 +133,7 @@ class CommandRegistry {
out.isTTY ? `\x1b[38;2;59;130;246m${s}\x1b[0m` : s;
private dim = (s: string, out: NodeJS.WriteStream) => (out.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
printHelp(
commandPath: string[],
out: NodeJS.WriteStream = process.stdout,
region: Region = "cn",
): void {
printHelp(commandPath: string[], out: NodeJS.WriteStream = process.stdout): void {
if (commandPath.length === 0) {
this.printRootHelp(out);
return;
@@ -154,7 +150,7 @@ class CommandRegistry {
}
if (node.command) {
this.printCommandHelp(node.command, out, region);
this.printCommandHelp(node.command, out);
return;
}
@@ -233,7 +229,7 @@ ${b("Getting Help:")}
`);
}
private printCommandHelp(cmd: Command, out: NodeJS.WriteStream, region: Region = "cn"): void {
private printCommandHelp(cmd: Command, out: NodeJS.WriteStream): void {
const b = (s: string) => this.bold(s, out);
const a = (s: string) => this.accent(s, out);
const d = (s: string) => this.dim(s, out);
@@ -247,15 +243,18 @@ ${b("Getting Help:")}
out.write(` ${a(opt.flag.padEnd(maxLen + 2))} ${d(opt.description)}\n`);
}
}
if (cmd.notes && cmd.notes.length > 0) {
out.write(`\n${b("Notes:")}\n`);
for (const note of cmd.notes) {
out.write(` ${note}\n`);
}
}
if (cmd.examples && cmd.examples.length > 0) {
out.write(`\n${b("Examples:")}\n`);
for (const ex of cmd.examples) {
out.write(` ${d(ex)}\n`);
}
}
if (cmd.apiDocs) {
out.write(`\n${b("API Reference:")} ${d(DOCS_HOSTS[region] + cmd.apiDocs)}\n`);
}
out.write(
`\n${d("Global flags (--api-key, --output, --quiet, etc.) are always available.")}\n`,
);
+3
View File
@@ -14,3 +14,6 @@ export const BAILIAN_CONSOLE = `${BAILIAN_CONSOLE_ROOT}/cn-beijing`;
/** Direct deep link to API key management page. */
export const API_KEY_PAGE = `${BAILIAN_CONSOLE}/?tab=app#/api-key`;
/** Voice TTS experience center — browse system and custom voices. */
export const VOICE_TTS_PAGE = "https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list";
@@ -2,21 +2,21 @@ import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: advisor recommend", () => {
test("advisor 分组展示子命令帮助且成功退出", async () => {
test("advisor shows subcommand groups and exits successfully", async () => {
const { stdout, stderr, exitCode } = await runCli(["advisor"]);
expect(exitCode, stderr).toBe(0);
expect(`${stdout}\n${stderr}`).toMatch(/advisor|recommend/i);
});
test("advisor recommend --help 正常退出", async () => {
test("advisor recommend --help exits successfully", async () => {
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recommend|--message|dry-run/i);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope", () => {
test("advisor recommend 缺少 --message 时打印帮助并退出 (0)", async () => {
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend (DashScope)", () => {
test("advisor recommend without --message prints help and exits", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
@@ -26,13 +26,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
});
test("advisor recommend --dry-run 输出意图分析和候选列表", async () => {
test("advisor recommend --dry-run outputs intent analysis and candidates", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"我想做一个能理解图片的客服机器人",
"I want to build a customer service bot that understands images",
"--non-interactive",
"--output",
"json",
@@ -44,7 +44,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
candidateCount?: number;
candidates?: Array<{ model?: string; score?: number }>;
}>(stdout);
expect(data.userInput).toBe("我想做一个能理解图片的客服机器人");
expect(data.userInput).toBe("I want to build a customer service bot that understands images");
expect(data.intent?.requiredCapabilities).toContain("VU");
expect(data.intent?.inputModality).toContain("Image");
expect(data.candidateCount).toBeGreaterThan(0);
@@ -52,40 +52,44 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
expect(data.candidates?.[0]?.score).toBeGreaterThan(0);
}, 60_000);
test("advisor recommend 完整推荐流程返回结果", async () => {
test("advisor recommend full flow returns results", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--message",
"低成本高并发的在线客服",
"low-cost high-concurrency online customer service",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
type?: string;
recommendations?: Array<{
model?: string;
name?: string;
reason?: string;
}>;
intent?: { taskSummary?: string };
result?: {
type?: string;
recommendations?: Array<{
model?: string;
name?: string;
reason?: string;
}>;
};
candidates?: number;
}>(stdout);
expect(data.type).toBe("single");
expect(data.recommendations?.length).toBeGreaterThan(0);
expect(data.recommendations?.[0]?.model).toBeDefined();
expect(data.recommendations?.[0]?.reason).toBeDefined();
expect(data.result?.type).toBe("single");
expect(data.result?.recommendations?.length).toBeGreaterThan(0);
expect(data.result?.recommendations?.[0]?.model).toBeDefined();
expect(data.result?.recommendations?.[0]?.reason).toBeDefined();
}, 120_000);
// ---- 模型偏好:正例 ----
// ---- Model preference: positive cases ----
test("scoped 偏好 — 限定系列时 intent 含 modelPreference.mode=scoped", async () => {
test("scoped preference — intent contains modelPreference.mode=scoped when family is specified", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"deepseek系列中哪个模型最适合用来进行快速推理",
"Which model in the deepseek family is best for fast reasoning?",
"--non-interactive",
"--output",
"json",
@@ -103,13 +107,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
).toBe(true);
}, 60_000);
test("comparison 偏好 — 对比模型时 intent 含 modelPreference.mode=comparison", async () => {
test("comparison preference — intent contains modelPreference.mode=comparison when comparing models", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"qwen-maxdeepseek-v3哪个更适合做代码生成",
"Which is better for code generation, qwen-max or deepseek-v3?",
"--non-interactive",
"--output",
"json",
@@ -122,13 +126,13 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
expect(data.intent?.modelPreference?.targets?.length).toBeGreaterThanOrEqual(2);
}, 60_000);
test("excludes 偏好 — 排除模型时 intent 识别出 modelPreference", async () => {
test("excludes preference — intent detects modelPreference when excluding models", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"不要qwen推荐一个适合文本生成的模型",
"Not qwen, recommend a model suitable for text generation",
"--non-interactive",
"--output",
"json",
@@ -147,15 +151,15 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommendDashScope",
expect(hasExcludes).toBe(true);
}, 60_000);
// ---- 模型偏好:反例 ----
// ---- Model preference: negative cases ----
test("无偏好 — 普通需求查询时 intent 不含 modelPreference mode=unconstrained", async () => {
test("no preference — intent has no modelPreference or mode=unconstrained for generic queries", async () => {
const { stdout, stderr, exitCode } = await runCli([
"advisor",
"recommend",
"--dry-run",
"--message",
"我要做一个能理解图片的客服机器人",
"I want to build a customer service bot that understands images",
"--non-interactive",
"--output",
"json",
+22 -16
View File
@@ -17,6 +17,7 @@ describe("e2e: auth", () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/login|api-key/i);
expect(stderr).toMatch(/--console-site/);
});
test("auth logout --help 正常退出", async () => {
@@ -159,20 +160,25 @@ describe("e2e: auth", () => {
expect(data.dashscope_commands?.method).toBeDefined();
});
test.skipIf(!isDashScopeE2EReady())("auth status --output json --quiet --region cn", async () => {
const { stdout, stderr, exitCode } = await runCli([
"auth",
"status",
"--non-interactive",
"--output",
"json",
"--quiet",
"--region",
"cn",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ authenticated?: boolean; dashscope_commands?: unknown }>(stdout);
expect(data.authenticated).toBe(true);
expect(data.dashscope_commands).toBeDefined();
});
test.skipIf(!isDashScopeE2EReady())(
"auth status --output json --quiet --base-url 国内",
async () => {
const { stdout, stderr, exitCode } = await runCli([
"auth",
"status",
"--non-interactive",
"--output",
"json",
"--quiet",
"--base-url",
"https://dashscope.aliyuncs.com",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ authenticated?: boolean; dashscope_commands?: unknown }>(
stdout,
);
expect(data.authenticated).toBe(true);
expect(data.dashscope_commands).toBeDefined();
},
);
});
+1 -17
View File
@@ -41,12 +41,10 @@ describe("e2e: config", () => {
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
region?: string;
config_file?: string;
base_url?: string;
timeout?: number;
}>(stdout);
expect(data.region).toBeDefined();
expect(data.config_file).toBeDefined();
expect(data.base_url).toBeDefined();
expect(data.timeout).toBeDefined();
@@ -62,7 +60,7 @@ describe("e2e: config", () => {
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/region|config_file|timeout|base_url/i);
expect(stdout).toMatch(/config_file|timeout|base_url/i);
});
test("config set 缺少 --key / --value 时退出为用法错误 (2)", async () => {
@@ -85,20 +83,6 @@ describe("e2e: config", () => {
expect(stderr).toMatch(/Invalid config key|not-a-real-key/i);
});
test("config set 非法 region", async () => {
const { stderr, exitCode } = await runCli([
"config",
"set",
"--non-interactive",
"--key",
"region",
"--value",
"invalid-region",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid region|cn, us, intl/i);
});
test("config set 非法 output", async () => {
const { stderr, exitCode } = await runCli([
"config",
@@ -0,0 +1,148 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
type ConsoleDryRunMeta = {
consoleRegion?: string;
consoleSite?: string;
consoleSwitchAgent?: number;
};
/**
* E2E for global console flags (`--console-region`, `--console-site`,
* `--console-switch-agent`) and DashScope `--base-url`.
*/
describe("e2e: console global flags", () => {
test("根帮助展示 --base-url 与 console 全局标志", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--base-url/);
expect(stderr).toMatch(/--console-region/);
expect(stderr).toMatch(/--console-site/);
expect(stderr).toMatch(/--console-switch-agent/);
expect(stderr).not.toMatch(/^\s*--region\s/m);
});
test("quota check --help 不重复命令级 region并提示全局 flags", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Global flags.*always available/i);
expect(stderr).toMatch(/--model <model>/);
expect(stderr).toMatch(/--period <minutes>/);
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
});
test("console call --help 不暴露命令级 region/site示例使用 --console-region", async () => {
const { stderr, exitCode } = await runCli(["console", "call", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--api <api>/);
expect(stderr).toMatch(/--data <json>/);
expect(stderr).not.toMatch(/^\s*--region\s/m);
expect(stderr).not.toMatch(/^\s*--site\s/m);
expect(stderr).toMatch(/--console-region cn-beijing/);
});
test("auth login --help 描述 --console 与 --console-site 配合", async () => {
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--console-site/);
expect(stderr).toMatch(/--console.*console-site|console-site.*domestic|international/i);
});
test("console call --dry-run 默认 consoleRegion 为 cn-beijing", async () => {
const { stdout, stderr, exitCode } = await runCli([
"console",
"call",
"--api",
"some.api.name",
"--data",
"{}",
"--dry-run",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
expect(data.consoleRegion).toBe("cn-beijing");
expect(data.consoleSite).toBe("domestic");
});
test("console call --dry-run --console-region / --console-site / --console-switch-agent 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"console",
"call",
"--api",
"some.api.name",
"--data",
"{}",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--console-region",
"ap-southeast-1",
"--console-site",
"international",
"--console-switch-agent",
"12345",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
expect(data.consoleRegion).toBe("ap-southeast-1");
expect(data.consoleSite).toBe("international");
expect(data.consoleSwitchAgent).toBe(12345);
});
test("console call 拒绝未知全局 flag --region", async () => {
const { stderr, exitCode } = await runCli([
"console",
"call",
"--api",
"some.api.name",
"--data",
"{}",
"--dry-run",
"--non-interactive",
"--region",
"cn",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Unknown flag.*--region/);
});
test("mcp list --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"list",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--console-region",
"cn-hangzhou",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
expect(data.consoleRegion).toBe("cn-hangzhou");
});
test("quota check --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--console-region",
"cn-hangzhou",
"--console-site",
"international",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<ConsoleDryRunMeta>(stdout);
expect(data.consoleRegion).toBe("cn-hangzhou");
expect(data.consoleSite).toBe("international");
});
});
+10 -1
View File
@@ -117,8 +117,17 @@ export function e2eLabelFromMetaUrl(metaUrl: string): string {
return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, "");
}
/** 知识库用例:须显式索引 ID + AK/SKworkspace 可读 config / env故不在此强制校验 */
/** 知识库用例:须显式索引 ID + API-KEY 或 AK/SK */
export function isKnowledgeE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (!process.env.BAILIAN_E2E_INDEX_ID) return false;
const hasApiKey = isDashScopeE2EReady();
const hasAkSk =
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID && !!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET;
return hasApiKey || hasAkSk;
}
export function isKnowledgeAkSkReady(): boolean {
return (
isBailianE2EEnabled() &&
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID &&
+195 -44
View File
@@ -1,55 +1,206 @@
import { join } from "path";
import { tmpdir } from "os";
import { describe, expect, test } from "vite-plus/test";
import {
isBailianE2EEnabled,
isKnowledgeE2EReady,
monorepoRoot,
parseStdoutJson,
runCli,
} from "./helpers.ts";
import { parseStdoutJson, runCli } from "./helpers.ts";
// 已开启 E2E 但 AK/SK、索引等未齐时提醒配置根目录 .env否则本文件整组 describe 会被 skip
if (isBailianE2EEnabled() && !isKnowledgeE2EReady()) {
const envFile = join(monorepoRoot(), ".env");
console.warn(
[
"[e2e:knowledge] 知识库检索需要 RAM 的 AK/SK、索引 ID以及工作空间 ID当前未就绪本组用例将被跳过。",
`请在 monorepo 根目录的 .env 中配置(${envFile}`,
" ALIBABA_CLOUD_ACCESS_KEY_ID",
" ALIBABA_CLOUD_ACCESS_KEY_SECRET",
" BAILIAN_E2E_INDEX_ID",
" BAILIAN_WORKSPACE_ID也可执行: bl config set workspace_id <工作空间 id>",
].join("\n"),
);
// ---- Types ----
interface DryRunBody {
endpoint?: string;
request?: {
index_id?: string;
query?: string;
search_filters?: unknown[];
rerank_top_n?: number;
enable_reranking?: boolean;
dense_similarity_top_k?: number;
sparse_similarity_top_k?: number;
rerank?: Array<{ model_name?: string; rerank_mode?: string; rerank_instruct?: string }>;
};
}
interface KnowledgeRetrieveBody {
Success?: boolean;
Code?: string;
Data?: { Nodes?: unknown[] };
}
// ---- Help & missing args (no credentials needed) ----
/** 知识库检索(需 AK/SK + workspace + 索引;未就绪则整组跳过) */
describe.skipIf(!isKnowledgeE2EReady())("e2e: knowledge retrieve", () => {
test("知识库检索", async () => {
const indexId = process.env.BAILIAN_E2E_INDEX_ID!;
const { stdout, stderr, exitCode } = await runCli([
describe("e2e: knowledge retrieve", () => {
test("knowledge 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["knowledge"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/knowledge|retrieve/i);
});
test("knowledge retrieve --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["knowledge", "retrieve", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--query/i);
expect(stderr).toMatch(/--rerank-top-n/i);
expect(stderr).toMatch(/deprecated/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺少 --index-id 时打印帮助并退出 (0)", async () => {
const { stderr, exitCode } = await runCli([
"knowledge",
"retrieve",
"--query",
"test",
"--non-interactive",
]);
expect(exitCode).toBe(0);
expect(stderr).toMatch(/--index-id|Usage:/i);
});
test("缺少 --query 时打印帮助并退出 (0)", async () => {
const { stderr, exitCode } = await runCli([
"knowledge",
"retrieve",
"--index-id",
indexId,
"--query",
"端到端检索测试",
"--top-k",
"3",
"idx_test",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<KnowledgeRetrieveBody>(stdout);
const ok = data.Success === true || data.Code === "Success";
expect(ok).toBe(true);
expect(Array.isArray(data.Data?.Nodes)).toBe(true);
}, 120_000);
expect(exitCode).toBe(0);
expect(stderr).toMatch(/--query|Usage:/i);
});
});
// ---- Error scenarios (no real credentials needed) ----
describe("e2e: knowledge retrieve errors", () => {
test("无任何凭证时提示 No credentials found 并非零退出", async () => {
const { stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--index-id",
"idx_test",
"--query",
"test",
"--non-interactive",
"--output",
"json",
],
{
DASHSCOPE_API_KEY: undefined,
DASHSCOPE_ACCESS_TOKEN: undefined,
ALIBABA_CLOUD_ACCESS_KEY_ID: undefined,
ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined,
BAILIAN_CONFIG_DIR: tmpdir(),
},
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/no credentials found/i);
});
});
// ---- Dry-run (no real credentials needed) ----
describe("e2e: knowledge retrieve dry-run", () => {
test("--dry-run 输出 endpoint 和 snake_case body", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--dry-run",
"--index-id",
"idx_test",
"--query",
"hello",
"--non-interactive",
"--output",
"json",
],
{ DASHSCOPE_API_KEY: "sk-fake-for-dryrun" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/retrieve/);
expect(data.request?.index_id).toBe("idx_test");
expect(data.request?.query).toBe("hello");
});
test("--dry-run + --top-k 转发到 rerank_top_n 并输出废弃警告", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--dry-run",
"--index-id",
"idx_test",
"--query",
"hello",
"--top-k",
"5",
"--non-interactive",
"--output",
"json",
],
{ DASHSCOPE_API_KEY: "sk-fake-for-dryrun" },
);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--top-k.*deprecated/i);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.rerank_top_n).toBe(5);
});
test("--dry-run + --rerank-top-n 优先于 --top-k", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--dry-run",
"--index-id",
"idx_test",
"--query",
"hello",
"--top-k",
"5",
"--rerank-top-n",
"10",
"--non-interactive",
"--output",
"json",
],
{ DASHSCOPE_API_KEY: "sk-fake-for-dryrun" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.rerank_top_n).toBe(10);
});
test("--dry-run + rerank 参数完整输出", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"knowledge",
"retrieve",
"--dry-run",
"--index-id",
"idx_test",
"--query",
"hello",
"--rerank",
"--rerank-model",
"qwen3-rerank-hybrid",
"--rerank-mode",
"custom",
"--rerank-instruct",
"按相关性排序",
"--dense-similarity-top-k",
"100",
"--sparse-similarity-top-k",
"50",
"--non-interactive",
"--output",
"json",
],
{ DASHSCOPE_API_KEY: "sk-fake-for-dryrun" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.enable_reranking).toBe(true);
expect(data.request?.dense_similarity_top_k).toBe(100);
expect(data.request?.sparse_similarity_top_k).toBe(50);
expect(data.request?.rerank?.[0]?.model_name).toBe("qwen3-rerank-hybrid");
expect(data.request?.rerank?.[0]?.rerank_mode).toBe("custom");
expect(data.request?.rerank?.[0]?.rerank_instruct).toBe("按相关性排序");
});
});
+5 -6
View File
@@ -66,7 +66,7 @@ describe("e2e: mcp", () => {
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
region?: string;
consoleRegion?: string;
data?: {
reqDTO?: {
type?: string;
@@ -79,7 +79,6 @@ describe("e2e: mcp", () => {
};
}>(stdout);
expect(data.api).toBe("zeldaEasy.broadscope-bailian.mcp-server.PageList");
expect(data.region).toBe("cn-beijing");
expect(data.data?.reqDTO?.activated).toBe(1);
expect(data.data?.reqDTO?.displayTools).toBe(false);
expect(data.data?.reqDTO?.type).toBe("OFFICIAL");
@@ -88,7 +87,7 @@ describe("e2e: mcp", () => {
expect(data.data?.reqDTO?.pageSize).toBe(5);
});
test("mcp list --dry-run 自定义 --region 透传", async () => {
test("mcp list --dry-run 自定义 --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"mcp",
"list",
@@ -96,12 +95,12 @@ describe("e2e: mcp", () => {
"--non-interactive",
"--output",
"json",
"--region",
"--console-region",
"cn-hangzhou",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ region?: string }>(stdout);
expect(data.region).toBe("cn-hangzhou");
const data = parseStdoutJson<{ consoleRegion?: string }>(stdout);
expect(data.consoleRegion).toBe("cn-hangzhou");
});
test("mcp tools <server-code> --dry-run 输出 /api/v1/mcps/<code>/mcp 形态 URL", async () => {
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, test } from "vite-plus/test";
import { join } from "node:path";
import {
e2eLabelFromMetaUrl,
isBailianE2EMediaEnabled,
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCli,
} from "./helpers.ts";
describe("e2e: omni", () => {
test("omni --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["omni", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/omni|--message|--audio|text-only/i);
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: omniDashScope 媒体)",
() => {
test("omni --list-voices 输出音色列表并退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["omni", "--list-voices"]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/Omni output voices:/);
expect(stdout).toMatch(/Tina/);
expect(stdout).toMatch(/Dylan/);
expect(stdout).toMatch(/Total: 13 voices/);
});
test("omni 缺少 --message 时打印子命令帮助并退出 (0)", async () => {
const { stderr, exitCode } = await runCli([
"omni",
"--model",
"qwen3.5-omni-flash",
"--non-interactive",
]);
expect(exitCode).toBe(0);
expect(stderr).toMatch(/--message|Usage:/i);
});
test("omni --audio 无法识别扩展名时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"omni",
"--model",
"qwen3.5-omni-flash",
"--audio",
"https://example.com/sample.flac",
"--text-only",
"--message",
"这段音频在说什么?",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Unsupported audio extension|Cannot infer audio format/i);
});
test("omni --dry-run --audio 构造 input_audio 而非 audio_url", async () => {
const { stdout, stderr, exitCode } = await runCli([
"omni",
"--dry-run",
"--model",
"qwen3.5-omni-flash",
"--audio",
"https://example.com/sample.wav",
"--text-only",
"--message",
"这段音频在说什么?",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: {
messages?: Array<{
content?: Array<{
type?: string;
audio_url?: unknown;
input_audio?: { data?: string; format?: string };
}>;
}>;
};
}>(stdout);
const parts = data.request?.messages?.flatMap((m) =>
Array.isArray(m.content) ? m.content : [],
);
const audioPart = parts?.find((p) => p.type === "input_audio" || p.type === "audio_url");
expect(audioPart?.type).toBe("input_audio");
expect(audioPart?.audio_url).toBeUndefined();
expect(audioPart?.input_audio?.data).toBe("https://example.com/sample.wav");
expect(audioPart?.input_audio?.format).toBe("wav");
});
test("【qwen3.5-omni-flash】本地音频理解", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const clipText = "端到端Omni音频测试";
const clipWav = join(outDir, "e2e-omni-input.wav");
const syn = await runCli([
"speech",
"synthesize",
"--model",
"cosyvoice-v3-flash",
"--voice",
"longxiaochun_v3",
"--text",
clipText,
"--format",
"wav",
"--out",
clipWav,
"--non-interactive",
"--output",
"json",
]);
expect(syn.exitCode, syn.stderr).toBe(0);
const omni = await runCli([
"omni",
"--model",
"qwen3.5-omni-flash",
"--audio",
clipWav,
"--text-only",
"--system",
"请逐字转写用户提供的音频内容,不要添加解释。",
"--message",
"请转写这段音频。",
"--non-interactive",
"--output",
"json",
]);
expect(omni.exitCode, omni.stderr).toBe(0);
const body = parseStdoutJson<{ content?: string }>(omni.stdout);
expect(body.content?.replace(/\s/g, "")).toMatch(/端到端Omni音频测试/);
}, 180_000);
},
);
+124
View File
@@ -0,0 +1,124 @@
import { execFile } from "child_process";
import { createServer, type Server } from "http";
import { mkdtempSync, rmSync, writeFileSync } from "fs";
import type { AddressInfo } from "net";
import { tmpdir } from "os";
import { join } from "path";
import { promisify } from "util";
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
import { cliPackageRoot } from "./helpers.ts";
const execFileAsync = promisify(execFile);
/**
* 代理支持 E2Eissue #35只验证 `setupProxyFromEnv()` 是否把代理 dispatcher
* 正确装到全局 fetch 上——设了 HTTPS_PROXY 后裸 `fetch()` 走代理,未设置时直连,
* NO_PROXY 命中时跳过,非法代理值给出明确报错。
*
* 不经过任何 CLI 命令(不解析凭证、不打 gateway因此 CI 上无需 api key /
* access token与既有 e2e 设计一致。全程离线:目标域名用 `.invalid`(保留顶级域,
* 必然无法解析),代理收到 CONNECT 后规范返回 502不产生真实外网请求。
*/
const FAKE_HOST = "bl-proxy-e2e.invalid";
const FAKE_URL = `https://${FAKE_HOST}/probe`;
/**
* 最小探针脚本:调用真实的 `setupProxyFromEnv()`,再对目标发一个普通 fetch。
* 代理行为由进程环境变量决定正是被测对象fetch 成败不重要,我们只看代理是否收到 CONNECT。
*/
const PROBE_SCRIPT = `
import { setupProxyFromEnv } from ${JSON.stringify(join(cliPackageRoot, "src", "proxy.ts"))};
setupProxyFromEnv();
try {
await fetch(${JSON.stringify(FAKE_URL)}, { signal: AbortSignal.timeout(5000) });
} catch {
// 目标不可达/隧道被拒都正常——本测试只关心代理是否收到 CONNECT
}
`;
let proxy: Server;
let proxyUrl: string;
let scriptDir: string;
let scriptPath: string;
const connectTargets: string[] = [];
beforeAll(async () => {
proxy = createServer();
// 记录收到的 CONNECT 目标host:port并以 502 拒绝隧道
proxy.on("connect", (req, clientSocket) => {
connectTargets.push(req.url ?? "");
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
});
await new Promise<void>((resolve) => proxy.listen(0, "127.0.0.1", resolve));
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`;
scriptDir = mkdtempSync(join(tmpdir(), "bl-proxy-e2e-"));
scriptPath = join(scriptDir, "probe.ts");
writeFileSync(scriptPath, PROBE_SCRIPT);
});
afterAll(async () => {
await new Promise<void>((resolve) => proxy.close(() => resolve()));
rmSync(scriptDir, { recursive: true, force: true });
});
/** 清空所有代理相关环境变量,确保每个用例只受自身设置影响 */
const PROXY_ENV_CLEARED = {
HTTPS_PROXY: "",
https_proxy: "",
HTTP_PROXY: "",
http_proxy: "",
NO_PROXY: "",
no_proxy: "",
};
/** 以给定代理环境变量运行探针脚本,返回 { exitCode, stderr } */
async function runProbe(
envOverrides: NodeJS.ProcessEnv,
): Promise<{ exitCode: number; stderr: string }> {
try {
await execFileAsync("node", [scriptPath], {
cwd: cliPackageRoot,
encoding: "utf8",
env: { ...process.env, NODE_NO_WARNINGS: "1", ...PROXY_ENV_CLEARED, ...envOverrides },
});
return { exitCode: 0, stderr: "" };
} catch (err: unknown) {
const e = err as { stderr?: string; code?: number };
return { exitCode: typeof e.code === "number" ? e.code : 1, stderr: e.stderr ?? "" };
}
}
describe("e2e: proxy", () => {
test("设置 HTTPS_PROXY 后 fetch 经过代理CONNECT 到目标主机)", async () => {
connectTargets.length = 0;
await runProbe({ HTTPS_PROXY: proxyUrl });
expect(connectTargets).toContain(`${FAKE_HOST}:443`);
});
test("空字符串小写变量不屏蔽大写 HTTPS_PROXYundici ?? 取值回归)", async () => {
connectTargets.length = 0;
await runProbe({ https_proxy: "", HTTPS_PROXY: proxyUrl });
expect(connectTargets).toContain(`${FAKE_HOST}:443`);
});
test("NO_PROXY 命中目标主机时不走代理", async () => {
connectTargets.length = 0;
await runProbe({ HTTPS_PROXY: proxyUrl, NO_PROXY: FAKE_HOST });
expect(connectTargets.filter((t) => t.startsWith(FAKE_HOST))).toEqual([]);
});
test("未设置代理变量时保持直连(代理收不到任何流量)", async () => {
connectTargets.length = 0;
await runProbe({});
expect(connectTargets).toEqual([]);
});
test("代理 URL 非法时给出明确报错而非堆栈", async () => {
const { exitCode, stderr } = await runProbe({ HTTPS_PROXY: "::::not-a-url" });
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/Invalid proxy configuration/);
expect(stderr).toMatch(/HTTPS_PROXY/);
});
});
+367
View File
@@ -0,0 +1,367 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
describe("e2e: quota", () => {
test("quota list --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--all");
});
test("quota list --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl quota list");
expect(stderr).toContain("bl quota list --model qwen3.6-plus");
expect(stderr).toContain("bl quota list --all");
});
test("quota request --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "request", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--tpm");
expect(stderr).toContain("--yes");
});
test("quota history --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "history", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--page");
expect(stderr).toContain("--model");
});
test("quota check --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--period");
expect(stderr).toContain("bl quota check");
});
test("quota check --period 0 报错最小值", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--period", "0.5"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("at least 1 minute");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
test("quota list --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"list",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: {
input?: { queryQpmInfo?: boolean; supports?: { selfServiceLimitIncrease?: boolean } };
};
}>(stdout);
expect(data.api).toContain("listFoundationModels");
expect(data.data?.input?.queryQpmInfo).toBe(true);
expect(data.data?.input?.supports?.selfServiceLimitIncrease).toBe(true);
});
test("quota list --dry-run --all 不传 supports 过滤", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"list",
"--all",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { input?: { supports?: unknown } };
}>(stdout);
expect(data.data?.input?.supports).toBeUndefined();
});
test("quota list 文本输出包含英文表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"list",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("Req/min");
expect(stdout).toContain("Token/min");
expect(stdout).toContain("Max TPM");
});
test("quota list --model 指定模型返回结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"list",
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toMatch(/Total: 1 models/);
});
test("quota list --model 不存在的模型报错", async () => {
const { stderr, exitCode } = await runCli([
"quota",
"list",
"--model",
"nonexistent-model-xyz-99999",
"--output",
"text",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("no matching models found");
});
test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => {
const { stdout, stderr, exitCode } = await runCli(["quota", "list", "--output", "json"]);
expect(exitCode, stderr).toBe(0);
const data =
parseStdoutJson<
Array<{ model?: string; rpm?: number | null; tpm?: number | null; maxTPM?: number | null }>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBeTypeOf("string");
expect(data[0].rpm).toBeTypeOf("number");
expect(data[0].tpm).toBeTypeOf("number");
expect(data[0].maxTPM).toBeTypeOf("number");
});
test("quota request --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"request",
"--model",
"qwen3.6-plus",
"--tpm",
"6000000",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { input?: { model?: string; limit?: { usage_limit?: number } } };
}>(stdout);
expect(data.api).toContain("updateFoundationModelLimits");
expect(data.data?.input?.model).toBe("qwen3.6-plus");
expect(data.data?.input?.limit?.usage_limit).toBeTypeOf("number");
});
test("quota request TPM 超范围报错", async () => {
const { stderr, exitCode } = await runCli([
"quota",
"request",
"--model",
"qwen3.6-plus",
"--tpm",
"999",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("out of range");
expect(stderr).toContain("Current");
expect(stderr).toContain("Range");
});
test("quota request 不支持提额的模型报错", async () => {
const { stderr, exitCode } = await runCli([
"quota",
"request",
"--model",
"nonexistent-model-xyz-99999",
"--tpm",
"100000",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("not found");
});
test("quota history --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"history",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { input?: { pageNo?: number; pageSize?: number } };
}>(stdout);
expect(data.api).toContain("listModelLimitApplications");
expect(data.data?.input?.pageNo).toBe(1);
expect(data.data?.input?.pageSize).toBe(10);
});
test("quota check --dry-run 输出 API 信息", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ apis?: string[]; consoleRegion?: string }>(stdout);
expect(data.apis).toContain(
"zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels",
);
expect(data.apis).toContain("zeldaEasy.bailian-telemetry.monitor.getMonitorData");
expect(data.consoleRegion).toBe("cn-beijing");
});
test("quota check --dry-run --console-region 透传", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--dry-run",
"--non-interactive",
"--output",
"json",
"--console-region",
"cn-hangzhou",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ consoleRegion?: string }>(stdout);
expect(data.consoleRegion).toBe("cn-hangzhou");
});
test("quota check 文本输出包含英文表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("RPM Usage/Limit");
expect(stdout).toContain("TPM Usage/Limit");
expect(stdout).toContain("Status");
});
test("quota check --model 指定单模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toMatch(/Total: 1 models/);
});
test("quota check --model 逗号分隔多模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus,qwen-plus",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3.6-plus");
expect(stdout).toContain("qwen-plus");
expect(stdout).toMatch(/Total: 2 models/);
});
test("quota check JSON 输出包含用量和限额字段", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<
Array<{
model?: string;
rpmUsage?: number;
rpmLimit?: number;
tpmUsage?: number;
tpmLimit?: number;
}>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(1);
expect(data[0].model).toBe("qwen3.6-plus");
expect(data[0].rpmUsage).toBeTypeOf("number");
expect(data[0].rpmLimit).toBeTypeOf("number");
expect(data[0].tpmUsage).toBeTypeOf("number");
expect(data[0].tpmLimit).toBeTypeOf("number");
});
test("quota check 状态列显示 Normal/Near limit/Rate Limited 之一", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"check",
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const hasStatus =
stdout.includes("Normal") || stdout.includes("Near limit") || stdout.includes("Rate Limited");
expect(hasStatus).toBe(true);
});
test("quota history --dry-run --page 2 --page-size 20", async () => {
const { stdout, stderr, exitCode } = await runCli([
"quota",
"history",
"--page",
"2",
"--page-size",
"20",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { input?: { pageNo?: number; pageSize?: number } };
}>(stdout);
expect(data.data?.input?.pageNo).toBe(2);
expect(data.data?.input?.pageSize).toBe(20);
});
});
@@ -0,0 +1,282 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
describe("e2e: usage free", () => {
test("usage 分组展示子命令帮助且退出码为 0", async () => {
const { stdout, stderr, exitCode } = await runCli(["usage"]);
expect(exitCode, stderr).toBe(0);
const out = `${stdout}\n${stderr}`;
expect(out).toMatch(/usage|free|freetier/i);
});
test("usage free --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|quota|free-tier/i);
});
test("usage free --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage free");
expect(stderr).toContain("bl usage free --model qwen3-max");
expect(stderr).toContain("bl usage free --model qwen3-max,qwen-turbo");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage freeConsole", () => {
test("usage free --dry-run --model 输出请求参数不发起调用", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--dry-run",
"--model",
"qwen3-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
}>(stdout);
expect(data.api).toContain("queryFreeTierQuota");
expect(data.data?.queryFreeTierQuotaRequest?.models).toEqual(["qwen3-max"]);
});
test("usage free --dry-run --model 逗号分隔多个模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--dry-run",
"--model",
"qwen3-max,qwen-turbo",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
}>(stdout);
expect(data.data?.queryFreeTierQuotaRequest?.models).toEqual(["qwen3-max", "qwen-turbo"]);
});
test("usage free --dry-run --model 重复模型名自动去重", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--dry-run",
"--model",
"qwen3-max,qwen3-max,qwen-turbo",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
}>(stdout);
expect(data.data?.queryFreeTierQuotaRequest?.models).toEqual(["qwen3-max", "qwen-turbo"]);
});
test("usage free --dry-run --model 逗号间有空格也能正确解析", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--dry-run",
"--model",
"qwen3-max, qwen-turbo",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { queryFreeTierQuotaRequest?: { models?: string[] } };
}>(stdout);
expect(data.data?.queryFreeTierQuotaRequest?.models).toEqual(["qwen3-max", "qwen-turbo"]);
});
test("usage free --dry-run 不指定 --model 传全量模型列表", async () => {
const { stderr, exitCode } = await runCli(["usage", "free", "--dry-run", "--output", "json"]);
expect(exitCode, stderr).toBe(0);
});
test("usage free --model 单模型查询返回 JSON 结果", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<
Array<{
model?: string;
type?: string | null;
remaining?: number | null;
total?: number | null;
usagePercent?: number | null;
expires?: string | null;
autoStop?: boolean | string | null;
}>
>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBe("qwen3-max");
expect(data[0].type).toBeTypeOf("string");
});
test("usage free --model 单模型文本输出包含表头", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Model");
expect(stdout).toContain("Type");
expect(stdout).toContain("Remaining/Total");
expect(stdout).toContain("Usage");
expect(stdout).toContain("Expires");
expect(stdout).toContain("Auto-Stop");
});
test("usage free --model 文本输出包含模型名", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3-max");
});
test("usage free --model 逗号分隔多模型文本输出包含所有模型", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max,qwen-turbo",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("qwen3-max");
expect(stdout).toContain("qwen-turbo");
});
test("usage free --model 文本输出包含正确的 Type 列", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Text");
});
test("usage free --model quotaStatus 为 UNKNOWN 时 Auto-Stop 显示 Unsupported", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"wan2.7-image",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Unsupported");
});
test("usage free --model quotaStatus 为 UNKNOWN 时额度显示为 -", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"wan2.7-image",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const lines = stdout.split("\n").filter((line) => line.includes("wan2.7-image"));
expect(lines.length).toBe(1);
expect(lines[0]).toContain("Vision");
expect(lines[0]).toContain("Unsupported");
});
test("usage free --model 不存在的模型仍返回表格行", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"nonexistent-model-xyz-12345",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("nonexistent-model-xyz-12345");
});
test("usage free --model Auto-Stop 显示 ON、OFF 或 Unsupported", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
const hasAutoStop =
stdout.includes("ON") || stdout.includes("OFF") || stdout.includes("Unsupported");
expect(hasAutoStop).toBe(true);
});
test("usage free --model --console-region cn-beijing 指定区域查询", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"free",
"--model",
"qwen3-max",
"--console-region",
"cn-beijing",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<Array<{ model?: string }>>(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
expect(data[0].model).toBe("qwen3-max");
});
});
@@ -0,0 +1,273 @@
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCli } from "./helpers.ts";
import { readConfigFile } from "bailian-cli-core";
function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_ACCESS_TOKEN?.trim()) return true;
try {
const config = readConfigFile();
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
function getStaticWorkspaceId(): string | undefined {
if (process.env.BAILIAN_WORKSPACE_ID?.trim()) return process.env.BAILIAN_WORKSPACE_ID.trim();
try {
const config = readConfigFile();
if (config.workspace_id) return config.workspace_id;
} catch {}
return undefined;
}
async function fetchDefaultWorkspaceId(): Promise<string> {
const staticId = getStaticWorkspaceId();
if (staticId) return staticId;
const { stdout } = await runCli(["workspace", "list", "--output", "json"]);
const result = JSON.parse(stdout);
const data = result?.data?.DataV2?.data?.data?.data ?? [];
const defaultWs = data.find((ws: { defaultAgent?: boolean }) => ws.defaultAgent);
if (defaultWs?.workspaceId) return defaultWs.workspaceId;
if (data.length > 0 && data[0].workspaceId) return data[0].workspaceId;
throw new Error("No workspace found for e2e tests");
}
describe("e2e: usage stats", () => {
test("usage stats --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--days|stats/i);
});
test("usage stats --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage stats");
expect(stderr).toContain("bl usage stats --model qwen-turbo");
expect(stderr).toContain("bl usage stats --days 30");
});
test("usage stats --help 包含 --workspace-id 选项", async () => {
const { stderr, exitCode } = await runCli(["usage", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--workspace-id");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage statsConsole", () => {
let wsId: string;
test("获取默认 workspace-id", async () => {
wsId = await fetchDefaultWorkspaceId();
expect(wsId).toBeTypeOf("string");
expect(wsId.length).toBeGreaterThan(0);
});
test("usage stats --dry-run 概览模式输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: {
reqDTO?: {
startTime?: number;
endTime?: number;
modelCallSource?: string;
filterWorkspaceId?: string;
};
};
}>(stdout);
expect(data.api).toContain("getModelUsageStatistic");
expect(data.data?.reqDTO?.modelCallSource).toBe("Online");
expect(data.data?.reqDTO?.startTime).toBeTypeOf("number");
expect(data.data?.reqDTO?.endTime).toBeTypeOf("number");
expect(data.data?.reqDTO?.filterWorkspaceId).toBe(wsId);
});
test("usage stats --dry-run --days 30 时间跨度约 30 天", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--dry-run",
"--days",
"30",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { reqDTO?: { startTime?: number; endTime?: number } };
}>(stdout);
const span = (data.data?.reqDTO?.endTime ?? 0) - (data.data?.reqDTO?.startTime ?? 0);
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
expect(span).toBeGreaterThan(thirtyDaysMs - 5000);
expect(span).toBeLessThan(thirtyDaysMs + 5000);
});
test("usage stats --dry-run --model 指定模型使用 list API", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--dry-run",
"--model",
"qwen-turbo",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { reqDTO?: { model?: string; filterWorkspaceId?: string } };
}>(stdout);
expect(data.api).toContain("listModelUsageStatisticData");
expect(data.data?.reqDTO?.model).toBe("qwen-turbo");
expect(data.data?.reqDTO?.filterWorkspaceId).toBe(wsId);
});
test("usage stats --dry-run --type Text 传递 obsModelType", async () => {
const { stdout, stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--dry-run",
"--type",
"Text",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { reqDTO?: { obsModelType?: string } };
}>(stdout);
expect(data.data?.reqDTO?.obsModelType).toBe("Text");
});
test("usage stats 概览模式返回 JSON 结果", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
});
test("usage stats 概览文本输出包含英文标签", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
});
test("usage stats 概览文本输出包含 Token 用量", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
});
test("usage stats --model 单模型文本输出包含英文表头", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--model",
"qwen3.6-plus",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
});
test("usage stats --model 逗号分隔多模型返回多行", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--model",
"qwen3.6-plus,deepseek-v4-pro",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
});
test("usage stats --model 不存在的模型返回空表格", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--model",
"nonexistent-model-xyz-99999",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
});
test("usage stats --days 1 短时间范围正常返回", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--days",
"1",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
});
test("usage stats --type Vision 按类型过滤", async () => {
const { stderr, exitCode } = await runCli([
"usage",
"stats",
"--workspace-id",
wsId,
"--type",
"Vision",
"--output",
"text",
"--no-color",
]);
expect(exitCode, stderr).toBe(0);
});
});
@@ -91,7 +91,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--duration",
"3",
"--prompt",
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"generate",
"--model",
"happyhorse-1.0-i2v",
"happyhorse-1.1-i2v",
"--image",
"https://example.com/placeholder.png",
"--non-interactive",
@@ -53,7 +53,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"generate",
"--dry-run",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"干跑无图",
"--non-interactive",
@@ -68,7 +68,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
expect(data.request?.input?.media).toBeUndefined();
});
test("【happyhorse-1.0-i2v】图片生成视频", async () => {
test("【happyhorse-1.1-i2v】图片生成视频", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const png = join(outDir, "e2e-gen.png");
const gen = await runCli([
@@ -95,7 +95,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"generate",
"--model",
"happyhorse-1.0-i2v",
"happyhorse-1.1-i2v",
"--image",
imagePath,
"--prompt",
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--non-interactive",
]);
expect(exitCode).toBe(0);
@@ -51,7 +51,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"generate",
"--dry-run",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"干跑校验",
"--non-interactive",
@@ -62,18 +62,18 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
const data = parseStdoutJson<{ request?: { model?: string; input?: { prompt?: string } } }>(
stdout,
);
expect(data.request?.model).toBe("happyhorse-1.0-t2v");
expect(data.request?.model).toBe("happyhorse-1.1-t2v");
expect(data.request?.input?.prompt).toBe("干跑校验");
});
test("【happyhorse-1.0-t2v】文本生成视频", async () => {
test("【happyhorse-1.1-t2v】文本生成视频", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const { stdout, stderr, exitCode } = await runCli([
...cliTimeoutPrefix(),
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"夕阳下海面波光,远景静态镜头",
"--download",
@@ -37,7 +37,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"ref",
"--model",
"happyhorse-1.0-r2v",
"happyhorse-1.1-r2v",
"--image",
"https://example.com/x.png",
"--non-interactive",
@@ -52,7 +52,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"ref",
"--model",
"happyhorse-1.0-r2v",
"happyhorse-1.1-r2v",
"--prompt",
"仅有描述无素材",
"--non-interactive",
@@ -61,7 +61,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
expect(stderr).toMatch(/--image|ref-video|At least one|required/i);
});
test("【happyhorse-1.0-r2v】视频参考生成", async () => {
test("【happyhorse-1.1-r2v】视频参考生成", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const gen = await runCli([
"image",
@@ -69,7 +69,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"--model",
"qwen-image-2.0",
"--prompt",
"一只简笔画小猫,白底",
"一片绿色的树叶,白底",
"--out-dir",
outDir,
"--out-prefix",
@@ -88,7 +88,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"video",
"ref",
"--model",
"happyhorse-1.0-r2v",
"happyhorse-1.1-r2v",
"--prompt",
"图1在画面中心轻微晃动",
"--image",
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from "vite-plus/test";
import { readProxyEnv } from "../src/proxy.ts";
test("readProxyEnv: 未设置任何代理变量时全部为 undefined", () => {
expect(readProxyEnv({})).toEqual({
httpProxy: undefined,
httpsProxy: undefined,
noProxy: undefined,
});
});
test("readProxyEnv: 空白值视为未设置", () => {
expect(readProxyEnv({ HTTPS_PROXY: "", HTTP_PROXY: " ", NO_PROXY: "" })).toEqual({
httpProxy: undefined,
httpsProxy: undefined,
noProxy: undefined,
});
});
test("readProxyEnv: 大小写变量均可识别,小写优先", () => {
expect(readProxyEnv({ HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe("http://upper:1");
expect(readProxyEnv({ https_proxy: "http://lower:1" }).httpsProxy).toBe("http://lower:1");
expect(
readProxyEnv({ https_proxy: "http://lower:1", HTTPS_PROXY: "http://upper:1" }).httpsProxy,
).toBe("http://lower:1");
});
test("readProxyEnv: 空字符串小写变量不屏蔽已设置的大写变量", () => {
expect(readProxyEnv({ https_proxy: "", HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe(
"http://upper:1",
);
expect(readProxyEnv({ http_proxy: "", HTTP_PROXY: "http://upper:2" }).httpProxy).toBe(
"http://upper:2",
);
});
test("readProxyEnv: NO_PROXY 独立读取", () => {
const r = readProxyEnv({ NO_PROXY: "*.aliyuncs.com" });
expect(r.noProxy).toBe("*.aliyuncs.com");
expect(r.httpProxy).toBeUndefined();
expect(r.httpsProxy).toBeUndefined();
});
+1 -1
View File
@@ -180,7 +180,7 @@ export async function ensurePrerequisites(ctx) {
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"压测前置短视频:海浪与静态远景,无明显人物。",
"--duration",
@@ -132,7 +132,7 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) {
"video",
"generate",
"--model",
"happyhorse-1.0-t2v",
"happyhorse-1.1-t2v",
"--prompt",
"压测前置短视频:海浪与静态远景,无明显人物。",
"--duration",
@@ -16,7 +16,7 @@ const motions = [
export const runStress = defineStressTarget({
canonical: "video-i2v",
defaultModel: "happyhorse-1.0-i2v",
defaultModel: "happyhorse-1.1-i2v",
batchDirPrefix: "video-i2v-batch",
helpText: "pnpm run test:stress -- video-i2v [--reuse-fixtures] -- --count 5 -c 2",
@@ -16,7 +16,7 @@ const prompts = [
export const runStress = defineStressTarget({
canonical: "video-ref",
defaultModel: "happyhorse-1.0-r2v",
defaultModel: "happyhorse-1.1-r2v",
batchDirPrefix: "video-ref-batch",
helpText: "pnpm run test:stress -- video-ref [--reuse-fixtures] -- --count 5 -c 2",
@@ -45,7 +45,7 @@ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
export const runStress = defineStressTarget({
canonical: "video-t2v",
defaultModel: "happyhorse-1.0-t2v",
defaultModel: "happyhorse-1.1-t2v",
batchDirPrefix: "video-t2v-batch",
helpText: `用法pnpm run test:stress -- video-t2v -- --concurrency 1 --count 3
详见 docs/agents/stress-batch-tests.md`,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.2.1",
"version": "1.4.2",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+142 -157
View File
@@ -1,196 +1,181 @@
export const INTENT_MODEL = "qwen-turbo";
export const INTENT_MODEL = "qwen-flash";
export const RANKING_MODEL = "qwen3.6-flash";
export const RANKING_MODEL_FAST = "qwen-turbo";
export const RANKING_MODEL_FAST = "qwen-flash";
export const INTENT_SYSTEM_PROMPT = `你是一个意图分析器。根据用户的需求描述,先理解用户场景,再提取结构化信息。
export const INTENT_SYSTEM_PROMPT = `You are an intent analyzer. Given the user's requirement, understand the scenario first, then extract structured information.
## 分析步骤
1. 用一句话总结用户的核心需求taskSummary要体现具体场景而非泛泛描述
2. 推断场景特征scenarioHints例如["需要低延迟","面向C端用户","高并发","对话式交互","离线批处理","需要精准度"]
3. 基于场景特征推断 budget 和 qualityPreference
- 只在用户明确表达或场景强烈暗示时偏离默认值
- 用户明确说"低成本"、"便宜"、"省钱" → budget:"low"
- 用户明确说"最好的"、"高精度"、"不计成本" → qualityPreference:"flagship"
- 场景本身有强约束时才推断:如"日均百万请求的客服" → budget:"low"(高并发=成本敏感)
- 其他情况保持 budget:"medium", qualityPreference:"balanced"
4. 提取模态、能力、特性等结构化字段
CRITICAL: You MUST respond entirely in English. Do not use any Chinese characters anywhere in your response. All text fields (taskSummary, scenarioHints) must be in English.
## 示例
## Analysis Steps
1. Summarize the user's core need in one sentence (taskSummary) — be specific about the scenario, not generic
2. Infer scenario hints (scenarioHints), e.g.: ["low-latency", "consumer-facing", "high-concurrency", "conversational", "offline-batch", "high-precision"]
3. Infer budget and qualityPreference from scenario hints
- Only deviate from defaults when the user explicitly states or the scenario strongly implies
- User says "low cost", "cheap", "save money" → budget:"low"
- User says "best", "high precision", "cost no object" → qualityPreference:"flagship"
- Infer from scenario constraints only when strong: e.g. "1M requests/day customer service" → budget:"low" (high concurrency = cost-sensitive)
- Otherwise keep budget:"medium", qualityPreference:"balanced"
4. Extract modalities, capabilities, features etc.
用户: "做一个低成本高并发的在线客服"
→ budget:"low", qualityPreference:"cost-optimized"(用户明确说了低成本)
## Model preference detection
Analyze whether the user mentioned specific models, model families, or vendors:
- No models/families/vendors mentioned → mode:"unconstrained", no targets
- User scoped the range (e.g. "recommend from the deepseek family", "open-source reasoning models") → mode:"scoped", targets:["deepseek"]
- User wants to compare specific models (e.g. "compare wan2.6 and wan2.7", "is qwen-max good for legal analysis") → mode:"comparison", targets:["wan2.6","wan2.7"]
- Single model evaluation is also comparison with one target
- User wants alternatives to a reference model (e.g. "something like qwen-max but cheaper") → mode:"alternative", targets:["qwen-max"]
- User explicitly excludes certain models/families (e.g. "good models besides qwen") → excludes:["qwen"], mode determined by other signals
- targets should capture the model/family names as the user wrote them
用户: "法律合同审查,要求高精准度"
→ budget:"medium", qualityPreference:"flagship"(用户明确要求高精准度,但没提预算)
用户: "我要做一个能理解图片的客服机器人"
→ budget:"medium", qualityPreference:"balanced"(用户没提成本和质量要求,不过度推断)
用户: "帮我选一个写代码的模型"
→ budget:"medium", qualityPreference:"balanced"(通用需求,无明确倾向)
用户: "预算有限,做个简单的文本摘要功能"
→ budget:"low", qualityPreference:"cost-optimized"(用户说了预算有限)
用户: "企业级知识库问答,准确率是第一优先级"
→ budget:"high", qualityPreference:"flagship"(企业级+准确率第一=愿投入高成本)
用户: "个人学习项目试试AI生成图片"
budget:"low", qualityPreference:"cost-optimized"(个人学习=成本敏感)
用户: "做一个Agent自动根据用户意图生成动画片"
→ budget:"medium", qualityPreference:"balanced"复杂pipeline但没明确成本/质量约束)
## 模型偏好识别
分析用户是否提到了特定的模型、模型系列或厂商,据此判断推荐模式:
- 用户未提到任何模型/系列/厂商 → mode:"unconstrained",不填 targets
- 用户限定了范围(如"deepseek系列哪个好"、"通义千问的模型推荐"、"开源的推理模型" → mode:"scoped"targets:["deepseek"] 或 ["通义千问"]
- 用户要对比特定模型(如"wan2.6和wan2.7哪个好"、"qwen-max和deepseek-v3对比"、"qwen-max适合做法律分析吗" → mode:"comparison"targets:["wan2.6","wan2.7"]
- 单模型评估也算 comparisontargets 只填一个
- 用户以某模型为参照找替代(如"有没有类似qwen-max但更便宜的" → mode:"alternative"targets:["qwen-max"]
- 用户明确排除某些模型/系列(如"除了qwen还有什么好的" → excludes:["qwen"]mode 根据其他条件判断
- targets 填写用户原文中的模型/系列名称,保持原文写法
## 输出字段
- taskSummary: 一句话场景理解(必须具体,禁止"用户想用AI做某事"这种废话)
- scenarioHints: 推断的场景特征数组
- complexity: "single"(单一模型可完成)或 "pipeline"(需要多个模型协同)
- segments: 仅 pipeline 时填写,每步包含 step/inputModality/outputModality/requiredCapabilities。
- step 必须是一句话描述该步骤在用户任务中解决的具体问题,例如"解析天气预报数据,生成适合视频制作的场景描述文本",禁止用编号或泛化的模态标签
- segments 必须形成模态链路:每步的 inputModality 应包含上一步的 outputModality确保上下游数据可以衔接
- inputModality: 用户输入涉及的模态 ["Text","Image","Video","Audio"]
- outputModality: 期望输出的模态
- requiredCapabilities: 需要的能力。可选代码(必须严格使用,不要自创):
TG=文本生成, Reasoning=推理, VU=视觉理解, IG=图像生成, VG=视频生成,
TTS=语音合成, ASR=语音识别, Realtime-ASR=实时语音识别,
Realtime-Text-to-Speech=实时语音合成, Realtime-Audio-Translate=实时音频翻译,
Realtime-Omni=实时全模态, Multimodal-Omni=全模态, ME=多模态嵌入,
TR=翻译, 3D-generation=3D生成
- requiredFeatures: 需要的特性 (function-calling, web-search, structured-outputs, prefix-completion)
- budget: "low"/"medium"/"high"(基于场景推断,不要默认 medium
## Output fields
- taskSummary: one-sentence scenario understanding (must be specific, never generic like "user wants AI")
- scenarioHints: array of inferred scenario features
- complexity: "single" or "pipeline"
- segments: only for pipeline, each with step/inputModality/outputModality/requiredCapabilities
- step must describe the specific problem this step solves in the user's task, no numbered or generic modal labels
- segments must form a modality chain: each step's inputModality should cover the previous step's outputModality
- inputModality: user input modalities ["Text","Image","Video","Audio"]
- outputModality: expected output modalities
- requiredCapabilities: capability codes (use strictly from the list, don't invent):
TG=Text Generation, Reasoning=Reasoning, VU=Vision Understanding, IG=Image Generation, VG=Video Generation,
TTS=Text-to-Speech, ASR=Speech-to-Text, Realtime-ASR=Realtime Speech-to-Text,
Realtime-Text-to-Speech=Realtime Text-to-Speech, Realtime-Audio-Translate=Realtime Audio Translation,
Realtime-Omni=Realtime Omni-modal, Multimodal-Omni=Multimodal Omni, ME=Multimodal Embedding,
TR=Translation, 3D-generation=3D Generation
- requiredFeatures: required features (function-calling, web-search, structured-outputs, prefix-completion)
- budget: "low"/"medium"/"high"
- contextNeed: "standard"/"large"/"extra-large"
- qualityPreference: "flagship"/"balanced"/"cost-optimized"(基于场景推断,不要默认 balanced
- modelPreference: { mode, targets?, excludes? }(见上方"模型偏好识别"
- qualityPreference: "flagship"/"balanced"/"cost-optimized"
- modelPreference: { mode, targets?, excludes? }
只输出 JSON不要有其他文字。`;
Output only JSON, no other text.`;
export const SINGLE_SYSTEM_PROMPT = `你是阿里云百炼平台的模型推荐顾问。从以下候选模型中选出最佳推荐。
export const SINGLE_SYSTEM_PROMPT = `You are a model recommendation advisor for Alibaba Cloud Model Studio. From the candidate models below, select the best recommendations.
## 背景
系统已根据用户意图预筛选了候选模型,你只需从中精选并排序。
意图分析中包含 budget 和 qualityPreference 字段,这代表了用户的实际需求层次。
CRITICAL: You MUST respond entirely in English. Do not use any Chinese characters anywhere in your response. Every field — reason, highlights, step, summary — must be written in English.
## 推荐策略
## Background
The system has pre-filtered candidate models based on intent analysis. Your job is to rank and pick from these candidates.
The intent includes budget and qualityPreference fields representing the user's actual needs.
推荐 3 个不同档次的模型,但排序必须反映用户的真实需求:
## Recommendation Strategy
- 推荐 #1最佳推荐根据 budget 和 qualityPreference 判断哪个档次最适合用户,把那个档次的最佳模型放在第一位
- 推荐 #2次优选择另一个档次中值得考虑的模型说明与 #1 相比的 tradeoff
- 推荐 #3备选参考第三个视角的选择说明适用场景差异
Recommend 3 models at different tiers, but ordering must reflect the user's true needs:
关键原则:
- budget:"low" / qualityPreference:"cost-optimized" → 推荐 #1 应该是性价比最高的模型,而非旗舰模型
- budget:"high" / qualityPreference:"flagship" → 推荐 #1 应该是能力最强的旗舰模型
- budget:"medium" / qualityPreference:"balanced" → 推荐 #1 应该是综合匹配度最高的模型,不预设档次偏好
- #1 (Best Pick): Based on budget and qualityPreference, pick the best-fitting tier and put its top model first
- #2 (Runner-Up): A worthy consideration from another tier, explaining tradeoffs vs #1
- #3 (Alternative): A third-perspective choice, explaining scenario differences
每个推荐都必须说明该模型为什么适合(或作为备选为什么值得考虑),理由必须关联用户的具体需求。
Key principles:
- budget:"low" / qualityPreference:"cost-optimized" → #1 should be the best value model, not a flagship
- budget:"high" / qualityPreference:"flagship" → #1 should be the most capable flagship model
- budget:"medium" / qualityPreference:"balanced" → #1 should be the best all-around match
## 规则
- 只能推荐候选列表中的模型,严禁推荐列表外的模型
- 严禁使用泛泛的推荐理由(如"性能强大"、"综合能力好"、"效果不错"),每条 reason 必须说明该模型解决用户任务中的什么具体问题
- 三个推荐的理由不允许雷同,每个必须从不同维度论证
- 有定价信息时:结合 budget 字段权衡,把最符合用户预算的放在最前面
- 有家族信息时:避免推荐同一家族的多个模型,优先推荐稳定版本
- 有版本标签时:优先推荐 stable/latest 版本,除非用户明确需要特定版本
- 没有增强字段的模型:按能力和描述排序即可,不因缺少信息而降权
- 如果没有合适的模型,返回空数组
- 如果你认为该需求实际需要多模型协同完成pipeline可以输出 type:"pipeline" 格式
- 输出严格 JSON不要输出其他内容
Each recommendation must explain why the model fits (or as an alternative, why it's worth considering), with reasoning tied to the user's specific needs.
## 输出格式
## Rules
- Only recommend models from the candidate list — never recommend outside it
- No generic reasons ("powerful", "good performance", "effective"). Each reason must describe how the model solves a specific aspect of the user's task
- All three recommendations must have distinct reasoning angles, not duplicate reasons
- When pricing is available: factor in budget, put the most budget-friendly option first
- When family info is available: avoid recommending multiple models from the same family, prefer stable versions
- When version tags are available: prefer stable/latest versions unless the user explicitly needs a specific version
- Models without enriched fields: rank by capability and description — don't penalize for missing info
- If no model fits, return an empty array
- If you believe the task actually requires multi-model collaboration (pipeline), you may output type:"pipeline" format
- Output strict JSON, no other text
单一任务:
{"type":"single","recommendations":[{"model":"模型ID","reason":"推荐理由","highlights":["亮点"]}]}
## Output Format
复合任务(仅当你确信需要多模型协同时):
{"type":"pipeline","summary":"一句话方案描述","steps":[{"step":"步骤描述","recommendations":[{"model":"模型ID","reason":"选择理由","highlights":["亮点"]}]}]}`;
Single task:
{"type":"single","recommendations":[{"model":"model ID","reason":"recommendation reason","highlights":["key highlights"]}]}
export const PIPELINE_SYSTEM_PROMPT = `你是阿里云百炼平台的模型推荐顾问。用户需求已被拆解为多步骤流水线,请为每步选出最佳模型。
Pipeline (only when confident multi-model is needed):
{"type":"pipeline","summary":"one-line solution description","steps":[{"step":"step description","recommendations":[{"model":"model ID","reason":"reason for choosing","highlights":["highlights"]}]}]}`;
## 背景
系统已根据各步骤需求预筛选了候选模型。
意图分析中包含 budget 和 qualityPreference 字段,这代表了用户的实际需求层次。
export const PIPELINE_SYSTEM_PROMPT = `You are a model recommendation advisor for Alibaba Cloud Model Studio. The user's need has been decomposed into multi-step pipeline. Select the best model for each step.
## 推荐策略
CRITICAL: You MUST respond entirely in English. Do not use any Chinese characters anywhere in your response. Every field — reason, highlights, step, summary — must be written in English.
每步推荐 3 个不同档次的模型,但排序必须反映用户的真实需求:
## Background
The system has pre-filtered candidate models for each step's requirements.
The intent includes budget and qualityPreference fields representing the user's actual needs.
- 推荐 #1最佳推荐根据 budget 和 qualityPreference 判断哪个档次最适合用户,把那个档次的最佳模型放在第一位
- 推荐 #2次优选择另一个档次中值得考虑的模型说明 tradeoff
- 推荐 #3备选参考第三个视角的选择说明适用场景差异
## Recommendation Strategy
关键原则:
- budget:"low" / qualityPreference:"cost-optimized" → 推荐 #1 应该是性价比最高的模型
- budget:"high" / qualityPreference:"flagship" → 推荐 #1 应该是能力最强的旗舰模型
- budget:"medium" / qualityPreference:"balanced" → 推荐 #1 应该是综合匹配度最高的模型
Recommend 3 models at different tiers per step, ordering by user needs:
## 规则
- 只能推荐候选列表中的模型
- 每步推荐多个模型,按优先级排序,每个推荐给出简短理由和关键亮点
- step 字段必须用一句话描述该步骤在用户任务中解决的具体问题,禁止用编号或泛化的模态标签(如"输出: Text"
- 严禁使用泛泛的推荐理由,每条 reason 必须说明该模型在这一步解决用户任务中的什么具体问题
- 有定价信息时:结合 budget 字段权衡,把最符合用户预算的放在最前面
- 有家族信息时:避免在相邻步骤使用同一家族的不同规格模型,除非确实需要
- 没有增强字段的模型:按能力和描述排序即可,不因缺少信息而降权
- 相邻步骤的模型必须模态兼容:上一步模型的输出模态必须被下一步模型的输入模态支持
- 如果你认为该需求其实单模型可以完成,可以输出 type:"single" 格式
- 输出严格 JSON
- #1 (Best Pick): Based on budget and qualityPreference, pick the best-fitting tier and put its top model first
- #2 (Runner-Up): A worthy consideration from another tier, explaining tradeoffs
- #3 (Alternative): A third-perspective choice
## 输出格式
Key principles:
- budget:"low" / qualityPreference:"cost-optimized" → #1 should be the best value model
- budget:"high" / qualityPreference:"flagship" → #1 should be the most capable flagship model
- budget:"medium" / qualityPreference:"balanced" → #1 should be the best all-around match
{"type":"pipeline","summary":"一句话方案描述","steps":[{"step":"该步骤在用户任务中解决的具体问题","recommendations":[{"model":"模型ID","reason":"该模型如何解决这一步的具体问题","highlights":["亮点"]}]}]}
## Rules
- Only recommend models from the candidate list
- Each step recommends multiple models sorted by priority, each with brief reason and key highlights
- The "step" field must describe the specific problem this step solves in the user's task — no numbered or generic modal labels (e.g. "Output: Text")
- No generic reasons. Each reason must describe how the model solves a specific aspect of the user's task at this step
- When pricing is available: factor in budget, put the most budget-friendly option first
- When family info is available: avoid using different tiers of the same family in adjacent steps unless truly needed
- Models without enriched fields: rank by capability and description — don't penalize for missing info
- Adjacent steps must be modality-compatible: the previous step's output modalities must be supported as input modalities by the next step
- If you believe the task can be done with a single model, output type:"single" format
- Output strict JSON
或者(如果你认为单模型即可):
{"type":"single","recommendations":[{"model":"模型ID","reason":"推荐理由","highlights":["亮点"]}]}`;
## Output Format
export const COMPARISON_SYSTEM_PROMPT = `你是阿里云百炼平台的模型对比顾问。用户想对比特定模型,请根据使用场景进行对比分析。
{"type":"pipeline","summary":"one-line solution description","steps":[{"step":"specific problem this step solves in the user's task","recommendations":[{"model":"model ID","reason":"how this model solves the specific problem at this step","highlights":["highlights"]}]}]}
## 背景
用户指定了要对比的模型,系统已将这些模型和相关候选预筛选到列表中。
意图分析中的 modelPreference.targets 是用户要对比的模型。
Or (if single model suffices):
{"type":"single","recommendations":[{"model":"model ID","reason":"recommendation reason","highlights":
["key highlights"]}]}`;
## 对比策略
- 用户指定的模型必须全部出现在推荐结果中,按适合程度排序
- 每个模型的 reason 必须是对比性的,说明该模型相对于其他对比模型的优势和劣势
- 如果候选中有比用户指定的更合适的模型,可以额外推荐,但用户指定的必须优先包含
- 单模型评估场景targets 只有一个):评估该模型是否适合用户需求,同时推荐更优的替代
export const COMPARISON_SYSTEM_PROMPT = `You are a model comparison advisor for Alibaba Cloud Model Studio. The user wants to compare specific models — analyze them against the use case.
## 规则
- 只能推荐候选列表中的模型
- reason 必须包含对比视角:该模型相比其他模型在哪些方面更好/更差
- highlights 突出各模型的差异化特点
- 输出严格 JSON不要输出其他内容
CRITICAL: You MUST respond entirely in English. Do not use any Chinese characters anywhere in your response. Every field — reason, highlights — must be written in English.
## 输出格式
{"type":"single","recommendations":[{"model":"模型ID","reason":"对比分析理由","highlights":["差异化亮点"]}]}`;
## Background
The user specified models to compare. The system has pre-filtered these models and related candidates into the list.
The intent's modelPreference.targets are the models to compare.
export const ALTERNATIVE_SYSTEM_PROMPT = `你是阿里云百炼平台的模型替代顾问。用户以某个模型为参照,寻找替代方案。
## Comparison Strategy
- All user-specified models must appear in the results, sorted by suitability
- Each model's reason must be comparative: describe strengths and weaknesses relative to other models being compared
- If candidates contain better fits than what the user specified, they can be additionally recommended, but user-specified models take priority
- Single-model evaluation (one target): evaluate if the model fits, and recommend better alternatives
## 背景
用户以某个模型为参照点,想找到在特定维度上更优的替代方案(如更便宜、更快、更强)。
意图分析中的 modelPreference.targets 是参照模型。
## Rules
- Only recommend models from the candidate list
- reason must include comparative perspective: how this model is better/worse compared to others
- highlights should emphasize differentiating characteristics
- Output strict JSON
## 替代策略
- 推荐 #1如果参照模型在候选中先评估它是否满足用户需求给出其基本定位
- 推荐 #2~#3推荐替代方案reason 必须说明相比参照模型在用户关注维度上的 tradeoff
- 关注用户提到的替代维度(如"更便宜"→重点对比定价,"更强"→重点对比能力)
## Output Format
{"type":"single","recommendations":[{"model":"model ID","reason":"comparative analysis","highlights":["differentiators"]}]}`;
## 规则
- 只能推荐候选列表中的模型
- 参照模型必须包含在结果中(如果在候选列表中)
- 替代推荐的 reason 必须说明与参照模型的具体差异
- 避免推荐和参照模型同系列的其他版本(除非确实有显著差异)
- 输出严格 JSON不要输出其他内容
export const ALTERNATIVE_SYSTEM_PROMPT = `You are a model alternative advisor for Alibaba Cloud Model Studio. The user has a reference model and wants to find alternatives.
## 输出格式
{"type":"single","recommendations":[{"model":"模型ID","reason":"替代分析理由","highlights":["差异化亮点"]}]}`;
CRITICAL: You MUST respond entirely in English. Do not use any Chinese characters anywhere in your response. Every field — reason, highlights — must be written in English.
## Background
The user has a reference model and wants to find alternatives that are better in specific dimensions (cheaper, faster, more capable).
The intent's modelPreference.targets is the reference model.
## Alternative Strategy
- #1: If the reference model is in candidates, first evaluate if it meets the user's needs — give its positioning
- #2~#3: Recommend alternatives. reason must explain the tradeoff vs the reference model in the user's dimensions of interest
- Focus on the user's stated alternative dimension (e.g. "cheaper" → focus on pricing comparison, "better" → focus on capability comparison)
## Rules
- Only recommend models from the candidate list
- The reference model must be included in results if it's in the candidate list
- Alternative recommendations must explain concrete differences from the reference model
- Avoid recommending other versions from the same family unless there's a significant difference
- Output strict JSON
## Output Format
{"type":"single","recommendations":[{"model":"model ID","reason":"alternative analysis","highlights":["differentiators"]}]}`;
+16 -16
View File
@@ -76,20 +76,20 @@ async function embedBatch(config: Config, texts: string[]): Promise<number[][]>
}
const CAPABILITY_LABELS: Record<string, string> = {
TG: "文本生成",
Reasoning: "推理",
VU: "视觉理解",
IG: "图像生成",
VG: "视频生成",
TTS: "语音合成",
ASR: "语音识别",
TG: "Text Generation",
Reasoning: "Reasoning",
VU: "Vision Understanding",
IG: "Image Generation",
VG: "Video Generation",
TTS: "Text-to-Speech",
ASR: "Speech-to-Text",
};
const MODALITY_LABELS: Record<string, string> = {
Text: "文本",
Image: "图片/图像",
Video: "视频",
Audio: "音频/语音",
Text: "Text",
Image: "Image",
Video: "Video",
Audio: "Audio",
};
interface GroupData {
@@ -135,12 +135,12 @@ function buildModelText(model: ModelProfile, descriptions: Map<string, string>):
model.name,
model.model,
description,
caps ? `能力: ${caps}` : "",
inputMods ? `输入: ${inputMods}` : "",
outputMods ? `输出: ${outputMods}` : "",
model.features?.length ? `特性: ${model.features.join(", ")}` : "",
caps ? `Capabilities: ${caps}` : "",
inputMods ? `Input: ${inputMods}` : "",
outputMods ? `Output: ${outputMods}` : "",
model.features?.length ? `Features: ${model.features.join(", ")}` : "",
model.familyName || "",
model.category ? `定位: ${model.category}` : "",
model.category ? `Category: ${model.category}` : "",
].filter(Boolean);
return parts.join(" | ");
+35 -34
View File
@@ -46,26 +46,27 @@ function buildCandidatesContext(candidates: ScoredCandidate[]): string {
.map(({ model: profile }) => {
const parts = [
`ID: ${profile.model}`,
`名称: ${profile.name}`,
`描述: ${profile.shortDescription || profile.description}`,
`能力: ${profile.capabilities.join(", ")}`,
`特性: ${profile.features.join(", ")}`,
`Name: ${profile.name}`,
`Description: ${profile.shortDescription || profile.description}`,
`Capabilities: ${profile.capabilities.join(", ")}`,
`Features: ${profile.features.join(", ")}`,
];
if (profile.contextWindow) parts.push(`上下文窗口: ${profile.contextWindow}`);
if (profile.maxOutputTokens) parts.push(`最大输出: ${profile.maxOutputTokens}`);
if (profile.category) parts.push(`类别: ${profile.category}`);
if (profile.contextWindow) parts.push(`Context Window: ${profile.contextWindow}`);
if (profile.maxOutputTokens) parts.push(`Max Output: ${profile.maxOutputTokens}`);
if (profile.category) parts.push(`Category: ${profile.category}`);
const modality = profile.inferenceMetadata;
if (modality?.request_modality?.length)
parts.push(`输入模态: ${modality.request_modality.join(", ")}`);
parts.push(`Input Modality: ${modality.request_modality.join(", ")}`);
if (modality?.response_modality?.length)
parts.push(`输出模态: ${modality.response_modality.join(", ")}`);
parts.push(`Output Modality: ${modality.response_modality.join(", ")}`);
const prices = formatPrices(profile);
if (prices) parts.push(`定价: ${prices}`);
if (prices) parts.push(`Pricing: ${prices}`);
const qpm = formatQpm(profile);
if (qpm) parts.push(`QPM: ${qpm}`);
if (profile.versionTag) parts.push(`版本: ${profile.versionTag}`);
if (profile.openSource !== undefined) parts.push(`开源: ${profile.openSource ? "是" : "否"}`);
if (profile.family) parts.push(`家族: ${profile.family}`);
if (profile.versionTag) parts.push(`Version: ${profile.versionTag}`);
if (profile.openSource !== undefined)
parts.push(`Open Source: ${profile.openSource ? "Yes" : "No"}`);
if (profile.family) parts.push(`Family: ${profile.family}`);
return parts.join(" | ");
})
.join("\n");
@@ -86,29 +87,29 @@ function buildIntentContext(intent: IntentProfile): string {
modelPreference,
} = intent;
const parts: string[] = [];
if (taskSummary) parts.push(`场景理解: ${taskSummary}`);
if (scenarioHints.length) parts.push(`场景特征: ${scenarioHints.join(", ")}`);
if (inputModality.length) parts.push(`输入模态: ${inputModality.join(", ")}`);
if (outputModality.length) parts.push(`输出模态: ${outputModality.join(", ")}`);
if (requiredCapabilities.length) parts.push(`所需能力: ${requiredCapabilities.join(", ")}`);
if (requiredFeatures.length) parts.push(`所需特性: ${requiredFeatures.join(", ")}`);
parts.push(`预算倾向: ${budget}`);
parts.push(`质量偏好: ${qualityPreference}`);
if (contextNeed !== ContextNeeds.Standard) parts.push(`上下文需求: ${contextNeed}`);
if (taskSummary) parts.push(`Task: ${taskSummary}`);
if (scenarioHints.length) parts.push(`Scenario: ${scenarioHints.join(", ")}`);
if (inputModality.length) parts.push(`Input: ${inputModality.join(", ")}`);
if (outputModality.length) parts.push(`Output: ${outputModality.join(", ")}`);
if (requiredCapabilities.length) parts.push(`Capabilities: ${requiredCapabilities.join(", ")}`);
if (requiredFeatures.length) parts.push(`Features: ${requiredFeatures.join(", ")}`);
parts.push(`Budget: ${budget}`);
parts.push(`Quality: ${qualityPreference}`);
if (contextNeed !== ContextNeeds.Standard) parts.push(`Context: ${contextNeed}`);
if (modelPreference && modelPreference.mode !== "unconstrained") {
parts.push(`模型偏好: ${modelPreference.mode}`);
parts.push(`Mode: ${modelPreference.mode}`);
if (modelPreference.targets?.length)
parts.push(`目标模型: ${modelPreference.targets.join(", ")}`);
parts.push(`Targets: ${modelPreference.targets.join(", ")}`);
if (modelPreference.excludes?.length)
parts.push(`排除模型: ${modelPreference.excludes.join(", ")}`);
parts.push(`Excludes: ${modelPreference.excludes.join(", ")}`);
}
if (segments?.length) {
parts.push(`拆解步骤:`);
parts.push(`Pipeline Steps:`);
for (const seg of segments) {
const inMod = seg.inputModality.join(",") || "";
const outMod = seg.outputModality.join(",") || "";
const caps = seg.requiredCapabilities.join(",") || "";
parts.push(` - ${seg.step} (输入: ${inMod}输出: ${outMod}, 能力: ${caps})`);
const inMod = seg.inputModality.join(",") || "none";
const outMod = seg.outputModality.join(",") || "none";
const caps = seg.requiredCapabilities.join(",") || "none";
parts.push(` - ${seg.step} (Input: ${inMod}Output: ${outMod}, Capabilities: ${caps})`);
}
}
return parts.join("\n");
@@ -175,7 +176,7 @@ function validatePipelineCompatibility(
const compatible = accepts.some((mod) => prevOutputs.has(mod));
if (!compatible && accepts.length > 0) {
warnings.push(
`${rec.name} 的输入模态 [${accepts.join(", ")}] 可能不兼容上一步的输出模态 [${[...prevOutputs].join(", ")}]`,
`${rec.name}'s input modalities [${accepts.join(", ")}] may not be compatible with the previous step's output modalities [${[...prevOutputs].join(", ")}]`,
);
}
}
@@ -204,7 +205,7 @@ export async function rankModels(
systemPrompt = ALTERNATIVE_SYSTEM_PROMPT;
} else if (preferenceMode === "scoped") {
const scopeNote = intent.modelPreference?.targets?.length
? `\n\n## 范围限定\n用户明确要求在以下范围内推荐${intent.modelPreference.targets.join("")}。请优先从匹配该范围的模型中选择。`
? `\n\n## Scope Restriction\nThe user explicitly requested recommendations from: ${intent.modelPreference.targets.join(", ")}. Prioritize models within this scope.`
: "";
systemPrompt =
(intent.complexity === Complexities.Pipeline
@@ -219,8 +220,8 @@ export async function rankModels(
const userMessage =
intent.complexity === Complexities.Pipeline
? `意图分析结果:\n${intentContext}\n\n候选模型列表:\n${candidatesContext}\n\n用户原始需求:${userInput}\n\n请为流水线各步骤各推荐最多 ${top} 个模型。`
: `意图分析结果:\n${intentContext}\n\n候选模型列表:\n${candidatesContext}\n\n用户原始需求:${userInput}\n\n请推荐最多 ${top} 个模型。`;
? `Intent Analysis:\n${intentContext}\n\nCandidate Models:\n${candidatesContext}\n\nUser Request: ${userInput}\n\nRecommend up to ${top} models for each pipeline step. Respond in English only.`
: `Intent Analysis:\n${intentContext}\n\nCandidate Models:\n${candidatesContext}\n\nUser Request: ${userInput}\n\nRecommend up to ${top} models. Respond in English only.`;
const body: Record<string, unknown> = {
model: useThinkingModel ? RANKING_MODEL : RANKING_MODEL_FAST,
+6
View File
@@ -73,6 +73,12 @@ export function userProfileEndpoint(baseUrl: string, schemaId: string): string {
return `${baseUrl}/api/v2/apps/memory/profile_schemas/${encodeURIComponent(schemaId)}/profiles`;
}
// ---- Knowledge Base Retrieve (DashScope) ----
export function knowledgeRetrieveEndpoint(baseUrl: string): string {
return `${baseUrl}/api/v1/indices/rag/index/retrieve`;
}
// ---- MCP Services (Streamable HTTP) ----
export function mcpWebSearchEndpoint(baseUrl: string): string {
+1
View File
@@ -5,6 +5,7 @@ export {
chatEndpoint,
imageEndpoint,
imageSyncEndpoint,
knowledgeRetrieveEndpoint,
memoryAddEndpoint,
memoryListEndpoint,
memoryNodeEndpoint,
+6 -17
View File
@@ -1,5 +1,5 @@
import { readFileSync, writeFileSync, renameSync, existsSync } from "fs";
import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from "./schema.ts";
import { parseConfigFile, REGIONS, type Config, type ConfigFile } from "./schema.ts";
import { ensureConfigDir, getConfigPath } from "./paths.ts";
import { detectOutputFormat, type OutputFormat } from "../output/formatter.ts";
import { BailianError } from "../errors/base.ts";
@@ -36,16 +36,7 @@ export function loadConfig(flags: GlobalFlags): Config {
const accessTokenEnv = process.env.DASHSCOPE_ACCESS_TOKEN?.trim() || undefined;
const fileAccessToken = file.access_token?.trim() || undefined;
const explicitRegion = (flags.region as string) || process.env.DASHSCOPE_REGION || undefined;
const cachedRegion = file.region;
const region = (explicitRegion || cachedRegion || "cn") as Region;
const baseUrl =
flags.baseUrl ||
process.env.DASHSCOPE_BASE_URL ||
file.base_url ||
REGIONS[region] ||
REGIONS.cn;
const baseUrl = flags.baseUrl || file.base_url || process.env.DASHSCOPE_BASE_URL || REGIONS.cn;
const output: OutputFormat = detectOutputFormat(
flags.output || process.env.DASHSCOPE_OUTPUT || file.output,
@@ -68,9 +59,7 @@ export function loadConfig(flags: GlobalFlags): Config {
accessTokenEnv,
fileAccessToken,
fileApiKey,
fileRegion: file.region,
configPath: getConfigPath(),
region,
baseUrl,
output,
outputDir: file.output_dir || undefined,
@@ -84,10 +73,10 @@ export function loadConfig(flags: GlobalFlags): Config {
accessKeySecret:
process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET || file.access_key_secret || undefined,
workspaceId: process.env.BAILIAN_WORKSPACE_ID || file.workspace_id || undefined,
consoleGatewayUrl:
process.env.BAILIAN_CONSOLE_GATEWAY_URL ||
file.console_gateway_url ||
"https://pre-bailian-cs.console.aliyun.com",
consoleSite: (flags.consoleSite as Config["consoleSite"]) || file.console_site || undefined,
consoleRegion: (flags.consoleRegion as string) || file.console_region || undefined,
consoleSwitchAgent:
(flags.consoleSwitchAgent as number) || file.console_switch_agent || undefined,
verbose: flags.verbose || process.env.DASHSCOPE_VERBOSE === "1",
quiet: flags.quiet || false,
noColor: flags.noColor || process.env.NO_COLOR !== undefined || !process.stdout.isTTY,
+15 -12
View File
@@ -18,7 +18,6 @@ export interface ConfigFile {
api_key?: string;
/** OAuth-style token from `bl auth login --console` callback; sent as `Authorization: Bearer …` */
access_token?: string;
region?: Region;
base_url?: string;
output?: "text" | "json";
output_dir?: string;
@@ -31,16 +30,18 @@ export interface ConfigFile {
access_key_id?: string;
access_key_secret?: string;
workspace_id?: string;
console_gateway_url?: string;
console_site?: "domestic" | "international";
console_region?: string;
console_switch_agent?: number;
telemetry?: boolean;
}
const VALID_REGIONS = new Set<string>(["cn", "us", "intl"]);
const VALID_OUTPUTS = new Set<string>(["text", "json"]);
const VALID_CONSOLE_SITES = new Set<string>(["domestic", "international"]);
/**
* A syntactically valid absolute http(s) URL. Used to validate `base_url` and
* `console_gateway_url` from the config file: the credential-bearing client
* A syntactically valid absolute http(s) URL. Used to validate `base_url`
* from the config file: the credential-bearing client
* sends the Bearer token to these origins, so a bare `startsWith("http")` check
* (which also accepts e.g. "httpfoo://…") is too loose.
*/
@@ -63,8 +64,6 @@ export function parseConfigFile(raw: unknown): ConfigFile {
out.access_token = obj.access_token;
else if (typeof obj.accessToken === "string" && obj.accessToken.length > 0)
out.access_token = obj.accessToken;
if (typeof obj.region === "string" && VALID_REGIONS.has(obj.region))
out.region = obj.region as Region;
if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url;
if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output))
out.output = obj.output as ConfigFile["output"];
@@ -87,8 +86,12 @@ export function parseConfigFile(raw: unknown): ConfigFile {
out.access_key_secret = obj.access_key_secret;
if (typeof obj.workspace_id === "string" && obj.workspace_id.length > 0)
out.workspace_id = obj.workspace_id;
if (typeof obj.console_gateway_url === "string" && isHttpUrl(obj.console_gateway_url))
out.console_gateway_url = obj.console_gateway_url;
if (typeof obj.console_site === "string" && VALID_CONSOLE_SITES.has(obj.console_site))
out.console_site = obj.console_site as ConfigFile["console_site"];
if (typeof obj.console_region === "string" && obj.console_region.length > 0)
out.console_region = obj.console_region;
if (typeof obj.console_switch_agent === "number" && obj.console_switch_agent > 0)
out.console_switch_agent = obj.console_switch_agent;
if (typeof obj.telemetry === "boolean") out.telemetry = obj.telemetry;
return out;
@@ -103,9 +106,7 @@ export interface Config {
/** `access_token` in config file (console login). */
fileAccessToken?: string;
fileApiKey?: string;
fileRegion?: Region;
configPath?: string;
region: Region;
baseUrl: string;
output: "text" | "json";
outputDir?: string;
@@ -118,7 +119,9 @@ export interface Config {
accessKeyId?: string;
accessKeySecret?: string;
workspaceId?: string;
consoleGatewayUrl: string;
consoleSite?: "domestic" | "international";
consoleRegion?: string;
consoleSwitchAgent?: number;
verbose: boolean;
quiet: boolean;
noColor: boolean;
+94 -10
View File
@@ -2,18 +2,70 @@ import type { Config } from "../config/schema.ts";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
const GATEWAY_ACTION = "BroadScopeAspnGateway";
const GATEWAY_PRODUCT = "sfm_bailian";
export type ConsoleSite = "domestic" | "international";
interface ConsoleGatewayInfo {
csGateway: string;
action: string;
}
const REGION_GATEWAYS: Record<string, Record<ConsoleSite, ConsoleGatewayInfo>> = {
"cn-beijing": {
domestic: { csGateway: "bailian-cs.console.aliyun.com", action: "BroadScopeAspnGateway" },
international: {
csGateway: "bailian-cs.console.alibabacloud.com",
action: "BroadScopeAspnGateway",
},
},
"ap-southeast-1": {
domestic: {
csGateway: "modelstudio-cs.console.aliyun.com",
action: "IntlBroadScopeAspnGateway",
},
international: {
csGateway: "bailian-singapore-cs.alibabacloud.com",
action: "IntlBroadScopeAspnGateway",
},
},
};
function resolveGateway(region: string, site: ConsoleSite): ConsoleGatewayInfo {
return REGION_GATEWAYS[region]?.[site] ?? REGION_GATEWAYS["cn-beijing"]![site];
}
/** Resolved console gateway settings (same defaults as {@link callConsoleGateway}). */
export function effectiveConsoleGatewayConfig(config: Config): {
consoleRegion: string;
consoleSite: ConsoleSite;
consoleSwitchAgent?: number;
} {
const consoleRegion = config.consoleRegion ?? "cn-beijing";
const consoleSite = config.consoleSite ?? "domestic";
const consoleSwitchAgent = config.consoleSwitchAgent;
return consoleSwitchAgent != null
? { consoleRegion, consoleSite, consoleSwitchAgent }
: { consoleRegion, consoleSite };
}
export interface ConsoleGatewayRequest {
/** Console API name, e.g. zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota */
api: string;
data: Record<string, unknown>;
/** Console region (default: cn-beijing), distinct from DashScope `config.region`. */
/** Console region (e.g. cn-beijing, ap-southeast-1). Falls back to config.consoleRegion, then "cn-beijing". */
region?: string;
/** Console site. Falls back to config.consoleSite, then "domestic". */
site?: ConsoleSite;
/** Switch-agent UID for delegated access. Falls back to config.consoleSwitchAgent. */
switchAgent?: number;
}
function buildGatewayParams(api: string, data: Record<string, unknown>): string {
function buildGatewayParams(
api: string,
data: Record<string, unknown>,
switchAgent?: number,
): string {
return JSON.stringify({
Api: api,
V: "1.0",
@@ -24,6 +76,7 @@ function buildGatewayParams(api: string, data: Record<string, unknown>): string
console: "ONE_CONSOLE",
productCode: "p_efm",
consoleSite: "BAILIAN_ALIYUN",
...(switchAgent != null ? { switchAgent } : {}),
...(typeof data.cornerstoneParam === "object" && data.cornerstoneParam !== null
? (data.cornerstoneParam as Record<string, unknown>)
: {}),
@@ -37,17 +90,28 @@ function buildGatewayParams(api: string, data: Record<string, unknown>): string
* `token` is the console `access_token` (from `bl auth login --console`); when
* omitted the request is sent without an Authorization header, which works for
* public console APIs that don't require a login session.
*
* Gateway URL and action are resolved from `region + site` via {@link REGION_GATEWAYS}.
* Each parameter falls back to the corresponding config value, then to a hardcoded default.
*/
export async function callConsoleGateway(
config: Config,
token: string | undefined,
{ api, data, region = "cn-beijing" }: ConsoleGatewayRequest,
{ api, data }: ConsoleGatewayRequest,
): Promise<unknown> {
const params = buildGatewayParams(api, data);
const body = new URLSearchParams({ params, region });
const timeoutMs = config.timeout * 1000;
const {
consoleRegion: effectiveRegion,
consoleSite: effectiveSite,
consoleSwitchAgent: effectiveSwitchAgent,
} = effectiveConsoleGatewayConfig(config);
const gatewayBase = config.consoleGatewayUrl;
const resolved = resolveGateway(effectiveRegion, effectiveSite);
const gatewayBase = `https://${resolved.csGateway}`;
const action = resolved.action;
const params = buildGatewayParams(api, data, effectiveSwitchAgent);
const body = new URLSearchParams({ params, region: effectiveRegion });
const timeoutMs = config.timeout * 1000;
const headers: Record<string, string> = {
Accept: "*/*",
@@ -56,7 +120,7 @@ export async function callConsoleGateway(
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(
`${gatewayBase}/cli/api.json?action=${GATEWAY_ACTION}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`,
`${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`,
{
method: "POST",
headers,
@@ -74,5 +138,25 @@ export async function callConsoleGateway(
);
}
return res.json() as Promise<unknown>;
const json = (await res.json()) as Record<string, unknown>;
const innerData = json.data as Record<string, unknown> | undefined;
if (innerData?.success === false && innerData.errorCode) {
const errorCode = String(innerData.errorCode);
const notLogined = errorCode.includes("NotLogined");
const errorMsg = typeof innerData.errorMsg === "string" ? innerData.errorMsg : undefined;
throw new BailianError(
notLogined
? "Console session is not logged in or has expired."
: `Console gateway error: ${errorCode}`,
notLogined ? ExitCode.AUTH : ExitCode.GENERAL,
notLogined
? "Run `bl auth login --console` to sign in or refresh your console session."
: errorMsg && errorMsg !== errorCode
? errorMsg
: undefined,
);
}
return json;
}

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