Compare commits

..

74 Commits

Author SHA1 Message Date
故璃 3909a17da1 Merge branch 'main' into feat/cli-skill-sync 2026-08-17 12:51:26 +08:00
gujieye 5ed15d3a16 Merge pull request #164 from modelstudioai/feat/version-1.15.1
chore(release): prepare 1.15.1
2026-08-17 12:04:38 +08:00
故璃 640dd02bc5 chore(release): prepare 1.15.1 2026-08-17 11:55:36 +08:00
gujieye 6eeb8fe0cb Merge pull request #163 from modelstudioai/feat/version-1.15.1
docs(changelog): document 1.15.1
2026-08-17 11:38:42 +08:00
故璃 d0610a61dc docs(changelog): document 1.15.1 2026-08-17 11:25:17 +08:00
gujieye 57c2d98308 Merge pull request #160 from modelstudioai/feat/skill-init-simplify
feat: update skill init output
2026-08-17 11:01:35 +08:00
gujieye 78e6993475 Merge pull request #162 from modelstudioai/feat/model-command-update
feat: update model quota limit & add model permission command
2026-08-17 00:06:30 +08:00
故璃 79a0d2db9a fix(permission): drop explicit undefined return and make revoke --all e2e credential-independent 2026-08-16 23:48:19 +08:00
故璃 8e6af6c669 feat: update model quota limit & add model permission command 2026-08-16 19:49:23 +08:00
Gong Shiqi f7d32504ab Merge pull request #161 from modelstudioai/agent/release-1.15.0
chore(release): prepare 1.15.0
2026-08-15 14:51:34 +08:00
若麒 cdf94a8c89 chore(release): prepare 1.15.0 2026-08-15 14:33:55 +08:00
故璃 7461189007 Merge branch 'main' into feat/cli-skill-sync 2026-08-15 10:22:09 +08:00
故璃 4ccda5f929 feat: update skill init output 2026-08-15 10:15:00 +08:00
Gong Shiqi ce4d66b736 Merge pull request #157 from modelstudioai/release/1.15.0
docs(changelog): document 1.14.2 through 1.15.0
2026-08-14 19:00:08 +08:00
若麒 196a0aa506 docs(changelog): document 1.14.2 through 1.15.0 2026-08-14 18:50:35 +08:00
gujieye a9b0a752a8 Merge pull request #155 from modelstudioai/feat/coding-plan-usage
feat: add coding plan usage
2026-08-14 17:36:47 +08:00
Gong Shiqi 2b7a0c742a Merge pull request #156 from modelstudioai/feat/text-chat-responses-api
feat(text): support Responses API
2026-08-14 17:21:40 +08:00
Gong Shiqi e5818e103c Merge pull request #145 from modelstudioai/feat/change-skills-install
Switch official skill install to bl skill init
2026-08-14 17:21:24 +08:00
若麒 7797940626 docs(skills): clarify update install channel 2026-08-14 17:14:23 +08:00
gujieye 5f1c97940d Merge branch 'main' into feat/coding-plan-usage 2026-08-14 17:05:53 +08:00
若麒 94ccab0898 feat(text): support Responses API 2026-08-14 17:02:29 +08:00
clh02467605 b895f88abb Merge remote-tracking branch 'origin/feat/change-skills-install' into feat/change-skills-install 2026-08-14 16:48:36 +08:00
clh02467605 7eedc05b99 docs: Modify the preferred installation method 2026-08-14 16:47:30 +08:00
若麒 f3c7b6fb10 docs(readme): add standalone installation options 2026-08-14 16:28:51 +08:00
若麒 eb196cb4a6 docs(readme): add standalone installation options 2026-08-14 16:26:47 +08:00
Gong Shiqi f1b6cacd7f Merge pull request #153 from modelstudioai/feat/mcp-support-sse
Add MCP classic SSE auto-fallback for Bailian and --url
2026-08-14 16:07:15 +08:00
故璃 9133b6bdd1 feat: add coding plan usage 2026-08-14 15:56:16 +08:00
clh02467605 98ba3279fa fix(runtime): expose errno in fetch-failed JSON cause.code 2026-08-14 15:27:26 +08:00
clh02467605 3b7c4cfabc Merge remote-tracking branch 'refs/remotes/origin/main' into feat/mcp-support-sse 2026-08-14 14:42:21 +08:00
clh02467605 d5d9fcb50f fix: fixed sse error 2026-08-14 14:41:37 +08:00
clh02467605 3ea2931152 fix(mcp): harden SSE parsing, abort, and fallback matching 2026-08-14 11:11:47 +08:00
gujieye b402f3eacd Merge pull request #141 from sonicg83/codex/usage-token-plan-reset-times
fix(usage): handle missing Token Plan quota fields
2026-08-14 11:10:56 +08:00
gujieye bedd59df27 Merge branch 'main' into codex/usage-token-plan-reset-times 2026-08-13 19:54:06 +08:00
故璃 39a488181e refactor(usage): align token-plan with --output convention and tolerant quota reading 2026-08-13 19:43:11 +08:00
若麒 4ec0f6828b fix(update): sync skills after binary upgrades 2026-08-13 19:14:49 +08:00
clh02467605 4dcec7d075 fix(mcp): fix SSE header timeout, 405 fallback matching, and parseSSE chunking 2026-08-13 18:32:54 +08:00
Gong Shiqi daefc094ec Merge pull request #149 from modelstudioai/fix/fixed_issue_146
fix: support sync-flash and qwen3-filetrans ASR models in speech recognize
2026-08-13 16:26:32 +08:00
clh02467605 ae0c2c1213 fix(speech): handle qwen3-filetrans singular result.transcription_url
Normalize async ASR transcription items so waiting mode downloads text and --out works without changing shared media task types.
2026-08-13 15:52:34 +08:00
clh02467605 01a62eb85b Merge remote-tracking branch 'refs/remotes/origin/main' into feat/mcp-support-sse 2026-08-13 15:40:04 +08:00
clh02467605 798ce596f6 fix(mcp): harden SSE fallback for Bailian and --url overrides 2026-08-13 15:37:32 +08:00
clh02467605 bd91e9d1c2 Merge remote-tracking branch 'refs/remotes/origin/main' into fix/fixed_issue_146
# Conflicts:
#	skills/bailian-gen/reference/index.md
#	skills/bailian-gen/reference/speech.md
2026-08-13 14:36:16 +08:00
clh02467605 e244771ee9 test(speech): harden flash ASR contract coverage and docs
Add SSE disable header, data-URI format inference, broader response text
parsing, HTTP contract e2e, pipeline routing tests, and ASR model selection
guidance in bailian-gen.
2026-08-13 14:25:47 +08:00
Gong Shiqi 94f9dbbe9e Merge pull request #151 from modelstudioai/feat/command-auth-help
feat(cli): show command authentication requirements in help
2026-08-13 13:38:28 +08:00
若麒 8a0dd70206 feat(cli): show command authentication requirements in help 2026-08-13 12:01:41 +08:00
clh02467605 9379da7a4c fix(speech): align flash vocabulary_id and qwen3-filetrans language params 2026-08-13 09:47:09 +08:00
clh02467605 ddcd564e61 test: dry-run realtime ASR usage-error e2e to skip auth in CI 2026-08-12 17:22:25 +08:00
gujieye 0e4dd4b824 Merge pull request #148 from modelstudioai/feat/usage_free_api
refactor(usage): consolidate shared poll logic; migrate freeTrial API…
2026-08-12 17:13:59 +08:00
clh02467605 241de61866 fix: support sync-flash and qwen3-filetrans ASR models in speech recognize
- Add asr-routes.ts with resolveAsrApi() to route models to the correct
  DashScope endpoint instead of always hitting asr/transcription
- Async filetrans: fun-asr / paraformer / *-filetrans → file_urls (plural)
- Async filetrans (qwen3): qwen3-asr-flash-filetrans* → file_url (singular)
- Sync flash (input-audio): fun-asr-flash* / qwen-audio-*-asr-flash → multimodal-generation
- Sync flash (qwen3): qwen3-asr-flash* → multimodal-generation + asr_options
- Realtime/streaming models now give a clear USAGE error instead of a
  confusing server-side "url error"
- Propagate same routing logic to pipeline speechRecognize step
- Add table-driven unit tests and dry-run e2e assertions
Fixes #146
2026-08-12 17:12:23 +08:00
故璃 61d9a74166 fix: 1.14.3 2026-08-12 17:05:18 +08:00
故璃 69eb759490 refactor(usage): consolidate shared poll logic; migrate freeTrial APIs to bailian-commerce
Dedup:
- shared.ts: extract generic pollConsoleUntilDone (request-builder callback
  absorbs each wrapper convention); pollTelemetryApi becomes a thin wrapper;
  add pollFreeTierBatch
- freetier.ts / stats.ts: drop inline duplicates of extractResponseData,
  polling, model-list paging, free-tier extractors and usage label maps;
  import from shared.ts (behaviour unchanged: freetier keeps its 20-poll
  budget, telemetry keeps 30)

Endpoint migration (broadscope-bailian.freeTrial -> bailian-commerce.freeTrial):
- queryFreeTierQuota, queryFreeTierOnlyStatus, batchActivateFreeTierOnly,
  batchDeactivateFreeTierOnly
- update the console call example and the gateway doc comment to match

Note: verified statically and via dry-run; live calls pending a fresh
console login (session expired).
2026-08-12 16:30:08 +08:00
clh02467605 313966d7a9 feat(mcp): add SSE support with fallback mechanism for MCP connections
- Add McpSseClient implementation for classic HTTP+SSE MCP protocol
- Implement connectBailianMcpWithFallback with Streamable HTTP to SSE fallback
- Add isStreamableHttpUnsupported helper to detect 405 streamableHttp errors
- Update activate-hint logic to handle WebSearch 405 streamableHttp cases
- Replace direct MCP client usage with connection manager in call/tools commands
- Add proper client cleanup with close() calls in finally blocks
- Export new MCP connection utilities and types from core client module
- Add comprehensive tests for SSE client and fallback behavior
2026-08-12 15:44:51 +08:00
故璃 d74d4efcd0 fix(dataset): align validation with the platform data-format rules doc
Reviewed against the official text-tuning data rules; fixes two confirmed
mismatches and fills enforcement gaps:

- thinking: exempt assistant messages carrying tool_calls from the
  THINK_TAG_NOT_LAST check — the spec's tool+thinking combo example puts
  <think> on a non-last assistant and was previously false-flagged
- DPO support matrix: reject image/video content items, tools, tool_calls
  and role:tool (DPO_UNSUPPORTED_ELEMENT); also scan chosen/rejected
- DPO: messages not ending with user upgraded warning -> error
- OpenAI migration: name/weight upgraded warning -> error (spec: must not
  carry); drop dead record-level name branch
- tool_call_id: unmatched tool response upgraded warning -> error
  (one-to-one per spec); new TOOL_CALL_NO_RESPONSE warning for orphan calls
- loss_weight: validate range at message level too; warn when placed on
  anything but the last assistant message (LOSS_WEIGHT_PLACEMENT)
- video params: fps/sample_fps must be within [0.1, 10]
  (INVALID_VIDEO_FPS); mode-mismatched params warned
  (VIDEO_PARAM_MODE_MISMATCH); video_start/video_end type-checked
- zip: skip macOS packaging metadata (__MACOSX/, .DS_Store, ._*) in
  filename constraints and image counting to stop false failures on
  Finder-created archives

Tests 45 -> 59 covering every new/changed rule, including a replica of the
spec's official tool+thinking example.
2026-08-12 10:09:10 +08:00
故璃 e7422bd2e5 fix(dataset): align validation rules with platform data format spec
- Support content array format [{text/image/video}] alongside legacy string
- Add tool role support with tool_calls structure and tool_call_id validation
- Add thinking tag placement check (only in last assistant message)
- Add OpenAI migration guards: warn on unsupported name/weight fields
- Add loss_weight range validation (0.0–1.0)
- Fix size limits: SFT/DPO 200MB, CPT 300MB, media ZIP 2GB
- Enforce data.jsonl at ZIP root (reject nested wrapping folders)
- Add ZIP filename constraints: charset [a-zA-Z0-9_-], length ≤120, uniqueness
- Add .tif to accepted image extensions
- Add DPO_LAST_MSG_NOT_USER warning when messages don't end with user role
- CPT profile now uses dedicated 300MB cap instead of shared default
- Expand unit tests from 19 to 45 covering all new validation paths
2026-08-12 07:48:11 +08:00
故璃 9749a11d76 fix(finetune): detect video-kf2v sub-variant from local dataset
finetune video create passed a fixed modality "video" to the profile
validator without probing the data for last_frame_path. This caused a
false KF2V_DATA_MISMATCH error when training kf2v models (wan2.2-kf2v-*)
with datasets that correctly contain last_frame_path.

Add sub-variant detection symmetric to the existing image-i2i upgrade:
when a local file is provided, detectModality() inspects the first record
and upgrades "video" → "video-kf2v" if last_frame_path is present.
2026-08-11 14:50:58 +08:00
故璃 2965080cb7 feat(skills): replace foreign skill dirs containing SKILL.md during fan-out
Previously, fan-out skipped any existing real directory not recorded in
the lock file, treating it as user content. This left stale skill copies
installed by other tools (e.g. npx skills add) permanently out of date.

Now: if the directory contains a SKILL.md, it is recognized as a skill
artifact and replaced with a symlink to the canonical dir. Directories
without SKILL.md are still preserved (user content safety boundary).
2026-08-11 14:29:10 +08:00
故璃 1c76749ee5 feat: update datalist validate 2026-08-11 13:33:31 +08:00
故璃 5d1b7aac3a Merge branch 'main' into feat/cli-skill-sync 2026-08-11 10:47:45 +08:00
sonicg83 4d84af614b Merge branch 'modelstudioai:main' into codex/usage-token-plan-reset-times 2026-08-07 23:29:01 +08:00
clh02467605 9ae5dc924d docs(cli): update skill installation command from add --name all to init
- Replace `bl skill add --name all` with `bl skill init` across documentation
- Update installation instructions in README, INSTALL, and agent skill guides
- Modify code references in update checker and UI components
- Adjust documentation links and cross-references accordingly
- Revise command examples in protocol and asset files
- Update versioning and setup instructions to reflect new command
- Modify HTML UI rendering for skill installation guidance
- Change internal command constants and execution calls
2026-08-07 17:56:30 +08:00
clh02467605 d6cb075629 Merge remote-tracking branch 'refs/remotes/origin/main' into feat/change-skills-install
# Conflicts:
#	README.md
#	README.zh.md
#	packages/cli/README.md
#	packages/cli/README.zh.md
2026-08-07 17:37:43 +08:00
故璃 5007b9b574 feat: change output to json 2026-08-07 16:35:47 +08:00
故璃 8286a74fb6 test: remove sync pipeline verification marker 2026-08-07 14:40:33 +08:00
故璃 9eb2acbb65 test: trigger skills sync pipeline 2026-08-07 14:38:40 +08:00
故璃 1d35326c86 ci: read FC trigger url from variables 2026-08-07 14:36:42 +08:00
故璃 eb6c2b8e2a Merge branch 'feat/model-finetune-opt' into feat/cli-skill-sync 2026-08-07 14:26:38 +08:00
故璃 ebd6226a9f ci: rename trigger secret to FC_TRIGGER_URL 2026-08-07 14:25:49 +08:00
故璃 e25d3b0b8e ci: add workflow to publish skills to OSS 2026-08-07 14:13:47 +08:00
clh02467605 0e33c70e65 docs: update skill installation instructions to use bl skill add
- Replace all instances of `npx skills add modelstudioai/cli --all -g` with `bl skill add --name all`
- Update installation documentation in INSTALL.md, README.md, and related files
- Modify code references in config/inventory.ts, generate-reference.ts, and other files
- Update HTML UI messages to reflect new installation command
- Correct setup.md to include binary installation option and update subset install instructions
- Adjust versioning documentation to use new skill installation command
- Update all SKILL.md files with consistent installation instructions
2026-08-07 14:09:23 +08:00
故璃 3c64461cca feat: video finetune/deploy/invoke full pipeline + training cost calculation
- Add finetune video create subcommand (wan2.7/2.5/2.2 i2v + kf2v)
- Align video hyperparams with official docs (n_epochs=50, per-model batch_size/max_pixels)
- Add --last-frame flag to video generate for kf2v (image2video endpoint)
- Fix wan2.1-2.6 i2v input format (img_url instead of media[])
- Add training_cost field to finetune get/watch (catalog ft price, API-key domain only)
- Add --aigc-* flags to deploy create (optional, for video LoRA prompt config)
2026-08-07 07:03:01 +08:00
sonicg 24092b423c fix(usage): handle unavailable token plan quotas 2026-08-06 22:47:26 +08:00
故璃 f30fff9065 feat(finetune): clarify model flags; add price estimate & actual cost
- Rename for clarity: finetune --model → --base-model (create/price/
  capability/list); deploy create --model → --model-name, --name →
  --display-name
- Add `finetune price` (console domain) for pre-training cost estimate
  (sft/dpo/cpt)
- Add actual training cost (fee.ts) enriched into finetune get/watch
  from catalog price × reported usage
2026-08-06 15:08:02 +08:00
故璃 a7245c0f62 feat(deploy): add pause/resume commands; JSON-only output for dataset/finetune/deploy
- Add `bl deploy pause` and `bl deploy resume` (console domain, first
  console-auth commands in deploy group) via modelInstance start/stop APIs
- Add core deploy/lifecycle.ts with input-wrapped console gateway calls
- Switch all dataset/finetune/deploy commands to JSON-only output, removing
  text formatting logic
- Expose usage/charge_type in finetune get, model_name/expire_time in
  finetune checkpoints with near-expiry warning
- Update deploy delete hint to suggest `bl deploy pause`
2026-08-06 11:34:48 +08:00
sonicg 752a79e442 fix(usage): handle missing token plan reset times 2026-08-06 09:12:41 +08:00
sonicg 80bdcb83f6 feat(usage): add token plan usage view 2026-08-05 23:28:39 +08:00
182 changed files with 10546 additions and 3064 deletions
+27
View File
@@ -0,0 +1,27 @@
# Poke the FC publish-skills flow after skills/ changes land.
# The FC side reconciles this repo's skills/ directory against OSS
# (bailian-wiki/skills/) using the repo HEAD snapshot as the only
# source of truth — the request itself carries no content. Both the
# repo and branch params are validated against FC-side whitelists
# (PUBLISH_REPOS / PUBLISH_BRANCHES).
#
# feat/cli-skill-sync is temporary for end-to-end testing; remove it
# (here and from the FC PUBLISH_BRANCHES whitelist) once the sync
# link is verified on main.
name: Publish skills to OSS
on:
push:
branches:
- main
- feat/cli-skill-sync
paths:
- "skills/**"
jobs:
poke:
runs-on: ubuntu-latest
steps:
- name: Trigger FC publish-skills
run: |
curl -sf -X POST "${{ vars.FC_TRIGGER_URL }}/publish-skills?repo=modelstudioai/cli&branch=${{ github.ref_name }}"
+1 -1
View File
@@ -35,7 +35,7 @@ packages/core/src/auth/ # apiKey / console credential 解析与落盘
packages/core/src/client/ # HTTP client / endpoints / console gateway
```
Skill / 命令手册随 `skills/bailian-*/``npx skills add modelstudioai/cli --all -g` 安装(整包装齐,含共享协议 `bailian-protocol`)。业务 skill`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts`**`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills/<skill>/reference/`(纳入 git);`tools/sync-skill-metadata.ts``packages/cli/package.json` 同步各 `skills/*/SKILL.md``metadata.version`。两者由根脚本 `pnpm run sync:skill-assets``.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。
Skill / 命令手册随 `skills/bailian-*/``bl skill init` 安装(装齐 registry 中全部 `bailian-*`,含共享协议 `bailian-protocol`)。业务 skill`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts`**`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills/<skill>/reference/`(纳入 git);`tools/sync-skill-metadata.ts``packages/cli/package.json` 同步各 `skills/*/SKILL.md``metadata.version`。两者由根脚本 `pnpm run sync:skill-assets``.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。
约定:
+50
View File
@@ -6,6 +6,56 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.15.1] - 2026-08-17
### Added
- **Model permission management** — `bl permission list` shows per-model inference / fine-tune / deploy grants; `bl permission grant` and `bl permission revoke` manage them, with `--all` to one-key grant inference for every model in the workspace (including future ones).
### Changed
- **`bl quota request` renamed to `bl quota update`** — set per-model QPM/TPM via `--rpm`/`--tpm` and clear custom limits with the new `--delete`; omitted fields keep their current values, and the old `quota request` path keeps working as an alias.
- **`bl quota list` reworked** — now reads the model-limits API and shows per-model and workspace-level request/usage limits plus async queue/concurrency limits in a single table.
- **`bl model list` no longer requires Console login** — the model catalog and `--enrich` parameter-schema endpoints are public.
- **`bl skill init` output simplified** — per-skill status is now `success`/`failed` (previously `installed`) with an aggregate `success`/`partial`/`failed` result; the `publishedAt` and `agents` fields were removed.
## [1.15.0] - 2026-08-14
### Added
- **Responses API for `bl text chat`** — Use `--api responses` to call the DashScope Responses API with streaming, tool definitions, and structured JSON output; Chat Completions remains the default.
- **Subscription plan usage views** — `bl usage token-plan` displays 5-hour and weekly quota usage, while `bl usage coding-plan` displays 5-hour, weekly, and monthly usage; both support text and JSON output.
- **Authentication requirements in command help** — Help output now states whether a command requires an API Key, Console login, or Alibaba Cloud OpenAPI credentials.
### Changed
- **Broader speech-recognition model support** — `bl speech recognize` now routes asynchronous file-transcription and synchronous Flash ASR models to the appropriate DashScope APIs, with clear guidance for unsupported realtime models.
- **MCP transport compatibility** — MCP commands now fall back from Streamable HTTP to classic SSE for compatible Bailian and custom endpoints.
### Fixed
- Binary updates now refresh installed Agent Skills after a successful CLI upgrade.
- Fixed unavailable Token Plan quota values and missing reset times.
- Fixed Qwen3 file-transcription result handling so waiting mode and `--out` work correctly.
- Fixed MCP SSE chunk parsing, header timeouts, abort cleanup, and fallback status matching.
- Network failures in JSON output now preserve the errno value in `cause.code`.
## [1.14.3] - 2026-08-12
### Fixed
- **Free-tier quota compatibility** — `bl usage free` and `bl usage freetier` now use the current Bailian Commerce console APIs for quota queries, activation, and deactivation, with consistent asynchronous-task polling.
## [1.14.2] - 2026-08-07
### Added
- **`bl skill init`** — Install all first-party `bailian-*` skills into detected local AI Agents in one step.
### Changed
- **Skill command interface** — Skill management commands now default to JSON output for Agent workflows; `bl skill add` and `bl skill update` use explicit `--all` and `--name` selectors.
## [1.14.1] - 2026-08-05
### Added
+50
View File
@@ -6,6 +6,56 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.15.1] - 2026-08-17
### 新增
- **模型权限管理** —— `bl permission list` 查看各模型的推理 / 微调 / 部署授权;`bl permission grant``bl permission revoke` 负责授予和回收,支持 `--all` 一键为工作区全部模型(含后续新增模型)开启推理授权。
### 变更
- **`bl quota request` 更名为 `bl quota update`** —— 通过 `--rpm`/`--tpm` 设置单模型 QPM/TPM新增 `--delete` 一键清除自定义限制;未指定的字段保持当前值,旧命令 `quota request` 仍作为别名可用。
- **`bl quota list` 重构** —— 改从模型限制接口读取数据,单表展示模型级与工作区级的请求/用量限制及异步队列/并发限制。
- **`bl model list` 不再需要控制台登录** —— 模型目录与 `--enrich` 参数结构端点均为公开接口。
- **`bl skill init` 输出精简** —— 单技能状态改为 `success`/`failed`(原为 `installed`),新增 `success`/`partial`/`failed` 汇总结果;移除 `publishedAt``agents` 字段。
## [1.15.0] - 2026-08-14
### 新增
- **`bl text chat` 支持 Responses API** —— 可通过 `--api responses` 调用 DashScope Responses API支持流式输出、工具定义和结构化 JSON 输出;默认仍使用 Chat Completions。
- **订阅套餐用量视图** —— `bl usage token-plan` 支持查看 5 小时和每周额度,`bl usage coding-plan` 支持查看 5 小时、每周和每月额度;两者均提供文本与 JSON 输出。
- **命令帮助展示鉴权要求** —— Help 输出现在会明确标注命令需要 API Key、控制台登录还是阿里云 OpenAPI 凭证。
### 变更
- **扩展语音识别模型支持** —— `bl speech recognize` 现在会将异步文件转写和同步 Flash ASR 模型路由至对应的 DashScope API并为暂不支持的实时模型提供明确提示。
- **增强 MCP 传输兼容性** —— MCP 命令现在可为兼容的百炼及自定义端点从 Streamable HTTP 自动回退至经典 SSE。
### 修复
- 二进制方式升级 CLI 成功后,现在会同步刷新已安装的 Agent Skills。
- 修复 Token Plan 额度不可用或缺少重置时间时的展示问题。
- 修复 Qwen3 文件转写结果处理,使等待模式和 `--out` 能够正常工作。
- 修复 MCP SSE 分块解析、响应头超时、中止清理和回退状态匹配问题。
- JSON 输出中的网络错误现在会在 `cause.code` 中保留 errno。
## [1.14.3] - 2026-08-12
### 修复
- **免费额度兼容性** —— `bl usage free``bl usage freetier` 现在使用最新的 Bailian Commerce 控制台 API 查询、开通和关闭免费额度,并统一处理异步任务轮询。
## [1.14.2] - 2026-08-07
### 新增
- **`bl skill init`** —— 一次性将全部官方 `bailian-*` Skill 安装到本机检测到的 AI Agent。
### 变更
- **Skill 命令接口** —— Skill 管理命令现在默认输出适合 Agent 工作流的 JSON`bl skill add``bl skill update` 使用明确的 `--all``--name` 选择参数。
## [1.14.1] - 2026-08-05
### 新增
+34 -31
View File
@@ -1,8 +1,38 @@
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**使用二进制一键安装(无需 Node若环境已有 Node / 需要 Command Pack再用 npm。不要臆造版本号或路径;以用户环境为准。
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**在已有 Node.js**≥ 18.17.0**)的环境用 npm 安装;若没有可用 Node / npm再走二进制一键安装。不要臆造版本号或路径;以用户环境为准。
## 1. 推荐:二进制安装(无需 Node
## 1. 推荐:npm 安装(要求 **≥ 18.17.0**
1. `node -v` 确认版本 ≥ 18.17.0。
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn
3. 执行:
```bash
npm install -g bailian-cli
```
4. 校验:`bl --version`
安装 skillsCLI 内置,无需 Git / npx skills
```bash
bl skill init
```
**Supported** `bl skill init` 一次装齐 registry 中全部 `bailian-*`(含共享协议 `bailian-protocol`)。
**Advanced / 按需子集:**
```bash
bl skill add --name bailian-protocol,bailian-gen
```
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
## 2. 备选:二进制安装(无需 Node
当环境没有 Node / npm或 Node 版本过低无法走 npm 时,使用二进制安装脚本。脚本安装 CLI 成功后会自动执行 `bl skill init`
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
@@ -37,36 +67,9 @@ bl --version
which bl # Windows: where.exe bl
```
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装
若自动 skill 安装失败,再手动执行:`bl skill init`
## 2. 备选npm 安装(要求 **≥ 18.17.0**
1. `node -v` 确认版本。
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn
3. 执行:
```bash
npm install -g bailian-cli
```
4. 校验:`bl --version`
可选 skills与 CLI 本体无关,按需):
```bash
npx skills add modelstudioai/cli --all -g
```
**Supported** 始终使用 `--all -g`,一次装齐整套 `bailian-*`(含共享协议 `bailian-protocol`。Agent Skills / `npx skills` **不会**按 metadata 自动拉依赖。
**Advanced / 不推荐:** 子集 `-s` 时 skills CLI 不会自动带上 `bailian-protocol`;若坚持子集,必须手动同时指定,例如:
```bash
# Advanced: you MUST include bailian-protocol yourself — installer does not pull it
npx skills add modelstudioai/cli -g -s bailian-protocol -s bailian-gen
```
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node回退到上方 npm 安装。
---
+18 -2
View File
@@ -82,15 +82,31 @@ Send the following to your Agent — it will detect your environment, then insta
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
```
**Manual install (npm)**
**Install with NPM**
```bash
npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
bl skill init
```
> Requires Node.js >= 18.17.
**Install on macOS/Linux**
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> No Node.js required. The installer automatically installs Bailian Skills.
**Install on Windows**
```powershell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
```
> No Node.js required. The installer automatically installs Bailian Skills.
## Quick Start
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
+18 -2
View File
@@ -81,15 +81,31 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
请阅读https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
```
**手动安装npm**
**NPM 安装**
```bash
npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
bl skill init
```
> 需要预先安装 Node.js >= 18.17。
**macOS/Linux 安装**
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
**Windows 安装**
```powershell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
## 快速开始
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
+12 -11
View File
@@ -11,16 +11,17 @@
## 统一口径(安装)
1. **Supported install** `npx skills add modelstudioai/cli --all -g`(整包装齐,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它Agent Skills / `npx skills` **不会**按 frontmatter 自动拉依赖
1. **Supported install** `bl skill init`(装齐 registry 中全部 `bailian-*`,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它
3. **不要**在 frontmatter 写 `companions`也不要对外说「companions = 安装器硬依赖」
4. 子集安装`-s`)为 **advanced / 不推荐**skills CLI 不会自动带上 protocol;漏装会导致相对路径 Read 失败
4. 子集安装`bl skill add --name bailian-protocol,<skill>`;漏装 protocol 会导致相对路径 Read 失败
5. **`bl skill add --all`** 安装 registry 全量(含 `spark-video` 等非 bailian 技能);一键安装 / `bl update``skill init`,不要用 `--all`
## 概念图
```text
bailian-protocol ← 共享协议consent / 鉴权 / 版本 / 错误上报)
▲ 靠 --all -g 与业务 skill 同装;非安装器强制 companions
▲ 靠 `bl skill init` 与业务 skill 同装;非安装器强制 companions
┌───────┴────────┬────────────────┬──────────────────┐
bailian-gen bailian-finetune bailian-managed-agent
@@ -37,8 +38,8 @@ bailian-gen bailian-finetune bailian-managed-agent
### A. 分层边界
- [ ] **整包装齐**:安装/升级文案主推 `--all -g`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `npx skills add modelstudioai/cli --all -g`
- [ ] **整包装齐**:安装/升级文案主推 `bl skill init`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init`
- [ ] **软 hand-off**:兄弟业务 skill **只写 skill 名**;已安装则 Read未安装则 `bl … --help` 或提示整包安装;**不要**把 `../bailian-gen/…` 等写成执行前提
- [ ] **Hub vs 领域**`bailian-cli` 的「When to use which command」只列 hub 拥有的意图;媒体 / 精调 / managed-agent 各留 hand-off 行,**不抄**领域默认模型与子命令明细
- [ ] **渐进披露**SKILL 写意图路由与领域硬规则flags / usage / examples 以 `reference/``bl <command> --help` 为准,表后保留「勿猜 flag」指向句
@@ -46,9 +47,9 @@ bailian-gen bailian-finetune bailian-managed-agent
### B. 文案与落款一致性
- [ ] 领域 skillgen / finetune / managed-agent路由或命令表后有指向 `reference/` 的句;文末 `## references`protocol + reference与家族对齐
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `--all -g`,不写 companions 必装
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `bl skill init`,不写 companions 必装
- [ ] Quick examples 只演示本 skill 职责hub 不示范 `bl image` / `bl video` 等)
- [ ] 若改了安装方式:同步 `README.md` / `README.zh.md` / `INSTALL.md` / `skills/*/README*` / `skills/bailian-protocol/assets/setup.md` 中的 `npx skills add …` 示例(改 `INSTALL.md` 时按 [install-doc-change.md](install-doc-change.md) 同步静态页)
- [ ] 若改了安装方式:同步 `README.md` / `README.zh.md` / `INSTALL.md` / `skills/*/README*` / `skills/bailian-protocol/assets/setup.md` 中的 `bl skill init` / `bl skill add …` 示例(改 `INSTALL.md` 时按 [install-doc-change.md](install-doc-change.md) 同步静态页)
### C. 归属与生成
@@ -60,8 +61,8 @@ bailian-gen bailian-finetune bailian-managed-agent
```sh
pnpm run sync:skill-assets
# 本地试装(测本仓库改动,勿只拉远端)
npx skills add "$(pwd)" --all -g -y
# 已发布版本试装
bl skill init
```
抽查:打开 `skills/bailian-cli/SKILL.md` 确认无领域子命令明细表、无 `companions`;打开对应领域 skill 确认有「勿猜 flag」与 hand-off。
@@ -69,7 +70,7 @@ npx skills add "$(pwd)" --all -g -y
## 常见漏点
- ✗ hub 路由表再次抄回 image / video / finetune / managed-agent 明细 → token 膨胀且与领域 skill 双份漂移
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 Agent Skills / `npx skills` 合同不符
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 `bl skill add` 合同不符
- ✗ 软 hand-off 写成硬路径 `../bailian-*/SKILL.md` 当执行前提 → 子集安装断链
- ✗ 只改 SKILL、忘改 `GROUP_OWNER_SKILL` → reference 落错 skill
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖
+18 -2
View File
@@ -82,15 +82,31 @@ Send the following to your Agent — it will detect your environment, then insta
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
```
**Manual install (npm)**
**Install with NPM**
```bash
npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
bl skill init
```
> Requires Node.js >= 18.17.
**Install on macOS/Linux**
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> No Node.js required. The installer automatically installs Bailian Skills.
**Install on Windows**
```powershell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
```
> No Node.js required. The installer automatically installs Bailian Skills.
## Quick Start
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
+18 -2
View File
@@ -81,15 +81,31 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
请阅读https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
```
**手动安装npm**
**NPM 安装**
```bash
npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
bl skill init
```
> 需要预先安装 Node.js >= 18.17。
**macOS/Linux 安装**
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
**Windows 安装**
```powershell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
## 快速开始
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.14.2",
"version": "1.15.1",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+30 -2
View File
@@ -45,15 +45,20 @@ import {
usageFreetier,
usageStats,
usageSummary,
usageTokenPlan,
usageCodingPlan,
pipelineRun,
pipelineValidate,
advisorRecommend,
modelList,
workspaceList,
quotaList,
quotaRequest,
quotaUpdate,
quotaHistory,
quotaCheck,
permissionList,
permissionGrant,
permissionRevoke,
datasetUpload,
datasetList,
datasetGet,
@@ -62,6 +67,7 @@ import {
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
finetuneVideoCreate,
finetuneList,
finetuneGet,
finetuneCancel,
@@ -71,6 +77,7 @@ import {
finetuneExport,
finetuneWatch,
finetuneCapability,
finetunePrice,
deployTextCreate,
deployAudioCreate,
deployImageCreate,
@@ -80,6 +87,8 @@ import {
deployScale,
deployUpdate,
deployDelete,
deployPause,
deployResume,
tokenPlanListSeats,
tokenPlanCreateKey,
tokenPlanAssignSeats,
@@ -164,15 +173,20 @@ export const commands: Record<string, AnyCommand> = {
"usage freetier": usageFreetier,
"usage stats": usageStats,
"usage summary": usageSummary,
"usage token-plan": usageTokenPlan,
"usage coding-plan": usageCodingPlan,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"advisor recommend": advisorRecommend,
"model list": modelList,
"workspace list": workspaceList,
"quota list": quotaList,
"quota request": quotaRequest,
"quota update": quotaUpdate,
"quota history": quotaHistory,
"quota check": quotaCheck,
"permission list": permissionList,
"permission grant": permissionGrant,
"permission revoke": permissionRevoke,
"dataset upload": datasetUpload,
"dataset list": datasetList,
"dataset get": datasetGet,
@@ -181,6 +195,7 @@ export const commands: Record<string, AnyCommand> = {
"finetune text create": finetuneTextCreate,
"finetune audio create": finetuneAudioCreate,
"finetune image create": finetuneImageCreate,
"finetune video create": finetuneVideoCreate,
"finetune list": finetuneList,
"finetune get": finetuneGet,
"finetune cancel": finetuneCancel,
@@ -190,6 +205,7 @@ export const commands: Record<string, AnyCommand> = {
"finetune export": finetuneExport,
"finetune watch": finetuneWatch,
"finetune capability": finetuneCapability,
"finetune price": finetunePrice,
"deploy text create": deployTextCreate,
"deploy audio create": deployAudioCreate,
"deploy image create": deployImageCreate,
@@ -199,6 +215,8 @@ export const commands: Record<string, AnyCommand> = {
"deploy scale": deployScale,
"deploy update": deployUpdate,
"deploy delete": deployDelete,
"deploy pause": deployPause,
"deploy resume": deployResume,
"token-plan list-seats": tokenPlanListSeats,
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
@@ -231,3 +249,13 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent session events": managedAgentSessionEvents,
"managed-agent skill-list": managedAgentSkillList,
};
/**
* Runtime-only aliases for renamed commands: dispatched by the CLI (merged in
* main.ts) but kept out of the canonical map so generate-reference.ts only
* documents the canonical path.
*/
export const commandAliases: Record<string, AnyCommand> = {
// Pre-migration name of "quota update".
"quota request": quotaUpdate,
};
+12 -9
View File
@@ -1,5 +1,5 @@
import { createCli } from "bailian-cli-runtime";
import { commands } from "./commands.ts";
import { commandAliases, commands } from "./commands.ts";
import { commandPackPolicy } from "./command-pack-policy.ts";
import pkg from "../package.json" with { type: "json" };
@@ -10,11 +10,14 @@ const quickStartTasks = [
"Help me analyze this video and write a Xiaohongshu-style post",
] as const;
void createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
}).run();
void createCli(
{ ...commands, ...commandAliases },
{
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
},
).run();
@@ -7,10 +7,15 @@ const commandPaths = Object.keys(commands).sort();
const groupPaths = deriveGroupPaths(commandPaths);
describe("e2e: bl registry smoke", () => {
test("根帮助展示 bl 与全局 flag", async () => {
test("根帮助展示 bl、逐命令鉴权域与全局 flag", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/\bbl\b/i);
expect(stderr).not.toMatch(/COMMAND\s+AUTH\s+DESCRIPTION/);
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
expect(stderr).toMatch(/token-plan create-key\s+\[AK\/SK\]\s+Create a Token Plan API key/);
expect(stderr).toMatch(/config show\s+\[No Auth\]\s+Display current configuration/);
expect(stderr).toMatch(/--base-url/);
expect(stderr).toMatch(/--console-region/);
expect(stderr).toMatch(/--console-site/);
@@ -18,6 +23,24 @@ describe("e2e: bl registry smoke", () => {
expect(stderr).not.toMatch(/^\s*--region\s/m);
});
test("分组帮助按叶子命令展示不同鉴权域", async () => {
const { stderr, exitCode } = await runCli(["app", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
});
test.each([
[["text", "chat"], "API Key"],
[["app", "list"], "Console"],
[["token-plan", "list-seats"], "AK/SK"],
[["config", "show"], "No Auth"],
] as const)("%s --help 明确展示鉴权域 %s", async (commandPath, authLabel) => {
const { stderr, exitCode } = await runCli([...commandPath, "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain(`Authentication: ${authLabel}`);
});
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.14.2",
"version": "1.15.1",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -1,5 +1,5 @@
// Read-only discovery of locally installed AI tooling, surfaced by `config ui`:
// - Agent skills installed under ~/.agents/skills (via `npx skills add`).
// - Agent skills installed under ~/.agents/skills (via `bl skill add`).
// - MCP servers declared in each coding agent's local config file.
// - Coding agent frameworks and whether the bailian-cli provider is wired in.
//
@@ -128,8 +128,8 @@ function countFiles(dir: string, budget = 500): number {
}
/**
* Skill directories to scan, keyed by the module that owns them. `npx skills
* add --all` fans skills out into each installed agent, so the same skill can
* Skill directories to scan, keyed by the module that owns them. `bl skill init` /
* `bl skill add` fans skills out into each installed agent, so the same skill can
* live in several of these roots at once.
*/
function skillRoots(home: string): Array<{ source: string; dir: string }> {
@@ -548,7 +548,7 @@ export const PAGE_HTML = `<!doctype html>
<section id="view-skills" class="view">
<div class="view-head">
<h2 class="view-title">Installed <span class="grad">Skills</span></h2>
<p class="view-sub">Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via <code style="font-family:var(--mono)">npx skills add</code>.</p>
<p class="view-sub">Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via <code style="font-family:var(--mono)">bl skill add</code>.</p>
</div>
<div class="toolbar"><input id="skillSearch" class="search" type="search" placeholder="Search skills…" autocomplete="off"><button id="addSkillBtn" class="btn-dark" type="button">+ Add skill</button></div>
<div id="skillsBody"><div class="loading">Loading…</div></div>
@@ -1444,7 +1444,7 @@ export const PAGE_HTML = `<!doctype html>
function renderSkills() {
var body = document.getElementById('skillsBody');
var pager = document.getElementById('skillsPager');
if (!SKILLS.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills installed.', 'Install with <code>npx skills add modelstudioai/cli --all -g</code>'); return; }
if (!SKILLS.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills installed.', 'Install with <code>bl skill init</code>'); return; }
var list = SKILLS.filter(function (s) { return skillMatches(s, SKILL_Q); });
if (!list.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills match "' + SKILL_Q + '".', ''); return; }
var info = pageSlice(list, SKILL_PAGE, getPageSize('skills')); SKILL_PAGE = info.page;
@@ -25,7 +25,7 @@ export default defineCommand({
},
},
exampleArgs: [
`--api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`--api zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`--api some.api.name --data '{"key":"value"}' --console-region cn-beijing`,
],
async run(ctx) {
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, deleteDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
fileId: {
@@ -19,20 +19,18 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const fileId = flags.fileId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "dataset.delete", file_id: fileId }, format);
emitResult({ action: "dataset.delete", file_id: fileId }, "json");
return;
}
const response = await deleteDataset(ctx.client, fileId);
if (settings.quiet || format === "text") {
emitBare(`Deleted ${fileId}.`);
emitRequestId(response.request_id, settings.quiet);
if (settings.quiet) {
emitBare(fileId);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
+7 -17
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const GET_FLAGS = {
fileId: {
@@ -19,10 +19,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const fileId = flags.fileId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "dataset.get", file_id: fileId }, format);
emitResult({ action: "dataset.get", file_id: fileId }, "json");
return;
}
@@ -45,19 +44,10 @@ export default defineCommand({
description: file.description ?? "",
};
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
if (settings.quiet) {
emitBare(item.file_id);
} else {
emitResult({ ...item, request_id: response.request_id }, "json");
}
// text / quiet
emitBare(`file_id: ${item.file_id}`);
emitBare(`name: ${item.name}`);
emitBare(`size: ${item.size}`);
if (item.md5) emitBare(`md5: ${item.md5}`);
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
if (item.description) emitBare(`description: ${item.description}`);
emitRequestId(response.request_id, settings.quiet);
},
});
+7 -19
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDatasets, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -23,7 +23,6 @@ export default defineCommand({
exampleArgs: ["", "--purpose fine-tune", "--purpose evaluation --page-size 20", "--output json"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -33,7 +32,7 @@ export default defineCommand({
page_size: flags.pageSize,
purpose: flags.purpose,
},
format,
"json",
);
return;
}
@@ -46,7 +45,6 @@ export default defineCommand({
const files = response.data?.files ?? [];
const total = response.data?.total;
// Normalize to consistent structure for both text/json output.
const items = files.map((item) => ({
file_id: item.file_id ?? "",
name: item.name ?? "",
@@ -54,20 +52,10 @@ export default defineCommand({
purpose: item.purpose ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
if (settings.quiet) {
for (const item of items) emitBare(item.file_id);
} else {
emitResult({ items, total, request_id: response.request_id }, "json");
}
// text / quiet
if (items.length === 0) {
emitBare("No dataset files found.");
return;
}
const headers = ["FILE_ID", "NAME", "SIZE", "PURPOSE"];
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -1,23 +1,23 @@
import {
defineCommand,
detectOutputFormat,
uploadDataset,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
MAX_DATASET_BYTES,
MAX_CPT_BYTES,
MAX_MEDIA_ZIP_BYTES,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const UPLOAD_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: "Local dataset file (.jsonl or .zip; ≤300MB text, ≤1GB image)",
description: "Local dataset file (.jsonl or .zip; ≤200MB SFT/DPO, ≤300MB CPT, ≤2GB media zip)",
required: true,
},
purpose: {
@@ -29,7 +29,7 @@ const UPLOAD_FLAGS = {
type: "string",
valueHint: "<s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record.',
},
noValidate: {
type: "switch",
@@ -45,7 +45,7 @@ export default defineCommand({
description: "Upload a dataset file (.jsonl or .zip) to Bailian",
auth: "apiKey",
usageArgs:
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image>] [--no-validate] [--full-validate]",
"--file <path> [--purpose <name>] [--schema <chatml|dpo|cpt|tts|image|video>] [--no-validate] [--full-validate]",
flags: UPLOAD_FLAGS,
exampleArgs: [
"--file train.jsonl",
@@ -58,13 +58,14 @@ export default defineCommand({
],
notes: [
"Supports .jsonl (text) and .zip (audio/image archives with a data.jsonl",
"manifest). Five record schemas are recognized: chatml = {messages:[...]}",
"manifest). Six record schemas are recognized: chatml = {messages:[...]}",
'(SFT); dpo = {messages:[...], chosen, rejected}; cpt = {text:"..."}',
'(continual pre-training, raw text); tts = {wav_fn:"train/xxx.wav",',
'text:"..."} (audio fine-tuning); image = {img_path:"..."} (image',
"generation). With no --schema, a record carrying wav_fn is validated as",
"TTS, img_path as image, chosen/rejected as DPO, text (no messages) as CPT,",
"otherwise ChatML. Upload cap: 300MB text, 1GB image. Upload uses the",
"generation); video = {first_frame_path:...} (video generation). With no",
"--schema, a record carrying wav_fn is validated as TTS, img_path as image,",
"chosen/rejected as DPO, text (no messages) as CPT, otherwise ChatML.",
"Upload cap: 200MB SFT/DPO text, 300MB CPT, 2GB media zip. Upload uses the",
"OpenAI-compatible /compatible-mode/v1/files endpoint so the purpose tag is",
"persisted (the DashScope-native /api/v1/files drops it).",
],
@@ -73,19 +74,15 @@ export default defineCommand({
const filePath = flags.file;
const purpose = flags.purpose || "fine-tune";
const schema = parseDatasetSchemaFlag(flags.schema);
if (schema === "video") {
throw new BailianError(
`--schema video is not supported.`,
ExitCode.USAGE,
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
// Image schema allows larger ZIPs (1 GB vs 300 MB for text).
const isMediaSchema = schema === "image";
// Size caps differ per training type: SFT/DPO 200MB, CPT 300MB, media ZIP 2GB.
const isMediaSchema = schema === "image" || schema === "video";
const maxBytes = isMediaSchema
? MAX_MEDIA_ZIP_BYTES
: schema === "cpt"
? MAX_CPT_BYTES
: MAX_DATASET_BYTES;
if (!flags.noValidate) {
const maxBytes = isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES;
const result = await validateDataset(filePath, {
fullValidate: flags.fullValidate,
schema,
@@ -125,11 +122,11 @@ export default defineCommand({
action: "dataset.upload",
file: filePath,
purpose,
max_bytes: isMediaSchema ? MAX_MEDIA_ZIP_BYTES : MAX_DATASET_BYTES,
max_bytes: maxBytes,
validate: !flags.noValidate,
schema: schema ?? "auto",
},
format,
"json",
);
return;
}
@@ -142,11 +139,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(file.file_id);
} else if (format === "text") {
emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`);
emitRequestId(request_id, settings.quiet);
} else {
emitResult({ ...file, request_id }, format);
emitResult({ ...file, request_id }, "json");
}
},
});
@@ -1,26 +1,13 @@
import {
defineCommand,
detectOutputFormat,
validateDataset,
parseDatasetSchemaFlag,
formatIssue,
BailianError,
ExitCode,
type ValidationResult,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
function formatStats(result: ValidationResult): string[] {
const out: string[] = [];
if (result.stats.totalRecords !== undefined) out.push(`records: ${result.stats.totalRecords}`);
if (result.stats.sampledRecords !== undefined)
out.push(`sampled: ${result.stats.sampledRecords}`);
if (result.stats.bytes !== undefined) out.push(`bytes: ${result.stats.bytes}`);
if (result.stats.durationMs !== undefined) out.push(`took: ${result.stats.durationMs}ms`);
return out;
}
const VALIDATE_FLAGS = {
file: {
type: "string",
@@ -36,7 +23,7 @@ const VALIDATE_FLAGS = {
type: "string",
valueHint: "<s>",
description:
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), or "image" (image generation). Default auto-detects per record.',
'Record schema: "chatml" (SFT), "dpo" (chosen/rejected), "cpt" (raw text), "tts" (audio), "image" (image generation), or "video" (video generation). Default auto-detects per record.',
},
} satisfies FlagsDef;
@@ -44,13 +31,14 @@ export default defineCommand({
description: "Locally validate a dataset file (.jsonl or .zip) without uploading",
// 纯本地校验,不触网、不需 API key与 `pipeline validate` 一致)。
auth: "none",
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image>]",
usageArgs: "--file <path> [--full-validate] [--schema <chatml|dpo|cpt|tts|image|video>]",
flags: VALIDATE_FLAGS,
exampleArgs: [
"--file train.jsonl",
"--file dpo.jsonl --schema dpo",
"--file cpt.jsonl --schema cpt",
"--file audio.zip --schema tts",
"--file wan-i2v-training-dataset.zip --schema video",
"--file eval.jsonl --full-validate",
"--file train.jsonl --output json",
],
@@ -60,27 +48,20 @@ export default defineCommand({
"Schemas: chatml = {messages:[...]} (SFT); dpo = {messages:[...], chosen,",
'rejected}; cpt = {text:"..."} (continual pre-training, raw text);',
'tts = {wav_fn:"train/xxx.wav", text:"..."} (audio fine-tuning);',
'image = {img_path:"..."} (image generation). With no --schema, a record',
"carrying wav_fn is validated as TTS, img_path as image, chosen/rejected",
"as DPO, text (no messages) as CPT, otherwise ChatML. Pass --schema to",
"require a specific shape on every record. ZIP archives (.zip) are",
"validated structurally (data.jsonl present, media references resolve) in",
"addition to per-record content checks. Use --full-validate to JSON.parse",
"every line.",
'image = {img_path:"..."} (image generation);',
'video = {first_frame_path:"...", video_path:"..."} (video generation,',
"i2v first-frame or kf2v first+last-frame with last_frame_path). With no",
"--schema, a record carrying wav_fn is validated as TTS, img_path as image,",
"first_frame_path/video_path as video, chosen/rejected as DPO, text (no",
"messages) as CPT, otherwise ChatML. Pass --schema to require a specific",
"shape on every record. ZIP archives (.zip) are validated structurally",
"(data.jsonl present, media references resolve) in addition to per-record",
"content checks. Use --full-validate to JSON.parse every line.",
],
async run(ctx) {
const { settings, flags } = ctx;
const filePath = flags.file;
const schema = parseDatasetSchemaFlag(flags.schema);
if (schema === "video") {
throw new BailianError(
`--schema video is not supported.`,
ExitCode.USAGE,
`Supported schemas: chatml, dpo, cpt, tts, image.`,
);
}
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
@@ -89,38 +70,17 @@ export default defineCommand({
full: flags.fullValidate,
schema: schema ?? "auto",
},
format,
"json",
);
return;
}
const result = await validateDataset(filePath, { fullValidate: flags.fullValidate, schema });
if (format === "json") {
// For json output we always emit the structured result, exit code conveys validity.
emitResult(result, format);
} else if (settings.quiet) {
if (settings.quiet) {
emitBare(result.valid ? "ok" : "fail");
} else {
const status = result.valid ? "PASSED" : "FAILED";
emitBare(`Dataset validation ${status} for ${result.filePath}`);
const stats = formatStats(result);
if (stats.length) emitBare(` ${stats.join(" · ")}`);
if (result.errors.length) {
emitBare(`Errors (${result.errors.length}):`);
for (const error of result.errors.slice(0, 20)) emitBare(formatIssue(error));
if (result.errors.length > 20) {
emitBare(` … and ${result.errors.length - 20} more.`);
}
}
if (result.warnings.length) {
emitBare(`Warnings (${result.warnings.length}):`);
for (const warning of result.warnings.slice(0, 10)) emitBare(formatIssue(warning));
if (result.warnings.length > 10) {
emitBare(` … and ${result.warnings.length - 10} more.`);
}
}
emitResult(result, "json");
}
if (!result.valid) {
+25 -39
View File
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
createDeployment,
pickPlanStrategy,
STRATEGIES,
@@ -11,16 +10,16 @@ import {
type CommandContext,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const CREATE_FLAGS = {
model: {
modelName: {
type: "string",
valueHint: "<name>",
description: "Model name (catalog model or fine-tuned output) (required)",
valueHint: "<model_name>",
description: "Model to deploy — fine-tuned output name or catalog model (required)",
required: true,
},
name: {
displayName: {
type: "string",
valueHint: "<display_name>",
description: "Console display name for the deployment (required)",
@@ -64,7 +63,7 @@ const CREATE_FLAGS = {
} satisfies FlagsDef;
const CREATE_USAGE =
"--model <model_name> --name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
"--model-name <model_name> --display-name <display_name> [--plan <plan>] [--deploy-spec <id>] [--capacity <n>] [--billing-method <m>] [--input-tpm <n>] [--output-tpm <n>] [--thinking-output-tpm <n>]";
const CREATE_NOTES = [
"Plan defaults to `lora` (Token-billed) for text/image and `mu` (model-unit-",
@@ -78,14 +77,11 @@ const CREATE_NOTES = [
"Use `bl deploy models --source base` to inspect available templates.",
"After creation, status starts at PENDING and transitions to RUNNING.",
"Invoke the deployed model with: bl text chat --model <deployed_model>",
"WARNING: --model is overloaded across commands and refers to DIFFERENT",
"values. `bl deploy <modality> create --model` takes the exported model_name",
"(e.g. `qwen3-8b-ft-...`), but the create response also returns a",
"`deployed_model` field (the deployment instance id, e.g.",
"`qwen3-8b-5ecb5f068d79`). The inference call `bl text chat --model` must use",
"the `deployed_model` from the create response — NOT the `model_name` you",
"passed to `deploy <modality> create`. Do not reuse the value across the two",
"commands.",
"NOTE: --model-name is the model being deployed (e.g. `qwen3-8b-ft-...`).",
"The create response also returns a `deployed_model` field — the deployment",
"instance id (e.g. `qwen3-8b-5ecb5f068d79`). Use that id for inference",
"(`bl text chat --model <deployed_model>`) and lifecycle commands",
"(`deploy get/scale/pause/resume/delete --deployed-model <id>`).",
];
/**
@@ -119,10 +115,9 @@ async function runCreate(
ctx: CommandContext<typeof CREATE_FLAGS>,
): Promise<void> {
const { identity, settings, flags } = ctx;
const model = flags.model as string;
const name = flags.name as string;
const model = flags.modelName as string;
const name = flags.displayName as string;
const plan = (flags.plan as string | undefined) || defaultDeployPlan(modality);
const format = detectOutputFormat(settings.output);
// Plan-specific behaviour is owned by core `plans.ts`. The strategy resolves
// the plan-specific body fragment (mu may auto-pick a template from the
@@ -146,7 +141,7 @@ async function runCreate(
};
if (settings.dryRun) {
emitResult({ action: "deploy.create", body }, format);
emitResult({ action: "deploy.create", body }, "json");
return;
}
@@ -155,17 +150,8 @@ async function runCreate(
if (settings.quiet) {
emitBare(deployment?.deployed_model ?? "");
} else if (format === "text") {
emitBare(`Created deployment.`);
if (deployment?.deployed_model) emitBare(` deployed_model: ${deployment.deployed_model}`);
if (deployment?.status) emitBare(` status: ${deployment.status}`);
if (deployment?.plan) emitBare(` plan: ${deployment.plan}`);
emitBare(
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
}
@@ -176,10 +162,10 @@ export const deployTextCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-qwen-sft --name my-sft-test",
"--model qwen3.6-flash-2026-04-16 --name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
"--model qwen3-8b --name my-qwen3-mu --plan mu",
"--model qwen3-8b --name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
"--model-name my-qwen-sft --display-name my-sft-test",
"--model-name qwen3.6-flash-2026-04-16 --display-name my-flash --plan ptu --input-tpm 10000 --output-tpm 1000",
"--model-name qwen3-8b --display-name my-qwen3-mu --plan mu",
"--model-name qwen3-8b --display-name my-qwen3 --plan mu --deploy-spec MU1 --capacity 2",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("text", flags),
@@ -193,9 +179,9 @@ export const deployAudioCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-cosyvoice-ft --name my-tts",
"--model my-cosyvoice-ft --name my-tts --deploy-spec dps-xxxx --capacity 1",
"--model my-cosyvoice-ft --name my-tts --dry-run",
"--model-name my-cosyvoice-ft --display-name my-tts",
"--model-name my-cosyvoice-ft --display-name my-tts --deploy-spec dps-xxxx --capacity 1",
"--model-name my-cosyvoice-ft --display-name my-tts --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("audio", flags),
@@ -209,9 +195,9 @@ export const deployImageCreate = defineCommand({
usageArgs: CREATE_USAGE,
flags: CREATE_FLAGS,
exampleArgs: [
"--model my-wan-ft --name my-wan",
"--model my-wan-ft --name my-wan-mu --plan mu",
"--model my-wan-ft --name my-wan --dry-run",
"--model-name my-wan-ft --display-name my-wan",
"--model-name my-wan-ft --display-name my-wan-mu --plan mu",
"--model-name my-wan-ft --display-name my-wan --dry-run",
],
notes: CREATE_NOTES,
validate: (flags) => validateCreate("image", flags),
@@ -1,13 +1,12 @@
import {
defineCommand,
detectOutputFormat,
deleteDeployment,
getDeployment,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
deployedModel: {
@@ -38,10 +37,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, format);
emitResult({ action: "deploy.delete", deployed_model: deployedModel }, "json");
return;
}
@@ -55,7 +53,8 @@ export default defineCommand({
if (status && status !== "STOPPED" && status !== "FAILED") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only STOPPED / FAILED deployments can be deleted. ` +
`Stop it first via the platform console, or pass --skip-precheck to attempt deletion anyway.`,
`Run \`bl deploy pause --deployed-model ${deployedModel}\` to pause it first, ` +
`or pass --skip-precheck to attempt deletion anyway.`,
ExitCode.USAGE,
);
}
@@ -69,11 +68,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
emitBare(`Deleted ${deployedModel}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
+5 -18
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const GET_FLAGS = {
deployedModel: {
@@ -22,10 +22,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "deploy.get", deployed_model: deployedModel }, format);
emitResult({ action: "deploy.get", deployed_model: deployedModel }, "json");
return;
}
@@ -33,7 +32,7 @@ export default defineCommand({
const deployment = response.output ?? response.data;
if (!deployment) {
emitBare(`No data returned for ${deployedModel}`);
emitResult({ deployed_model: deployedModel, request_id: response.request_id }, "json");
return;
}
@@ -57,18 +56,6 @@ export default defineCommand({
if (deployment.gmt_create) item.created_at = deployment.gmt_create;
if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified;
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
}
// text / quiet — fixed-width label column for alignment
const label = (key: string) => `${key}:`.padEnd(18);
for (const [key, value] of Object.entries(item)) {
if (value === "" || value === undefined) continue;
const display = typeof value === "string" ? value : JSON.stringify(value);
emitBare(`${label(key)}${display}`);
}
emitRequestId(response.request_id, settings.quiet);
emitResult({ ...item, request_id: response.request_id }, "json");
},
});
+4 -31
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listDeployments,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDeployments, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -28,13 +23,12 @@ export default defineCommand({
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const status = flags.status || undefined;
if (settings.dryRun) {
emitResult(
{ action: "deploy.list", page: flags.page, page_size: flags.pageSize, status },
format,
"json",
);
return;
}
@@ -57,27 +51,6 @@ export default defineCommand({
created_at: item.gmt_create ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet
if (items.length === 0) {
emitBare("No deployments found.");
return;
}
const headers = ["DEPLOYED_MODEL", "MODEL_NAME", "STATUS", "PLAN", "CAPACITY", "CREATED_AT"];
const rows = items.map((item) => [
item.deployed_model,
item.model_name,
item.status,
item.plan,
item.capacity,
item.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
+49 -102
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listDeployableModels,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listDeployableModels, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const MODELS_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -39,7 +34,6 @@ export default defineCommand({
],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
// Default version to v1.0 — without it, the API returns the legacy catalog
// (only old fine-tune outputs). Pass --catalog-version "" to opt out.
const version = flags.catalogVersion === "" ? undefined : (flags.catalogVersion ?? "v1.0");
@@ -54,7 +48,7 @@ export default defineCommand({
version,
model_source: modelSource,
},
format,
"json",
);
return;
}
@@ -72,102 +66,55 @@ export default defineCommand({
// Two response shapes:
// - custom (fine-tuned): top-level supported_plans: string[]
// - base (catalog): plans: [{plan, templates?, cu_specs?}]
// For json: surface the deployment-relevant fields preserved as a tree, so
// Surface the deployment-relevant fields preserved as a tree, so
// downstream tooling can drive `bl deploy <modality> create --deploy-spec <…>`
// without a second round-trip. For text: keep the compact one-line summary.
if (format === "json") {
const items = models.map((model) => {
const out: Record<string, unknown> = {
model_name: model.model_name ?? "",
};
if (model.base_model) out.base_model = model.base_model;
if (model.model_source) out.model_source = model.model_source;
if (model.supported_plans && model.supported_plans.length > 0) {
out.supported_plans = model.supported_plans;
}
if (model.plans && model.plans.length > 0) {
out.plans = model.plans.map((plan) => {
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
if (plan.cu_specs && plan.cu_specs.length > 0) {
planEntry.cu_specs = plan.cu_specs;
}
if (plan.templates && plan.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = plan.templates.map((template) => {
const tpl: Record<string, unknown> = {};
if (template.template_id) tpl.template_id = template.template_id;
if (template.template_name) tpl.template_name = template.template_name;
if (template.charge_type) tpl.charge_type = template.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = template.roles?.unified;
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
if (unified?.capacity_unit_per_instance !== undefined)
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
// Preserve split-role configs (SEPERATED) as-is so callers
// can still drive prefill/decode sizing.
if (template.roles?.prefill || template.roles?.decode) {
tpl.roles = {
prefill: template.roles?.prefill,
decode: template.roles?.decode,
};
}
if (template.template_desc) tpl.template_desc = template.template_desc;
return tpl;
});
}
return planEntry;
});
}
return out;
});
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet — keep the compact single-line summary table.
const textItems = models.map((model) => {
let plansSummary = "";
if (model.supported_plans && model.supported_plans.length > 0) {
plansSummary = model.supported_plans.join(",");
} else if (model.plans && model.plans.length > 0) {
plansSummary = model.plans
.map((plan) => {
const planName = plan.plan ?? "?";
if (plan.templates && plan.templates.length > 0) {
return `${planName}(${plan.templates.length}t)`;
}
if (plan.cu_specs && plan.cu_specs.length > 0) {
return `${planName}(${plan.cu_specs.join("/")})`;
}
return planName;
})
.join(",");
} else {
plansSummary = "-";
}
return {
// without a second round-trip.
const items = models.map((model) => {
const out: Record<string, unknown> = {
model_name: model.model_name ?? "",
base_model: model.base_model ?? "",
source: model.model_source ?? "",
plans: plansSummary,
};
if (model.base_model) out.base_model = model.base_model;
if (model.model_source) out.model_source = model.model_source;
if (model.supported_plans && model.supported_plans.length > 0) {
out.supported_plans = model.supported_plans;
}
if (model.plans && model.plans.length > 0) {
out.plans = model.plans.map((plan) => {
const planEntry: Record<string, unknown> = { plan: plan.plan ?? "" };
if (plan.cu_specs && plan.cu_specs.length > 0) {
planEntry.cu_specs = plan.cu_specs;
}
if (plan.templates && plan.templates.length > 0) {
// Pull the top 6 fields most useful for `bl deploy <modality> create`.
// Drop noisy/redundant: template_source, template_type,
// template_version, deploy_spec (typically == template_id).
planEntry.templates = plan.templates.map((template) => {
const tpl: Record<string, unknown> = {};
if (template.template_id) tpl.template_id = template.template_id;
if (template.template_name) tpl.template_name = template.template_name;
if (template.charge_type) tpl.charge_type = template.charge_type;
// Flatten roles.unified for the common COUPLED case.
const unified = template.roles?.unified;
if (unified?.model_unit_spec) tpl.model_unit_spec = unified.model_unit_spec;
if (unified?.capacity_unit_per_instance !== undefined)
tpl.capacity_unit_per_instance = unified.capacity_unit_per_instance;
// Preserve split-role configs (SEPERATED) as-is so callers
// can still drive prefill/decode sizing.
if (template.roles?.prefill || template.roles?.decode) {
tpl.roles = {
prefill: template.roles?.prefill,
decode: template.roles?.decode,
};
}
if (template.template_desc) tpl.template_desc = template.template_desc;
return tpl;
});
}
return planEntry;
});
}
return out;
});
if (textItems.length === 0) {
emitBare("No deployable models found.");
return;
}
const headers = ["MODEL_NAME", "BASE_MODEL", "SOURCE", "PLANS"];
const rows = textItems.map((item) => [
item.model_name,
item.base_model,
item.source,
item.plans,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
@@ -0,0 +1,85 @@
import {
defineCommand,
stopModelService,
listIndependentDeployedModels,
findDeploymentEntry,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const PAUSE_FLAGS = {
deployedModel: {
type: "string",
valueHint: "<id>",
description: "Deployed model identifier (required)",
required: true,
},
skipPrecheck: {
type: "switch",
description: "Skip the local RUNNING/PENDING status precheck",
},
} satisfies FlagsDef;
/**
* `bl deploy pause` — pause a running deployment.
*
* Takes the model service offline so it no longer serves inference requests.
* For mu/ptu plans, billing stops while paused.
* Precheck: status must be RUNNING or PENDING.
*/
export default defineCommand({
description: "Pause a running model deployment (stops billing for mu/ptu)",
auth: "console",
usageArgs: "--deployed-model <id> [--skip-precheck]",
flags: PAUSE_FLAGS,
exampleArgs: [
"--deployed-model dep-...",
"--deployed-model dep-... --skip-precheck",
"--deployed-model dep-... --dry-run",
],
notes: [
"While paused, billing ceases for mu/ptu plans. Use `deploy resume` to bring it back online or `deploy delete` to remove.",
"Precheck verifies status is RUNNING/PENDING before issuing the pause; pass --skip-precheck to bypass.",
],
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
if (settings.dryRun) {
emitResult({ action: "deploy.pause", deployed_model: deployedModel }, "json");
return;
}
// Precheck: verify the deployment is in a pausable state.
if (!flags.skipPrecheck) {
try {
const entries = await listIndependentDeployedModels(ctx.client);
const entry = findDeploymentEntry(entries, deployedModel);
if (entry) {
const status = (entry.status ?? "").toUpperCase();
if (status && status !== "RUNNING" && status !== "PENDING") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only RUNNING / PENDING deployments can be paused. ` +
`Pass --skip-precheck to attempt the pause anyway.`,
ExitCode.USAGE,
);
}
}
// If entry not found in list, proceed — the server will surface the real error.
} catch (error) {
if (error instanceof BailianError) throw error;
// If the list call itself failed, proceed and let the API call surface the error.
}
}
const response = await stopModelService(ctx.client, deployedModel);
if (settings.quiet) {
emitBare(deployedModel);
} else {
emitResult({ deployed_model: deployedModel, action: "pause", ...response }, "json");
}
},
});
@@ -0,0 +1,84 @@
import {
defineCommand,
startModelService,
listIndependentDeployedModels,
findDeploymentEntry,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const RESUME_FLAGS = {
deployedModel: {
type: "string",
valueHint: "<id>",
description: "Deployed model identifier (required)",
required: true,
},
skipPrecheck: {
type: "switch",
description: "Skip the local STOPPED status precheck",
},
} satisfies FlagsDef;
/**
* `bl deploy resume` — resume a paused deployment.
*
* Brings the model service back online so it can serve inference requests.
* Precheck: status must be STOPPED.
*/
export default defineCommand({
description: "Resume a paused model deployment (brings service back online)",
auth: "console",
usageArgs: "--deployed-model <id> [--skip-precheck]",
flags: RESUME_FLAGS,
exampleArgs: [
"--deployed-model dep-...",
"--deployed-model dep-... --skip-precheck",
"--deployed-model dep-... --dry-run",
],
notes: [
"Precheck verifies status is STOPPED before issuing the resume; pass --skip-precheck to bypass.",
"For mu/ptu plans, billing resumes once the service is back online.",
],
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
if (settings.dryRun) {
emitResult({ action: "deploy.resume", deployed_model: deployedModel }, "json");
return;
}
// Precheck: verify the deployment is in a resumable state.
if (!flags.skipPrecheck) {
try {
const entries = await listIndependentDeployedModels(ctx.client);
const entry = findDeploymentEntry(entries, deployedModel);
if (entry) {
const status = (entry.status ?? "").toUpperCase();
if (status && status !== "STOPPED") {
throw new BailianError(
`Deployment ${deployedModel} is ${status}. Only STOPPED deployments can be resumed. ` +
`Pass --skip-precheck to attempt the resume anyway.`,
ExitCode.USAGE,
);
}
}
// If entry not found in list, proceed — the server will surface the real error.
} catch (error) {
if (error instanceof BailianError) throw error;
// If the list call itself failed, proceed and let the API call surface the error.
}
}
const response = await startModelService(ctx.client, deployedModel);
if (settings.quiet) {
emitBare(deployedModel);
} else {
emitResult({ deployed_model: deployedModel, action: "resume", ...response }, "json");
}
},
});
+4 -15
View File
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
scaleDeployment,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, scaleDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const SCALE_FLAGS = {
deployedModel: {
@@ -52,7 +47,6 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
const body: Record<string, unknown> = {};
if (flags.capacity !== undefined) body.capacity = flags.capacity;
@@ -60,21 +54,16 @@ export default defineCommand({
if (flags.outputTpm !== undefined) body.output_tpm = flags.outputTpm;
if (settings.dryRun) {
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, format);
emitResult({ action: "deploy.scale", deployed_model: deployedModel, body }, "json");
return;
}
const response = await scaleDeployment(ctx.client, deployedModel, body);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
emitBare(`Scaled ${deployedModel}${cap}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
updateDeployment,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, updateDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const UPDATE_FLAGS = {
deployedModel: {
@@ -48,31 +43,22 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const deployedModel = flags.deployedModel;
const format = detectOutputFormat(settings.output);
const body: Record<string, unknown> = {};
if (flags.rpmLimit !== undefined) body.rpm_limit = flags.rpmLimit;
if (flags.tpmLimit !== undefined) body.tpm_limit = flags.tpmLimit;
if (settings.dryRun) {
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, format);
emitResult({ action: "deploy.update", deployed_model: deployedModel, body }, "json");
return;
}
const response = await updateDeployment(ctx.client, deployedModel, body);
const deployment = response.output ?? response.data;
if (settings.quiet) {
emitBare(deployedModel);
} else if (format === "text") {
const parts: string[] = [];
if (deployment?.rpm_limit !== undefined) parts.push(`rpm_limit=${deployment.rpm_limit}`);
if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`);
const summary = parts.length ? ` (${parts.join(", ")})` : "";
emitBare(`Updated ${deployedModel}${summary}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, cancelFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const CANCEL_FLAGS = {
jobId: {
@@ -23,24 +23,18 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.cancel", job_id: jobId }, format);
emitResult({ action: "finetune.cancel", job_id: jobId }, "json");
return;
}
const response = await cancelFineTune(ctx.client, jobId);
const job = response.output ?? response.data;
if (settings.quiet) {
emitBare(jobId);
} else if (format === "text") {
const status = job?.status ? ` (status=${job.status})` : "";
emitBare(`Cancelled ${jobId}${status}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,15 +1,13 @@
import {
defineCommand,
detectOutputFormat,
fetchModelList,
fetchModelListAll,
fetchModelCapability,
listSupportedTrainingTypes,
modelSupportsTrainingType,
isTrainingTypeCli,
trainingTypeMethodVariant,
TRAINING_TYPES_CLI,
callConsoleGateway,
effectiveConsoleGatewayConfig,
anonymousConsoleCall,
UsageError,
type Settings,
type ModelCapability,
@@ -17,8 +15,6 @@ import {
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const PAGE_SIZE = 50;
/**
* Page through every foundation-model page (listFoundationModels, public — no
* console login needed, so the gateway is called anonymously). Returns raw
@@ -26,36 +22,12 @@ const PAGE_SIZE = 50;
* for filtering.
*/
async function fetchAllFoundationModels(settings: Settings): Promise<ModelCapability[]> {
const eff = effectiveConsoleGatewayConfig(settings);
const call = (api: string, data: Record<string, unknown>) =>
callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
settings.timeout,
{ api, data },
);
const first = await fetchModelList(call, { pageNo: 1, pageSize: PAGE_SIZE });
const all = [...first.models];
const totalPages = Math.ceil(first.total / PAGE_SIZE);
for (let pageNo = 2; pageNo <= totalPages; pageNo++) {
const result = await fetchModelList(call, { pageNo, pageSize: PAGE_SIZE });
all.push(...result.models);
}
const all = await fetchModelListAll(anonymousConsoleCall(settings));
return all as ModelCapability[];
}
const VARIANT_LABEL: Record<string, string> = {
full: "full-parameter",
lora: "LoRA",
};
function describeTrainingType(value: string): string {
if (!isTrainingTypeCli(value)) return value;
const { method, variant } = trainingTypeMethodVariant(value);
return `${VARIANT_LABEL[variant] ?? variant} ${method.toUpperCase()}`;
}
const CAPABILITY_FLAGS = {
model: {
baseModel: {
type: "string",
valueHint: "<m>",
description: "List training types supported by this base model.",
@@ -71,31 +43,31 @@ export default defineCommand({
description:
"Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it)",
auth: "none",
usageArgs: "--model <m> | --training-type <t>",
usageArgs: "--base-model <m> | --training-type <t>",
flags: CAPABILITY_FLAGS,
exampleArgs: [
"--model qwen3-8b",
"--base-model qwen3-8b",
"--training-type sft-lora",
"--training-type cpt --output json",
"--training-type sft --quiet",
],
notes: [
"Exactly one of --model / --training-type is required.",
"Exactly one of --base-model / --training-type is required.",
"Training-type values use the `<method>` / `<method>-lora` convention:",
"sft | sft-lora | dpo | dpo-lora | cpt. (cpt has no -lora variant server-side.)",
"Queries listFoundationModels, a public API — no console login needed.",
],
validate: (f) => {
if (f.model && f.trainingType)
return "--model and --training-type are mutually exclusive; pass one.";
if (!f.model && !f.trainingType) return "one of --model / --training-type is required.";
if (f.baseModel && f.trainingType)
return "--base-model and --training-type are mutually exclusive; pass one.";
if (!f.baseModel && !f.trainingType)
return "one of --base-model / --training-type is required.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const model = flags.model || undefined;
const model = flags.baseModel || undefined;
const trainingType = flags.trainingType || undefined;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -104,7 +76,7 @@ export default defineCommand({
model,
training_type: trainingType,
},
format,
"json",
);
return;
}
@@ -113,7 +85,7 @@ export default defineCommand({
if (model) {
const capability = await fetchModelCapability(settings, model);
if (!capability) {
emitBare(`No foundation model found matching "${model}".`);
emitResult({ model, error: `No foundation model found matching "${model}".` }, "json");
return;
}
const supported = listSupportedTrainingTypes(capability);
@@ -121,23 +93,15 @@ export default defineCommand({
for (const value of supported) emitBare(value);
return;
}
if (format !== "text") {
emitResult(
{
model: capability.model ?? model,
supported,
supports: capability.supports,
trainingTypes: capability.trainingTypes,
},
format,
);
return;
}
emitBare(`${capability.model ?? model}`);
emitBare(supported.length ? "Supported training types:" : "No supported training types.");
for (const value of supported) {
emitBare(` ${value.padEnd(10)} ${describeTrainingType(value)}`);
}
emitResult(
{
model: capability.model ?? model,
supported,
supports: capability.supports,
trainingTypes: capability.trainingTypes,
},
"json",
);
return;
}
@@ -162,20 +126,15 @@ export default defineCommand({
for (const entry of matched) emitBare(entry.model);
return;
}
if (format !== "text") {
emitResult(
{
training_type: trainingType,
method,
variant,
count: matched.length,
models: matched,
},
format,
);
return;
}
emitBare(`Models supporting ${trainingType} (${method} / ${variant}): ${matched.length}`);
for (const entry of matched) emitBare(` ${entry.model}`);
emitResult(
{
training_type: trainingType,
method,
variant,
count: matched.length,
models: matched,
},
"json",
);
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
listCheckpoints,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listCheckpoints, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const CHECKPOINTS_FLAGS = {
jobId: {
@@ -15,6 +10,8 @@ const CHECKPOINTS_FLAGS = {
},
} satisfies FlagsDef;
const EXPIRY_WARN_THRESHOLD_MS = 72 * 60 * 60 * 1000; // 72 hours
export default defineCommand({
description: "List checkpoints produced by a fine-tune job",
auth: "apiKey",
@@ -22,16 +19,15 @@ export default defineCommand({
flags: CHECKPOINTS_FLAGS,
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
notes: [
"Use the returned `checkpoint` value with `finetune export` to publish",
"a deployable model.",
"`model_name` (shown for SUCCEEDED checkpoints) is the direct input for `deploy create --model-name`.",
"Checkpoints expire ~15 days after creation; `expire_time` shows the deadline. Export or deploy before expiry.",
],
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.checkpoints", job_id: jobId }, format);
emitResult({ action: "finetune.checkpoints", job_id: jobId }, "json");
return;
}
@@ -44,22 +40,26 @@ export default defineCommand({
checkpoint: item.checkpoint ?? item.checkpoint_id ?? "",
step: item.step !== undefined ? String(item.step) : "",
status: item.status ?? "",
model_name: item.model_name ?? "",
expire_time: item.expire_time ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
emitResult({ items, total, request_id: response.request_id }, "json");
// text / quiet
if (items.length === 0) {
emitBare("No checkpoints found.");
return;
// Near-expiry warning: check if any non-expired checkpoint is within 72h of expiry.
const now = Date.now();
const expiringSoon = items.filter((item) => {
if (!item.expire_time) return false;
const deadline = new Date(item.expire_time).getTime();
if (Number.isNaN(deadline)) return false;
const remaining = deadline - now;
return remaining > 0 && remaining < EXPIRY_WARN_THRESHOLD_MS;
});
if (expiringSoon.length > 0) {
process.stderr.write(
`\n[warning] ${expiringSoon.length} checkpoint(s) will expire within 72 hours. ` +
"Export or deploy before expiry to avoid losing the model artifact.\n",
);
}
const headers = ["CHECKPOINT", "STEP", "STATUS"];
const rows = items.map((i) => [i.checkpoint, i.step, i.status]);
for (const line of formatTable(headers, rows)) emitBare(line);
emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -1,6 +1,5 @@
import {
defineCommand,
detectOutputFormat,
createFineTune,
getDataset,
uploadDataset,
@@ -27,7 +26,7 @@ import {
} from "bailian-cli-core";
import { existsSync, statSync } from "fs";
import { basename } from "path";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
/**
* A `--datasets` / `--validations` token is treated as a local file to upload
@@ -208,7 +207,7 @@ async function uploadResolvedLocal(
}
/** The modality a `finetune <modality> create` subcommand is bound to. */
type CommandModality = "text" | "audio" | "image";
type CommandModality = "text" | "audio" | "image" | "video";
/**
* Flags shared by every `finetune <modality> create` subcommand: what to train
@@ -216,10 +215,10 @@ type CommandModality = "text" | "audio" | "image";
* output. Every modality's model consumes these.
*/
const COMMON_FLAGS = {
model: {
baseModel: {
type: "string",
valueHint: "<model>",
description: "Base model to fine-tune",
description: "Base model to fine-tune (e.g. qwen3-8b; not the output model name)",
required: true,
},
datasets: {
@@ -317,13 +316,41 @@ const IMAGE_FLAGS = {
} satisfies FlagsDef;
const TEXT_USAGE =
"--model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
"--base-model <model> --datasets <id|path,...> [--validations <id|path,...>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>] [--max-length <n>] [--training-type <sft|sft-lora|dpo|dpo-lora|cpt>]";
const AUDIO_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>]";
const IMAGE_USAGE =
"--model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--generation-type <t2i|i2i>] [--learning-rate <str>]";
/**
* Video (Wan i2v/kf2v) flags: exposes the three hyper-parameters that the
* video API supports and users may want to override. Defaults are model-specific
* (resolved by the sft-lora profile: wan2.7 → batch_size 1 / max_pixels 102400,
* wan2.5 → 4 / 36864, wan2.2 → 4 / 262144).
*/
const VIDEO_FLAGS = {
...COMMON_FLAGS,
nEpochs: {
type: "number",
valueHint: "<n>",
description: "Training epochs (default: 50)",
},
batchSize: {
type: "number",
valueHint: "<n>",
description: "Batch size (default: model-specific, 1 for wan2.7, 4 for wan2.5/2.2)",
},
learningRate: {
type: "string",
valueHint: "<str>",
description: 'Learning rate as a string to preserve precision (default: "2e-5")',
},
} satisfies FlagsDef;
const VIDEO_USAGE =
"--base-model <model> --datasets <id|path> [--validations <id|path>] [--model-name <name>] [--suffix <text>] [--n-epochs <n>] [--batch-size <n>] [--learning-rate <str>]";
const COMMON_NOTES = [
"Creating a job uploads any local datasets and consumes training quota.",
@@ -383,7 +410,7 @@ async function runCreate<F extends FlagsDef>(
): Promise<void> {
const { identity, settings } = ctx;
const flags = ctx.flags as Record<string, unknown>;
const model = flags.model as string;
const model = flags.baseModel as string;
const datasetsRaw = flags.datasets as string;
// CosyVoice audio fine-tuning accepts exactly one training file
@@ -441,6 +468,10 @@ async function runCreate<F extends FlagsDef>(
if (detected === "image-i2i") modality = "image-i2i";
}
}
if (commandModality === "video" && firstLocalPath && !settings.dryRun) {
const detected = await detectModality(firstLocalPath);
if (detected === "video-kf2v") modality = "video-kf2v";
}
const training = await analyzeDatasetTokens(
settings,
@@ -606,8 +637,6 @@ async function runCreate<F extends FlagsDef>(
if (modelName) body.model_name = modelName;
if (suffix) body.finetuned_output_suffix = suffix;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const pending = [
...training.localPaths.map((path) => ({ field: "datasets", path })),
@@ -617,7 +646,7 @@ async function runCreate<F extends FlagsDef>(
pending.length > 0
? { action: "finetune.create", body, pending_uploads: pending }
: { action: "finetune.create", body },
format,
"json",
);
return;
}
@@ -627,16 +656,8 @@ async function runCreate<F extends FlagsDef>(
if (settings.quiet) {
if (job?.job_id) emitBare(job.job_id);
} else if (format === "text") {
if (job?.job_id) {
emitBare(`Created fine-tune job: ${job.job_id}`);
if (job.status) emitBare(`Status: ${job.status}`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
} else {
emitResult(response, format);
emitResult(response, "json");
}
}
@@ -647,14 +668,14 @@ export const finetuneTextCreate = defineCommand({
usageArgs: TEXT_USAGE,
flags: TEXT_FLAGS,
exampleArgs: [
"--model qwen3-8b --datasets file-xxx",
"--model qwen3-8b --datasets ./train.jsonl",
"--model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
"--model qwen3-8b --datasets file-aaa,./extra.jsonl",
"--model qwen3-8b --datasets ./train.jsonl --training-type sft",
'--model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
"--model qwen3-8b --datasets file-xxx --output json",
"--model qwen3-8b --datasets file-xxx --dry-run",
"--base-model qwen3-8b --datasets file-xxx",
"--base-model qwen3-8b --datasets ./train.jsonl",
"--base-model qwen3-8b --datasets ./train.jsonl --validations ./eval.jsonl",
"--base-model qwen3-8b --datasets file-aaa,./extra.jsonl",
"--base-model qwen3-8b --datasets ./train.jsonl --training-type sft",
'--base-model qwen3-8b --datasets file-xxx --learning-rate "1.6e-5" --n-epochs 4',
"--base-model qwen3-8b --datasets file-xxx --output json",
"--base-model qwen3-8b --datasets file-xxx --dry-run",
],
notes: TEXT_NOTES,
run: (ctx) => runCreate("text", ctx),
@@ -667,11 +688,11 @@ export const finetuneAudioCreate = defineCommand({
usageArgs: AUDIO_USAGE,
flags: AUDIO_FLAGS,
exampleArgs: [
"--model cosyvoice-v3-flash --datasets ./audio.zip",
"--model cosyvoice-v3-flash --datasets file-xxx",
"--model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
"--model cosyvoice-v3-flash --datasets file-xxx --output json",
"--model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip",
"--base-model cosyvoice-v3-flash --datasets file-xxx",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip --model-name my-tts",
"--base-model cosyvoice-v3-flash --datasets file-xxx --output json",
"--base-model cosyvoice-v3-flash --datasets ./audio.zip --dry-run",
],
notes: AUDIO_NOTES,
run: (ctx) => runCreate("audio", ctx),
@@ -684,13 +705,38 @@ export const finetuneImageCreate = defineCommand({
usageArgs: IMAGE_USAGE,
flags: IMAGE_FLAGS,
exampleArgs: [
"--model wan2.7-image-pro --datasets ./images.zip",
"--model wan2.7-image-pro --datasets file-xxx",
"--model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
"--model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
"--model wan2.7-image-pro --datasets file-xxx --output json",
"--model wan2.7-image-pro --datasets ./images.zip --dry-run",
"--base-model wan2.7-image-pro --datasets ./images.zip",
"--base-model wan2.7-image-pro --datasets file-xxx",
"--base-model wan2.7-image-pro --datasets file-xxx --generation-type i2i",
"--base-model wan2.7-image-pro --datasets ./images.zip --model-name my-wan",
"--base-model wan2.7-image-pro --datasets file-xxx --output json",
"--base-model wan2.7-image-pro --datasets ./images.zip --dry-run",
],
notes: IMAGE_NOTES,
run: (ctx) => runCreate("image", ctx),
});
const VIDEO_NOTES = [
...COMMON_NOTES,
"Video generation training (Wan i2v/kf2v) runs efficient_sft with model-",
"specific defaults: wan2.7 (batch_size=1, max_pixels=102400), wan2.5/2.2",
"(batch_size=4, max_pixels per model). Override with --batch-size/--n-epochs.",
"Datasets are .zip archives with data.jsonl + frame images + videos.",
"Recommended: ≥10 training samples, 20-100 for stable results.",
];
/** `bl finetune video create` — fine-tune a video generation model. Datasets are `.zip`. */
export const finetuneVideoCreate = defineCommand({
description: "Create a video generation model fine-tune job (Wan i2v/kf2v, efficient_sft)",
auth: "apiKey",
usageArgs: VIDEO_USAGE,
flags: VIDEO_FLAGS,
exampleArgs: [
"--base-model wan2.7-i2v --datasets file-xxx",
"--base-model wan2.7-i2v --datasets ./i2v-data.zip",
"--base-model wan2.2-kf2v-flash --datasets file-xxx --n-epochs 100",
"--base-model wan2.7-i2v --datasets file-xxx --dry-run",
],
notes: VIDEO_NOTES,
run: (ctx) => runCreate("video", ctx),
});
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, deleteFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const DELETE_FLAGS = {
jobId: {
@@ -23,10 +23,9 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.delete", job_id: jobId }, format);
emitResult({ action: "finetune.delete", job_id: jobId }, "json");
return;
}
@@ -34,11 +33,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(jobId);
} else if (format === "text") {
emitBare(`Deleted ${jobId}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -1,10 +1,5 @@
import {
defineCommand,
detectOutputFormat,
exportCheckpoint,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, exportCheckpoint, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const EXPORT_FLAGS = {
jobId: {
@@ -39,11 +34,10 @@ export default defineCommand({
"explicit export is the canonical path for non-best checkpoints.",
],
async run(ctx) {
const { identity, settings, flags } = ctx;
const { settings, flags } = ctx;
const jobId = flags.jobId;
const checkpoint = flags.checkpoint;
const modelName = flags.modelName;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -53,7 +47,7 @@ export default defineCommand({
checkpoint,
model_name: modelName,
},
format,
"json",
);
return;
}
@@ -64,14 +58,8 @@ export default defineCommand({
if (settings.quiet) {
emitBare(exported);
} else if (format === "text") {
emitBare(`Exported ${jobId} / ${checkpoint} → model_name=${exported}`);
emitBare(
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
emitResult(response, "json");
}
},
});
@@ -0,0 +1,105 @@
/**
* Best-effort actual training fee calculation using the model catalog's
* "ft" (fine-tune) price entry. Pure API-key domain — no console auth needed.
*
* The model catalog (`listFoundationModels` via public gateway) returns a
* `prices[]` array **only when `queryPrice: true` is passed** (the same flag
* `fetchModelDetail` uses). Combined with the job's `output.usage` (actual
* consumed tokens, present on SUCCEEDED / CANCELED), this gives the exact
* training cost without any console-domain login.
*/
import {
callConsoleGateway,
effectiveConsoleGatewayConfig,
unwrapResponse,
MODEL_LIST_API,
type Settings,
type ModelPriceInfo,
} from "bailian-cli-core";
export interface ActualFee {
cost: number;
unitPrice: number;
priceUnit: string;
}
/**
* Fetch the model's training price from the public catalog gateway.
* Uses the same anonymous gateway path as `fetchModelCapability` (no console
* token required), but adds `queryPrice: true` to include the prices array.
*/
async function fetchTrainingPrice(
settings: Settings,
model: string,
): Promise<ModelPriceInfo | null> {
const eff = effectiveConsoleGatewayConfig(settings);
const result = await callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
settings.timeout,
{
api: MODEL_LIST_API,
data: {
input: {
pageNo: 1,
pageSize: 10,
group: true,
model,
queryPrice: true,
querySampleCode: false,
queryGroupByModel: true,
queryQuota: false,
queryQpmInfo: false,
queryApplyStatus: false,
queryPermissions: false,
queryActivationStatus: false,
},
},
},
);
const responseData = unwrapResponse(result as Record<string, unknown>);
const list = (responseData.list as Record<string, unknown>[]) ?? [];
// The response is grouped; find the exact model in items.
for (const group of list) {
const items = (group.items as Record<string, unknown>[]) ?? [];
for (const item of items) {
if (item.model === model) {
const prices = (item.prices as ModelPriceInfo[]) ?? [];
return prices.find((entry) => entry.type === "ft") ?? null;
}
}
// Flat response fallback (no items nesting).
if (group.model === model) {
const prices = (group.prices as ModelPriceInfo[]) ?? [];
return prices.find((entry) => entry.type === "ft") ?? null;
}
}
return null;
}
/**
* Compute the actual training fee from the model catalog's "ft" price entry.
* Returns null when the price is unavailable (network error, model not in
* catalog, or no "ft" entry). Never throws.
*
* Only uses the public model catalog (model metadata) — does NOT call
* console-domain pricing APIs (modelCenter.getModelPrice). Models whose
* catalog entry lacks a "ft" price (e.g. CosyVoice) will simply omit the
* training_cost field until the platform adds it to the catalog.
*/
export async function computeActualFee(
settings: Settings,
model: string,
usageTokens: number,
): Promise<ActualFee | null> {
try {
const ftEntry = await fetchTrainingPrice(settings, model);
const unitPrice = Number(ftEntry?.price);
if (!Number.isFinite(unitPrice) || unitPrice <= 0) return null;
const priceUnit = ftEntry?.priceUnit ?? "每百万tokens";
// Catalog price is yuan per million tokens.
const cost = (usageTokens / 1_000_000) * unitPrice;
return { cost: Number(cost.toFixed(4)), unitPrice, priceUnit };
} catch {
return null;
}
}
+29 -32
View File
@@ -1,5 +1,6 @@
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { defineCommand, getFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { computeActualFee } from "./fee.ts";
const GET_FLAGS = {
jobId: {
@@ -17,12 +18,11 @@ export default defineCommand({
flags: GET_FLAGS,
exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --output json"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const { settings, flags } = ctx;
const jobId = flags.jobId;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ action: "finetune.get", job_id: jobId }, format);
emitResult({ action: "finetune.get", job_id: jobId }, "json");
return;
}
@@ -30,18 +30,24 @@ export default defineCommand({
const job = response.output ?? response.data;
if (!job) {
emitBare(`No data returned for ${jobId}`);
emitResult({ job_id: jobId, error: "No data returned" }, "json");
return;
}
const hp = job.hyper_parameters;
const hyperParameters = job.hyper_parameters;
const hyperParts: string[] = [];
if (hp?.n_epochs !== undefined) hyperParts.push(`n_epochs=${hp.n_epochs}`);
if (hp?.batch_size !== undefined) hyperParts.push(`batch_size=${hp.batch_size}`);
if (hp?.learning_rate !== undefined) hyperParts.push(`learning_rate=${hp.learning_rate}`);
if (hp?.max_length !== undefined) hyperParts.push(`max_length=${hp.max_length}`);
if (hyperParameters?.n_epochs !== undefined)
hyperParts.push(`n_epochs=${hyperParameters.n_epochs}`);
if (hyperParameters?.batch_size !== undefined)
hyperParts.push(`batch_size=${hyperParameters.batch_size}`);
if (hyperParameters?.learning_rate !== undefined)
hyperParts.push(`learning_rate=${hyperParameters.learning_rate}`);
if (hyperParameters?.max_length !== undefined)
hyperParts.push(`max_length=${hyperParameters.max_length}`);
const item = {
const usageTokens = typeof job.usage === "number" ? job.usage : undefined;
const item: Record<string, unknown> = {
job_id: job.job_id ?? jobId,
base_model: job.model ?? "",
status: job.status ?? "",
@@ -53,29 +59,20 @@ export default defineCommand({
model_name: job.model_name ?? "",
created_at: job.create_time ?? job.gmt_create ?? "",
updated_at: job.end_time ?? job.gmt_modified ?? "",
usage_tokens: usageTokens ?? "",
charge_type: typeof job.charge_type === "string" ? job.charge_type : "",
};
if (format === "json") {
emitResult({ ...item, request_id: response.request_id }, format);
return;
// Actual fee: only when the platform reports a concrete token count
// (SUCCEEDED / CANCELED). Best-effort — silently omitted on lookup failure.
if (usageTokens !== undefined && usageTokens > 0 && job.model) {
const fee = await computeActualFee(settings, job.model, usageTokens);
if (fee) {
item.training_cost = fee.cost;
item.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
// text / quiet
emitBare(`job_id: ${item.job_id}`);
if (item.base_model) emitBare(`base_model: ${item.base_model}`);
if (item.status) emitBare(`status: ${item.status}`);
if (item.training_type) emitBare(`training_type: ${item.training_type}`);
if (item.training_files.length) emitBare(`training_files: ${item.training_files.join(", ")}`);
if (item.validation_files.length)
emitBare(`validation_files: ${item.validation_files.join(", ")}`);
if (item.hyper_params) emitBare(`hyper_params: ${item.hyper_params}`);
if (item.output_model)
emitBare(
`output_model: ${item.output_model} (→ ${identity.binName} deploy text create --model)`,
);
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`);
emitRequestId(response.request_id, settings.quiet);
emitResult({ ...item, request_id: response.request_id }, "json");
},
});
+24 -47
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
import { defineCommand, listFineTunes, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -13,71 +13,48 @@ const LIST_FLAGS = {
valueHint: "<s>",
description: "Filter by status (PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED)",
},
baseModel: {
type: "string",
valueHint: "<model>",
description: "Filter by base model ID (server-side)",
},
} satisfies FlagsDef;
export default defineCommand({
description: "List fine-tune jobs",
auth: "apiKey",
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>]",
usageArgs: "[--page <n>] [--page-size <n>] [--status <s>] [--base-model <model>]",
flags: LIST_FLAGS,
exampleArgs: ["", "--status RUNNING", "--page-size 20 --output json"],
exampleArgs: ["", "--status RUNNING", "--base-model qwen3-8b", "--page-size 20"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const { settings, flags } = ctx;
const pageNo = flags.page;
const pageSize = flags.pageSize;
const status = flags.status || undefined;
const model = flags.baseModel || undefined;
if (settings.dryRun) {
emitResult({ action: "finetune.list", page: pageNo, page_size: pageSize, status }, format);
emitResult(
{ action: "finetune.list", page: pageNo, page_size: pageSize, status, model },
"json",
);
return;
}
const response = await listFineTunes(ctx.client, { pageNo, pageSize, status });
const response = await listFineTunes(ctx.client, { pageNo, pageSize, status, model });
const payload = response.output ?? response.data;
const jobs = payload?.jobs ?? [];
const total = payload?.total;
const items = jobs.map((item) => ({
job_id: item.job_id ?? "",
base_model: item.model ?? "",
status: item.status ?? "",
training_type: item.training_type ?? "",
output_model: item.finetuned_output ?? "",
created_at: item.create_time ?? item.gmt_create ?? "",
const items = jobs.map((job) => ({
job_id: job.job_id ?? "",
base_model: job.model ?? "",
status: job.status ?? "",
training_type: job.training_type ?? "",
output_model: job.finetuned_output ?? "",
created_at: job.create_time ?? job.gmt_create ?? "",
}));
if (format === "json") {
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
// text / quiet
if (items.length === 0) {
emitBare("No fine-tune jobs found.");
return;
}
const headers = [
"JOB_ID",
"BASE_MODEL",
"STATUS",
"TRAINING_TYPE",
"OUTPUT_MODEL",
"CREATED_AT",
];
const rows = items.map((i) => [
i.job_id,
i.base_model,
i.status,
i.training_type,
i.output_model,
i.created_at,
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitBare(
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
);
emitRequestId(response.request_id, settings.quiet);
emitResult({ items, total, request_id: response.request_id }, "json");
},
});
+15 -44
View File
@@ -1,25 +1,24 @@
import {
defineCommand,
detectOutputFormat,
getFineTuneLogs,
type Client,
type FineTuneLogEntry,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult } from "bailian-cli-runtime";
/**
* Render a single log entry as a single line (mirrors the flatten logic used
* for non-search text output: prefer common fields, fall back to JSON).
* Render a single log entry as a single line (used for search matching:
* prefer common fields, fall back to JSON).
*/
function renderEntry(entry: FineTuneLogEntry | string): string {
if (typeof entry === "string") return entry;
const record = entry as Record<string, unknown>;
const ts = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
const timestamp = (record.timestamp ?? record.time ?? record.create_time ?? "") as string;
const level = (record.level ?? "") as string;
const msg = (record.message ?? record.msg ?? record.log ?? "") as string;
if (msg || ts || level) {
return [ts, level, msg].filter(Boolean).join("\t");
const message = (record.message ?? record.msg ?? record.log ?? "") as string;
if (message || timestamp || level) {
return [timestamp, level, message].filter(Boolean).join("\t");
}
return JSON.stringify(entry);
}
@@ -48,16 +47,16 @@ async function fetchAllLogs(
let total = 0;
// Hard cap to avoid an unbounded loop if the server misreports `total`.
const maxPages = 200;
for (let i = 0; i < maxPages; i++) {
for (let page = 0; page < maxPages; page++) {
const response = await getFineTuneLogs(client, jobId, { pageNo, pageSize });
const payload = response.output ?? response.data;
const page = payload?.logs ?? [];
const logs = payload?.logs ?? [];
total = payload?.total ?? total;
if (page.length === 0) break;
entries.push(...page);
if (logs.length === 0) break;
entries.push(...logs);
// Stop once we've collected everything the server claims exists.
if (total && entries.length >= total) break;
if (page.length < pageSize) break;
if (logs.length < pageSize) break;
pageNo++;
}
return { entries, total };
@@ -110,7 +109,6 @@ export default defineCommand({
const pageSize = flags.pageSize;
const search = flags.search || undefined;
const tail = flags.tail;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -122,7 +120,7 @@ export default defineCommand({
search,
tail,
},
format,
"json",
);
return;
}
@@ -147,18 +145,6 @@ export default defineCommand({
const result =
tailApplied !== undefined ? scanned.slice(scanned.length - tailApplied) : scanned;
if (settings.quiet || format === "text") {
if (result.length === 0) {
emitBare(search ? `No logs matched "${search}".` : "No logs returned.");
return;
}
for (const entry of result) emitBare(renderEntry(entry));
const parts: string[] = [`${result.length} shown`];
if (matched !== undefined) parts.push(`matched ${matched}`);
parts.push(`of ${entries.length}` + (total ? ` (total ${total})` : ""));
emitBare(`\n${parts.join(", ")}`);
return;
}
emitResult(
{
...(matched !== undefined ? { matched } : {}),
@@ -168,28 +154,13 @@ export default defineCommand({
...(tailApplied !== undefined ? { tail: tailApplied } : {}),
logs: result,
},
format,
"json",
);
return;
}
// Default: single page, verbatim response.
const response = await getFineTuneLogs(ctx.client, jobId, { pageNo, pageSize });
const payload = response.output ?? response.data;
const logs = payload?.logs ?? [];
if (settings.quiet || format === "text") {
if (logs.length === 0) {
emitBare("No logs returned.");
return;
}
for (const entry of logs) {
emitBare(renderEntry(entry));
}
if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
emitResult(response, "json");
},
});
@@ -0,0 +1,139 @@
import {
defineCommand,
fetchTrainingModelPrice,
estimateSftDpoTokens,
estimateCptTokens,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const PRICE_FLAGS = {
baseModel: {
type: "string",
valueHint: "<model>",
description: "Base model to fine-tune (e.g. qwen3-8b; not the output model name)",
required: true,
},
datasets: {
type: "string",
valueHint: "<ids>",
description: "Training dataset file IDs, comma-separated (required)",
required: true,
},
trainingType: {
type: "string",
valueHint: "<type>",
description: "Training type: sft | dpo | cpt (default: sft)",
},
nEpochs: {
type: "number",
valueHint: "<n>",
description: "Number of training epochs (default: 3)",
},
} satisfies FlagsDef;
const SUPPORTED_TRAINING_TYPES = ["sft", "dpo", "cpt"];
// Fixed hyper-parameters used for estimation. Only n_epochs materially affects
// the estimate; the rest are held at representative defaults (not exposed as
// flags to keep the command surface minimal).
const ESTIMATE_BATCH_SIZE = 16;
const ESTIMATE_MAX_LENGTH = 8192;
const DEFAULT_N_EPOCHS = 3;
export default defineCommand({
description: "Estimate the training cost for a fine-tune job (token billing)",
auth: "console",
usageArgs: "--base-model <model> --datasets <ids> [--training-type <type>] [--n-epochs <n>]",
flags: PRICE_FLAGS,
exampleArgs: [
"--base-model qwen3-8b --datasets file-ft-xxx",
"--base-model qwen3-8b --datasets file-ft-xxx,file-ft-yyy --n-epochs 2",
"--base-model qwen3-8b --datasets file-ft-xxx --training-type cpt",
],
notes: [
"Estimate only — the server computes token usage from the datasets; final cost is subject to the bill.",
"Covers token billing for sft / dpo / cpt. Training-unit (MTU) billing is not supported by this command.",
"Hyper-parameters other than --n-epochs are fixed at representative defaults for estimation.",
],
async run(ctx) {
const { settings, flags } = ctx;
const model = flags.baseModel;
const datasetIds = flags.datasets
.split(",")
.map((datasetId) => datasetId.trim())
.filter(Boolean);
const trainingType = (flags.trainingType ?? "sft").toLowerCase();
const nEpochs = flags.nEpochs ?? DEFAULT_N_EPOCHS;
if (!SUPPORTED_TRAINING_TYPES.includes(trainingType)) {
throw new BailianError(
`Unsupported training type "${trainingType}". Supported: ${SUPPORTED_TRAINING_TYPES.join(", ")}.`,
ExitCode.USAGE,
);
}
if (datasetIds.length === 0) {
throw new BailianError("--datasets must contain at least one file ID.", ExitCode.USAGE);
}
if (settings.dryRun) {
emitResult(
{ action: "finetune.price", model, datasets: datasetIds, trainingType, nEpochs },
"json",
);
return;
}
// Unit price (yuan per 千Token).
const priceInfo = await fetchTrainingModelPrice(ctx.client, model);
const unitPrice = Number(priceInfo.price);
if (!Number.isFinite(unitPrice)) {
throw new BailianError(
`No training price found for model "${model}".`,
ExitCode.GENERAL,
undefined,
{ rawResponse: JSON.stringify(priceInfo) },
);
}
// Per-epoch token estimate (min/max range).
const estimate =
trainingType === "cpt"
? await estimateCptTokens(ctx.client, model, datasetIds.join(","), nEpochs)
: await estimateSftDpoTokens(ctx.client, datasetIds, {
nEpochs,
batchSize: ESTIMATE_BATCH_SIZE,
maxLength: ESTIMATE_MAX_LENGTH,
});
const minPerEpoch = estimate.estimatedDatasetConsumedTokensMinPerEpoch ?? 0;
const maxPerEpoch = estimate.estimatedDatasetConsumedTokensMaxPerEpoch ?? 0;
const mixedMinPerEpoch = estimate.estimatedMixedConsumedTokensMinPerEpoch ?? 0;
const mixedMaxPerEpoch = estimate.estimatedMixedConsumedTokensMaxPerEpoch ?? 0;
const minTokens = (minPerEpoch + mixedMinPerEpoch) * nEpochs;
const maxTokens = (maxPerEpoch + mixedMaxPerEpoch) * nEpochs;
// price is yuan per 1000 tokens.
const minFee = (minTokens / 1000) * unitPrice;
const maxFee = (maxTokens / 1000) * unitPrice;
emitResult(
{
model,
training_type: trainingType,
n_epochs: nEpochs,
unit_price: unitPrice,
price_unit: priceInfo.priceUnit ?? "千Token",
estimated_tokens: { min: minTokens, max: maxTokens },
estimated_fee_yuan: {
min: Number(minFee.toFixed(4)),
max: Number(maxFee.toFixed(4)),
},
disclaimer: "Server-side estimate; final cost is subject to the bill.",
},
"json",
);
},
});
@@ -1,12 +1,12 @@
import {
defineCommand,
detectOutputFormat,
getFineTune,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { computeActualFee } from "./fee.ts";
const DEFAULT_INTERVAL_SEC = 10;
const MIN_INTERVAL_SEC = 1;
@@ -103,7 +103,6 @@ export default defineCommand({
const follow = flags.follow;
const intervalSec = Math.max(MIN_INTERVAL_SEC, flags.interval ?? DEFAULT_INTERVAL_SEC);
const pollTimeoutSec = flags.pollTimeout;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
@@ -114,7 +113,7 @@ export default defineCommand({
interval: intervalSec,
timeout: pollTimeoutSec,
},
format,
"json",
);
return;
}
@@ -132,16 +131,24 @@ export default defineCommand({
if (settings.quiet) {
// Just the status word — ideal for `status=$(... finetune watch ... --quiet)`.
emitBare(status || "UNKNOWN");
} else if (format === "text") {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (status === "SUCCEEDED") emitBare(`${jobId} ${status}`);
emitRequestId(response.request_id, settings.quiet);
} else {
// json: a compact, purpose-built status probe.
emitResult(
{ job_id: jobId, status: status || "UNKNOWN", terminal, request_id: response.request_id },
format,
);
const output: Record<string, unknown> = {
job_id: jobId,
status: status || "UNKNOWN",
terminal,
request_id: response.request_id,
};
// Enrich terminal output with actual fee when usage is reported.
const usageTokens = typeof job?.usage === "number" ? job.usage : undefined;
if (terminal && usageTokens && usageTokens > 0 && job?.model) {
output.usage_tokens = usageTokens;
const fee = await computeActualFee(settings, job.model as string, usageTokens);
if (fee) {
output.training_cost = fee.cost;
output.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
emitResult(output, "json");
}
if (terminal && status !== "SUCCEEDED") {
@@ -168,18 +175,28 @@ export default defineCommand({
const job = response.output ?? response.data;
const status = String(job?.status ?? "").toUpperCase();
if (format === "text" && !settings.quiet && status !== lastStatus) {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (!settings.quiet && status !== lastStatus) {
process.stderr.write(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}\n`);
lastStatus = status;
}
if (TERMINAL_STATUSES.has(status)) {
const elapsed = Date.now() - startedAt;
if (format !== "text" || settings.quiet) {
emitResult(response, format);
} else if (status === "SUCCEEDED") {
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
emitRequestId(response.request_id, settings.quiet);
if (settings.quiet) {
emitBare(status || "UNKNOWN");
} else {
// Enrich the raw response with actual fee when usage is available.
const usageTokens = typeof job?.usage === "number" ? job.usage : undefined;
const enriched: Record<string, unknown> = { ...response };
if (usageTokens && usageTokens > 0 && job?.model) {
const fee = await computeActualFee(settings, job.model as string, usageTokens);
if (fee) {
enriched.training_cost = fee.cost;
enriched.usage_tokens = usageTokens;
enriched.cost_basis = `${fee.unitPrice} 元/${fee.priceUnit}`;
}
}
emitResult(enriched, "json");
}
if (status !== "SUCCEEDED") {
throw new BailianError(
@@ -205,7 +222,7 @@ export default defineCommand({
// Any other error (including the BailianError thrown above) propagates to
// the central handler.
if (controller.signal.aborted) {
emitBare("\nInterrupted.");
process.stderr.write("\nInterrupted.\n");
return;
}
throw error;
@@ -1,11 +1,11 @@
import { BailianError } from "bailian-cli-core";
import { BailianError, isStreamableHttpUnsupported } from "bailian-cli-core";
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
/** Detect MCP-not-activated / invalid 404 errors (CLI-wrapped server message). */
export function isMcpNotActivated(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
const message = error.message;
if (!/MCP request failed:\s*404\b/i.test(message)) return false;
if (!/^MCP request failed:\s*404\b/i.test(message)) return false;
return /未开通|MCP不存在|MCP_IS_INVALID/i.test(message);
}
@@ -26,14 +26,28 @@ export function mcpActivateHint(serverCode: string): string {
/**
* For not-activated errors, keep the original message / exitCode and append a hint only.
* Do not replace the server error message.
* WebSearch + 405 streamableHttp: do not fall back; attach a re-activate / upgrade hint.
*/
export function rethrowWithMcpActivateHint(error: unknown, serverCode: string): never {
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
if (!(error instanceof BailianError) || error.hint) {
throw error;
}
if (isMcpNotActivated(error)) {
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
if (serverCode === "WebSearch" && isStreamableHttpUnsupported(error)) {
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
throw error;
}
+11 -7
View File
@@ -36,7 +36,8 @@ const CALL_FLAGS = {
url: {
type: "string",
valueHint: "<url>",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
},
} satisfies FlagsDef;
type CallFlags = ParsedFlags<typeof CALL_FLAGS>;
@@ -114,14 +115,14 @@ export default defineCommand({
const { serverCode, toolName } = parseTarget(flags.target);
const toolArgs = buildToolArgs(flags);
const url = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
server: serverCode,
url,
url: previewUrl,
tool: toolName,
arguments: toolArgs,
},
@@ -130,13 +131,14 @@ export default defineCommand({
return;
}
const client = ctx.client.mcp(url);
let client: { close?(): void } | undefined;
try {
await client.initialize();
const result = await client.callTool(toolName, toolArgs);
const connected = await ctx.client.connectBailianMcp(serverCode, flags.url);
client = connected.client;
const result = await connected.client.callTool(toolName, toolArgs);
if (result.isError) {
const errText = result.content.map((c) => c.text || "").join("\n");
const errText = result.content.map((contentItem) => contentItem.text || "").join("\n");
throw new BailianError(`Tool error: ${errText}`);
}
@@ -146,6 +148,8 @@ export default defineCommand({
rethrowWithMcpActivateHint(error, serverCode);
}
throw error;
} finally {
client?.close?.();
}
},
});
+11 -7
View File
@@ -16,7 +16,8 @@ export default defineCommand({
url: {
type: "string",
valueHint: "<url>",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
},
},
exampleArgs: [
@@ -28,24 +29,27 @@ export default defineCommand({
const { settings, flags } = ctx;
const code = flags.server;
const url = flags.url || ctx.client.url(bailianMcpPath(code));
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(code));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ server: code, url, action: "tools/list" }, format);
emitResult({ server: code, url: previewUrl, action: "tools/list" }, format);
return;
}
const client = ctx.client.mcp(url);
let client: { close?(): void } | undefined;
try {
await client.initialize();
const tools = await client.listTools();
emitResult({ server: code, url, tools }, format);
const connected = await ctx.client.connectBailianMcp(code, flags.url);
client = connected.client;
const tools = await connected.client.listTools();
emitResult({ server: code, url: connected.url, tools }, format);
} catch (error) {
if (!flags.url) {
rethrowWithMcpActivateHint(error, code);
}
throw error;
} finally {
client?.close?.();
}
},
});
+9 -7
View File
@@ -1,4 +1,5 @@
import {
anonymousConsoleCall,
defineCommand,
detectOutputFormat,
fetchModelDetail,
@@ -290,7 +291,7 @@ function printPredictConfigTable(entries: PredictConfigEntry[]): void {
export default defineCommand({
description: "Browse model families or show detailed model info in the Bailian model marketplace",
auth: "console",
auth: "none",
usageArgs:
"[--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]",
flags: LIST_FLAGS,
@@ -302,10 +303,14 @@ export default defineCommand({
"--model qwen-max --enrich --output json",
"--feature function-calling --output json",
],
notes: [
"Both the catalog and --enrich parameter-schema endpoints are public — no console login needed.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
const modelKey = flags.model;
const call = anonymousConsoleCall(settings);
// ── Detail mode ──
if (modelKey) {
@@ -316,7 +321,7 @@ export default defineCommand({
return;
}
const detail = await fetchModelDetail(ctx.client.console.bind(ctx.client), modelKey);
const detail = await fetchModelDetail(call, modelKey);
if (!detail) {
emitBare(`Model "${modelKey}" not found.`);
@@ -328,10 +333,7 @@ export default defineCommand({
await Promise.all(
trunkItems.map(async (item) => {
if (!item.model) return;
const config = await fetchPredictConfig(
ctx.client.console.bind(ctx.client),
item.model,
);
const config = await fetchPredictConfig(call, item.model);
if (config) item.predictConfig = config;
}),
);
@@ -361,7 +363,7 @@ export default defineCommand({
return;
}
const { total, groups } = await fetchModelGroups(ctx.client.console.bind(ctx.client), params);
const { total, groups } = await fetchModelGroups(call, params);
if (format === "json") {
emitResult(formatBrowseJson(groups, total), format);
@@ -0,0 +1,41 @@
import { defineCommand } from "bailian-cli-core";
import { runPermissionChange, validatePermissionChange } from "./shared.ts";
export default defineCommand({
description: "Grant model permissions (inference / finetune / deploy)",
auth: "apiKey",
usageArgs: "--model <models> [--action <actions>] | --all",
flags: {
model: {
type: "string",
valueHint: "<models>",
description: "Model ID(s), comma-separated (max 20)",
},
action: {
type: "string",
valueHint: "<actions>",
description:
"Permission action(s), comma-separated: inference, finetune, deploy (default: inference)",
},
all: {
type: "switch",
description:
"One-key grant inference for all models in the workspace (including future ones)",
},
},
exampleArgs: [
"--model qwen-plus",
"--model qwen-plus,qwen3-max --action inference,finetune",
"--all",
"--model qwen-plus --dry-run --output json",
],
notes: [
"Grants apply to the business workspace your API key belongs to.",
"--all maps to the server one-key switch (access_all_entities: OPEN) and only covers inference.",
"Actions you omit keep their current grants (server-side tri-state patch).",
],
validate: (flags) => validatePermissionChange(flags),
async run(ctx) {
await runPermissionChange(ctx, ctx.flags, true);
},
});
@@ -0,0 +1,145 @@
import { defineCommand, detectOutputFormat, modelsPermissionsPath } from "bailian-cli-core";
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
// ---------------------------------------------------------------------------
// Types — mirror GET /api/v1/models/permissions
// ---------------------------------------------------------------------------
interface PermissionDetail {
inference?: boolean | null;
fine_tune?: boolean | null;
deploy?: boolean | null;
}
interface ModelPermission {
model: string;
name?: string;
permissions?: PermissionDetail;
}
interface PermissionsResponse {
output?: {
total?: number;
page_no?: number;
page_size?: number;
permissions?: ModelPermission[];
};
request_id?: string;
}
// ---------------------------------------------------------------------------
// Formatters
// ---------------------------------------------------------------------------
/** Tri-state permission cell: true → yes, false → no, null/undefined → "-". */
function formatGrant(granted: boolean | null | undefined): string {
if (granted == null) return "-";
return granted ? "yes" : "no";
}
function printTable(permissions: ModelPermission[], total: number, emptyHint: string): void {
if (permissions.length === 0) {
process.stdout.write(`No model permissions found.\n${emptyHint}\n`);
return;
}
const headers = ["Model", "Name", "Inference", "Fine-tune", "Deploy"];
const rows = permissions.map((entry) => [
entry.model,
entry.name ?? "-",
formatGrant(entry.permissions?.inference),
formatGrant(entry.permissions?.fine_tune),
formatGrant(entry.permissions?.deploy),
]);
const lines = renderBoxTable({
headers,
rows,
align: ["left", "left", "right", "right", "right"],
});
for (const line of lines) process.stdout.write(line + "\n");
process.stdout.write(`\nTotal: ${total}\n`);
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
export default defineCommand({
description: "List model permissions (inference / fine-tune / deploy) in the workspace",
auth: "apiKey",
usageArgs: "[--scope <scope>] [--model <model>] [--name <name>] [--page <n>] [--page-size <n>]",
flags: {
scope: {
type: "string",
valueHint: "<scope>",
choices: ["authorized", "authorizable"] as const,
description: "Authorization scope: authorizable (default, full catalog), authorized",
},
model: {
type: "string",
valueHint: "<model>",
description: "Model ID (exact match)",
},
name: {
type: "string",
valueHint: "<name>",
description: "Fuzzy search by model name or ID",
},
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 20)" },
},
exampleArgs: [
"",
"--model qwen-plus",
"--scope authorized",
"--name qwen --page-size 50",
"--output text",
],
notes: [
"Default scope is `authorizable` (the full grantable catalog); use `--scope authorized` to see only models already granted.",
"Output defaults to JSON; pass `--output text` for a table. Permission values are tri-state: true / false / null (never set).",
"Values mirror the server's grant records as-is for the workspace bound to your API key. A model reporting false/null can still be callable (access may come from other channels); see the Model Studio authorization docs for the exact semantics.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
const scope = flags.scope ?? "authorizable";
const query = {
authorization_scope: scope.toUpperCase(),
model: flags.model || undefined,
name: flags.name || undefined,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
};
if (settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsPermissionsPath()), method: "GET", query },
format,
);
return;
}
const resp = await ctx.client.requestJson<PermissionsResponse>({
path: modelsPermissionsPath() + buildQuery(query),
});
const permissions = resp.output?.permissions ?? [];
const total = resp.output?.total ?? permissions.length;
if (format === "json") {
emitResult({ items: permissions, total }, format);
return;
}
// The default authorized view is empty until something is granted — point
// at the authorizable catalog instead of ending with a bare "nothing".
const binName = ctx.identity.binName;
const emptyHint =
scope === "authorized"
? `Nothing granted yet in this workspace. Browse grantable models with \`${binName} permission list --scope authorizable\`, then grant with \`${binName} permission grant --model <model>\`.`
: `Adjust --name/--model filters, or check pagination with --page/--page-size.`;
printTable(permissions, total, emptyHint);
},
});
@@ -0,0 +1,52 @@
import { defineCommand, BailianError, ExitCode } from "bailian-cli-core";
import { runPermissionChange, validatePermissionChange } from "./shared.ts";
export default defineCommand({
description: "Revoke model permissions (inference / finetune / deploy)",
auth: "apiKey",
usageArgs: "--model <models> [--action <actions>] | --all --yes",
flags: {
model: {
type: "string",
valueHint: "<models>",
description: "Model ID(s), comma-separated (max 20)",
},
action: {
type: "string",
valueHint: "<actions>",
description:
"Permission action(s), comma-separated: inference, finetune, deploy (default: inference)",
},
all: {
type: "switch",
description: "Close one-key authorization and clear ALL historical inference grants",
},
yes: {
type: "switch",
description: "Confirm --all without an interactive prompt (required)",
},
},
exampleArgs: [
"--model qwen-plus",
"--model qwen-plus,qwen3-max --action inference,finetune",
"--all --yes",
"--model qwen-plus --dry-run --output json",
],
notes: [
"Grants apply to the business workspace your API key belongs to.",
"--all maps to the server one-key switch (access_all_entities: CLOSE): it clears every historical inference grant and cannot be undone, so it requires --yes.",
"Actions you omit keep their current grants (server-side tri-state patch).",
],
validate: (flags) => validatePermissionChange(flags),
async run(ctx) {
const { flags, settings } = ctx;
if (flags.all && !flags.yes && !settings.dryRun) {
throw new BailianError(
"Refusing to clear all historical inference grants without confirmation.",
ExitCode.USAGE,
"Re-run with --yes to close one-key authorization (or preview with --dry-run).",
);
}
await runPermissionChange(ctx, flags, false);
},
});
@@ -0,0 +1,109 @@
import {
detectOutputFormat,
modelsPermissionsPath,
type Client,
type Settings,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { parseCommaList } from "../shared/params.ts";
// POST /api/v1/models/permissions accepts at most 20 models per call.
export const MAX_MODELS_PER_REQUEST = 20;
// POST body field names (server ignores unknown keys silently — the docs' curl
// example spells `fine_tune`, but only `finetune` actually takes effect).
export const PERMISSION_ACTIONS = ["inference", "finetune", "deploy"] as const;
export type PermissionAction = (typeof PERMISSION_ACTIONS)[number];
/** Parse --action into deduped actions (default: inference); returns an error message on bad values. */
export function parsePermissionActions(
actionFlag: string | undefined,
): PermissionAction[] | { error: string } {
if (!actionFlag) return ["inference"];
const actions = parseCommaList(actionFlag);
if (actions.length === 0) return { error: "--action must not be empty." };
for (const action of actions) {
if (!(PERMISSION_ACTIONS as readonly string[]).includes(action)) {
return { error: `--action "${action}" is invalid; use ${PERMISSION_ACTIONS.join(", ")}.` };
}
}
return actions as PermissionAction[];
}
/** Cross-flag validation shared by grant and revoke. */
export function validatePermissionChange(flags: {
model?: string;
action?: string;
all: boolean;
}): string | undefined {
if (flags.all && flags.model) return "--all cannot be combined with --model.";
if (!flags.all && !flags.model) return "one of --model / --all is required.";
const actions = parsePermissionActions(flags.action);
if ("error" in actions) return actions.error;
if (flags.all && (actions.length !== 1 || actions[0] !== "inference"))
return "--all only supports the inference action.";
if (flags.model) {
const models = parseCommaList(flags.model);
if (models.length === 0) return "--model must not be empty.";
if (models.length > MAX_MODELS_PER_REQUEST)
return `--model accepts at most ${MAX_MODELS_PER_REQUEST} models per call.`;
}
}
/**
* Shared grant/revoke execution: build the POST body (per-model tri-state
* patch, or the access_all_entities one-key switch) and send it. Validation
* (mutual exclusion, action values, model count) has already run.
*/
export async function runPermissionChange(
ctx: { settings: Settings; client: Client },
flags: { model?: string; action?: string; all: boolean },
grant: boolean,
): Promise<void> {
const format = ctx.settings.outputExplicit ? detectOutputFormat(ctx.settings.output) : "json";
const actions = parsePermissionActions(flags.action) as PermissionAction[];
const models = flags.model ? parseCommaList(flags.model) : [];
const body: Record<string, unknown> = flags.all
? { access_all_entities: grant ? "OPEN" : "CLOSE" }
: {
models: models.map((model) => {
const entry: Record<string, unknown> = { model };
for (const action of actions) entry[action] = grant;
return entry;
}),
};
if (ctx.settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsPermissionsPath()), method: "POST", request: body },
format,
);
return;
}
const result = await ctx.client.requestJson<{ request_id?: string }>({
path: modelsPermissionsPath(),
method: "POST",
body,
});
const verb = grant ? "granted" : "revoked";
if (format === "json") {
const summary: Record<string, unknown> = flags.all
? { all: true, action: "inference" }
: { models, actions };
emitResult({ ...summary, [verb]: true, request_id: result.request_id }, format);
return;
}
if (flags.all) {
process.stdout.write(
grant
? "Inference permission granted for all models in the workspace (including future ones).\n"
: "One-key authorization closed; historical inference grants cleared.\n",
);
return;
}
process.stdout.write(`Permissions ${verb} (${actions.join(", ")}): ${models.join(", ")}\n`);
}
@@ -1,6 +1,7 @@
import { defineCommand, detectOutputFormat, BailianError, ExitCode } from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
const HISTORY_API = "zeldaEasy.broadscope-platform.modelInstance.listModelLimitApplications";
@@ -49,10 +50,6 @@ function formatDateTime(ts: string | undefined): string {
}
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
function printTable(records: LimitApplicationItem[], total: number): void {
const color = ansi(process.stdout);
+142 -244
View File
@@ -1,297 +1,195 @@
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
unwrapResponse,
MODEL_LIST_API,
type Client,
} from "bailian-cli-core";
import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core";
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
import { buildQuery, parseCommaList } from "../shared/params.ts";
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
// ---------------------------------------------------------------------------
// Types — mirror GET /api/v1/models/limits
// ---------------------------------------------------------------------------
interface QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
interface LimitSpec {
request_limit: number | null;
request_limit_period: number | null;
usage_limit: number | null;
usage_limit_field: string | null;
usage_limit_period: number | null;
async_user_queue_limit: number | null;
async_user_concurrency_limit: number | null;
}
interface ModelWithQpm {
interface ModelQuota {
model: string;
qpmInfo?: Record<string, QpmInfoItem>;
workspace_id?: string;
model_limit?: LimitSpec | null;
workspace_limit?: LimitSpec | null;
}
interface MonitorPoint {
value: number;
timestamp: number;
interface LimitsResponse {
output?: {
total?: number;
page_no?: number;
page_size?: number;
quotas?: ModelQuota[];
};
request_id?: string;
}
interface MonitorMetric {
aggMethod: string;
metricName: string;
points: MonitorPoint[];
// ---------------------------------------------------------------------------
// Formatters
// ---------------------------------------------------------------------------
/** Compact rate display: `500/s`, `60/min`, `83,333/6s`; "-" when unlimited. */
function formatLimit(limit: number | null | undefined, period: number | null | undefined): string {
if (limit == null) return "-";
const seconds = period ?? 60;
if (seconds === 1) return `${formatNumber(limit)}/s`;
if (seconds === 60) return `${formatNumber(limit)}/min`;
return `${formatNumber(limit)}/${seconds}s`;
}
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 formatRequestLimit(spec: LimitSpec | null | undefined): string {
if (!spec) return "-";
return formatLimit(spec.request_limit, spec.request_limit_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 formatUsageLimit(spec: LimitSpec | null | undefined): string {
if (!spec) return "-";
return formatLimit(spec.usage_limit, spec.usage_limit_period);
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
async function fetchMonitorData(
client: Client,
modelName: string,
windowMinutes: number,
): Promise<{ rpm: number; tpm: number }> {
const now = Date.now();
const startTime = now - windowMinutes * 60 * 1000;
try {
const raw = await client.console(MONITOR_API, {
reqDTO: {
monitorType: "Advanced",
metricFilters: [
{ aggMethod: "sum_pm", metricName: "model_total_amount" },
{ aggMethod: "sum_pm", metricName: "model_call_count" },
],
labelFilters: {
resourceId: modelName,
resourceType: "model",
},
startTime,
endTime: now,
},
});
const resp = unwrapResponse(raw as Record<string, unknown>);
const metrics = (resp.data ?? resp) as MonitorMetric[] | Record<string, unknown>;
if (!Array.isArray(metrics)) {
return { rpm: 0, tpm: 0 };
}
let rpm = 0;
let tpm = 0;
for (const metric of metrics) {
if (metric.aggMethod !== "sum_pm" || !metric.points?.length) continue;
const lastValue = metric.points[metric.points.length - 1].value ?? 0;
if (metric.metricName === "model_call_count") rpm = Math.round(lastValue);
if (metric.metricName === "model_total_amount") tpm = Math.round(lastValue);
}
return { rpm, tpm };
} catch (error) {
// Re-throw authentication errors (BailianError with ExitCode.AUTH);
// other errors are treated as "no data" and show "-" in the table.
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
throw error;
}
return { rpm: -1, tpm: -1 };
/** Async task headroom as `queue/concurrency`; "-" when the model has no async limits. */
function formatAsync(spec: LimitSpec | null | undefined): string {
if (!spec || (spec.async_user_queue_limit == null && spec.async_user_concurrency_limit == null)) {
return "-";
}
const queue =
spec.async_user_queue_limit != null ? formatNumber(spec.async_user_queue_limit) : "-";
const concurrency =
spec.async_user_concurrency_limit != null
? formatNumber(spec.async_user_concurrency_limit)
: "-";
return `${queue}/${concurrency}`;
}
async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
const allModels: ModelWithQpm[] = [];
let pageNo = 1;
while (true) {
const input: Record<string, unknown> = {
pageNo,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
};
const raw = await client.console(MODEL_LIST_API, { input });
const resp = unwrapResponse(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++;
function printTable(quotas: ModelQuota[], total: number): void {
if (quotas.length === 0) {
process.stdout.write("No rate limits found.\n");
return;
}
return allModels;
}
interface ListRow {
model: string;
rpm: string;
tpm: string;
rpmQuotaLeft: number | null;
tpmQuotaLeft: number | null;
rpmQuotaLabel: string | null;
tpmQuotaLabel: string | null;
}
function printTable(rows: ListRow[]): void {
const headers = ["Model", "Req/min", "Token/min", "RPM Left", "TPM Left"];
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
const tableRows = rows.map((r) => [r.model, r.rpm, r.tpm, "", ""]);
const headers = ["Model", "Req Limit", "Usage Limit", "WS Req", "WS Usage", "Async Q/C"];
const rows = quotas.map((quota) => [
quota.model,
formatRequestLimit(quota.model_limit),
formatUsageLimit(quota.model_limit),
formatRequestLimit(quota.workspace_limit),
formatUsageLimit(quota.workspace_limit),
formatAsync(quota.model_limit),
]);
const lines = renderBoxTable({
headers,
rows: tableRows,
align: ["left", "right", "right", "left", "left"],
barColumns: [
{ index: 3, percents: rpmPercents, labels: rpmLabels, width: 15 },
{ index: 4, percents: tpmPercents, labels: tpmLabels, width: 15 },
],
rows,
align: ["left", "right", "right", "right", "right", "right"],
});
for (const line of lines) process.stdout.write(line + "\n");
process.stdout.write(`\nTotal: ${total}\n`);
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
export default defineCommand({
description: "View model RPM/TPM rate limits",
auth: "console",
usageArgs: "[--model <model>] [flags]",
description: "View model rate limits (QPM/TPM, account and workspace level)",
auth: "apiKey",
usageArgs: "[--model <model>] [--name <name>] [--page <n>] [--page-size <n>]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name(s), comma-separated",
description: "Model name(s), comma-separated (exact match)",
},
name: {
type: "string",
valueHint: "<name>",
description: "Fuzzy search by model name",
},
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 20)" },
},
exampleArgs: ["", "--model qwen3.6-plus", "--model qwen3.6-plus,qwen-turbo", "--output json"],
exampleArgs: [
"",
"--model qwen3-max",
"--model qwen3-max,qwen-plus",
"--name qwen --page-size 50",
"--output json",
],
notes: ["Usage-vs-limit pressure checks live in `quota check` (console auth)."],
async run(ctx) {
const { settings, flags } = ctx;
const modelFlag = flags.model || undefined;
const nameFlag = flags.name || undefined;
const format = detectOutputFormat(settings.output);
const endpoint = ctx.client.url(modelsLimitsPath());
if (settings.dryRun) {
const input: Record<string, unknown> = {
pageNo: 1,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
};
emitResult(
{
apis: [
MODEL_LIST_API,
{ api: MONITOR_API, note: "called per-model for text output with gauges" },
],
modelListInput: { input },
},
format,
);
if (modelFlag) {
// One exact-match GET per model; dry-run lists them all.
const requests = parseCommaList(modelFlag).map((model) => ({
endpoint,
method: "GET",
query: { model, page_size: 100 },
}));
emitResult({ requests }, format);
} else {
emitResult(
{
endpoint,
method: "GET",
query: {
name: nameFlag,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
},
},
format,
);
}
return;
}
let models = await fetchAllModelsWithQpm(ctx.client);
let quotas: ModelQuota[];
let total: number;
if (modelFlag) {
const names = new Set(
modelFlag
.split(",")
.map((n) => n.trim())
.filter(Boolean),
// Exact lookup per model, then merge.
const responses = await Promise.all(
parseCommaList(modelFlag).map((model) =>
ctx.client.requestJson<LimitsResponse>({
path: modelsLimitsPath() + buildQuery({ model, page_size: 100 }),
}),
),
);
models = models.filter((m) => names.has(m.model));
if (models.length === 0) {
throw new BailianError(`no matching models found for "${modelFlag}".`);
}
quotas = responses.flatMap((resp) => resp.output?.quotas ?? []);
total = quotas.length;
} else {
const resp = await ctx.client.requestJson<LimitsResponse>({
path:
modelsLimitsPath() +
buildQuery({
name: nameFlag,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
}),
});
quotas = resp.output?.quotas ?? [];
total = resp.output?.total ?? quotas.length;
}
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;
return {
model: m.model,
rpm: currentRPM > 0 ? currentRPM : null,
tpm: currentTPM > 0 ? currentTPM : null,
};
});
emitResult(items, format);
emitResult({ items: quotas, total }, format);
return;
}
// For text output with gauges, we need monitor data
const monitorResults = await Promise.all(
models.map((m) => fetchMonitorData(ctx.client, m.model, 2)),
);
const rows: ListRow[] = models.map((m, idx) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
const rpmUsage = monitorResults[idx].rpm;
const tpmUsage = monitorResults[idx].tpm;
// RPM Quota Left = 1 - (rpmUsage / currentRPM) in percentage
let rpmQuotaPercent: number | null = null;
let rpmQuotaLabel: string | null = null;
if (rpmUsage >= 0 && currentRPM > 0) {
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / currentRPM) * 100);
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
}
// TPM Quota Left = 1 - (tpmUsage / currentTPM) in percentage
let tpmQuotaPercent: number | null = null;
let tpmQuotaLabel: string | null = null;
if (tpmUsage >= 0 && currentTPM > 0) {
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / currentTPM) * 100);
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
}
return {
model: m.model,
rpm: currentRPM > 0 ? formatNumber(currentRPM) : "-",
tpm: currentTPM > 0 ? formatNumber(currentTPM) : "-",
rpmQuotaLeft: rpmQuotaPercent,
tpmQuotaLeft: tpmQuotaPercent,
rpmQuotaLabel,
tpmQuotaLabel,
};
});
if (rows.length === 0) {
process.stdout.write("No models found.\n");
return;
}
printTable(rows);
printTable(quotas, total);
},
});
@@ -1,188 +0,0 @@
import {
defineCommand,
UsageError,
BailianError,
ExitCode,
detectOutputFormat,
type Client,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
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(
client: Client,
modelName: string,
): Promise<{ model: string; qpmInfo: Record<string, QpmInfoItem> } | undefined> {
const raw = await client.console(MODEL_LIST_API, {
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({
description: "Request a temporary quota increase",
auth: "console",
usageArgs: "--model <model> --tpm <value> [flags]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name (required)",
required: true,
},
tpm: {
type: "string",
valueHint: "<value>",
description: "Target TPM value (required)",
required: true,
},
},
exampleArgs: [
"--model qwen-turbo --tpm 100000",
"--model qwen3.6-plus --tpm 8000000",
"--model qwen-turbo --tpm 100000 --output json",
],
validate: (f) => (Number(f.tpm) > 0 ? undefined : "--tpm must be a positive number."),
async run(ctx) {
const { identity, settings, flags } = ctx;
const modelName = flags.model;
const tpmValue = Number(flags.tpm);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const requestData = {
input: {
model: modelName,
limit: { usage_limit: tpmValue },
},
};
emitResult({ api: UPDATE_LIMITS_API, data: requestData }, format);
return;
}
const modelInfo = await fetchModelQpmInfo(ctx.client, modelName);
if (!modelInfo) {
throw new BailianError(
`model "${modelName}" not found or does not support self-service quota increase.`,
ExitCode.GENERAL,
`Run \`${identity.binName} quota list\` to view available models.`,
);
}
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) {
throw new UsageError(
`TPM value ${tpmValue.toLocaleString()} is out of range. ` +
`Current: ${currentLimit.toLocaleString()}, Range: ${minLimit.toLocaleString()} ~ ${maxLimit.toLocaleString()}.`,
);
}
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 ctx.client.console(UPDATE_LIMITS_API, requestData);
} catch (err) {
if (err instanceof BailianError && err.message.includes("NotLogined")) {
throw new BailianError(
"session expired.",
ExitCode.AUTH,
`Run \`${identity.binName} auth login --console\` to re-authenticate.`,
);
}
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") {
throw new BailianError("rate limit has been updated externally. Please retry.");
}
if (confirmCode === "Downgrade") {
result = await submitRequest(true);
}
}
if (format === "json") {
emitResult(result, format);
return;
}
process.stdout.write(
`Quota updated for "${modelName}": TPM ${currentLimit.toLocaleString()}${tpmValue.toLocaleString()}\n`,
);
},
});
@@ -0,0 +1,100 @@
import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
const MINUTE_SECONDS = 60;
export default defineCommand({
description: "Update model rate limits (QPM/TPM), or clear them with --delete",
auth: "apiKey",
usageArgs: "--model <model> [--rpm <n>] [--tpm <n>] [--delete]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name (required)",
required: true,
},
rpm: {
type: "number",
valueHint: "<n>",
description: "Max requests per minute (QPM)",
},
tpm: {
type: "number",
valueHint: "<n>",
description: "Max tokens per minute (TPM)",
},
delete: {
type: "switch",
description: "Clear all custom rate limits for the model",
},
},
exampleArgs: [
"--model qwen-plus --rpm 60 --tpm 100000",
"--model qwen3-max --tpm 500000",
"--model qwen-plus --delete",
"--model qwen-plus --rpm 60 --output json",
],
notes: [
"Fields you omit keep their current values (server-side OVERLAY merge); --delete clears all custom limits.",
"Setting TPM without an existing QPM limit is rejected server-side — pass --rpm first or together.",
],
validate: (flags) => {
if (flags.delete && (flags.rpm !== undefined || flags.tpm !== undefined))
return "--delete cannot be combined with --rpm/--tpm.";
if (!flags.delete && flags.rpm === undefined && flags.tpm === undefined)
return "one of --rpm / --tpm / --delete is required.";
if (flags.rpm !== undefined && flags.rpm < 0) return "--rpm must be a non-negative number.";
if (flags.tpm !== undefined && flags.tpm < 0) return "--tpm must be a non-negative number.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const modelName = flags.model;
const format = detectOutputFormat(settings.output);
const entry: Record<string, unknown> = { model: modelName };
if (flags.delete) {
entry.operation_type = "DELETE";
} else {
if (flags.rpm !== undefined) {
entry.request_limit = flags.rpm;
entry.request_limit_period = MINUTE_SECONDS;
}
if (flags.tpm !== undefined) {
entry.usage_limit = flags.tpm;
entry.usage_limit_period = MINUTE_SECONDS;
}
}
const body = { models: [entry] };
if (settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsLimitsPath()), method: "POST", request: body },
format,
);
return;
}
const result = await ctx.client.requestJson<{ request_id?: string }>({
path: modelsLimitsPath(),
method: "POST",
body,
});
if (format === "json") {
emitResult({ model: modelName, ...result }, format);
return;
}
if (flags.delete) {
process.stdout.write(`Rate limits cleared for "${modelName}".\n`);
return;
}
const parts: string[] = [];
if (flags.rpm !== undefined) parts.push(`QPM ${formatNumber(flags.rpm)}`);
if (flags.tpm !== undefined) parts.push(`TPM ${formatNumber(flags.tpm)}`);
process.stdout.write(`Rate limits updated for "${modelName}": ${parts.join(", ")}\n`);
},
});
@@ -0,0 +1,4 @@
/** Format an integer with en-US thousands separators for table / text output. */
export function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
@@ -0,0 +1,21 @@
/** Split a comma-separated flag value into trimmed, deduped, non-empty entries. */
export function parseCommaList(value: string): string[] {
return [
...new Set(
value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
/** Serialize defined, non-empty params into a `?key=value` query string ("" when empty). */
export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") search.set(key, String(value));
}
const queryString = search.toString();
return queryString ? `?${queryString}` : "";
}
+54 -36
View File
@@ -4,21 +4,12 @@ import {
defineCommand,
detectInstalledAgents,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
installSkillWithFanout,
readSkillLock,
runWithConcurrency,
writeSkillLock,
} from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
interface InitOutcome {
name: string;
status: "installed" | "failed";
publishedAt?: string;
agents?: string[];
reason?: string;
}
import { emitBare, emitResult } from "bailian-cli-runtime";
/** Prefix used to identify first-party Bailian skills in the registry. */
const BAILIAN_PREFIX = "bailian-";
@@ -26,6 +17,22 @@ const BAILIAN_PREFIX = "bailian-";
/** Max number of skills downloading/installing at the same time. */
const INIT_CONCURRENCY = 3;
/** Default output format when user does not pass --output explicitly. */
const DEFAULT_FORMAT = "json";
/** All status values used by skill init (per-skill outcome + aggregate result). */
const STATUS = {
success: "success",
partial: "partial",
failed: "failed",
} as const;
interface InitOutcome {
name: string;
status: typeof STATUS.success | typeof STATUS.failed;
reason?: string;
}
export default defineCommand({
description: "Install all bailian-* skills (one-shot bootstrap for new environments)",
auth: "none",
@@ -36,7 +43,7 @@ export default defineCommand({
"Equivalent to: bl skill add --all (filtered to bailian-* skills)",
],
async run(ctx) {
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const format = ctx.settings.outputExplicit ? ctx.settings.output : DEFAULT_FORMAT;
const index = await fetchSkillsIndex();
// Discover all bailian-* skills from the live registry index
@@ -55,16 +62,11 @@ export default defineCommand({
lock.skills[name]?.links ?? [],
);
lock.skills[name] = record.lockEntry;
return {
name,
status: "installed",
publishedAt: entry.publishedAt,
agents: record.linkedAgents,
};
return { name, status: STATUS.success };
} catch (err) {
return {
name,
status: "failed",
status: STATUS.failed,
reason: err instanceof Error ? err.message : String(err),
};
}
@@ -72,30 +74,46 @@ export default defineCommand({
const results = await runWithConcurrency(tasks, INIT_CONCURRENCY);
writeSkillLock(lock);
if (format === "json") {
emitResult(
{
registry: getSkillRegistryBaseUrl(),
agents: agents.map((agent) => agent.id),
skills: results,
},
format,
);
const installed = results.filter((result) => result.status === STATUS.success);
const failed = results.filter((result) => result.status === STATUS.failed);
const status =
failed.length === 0
? STATUS.success
: installed.length === 0
? STATUS.failed
: STATUS.partial;
if (format === DEFAULT_FORMAT) {
const agentIds = agents.map((agent) => agent.id);
const payload: Record<string, unknown> = {
status,
skills: installed.map((result) => result.name),
};
if (failed.length > 0) {
payload.failed = failed.map((result) => ({
name: result.name,
reason: result.reason,
agents: agentIds,
}));
}
emitResult(payload, format);
} else if (results.length === 0) {
emitBare("No bailian-* skills found in the registry.");
} else {
const rows = results.map((result) => [
result.name,
result.status,
result.publishedAt ? result.publishedAt.slice(0, 10) : "-",
result.status === "installed" ? result.agents?.join(", ") || "-" : (result.reason ?? "-"),
]);
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) {
emitBare(line);
emitBare(
status === STATUS.success
? `Installed ${installed.length} bailian-* skills.`
: `Installed ${installed.length}/${results.length} bailian-* skills.`,
);
if (failed.length > 0) {
emitBare("Failed:");
for (const item of failed) {
emitBare(` ${item.name}: ${item.reason}`);
}
}
}
const failed = results.filter((result) => result.status === "failed");
if (failed.length > 0) {
throw new BailianError(
`${failed.length}/${results.length} skill(s) failed to install`,
@@ -12,6 +12,13 @@ import {
stripUndefined,
taskPath,
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
type AsrApiRoute,
type AsrFlashFamily,
type OutputFormat,
type FlagsDef,
type ParsedFlags,
@@ -27,8 +34,18 @@ const RECOGNIZE_FLAGS = {
description: "Audio file URL or local file path (repeatable, max 100)",
required: true,
},
model: { type: "string", valueHint: "<model>", description: "Model ID (default: fun-asr)" },
language: { type: "string", valueHint: "<lang>", description: "Language hint (e.g. zh, en, ja)" },
model: {
type: "string",
valueHint: "<model>",
description:
"Model ID (default: fun-asr). Async: fun-asr / *-filetrans / paraformer-*; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash",
},
language: {
type: "string",
valueHint: "<lang>",
description:
"Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language",
},
diarization: { type: "switch", description: "Enable automatic speaker diarization" },
speakerCount: {
type: "number",
@@ -55,8 +72,33 @@ const RECOGNIZE_FLAGS = {
} satisfies FlagsDef;
type RecognizeFlags = ParsedFlags<typeof RECOGNIZE_FLAGS>;
function assertSyncFlashFlagsAllowed(
flags: RecognizeFlags,
model: string,
flashFamily: AsrFlashFamily,
): void {
const unsupported: string[] = [];
if (flags.diarization === true) unsupported.push("--diarization");
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
// qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does
if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) {
unsupported.push("--vocabulary-id");
}
if (flags.channelId !== undefined) unsupported.push("--channel-id");
if (flags.async === true) unsupported.push("--async");
if (flags.pollInterval !== undefined) unsupported.push("--poll-interval");
if (unsupported.length > 0) {
throw new BailianError(
`Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` +
`Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`,
ExitCode.USAGE,
);
}
}
export default defineCommand({
description: "Recognize speech from audio files (FunAudio-ASR)",
description: "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
auth: "apiKey",
usageArgs: "--url <audio-url> [flags]",
flags: RECOGNIZE_FLAGS,
@@ -68,6 +110,7 @@ export default defineCommand({
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
"--url https://example.com/audio.mp3 --out result.json",
"--url https://example.com/audio.mp3 --async --quiet",
"--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en",
],
async run(ctx) {
const { settings, flags } = ctx;
@@ -90,22 +133,70 @@ export default defineCommand({
}
const model = flags.model || "fun-asr";
const route = resolveAsrApi(model);
if (route.kind === "unsupported") {
throw new BailianError(
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
ExitCode.USAGE,
);
}
if (route.kind === "sync-flash") {
assertSyncFlashFlagsAllowed(flags, model, route.flashFamily!);
if (rawUrls.length !== 1) {
throw new BailianError(
`Model "${model}" is a sync Flash ASR model and accepts exactly one --url (got ${rawUrls.length}).\n` +
`Hint: Pass a single audio URL, or use an async filetrans model for batch files.`,
ExitCode.USAGE,
);
}
}
if (
route.kind === "async-filetrans" &&
route.asyncInputStyle === "file_url" &&
rawUrls.length !== 1
) {
throw new BailianError(
`Model "${model}" accepts exactly one --url (got ${rawUrls.length}).\n` +
"Hint: qwen3-asr-flash-filetrans* requires a single file_url.",
ExitCode.USAGE,
);
}
const format = detectOutputFormat(settings.output);
// Auto-upload local files in parallel
const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model)));
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
if (route.kind === "sync-flash") {
await handleSyncFlashMode(
ctx.client,
settings,
flags,
format,
model,
route,
resolvedUrls[0]!,
);
return;
}
const channelId = flags.channelId;
const language = flags.language;
const vocabularyId = flags.vocabularyId;
const languageFields = buildAsyncAsrLanguageFields(
route.asyncLanguageStyle ?? "language_hints",
flags.language,
);
const body: DashScopeASRRequest = {
model,
input: {
file_urls: resolvedUrls,
},
input:
route.asyncInputStyle === "file_url"
? { file_url: resolvedUrls[0]! }
: { file_urls: resolvedUrls },
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
language_hints: language ? [language] : undefined,
...languageFields,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
@@ -116,7 +207,7 @@ export default defineCommand({
stripUndefined(body.parameters as Record<string, unknown>);
if (settings.dryRun) {
emitResult({ request: body, mode: "async" }, format);
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
return;
}
@@ -128,6 +219,55 @@ export default defineCommand({
},
});
async function handleSyncFlashMode(
client: Client,
settings: Settings,
flags: RecognizeFlags,
format: OutputFormat,
model: string,
route: AsrApiRoute,
audioUrl: string,
): Promise<void> {
const flashFamily = route.flashFamily as AsrFlashFamily;
const body = buildAsrFlashRequest({
model,
audioUrl,
language: flags.language,
vocabularyId: flags.vocabularyId,
flashFamily,
});
if (settings.dryRun) {
emitResult({ request: body, mode: "sync", path: route.path }, format);
return;
}
if (!settings.quiet) {
process.stderr.write(`[Model: ${model}] [Mode: sync] [Files: 1]\n`);
}
const response = await client.requestJson<Record<string, unknown>>({
path: route.path,
method: "POST",
headers: { "X-DashScope-SSE": "disable" },
body,
});
const text = extractAsrFlashText(response, flashFamily);
if (text) {
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
} else {
emitBare(JSON.stringify(response));
}
if (flags.out) {
writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n");
if (!settings.quiet) {
process.stderr.write(`Full result saved to: ${flags.out}\n`);
}
}
}
async function handleAsyncMode(
client: Client,
settings: Settings,
@@ -160,16 +300,16 @@ async function handleAsyncMode(
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: settings.timeout,
isComplete: (d) => (d as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
isFailed: (d) => (d as DashScopeASRTaskResult).output.task_status === "FAILED",
getStatus: (d) => (d as DashScopeASRTaskResult).output.task_status,
getErrorMessage: (d) => {
const o = (d as DashScopeASRTaskResult).output;
return (o as unknown as Record<string, unknown>).message as string | undefined;
isComplete: (data) => (data as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
isFailed: (data) => (data as DashScopeASRTaskResult).output.task_status === "FAILED",
getStatus: (data) => (data as DashScopeASRTaskResult).output.task_status,
getErrorMessage: (data) => {
const output = (data as DashScopeASRTaskResult).output;
return (output as unknown as Record<string, unknown>).message as string | undefined;
},
});
const results = result.output.results ?? [];
const results = collectAsrTranscriptionItems(result.output);
if (results.length === 0) {
emitResult({ task_id: taskId, status: result.output.task_status }, format);
@@ -179,12 +319,14 @@ async function handleAsyncMode(
// Collect all transcription data for --out
const allTransData: Record<string, unknown>[] = [];
for (let i = 0; i < results.length; i++) {
const subResult = results[i]!;
for (let index = 0; index < results.length; index++) {
const subResult = results[index]!;
const isMulti = fileCount > 1;
if (isMulti) {
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
process.stdout.write(
`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`,
);
}
if (subResult.subtask_status === "FAILED") {
+96 -26
View File
@@ -1,20 +1,35 @@
import {
defineCommand,
chatPath,
responsesPath,
parseSSE,
detectOutputFormat,
readTextFromPathOrStdin,
type ChatMessage,
type ChatRequest,
type ChatResponse,
type ResponsesRequest,
type ResponsesResponse,
type ResponsesStreamEvent,
type StreamChunk,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
import { readFileSync } from "fs";
import {
assertResponsesStreamCompleted,
inspectResponsesStreamEvent,
extractResponsesText,
} from "./responses.ts";
const CHAT_FLAGS = {
api: {
type: "string",
valueHint: "<chat|responses>",
choices: ["chat", "responses"] as const,
description: "API to call (default: chat)",
},
model: { type: "string", valueHint: "<model>", description: "Model ID (default: qwen3.8-max)" },
message: {
type: "array",
@@ -72,31 +87,31 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
if (flags.messagesFile) {
const raw = readTextFromPathOrStdin(flags.messagesFile);
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
for (const m of parsed) {
if (m.role === "system") {
system = typeof m.content === "string" ? m.content : "";
for (const parsedMessage of parsed) {
if (parsedMessage.role === "system") {
system = typeof parsedMessage.content === "string" ? parsedMessage.content : "";
} else {
messages.push(m as ChatMessage);
messages.push(parsedMessage as ChatMessage);
}
}
}
if (flags.message) {
const validRoles = new Set(["system", "user", "assistant"]);
const msgs = flags.message;
for (const m of msgs) {
const colonIdx = m.indexOf(":");
const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : "";
const messageValues = flags.message;
for (const messageValue of messageValues) {
const colonIndex = messageValue.indexOf(":");
const maybeRole = colonIndex !== -1 ? messageValue.slice(0, colonIndex) : "";
if (validRoles.has(maybeRole)) {
const content = m.slice(colonIdx + 1);
const content = messageValue.slice(colonIndex + 1);
if (maybeRole === "system") {
system = content;
} else {
messages.push({ role: maybeRole as "user" | "assistant", content });
}
} else {
messages.push({ role: "user", content: m });
messages.push({ role: "user", content: messageValue });
}
}
}
@@ -105,24 +120,33 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
}
export default defineCommand({
description: "Send a chat completion (OpenAI compatible, DashScope)",
description: "Send a text model request (OpenAI compatible, DashScope)",
auth: "apiKey",
usageArgs: "--message <text> [flags]",
flags: CHAT_FLAGS,
exampleArgs: [
'--message "What is Qwen?"',
`--api responses --model qwen3.8-max --tool '{"type":"web_search"}' --message "Search for recent Alibaba Cloud news"`,
'--model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python"',
'--message "Hello" --message "assistant:Hi!" --message "How are you?"',
"--messages-file - --stream",
'--message "Hello" --output json',
'--model qwq-plus --message "Solve 1+1" --enable-thinking',
],
validate: (f) =>
!f.message && !f.messagesFile ? "Provide --message or --messages-file." : undefined,
validate: (flags) => {
if (!flags.message && !flags.messagesFile) {
return "Provide --message or --messages-file.";
}
if (flags.api === "responses" && flags.thinkingBudget !== undefined) {
return "--thinking-budget is not supported by the Responses API.";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const { system, messages } = parseMessages(flags);
const api = flags.api ?? "chat";
const model = flags.model || settings.defaultTextModel || "qwen3.8-max";
const shouldStream = flags.stream || process.stdout.isTTY;
const format = detectOutputFormat(settings.output);
@@ -134,29 +158,39 @@ export default defineCommand({
}
allMessages.push(...messages);
const body: ChatRequest = {
model,
messages: allMessages,
max_tokens: flags.maxTokens ?? 4096,
stream: shouldStream,
};
let body: ChatRequest | ResponsesRequest;
if (api === "responses") {
body = {
model,
input: allMessages,
max_output_tokens: flags.maxTokens ?? 4096,
stream: shouldStream,
};
} else {
body = {
model,
messages: allMessages,
max_tokens: flags.maxTokens ?? 4096,
stream: shouldStream,
};
}
if (flags.temperature !== undefined) body.temperature = flags.temperature;
if (flags.topP !== undefined) body.top_p = flags.topP;
if (flags.enableThinking) {
body.enable_thinking = true;
if (flags.thinkingBudget !== undefined) {
if (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget;
}
}
if (flags.tool) {
const tools = flags.tool.map((t) => {
const tools = flags.tool.map((toolValue) => {
try {
return JSON.parse(t);
return JSON.parse(toolValue);
} catch {
const raw = readFileSync(t, "utf-8");
const raw = readFileSync(toolValue, "utf-8");
return JSON.parse(raw);
}
});
@@ -169,8 +203,8 @@ export default defineCommand({
}
if (shouldStream) {
const res = await ctx.client.request({
path: chatPath(),
const responseStream = await ctx.client.request({
path: api === "responses" ? responsesPath() : chatPath(),
method: "POST",
body,
stream: true,
@@ -178,6 +212,7 @@ export default defineCommand({
let textContent = "";
let inThinking = false;
let responsesCompleted = false;
const writesStreamingStdout = format === "text";
const isTTY = process.stdout.isTTY;
const statusOut =
@@ -185,8 +220,28 @@ export default defineCommand({
const resultOut = process.stdout;
const statusColor = ansi(statusOut);
for await (const event of parseSSE(res)) {
for await (const event of parseSSE(responseStream)) {
if (event.data === "[DONE]") break;
if (api === "responses") {
let parsedEvent: ResponsesStreamEvent;
try {
parsedEvent = JSON.parse(event.data) as ResponsesStreamEvent;
} catch {
continue;
}
const update = inspectResponsesStreamEvent(parsedEvent);
if (update.delta) {
textContent += update.delta;
if (writesStreamingStdout) resultOut.write(update.delta);
}
if (update.completed) {
responsesCompleted = true;
break;
}
continue;
}
try {
const parsed = JSON.parse(event.data) as StreamChunk;
@@ -216,6 +271,7 @@ export default defineCommand({
// Skip unparseable chunks
}
}
if (api === "responses") assertResponsesStreamCompleted(responsesCompleted);
if (inThinking) statusOut.write(statusColor.reset);
if (format === "json") {
@@ -223,6 +279,20 @@ export default defineCommand({
} else {
resultOut.write("\n");
}
} else if (api === "responses") {
const response = await ctx.client.requestJson<ResponsesResponse>({
path: responsesPath(),
method: "POST",
body,
});
const text = extractResponsesText(response);
if (settings.quiet || format === "text") {
emitBare(text);
} else {
emitResult(response, format);
}
} else {
const response = await ctx.client.requestJson<ChatResponse>({
path: chatPath(),
@@ -0,0 +1,76 @@
import {
BailianError,
ExitCode,
type ResponsesResponse,
type ResponsesStreamEvent,
} from "bailian-cli-core";
export interface ResponsesStreamUpdate {
delta: string;
completed: boolean;
}
export function extractResponsesText(response: ResponsesResponse): string {
return response.output
.filter((outputItem) => outputItem.type === "message")
.flatMap((outputItem) => outputItem.content ?? [])
.filter((contentItem) => contentItem.type === "output_text")
.map((contentItem) => contentItem.text ?? "")
.join("");
}
export function extractResponsesStreamDelta(event: ResponsesStreamEvent): string {
return event.type === "response.output_text.delta" ? (event.delta ?? "") : "";
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: undefined;
}
function stringProperty(record: Record<string, unknown> | undefined, property: string) {
const value = record?.[property];
return typeof value === "string" && value.trim() ? value : undefined;
}
function responsesErrorMessage(event: ResponsesStreamEvent): string | undefined {
const response = asRecord(event.response);
const responseError = asRecord(response?.error);
const eventError = asRecord(event.error);
return (
stringProperty(responseError, "message") ??
stringProperty(eventError, "message") ??
stringProperty(event, "message")
);
}
export function inspectResponsesStreamEvent(event: ResponsesStreamEvent): ResponsesStreamUpdate {
if (event.type === "response.failed" || event.type === "error") {
throw new BailianError(responsesErrorMessage(event) ?? "Response failed.", ExitCode.GENERAL);
}
if (event.type === "response.incomplete") {
const response = asRecord(event.response);
const incompleteDetails = asRecord(response?.incomplete_details);
const reason = stringProperty(incompleteDetails, "reason");
throw new BailianError(
responsesErrorMessage(event) ??
(reason ? `Response incomplete: ${reason}` : "Response incomplete."),
ExitCode.GENERAL,
);
}
return {
delta: extractResponsesStreamDelta(event),
completed: event.type === "response.completed",
};
}
export function assertResponsesStreamCompleted(completed: boolean): void {
if (completed) return;
throw new BailianError(
"Stream disconnected before completion: stream closed before response.completed.",
ExitCode.GENERAL,
);
}
+2 -2
View File
@@ -20,8 +20,7 @@ import {
type AnsiStyles,
} from "bailian-cli-runtime";
const SKILL_SOURCE = "modelstudioai/cli";
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
const SKILL_INSTALL_CMD = "bl skill init";
function updateAgentSkill(color: AnsiStyles): void {
process.stderr.write("\nUpdating agent skill...\n");
@@ -143,6 +142,7 @@ export default defineCommand({
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
);
writeUpdateState(newVer);
updateAgentSkill(color);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const reinstall =
@@ -0,0 +1,132 @@
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts";
import { formatNumber } from "../shared/format.ts";
const CODING_PLAN_USAGE_API =
"zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2";
const COMMODITY_CODES: Record<string, string> = {
domestic: "sfm_codingplan_public_cn",
international: "sfm_codingplan_public_intl",
};
interface CodingPlanWindow {
usedQuota?: number;
totalQuota?: number;
/** Usage ratio in [0, 1]; absent when the window has no positive total or no used value. */
percentage?: number;
resetTime?: number;
}
interface CodingPlanUsage {
instanceType?: string;
per5Hour: CodingPlanWindow;
perWeek: CodingPlanWindow;
perBillMonth: CodingPlanWindow;
}
function readWindow(
quotaInfo: Record<string, unknown> | undefined,
fieldPrefix: string,
): CodingPlanWindow {
const window: CodingPlanWindow = {};
if (!quotaInfo) return window;
const usedQuota = readNumber(quotaInfo[`${fieldPrefix}UsedQuota`]);
if (usedQuota !== undefined) window.usedQuota = usedQuota;
const totalQuota = readNumber(quotaInfo[`${fieldPrefix}TotalQuota`]);
if (totalQuota !== undefined) window.totalQuota = totalQuota;
const resetTime = readNumber(quotaInfo[`${fieldPrefix}QuotaNextRefreshTime`]);
if (resetTime !== undefined) window.resetTime = resetTime;
// Console rule: the usage rate only exists with a positive total and a used value.
if (usedQuota !== undefined && totalQuota !== undefined && totalQuota > 0) {
window.percentage = usedQuota / totalQuota;
}
return window;
}
/** Pick the first VALID instance's quota info, mirroring the Coding Plan console. */
function readUsage(result: unknown): CodingPlanUsage | undefined {
const response = unwrapResponse(result as Record<string, unknown>);
const instances = Array.isArray(response.codingPlanInstanceInfos)
? (response.codingPlanInstanceInfos as Record<string, unknown>[])
: [];
const validInstance = instances.find((instance) => instance.status === "VALID");
if (!validInstance) return undefined;
const quotaInfo = validInstance.codingPlanQuotaInfo as Record<string, unknown> | undefined;
const usage: CodingPlanUsage = {
per5Hour: readWindow(quotaInfo, "per5Hour"),
perWeek: readWindow(quotaInfo, "perWeek"),
perBillMonth: readWindow(quotaInfo, "perBillMonth"),
};
if (typeof validInstance.instanceType === "string" && validInstance.instanceType) {
usage.instanceType = validInstance.instanceType;
}
return usage;
}
function toSection(label: string, window: CodingPlanWindow): QuotaSection {
const section: QuotaSection = {
label,
emptyMessage: "No quota data for this window; verify in the Bailian Coding Plan console.",
percentage: window.percentage,
resetTime: window.resetTime,
};
if (window.usedQuota !== undefined && window.totalQuota !== undefined) {
section.detail = `Used: ${formatNumber(window.usedQuota)} / ${formatNumber(window.totalQuota)}`;
}
return section;
}
function printView(usage: CodingPlanUsage, generatedAt: number): void {
const planSuffix = usage.instanceType ? ` (${usage.instanceType})` : "";
printQuotaBox(
`Coding Plan Usage${planSuffix}`,
[
toSection("5-hour quota", usage.per5Hour),
toSection("1-week quota", usage.perWeek),
toSection("Monthly quota", usage.perBillMonth),
],
generatedAt,
);
}
export default defineCommand({
description: "Show Coding Plan quota usage",
auth: "console",
usageArgs: "[flags]",
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings } = ctx;
const format = detectOutputFormat(settings.output);
const requestData = {
queryCodingPlanInstanceInfoRequest: {
commodityCode: COMMODITY_CODES[settings.consoleSite ?? "domestic"],
onlyLatestOne: true,
},
};
if (settings.dryRun) {
emitResult({ api: CODING_PLAN_USAGE_API, data: requestData }, format);
return;
}
const result = await ctx.client.console(CODING_PLAN_USAGE_API, requestData);
const usage = readUsage(result);
if (format === "json") {
emitResult(usage ?? {}, format);
return;
}
if (!usage) {
process.stdout.write("No active Coding Plan subscription found.\n");
return;
}
printView(usage, Date.now());
},
});
+4 -6
View File
@@ -1,4 +1,4 @@
import { defineCommand, detectOutputFormat, fetchModelList } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, findModelByName } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
FREE_TIER_API,
@@ -93,13 +93,11 @@ export default defineCommand({
}
requestData.queryFreeTierQuotaRequest.models = models;
} else {
const searchResults = await Promise.all(
models.map((name) =>
fetchModelList((api, data) => ctx.client.console(api, data), { name, pageSize: 50 }),
),
const matches = await Promise.all(
models.map((name) => findModelByName((api, data) => ctx.client.console(api, data), name)),
);
for (let idx = 0; idx < models.length; idx++) {
const matched = searchResults[idx].models.find((item) => item.model === models[idx]);
const matched = matches[idx];
if (matched) {
typeMap.set(models[idx], resolveModelType((matched.capabilities as string[]) || []));
}
@@ -1,95 +1,22 @@
import { defineCommand, detectOutputFormat, fetchModelList, type Client } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
FREE_TIER_API,
FREE_TIER_ONLY_STATUS_API,
extractFreeTierOnlyStatuses,
extractQuotas,
fetchAllModels,
pollFreeTierBatch,
} from "./shared.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;
}
const ACTIVATE_API = "zeldaEasy.bailian-commerce.freeTrial.batchActivateFreeTierOnly";
const DEACTIVATE_API = "zeldaEasy.bailian-commerce.freeTrial.batchDeactivateFreeTierOnly";
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(
client: Client,
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 client.console(api, requestData);
const resp = extractResponseData(raw as Record<string, unknown>);
if (resp.taskId && Object.keys(resp).length === 1) {
nextTaskId = resp.taskId as string;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
return raw;
}
return null;
}
async function fetchAllModelNames(client: Client): Promise<string[]> {
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList((api, data) => client.console(api, data), {
pageNo: page,
pageSize: 50,
});
allModels.push(...result.models);
if (allModels.length >= result.total) break;
page++;
}
return allModels.map((item) => item.model as string).filter(Boolean);
}
export default defineCommand({
description:
"Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable",
@@ -161,7 +88,7 @@ export default defineCommand({
}
if (!modelFlag) {
models = await fetchAllModelNames(ctx.client);
models = (await fetchAllModels(ctx.client)).map((model) => model.name);
}
if (off) {
@@ -172,12 +99,10 @@ export default defineCommand({
}),
]);
const quotaData = extractResponseData(quotaResult as Record<string, unknown>);
const quotas = (quotaData.freeTierQuotas ?? []) as FreeTierQuota[];
const quotas = extractQuotas(quotaResult);
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 stopStatuses = extractFreeTierOnlyStatuses(stopResult);
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
for (const name of models) {
@@ -192,7 +117,7 @@ export default defineCommand({
);
continue;
}
await pollUntilDone(ctx.client, api, requestKey, [name]);
await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
process.stdout.write(`Disabled auto-stop for "${name}".\n`);
}
return;
@@ -200,13 +125,13 @@ export default defineCommand({
const jsonResults: unknown[] = [];
for (const name of models) {
const result = await pollUntilDone(ctx.client, api, requestKey, [name]);
const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
if (format === "json") {
jsonResults.push(result);
continue;
}
if (result) {
const resultData = extractResponseData(result as Record<string, unknown>);
const resultData = unwrapResponse(result as Record<string, unknown>);
const failureModels = (resultData.failureModels as BatchResultFailure[]) ?? [];
if (failureModels.length > 0) {
process.stderr.write(
@@ -0,0 +1,91 @@
import {
ansi,
displayWidth,
renderGauge,
type GaugeCell,
type TextStyle,
} from "bailian-cli-runtime";
import { formatDateTime } from "./shared.ts";
const BOX_WIDTH = 76;
/** One quota window rendered inside the box: a label + usage ratio + reset time. */
export interface QuotaSection {
label: string;
/** Shown instead of the gauge when the usage ratio is absent. */
emptyMessage: string;
/** Usage ratio in [0, 1]; absent means no data (possibly unlimited). */
percentage?: number;
resetTime?: number;
/** Optional dim line under the gauge, e.g. "Used: 38 / 100". */
detail?: string;
}
/** Accept only finite numbers; anything else counts as absent (possibly unlimited). */
export function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Match the `usage free` gauge label style: 0.1% precision, no trailing zeros. */
function formatPercentage(ratio: number): string {
const percent = Math.round(ratio * 1000) / 10;
return `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`;
}
function formatRemainingTime(resetTime: number, now: number): string {
const remainingMs = Math.max(0, resetTime - now);
const totalMinutes = Math.floor(remainingMs / 60_000);
if (totalMinutes === 0) return "now";
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
return parts.join(" ");
}
/** Print a bordered quota box with a title line and one gauge per section. */
export function printQuotaBox(title: string, sections: QuotaSection[], generatedAt: number): void {
const color = ansi(process.stdout);
const writeLine = (text = "", style?: TextStyle) => {
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${text}`));
process.stdout.write(`${style ? style(text) : text}${" ".repeat(padding)}\n`);
};
// Pre-colored gauge cell: pad from the plain variant so ANSI escapes never shift the border.
const writeGaugeLine = (cell: GaugeCell) => {
const padding = Math.max(0, BOX_WIDTH - displayWidth(` ${cell.plain}`));
process.stdout.write(`${cell.colored}${" ".repeat(padding)}\n`);
};
const writeQuota = (section: QuotaSection) => {
writeLine(section.label, color.bold);
if (section.percentage === undefined) {
writeLine(section.emptyMessage, color.dim);
return;
}
const gaugeLabel = `${formatPercentage(section.percentage)} used`;
writeGaugeLine(renderGauge(section.percentage * 100, gaugeLabel));
if (section.detail) {
writeLine(section.detail, color.dim);
}
if (section.resetTime === undefined) {
writeLine("Resets: not applicable (no usage yet)", color.dim);
return;
}
const resetText = `Resets: ${formatDateTime(section.resetTime)} (in ${formatRemainingTime(section.resetTime, generatedAt)})`;
writeLine(resetText, color.dim);
};
process.stdout.write(`${"─".repeat(BOX_WIDTH)}\n`);
writeLine(title, color.cyan);
writeLine(`Generated at: ${formatDateTime(generatedAt)} (local time)`, color.dim);
for (const section of sections) {
process.stdout.write(`${"─".repeat(BOX_WIDTH)}\n`);
writeQuota(section);
}
process.stdout.write(`${"─".repeat(BOX_WIDTH)}\n`);
}
+54 -28
View File
@@ -1,5 +1,5 @@
import {
fetchModelList,
fetchModelListAll,
BailianError,
ExitCode,
unwrapResponse,
@@ -7,15 +7,12 @@ import {
type Settings,
} from "bailian-cli-core";
import { ansi, renderBoxTable, displayWidth, padEnd } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
// ---------------------------------------------------------------------------
// Common formatters
// ---------------------------------------------------------------------------
export function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
export function formatDate(ts: number): string {
const date = new Date(ts);
const year = date.getFullYear();
@@ -24,6 +21,14 @@ export function formatDate(ts: number): string {
return `${year}-${month}-${day}`;
}
export function formatDateTime(ts: number): string {
const date = new Date(ts);
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
return `${formatDate(ts)} ${hour}:${minute}:${second}`;
}
export function requireWorkspaceId(settings: Settings, binName: string): string {
if (settings.workspaceId) return settings.workspaceId;
@@ -79,17 +84,7 @@ export interface ModelInfo {
}
export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList((api, data) => client.console(api, data), {
pageNo: page,
pageSize: 50,
});
allModels.push(...result.models);
if (allModels.length >= result.total) break;
page++;
}
const allModels = await fetchModelListAll((api, data) => client.console(api, data));
return allModels
.filter((item) => typeof item.model === "string" && item.model)
.map((item) => ({
@@ -102,9 +97,9 @@ export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
// Free-tier quota
// ---------------------------------------------------------------------------
export const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
export const FREE_TIER_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota";
export const FREE_TIER_ONLY_STATUS_API =
"zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
"zeldaEasy.bailian-commerce.freeTrial.queryFreeTierOnlyStatus";
export interface FreeTierQuota {
model: string;
@@ -257,22 +252,27 @@ export interface ListStatisticResponse {
}
const POLL_INTERVAL_MS = 500;
const MAX_POLLS = 30;
const DEFAULT_MAX_POLLS = 30;
export async function pollTelemetryApi(
/**
* Poll a console API until it returns a terminal (non task-id) response.
* The gateway answers an async request with a bare `{taskId}` envelope; the
* caller re-issues with that id until real data arrives or the budget runs out.
* `buildRequest` shapes each attempt (initial call vs. taskId follow-up) so the
* same loop serves every request-wrapper convention (telemetry `reqDTO`,
* free-tier batch `…Request`).
*/
export async function pollConsoleUntilDone(
client: Client,
api: string,
reqDTO: Record<string, unknown>,
buildRequest: (taskId: string | undefined) => Record<string, unknown>,
maxPolls = DEFAULT_MAX_POLLS,
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
const requestData = nextTaskId
? { reqDTO: { ...reqDTO, asyncTaskId: nextTaskId } }
: { reqDTO };
const raw = await client.console(api, requestData);
const resp = extractResponseData(raw as Record<string, unknown>);
for (let attempt = 0; attempt < maxPolls; attempt++) {
const raw = await client.console(api, buildRequest(nextTaskId));
const resp = unwrapResponse(raw as Record<string, unknown>);
if (resp.taskId && Object.keys(resp).length === 1) {
nextTaskId = resp.taskId as string;
@@ -284,6 +284,32 @@ export async function pollTelemetryApi(
return null;
}
/** Telemetry APIs wrap the payload in `reqDTO` and echo the task id as `asyncTaskId`. */
export async function pollTelemetryApi(
client: Client,
api: string,
reqDTO: Record<string, unknown>,
): Promise<unknown> {
return pollConsoleUntilDone(client, api, (taskId) =>
taskId ? { reqDTO: { ...reqDTO, asyncTaskId: taskId } } : { reqDTO },
);
}
/** Free-tier batch activate/deactivate wrap the payload in `requestKey` and echo `taskId`. */
export async function pollFreeTierBatch(
client: Client,
api: string,
requestKey: string,
models: string[],
): Promise<unknown> {
return pollConsoleUntilDone(
client,
api,
(taskId) => ({ [requestKey]: taskId ? { taskId } : { models } }),
20,
);
}
export function extractOverviewData(result: unknown): OverviewStatistic | undefined {
const resp = extractResponseData(result as Record<string, unknown>);
if (resp.callSuccessCount !== undefined || resp.usages !== undefined) {
+15 -165
View File
@@ -1,176 +1,26 @@
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
type Settings,
type Client,
} from "bailian-cli-core";
import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
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(
client: Client,
api: string,
reqDTO: Record<string, unknown>,
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
const requestData = nextTaskId
? { reqDTO: { ...reqDTO, asyncTaskId: nextTaskId } }
: { reqDTO };
const raw = await client.console(api, requestData);
const resp = extractResponseData(raw as Record<string, unknown>);
if (resp.taskId && Object.keys(resp).length === 1) {
nextTaskId = resp.taskId as string;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
return raw;
}
return null;
}
function requireWorkspaceId(settings: Settings, binName: string): string {
if (settings.workspaceId) return settings.workspaceId;
throw new BailianError(
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id <id>\`.`,
ExitCode.GENERAL,
`Run \`${binName} workspace list\` to view available workspaces.`,
);
}
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;
}
import {
LIST_API,
OVERVIEW_API,
USAGE_KEY_LABELS,
extractListData,
extractOverviewData,
formatDate,
pollTelemetryApi,
requireWorkspaceId,
resolveUsageMap,
type ModelStatisticItem,
type OverviewStatistic,
} from "./shared.ts";
import { formatNumber } from "../shared/format.ts";
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}`;
@@ -0,0 +1,77 @@
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { printQuotaBox, readNumber } from "./quota-box.ts";
const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
interface TokenPlanUsage {
per5HourPercentage?: number;
per5HourResetTime?: number;
per1WeekPercentage?: number;
per1WeekResetTime?: number;
}
function readUsage(result: unknown): TokenPlanUsage {
const response = unwrapResponse(result as Record<string, unknown>);
const usage: TokenPlanUsage = {};
const per5HourPercentage = readNumber(response.per5HourPercentage);
if (per5HourPercentage !== undefined) usage.per5HourPercentage = per5HourPercentage;
const per5HourResetTime = readNumber(response.per5HourResetTime);
if (per5HourResetTime !== undefined) usage.per5HourResetTime = per5HourResetTime;
const per1WeekPercentage = readNumber(response.per1WeekPercentage);
if (per1WeekPercentage !== undefined) usage.per1WeekPercentage = per1WeekPercentage;
const per1WeekResetTime = readNumber(response.per1WeekResetTime);
if (per1WeekResetTime !== undefined) usage.per1WeekResetTime = per1WeekResetTime;
return usage;
}
function printView(usage: TokenPlanUsage, generatedAt: number): void {
printQuotaBox(
"Token Plan Usage",
[
{
label: "5-hour quota",
emptyMessage:
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
percentage: usage.per5HourPercentage,
resetTime: usage.per5HourResetTime,
},
{
label: "1-week quota",
emptyMessage:
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
percentage: usage.per1WeekPercentage,
resetTime: usage.per1WeekResetTime,
},
],
generatedAt,
);
}
export default defineCommand({
description: "Show Token Plan quota usage",
auth: "console",
usageArgs: "[flags]",
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ api: TOKEN_PLAN_USAGE_API, data: {} }, format);
return;
}
const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
const usage = readUsage(result);
if (format === "json") {
emitResult(usage, format);
return;
}
printView(usage, Date.now());
},
});
@@ -1,6 +1,7 @@
import {
defineCommand,
videoGeneratePath,
image2videoPath,
taskPath,
detectOutputFormat,
type DashScopeVideoRequest,
@@ -43,6 +44,11 @@ export default defineCommand({
valueHint: "<url>",
description: "Input image URL for image-to-video generation",
},
lastFrame: {
type: "string",
valueHint: "<url>",
description: "Last frame image URL (with --image, enables kf2v first+last frame mode)",
},
negativePrompt: {
type: "string",
valueHint: "<text>",
@@ -110,12 +116,20 @@ export default defineCommand({
const format = detectOutputFormat(settings.output);
const imageUrl = flags.image;
const lastFrameUrl = flags.lastFrame as string | undefined;
// Auto-upload local image file for i2v
let resolvedImageUrl: string | undefined;
if (imageUrl) {
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
}
let resolvedLastFrameUrl: string | undefined;
if (lastFrameUrl) {
resolvedLastFrameUrl = await ctx.client.resolveImageInput(lastFrameUrl, model);
}
// kf2v mode: both --image and --last-frame provided.
const isKf2v = Boolean(resolvedImageUrl && resolvedLastFrameUrl);
const watermark = resolveWatermark(flags.watermark);
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
@@ -125,10 +139,16 @@ export default defineCommand({
input: {
prompt: prompt,
negative_prompt: flags.negativePrompt || undefined,
// i2v models (happyhorse-1.1-i2v) require input.media with type 'first_frame'
...(resolvedImageUrl
? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
// kf2v: first+last frame flat fields via image2video endpoint.
// wan2.1~2.6 i2v: flat img_url via video-generation endpoint.
// wan2.7+ / happyhorse i2v: media[] via video-generation endpoint.
...(isKf2v
? { first_frame_url: resolvedImageUrl, last_frame_url: resolvedLastFrameUrl }
: resolvedImageUrl
? /wan[x]?2\.[1-6]/i.test(model)
? { img_url: resolvedImageUrl }
: { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
},
parameters: {
resolution: flags.resolution || undefined,
@@ -141,15 +161,28 @@ export default defineCommand({
};
if (settings.dryRun) {
const previewBody = resolvedImageUrl
? {
...body,
input: {
...body.input,
media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }],
},
}
: body;
let previewBody = body;
if (isKf2v) {
previewBody = {
...body,
input: {
...body.input,
first_frame_url: redactDataUri(resolvedImageUrl ?? ""),
last_frame_url: redactDataUri(resolvedLastFrameUrl ?? ""),
},
};
} else if (resolvedImageUrl) {
const redactedUrl = redactDataUri(resolvedImageUrl);
previewBody = {
...body,
input: {
...body.input,
...(/wan[x]?2\.[1-6]/i.test(model)
? { img_url: redactedUrl }
: { media: [{ type: "first_frame" as const, url: redactedUrl }] }),
},
};
}
emitResult({ request: previewBody }, format);
return;
}
@@ -162,7 +195,7 @@ export default defineCommand({
settings,
() =>
ctx.client.requestJson<DashScopeAsyncResponse>({
path: videoGeneratePath(),
path: isKf2v ? image2videoPath() : videoGeneratePath(),
method: "POST",
body,
async: true,
+10 -1
View File
@@ -48,15 +48,20 @@ export { default as usageFree } from "./commands/usage/free.ts";
export { default as usageFreetier } from "./commands/usage/freetier.ts";
export { default as usageStats } from "./commands/usage/stats.ts";
export { default as usageSummary } from "./commands/usage/summary.ts";
export { default as usageTokenPlan } from "./commands/usage/token-plan.ts";
export { default as usageCodingPlan } from "./commands/usage/coding-plan.ts";
export { default as pipelineRun } from "./commands/pipeline/run.ts";
export { default as pipelineValidate } from "./commands/pipeline/validate.ts";
export { default as advisorRecommend } from "./commands/advisor/recommend.ts";
export { default as modelList } from "./commands/model/list.ts";
export { default as workspaceList } from "./commands/workspace/list.ts";
export { default as quotaList } from "./commands/quota/list.ts";
export { default as quotaRequest } from "./commands/quota/request.ts";
export { default as quotaUpdate } from "./commands/quota/update.ts";
export { default as quotaHistory } from "./commands/quota/history.ts";
export { default as quotaCheck } from "./commands/quota/check.ts";
export { default as permissionList } from "./commands/permission/list.ts";
export { default as permissionGrant } from "./commands/permission/grant.ts";
export { default as permissionRevoke } from "./commands/permission/revoke.ts";
export { default as datasetUpload } from "./commands/dataset/upload.ts";
export { default as datasetList } from "./commands/dataset/list.ts";
export { default as datasetGet } from "./commands/dataset/get.ts";
@@ -66,6 +71,7 @@ export {
finetuneTextCreate,
finetuneAudioCreate,
finetuneImageCreate,
finetuneVideoCreate,
} from "./commands/finetune/create.ts";
export { default as finetuneList } from "./commands/finetune/list.ts";
export { default as finetuneGet } from "./commands/finetune/get.ts";
@@ -76,6 +82,7 @@ export { default as finetuneCheckpoints } from "./commands/finetune/checkpoints.
export { default as finetuneExport } from "./commands/finetune/export.ts";
export { default as finetuneWatch } from "./commands/finetune/watch.ts";
export { default as finetuneCapability } from "./commands/finetune/capability.ts";
export { default as finetunePrice } from "./commands/finetune/price.ts";
export {
deployTextCreate,
deployAudioCreate,
@@ -87,6 +94,8 @@ export { default as deployModels } from "./commands/deploy/models.ts";
export { default as deployScale } from "./commands/deploy/scale.ts";
export { default as deployUpdate } from "./commands/deploy/update.ts";
export { default as deployDelete } from "./commands/deploy/delete.ts";
export { default as deployPause } from "./commands/deploy/pause.ts";
export { default as deployResume } from "./commands/deploy/resume.ts";
export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.ts";
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
@@ -0,0 +1,213 @@
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import codingPlanUsage from "../src/commands/usage/coding-plan.ts";
const originalNoColor = process.env.NO_COLOR;
const originalForceColor = process.env.FORCE_COLOR;
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
afterEach(() => {
if (originalNoColor === undefined) delete process.env.NO_COLOR;
else process.env.NO_COLOR = originalNoColor;
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
else process.env.FORCE_COLOR = originalForceColor;
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
else delete (process.stdout as { isTTY?: boolean }).isTTY;
vi.restoreAllMocks();
});
function captureStdout(): string[] {
const output: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
return output;
}
async function runCodingPlan(response: Record<string, unknown>, output?: string): Promise<void> {
await codingPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(response) },
flags: {},
settings: { dryRun: false, output },
} as never);
}
function wrapResponse(data: Record<string, unknown>): Record<string, unknown> {
return {
data: {
DataV2: {
data: {
data,
},
},
},
};
}
function makeInstanceResponse(
quotaInfo: Record<string, unknown>,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return wrapResponse({
codingPlanInstanceInfos: [
{ status: "VALID", instanceType: "pro", codingPlanQuotaInfo: quotaInfo, ...overrides },
],
});
}
const FULL_QUOTA_INFO = {
per5HourUsedQuota: 38,
per5HourTotalQuota: 100,
per5HourQuotaNextRefreshTime: 1_786_000_000_000,
perWeekUsedQuota: 500,
perWeekTotalQuota: 1000,
perWeekQuotaNextRefreshTime: 1_786_100_000_000,
perBillMonthUsedQuota: 950,
perBillMonthTotalQuota: 1000,
perBillMonthQuotaNextRefreshTime: 1_786_200_000_000,
};
describe("usage coding-plan view", () => {
test("renders the three quota windows with usage rates and used/total details", async () => {
const output = captureStdout();
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
const renderedOutput = output.join("");
expect(renderedOutput).toContain("Coding Plan Usage (pro)");
expect(renderedOutput).toContain("5-hour quota");
expect(renderedOutput).toContain("1-week quota");
expect(renderedOutput).toContain("Monthly quota");
expect(renderedOutput).toContain("38% used");
expect(renderedOutput).toContain("50% used");
expect(renderedOutput).toContain("95% used");
expect(renderedOutput).toContain("Used: 38 / 100");
expect(renderedOutput).toContain("Used: 950 / 1,000");
});
test("skips non-VALID instances when picking quota info", async () => {
const output = captureStdout();
await runCodingPlan(
wrapResponse({
codingPlanInstanceInfos: [
{
status: "EXPIRED",
codingPlanQuotaInfo: { per5HourUsedQuota: 1, per5HourTotalQuota: 2 },
},
{ status: "VALID", codingPlanQuotaInfo: FULL_QUOTA_INFO },
],
}),
);
expect(output.join("")).toContain("38% used");
});
test("renders windows without a positive total as missing quota data", async () => {
const output = captureStdout();
await runCodingPlan(
makeInstanceResponse({
per5HourUsedQuota: 38,
per5HourTotalQuota: 0,
perWeekUsedQuota: 500,
perBillMonthUsedQuota: "not-a-number",
perBillMonthTotalQuota: 1000,
}),
);
const renderedOutput = output.join("");
const emptyMessageCount = renderedOutput.split(
"No quota data for this window; verify in the Bailian Coding Plan console.",
).length;
expect(emptyMessageCount - 1).toBe(3);
});
test("reports when there is no active subscription", async () => {
const output = captureStdout();
await runCodingPlan(wrapResponse({ codingPlanInstanceInfos: [] }));
expect(output.join("")).toContain("No active Coding Plan subscription found.");
});
test("renders the gauge in the usage-free style: brand fill and proportional cells", async () => {
delete process.env.NO_COLOR;
process.env.FORCE_COLOR = "3";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const output = captureStdout();
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
// Brand-cyan fill cell, same as the `usage free` gauge column
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
});
test("fills gauge cells proportionally to the usage rate", async () => {
process.env.NO_COLOR = "1";
const output = captureStdout();
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO));
const renderedOutput = output.join("");
// 95% of the default 20-cell gauge → 19 filled cells + 1 track space
expect(renderedOutput).toContain(`${"\u2588".repeat(19)} `);
expect(renderedOutput).not.toContain("\u2588".repeat(20));
});
});
describe("usage coding-plan json", () => {
test("outputs the three windows with used/total/percentage/resetTime", async () => {
const output = captureStdout();
await runCodingPlan(makeInstanceResponse(FULL_QUOTA_INFO), "json");
expect(JSON.parse(output.join(""))).toEqual({
instanceType: "pro",
per5Hour: {
usedQuota: 38,
totalQuota: 100,
percentage: 0.38,
resetTime: 1_786_000_000_000,
},
perWeek: {
usedQuota: 500,
totalQuota: 1000,
percentage: 0.5,
resetTime: 1_786_100_000_000,
},
perBillMonth: {
usedQuota: 950,
totalQuota: 1000,
percentage: 0.95,
resetTime: 1_786_200_000_000,
},
});
});
test("returns an empty JSON object when no VALID instance exists", async () => {
const output = captureStdout();
await runCodingPlan(wrapResponse({}), "json");
expect(output.join("").trim()).toBe("{}");
});
test("omits non-numeric quota fields from the JSON output", async () => {
const output = captureStdout();
await runCodingPlan(
makeInstanceResponse(
{ per5HourUsedQuota: "not-a-number", per5HourTotalQuota: 100 },
{ instanceType: undefined },
),
"json",
);
expect(JSON.parse(output.join(""))).toEqual({
per5Hour: { totalQuota: 100 },
perWeek: {},
perBillMonth: {},
});
});
});
@@ -30,7 +30,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--name/i);
expect(stderr).toMatch(/--model-name|--display-name/i);
});
test("deploy create --dry-run 构造 lora 部署请求体", async () => {
@@ -38,9 +38,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"text",
"create",
"--model",
"--model-name",
"qwen-plus-2025-12-01",
"--name",
"--display-name",
"my-qwen-plus",
"--dry-run",
"--output",
@@ -68,9 +68,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"text",
"create",
"--model",
"--model-name",
"qwen3-8b",
"--name",
"--display-name",
"my-qwen3-mu",
"--plan",
"mu",
@@ -102,9 +102,9 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: deploy (offline)", () => {
"deploy",
"audio",
"create",
"--model",
"--model-name",
"my-cosyvoice-ft",
"--name",
"--display-name",
"my-tts",
"--dry-run",
"--output",
+112 -14
View File
@@ -31,7 +31,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
expect(stderr).toMatch(/--base-model|--datasets/i);
});
test("finetune create --dry-run 构造 SFT 默认请求体", async () => {
@@ -39,7 +39,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa,file-bbb",
@@ -73,7 +73,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -135,7 +135,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -157,7 +157,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
"file-aaa",
@@ -176,7 +176,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
`${localPath},file-bbb`,
@@ -207,7 +207,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
" , ",
@@ -228,7 +228,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
localPath,
@@ -250,7 +250,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
localPath,
@@ -272,7 +272,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
["cancel", ["--job-id", "ft-xxx"]],
["delete", ["--job-id", "ft-xxx"]],
["watch", ["--job-id", "ft-xxx"]],
["capability", ["--model", "qwen3-8b"]],
["capability", ["--base-model", "qwen3-8b"]],
])("finetune %s --dry-run 发出结构化动作", async (sub, extra) => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
@@ -292,7 +292,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"text",
"create",
"--model",
"--base-model",
"qwen3-8b",
"--datasets",
" file-a , ,file-b ",
@@ -314,7 +314,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"audio",
"create",
"--model",
"--base-model",
"cosyvoice-v3-flash",
"--datasets",
"file-audio",
@@ -343,7 +343,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model|--datasets/i);
expect(stderr).toMatch(/--base-model|--datasets/i);
expect(stderr).not.toMatch(/--training-type|--n-epochs|--batch-size|--max-length/);
});
@@ -352,7 +352,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
"finetune",
"image",
"create",
"--model",
"--base-model",
"wan2.7-image-pro",
"--datasets",
"file-image",
@@ -365,6 +365,104 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (offline)", () => {
expect(data.action).toBe("finetune.create");
expect(data.body.training_type).toBe("efficient_sft");
});
test("finetune video create --help 暴露视频超参且不含文本超参", async () => {
// Video exposes --n-epochs / --batch-size / --learning-rate; the text-only
// --training-type / --max-length surface is not offered.
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--base-model/);
expect(stderr).toMatch(/--n-epochs/);
expect(stderr).toMatch(/--batch-size/);
expect(stderr).toMatch(/--learning-rate/);
expect(stderr).not.toMatch(/--training-type|--max-length/);
});
test("finetune video create --datasets 缺失时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
"wan2.7-i2v",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--datasets|Missing required/i);
});
test.each([
// Model-family-specific defaults resolved by the sft-lora video profile.
["wan2.7-i2v", 1, 102400],
["wan2.5-i2v-preview", 4, 36864],
["wan2.2-kf2v-flash", 4, 262144],
])(
"finetune video create --dry-run %s 解析 batch_size=%i / max_pixels=%i",
async (baseModel, batchSize, maxPixels) => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
baseModel,
"--datasets",
"file-video",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
action: string;
body: {
model: string;
training_type: string;
hyper_parameters: Record<string, unknown>;
};
}>(stdout);
expect(data.action).toBe("finetune.create");
expect(data.body.model).toBe(baseModel);
expect(data.body.training_type).toBe("efficient_sft");
expect(data.body.hyper_parameters.batch_size).toBe(batchSize);
expect(data.body.hyper_parameters.max_pixels).toBe(maxPixels);
expect(data.body.hyper_parameters.learning_rate).toBe("2e-5");
expect(data.body.hyper_parameters.lora_rank).toBe(32);
},
);
test("finetune video create --dry-run 转发超参覆盖且不做 clamp", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(FINETUNE_ROUTES, [
"finetune",
"video",
"create",
"--base-model",
"wan2.7-i2v",
"--datasets",
"file-video",
"--n-epochs",
"100",
"--batch-size",
"2",
"--learning-rate",
"1e-5",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
body: { hyper_parameters: Record<string, unknown> };
}>(stdout);
// Video overrides are forwarded verbatim (no [8, 1024] text clamp).
expect(data.body.hyper_parameters.n_epochs).toBe(100);
expect(data.body.hyper_parameters.batch_size).toBe(2);
expect(data.body.hyper_parameters.learning_rate).toBe("1e-5");
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: finetune (DashScope)", () => {
@@ -0,0 +1,267 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { PERMISSION_ROUTES } from "./topic-routes.ts";
describe("e2e: permission", () => {
test("permission list --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--scope");
expect(stderr).toContain("--model");
expect(stderr).toContain("--name");
expect(stderr).toContain("--page-size");
});
test("permission grant --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--action");
expect(stderr).toContain("--all");
});
test("permission revoke --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"revoke",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--yes");
});
test("permission grant 缺少 --model/--all 报用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("one of --model / --all");
});
test("permission grant --all 与 --model 互斥", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--all",
"--model",
"qwen-plus",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("cannot be combined");
});
test("permission grant --action 非法值报错", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--model",
"qwen-plus",
"--action",
"training",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("invalid");
});
test("permission grant --all 仅支持 inference", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--all",
"--action",
"finetune",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("only supports the inference action");
});
test("permission grant --model 超过 20 个报错", async () => {
const tooMany = Array.from({ length: 21 }, (_, index) => `model-${index}`).join(",");
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--model",
tooMany,
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("at most 20");
});
test("permission revoke --all 缺 --yes 拒绝执行", async () => {
// --yes 护栏在 run() 开头、任何网络调用之前抛出;带 dummy key 让用例不依赖环境凭证(否则 auth stage 先报 AUTH(3))。
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"revoke",
"--all",
"--api-key",
"e2e-dummy-key",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("Refusing");
expect(stderr).toContain("--yes");
});
// --dry-run 跳过 auth stage见 runtime middleware无需凭证即可断言请求形状。
// 不传 --outputpermission 命令组默认 JSON 输出。
test("permission list --dry-run 输出 GET 请求(默认 AUTHORIZABLE + JSON", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
endpoint?: string;
method?: string;
query?: { authorization_scope?: string; page_no?: number; page_size?: number };
}>(stdout);
expect(data.endpoint).toContain("/api/v1/models/permissions");
expect(data.method).toBe("GET");
expect(data.query?.authorization_scope).toBe("AUTHORIZABLE");
expect(data.query?.page_no).toBe(1);
expect(data.query?.page_size).toBe(20);
});
test("permission list --scope authorized --dry-run 透传 scope", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--scope",
"authorized",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ query?: { authorization_scope?: string } }>(stdout);
expect(data.query?.authorization_scope).toBe("AUTHORIZED");
});
test("permission grant --dry-run 输出逐模型 POST 请求体(默认 JSON", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--model",
"qwen-plus,qwen3-max",
"--action",
"inference,finetune",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
endpoint?: string;
method?: string;
request?: { models?: { model?: string; inference?: boolean; finetune?: boolean }[] };
}>(stdout);
expect(data.endpoint).toContain("/api/v1/models/permissions");
expect(data.method).toBe("POST");
expect(data.request?.models?.length).toBe(2);
expect(data.request?.models?.[0]).toEqual({
model: "qwen-plus",
inference: true,
finetune: true,
});
});
test("permission revoke --dry-run 输出取消授权请求体", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"revoke",
"--model",
"qwen-plus",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { models?: { model?: string; inference?: boolean }[] };
}>(stdout);
expect(data.request?.models?.[0]).toEqual({ model: "qwen-plus", inference: false });
});
test("permission grant --all --dry-run 输出一键授权 OPEN", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--all",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { access_all_entities?: string } }>(stdout);
expect(data.request?.access_all_entities).toBe("OPEN");
});
test("permission revoke --all --dry-run 免 --yes 输出 CLOSE", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"revoke",
"--all",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { access_all_entities?: string } }>(stdout);
expect(data.request?.access_all_entities).toBe("CLOSE");
});
});
// 真实调用 GET /api/v1/models/permissions。grant/revoke 只测 --dry-run——live POST
// 会真实改写业务空间的模型授权,不做 e2e。
describe.skipIf(!isDashScopeE2EReady())("e2e: permissionDashScope", () => {
test("permission list JSON 输出返回授权列表(默认 JSON", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ items?: unknown[]; total?: number }>(stdout);
expect(Array.isArray(data.items)).toBe(true);
expect(typeof data.total).toBe("number");
});
test("permission list --scope authorizable 分页生效", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--scope",
"authorizable",
"--page-size",
"5",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
items?: { model?: string; permissions?: Record<string, unknown> }[];
total?: number;
}>(stdout);
expect(data.items?.length).toBeLessThanOrEqual(5);
expect(data.total).toBeGreaterThan(0);
expect(data.items?.[0]).toHaveProperty("permissions");
});
test("permission list 文本输出正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--scope",
"authorizable",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
});
});
+202 -105
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vite-plus/test";
import {
isConsoleE2EReady,
isConsoleAuthFailure,
isDashScopeE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
@@ -12,20 +13,31 @@ describe("e2e: quota", () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--name");
});
test("quota list --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["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 --model qwen3-max");
expect(stderr).toContain("bl quota list --name qwen --page-size 50");
});
test("quota request --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "request", "--help"]);
test("quota update --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--rpm");
expect(stderr).toContain("--tpm");
expect(stderr).toContain("--delete");
});
test("quota request 作为 quota update 的兼容别名可用", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "request", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--rpm");
expect(stderr).toContain("--delete");
});
test("quota history --help 正常退出", async () => {
@@ -53,10 +65,47 @@ describe("e2e: quota", () => {
expect(exitCode).toBe(2);
expect(stderr).toContain("at least 1 minute");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
test("quota list --dry-run 输出请求参数", async () => {
test("quota update 缺少 --rpm/--tpm/--delete 报用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("one of --rpm / --tpm / --delete");
});
test("quota update --delete 与 --rpm 互斥", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
"--delete",
"--rpm",
"60",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("cannot be combined");
});
test("quota update --rpm 负数报错", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
"--rpm",
"-1",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("non-negative");
});
// --dry-run 跳过 auth stage见 runtime middleware无需凭证即可断言请求形状。
test("quota list --dry-run 输出 GET 请求", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
@@ -66,105 +115,90 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
apis?: (string | { api: string; note?: string })[];
modelListInput?: {
input?: { queryQpmInfo?: boolean; supports?: { selfServiceLimitIncrease?: boolean } };
};
endpoint?: string;
method?: string;
query?: { page_no?: number; page_size?: number };
}>(stdout);
expect(data.apis?.[0]).toContain("listFoundationModels");
expect(data.modelListInput?.input?.queryQpmInfo).toBe(true);
expect(data.modelListInput?.input?.supports?.selfServiceLimitIncrease).toBe(true);
expect(data.endpoint).toContain("/api/v1/models/limits");
expect(data.method).toBe("GET");
expect(data.query?.page_no).toBe(1);
expect(data.query?.page_size).toBe(20);
});
test("quota list 文本输出包含英文表头", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--output", "text"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 指定模型返回结果", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"qwen3.6-plus",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 不存在的模型报错", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"nonexistent-model-xyz-99999",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("no matching models found");
});
test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota request --dry-run 输出请求参数", async () => {
test("quota list --model 多模型 --dry-run 逐模型一个请求", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"list",
"--model",
"qwen3.6-plus",
"--tpm",
"6000000",
"qwen3-max,qwen-plus",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { input?: { model?: string; limit?: { usage_limit?: number } } };
requests?: { endpoint?: string; method?: string; query?: { model?: string } }[];
}>(stdout);
expect(data.api).toContain("updateFoundationModelLimits");
expect(data.data?.input?.model).toBe("qwen3.6-plus");
expect(data.data?.input?.limit?.usage_limit).toBeTypeOf("number");
expect(data.requests?.length).toBe(2);
expect(data.requests?.[0]?.endpoint).toContain("/api/v1/models/limits");
expect(data.requests?.[0]?.query?.model).toBe("qwen3-max");
expect(data.requests?.[1]?.query?.model).toBe("qwen-plus");
});
test("quota request TPM 超范围报错", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
test("quota update --dry-run 输出 OVERLAY 请求体", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"update",
"--model",
"qwen3.6-plus",
"--tpm",
"999",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("out of range");
expect(result.stderr).toContain("Current");
expect(result.stderr).toContain("Range");
});
test("quota request 不支持提额的模型报错", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"--model",
"nonexistent-model-xyz-99999",
"qwen-plus",
"--rpm",
"60",
"--tpm",
"100000",
"--dry-run",
"--output",
"json",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("not found");
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
endpoint?: string;
method?: string;
request?: {
models?: {
model?: string;
request_limit?: number;
request_limit_period?: number;
usage_limit?: number;
usage_limit_period?: number;
}[];
};
}>(stdout);
expect(data.endpoint).toContain("/api/v1/models/limits");
expect(data.method).toBe("POST");
const entry = data.request?.models?.[0];
expect(entry?.model).toBe("qwen-plus");
expect(entry?.request_limit).toBe(60);
expect(entry?.request_limit_period).toBe(60);
expect(entry?.usage_limit).toBe(100000);
expect(entry?.usage_limit_period).toBe(60);
});
test("quota update --delete --dry-run 输出 DELETE 操作", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
"--delete",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { models?: { model?: string; operation_type?: string }[] };
}>(stdout);
expect(data.request?.models?.[0]?.operation_type).toBe("DELETE");
});
test("quota history --dry-run 输出请求参数", async () => {
@@ -217,6 +251,89 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
expect(data.consoleRegion).toBe("cn-hangzhou");
});
test("quota history --dry-run --page 2 --page-size 20", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"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);
});
});
// 真实调用 GET /api/v1/models/limits。quota update 只测 --dry-run——live POST
// 会真实改写账号限流,不做 e2e。
describe.skipIf(!isDashScopeE2EReady())("e2e: quotaDashScope", () => {
test("quota list 文本输出正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
});
test("quota list --model 精确查询返回模型限流", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"qwen3-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
items?: { model?: string; model_limit?: { request_limit?: number | null } | null }[];
}>(stdout);
expect(data.items?.[0]?.model).toBe("qwen3-max");
expect(data.items?.[0]).toHaveProperty("model_limit");
});
test("quota list --name 模糊搜索分页生效", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--name",
"qwen",
"--page-size",
"5",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ items?: unknown[]; total?: number }>(stdout);
expect(data.items?.length).toBeLessThanOrEqual(5);
expect(data.total).toBeGreaterThan(0);
});
test("quota list --model 不存在的模型返回空列表", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"nonexistent-model-xyz-99999",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No rate limits found");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
test("quota check 文本输出包含英文表头", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "check", "--output", "text"]);
if (isConsoleAuthFailure(result)) return;
@@ -261,24 +378,4 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota history --dry-run --page 2 --page-size 20", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"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);
});
});
@@ -1,4 +1,6 @@
import { readFileSync } from "node:fs";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import {
@@ -16,6 +18,37 @@ import { SPEECH_ROUTES } from "./topic-routes.ts";
*/
describe("e2e: speech recognize", () => {
async function runRecognizeDryRun(args: string[]) {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
...args,
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
return parseStdoutJson<{
mode?: string;
path?: string;
request?: {
model?: string;
parameters?: {
format?: string;
language_hints?: string[];
language?: string;
vocabulary_id?: string;
};
input?: {
file_url?: string;
file_urls?: string[];
messages?: Array<{ content?: Array<{ type?: string }> }>;
};
};
}>(stdout);
}
test("speech recognize --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
@@ -25,6 +58,217 @@ describe("e2e: speech recognize", () => {
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recognize|--url|model|audio/i);
});
test("speech recognize sync-flash dry-run 走 multimodal-generation", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
"--language",
"en",
"--vocabulary-id",
"vocab-e2e",
]);
expect(body.mode).toBe("sync");
expect(body.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(body.request?.model).toBe("qwen-audio-3.0-asr-flash");
expect(body.request?.parameters?.format).toBe("wav");
expect(body.request?.parameters?.language_hints).toEqual(["en"]);
expect(body.request?.parameters?.vocabulary_id).toBe("vocab-e2e");
expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize qwen3 filetrans dry-run 使用 file_url 与 language", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen3-asr-flash-filetrans",
"--url",
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
"--language",
"zh",
]);
expect(body.mode).toBe("async");
expect(body.path).toBe("/api/v1/services/audio/asr/transcription");
expect(body.request?.input?.file_url?.startsWith("https://")).toBe(true);
expect(body.request?.input?.file_urls).toBeUndefined();
expect(body.request?.parameters?.language).toBe("zh");
expect(body.request?.parameters?.language_hints).toBeUndefined();
});
test("speech recognize realtime 模型报用法错误", async () => {
// Use --dry-run to skip auth so CI without API keys still hits USAGE(2)
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash-realtime",
"--url",
"https://example.com/a.wav",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/realtime|WebSocket|unsupported/i);
});
test("speech recognize flash 真实请求走 sync endpoint 并落盘 --out", async () => {
let requestPath = "";
let requestBody: Record<string, unknown> = {};
let sseHeader: string | undefined;
const server = http.createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
requestPath = request.url ?? "";
requestBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
sseHeader = request.headers["x-dashscope-sse"] as string | undefined;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
output: { text: "flash recognition works" },
request_id: "request-146",
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const outDir = makeE2eOutputDir("speech-recognize-flash-sync");
const outPath = join(outDir, "result.json");
try {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"fun-asr-flash-2026-06-15",
"--url",
"https://example.com/sample.wav",
"--api-key",
"sk-e2e-placeholder",
"--base-url",
`http://127.0.0.1:${address.port}`,
"--out",
outPath,
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("flash recognition works");
expect(requestPath).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(sseHeader).toBe("disable");
expect(requestBody).toMatchObject({
model: "fun-asr-flash-2026-06-15",
parameters: { format: "wav" },
});
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
output: { text: "flash recognition works" },
request_id: "request-146",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test("speech recognize flash 多 --url 在发请求前报用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://example.com/a.wav",
"--url",
"https://example.com/b.wav",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/exactly one --url|sync Flash/i);
});
test("speech recognize qwen3-filetrans 轮询成功后下载 result.transcription_url", async () => {
const server = http.createServer((request, response) => {
const url = request.url ?? "";
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
response.writeHead(200, { "Content-Type": "application/json" });
if (url.startsWith("/api/v1/services/audio/asr/transcription")) {
response.end(
JSON.stringify({
output: { task_id: "task-qwen3", task_status: "PENDING" },
request_id: "req-submit",
}),
);
return;
}
if (url.startsWith("/api/v1/tasks/")) {
const address = server.address() as AddressInfo;
response.end(
JSON.stringify({
output: {
task_id: "task-qwen3",
task_status: "SUCCEEDED",
result: {
transcription_url: `http://127.0.0.1:${address.port}/transcription.json`,
},
},
request_id: "req-poll",
}),
);
return;
}
if (url.startsWith("/transcription.json")) {
response.end(
JSON.stringify({
file_url: "https://example.com/a.wav",
transcripts: [{ text: "你好世界", sentences: [{ text: "你好世界" }] }],
}),
);
return;
}
response.writeHead(404);
response.end(JSON.stringify({ message: `unexpected path: ${url}` }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const outDir = makeE2eOutputDir("speech-recognize-qwen3-filetrans");
const outPath = join(outDir, "result.json");
try {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--language",
"zh",
"--api-key",
"sk-e2e-placeholder",
"--base-url",
`http://127.0.0.1:${address.port}`,
"--poll-interval",
"1",
"--out",
outPath,
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("你好世界");
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
transcripts: [{ text: "你好世界" }],
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
@@ -10,8 +10,37 @@ describe("e2e: text chat", () => {
test("text chat --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, ["text", "chat", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--api\s+<chat\|responses>/i);
expect(stderr).toMatch(/chat|--message|model|stream/i);
});
test("text chat 拒绝未知的 --api 值", async () => {
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--api",
"legacy",
"--message",
"hello",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--api|chat.*responses/i);
});
test("text chat 在 Responses 模式拒绝 --thinking-budget", async () => {
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--api",
"responses",
"--message",
"hello",
"--thinking-budget",
"8",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/thinking-budget.*Responses/i);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: text chatDashScope", () => {
@@ -45,7 +74,42 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chatDashScope", () => {
request?: { model?: string; messages?: Array<{ content?: string }> };
}>(stdout);
expect(data.request?.model).toBe("qwen3.8-max");
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
expect(data.request?.messages?.some((message) => message.content === "干跑")).toBe(true);
});
test("text chat --api responses --dry-run 生成 Responses 请求", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--dry-run",
"--api",
"responses",
"--model",
"qwen3.8-max",
"--system",
"system",
"--message",
"hello",
"--max-tokens",
"8",
"--tool",
'{"type":"web_search"}',
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: Record<string, unknown> }>(stdout);
expect(data.request).toMatchObject({
model: "qwen3.8-max",
input: [
{ role: "system", content: "system" },
{ role: "user", content: "hello" },
],
max_output_tokens: 8,
tools: [{ type: "web_search" }],
});
expect(data.request).not.toHaveProperty("messages");
expect(data.request).not.toHaveProperty("max_tokens");
});
test("【qwen3.8-max】文本对话", async () => {
+12 -1
View File
@@ -100,15 +100,25 @@ export const ADVISOR_ROUTES: E2eRouteExports = {
export const QUOTA_ROUTES: E2eRouteExports = {
"quota list": "quotaList",
"quota request": "quotaRequest",
"quota update": "quotaUpdate",
// Backward-compatible alias of "quota update".
"quota request": "quotaUpdate",
"quota history": "quotaHistory",
"quota check": "quotaCheck",
};
export const PERMISSION_ROUTES: E2eRouteExports = {
"permission list": "permissionList",
"permission grant": "permissionGrant",
"permission revoke": "permissionRevoke",
};
export const USAGE_ROUTES: E2eRouteExports = {
"usage free": "usageFree",
"usage freetier": "usageFreetier",
"usage stats": "usageStats",
"usage token-plan": "usageTokenPlan",
"usage coding-plan": "usageCodingPlan",
};
export const DEPLOY_ROUTES: E2eRouteExports = {
@@ -135,6 +145,7 @@ export const FINETUNE_ROUTES: E2eRouteExports = {
"finetune text create": "finetuneTextCreate",
"finetune audio create": "finetuneAudioCreate",
"finetune image create": "finetuneImageCreate",
"finetune video create": "finetuneVideoCreate",
"finetune list": "finetuneList",
"finetune get": "finetuneGet",
"finetune cancel": "finetuneCancel",
@@ -0,0 +1,80 @@
import { describe, expect, test } from "vite-plus/test";
import {
isConsoleAuthFailure,
isConsoleE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
describe("e2e: usage coding-plan", () => {
test("usage coding-plan --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"coding-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Coding Plan|quota/i);
});
test("usage coding-plan --help 包含 --output json 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"coding-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage coding-plan --output json");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-planConsole", () => {
test("usage coding-plan --dry-run 输出网关请求计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"coding-plan",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { queryCodingPlanInstanceInfoRequest?: Record<string, unknown> };
}>(stdout);
expect(data.api).toBe("zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2");
expect(data.data?.queryCodingPlanInstanceInfoRequest).toEqual({
commodityCode: "sfm_codingplan_public_cn",
onlyLatestOne: true,
});
});
test("usage coding-plan --output json 返回窗口结构", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
const data = parseStdoutJson<{
per5Hour?: { percentage?: number };
perWeek?: { percentage?: number };
perBillMonth?: { percentage?: number };
}>(result.stdout);
// 无有效订阅返回 {};有订阅时三个窗口必须存在
if (Object.keys(data).length > 0) {
expect(data.per5Hour).toBeTypeOf("object");
expect(data.perWeek).toBeTypeOf("object");
expect(data.perBillMonth).toBeTypeOf("object");
}
});
test("usage coding-plan 默认渲染生成时间与额度窗口或无订阅提示", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
if (result.stdout.includes("No active Coding Plan subscription found.")) return;
expect(result.stdout).toContain("Generated at:");
expect(result.stdout).toContain("5-hour quota");
expect(result.stdout).toContain("1-week quota");
expect(result.stdout).toContain("Monthly quota");
});
});
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vite-plus/test";
import {
isConsoleAuthFailure,
isConsoleE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
describe("e2e: usage token-plan", () => {
test("usage token-plan --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Token Plan|quota/i);
});
test("usage token-plan --help 包含 --output json 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage token-plan --output json");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage token-planConsole", () => {
test("usage token-plan --dry-run 输出网关请求计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string; data?: Record<string, unknown> }>(stdout);
expect(data.api).toBe("zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage");
expect(data.data).toEqual({});
});
test("usage token-plan --output json 返回可用的额度字段", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
const data = parseStdoutJson<{
per5HourPercentage?: number;
per5HourResetTime?: number;
per1WeekPercentage?: number;
per1WeekResetTime?: number;
}>(result.stdout);
const fields = [
data.per5HourPercentage,
data.per5HourResetTime,
data.per1WeekPercentage,
data.per1WeekResetTime,
];
for (const field of fields) {
if (field !== undefined) expect(field).toBeTypeOf("number");
}
});
test("usage token-plan 默认渲染生成时间与两个额度窗口", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain("Generated at:");
expect(result.stdout).toContain("5-hour quota");
expect(result.stdout).toContain("1-week quota");
});
});
@@ -112,6 +112,62 @@ describe("e2e: video generate (i2v)", () => {
}>(stdout);
expect(data.request?.input?.media?.[0]?.url).toBe("data:image/png;base64,<omitted>");
});
test.each([
// wan2.1~2.6 (legacy) use flat img_url; wan2.7+ and happyhorse use media[].
["wan2.5-i2v-preview", "img_url"],
["wan2.6-i2v", "img_url"],
["wan2.7-i2v", "media"],
["happyhorse-1.1-i2v", "media"],
])("video generate --dry-run %s 首帧走 %s 字段", async (model, field) => {
const configDir = makeE2eOutputDir(`video-i2v-input-shape-${model}`);
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
},
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
VIDEO_ROUTES,
[
"video",
"generate",
"--config",
"token-plan",
"--dry-run",
"--model",
model,
"--image",
"https://example.com/placeholder.png",
"--prompt",
"干跑校验",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: {
input?: { img_url?: string; media?: Array<{ type?: string; url?: string }> };
};
}>(stdout);
if (field === "img_url") {
expect(data.request?.input?.img_url).toBe("https://example.com/placeholder.png");
expect(data.request?.input?.media).toBeUndefined();
} else {
expect(data.request?.input?.media?.[0]?.type).toBe("first_frame");
expect(data.request?.input?.img_url).toBeUndefined();
}
});
});
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
@@ -26,6 +26,12 @@ describe("mcp-activate-hint", () => {
false,
);
expect(isMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false);
// Nested wrapper phrase must not match (anchored at start).
expect(
isMcpNotActivated(
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found - 未开通"),
),
).toBe(false);
});
test("hint 含对应 server 的 MCP 广场深链", () => {
@@ -38,6 +44,36 @@ describe("mcp-activate-hint", () => {
expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i);
});
test("WebSearch + 405 streamableHttp 补重开通 hint", () => {
const original = new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
ExitCode.GENERAL,
);
try {
rethrowWithMcpActivateHint(original, "WebSearch");
expect.unreachable("should throw");
} catch (error) {
expect(error).toBeInstanceOf(BailianError);
const wrapped = error as BailianError;
expect(wrapped.message).toBe(original.message);
expect(wrapped.hint).toMatch(/SSE|Streamable HTTP|Activate|re-activate/i);
expect(wrapped.hint).toContain(mcpMarketplaceDetailPage("WebSearch"));
}
});
test("非 WebSearch 的 405 streamableHttp 不补 hint由 fallback 处理)", () => {
const original = new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
ExitCode.GENERAL,
);
try {
rethrowWithMcpActivateHint(original, "WebParser");
expect.unreachable("should throw");
} catch (error) {
expect(error).toBe(original);
}
});
test("rethrow 保留原 message补 hint", () => {
const serverCode = "market-cmapi00073529";
const original = new BailianError(
@@ -0,0 +1,138 @@
import { BailianError, ExitCode } from "bailian-cli-core";
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import chatCommand from "../src/commands/text/chat.ts";
import {
extractResponsesStreamDelta,
extractResponsesText,
} from "../src/commands/text/responses.ts";
afterEach(() => {
vi.restoreAllMocks();
});
function responsesStreamResponse(events: unknown[]): Response {
const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
return new Response(body, { headers: { "content-type": "text/event-stream" } });
}
async function runResponsesStream(events: unknown[]): Promise<BailianError | undefined> {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
await chatCommand.run({
settings: { output: "json" },
flags: {
api: "responses",
message: ["hello"],
stream: true,
},
client: {
request: async () => responsesStreamResponse(events),
},
} as never);
return undefined;
} catch (error) {
expect(error).toBeInstanceOf(BailianError);
return error as BailianError;
}
}
describe("Responses output", () => {
test("extracts only output_text from message items", () => {
const text = extractResponsesText({
id: "resp_1",
object: "response",
status: "completed",
output: [
{ type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }] },
{ type: "web_search_call", status: "completed" },
{ type: "message", content: [{ type: "output_text", text: "first" }] },
{
type: "message",
content: [
{ type: "refusal", text: "ignored" },
{ type: "output_text", text: " second" },
],
},
],
});
expect(text).toBe("first second");
});
test("extracts only response.output_text.delta stream events", () => {
expect(extractResponsesStreamDelta({ type: "response.output_text.delta", delta: "hi" })).toBe(
"hi",
);
expect(extractResponsesStreamDelta({ type: "response.web_search_call.completed" })).toBe("");
});
test("streaming response.completed finishes successfully", async () => {
const error = await runResponsesStream([
{ type: "response.output_text.delta", sequence_number: 1, delta: "done" },
{
type: "response.completed",
sequence_number: 2,
response: {
id: "resp_completed",
object: "response",
status: "completed",
output: [],
error: null,
incomplete_details: null,
},
},
]);
expect(error).toBeUndefined();
});
test("streaming response.failed throws the original service message", async () => {
const error = await runResponsesStream([
{
type: "response.failed",
sequence_number: 1,
response: {
id: "resp_failed",
object: "response",
status: "failed",
output: [],
error: { code: "quota_exceeded", message: "provider quota exceeded" },
},
},
]);
expect(error?.exitCode).toBe(ExitCode.GENERAL);
expect(error?.message).toBe("provider quota exceeded");
});
test("streaming response.incomplete exits non-zero with the service reason", async () => {
const error = await runResponsesStream([
{
type: "response.incomplete",
sequence_number: 1,
response: {
id: "resp_incomplete",
object: "response",
status: "incomplete",
output: [],
incomplete_details: { reason: "max_output_tokens" },
},
},
]);
expect(error?.exitCode).toBe(ExitCode.GENERAL);
expect(error?.message).toBe("Response incomplete: max_output_tokens");
});
test("stream ending before response.completed exits non-zero", async () => {
const error = await runResponsesStream([
{ type: "response.output_text.delta", sequence_number: 1, delta: "partial" },
]);
expect(error?.exitCode).toBe(ExitCode.GENERAL);
expect(error?.message).toBe(
"Stream disconnected before completion: stream closed before response.completed.",
);
});
});
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import tokenPlanUsage from "../src/commands/usage/token-plan.ts";
const originalNoColor = process.env.NO_COLOR;
const originalForceColor = process.env.FORCE_COLOR;
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
afterEach(() => {
if (originalNoColor === undefined) delete process.env.NO_COLOR;
else process.env.NO_COLOR = originalNoColor;
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
else process.env.FORCE_COLOR = originalForceColor;
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
else delete (process.stdout as { isTTY?: boolean }).isTTY;
vi.restoreAllMocks();
});
function captureStdout(): string[] {
const output: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
return output;
}
async function runTokenPlan(response: Record<string, unknown>, output?: string): Promise<void> {
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(response) },
flags: {},
settings: { dryRun: false, output },
} as never);
}
function makeUsageResponse(
per5HourPercentage?: number,
per1WeekPercentage = per5HourPercentage,
): Record<string, unknown> {
const usage: Record<string, number> = {};
if (per5HourPercentage !== undefined) {
usage.per5HourPercentage = per5HourPercentage;
if (per5HourPercentage !== 0) usage.per5HourResetTime = 1_786_000_000_000;
}
if (per1WeekPercentage !== undefined) {
usage.per1WeekPercentage = per1WeekPercentage;
if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000;
}
return wrapResponse(usage);
}
function wrapResponse(usage: Record<string, unknown>): Record<string, unknown> {
return {
data: {
DataV2: {
data: {
data: usage,
},
},
},
};
}
describe("usage token-plan view", () => {
test("renders the gauge with the usage-free brand fill color", async () => {
delete process.env.NO_COLOR;
process.env.FORCE_COLOR = "3";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5));
// Brand-cyan fill cell, same as the `usage free` gauge column
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
});
test("renders proportional gauge cells with a transparent track", async () => {
process.env.NO_COLOR = "1";
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5));
const renderedOutput = output.join("");
// 50% of the default 20-cell gauge → 10 filled cells + 10 track spaces
expect(renderedOutput).toContain(`${"\u2588".repeat(10)}${" ".repeat(10)}`);
expect(renderedOutput).not.toContain("\u2588".repeat(11));
});
test("accepts missing reset times when the quota usage is zero", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0));
expect(output.join("")).toContain("Resets: not applicable (no usage yet)");
});
test("allows one unused quota window without masking another reset time", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0, 0.5));
const renderedOutput = output.join("");
expect(renderedOutput).toContain("Resets: not applicable (no usage yet)");
expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
test("renders missing quota windows as possibly unlimited", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse());
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
test("renders only the missing quota window as possibly unlimited", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(undefined, 0.5));
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).not.toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
test("renders a window with a missing percentage as possibly unlimited even when its reset time is present", async () => {
const output = captureStdout();
await runTokenPlan(wrapResponse({ per5HourResetTime: 1_786_000_000_000 }));
expect(output.join("")).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
test("treats non-numeric quota fields as absent instead of failing", async () => {
const output = captureStdout();
await runTokenPlan(
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: Number.NaN }),
);
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
});
describe("usage token-plan json", () => {
test("outputs the four core usage fields with --output json", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5, 0.25), "json");
expect(JSON.parse(output.join(""))).toEqual({
per5HourPercentage: 0.5,
per5HourResetTime: 1_786_000_000_000,
per1WeekPercentage: 0.25,
per1WeekResetTime: 1_786_100_000_000,
});
});
test("returns an empty JSON object when no quota fields are available", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(), "json");
expect(output.join("").trim()).toBe("{}");
});
test("omits non-numeric quota fields from the JSON output", async () => {
const output = captureStdout();
await runTokenPlan(
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: 0 }),
"json",
);
expect(JSON.parse(output.join(""))).toEqual({ per1WeekPercentage: 0 });
});
});
@@ -0,0 +1,62 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
const runtimeMocks = vi.hoisted(() => ({
performBinaryUpdate: vi.fn(),
}));
const childProcessMocks = vi.hoisted(() => ({
execSync: vi.fn(),
}));
vi.mock("bailian-cli-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("bailian-cli-runtime")>();
return { ...actual, performBinaryUpdate: runtimeMocks.performBinaryUpdate };
});
vi.mock("child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("child_process")>();
return { ...actual, execSync: childProcessMocks.execSync };
});
import updateCommand from "../src/commands/update.ts";
let configDir: string;
let previousConfigDir: string | undefined;
let previousInstallMethod: string | undefined;
beforeEach(() => {
configDir = mkdtempSync(join(tmpdir(), "bl-update-binary-"));
previousConfigDir = process.env.BAILIAN_CONFIG_DIR;
previousInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
process.env.BAILIAN_CONFIG_DIR = configDir;
process.env.BAILIAN_INSTALL_METHOD = "binary";
runtimeMocks.performBinaryUpdate.mockResolvedValue("1.15.0");
});
afterEach(() => {
if (previousConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
else process.env.BAILIAN_CONFIG_DIR = previousConfigDir;
if (previousInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
else process.env.BAILIAN_INSTALL_METHOD = previousInstallMethod;
rmSync(configDir, { recursive: true, force: true });
vi.clearAllMocks();
});
test("binary bl update syncs bailian skills after the CLI update succeeds", async () => {
await updateCommand.run({
identity: {
binName: "bl",
clientName: "bailian-cli",
npmPackage: "bailian-cli",
version: "1.14.3",
},
flags: { to: "1.15.0" },
settings: {},
} as never);
expect(runtimeMocks.performBinaryUpdate).toHaveBeenCalledWith("1.15.0");
expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill init", { stdio: "inherit" });
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.14.2",
"version": "1.15.1",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+3 -20
View File
@@ -1,11 +1,9 @@
import type { Settings } from "../../config/schema.ts";
import { callConsoleGateway, effectiveConsoleGatewayConfig } from "../../console/gateway.ts";
import { fetchModelList } from "../../console/models.ts";
import { anonymousConsoleCall } from "../../console/gateway.ts";
import { fetchModelListAll } from "../../console/models.ts";
import type { ModelProfile } from "../types.ts";
import type { ModelSource } from "./types.ts";
const PAGE_SIZE = 50;
function toModelProfile(item: Record<string, unknown>): ModelProfile | null {
if (!item.model) return null;
const meta = item.inferenceMetadata as Record<string, unknown> | undefined;
@@ -41,22 +39,7 @@ export class ApiSource implements ModelSource {
async load(): Promise<ModelProfile[]> {
// Public model catalog — no console token (advisor runs unauthenticated).
const eff = effectiveConsoleGatewayConfig(this.settings);
const call = (api: string, data: Record<string, unknown>) =>
callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
this.settings.timeout,
{ api, data },
);
const first = await fetchModelList(call, { pageNo: 1, pageSize: PAGE_SIZE });
const allRaw = [...first.models];
const totalPages = Math.ceil(first.total / PAGE_SIZE);
for (let page = 2; page <= totalPages; page++) {
const result = await fetchModelList(call, { pageNo: page, pageSize: PAGE_SIZE });
allRaw.push(...result.models);
}
const allRaw = await fetchModelListAll(anonymousConsoleCall(this.settings));
return allRaw
.map(toModelProfile)
+327
View File
@@ -0,0 +1,327 @@
import { imageSyncPath, speechRecognizePath } from "./endpoints.ts";
/**
* DashScope ASR APIs differ by model family:
*
* - async file transcription (`.../audio/asr/transcription`):
* fun-asr*, paraformer* (non-realtime), *-filetrans, sensevoice*
* language via `parameters.language_hints`
* - sync multimodal (`.../aigc/multimodal-generation/generation`):
* - qwen3: `{ content: [{ audio }] }` + optional `asr_options.language`
* (qwen3-asr-flash*)
* - input-audio: `{ type: input_audio, input_audio.data }` +
* `format`/`sample_rate` + optional `language_hints`
* (fun-asr-flash*, qwen-audio-*-asr-flash*)
* - realtime / streaming: WebSocket — not supported by `speech recognize`
*/
export type AsrApiKind = "async-filetrans" | "sync-flash" | "unsupported";
/** Sync-flash request body shape differs by Flash protocol family. */
export type AsrFlashFamily = "qwen3" | "input-audio";
export interface AsrApiRoute {
kind: AsrApiKind;
path: string;
/** True when the call is synchronous (no X-DashScope-Async / task poll). */
useSync: boolean;
/**
* Async transcription request input style.
* - `file_urls`: classic async models (fun-asr / paraformer / qwen-audio filetrans...)
* - `file_url`: qwen3-asr-flash-filetrans family
*/
asyncInputStyle?: "file_urls" | "file_url";
/**
* Async transcription language field style.
* - `language_hints`: fun-asr / paraformer / qwen-audio filetrans...
* - `language`: qwen3-asr-flash-filetrans*
*/
asyncLanguageStyle?: "language_hints" | "language";
flashFamily?: AsrFlashFamily;
/** Human-readable reason when kind is unsupported. */
unsupportedReason?: string;
}
function isRealtimeOrStreaming(model: string): boolean {
return /realtime|streaming/i.test(model);
}
function isFiletransModel(model: string): boolean {
return /filetrans/i.test(model);
}
function isQwen3FiletransModel(model: string): boolean {
return /^qwen3-asr-flash-filetrans(?:-|$)/i.test(model);
}
const INPUT_AUDIO_FLASH_PREFIXES = ["fun-asr-flash", "qwen-audio"] as const;
/**
* Fun-ASR-Flash / Qwen-Audio-*-ASR-Flash share the input_audio + format protocol.
* Examples: fun-asr-flash-2026-06-15, qwen-audio-3.0-asr-flash
*/
function isInputAudioFlashModel(model: string): boolean {
if (isRealtimeOrStreaming(model) || isFiletransModel(model)) return false;
if (model.startsWith(INPUT_AUDIO_FLASH_PREFIXES[0])) return true;
if (model.startsWith(INPUT_AUDIO_FLASH_PREFIXES[1]) && /asr-flash/i.test(model)) return true;
return false;
}
/**
* Qwen3-ASR-Flash sync models use content.audio + asr_options.
* Examples: qwen3-asr-flash, qwen3-asr-flash-2025-09-08, qwen3-asr-flash-us
*/
function isQwen3AsrFlashModel(model: string): boolean {
if (!/^qwen3-asr-flash(?:-|$)/i.test(model)) return false;
if (isFiletransModel(model) || isRealtimeOrStreaming(model)) return false;
if (isInputAudioFlashModel(model)) return false;
return true;
}
/**
* Resolve which DashScope ASR API a model should use for file recognition.
* Unknown models default to async-filetrans (preserves existing CLI behavior).
*/
export function resolveAsrApi(model: string): AsrApiRoute {
if (isRealtimeOrStreaming(model)) {
return {
kind: "unsupported",
path: "",
useSync: false,
unsupportedReason:
`Model "${model}" is a realtime/streaming ASR model and requires a WebSocket API. ` +
`Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) or a sync flash model ` +
`(e.g. qwen3-asr-flash, qwen-audio-3.0-asr-flash) with this command.`,
};
}
if (isFiletransModel(model)) {
const isQwen3Filetrans = isQwen3FiletransModel(model);
return {
kind: "async-filetrans",
path: speechRecognizePath(),
useSync: false,
asyncInputStyle: isQwen3Filetrans ? "file_url" : "file_urls",
asyncLanguageStyle: isQwen3Filetrans ? "language" : "language_hints",
};
}
if (isInputAudioFlashModel(model)) {
return {
kind: "sync-flash",
path: imageSyncPath(),
useSync: true,
flashFamily: "input-audio",
};
}
if (isQwen3AsrFlashModel(model)) {
return {
kind: "sync-flash",
path: imageSyncPath(),
useSync: true,
flashFamily: "qwen3",
};
}
// fun-asr / paraformer / sensevoice / unknown → keep legacy async path
return {
kind: "async-filetrans",
path: speechRecognizePath(),
useSync: false,
asyncInputStyle: "file_urls",
asyncLanguageStyle: "language_hints",
};
}
/** Infer audio container hint for input-audio Flash `parameters.format`. */
export function inferAudioFormatHint(audioUrl: string): string {
// data URI: data:audio/mpeg;base64,... → mp3; data:audio/x-wav;... → wav
const dataType = /^data:audio\/([^;,]+)/i.exec(audioUrl)?.[1]?.toLowerCase();
if (dataType) {
if (dataType === "mpeg") return "mp3";
if (dataType === "x-wav" || dataType === "wave") return "wav";
return dataType;
}
const pathPart = audioUrl.split(/[?#]/, 1)[0] ?? audioUrl;
const match = pathPart.match(/\.([a-zA-Z0-9]+)$/);
const extension = match?.[1]?.toLowerCase();
if (!extension) return "wav";
if (extension === "mpeg") return "mp3";
return extension;
}
export interface BuildAsrFlashRequestOpts {
model: string;
audioUrl: string;
language?: string;
/** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */
vocabularyId?: string;
flashFamily: AsrFlashFamily;
}
/**
* Build language fields for async ASR routes.
* qwen3-asr-flash-filetrans* → `language`; other async models → `language_hints`.
*/
export function buildAsyncAsrLanguageFields(
languageStyle: "language_hints" | "language",
language?: string,
): { language_hints?: string[]; language?: string } {
if (!language) return {};
if (languageStyle === "language") {
return { language };
}
return { language_hints: [language] };
}
/** Build a sync multimodal ASR request body for Flash models. */
export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<string, unknown> {
const { model, audioUrl, language, vocabularyId, flashFamily } = opts;
if (flashFamily === "input-audio") {
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id
const parameters: Record<string, unknown> = {
format: inferAudioFormatHint(audioUrl),
sample_rate: "16000",
};
if (language) {
parameters.language_hints = [language];
}
if (vocabularyId) {
parameters.vocabulary_id = vocabularyId;
}
return {
model,
input: {
messages: [
{
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
},
],
},
parameters,
};
}
const asrOptions: Record<string, unknown> = {};
if (language) {
asrOptions.language = language;
}
const parameters: Record<string, unknown> = {};
if (Object.keys(asrOptions).length > 0) {
parameters.asr_options = asrOptions;
}
const body: Record<string, unknown> = {
model,
input: {
messages: [
{
role: "user",
content: [{ audio: audioUrl }],
},
],
},
};
if (Object.keys(parameters).length > 0) {
body.parameters = parameters;
}
return body;
}
/**
* Extract recognition text from a sync Flash ASR response.
* Qwen3 uses choices[].message.content; input-audio Flash uses output.text /
* output.sentence.text / output.output.sentence.text.
*/
export function extractAsrFlashText(
response: Record<string, unknown>,
flashFamily: AsrFlashFamily,
): string {
const output = response.output as Record<string, unknown> | undefined;
if (!output) return "";
if (flashFamily === "input-audio") {
if (typeof output.text === "string" && output.text.length > 0) {
return output.text;
}
const topSentence = output.sentence as Record<string, unknown> | undefined;
if (typeof topSentence?.text === "string" && topSentence.text.length > 0) {
return topSentence.text;
}
const nested = output.output as Record<string, unknown> | undefined;
const nestedSentence = nested?.sentence as Record<string, unknown> | undefined;
if (typeof nestedSentence?.text === "string") {
return nestedSentence.text;
}
return "";
}
const choices = output.choices as Array<Record<string, unknown>> | undefined;
if (!choices?.length) return "";
const texts: string[] = [];
for (const choice of choices) {
const message = choice.message as Record<string, unknown> | undefined;
if (!message) continue;
const content = message.content;
if (typeof content === "string") {
texts.push(content);
continue;
}
if (!Array.isArray(content)) continue;
for (const item of content) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
if (typeof record.text === "string") {
texts.push(record.text);
}
}
}
}
return texts.join("");
}
/**
* Normalize async ASR task transcription items:
* - classic models: `output.results[]`
* - qwen3-asr-flash-filetrans*: `output.result.transcription_url`
*/
export function collectAsrTranscriptionItems(output: {
results?: Array<{
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}>;
result?: { transcription_url?: string };
}): Array<{
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}> {
if (output.results && output.results.length > 0) {
return output.results;
}
const transcriptionUrl = output.result?.transcription_url;
if (typeof transcriptionUrl === "string" && transcriptionUrl.length > 0) {
return [{ transcription_url: transcriptionUrl, subtask_status: "SUCCEEDED" }];
}
return [];
}
+26 -1
View File
@@ -5,7 +5,13 @@ import { ExitCode } from "../errors/codes.ts";
import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts";
import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts";
import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts";
import { McpClient } from "./mcp.ts";
import {
bailianMcpPath,
bailianMcpSsePath,
connectBailianMcpWithFallback,
McpClient,
type McpConnectedClient,
} from "./mcp.ts";
import { callConsoleGateway } from "../console/gateway.ts";
import { refreshAccessToken } from "../auth/refresh-token.ts";
import { maskToken } from "../utils/token.ts";
@@ -164,6 +170,25 @@ export class Client {
return new McpClient(this.http, url, this.deps.apiCred?.token);
}
/**
* Connect to a Bailian MCP: try Streamable HTTP, then SSE on 405 (except WebSearch).
* `urlOverride` maps to `--url`: Streamable first, then classic SSE on the same URL (405/404).
*/
connectBailianMcp(
serverCode: string,
urlOverride?: string,
): Promise<{ client: McpConnectedClient; url: string }> {
this.requireApi();
return connectBailianMcpWithFallback({
deps: this.http,
authToken: this.deps.apiCred?.token,
httpUrl: this.url(bailianMcpPath(serverCode)),
sseUrl: this.url(bailianMcpSsePath(serverCode)),
serverCode,
urlOverride,
});
}
async console<T>(api: string, data: Record<string, unknown>): Promise<T> {
if (!this.deps.consoleCred) {
throw new BailianError("This command needs a console access token.", ExitCode.AUTH);
+20
View File
@@ -6,6 +6,11 @@ export function chatPath(): string {
return "/compatible-mode/v1/chat/completions";
}
// ---- Responses (OpenAI Compatible) ----
export function responsesPath(): string {
return "/compatible-mode/v1/responses";
}
// ---- Image Generation (DashScope) ----
/** Async image API used by wan2.6-t2i / wan2.6-image (T2I) and similar message-format models. */
export function imagePath(): string {
@@ -32,11 +37,26 @@ export function videoGeneratePath(): string {
return "/api/v1/services/aigc/video-generation/video-synthesis";
}
/** POST /api/v1/services/aigc/image2video/video-synthesis — kf2v (first+last frame). */
export function image2videoPath(): string {
return "/api/v1/services/aigc/image2video/video-synthesis";
}
// ---- Async Task Query ----
export function taskPath(taskId: string): string {
return `/api/v1/tasks/${encodeURIComponent(taskId)}`;
}
// ---- Model Rate Limits (DashScope) ----
export function modelsLimitsPath(): string {
return "/api/v1/models/limits";
}
// ---- Model Permissions (DashScope) ----
export function modelsPermissionsPath(): string {
return "/api/v1/models/permissions";
}
// ---- Application (Agent / Workflow) ----
export function appCompletionPath(appId: string): string {
return `/api/v1/apps/${encodeURIComponent(appId)}/completion`;
+30 -2
View File
@@ -13,12 +13,16 @@ export {
memoryNodePath,
memorySearchPath,
mcpWebSearchPath,
modelsLimitsPath,
modelsPermissionsPath,
profileSchemaPath,
responsesPath,
speechRecognizePath,
speechSynthesizePath,
taskPath,
userProfilePath,
videoGeneratePath,
image2videoPath,
} from "./endpoints.ts";
export {
isLegacyImage2ImageModel,
@@ -34,6 +38,18 @@ export {
type ImageInputStyle,
type ImageSizeProfile,
} from "./image-routes.ts";
export {
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
inferAudioFormatHint,
resolveAsrApi,
type AsrApiKind,
type AsrApiRoute,
type AsrFlashFamily,
type BuildAsrFlashRequestOpts,
} from "./asr-routes.ts";
export { CHANNEL, sourceConfig, trackingHeaders, type TrackingIdentity } from "./headers.ts";
export type { HttpDeps, RequestOpts } from "./http.ts";
export { request, requestJson } from "./http.ts";
@@ -57,7 +73,19 @@ export {
type AcsQueryParams,
type AcsSignConfig,
} from "./acs.ts";
export type { McpTool, McpToolResult } from "./mcp.ts";
export { McpClient, bailianMcpPath } from "./mcp.ts";
export type {
McpTool,
McpToolResult,
McpConnectedClient,
ConnectBailianMcpOptions,
} from "./mcp.ts";
export {
McpClient,
bailianMcpPath,
bailianMcpSsePath,
isStreamableHttpUnsupported,
isUrlOverrideSseFallbackCandidate,
connectBailianMcpWithFallback,
} from "./mcp.ts";
export type { ServerSentEvent } from "./stream.ts";
export { parseSSE } from "./stream.ts";
+474
View File
@@ -0,0 +1,474 @@
/**
* MCP classic HTTP+SSE client (protocol 2024-11-05 transport).
*
* Flow: GET /sse → endpoint event → POST JSON-RPC to message URL;
* responses arrive as SSE `message` events matched by JSON-RPC id.
*/
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { HttpDeps } from "./http.ts";
import { trackingHeaders } from "./headers.ts";
import type { McpTool, McpToolResult } from "./mcp.ts";
import { parseSSE } from "./stream.ts";
interface JsonRpcResponse {
jsonrpc: "2.0";
id?: number | string | null;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
type PendingResolver = {
resolve: (value: JsonRpcResponse) => void;
reject: (reason: unknown) => void;
};
/** Match JSON-RPC ids with string keys (number or string echo from server). */
function pendingKey(id: number | string): string {
return String(id);
}
export class McpSseClient {
private sseUrl: string;
private messageUrl: string | undefined;
private nextId = 1;
private deps: HttpDeps;
private authToken: string | undefined;
private abortController: AbortController | undefined;
private pending = new Map<string, PendingResolver>();
private endpointReady: Promise<void>;
private resolveEndpoint: (() => void) | undefined;
private rejectEndpoint: ((reason: unknown) => void) | undefined;
private closed = false;
/** Set when the SSE GET ends without an intentional close(); later RPCs fail fast. */
private streamEnded = false;
constructor(deps: HttpDeps, sseUrl: string, authToken?: string) {
this.deps = deps;
this.sseUrl = sseUrl;
this.authToken = authToken;
this.endpointReady = new Promise<void>((resolve, reject) => {
this.resolveEndpoint = resolve;
this.rejectEndpoint = reject;
});
}
/** Open the SSE session and run initialize / notifications/initialized. */
async initialize(): Promise<void> {
if (!this.authToken) {
throw new BailianError("This command needs a model-domain API key.", ExitCode.AUTH);
}
await this.openSse();
const result = await this.rpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: {
name: this.deps.identity.clientName,
version: this.deps.identity.version,
},
});
if (this.deps.settings.verbose) {
console.error(`[MCP SSE] Session initialized`);
console.error(`[MCP SSE] Server: ${JSON.stringify(result)}`);
}
await this.notify("notifications/initialized");
}
async listTools(): Promise<McpTool[]> {
const result = (await this.rpc("tools/list")) as { tools: McpTool[] };
return result.tools || [];
}
async callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
const result = (await this.rpc("tools/call", { name, arguments: args })) as McpToolResult;
return result;
}
/** Abort the hanging GET /sse so the CLI process can exit. */
close(): void {
if (this.closed) return;
this.closed = true;
this.abortController?.abort();
this.failPending(new BailianError("MCP SSE session closed.", ExitCode.GENERAL));
this.messageUrl = undefined;
}
private failPending(reason: unknown): void {
for (const [, waiter] of this.pending) {
waiter.reject(reason);
}
this.pending.clear();
}
private markStreamEnded(reason: BailianError): void {
this.streamEnded = true;
this.messageUrl = undefined;
this.failPending(reason);
}
private async openSse(): Promise<void> {
if (this.abortController) return;
// One abortController for header/error-body wait; clear timer before the long-lived stream.
this.abortController = new AbortController();
const timeoutMs = this.deps.settings.timeout * 1000;
let headerTimedOut = false;
const headerTimer = setTimeout(() => {
headerTimedOut = true;
this.abortController?.abort();
}, timeoutMs);
const headers: Record<string, string> = {
Accept: "text/event-stream",
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
...trackingHeaders(this.deps.identity),
};
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
if (this.deps.settings.verbose) {
console.error(`> GET ${this.sseUrl}`);
}
let response: Response;
try {
response = await fetch(this.sseUrl, {
method: "GET",
headers,
signal: this.abortController.signal,
});
} catch (error) {
clearTimeout(headerTimer);
// Allow a later initialize() to openSse again on this instance.
this.abortController = undefined;
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (headerTimedOut) {
throw new BailianError("MCP SSE timed out waiting for response headers.", ExitCode.TIMEOUT);
}
// Rethrow fetch failures so runtime can surface errno (e.g. ENOTFOUND) in JSON/text.
throw error;
}
if (this.deps.settings.verbose) {
console.error(`< ${response.status} ${response.statusText}`);
}
if (!response.ok) {
// Keep headerTimer until error body is read (or times out).
let errMsg = `MCP request failed: ${response.status} ${response.statusText}`;
try {
const errBody = await response.text();
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
} catch (error) {
clearTimeout(headerTimer);
this.abortController = undefined;
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (headerTimedOut) {
throw new BailianError(
"MCP SSE timed out reading error response body.",
ExitCode.TIMEOUT,
);
}
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
}
clearTimeout(headerTimer);
this.abortController = undefined;
// Do not rejectEndpoint — openSse never awaits endpointReady on this path.
throw new BailianError(errMsg, ExitCode.GENERAL);
}
clearTimeout(headerTimer);
void this.consumeSse(response).catch((error) => {
if (this.closed) return;
const reason =
error instanceof BailianError
? error
: new BailianError(
`MCP SSE stream failed: ${error instanceof Error ? error.message : String(error)}`,
ExitCode.GENERAL,
);
this.rejectEndpoint?.(reason);
// consumeSse already markStreamEnded on a clean end; cover parse/read failures here.
if (!this.streamEnded) {
this.markStreamEnded(reason);
}
});
const endpointTimeout = cancellableTimeoutReject(
timeoutMs,
"MCP SSE timed out waiting for endpoint event.",
);
try {
await Promise.race([this.endpointReady, endpointTimeout.promise]);
} finally {
endpointTimeout.cancel();
}
}
private async consumeSse(response: Response): Promise<void> {
for await (const event of parseSSE(response)) {
if (this.closed) break;
// Spec requires event: endpoint; ignore unnamed events so JSON is not treated as a URL.
if (event.event === "endpoint") {
const raw = event.data.trim();
if (!raw) continue;
// Only accept same-origin message URLs so we never forward the Bearer token cross-origin.
this.messageUrl = resolveSameOriginMessageUrl(this.sseUrl, raw);
this.resolveEndpoint?.();
this.resolveEndpoint = undefined;
this.rejectEndpoint = undefined;
continue;
}
// Omitted SSE event type defaults to "message".
if (event.event === "message" || event.event === undefined) {
let payload: JsonRpcResponse;
try {
payload = JSON.parse(event.data) as JsonRpcResponse;
} catch {
continue;
}
if (typeof payload.id !== "number" && typeof payload.id !== "string") continue;
const key = pendingKey(payload.id);
const waiter = this.pending.get(key);
if (!waiter) continue;
this.pending.delete(key);
waiter.resolve(payload);
}
}
if (this.closed) return;
if (!this.messageUrl) {
const error = new BailianError(
"MCP SSE stream ended before endpoint event.",
ExitCode.GENERAL,
);
this.rejectEndpoint?.(error);
throw error;
}
// After endpoint: mark dead and wake pending; don't throw (avoid unhandledRejection).
this.markStreamEnded(new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL));
}
private async rpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
const id = this.nextId++;
const key = pendingKey(id);
const body = {
jsonrpc: "2.0" as const,
id,
method,
...(params ? { params } : {}),
};
const timeoutMs = this.deps.settings.timeout * 1000;
const responsePromise = new Promise<JsonRpcResponse>((resolve, reject) => {
this.pending.set(key, { resolve, reject });
});
// Stream may end and reject pending before Promise.race; attach catch to avoid unhandledRejection.
void responsePromise.catch(() => undefined);
const responseTimeout = cancellableTimeoutReject(
timeoutMs,
`MCP SSE timed out waiting for response to ${method}.`,
);
try {
await this.postMessage(body);
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
const data = await Promise.race([responsePromise, responseTimeout.promise]);
if (data.error) {
throw new BailianError(
`MCP error (${data.error.code}): ${data.error.message}`,
ExitCode.GENERAL,
);
}
return data.result;
} catch (error) {
this.pending.delete(key);
throw error;
} finally {
responseTimeout.cancel();
}
}
private async notify(method: string, params?: Record<string, unknown>): Promise<void> {
const body = {
jsonrpc: "2.0" as const,
method,
...(params ? { params } : {}),
};
await this.postMessage(body);
}
private async postMessage(body: unknown): Promise<void> {
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
if (!this.messageUrl) {
throw new BailianError("MCP SSE message endpoint is not ready.", ExitCode.GENERAL);
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
...trackingHeaders(this.deps.identity),
};
// Bearer is only sent to a messageUrl that already passed the same-origin check.
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
if (this.deps.settings.verbose) {
console.error(`> POST ${this.messageUrl}`);
console.error(`> Method: ${(body as { method?: string }).method}`);
}
const timeoutMs = this.deps.settings.timeout * 1000;
// Combine per-RPC timeout with session abort so close() cancels in-flight POSTs.
const requestSignal = createLinkedAbortSignal(timeoutMs, this.abortController?.signal);
let res: Response;
try {
try {
res = await fetch(this.messageUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: requestSignal.signal,
});
} catch (error) {
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
throw error;
}
if (this.deps.settings.verbose) {
console.error(`< ${res.status} ${res.statusText}`);
}
if (!res.ok) {
// Keep signal until error body is read (same class of bug as GET openSse).
let errMsg = `MCP request failed: ${res.status} ${res.statusText}`;
try {
const errBody = await res.text();
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
} catch (error) {
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (requestSignal.timedOut) {
throw new BailianError(
"MCP SSE timed out reading error response body.",
ExitCode.TIMEOUT,
);
}
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
}
throw new BailianError(errMsg, ExitCode.GENERAL);
}
} finally {
requestSignal.cleanup();
}
}
}
/** Resolve the SSE endpoint data to an absolute URL and require same origin as sseUrl. */
export function resolveSameOriginMessageUrl(sseUrl: string, endpointData: string): string {
let resolved: URL;
let base: URL;
try {
base = new URL(sseUrl);
resolved = new URL(endpointData, sseUrl);
} catch {
throw new BailianError(
`MCP SSE endpoint is not a valid URL: ${endpointData}`,
ExitCode.GENERAL,
);
}
if (resolved.origin !== base.origin) {
throw new BailianError(
`MCP SSE endpoint origin mismatch: expected ${base.origin}, got ${resolved.origin}`,
ExitCode.GENERAL,
);
}
return resolved.toString();
}
/**
* Cancellable timeout rejection: after Promise.race settles, call cancel()
* to clear the timer and avoid unhandledRejection.
*/
function cancellableTimeoutReject(
timeoutMs: number,
message: string,
): { promise: Promise<never>; cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined;
const promise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
timer = undefined;
reject(new BailianError(message, ExitCode.TIMEOUT));
}, timeoutMs);
});
// Swallow late rejects after cancel to avoid unhandledRejection.
void promise.catch(() => undefined);
return {
promise,
cancel: () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
},
};
}
/** Timeout + optional parent abort without AbortSignal.any (Node 18). */
function createLinkedAbortSignal(
timeoutMs: number,
parentSignal?: AbortSignal,
): { signal: AbortSignal; cleanup: () => void; timedOut: boolean } {
const controller = new AbortController();
const state = { timedOut: false };
const timeout = setTimeout(() => {
state.timedOut = true;
controller.abort();
}, timeoutMs);
const abortFromParent = () => controller.abort(parentSignal?.reason);
const cleanup = () => {
clearTimeout(timeout);
parentSignal?.removeEventListener("abort", abortFromParent);
};
if (parentSignal?.aborted) abortFromParent();
else parentSignal?.addEventListener("abort", abortFromParent, { once: true });
controller.signal.addEventListener("abort", cleanup, { once: true });
return {
signal: controller.signal,
cleanup,
get timedOut() {
return state.timedOut;
},
};
}
+137 -2
View File
@@ -15,6 +15,8 @@ import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { HttpDeps } from "./http.ts";
import { trackingHeaders } from "./headers.ts";
import { McpSseClient } from "./mcp-sse.ts";
import { parseSSE } from "./stream.ts";
// ---- JSON-RPC 2.0 Types ----
@@ -27,7 +29,7 @@ interface JsonRpcRequest {
interface JsonRpcResponse {
jsonrpc: "2.0";
id: number;
id?: number | string | null;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
@@ -61,6 +63,101 @@ export function bailianMcpPath(serverCode: string): string {
return `/api/v1/mcps/${serverCode}/mcp`;
}
/** Classic SSE path: `/api/v1/mcps/<serverCode>/sse`. */
export function bailianMcpSsePath(serverCode: string): string {
return `/api/v1/mcps/${serverCode}/sse`;
}
/**
* True when Streamable HTTP is unsupported and classic SSE fallback should be tried.
* Anchored to HTTP wrapper text only (not JSON-RPC / nested copies). Bailian 404 excluded.
*/
export function isStreamableHttpUnsupported(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
return /^MCP request failed:\s*405\b/i.test(error.message);
}
/**
* SSE fallback for `--url` (official backwards-compat: same URL, HTTP 405/404 then GET SSE).
*/
export function isUrlOverrideSseFallbackCandidate(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
return /^MCP request failed:\s*(405|404)\b/i.test(error.message);
}
export type McpConnectedClient = {
initialize(): Promise<void>;
listTools(): Promise<McpTool[]>;
callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult>;
close?(): void;
};
export type ConnectBailianMcpOptions = {
deps: HttpDeps;
authToken: string | undefined;
/** Full Streamable HTTP URL (/mcp). */
httpUrl: string;
/** Full classic SSE URL (/sse). */
sseUrl: string;
serverCode: string;
/**
* Explicit `--url` override: try Streamable on that URL first;
* on 405/404 fall back to classic SSE on the same URL.
*/
urlOverride?: string;
};
/**
* Connect via Streamable HTTP first; on 405 (except WebSearch), fall back to SSE.
* `--url` uses the same URL for Streamable then classic SSE (official backwards-compat).
* For WebSearch, rethrow the original error so commands can attach a re-activate hint.
*/
export async function connectBailianMcpWithFallback(
options: ConnectBailianMcpOptions,
): Promise<{ client: McpConnectedClient; url: string }> {
const { deps, authToken, httpUrl, sseUrl, serverCode, urlOverride } = options;
if (urlOverride) {
const httpClient = new McpClient(deps, urlOverride, authToken);
try {
await httpClient.initialize();
return { client: httpClient, url: urlOverride };
} catch (error) {
if (!isUrlOverrideSseFallbackCandidate(error)) {
throw error;
}
}
const sseClient = new McpSseClient(deps, urlOverride, authToken);
try {
await sseClient.initialize();
return { client: sseClient, url: urlOverride };
} catch (error) {
sseClient.close();
throw error;
}
}
const httpClient = new McpClient(deps, httpUrl, authToken);
try {
await httpClient.initialize();
return { client: httpClient, url: httpUrl };
} catch (error) {
if (!isStreamableHttpUnsupported(error) || serverCode === "WebSearch") {
throw error;
}
}
const sseClient = new McpSseClient(deps, sseUrl, authToken);
try {
await sseClient.initialize();
return { client: sseClient, url: sseUrl };
} catch (error) {
sseClient.close();
throw error;
}
}
// ---- MCP Client ----
export class McpClient {
@@ -121,7 +218,7 @@ export class McpClient {
};
const response = await this.send(body);
const data = (await response.json()) as JsonRpcResponse;
const data = await this.readJsonRpcResponse(response, id);
if (data.error) {
throw new BailianError(
@@ -143,6 +240,44 @@ export class McpClient {
await this.send(body);
}
/**
* Read a JSON-RPC response by Content-Type: application/json or text/event-stream.
*/
private async readJsonRpcResponse(
response: Response,
expectedId: number,
): Promise<JsonRpcResponse> {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
return await this.readJsonRpcFromSse(response, expectedId);
}
return (await response.json()) as JsonRpcResponse;
}
private async readJsonRpcFromSse(
response: Response,
expectedId: number,
): Promise<JsonRpcResponse> {
const expectedKey = String(expectedId);
for await (const event of parseSSE(response)) {
if (event.event && event.event !== "message") continue;
let payload: JsonRpcResponse;
try {
payload = JSON.parse(event.data) as JsonRpcResponse;
} catch {
continue;
}
if (payload.id == null) continue;
if (String(payload.id) !== expectedKey) continue;
return payload;
}
throw new BailianError(
"MCP SSE response stream ended without a matching JSON-RPC response.",
ExitCode.GENERAL,
);
}
private async send(body: unknown): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
+97 -46
View File
@@ -7,6 +7,71 @@ export interface ServerSentEvent {
id?: string;
}
/** Normalize CRLF/CR to LF; hold a trailing `\r` so a split CRLF is not double-broken. */
function takeNormalizedSseLines(buffer: string): { lines: string[]; rest: string } {
let text = buffer;
let holdTrailingCr = false;
if (text.endsWith("\r")) {
holdTrailingCr = true;
text = text.slice(0, -1);
}
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const parts = text.split("\n");
const incomplete = parts.pop() ?? "";
return {
lines: parts,
rest: holdTrailingCr ? `${incomplete}\r` : incomplete,
};
}
function applySseLine(
line: string,
event: Partial<ServerSentEvent>,
maxBuffer: number,
): { event: Partial<ServerSentEvent>; completed?: ServerSentEvent } {
if (line === "") {
if (event.data === undefined) {
return { event: {} };
}
return {
event: {},
completed: { data: event.data, event: event.event, id: event.id },
};
}
if (line.startsWith(":")) {
return { event };
}
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
return { event };
}
const field = line.slice(0, colonIndex);
const fieldValue = line.slice(colonIndex + 1).trimStart();
const nextEvent: Partial<ServerSentEvent> = { ...event };
switch (field) {
case "data":
nextEvent.data =
nextEvent.data !== undefined ? `${nextEvent.data}\n${fieldValue}` : fieldValue;
if (nextEvent.data.length > maxBuffer) {
throw new BailianError("SSE event exceeded the maximum buffer size.", ExitCode.GENERAL);
}
break;
case "event":
nextEvent.event = fieldValue;
break;
case "id":
nextEvent.id = fieldValue;
break;
}
return { event: nextEvent };
}
export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEvent> {
const reader = response.body?.getReader();
if (!reader) return;
@@ -14,69 +79,55 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
const decoder = new TextDecoder();
let buffer = "";
// Guard against a hostile or malfunctioning stream that never emits a newline
// (or builds a single absurdly large event): bound the in-memory buffer so the
// parser cannot be driven to exhaust process memory.
const MAX_SSE_BUFFER = 16 * 1024 * 1024; // 16 MiB
try {
// Keep partial event fields across chunks.
let event: Partial<ServerSentEvent> = {};
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (done) {
// EOF: treat any held `\r` as a line ending.
if (buffer.length > 0) {
const finalText = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const parts = finalText.split("\n");
buffer = parts.pop() ?? "";
for (const line of parts) {
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
event = applied.event;
if (applied.completed) {
yield applied.completed;
}
}
}
break;
}
buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER) {
throw new BailianError("SSE stream exceeded the maximum buffer size.", ExitCode.GENERAL);
}
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let event: Partial<ServerSentEvent> = {};
const { lines, rest } = takeNormalizedSseLines(buffer);
buffer = rest;
for (const line of lines) {
if (line === "") {
if (event.data !== undefined) {
yield { data: event.data, event: event.event, id: event.id };
}
event = {};
continue;
}
if (line.startsWith(":")) continue; // comment
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const field = line.slice(0, colonIndex);
const value = line.slice(colonIndex + 1).trimStart();
switch (field) {
case "data":
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
if (event.data.length > MAX_SSE_BUFFER) {
throw new BailianError(
"SSE event exceeded the maximum buffer size.",
ExitCode.GENERAL,
);
}
break;
case "event":
event.event = value;
break;
case "id":
event.id = value;
break;
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
event = applied.event;
if (applied.completed) {
yield applied.completed;
}
}
}
// Flush remaining
if (buffer.trim() && buffer.includes("data:")) {
const colonIndex = buffer.indexOf(":");
if (colonIndex !== -1) {
yield { data: buffer.slice(colonIndex + 1).trimStart() };
}
// Legacy EOF flush: apply trailing field line and dispatch with event/id intact.
if (buffer.length > 0) {
const applied = applySseLine(buffer, event, MAX_SSE_BUFFER);
event = applied.event;
}
if (event.data !== undefined) {
yield { data: event.data, event: event.event, id: event.id };
}
} finally {
reader.releaseLock();
+19
View File
@@ -63,6 +63,25 @@ export interface ConsoleGatewayRequest {
data: Record<string, unknown>;
}
/** Console-call signature shared by catalog helpers (`client.console` or an anonymous call). */
export type ConsoleCall = (api: string, data: Record<string, unknown>) => Promise<unknown>;
/**
* Build an anonymous (token-less) gateway caller for public catalog APIs such
* as `listFoundationModels` — no console login required.
*/
export function anonymousConsoleCall(
config: Pick<Settings, "consoleRegion" | "consoleSite" | "consoleSwitchAgent" | "timeout">,
): ConsoleCall {
const eff = effectiveConsoleGatewayConfig(config);
return (api, data) =>
callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
config.timeout,
{ api, data },
);
}
function buildGatewayParams(
api: string,
data: Record<string, unknown>,
+13 -2
View File
@@ -1,5 +1,14 @@
export type { ConsoleGatewayRequest, ConsoleGatewayTarget, ConsoleSite } from "./gateway.ts";
export { callConsoleGateway, effectiveConsoleGatewayConfig } from "./gateway.ts";
export type {
ConsoleCall,
ConsoleGatewayRequest,
ConsoleGatewayTarget,
ConsoleSite,
} from "./gateway.ts";
export {
anonymousConsoleCall,
callConsoleGateway,
effectiveConsoleGatewayConfig,
} from "./gateway.ts";
export type {
ModelListParams,
ModelListResult,
@@ -12,6 +21,8 @@ export type {
} from "./models.ts";
export {
fetchModelList,
fetchModelListAll,
findModelByName,
fetchModelGroups,
fetchModelDetail,
fetchPredictConfig,
+32 -2
View File
@@ -1,3 +1,5 @@
import type { ConsoleCall } from "./gateway.ts";
export const MODEL_LIST_API =
"zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
export const PREDICT_CONFIG_API = "zeldaEasy.bmp.modelPredictRpcService.getPredictParamConfig";
@@ -6,8 +8,6 @@ export const PREDICT_CONFIG_API = "zeldaEasy.bmp.modelPredictRpcService.getPredi
// Shared helpers
// ---------------------------------------------------------------------------
type ConsoleCall = (api: string, data: Record<string, unknown>) => Promise<unknown>;
/** Unwrap the DataV2 double-envelope that console gateway returns. */
export function unwrapResponse(result: Record<string, unknown>): Record<string, unknown> {
const data = result.data as Record<string, unknown> | undefined;
@@ -77,6 +77,36 @@ export async function fetchModelList(
return { total, models };
}
/** Page through every model-list page and return all raw model items. */
export async function fetchModelListAll(
call: ConsoleCall,
params: Omit<ModelListParams, "pageNo"> = {},
): Promise<Record<string, unknown>[]> {
const pageSize = params.pageSize ?? 50;
const first = await fetchModelList(call, { ...params, pageNo: 1, pageSize });
const allModels = [...first.models];
const totalPages = Math.ceil(first.total / pageSize);
for (let pageNo = 2; pageNo <= totalPages; pageNo++) {
const result = await fetchModelList(call, { ...params, pageNo, pageSize });
if (result.models.length === 0) break;
allModels.push(...result.models);
}
return allModels;
}
/**
* Look up a single model by exact id. The server's `name` filter is a
* substring match, so an exact `model` equality check narrows the result
* (e.g. avoids `qwen3-8b` matching `qwen3-8b-v2`).
*/
export async function findModelByName(
call: ConsoleCall,
modelName: string,
): Promise<Record<string, unknown> | null> {
const result = await fetchModelList(call, { name: modelName, pageSize: 50 });
return result.models.find((item) => item.model === modelName) ?? null;
}
// ---------------------------------------------------------------------------
// Model group types — family-level structure returned by `group: true`
// ---------------------------------------------------------------------------
+1
View File
@@ -7,6 +7,7 @@ export {
registerValidator,
listSupportedFormats,
MAX_DATASET_BYTES,
MAX_CPT_BYTES,
MAX_MEDIA_ZIP_BYTES,
parseDatasetSchemaFlag,
formatIssue,

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