mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60c49ec1ac | |||
| 07c71412cf | |||
| e2c4935e84 | |||
| 4f10b7f50c | |||
| fd36db5cab | |||
| 30a0bbbc87 | |||
| 9761932b4c | |||
| 9cbd4aab85 | |||
| 19c4f5f2ab | |||
| af524e5487 | |||
| 4e025dda8d | |||
| 1ffcbdd80c | |||
| 8aedeca4ac | |||
| f5a7dd494b | |||
| 07dafc0fd4 | |||
| 0cd0daa18d | |||
| 5e39d1abc3 | |||
| c3df659ef0 | |||
| 1d803bb4b9 | |||
| 847b291ccc | |||
| 0ceb15b0be | |||
| dc3c02f68c | |||
| f1eeeff682 | |||
| f184c60357 | |||
| 8906af8ad1 | |||
| 3b705f2b0d | |||
| 2260c51c7e | |||
| 8c893a59ef | |||
| 611ecc1e68 | |||
| 17c1f5f7b2 | |||
| 7593baf4f6 | |||
| edb34658a1 | |||
| cbeb2bf071 | |||
| 1fd08fe1c8 | |||
| 8cb24b719b | |||
| d4c0809951 | |||
| b01d35c246 | |||
| 67ff4d2811 | |||
| a830965feb | |||
| 14583cf6d1 | |||
| c32f03e8dc | |||
| e9feb380f5 | |||
| 62ad8768e3 | |||
| e8a40cdad0 | |||
| 9bcb66b1ff | |||
| 60cad1001c | |||
| a5d078b8a0 | |||
| 685c0176ff | |||
| d24b41d452 | |||
| d320d36ba7 | |||
| f816d5cb1f | |||
| 93cecc35de | |||
| a5d055f45a | |||
| 631a9c1818 | |||
| 682321247b | |||
| d82f334a99 | |||
| f7c18276bc | |||
| c54f6a64d7 | |||
| 73143dbae2 | |||
| cb25bc4149 | |||
| 35d681f0c7 | |||
| a16afb3f0b | |||
| 2ed513124e | |||
| 062bbd4052 | |||
| f847476016 | |||
| 83ea0dfd03 | |||
| 9a5797da1e | |||
| 8c398bae57 | |||
| 14fc293ef6 | |||
| 2bcbf56282 | |||
| ce64d628bb | |||
| 3ca8da8e75 | |||
| 6c4ac80882 | |||
| bd4644448a | |||
| a0ab35acf1 | |||
| 3f29f93ef5 | |||
| e5abd1b554 | |||
| 4749b493da | |||
| 7a65fb850c | |||
| f45b19c261 | |||
| 270412d146 | |||
| 36a60a2848 | |||
| 6c716e5120 | |||
| 2182a2239f | |||
| 51d9833a3d | |||
| e68abb6975 | |||
| 067e96689a | |||
| 173e5a7e45 | |||
| ef7aa493e0 | |||
| e67acc118f | |||
| a90a35ddef | |||
| d36bc5a82f | |||
| ada7ed32fb | |||
| 7c1be39067 | |||
| a96f3a2adf | |||
| b730a26336 | |||
| 489ba4f843 | |||
| c6afc21b11 | |||
| cc63e1ec3c | |||
| d5fb2bfaf8 | |||
| 8a0de83c24 | |||
| b36eaf34be | |||
| abe29d16b6 | |||
| 017ab86b33 | |||
| d20eea5c1c | |||
| b9e2d75ea0 | |||
| 2f22b333fa | |||
| 8023809666 | |||
| 240ce9ae3e | |||
| 386ff0fdc0 | |||
| a72f0508c3 | |||
| 65c6a358ef | |||
| 3734a6e8b9 | |||
| c070699fb2 | |||
| 8604567ce4 | |||
| 8b4dceafab | |||
| 00b1bfe7a7 | |||
| 94120d8a2b | |||
| fabf8e761d | |||
| da0ae26120 | |||
| ffc4aecce1 | |||
| c167bba32c | |||
| 1544af1f44 | |||
| c56c394527 | |||
| 418596b960 | |||
| dd56b04569 | |||
| db6ee7a5f0 | |||
| 6a0d39c726 | |||
| 822c4e6bfe | |||
| 5137257421 | |||
| f90ed8a0cc | |||
| f68717527a | |||
| 3395858c96 | |||
| a3c985c84e | |||
| c9f7e0b6b8 | |||
| b5abcaefd9 | |||
| d93b951d92 | |||
| d5407ae39b | |||
| 20704ff1c6 | |||
| 9742209c4c | |||
| 3689c2644f | |||
| efa624da2b | |||
| 04e7f30dc9 | |||
| bfb02927d1 | |||
| 6154004e00 | |||
| a02ab374d1 | |||
| fbf86887b0 | |||
| 6317da8454 | |||
| ab0cf8c78e | |||
| 00934973e9 | |||
| d75ddb407a | |||
| 252f85c717 | |||
| db5a96158d |
@@ -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.
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm run sync:skill-assets
|
||||
|
||||
- run: pnpm -r --filter "./packages/*" build
|
||||
|
||||
- run: pnpm run check
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# When CLI command definitions or the reference generator change, regenerate skill
|
||||
# reference markdown, push to modelstudioai/skills, and open a PR against main.
|
||||
#
|
||||
# Required repository secret (Settings → Secrets and variables → Actions):
|
||||
# SKILLS_SYNC_TOKEN — PAT with repo scope on modelstudioai/skills:
|
||||
# Contents: Read and write
|
||||
# Pull requests: Read and write
|
||||
# Prefer a bot / machine user PAT if your org restricts personal PATs.
|
||||
|
||||
name: Sync bailian-cli skill reference
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/cli/src/commands/**"
|
||||
- "tools/generate-reference.ts"
|
||||
- "packages/core/src/types/command.ts"
|
||||
paths-ignore:
|
||||
# Tests and fixtures under commands/ do not affect generated reference.
|
||||
- "packages/cli/src/commands/**/*.test.ts"
|
||||
- "packages/cli/src/commands/**/*.spec.ts"
|
||||
- "packages/cli/src/commands/**/__fixtures__/**"
|
||||
- "packages/cli/src/commands/**/__tests__/**"
|
||||
schedule:
|
||||
- cron: "0 19 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: sync-bailian-cli-skill-reference
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'modelstudioai/cli'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout cli
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build bailian-cli-core (required by generate-reference)
|
||||
run: pnpm --filter bailian-cli-core run build
|
||||
|
||||
- name: Generate reference markdown
|
||||
run: pnpm --filter bailian-cli run generate:reference
|
||||
|
||||
- name: Checkout skills repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: modelstudioai/skills
|
||||
path: skills-repo
|
||||
token: ${{ secrets.SKILLS_SYNC_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync reference into skills and open PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SKILLS_SYNC_TOKEN }}
|
||||
CLI_SHA: ${{ github.sha }}
|
||||
CLI_RUN: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "${GITHUB_WORKSPACE}/skills-repo"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
SHORT_SHA="${CLI_SHA:0:7}"
|
||||
BRANCH="sync/bailian-cli-reference-${SHORT_SHA}"
|
||||
REFERENCE_PATH="skills/bailian-cli/reference"
|
||||
|
||||
git checkout -B "$BRANCH"
|
||||
|
||||
SRC="${GITHUB_WORKSPACE}/tools/generated/reference"
|
||||
DEST="${GITHUB_WORKSPACE}/skills-repo/${REFERENCE_PATH}"
|
||||
mkdir -p "$DEST"
|
||||
rsync -a --delete "$SRC/" "$DEST/"
|
||||
|
||||
# Untracked new files are invisible to `git diff` until added.
|
||||
git add -A -- "$REFERENCE_PATH"
|
||||
|
||||
if git diff --cached --quiet origin/main -- "$REFERENCE_PATH"; then
|
||||
echo "No diff vs origin/main under ${REFERENCE_PATH}; exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${CLI_RUN}"
|
||||
git commit \
|
||||
-m "chore(bailian-cli): sync reference from cli" \
|
||||
-m "Synced from modelstudioai/cli@${CLI_SHA}" \
|
||||
-m "Workflow run: ${RUN_URL}"
|
||||
|
||||
# Re-fetch before push: main may have been updated with the same reference content.
|
||||
git fetch origin main
|
||||
if git diff --quiet origin/main HEAD -- "$REFERENCE_PATH"; then
|
||||
echo "Committed tree matches origin/main; skipping push and PR."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git push -u origin "$BRANCH" --force-with-lease
|
||||
|
||||
EXISTING=$(gh pr list --repo modelstudioai/skills --head "$BRANCH" --state open --json number --jq 'length')
|
||||
if [ "${EXISTING}" -eq 0 ]; then
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "## Summary"
|
||||
echo ""
|
||||
echo "- Regenerated \`skills/bailian-cli/reference/*.md\` from CLI command definitions at [\`modelstudioai/cli\`](https://github.com/modelstudioai/cli) commit \`${SHORT_SHA}\`."
|
||||
echo ""
|
||||
echo "## Test plan"
|
||||
echo ""
|
||||
echo "- [ ] Spot-check \`reference/index.md\` links and a sample group file under \`skills/bailian-cli/reference/\`."
|
||||
echo "- [ ] Merge if docs only."
|
||||
} >"$BODY_FILE"
|
||||
gh pr create \
|
||||
--repo modelstudioai/skills \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(bailian-cli): sync CLI command reference" \
|
||||
--body-file "$BODY_FILE"
|
||||
rm -f "$BODY_FILE"
|
||||
else
|
||||
echo "Open PR already exists for head ${BRANCH}."
|
||||
fi
|
||||
@@ -33,6 +33,7 @@ tools/generated
|
||||
.claude/worktrees/
|
||||
.claude/settings.json
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.cursor/
|
||||
.qwen/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.16.0
|
||||
@@ -1 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Regenerate skill reference + SKILL metadata (needs bailian-cli-core dist).
|
||||
pnpm run sync:skill-assets
|
||||
|
||||
# Stage generator output so it is included in this commit.
|
||||
git add skills/bailian-cli/reference skills/bailian-cli/SKILL.md
|
||||
|
||||
vp staged
|
||||
|
||||
@@ -25,13 +25,14 @@ packages/cli/
|
||||
└── tests/e2e/
|
||||
```
|
||||
|
||||
Skill / 命令手册不再随 npm 包发布,改由独立的 `npx add skills` 机制安装。`tools/generate-reference.ts` 仍然从 `catalog.ts` 生成命令手册到 `tools/generated/reference/`(gitignore,临时),等新机制接入后再迁走。
|
||||
Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/cli` 安装。`tools/generate-reference.ts` 从 `catalog.ts` 生成命令手册到 `skills/bailian-cli/reference/`(纳入 git);与 `tools/sync-skill-metadata.ts` 一起在 **pre-commit**(`.vite-hooks/pre-commit`)及根脚本 `pnpm run sync:skill-assets` 中执行。
|
||||
|
||||
非代码资产:
|
||||
|
||||
- `tools/release/` — 发版自动化(CI 驱动,见 `.github/workflows/publish.yml`)
|
||||
- `tools/generate-reference.ts` — 从 `catalog.ts` 生成命令手册(临时输出到 `tools/generated/reference/`)
|
||||
- `README.md` / `README_CN.md` — npm 和 GitHub 主页
|
||||
- `tools/generate-reference.ts` — 从 `catalog.ts` 生成命令手册到 `skills/bailian-cli/reference/`
|
||||
- `tools/sync-skill-metadata.ts` — 从 `packages/cli/package.json` 同步 `skills/bailian-cli/SKILL.md` 的 `metadata.version`(与 `generate:reference` 一并由根目录 `pnpm run sync:skill-assets` 及 pre-commit 执行)
|
||||
- `README.md` / `README.zh.md` — npm 和 GitHub 主页
|
||||
|
||||
约定:
|
||||
|
||||
@@ -92,6 +93,18 @@ CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的
|
||||
|
||||
不要扮演服务端错误的翻译官——我们没有最新的错误码体系认知,二次包装只会撒谎(详见 `docs/agents/error-hint-change.md` 中的反面 case)。
|
||||
|
||||
### 4. Console Gateway 命令必须声明 console 全局 flags
|
||||
|
||||
如果新命令使用了 `callConsoleGateway`,必须在 `options` 中添加以下三个全局 flag 的说明,以便 `--help` 中展示:
|
||||
|
||||
```ts
|
||||
{ flag: "--console-region <region>", description: "Console region" },
|
||||
{ flag: "--console-site <site>", description: "Console site: domestic, international" },
|
||||
{ flag: "--console-switch-agent <uid>", description: "Switch agent UID", type: "number" },
|
||||
```
|
||||
|
||||
这些 flag 已在 `GLOBAL_OPTIONS`(`packages/core/src/types/command.ts`)中注册,由 `loadConfig` 写入 `config.consoleRegion` / `config.consoleSite` / `config.consoleSwitchAgent`,`callConsoleGateway` 自动读取——命令无需手动提取或传递。
|
||||
|
||||
## 完成改动后的快速验证
|
||||
|
||||
```sh
|
||||
|
||||
+118
-1
@@ -4,7 +4,124 @@ All notable changes to `bailian-cli` and `bailian-cli-core` are documented here.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The two packages share a single version number — they are always released together.
|
||||
|
||||
[中文版](CHANGELOG_CN.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
[中文版](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
|
||||
|
||||
- Skill install command updated from `npx skills add modelstudioai/skills` to `npx skills add modelstudioai/cli --all -g` across all READMEs and docs.
|
||||
- `bl update` now automatically updates the `bailian-cli` agent skill after CLI upgrade.
|
||||
- Renamed `README_CN.md` to `README.zh.md` (ISO 639 convention) across the entire repo.
|
||||
|
||||
### Added
|
||||
|
||||
- Official skill (`skills/bailian-cli/`) now ships in this repository with pre-commit auto-generation of reference docs and SKILL.md version sync.
|
||||
- Bilingual READMEs (EN + CN) for the `bailian-cli` skill.
|
||||
|
||||
## [1.2.0] - 2026-06-05
|
||||
|
||||
|
||||
@@ -4,7 +4,123 @@
|
||||
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。两个包共享一个版本号,总是一起发布。
|
||||
|
||||
[English](CHANGELOG.md) · [README](README_CN.md) · [参与贡献](CONTRIBUTING_CN.md)
|
||||
[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
|
||||
|
||||
### 变更
|
||||
|
||||
- Skill 安装命令从 `npx skills add modelstudioai/skills` 更新为 `npx skills add modelstudioai/cli --all -g`,所有 README 和文档已同步。
|
||||
- `bl update` 现在会在 CLI 升级后自动更新 `bailian-cli` agent skill。
|
||||
- 全仓库 `README_CN.md` 统一重命名为 `README.zh.md`(ISO 639 命名规范)。
|
||||
|
||||
### 新增
|
||||
|
||||
- 官方 skill(`skills/bailian-cli/`)迁入本仓库,pre-commit 自动生成 reference 文档并同步 SKILL.md 版本号。
|
||||
- `bailian-cli` skill 新增中英文双语 README。
|
||||
|
||||
## [1.2.0] - 2026-06-05
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (DashScope). For end-user usage, see [README.md](README.md).
|
||||
|
||||
[中文版](CONTRIBUTING_CN.md) · [README](README.md) · [Changelog](CHANGELOG.md)
|
||||
[中文版](CONTRIBUTING.zh.md) · [README](README.md) · [Changelog](CHANGELOG.md)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 参与贡献 bailian-cli
|
||||
|
||||
`bailian-cli` 是阿里云百炼(DashScope)的官方 CLI。本文是面向**开发者**的指南;终端用户请看 [README_CN.md](README_CN.md)。
|
||||
`bailian-cli` 是阿里云百炼(DashScope)的官方 CLI。本文是面向**开发者**的指南;终端用户请看 [README.zh.md](README.zh.md)。
|
||||
|
||||
[English](CONTRIBUTING.md) · [README](README_CN.md) · [更新日志](CHANGELOG_CN.md)
|
||||
[English](CONTRIBUTING.md) · [README](README.zh.md) · [更新日志](CHANGELOG.zh.md)
|
||||
|
||||
## 环境要求
|
||||
|
||||
+3
-3
@@ -50,10 +50,10 @@ which bl # Windows 可用 where bl
|
||||
CLI 校验通过后,在本机终端执行:
|
||||
|
||||
```bash
|
||||
npx skills add modelstudioai/skills --all -g
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
完整说明见 [百炼Skills](https://github.com/modelstudioai/skills)。安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
|
||||
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +102,7 @@ bl auth status --output json
|
||||
bl text chat --message "ping" --non-interactive --output json
|
||||
```
|
||||
|
||||
若失败:根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、region 等)。全局 region:`--region cn|us|intl`,默认 `cn`。
|
||||
若失败:根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、`base_url` 等)。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README_CN.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
|
||||
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,15 +27,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 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
|
||||
> **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.
|
||||
@@ -73,7 +76,7 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/skills --all -g
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
@@ -108,9 +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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(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 分钟成片。
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
@@ -71,7 +74,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/skills --all -g
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
@@ -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,19 +182,19 @@ bl update
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
每个版本的变更详情记录在 [CHANGELOG_CN.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG_CN.md)。
|
||||
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING_CN.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING_CN.md)。
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
@@ -78,9 +78,7 @@ flag 优先 ─→ config 文件 ─→ env var
|
||||
|
||||
### D. main 启动逻辑
|
||||
|
||||
- [ ] `packages/cli/src/main.ts:NO_AUTH_SETUP` 列表:
|
||||
- 如果新增的命令"自己管鉴权或不需要鉴权",加进去绕开 ensureApiKey 拦截
|
||||
- 当前清单以 `main.ts:NO_AUTH_SETUP` 为准
|
||||
- [ ] 若新增命令**自行处理鉴权**或**不应在入口触发默认 API key 引导**,在对应 `defineCommand` 上设 `skipDefaultApiKeySetup: true`(见 `packages/core/src/types/command.ts`;`packages/cli/src/main.ts` 在 `registry.resolve` 后读取 `command.skipDefaultApiKeySetup`)
|
||||
|
||||
### E. 错误文案
|
||||
|
||||
@@ -89,7 +87,7 @@ flag 优先 ─→ config 文件 ─→ env var
|
||||
|
||||
### F. 用户面文档
|
||||
|
||||
- [ ] `README.md` / `README_CN.md` "Authentication" 段落
|
||||
- [ ] `README.md` / `README.zh.md` "Authentication" 段落
|
||||
|
||||
### G. 测试
|
||||
|
||||
|
||||
@@ -50,14 +50,14 @@ git diff --name-only <base>...<head>
|
||||
- [ ] **`package.json` 没破坏发布元数据**:`bin` / `exports` / `files` / `inlinedDependencies` 字段任何删除或改名都要单独评估
|
||||
- [ ] **公共依赖没被悄悄升级**:catalog / 根 lockfile 改动要列出来
|
||||
- [ ] **`package.json` version 没倒退**:目标分支已经更高时(如 main 1.0.3 vs head 1.0.0-beta.1),手动对齐版本号,不要被 head 覆盖
|
||||
- [ ] **全局表没冲突**:`registry.ts`、`NO_AUTH_SETUP`(`packages/cli/src/main.ts`)、`ExitCode` 三个全局表新增项不和现有项冲突
|
||||
- [ ] **全局表没冲突**:`registry.ts`、`defineCommand` 的 `skipDefaultApiKeySetup`(见 `packages/core/src/types/command.ts`)、`ExitCode` 三处新增项不和现有项冲突
|
||||
|
||||
## 清单 B:用户透出(用户可见的新东西必看)
|
||||
|
||||
- [ ] **新命令 / 新 flag** 已同步到用户面文档:
|
||||
- [README.md](README.md) + [README_CN.md](README_CN.md)(中英文都要,常漏 `_CN`)
|
||||
- [README.md](README.md) + [README.zh.md](README.zh.md)(中英文都要,常漏 `_CN`)
|
||||
- (SKILL.md 已迁出本仓库,由 `npx add skills` 机制独立维护,不在本仓库 review 范围)
|
||||
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` / `apiDocs` 都填了
|
||||
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` 都填了
|
||||
- [ ] **demo / quickstart**:用户可调用的新命令至少有一个示例
|
||||
- [ ] **行为变化的老命令**:在 commit message / CHANGELOG 注明用户感知的差异
|
||||
- [ ] **错误信息 / 提示文案**:面向用户的字符串通顺、双语(项目主体是中文场景)
|
||||
@@ -67,7 +67,7 @@ git diff --name-only <base>...<head>
|
||||
- [ ] **改了文件但没补测试**:`git diff --stat <base>...<head> -- '*test*' '*spec*'` 与改动文件清单对照
|
||||
- [ ] **新功能埋点同步**:遥测事件名 + 参数 allowlist(参考 main 上的 `feat(telemetry): track console gateway api name in params allowlist` commit)
|
||||
- [ ] **环境变量**:新增 / 重命名的 env var 进 README,旧的有没有兼容
|
||||
- [ ] **i18n**:`README.md` 改了,`README_CN.md` 同步了吗
|
||||
- [ ] **i18n**:`README.md` 改了,`README.zh.md` 同步了吗
|
||||
|
||||
## 输出报告(照模板填)
|
||||
|
||||
@@ -80,7 +80,7 @@ git diff --name-only <base>...<head>
|
||||
解冲突要点(merge 时不要漏):
|
||||
- <冲突文件> + <字段/段落> + <怎么取舍>
|
||||
↑ 放"合并那一刻才会出现"的细节,例如 package.json 的 files/scripts/devDependencies 各取并集、
|
||||
NO_AUTH_SETUP 这种全局表两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
|
||||
`skipDefaultApiKeySetup` 这类命令元数据两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
|
||||
建议修(可后置):
|
||||
- ...
|
||||
仅信息(无需动作,告知即可):
|
||||
@@ -94,11 +94,11 @@ git diff --name-only <base>...<head>
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `pnpm-workspace.yaml` 把 `packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
|
||||
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
|
||||
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README_CN](README_CN.md) | 用户完全感知不到新功能 |
|
||||
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
|
||||
| `NO_AUTH_SETUP` 加了不该免登录的命令 | 安全风险,用户没登录也能调付费 API |
|
||||
| `NO_AUTH_SETUP` / `registry.ts` 这类全局表两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `pnpm-workspace.yaml` 把 `packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
|
||||
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
|
||||
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README.zh](README.zh.md) | 用户完全感知不到新功能 |
|
||||
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
|
||||
| 不该跳过默认 API key 引导的命令误设 `skipDefaultApiKeySetup: true` | 安全风险,用户没配置 key 也能调付费 API |
|
||||
| `catalog.ts` / `skipDefaultApiKeySetup` 这类元数据两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
更新仓库内的两份文件,英文优先,中文同步:
|
||||
|
||||
- [`CHANGELOG.md`](../../CHANGELOG.md)
|
||||
- [`CHANGELOG_CN.md`](../../CHANGELOG_CN.md)
|
||||
- [`CHANGELOG.zh.md`](../../CHANGELOG.zh.md)
|
||||
|
||||
新版本条目插在文件顶部"## [X.Y.Z] - YYYY-MM-DD"位置,旧版本依次向下保留。两份文件保持一一对应——任何条目只在一份里出现,另一份漏写,视为错误。
|
||||
|
||||
@@ -129,7 +129,7 @@ git show <commit> --stat
|
||||
|
||||
### 8. 写完后给用户过一遍再写入文件
|
||||
|
||||
**不要直接编辑 `CHANGELOG.md` / `CHANGELOG_CN.md`**。先把中英两份草稿都贴回对话里,让用户:
|
||||
**不要直接编辑 `CHANGELOG.md` / `CHANGELOG.zh.md`**。先把中英两份草稿都贴回对话里,让用户:
|
||||
|
||||
- 增删条目
|
||||
- 调整措辞(中英、术语)
|
||||
@@ -183,4 +183,4 @@ git show <commit> --stat
|
||||
| [publish.md](publish.md) | 发布流程:自检 / 构建 / npm publish(CI 驱动) |
|
||||
| 本文档 | 发版后写说明:面向用户的 release notes |
|
||||
|
||||
两者顺序:`publish.md` → npm publish → 本文档(更新 `CHANGELOG.md` + `CHANGELOG_CN.md`)→ 推到 GitHub。
|
||||
两者顺序:`publish.md` → npm publish → 本文档(更新 `CHANGELOG.md` + `CHANGELOG.zh.md`)→ 推到 GitHub。
|
||||
|
||||
@@ -60,7 +60,7 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
## 新增 command 检查清单
|
||||
|
||||
- [ ] `commands/catalog.ts` 登记 + `tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
|
||||
- [ ] 若改了 `usage` / `options` / `examples`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `tools/generated/reference/`(本仓库 gitignore)
|
||||
- [ ] 若改了 `usage` / `options` / `examples`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交
|
||||
- [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help)
|
||||
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
|
||||
- [ ] 至少一条真实集成(或说明为何仅 smoke);不破坏已有集成用例顺序
|
||||
|
||||
@@ -27,20 +27,20 @@
|
||||
命令元数据以 **`catalog.ts` 为单一登记处**;`registry.ts` 只负责解析与打印 help,不再内嵌命令表或手写 Resources 列表。
|
||||
|
||||
```
|
||||
commands/<...>.ts defineCommand({ name, description, usage, options, examples, apiDocs?, run })
|
||||
commands/<...>.ts defineCommand({ name, description, usage, options, examples, run })
|
||||
↓
|
||||
commands/catalog.ts export const commands: Record<string, Command>
|
||||
↓
|
||||
┌────┴────┬──────────────────────┬─────────────────────┐
|
||||
↓ ↓ ↓ ↓
|
||||
registry.ts main.ts tools/generate-reference.ts export-schema.ts
|
||||
(解析/help) (入口) → tools/generated/reference/index.md + <group>.md
|
||||
(解析/help) (入口) → skills/bailian-cli/reference/index.md + <group>.md
|
||||
```
|
||||
|
||||
- **`packages/cli/src/commands/catalog.ts`**: `import` 命令模块 + `"<path>": handler` 映射;**不** `import registry.ts`(避免构建时循环依赖)
|
||||
- **`packages/cli/src/commands/index.ts`**: `export { commands } from "./catalog.ts"`(给包内 re-export 用)
|
||||
- **`packages/cli/src/registry.ts`**: `import { commands } from "./commands/catalog.ts"`,建树、`resolve`、`printHelp`;Commands / Global Flags 从 `Command` 元数据与 `GLOBAL_OPTIONS` **动态生成**
|
||||
- **`tools/generate-reference.ts`**: build 前读 `catalog.ts`,写 `tools/generated/reference/index.md`(索引) + `tools/generated/reference/<一级命令>.md`(详情,勿手改)。该目录被 gitignore,产物供未来的 `npx add skills` 安装机制消费
|
||||
- **`tools/generate-reference.ts`**: pre-commit / `pnpm run sync:skill-assets` 时读 `catalog.ts`,写 `skills/bailian-cli/reference/index.md`(索引) + `skills/bailian-cli/reference/<一级命令>.md`(详情,勿手改)。该目录**纳入 git**,随 `npx skills add modelstudioai/cli` 分发
|
||||
|
||||
已删除、勿再引用:`commands/help.ts`、`registry.ts` 内联 `new CommandRegistry({...})`、`printRootHelp` 手写命令行。
|
||||
|
||||
@@ -53,15 +53,14 @@ registry.ts main.ts tools/generate-reference.ts export-schema.ts
|
||||
- 增删 `import xxx from "./.../xxx.ts"`
|
||||
- 在 `export const commands` 里增删 `"<group> <action>": xxx`(key 与 `defineCommand({ name })` 一致)
|
||||
- [ ] **不要**在 `registry.ts` 里重复登记命令(已从 catalog 读取)
|
||||
- [ ] 命令需在 `bl help` / `reference/` 展示 API 文档链接时,在 `defineCommand` 里设 `apiDocs`(相对路径);help 与 reference 均从此字段生成
|
||||
- [ ] 如果命令需要鉴权之外的特殊路径,看 `packages/cli/src/main.ts` 的 `NO_AUTH_SETUP`
|
||||
- [ ] 如果命令需要跳过入口的默认 DashScope API key 引导(`ensureApiKey`),在对应 `defineCommand` 上设 `skipDefaultApiKeySetup: true`(字段定义见 `packages/core/src/types/command.ts`;`main.ts` 根据已解析的 `command` 读取)
|
||||
- [ ] **`config/export-schema.ts`**: 若新命令不适合作为 agent tool,评估是否加入 `SKIP_PREFIXES`;该文件在 `run()` 内 `import("../catalog.ts")`,勿顶层 import catalog 以免循环依赖
|
||||
|
||||
### B. 文档层
|
||||
|
||||
- [ ] 运行 `pnpm --filter bailian-cli run generate:reference`(或 `build`),刷新 `tools/generated/reference/` 下生成文件(本仓库 gitignore,仅供本地校验和未来 skill 安装机制消费)
|
||||
- [ ] `README.md` / `README_CN.md`: Quick Start、命令一览(用户向,与 help 对齐即可)
|
||||
- [ ] SKILL.md 已搬出本仓库(由 `npx add skills` 机制分发),本仓库不再维护
|
||||
- [ ] 运行 `pnpm run sync:skill-assets`(或正常 `git commit` 走 pre-commit),刷新 `skills/bailian-cli/reference/` 与 `SKILL.md` 的 `metadata.version` 并提交
|
||||
- [ ] `README.md` / `README.zh.md`: Quick Start、命令一览(用户向,与 help 对齐即可)
|
||||
- [ ] `skills/bailian-cli/SKILL.md`: 若安装说明或能力边界有变,同步更新
|
||||
|
||||
### C. 测试层
|
||||
|
||||
@@ -73,14 +72,14 @@ registry.ts main.ts tools/generate-reference.ts export-schema.ts
|
||||
- [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新:
|
||||
- `catalog.ts` 的 key
|
||||
- error hints(cli 层)
|
||||
- `tools/generated/reference/`(重建后检查;本仓库 gitignore)
|
||||
- `skills/bailian-cli/reference/`(重建后检查并提交)
|
||||
- README 示例
|
||||
- 测试断言
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
pnpm --filter bailian-cli run generate:reference # reference/ 与 catalog 一致
|
||||
pnpm run sync:skill-assets # reference/ + SKILL metadata.version 与 catalog / package.json 一致
|
||||
node packages/cli/src/main.ts <new-command> --help
|
||||
node packages/cli/src/main.ts # 根 help 列表含新命令
|
||||
vp test packages/cli/tests/e2e/<topic>.e2e.test.ts # 相关 e2e
|
||||
@@ -89,6 +88,6 @@ vp test packages/cli/tests/e2e/<topic>.e2e.test.ts # 相关 e2e
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 只改了命令文件,忘了 **`catalog.ts`** → 命令不存在或 help 里没有
|
||||
- ✗ 手改 **`tools/generated/reference/*.md`** → 下次 build 被覆盖;应改 `defineCommand` 后重新 generate
|
||||
- ✗ 手改 **`skills/bailian-cli/reference/*.md`** → 下次 generate 被覆盖;应改 `defineCommand` 后重新 generate 并提交
|
||||
- ✗ 在 `export-schema.ts` 顶层 `import catalog` → 可能与 registry 循环依赖
|
||||
- ✗ 单 action 的子组是反模式,新增时优先拍平为两级
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
### C. 文档层
|
||||
|
||||
- [ ] `README.md` / `README_CN.md` 如果在示例里展示了相关命令,补充新 flag
|
||||
- [ ] 跑 `pnpm --filter bailian-cli run generate:reference`,让 `tools/generated/reference/` 与命令一致(本仓库 gitignore,勿手改;SKILL.md 已迁出本仓库)
|
||||
- [ ] `README.md` / `README.zh.md` 如果在示例里展示了相关命令,补充新 flag
|
||||
- [ ] 跑 `pnpm --filter bailian-cli run generate:reference`,让 `skills/bailian-cli/reference/` 与命令一致(勿手改;改完提交)
|
||||
|
||||
### D. 测试层
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ config 文件 ─┘
|
||||
|
||||
### E. 文档
|
||||
|
||||
- [ ] `README.md` / `README_CN.md` 的 env var 表格
|
||||
- [ ] `README.md` / `README.zh.md` 的 env var 表格
|
||||
|
||||
### F. 测试
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
- [ ] `.vite-hooks/pre-commit` 改动后,`pnpm install` 重新软链(走 `prepare: vp config`)
|
||||
- [ ] 增加 hook 时,确认在干净 clone 后能自动激活
|
||||
- [ ] pre-commit 会跑 `pnpm run sync:skill-assets`(先 build core,再 `generate:reference` + `sync:skill-version`)并 `git add` skill 资产,最后 `vp staged`
|
||||
|
||||
### F. CI / 发版工具
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
|
||||
### C. 命令手册
|
||||
|
||||
- [ ] 若 `--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新 `tools/generated/reference/<group>.md`(本仓库 gitignore;SKILL.md 由独立的 `npx add skills` 仓库维护,本仓库不再含)
|
||||
- [ ] 若 `--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/<group>.md` 并提交
|
||||
|
||||
### D. 用户面文档
|
||||
|
||||
- [ ] `README.md` / `README_CN.md`:
|
||||
- [ ] `README.md` / `README.zh.md`:
|
||||
- Quick Start 示例如使用了具体型号,确认仍可用
|
||||
- 顶部 introduction 段落如提到"Qwen-Omni"等品牌名,无需变(模型代号变化不算品牌变)
|
||||
|
||||
|
||||
+16
-9
@@ -67,9 +67,15 @@ node tools/release/publish-channel.mjs --channel test --dry-run
|
||||
- [ ] `packages/cli/package.json` 和 `packages/core/package.json` 已升到目标版本
|
||||
- [ ] pre-release 格式正确(`1.0.0-beta.0` / `1.0.0-rc.1`,**不要直接用 `1.0.0` 当 beta**)
|
||||
|
||||
### CHANGELOG(仅 stable)
|
||||
|
||||
- [ ] `CHANGELOG.md` 和 `CHANGELOG.zh.md` 都已新增目标版本条目,中英文一一对应
|
||||
- [ ] 分类标题用 Keep a Changelog 规范的 `Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`(中文版对应 `新增` / `变更` / `已弃用` / `已移除` / `修复` / `安全`),**不要自创 `Improved` / `优化` 等规范外分类**
|
||||
- [ ] 条目日期与发版日期一致
|
||||
|
||||
### 用户面文档
|
||||
|
||||
- [ ] `README.md` / `README_CN.md` 的 Quick Start 命令仍能跑通
|
||||
- [ ] `README.md` / `README.zh.md` 的 Quick Start 命令仍能跑通
|
||||
- [ ] README 的 Node.js 徽章版本与 `cli/package.json.engines.node` 一致
|
||||
- [ ] README 宣传的 bin 名称在 `cli/package.json.bin` 都真的注册
|
||||
- [ ] `LICENSE` 文件存在(根 + cli + core 各一份)
|
||||
@@ -81,11 +87,12 @@ node tools/release/publish-channel.mjs --channel test --dry-run
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| ----------------------------------------------------- | -------------------------------------------------- |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
|
||||
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
|
||||
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 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 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
|
||||
@@ -46,8 +46,8 @@ grep -rnE "https://dashscope[a-z-]*\.aliyuncs\.com" packages/ --include="*.ts" \
|
||||
|
||||
### B. 非 TS 文件(只能人工同步,无法 import)
|
||||
|
||||
- [ ] `tools/generated/reference/` 各 `<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对;本仓库 gitignore)
|
||||
- [ ] `README.md` / `README_CN.md` 中所有 URL
|
||||
- [ ] `skills/bailian-cli/reference/` 各 `<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对并提交)
|
||||
- [ ] `README.md` / `README.zh.md` 中所有 URL
|
||||
|
||||
### C. 渠道追踪参数
|
||||
|
||||
|
||||
@@ -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_id(bl 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>`
|
||||
|
||||
列出可选 checkpoint(step, 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 |
|
||||
@@ -16,6 +16,7 @@
|
||||
"ready": "vp check && vp run -r test && vp run -r build",
|
||||
"prepare": "vp config",
|
||||
"check": "vp check",
|
||||
"sync:skill-assets": "pnpm --filter bailian-cli-core run build && pnpm --filter bailian-cli run generate:reference && pnpm --filter bailian-cli run sync:skill-version",
|
||||
"dev": "pnpm -F bailian-cli-core dev",
|
||||
"bl": "pnpm -F bailian-cli dev",
|
||||
"test": "vp test",
|
||||
|
||||
+34
-18
@@ -9,7 +9,7 @@
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README_CN.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
|
||||
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,15 +27,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 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
|
||||
> **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.
|
||||
@@ -73,7 +76,7 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/skills --all -g
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
@@ -108,9 +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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(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 分钟成片。
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
@@ -71,7 +74,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/skills --all -g
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
@@ -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,19 +182,19 @@ bl update
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cli?source_channel=key_github& |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
每个版本的变更详情记录在 [CHANGELOG_CN.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG_CN.md)。
|
||||
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING_CN.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING_CN.md)。
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.2",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README_CN.md"
|
||||
"README.zh.md"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -36,8 +36,9 @@
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:reference": "node --experimental-strip-types ../../tools/generate-reference.ts",
|
||||
"build": "pnpm run generate:reference && vp pack",
|
||||
"generate:reference": "node --experimental-strip-types ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference'",
|
||||
"sync:skill-version": "node --experimental-strip-types ../../tools/sync-skill-metadata.ts",
|
||||
"build": "vp pack",
|
||||
"dev": "node src/main.ts",
|
||||
"test": "vp test",
|
||||
"check": "vp check"
|
||||
@@ -45,7 +46,8 @@
|
||||
"dependencies": {
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:"
|
||||
"chalk": "catalog:",
|
||||
"undici": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) ?? {};
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -5,14 +5,33 @@ import { defineCommand, getConfigDir } from "bailian-cli-core";
|
||||
import { CLI_VERSION } from "../version.ts";
|
||||
import { NPM_PACKAGE, fetchLatestVersion } from "../utils/update-checker.ts";
|
||||
|
||||
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 } {
|
||||
return { cmd: `npm install -g ${NPM_PACKAGE}@latest`, label: "npm" };
|
||||
}
|
||||
|
||||
function updateAgentSkill(colors: { green: string; yellow: string; reset: string }): void {
|
||||
const { green, yellow, reset } = colors;
|
||||
process.stderr.write("\nUpdating agent skill...\n");
|
||||
try {
|
||||
// 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: ${SKILL_INSTALL_CMD}${reset}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "update",
|
||||
description: "Update bl to the latest version",
|
||||
skipDefaultApiKeySetup: true,
|
||||
usage: "bl update",
|
||||
examples: ["bl update"],
|
||||
async run() {
|
||||
@@ -29,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;
|
||||
}
|
||||
|
||||
@@ -62,6 +82,7 @@ export default defineCommand({
|
||||
} catch {
|
||||
process.stderr.write(`\n${green}\u2713 Update complete.${reset}\n`);
|
||||
}
|
||||
updateAgentSkill({ green, yellow, reset });
|
||||
} catch {
|
||||
process.stderr.write("\nAutomatic update failed. Please run manually:\n");
|
||||
process.stderr.write(` ${cmd}\n\n`);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 内置 fetch(undici)默认不读取代理环境变量,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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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 recommend(DashScope)", () => {
|
||||
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 recommend(DashScope)",
|
||||
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 recommend(DashScope)",
|
||||
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 recommend(DashScope)",
|
||||
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 recommend(DashScope)",
|
||||
).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-max和deepseek-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 recommend(DashScope)",
|
||||
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 recommend(DashScope)",
|
||||
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",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -117,8 +117,17 @@ export function e2eLabelFromMetaUrl(metaUrl: string): string {
|
||||
return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, "");
|
||||
}
|
||||
|
||||
/** 知识库用例:须显式索引 ID + AK/SK(workspace 可读 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 &&
|
||||
|
||||
@@ -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("按相关性排序");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: omni(DashScope 媒体)",
|
||||
() => {
|
||||
test("omni --list-voices 输出音色列表并退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["omni", "--list-voices"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(/Omni output voices:/);
|
||||
expect(stdout).toMatch(/Tina/);
|
||||
expect(stdout).toMatch(/Dylan/);
|
||||
expect(stdout).toMatch(/Total: 13 voices/);
|
||||
});
|
||||
test("omni 缺少 --message 时打印子命令帮助并退出 (0)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"omni",
|
||||
"--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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 代理支持 E2E(issue #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_PROXY(undici ?? 取值回归)", 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/);
|
||||
});
|
||||
});
|
||||
@@ -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: quota(Console)", () => {
|
||||
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 free(Console)", () => {
|
||||
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 stats(Console)", () => {
|
||||
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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -180,7 +180,7 @@ export async function ensurePrerequisites(ctx) {
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"压测前置短视频:海浪与静态远景,无明显人物。",
|
||||
"--duration",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user