Compare commits

..

1 Commits

Author SHA1 Message Date
chenanran555 ac48c3ec23 faet(agent): add sync and migrate commands 2026-08-10 20:55:02 +08:00
160 changed files with 3888 additions and 8128 deletions
-1
View File
@@ -37,7 +37,6 @@ tools/generated
.claude/settings.local.json
.claude/scheduled_tasks.lock
.cursor/
.qoder/
.qwen/
.qoder
.playwright-mcp/
+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-*/``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)。
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)。
约定:
-61
View File
@@ -6,67 +6,6 @@ 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
- **Focused Bailian Skills** — `npx skills add modelstudioai/cli --all -g` now installs dedicated skills for media generation, fine-tuning, Managed Agent, and shared execution rules, improving task routing while reducing irrelevant context.
### Changed
- **Default image model upgraded to Qwen-Image 3.0** — image generation, image editing, pipelines, the config UI, and related documentation now default to `qwen-image-3.0` for API Key users.
- **Broader coding-agent compatibility** — Skill installation and updates now detect more coding agents, preserve existing installation links, and automatically backfill skills into newly detected agents.
## [1.14.0] - 2026-08-04
### Added
-61
View File
@@ -6,67 +6,6 @@
[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
### 新增
- **百炼 Skill 按领域拆分** —— 通过 `npx skills add modelstudioai/cli --all -g` 可统一安装图片与视频生成、模型微调、Managed Agent 和共享执行协议等专用 Skill提升任务路由准确性并减少无关上下文。
### 变更
- **默认图片模型升级至 Qwen-Image 3.0** —— 普通 API Key 用户的图片生成、图片编辑、Pipeline、配置 UI 和相关文档现在默认使用 `qwen-image-3.0`
- **扩展 Coding Agent 兼容范围** —— Skill 安装与更新现在能够识别更多 Coding Agent保留已有安装链接并自动将 Skill 补充到新识别的 Agent。
## [1.14.0] - 2026-08-04
### 新增
+31 -34
View File
@@ -1,38 +1,8 @@
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**在已有 Node.js**≥ 18.17.0**)的环境用 npm 安装;若没有可用 Node / npm再走二进制一键安装。不要臆造版本号或路径;以用户环境为准。
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**使用二进制一键安装(无需 Node若环境已有 Node / 需要 Command Pack再用 npm。不要臆造版本号或路径;以用户环境为准。
## 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`
## 1. 推荐:二进制安装(无需 Node
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
@@ -67,9 +37,36 @@ bl --version
which bl # Windows: where.exe bl
```
若自动 skill 安装失败,再手动执行:`bl skill init`
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装
> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node回退到上方 npm 安装。
## 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 及用户可做什么。
---
+136 -88
View File
@@ -13,9 +13,8 @@
---
_Chat with Qwen, generate and edit images and videos, understand images, synthesize_
_and recognize speech, call apps, manage memory, retrieve knowledge, search the web —_
_every AI capability, one command away._
_Chat with Qwen, generate images & videos, understand images, call agents,_
_manage memory, search the web — all from your terminal._
_Built for AI Agents. Every command works as a structured tool call._
@@ -23,16 +22,28 @@ _Built for AI Agents. Every command works as a structured tool call._
## Features
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
- **Text chat** — Qwen3.8-max: major gains in agentic coding, frontend coding, and vibe coding
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
## Showcase 1: A Cinematic Short Film from One Sentence
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
## Showcase: One-Sentence Cinematic Video
<p align="center">
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
@@ -45,93 +56,129 @@ _Built for AI Agents. Every command works as a structured tool call._
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
### The single prompt
> _"Generate a roughly 2-minute video in Japanese cinematic style — a sweet, innocent first-love story about a high-school girl. The plot should be heart-fluttering enough to make viewers want to fall in love. Aspect ratio: 16:9."_
>
> _(Original: "帮我生成一段日系影视风格高中女生的青涩初恋故事剧情高甜让人看了想谈恋爱2分钟左右的视频尺寸是16:9")_
## Showcase 2: A Short-Film Director Managed Agent from One Sentence
### How it works
<p align="center">
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="Click to play the demo video" width="720" />
</a>
</p>
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
<p align="center"><i>👆 Click the cover to play the full demo</i></p>
One sentence builds a reusable cloud-side short-film director for storyboarding, storyboard image generation, and video creation:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — understands the requirement and generates the agent configuration
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — validates the configuration, previews the changes, and completes the deployment
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** — runs the director role along with its skills and tools in the cloud
### The single prompt
> _"Build me a Managed Agent app that can produce short films — a director expert that generates videos and can also design the matching storyboards."_
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
## Installation
**Agent install (recommended)**
Send the following to your Agent — it will detect your environment, then install and verify the CLI for you:
```text
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
```
**Install with NPM**
```bash
npm install -g bailian-cli
bl skill init
```
> Requires Node.js >= 18.17.
**Install on macOS/Linux**
```bash
# Recommended — no Node required
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> No Node.js required. The installer automatically installs Bailian Skills.
**Install on Windows**
```powershell
# Windows (PowerShell)
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node users / developers (Node.js >= 18.17)
npm install -g bailian-cli
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> No Node.js required. The installer automatically installs Bailian Skills.
> Binary install does not require Node.js. `npm install -g` remains fully supported.
## Quick Start
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
```bash
# Authenticate, recommended
bl auth login --console
| Scenario | What to say to your Agent |
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
# Or authenticate with an API key
bl auth login --api-key sk-xxxxx
# Or use Token Plan (Base URL built in; the key is tested during login)
bl auth login --config token-plan --api-key sk-sp-xxxxx
# Configure a coding agent to use DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# Chat with Qwen
bl text chat --message "What is DashScope?"
# Multimodal chat (text + image + audio + video)
bl omni --message "Describe this image" --image ./photo.jpg
# Generate an image
bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
# Generate a video from local image
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
# Model recommendation — find the best model for your use case
bl advisor recommend --message "I need a visual-understanding chatbot"
# Compare specific models
bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
# Browser login (required for console capability commands)
bl auth login --console
# Fine-tune & deploy — a one-shot train-to-serve workflow
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
bl finetune capability --model qwen3-8b # Which training types a model supports
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
# Browse models / apps / free-tier quota / usage statistics / workspaces
bl model list # Browse model families and pricing
bl app list
bl usage summary # Unified view: free-tier quota + recent usage overview
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
bl workspace list # List all workspaces
# Rate limit management (list / check / request / history)
bl quota list # View RPM/TPM limits (add --model to filter)
bl quota check # Current usage vs rate limits (add --model/--period)
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
bl quota history # View quota-change history
# Token Plan team management (requires AK/SK, see auth below)
bl token-plan list-seats # View subscription seat details
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
## Authentication
### API Key
### DashScope API Key
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
```bash
# Option 1: Environment variable
export DASHSCOPE_API_KEY=sk-xxxxx
# Option 2: Login command (persisted to ~/.bailian/config.json)
bl auth login --api-key sk-xxxxx
# Option 3: Per-command flag
bl text chat --api-key sk-xxxxx --message "Hello"
```
Get or copy your Token Plan API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
### Token Plan API Key
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
@@ -139,20 +186,26 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### Console Login (OAuth)
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in.
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
```
### Alibaba Cloud OpenAPI AK/SK
### Alibaba Cloud OpenAPI AK/SK (Token Plan only)
Token Plan seat and member management requires an Alibaba Cloud AccessKey. Get yours from the [RAM Console](https://ram.console.aliyun.com/manage/ak).
Required for the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
```bash
# Option 1: Login command (persisted to ~/.bailian/config.json)
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
# Option 2: Environment variables
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
export BAILIAN_WORKSPACE_ID=ws-...
```
## Configuration
@@ -161,31 +214,18 @@ bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
# View current config
bl config show
# List all config profiles
bl config list
# Set defaults
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# Switch config profile
bl config use --name token-plan
# Self-update to latest or a specific version
bl update
bl update --to 0.1.14
```
Config file location: `~/.bailian/config.json`
## Update
```bash
bl update
```
Upgrades the CLI to the latest version and refreshes the installed Agent Skills. Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
## Contributing
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
Scan the QR code to join the Aliyun Model Studio CLI DingTalk user group for usage help, troubleshooting, bug reports, and tips from other users.
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="Aliyun Model Studio CLI DingTalk user group" width="240" />
## Links
| Resource | URL |
@@ -197,3 +237,11 @@ Scan the QR code to join the Aliyun Model Studio CLI DingTalk user group for usa
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
## Contributing
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
+137 -88
View File
@@ -22,16 +22,28 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
## 功能特性
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
- **素材理解** — 图像、文档、音频、长视频的解析与问答
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流接入知识库、记忆库、联网搜索与 MCP 工具
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站aliyun.com账号暂不支持国际站 / 全球站账号。
- **文本对话** — Qwen3.8-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
## 示例 1一句话生成一部电影短片
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT、非阻塞探测任务状态`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
## 示例:一句话生成一部电影短片
<p align="center">
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
@@ -41,96 +53,130 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
<p align="center"><i>👆 点击封面播放完整 2 分钟演示</i></p>
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型解析用户意图、驱动整个工作流
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**百炼的文生/图生/参考生视频模型
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
### 唯一的提示词
> _帮我生成一段日系影视风格高中女生的青涩初恋故事剧情高甜让人看了想谈恋爱2 分钟左右的视频尺寸是 16:9。”_
> _"帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9"_
## 示例 2一句话构建短片导演 Managed Agent
### 工作流程
<p align="center">
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="点击播放演示视频" width="720" />
</a>
</p>
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
<p align="center"><i>👆 点击封面播放完整演示</i></p>
一句话构建一个可复用的云端短片导演,用于分镜设计、分镜图生成和视频创作:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— 理解需求并生成 Agent 配置
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 校验配置、预览变更并完成部署
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** —— 在云端运行导演角色及其 Skill 和工具
### 唯一的提示词
> _“帮我构建一个 managedagent 应用能够实现短片拍摄导演专家生成视频然后也能进行设计对应的分镜图。”_
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
## 安装
**Agent 安装(推荐)**
把下面这句话发给你的 Agent它会自行判断环境并完成安装与校验
```text
请阅读https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
```
**NPM 安装**
```bash
npm install -g bailian-cli
bl skill init
```
> 需要预先安装 Node.js >= 18.17。
**macOS/Linux 安装**
```bash
# 推荐 — 无需本机 Node.js
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
**Windows 安装**
```powershell
# WindowsPowerShell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node 用户 / 开发者(需要 Node.js >= 18.17
npm install -g bailian-cli
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留
## 快速开始
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
```bash
# 认证(推荐浏览器登录)
bl auth login --console
| 场景 | 可以这样对 Agent 说 |
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 或使用 Token Plan已内置 Base URL登录时自动测试 Key
bl auth login --config token-plan --api-key sk-sp-xxxxx
# 配置 Coding Agent 使用 DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# 和通义千问对话
bl text chat --message "你好,介绍一下阿里云百炼平台"
# 多模态对话(文本 + 图片 + 音频 + 视频)
bl omni --message "描述这张图片" --image ./photo.jpg
# 生成图片
bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/
# 图生视频(本地文件自动上传)
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
# 模型推荐 — 根据场景推荐最适合的模型
bl advisor recommend --message "我要做一个能理解图片的客服机器人"
# 对比特定模型
bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代码生成"
# 浏览器登录(控制台能力相关命令需要)
bl auth login --console
# 微调与部署 — 从训练到服务的一站式流程
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0失败/取消报错)
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
bl model list # 浏览模型系列与价格信息
bl app list
bl usage summary # 统一视图:免费额度 + 近期用量概览
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
bl workspace list # 列出所有业务空间
# 限流管理与提额list / check / request / history
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
bl quota check # 当前用量 vs 限流阈值(加 --model/--period
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录
# Token Plan 团队版管理(需 AK/SK见下方认证说明
bl token-plan list-seats # 查看订阅席位明细
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
## 认证方式
### API Key
### DashScope API Key
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
```bash
# 方式一:环境变量
export DASHSCOPE_API_KEY=sk-xxxxx
# 方式二:登录命令(持久化到 ~/.bailian/config.json
bl auth login --api-key sk-xxxxx
# 方式三:命令行参数
bl text chat --api-key sk-xxxxx --message "你好"
```
Token Plan API Key 前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制。
### Token Plan API Key
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
CLI 已内置 Token Plan 的默认 Base URL登录命令会先测试 Key通过后才保存并激活 `token-plan` 配置。
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
@@ -138,20 +184,26 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### 控制台登录OAuth
控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
```
### 阿里云 OpenAPI AK/SK
### 阿里云 OpenAPI AK/SK(仅 Token Plan
Token Plan 的席位与成员管理需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
`token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
```bash
# 方式一:登录命令(持久化到 ~/.bailian/config.json
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
# 方式二:环境变量
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
export BAILIAN_WORKSPACE_ID=ws-...
```
## 配置
@@ -160,31 +212,20 @@ bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
# 查看当前配置
bl config show
# 查看全部配置档
bl config list
# 设置默认值
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# 切换配置档
bl config use --name token-plan
# 自更新到最新版本
bl update
# 安装指定版本
bl update --to 0.1.14
```
配置文件位置:`~/.bailian/config.json`
## 更新
```bash
bl update
```
升级 CLI 至最新版本,并同步更新已安装的 Agent Skills。每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
## 参与贡献
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
欢迎扫码加入阿里云百炼 CLI 钉钉用户交流群获取使用答疑、问题排查、Bug 反馈和使用经验交流支持。
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="阿里云百炼 CLI 钉钉用户交流群" width="240" />
## 相关链接
| 资源 | 地址 |
@@ -196,3 +237,11 @@ bl update
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
## 参与贡献
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
+3 -10
View File
@@ -25,7 +25,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
当前 command 鉴权域(`AuthRequirement`):
- `apiKey` — DashScope / OpenAI-compatible 模型域,用 API key 与 model base URL
- `console` — Bailian Console Gateway,用 console access token + region/site/switchAgent;`workspace_id` 是独立的 Settings 作用域,不属于 credential
- `console` — Bailian Console Gateway,用 console access token + region/site/switchAgent/workspace
- `openapi` — 阿里云 OpenAPI 签名域,用 AccessKey ID/Secret 调用 Token Plan 等 OpenAPI
- `none` — 本地命令、登录/配置类命令、无需 credential 的命令
@@ -35,7 +35,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- `bl auth login --api-key ...` 只更新 `api_key` / `base_url`
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
- `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi``console` 域凭证
- `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`
- `bl auth logout --console` 只清 `access_token`
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token`
- `bl auth logout``api_key` + `base_url` + `access_token` + `access_key_*`
@@ -78,9 +78,6 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- 如新增鉴权域,扩展 `AuthRequirement`
- 更新 `credentialFlagDefs()` 暴露该域可见的 flag
- 必要时新增 `*_AUTH_FLAGS`
- `workspace_id` 是作用域字段而非 credential,不要把它放进 `ConsoleCredential`;读取方式按命令 `auth` 域区分:
- `auth: "console"` 命令通过 `CONSOLE_AUTH_FLAGS` 自动获得 `--workspace-id`,由 `buildSettings()` 解析到 `settings.workspaceId`,命令统一从 `settings.workspaceId` 读取
- `auth: "apiKey"`/`"openapi"`/`"none"` 命令如需 `--workspace-id`,必须自声明 flag;因它不会进入 credential/global flags,命令从 `ctx.flags.workspaceId` 读取(可回退到 `settings.workspaceId`)
- [ ] `packages/core/src/auth/types.ts`:
- 新增 credential 类型 / source / scope 字段
- [ ] `packages/core/src/auth/resolver.ts`:
@@ -134,8 +131,6 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
## 完成后自查
本仓库同时存在 `bl`(packages/cli) 与 `kscli`(packages/kscli) 两个入口,二者共享 core/runtime 鉴权链路,但暴露的命令不同。如果改动会影响两个入口共用的命令或错误提示,再分别验证它们各自实际暴露的路径;不要假设 `kscli` 也有 `bl auth *` 命令。
```sh
# 各种凭证组合
unset DASHSCOPE_API_KEY ALIBABA_CLOUD_ACCESS_KEY_ID ALIBABA_CLOUD_ACCESS_KEY_SECRET
@@ -155,11 +150,9 @@ Console 登录/网关相关改动:
```sh
pnpm -F bailian-cli exec tsx src/main.ts auth login --console
pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json --workspace-id ws-xxx
pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json
```
注意:`usage stats --dry-run` 仍会先校验 workspace,必须传入 `--workspace-id`(或 `BAILIAN_WORKSPACE_ID` / config `workspace_id`)。
## 常见漏点
- ✗ 加了新 token 来源但忘了改 resolver 优先级,实际不生效
+11 -12
View File
@@ -11,17 +11,16 @@
## 统一口径(安装)
1. **Supported install** `bl skill init`(装齐 registry 中全部 `bailian-*`,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它
1. **Supported install** `npx skills add modelstudioai/cli --all -g`(整包装齐,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它Agent Skills / `npx skills` **不会**按 frontmatter 自动拉依赖
3. **不要**在 frontmatter 写 `companions`也不要对外说「companions = 安装器硬依赖」
4. 子集安装`bl skill add --name bailian-protocol,<skill>`;漏装 protocol 会导致相对路径 Read 失败
5. **`bl skill add --all`** 安装 registry 全量(含 `spark-video` 等非 bailian 技能);一键安装 / `bl update``skill init`,不要用 `--all`
4. 子集安装`-s`)为 **advanced / 不推荐**skills CLI 不会自动带上 protocol;漏装会导致相对路径 Read 失败
## 概念图
```text
bailian-protocol ← 共享协议consent / 鉴权 / 版本 / 错误上报)
▲ 靠 `bl skill init` 与业务 skill 同装;非安装器强制 companions
▲ 靠 --all -g 与业务 skill 同装;非安装器强制 companions
┌───────┴────────┬────────────────┬──────────────────┐
bailian-gen bailian-finetune bailian-managed-agent
@@ -38,8 +37,8 @@ bailian-gen bailian-finetune bailian-managed-agent
### A. 分层边界
- [ ] **整包装齐**:安装/升级文案主推 `bl skill init`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init`
- [ ] **整包装齐**:安装/升级文案主推 `--all -g`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `npx skills add modelstudioai/cli --all -g`
- [ ] **软 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」指向句
@@ -47,9 +46,9 @@ bailian-gen bailian-finetune bailian-managed-agent
### B. 文案与落款一致性
- [ ] 领域 skillgen / finetune / managed-agent路由或命令表后有指向 `reference/` 的句;文末 `## references`protocol + reference与家族对齐
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `bl skill init`,不写 companions 必装
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `--all -g`,不写 companions 必装
- [ ] Quick examples 只演示本 skill 职责hub 不示范 `bl image` / `bl video` 等)
- [ ] 若改了安装方式:同步 `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) 同步静态页)
- [ ] 若改了安装方式:同步 `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) 同步静态页)
### C. 归属与生成
@@ -61,8 +60,8 @@ bailian-gen bailian-finetune bailian-managed-agent
```sh
pnpm run sync:skill-assets
# 已发布版本试装
bl skill init
# 本地试装(测本仓库改动,勿只拉远端)
npx skills add "$(pwd)" --all -g -y
```
抽查:打开 `skills/bailian-cli/SKILL.md` 确认无领域子命令明细表、无 `companions`;打开对应领域 skill 确认有「勿猜 flag」与 hand-off。
@@ -70,7 +69,7 @@ bl skill init
## 常见漏点
- ✗ hub 路由表再次抄回 image / video / finetune / managed-agent 明细 → token 膨胀且与领域 skill 双份漂移
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 `bl skill add` 合同不符
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 Agent Skills / `npx skills` 合同不符
- ✗ 软 hand-off 写成硬路径 `../bailian-*/SKILL.md` 当执行前提 → 子集安装断链
- ✗ 只改 SKILL、忘改 `GROUP_OWNER_SKILL` → reference 落错 skill
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖
+136 -88
View File
@@ -13,9 +13,8 @@
---
_Chat with Qwen, generate and edit images and videos, understand images, synthesize_
_and recognize speech, call apps, manage memory, retrieve knowledge, search the web —_
_every AI capability, one command away._
_Chat with Qwen, generate images & videos, understand images, call agents,_
_manage memory, search the web — all from your terminal._
_Built for AI Agents. Every command works as a structured tool call._
@@ -23,16 +22,28 @@ _Built for AI Agents. Every command works as a structured tool call._
## Features
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
- **Text chat** — Qwen3.8-max: major gains in agentic coding, frontend coding, and vibe coding
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
## Showcase 1: A Cinematic Short Film from One Sentence
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
## Showcase: One-Sentence Cinematic Video
<p align="center">
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
@@ -45,93 +56,129 @@ _Built for AI Agents. Every command works as a structured tool call._
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
### The single prompt
> _"Generate a roughly 2-minute video in Japanese cinematic style — a sweet, innocent first-love story about a high-school girl. The plot should be heart-fluttering enough to make viewers want to fall in love. Aspect ratio: 16:9."_
>
> _(Original: "帮我生成一段日系影视风格高中女生的青涩初恋故事剧情高甜让人看了想谈恋爱2分钟左右的视频尺寸是16:9")_
## Showcase 2: A Short-Film Director Managed Agent from One Sentence
### How it works
<p align="center">
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="Click to play the demo video" width="720" />
</a>
</p>
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
<p align="center"><i>👆 Click the cover to play the full demo</i></p>
One sentence builds a reusable cloud-side short-film director for storyboarding, storyboard image generation, and video creation:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — understands the requirement and generates the agent configuration
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — validates the configuration, previews the changes, and completes the deployment
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** — runs the director role along with its skills and tools in the cloud
### The single prompt
> _"Build me a Managed Agent app that can produce short films — a director expert that generates videos and can also design the matching storyboards."_
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
## Installation
**Agent install (recommended)**
Send the following to your Agent — it will detect your environment, then install and verify the CLI for you:
```text
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
```
**Install with NPM**
```bash
npm install -g bailian-cli
bl skill init
```
> Requires Node.js >= 18.17.
**Install on macOS/Linux**
```bash
# Recommended — no Node required
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> No Node.js required. The installer automatically installs Bailian Skills.
**Install on Windows**
```powershell
# Windows (PowerShell)
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node users / developers (Node.js >= 18.17)
npm install -g bailian-cli
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> No Node.js required. The installer automatically installs Bailian Skills.
> Binary install does not require Node.js. `npm install -g` remains fully supported.
## Quick Start
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
```bash
# Authenticate, recommended
bl auth login --console
| Scenario | What to say to your Agent |
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
# Or authenticate with an API key
bl auth login --api-key sk-xxxxx
# Or use Token Plan (Base URL built in; the key is tested during login)
bl auth login --config token-plan --api-key sk-sp-xxxxx
# Configure a coding agent to use DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# Chat with Qwen
bl text chat --message "What is DashScope?"
# Multimodal chat (text + image + audio + video)
bl omni --message "Describe this image" --image ./photo.jpg
# Generate an image
bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
# Generate a video from local image
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
# Model recommendation — find the best model for your use case
bl advisor recommend --message "I need a visual-understanding chatbot"
# Compare specific models
bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
# Browser login (required for console capability commands)
bl auth login --console
# Fine-tune & deploy — a one-shot train-to-serve workflow
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
bl finetune capability --model qwen3-8b # Which training types a model supports
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
# Browse models / apps / free-tier quota / usage statistics / workspaces
bl model list # Browse model families and pricing
bl app list
bl usage summary # Unified view: free-tier quota + recent usage overview
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
bl workspace list # List all workspaces
# Rate limit management (list / check / request / history)
bl quota list # View RPM/TPM limits (add --model to filter)
bl quota check # Current usage vs rate limits (add --model/--period)
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
bl quota history # View quota-change history
# Token Plan team management (requires AK/SK, see auth below)
bl token-plan list-seats # View subscription seat details
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
## Authentication
### API Key
### DashScope API Key
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
```bash
# Option 1: Environment variable
export DASHSCOPE_API_KEY=sk-xxxxx
# Option 2: Login command (persisted to ~/.bailian/config.json)
bl auth login --api-key sk-xxxxx
# Option 3: Per-command flag
bl text chat --api-key sk-xxxxx --message "Hello"
```
Get or copy your Token Plan API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
### Token Plan API Key
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
@@ -139,20 +186,26 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### Console Login (OAuth)
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in.
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
```
### Alibaba Cloud OpenAPI AK/SK
### Alibaba Cloud OpenAPI AK/SK (Token Plan only)
Token Plan seat and member management requires an Alibaba Cloud AccessKey. Get yours from the [RAM Console](https://ram.console.aliyun.com/manage/ak).
Required for the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
```bash
# Option 1: Login command (persisted to ~/.bailian/config.json)
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
# Option 2: Environment variables
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
export BAILIAN_WORKSPACE_ID=ws-...
```
## Configuration
@@ -161,31 +214,18 @@ bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
# View current config
bl config show
# List all config profiles
bl config list
# Set defaults
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# Switch config profile
bl config use --name token-plan
# Self-update to latest or a specific version
bl update
bl update --to 0.1.14
```
Config file location: `~/.bailian/config.json`
## Update
```bash
bl update
```
Upgrades the CLI to the latest version and refreshes the installed Agent Skills. Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
## Contributing
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
Scan the QR code to join the Aliyun Model Studio CLI DingTalk user group for usage help, troubleshooting, bug reports, and tips from other users.
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="Aliyun Model Studio CLI DingTalk user group" width="240" />
## Links
| Resource | URL |
@@ -197,3 +237,11 @@ Scan the QR code to join the Aliyun Model Studio CLI DingTalk user group for usa
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
## Contributing
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
+137 -88
View File
@@ -22,16 +22,28 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
## 功能特性
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
- **素材理解** — 图像、文档、音频、长视频的解析与问答
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流接入知识库、记忆库、联网搜索与 MCP 工具
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站aliyun.com账号暂不支持国际站 / 全球站账号。
- **文本对话** — Qwen3.8-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
## 示例 1一句话生成一部电影短片
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT、非阻塞探测任务状态`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
## 示例:一句话生成一部电影短片
<p align="center">
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
@@ -41,96 +53,130 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
<p align="center"><i>👆 点击封面播放完整 2 分钟演示</i></p>
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型解析用户意图、驱动整个工作流
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**百炼的文生/图生/参考生视频模型
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
### 唯一的提示词
> _帮我生成一段日系影视风格高中女生的青涩初恋故事剧情高甜让人看了想谈恋爱2 分钟左右的视频尺寸是 16:9。”_
> _"帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9"_
## 示例 2一句话构建短片导演 Managed Agent
### 工作流程
<p align="center">
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="点击播放演示视频" width="720" />
</a>
</p>
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
<p align="center"><i>👆 点击封面播放完整演示</i></p>
一句话构建一个可复用的云端短片导演,用于分镜设计、分镜图生成和视频创作:
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— 理解需求并生成 Agent 配置
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 校验配置、预览变更并完成部署
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** —— 在云端运行导演角色及其 Skill 和工具
### 唯一的提示词
> _“帮我构建一个 managedagent 应用能够实现短片拍摄导演专家生成视频然后也能进行设计对应的分镜图。”_
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
## 安装
**Agent 安装(推荐)**
把下面这句话发给你的 Agent它会自行判断环境并完成安装与校验
```text
请阅读https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
```
**NPM 安装**
```bash
npm install -g bailian-cli
bl skill init
```
> 需要预先安装 Node.js >= 18.17。
**macOS/Linux 安装**
```bash
# 推荐 — 无需本机 Node.js
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
**Windows 安装**
```powershell
# WindowsPowerShell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node 用户 / 开发者(需要 Node.js >= 18.17
npm install -g bailian-cli
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留
## 快速开始
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
```bash
# 认证(推荐浏览器登录)
bl auth login --console
| 场景 | 可以这样对 Agent 说 |
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 或使用 Token Plan已内置 Base URL登录时自动测试 Key
bl auth login --config token-plan --api-key sk-sp-xxxxx
# 配置 Coding Agent 使用 DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# 和通义千问对话
bl text chat --message "你好,介绍一下阿里云百炼平台"
# 多模态对话(文本 + 图片 + 音频 + 视频)
bl omni --message "描述这张图片" --image ./photo.jpg
# 生成图片
bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/
# 图生视频(本地文件自动上传)
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
# 模型推荐 — 根据场景推荐最适合的模型
bl advisor recommend --message "我要做一个能理解图片的客服机器人"
# 对比特定模型
bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代码生成"
# 浏览器登录(控制台能力相关命令需要)
bl auth login --console
# 微调与部署 — 从训练到服务的一站式流程
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0失败/取消报错)
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
bl model list # 浏览模型系列与价格信息
bl app list
bl usage summary # 统一视图:免费额度 + 近期用量概览
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
bl workspace list # 列出所有业务空间
# 限流管理与提额list / check / request / history
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
bl quota check # 当前用量 vs 限流阈值(加 --model/--period
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录
# Token Plan 团队版管理(需 AK/SK见下方认证说明
bl token-plan list-seats # 查看订阅席位明细
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
## 认证方式
### API Key
### DashScope API Key
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
```bash
# 方式一:环境变量
export DASHSCOPE_API_KEY=sk-xxxxx
# 方式二:登录命令(持久化到 ~/.bailian/config.json
bl auth login --api-key sk-xxxxx
# 方式三:命令行参数
bl text chat --api-key sk-xxxxx --message "你好"
```
Token Plan API Key 前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制。
### Token Plan API Key
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
CLI 已内置 Token Plan 的默认 Base URL登录命令会先测试 Key通过后才保存并激活 `token-plan` 配置。
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
@@ -138,20 +184,26 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### 控制台登录OAuth
控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
```
### 阿里云 OpenAPI AK/SK
### 阿里云 OpenAPI AK/SK(仅 Token Plan
Token Plan 的席位与成员管理需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
`token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
```bash
# 方式一:登录命令(持久化到 ~/.bailian/config.json
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
# 方式二:环境变量
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
export BAILIAN_WORKSPACE_ID=ws-...
```
## 配置
@@ -160,31 +212,20 @@ bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
# 查看当前配置
bl config show
# 查看全部配置档
bl config list
# 设置默认值
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# 切换配置档
bl config use --name token-plan
# 自更新到最新版本
bl update
# 安装指定版本
bl update --to 0.1.14
```
配置文件位置:`~/.bailian/config.json`
## 更新
```bash
bl update
```
升级 CLI 至最新版本,并同步更新已安装的 Agent Skills。每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
## 参与贡献
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
欢迎扫码加入阿里云百炼 CLI 钉钉用户交流群获取使用答疑、问题排查、Bug 反馈和使用经验交流支持。
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="阿里云百炼 CLI 钉钉用户交流群" width="240" />
## 相关链接
| 资源 | 地址 |
@@ -196,3 +237,11 @@ bl update
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
## 参与贡献
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.15.1",
"version": "1.14.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+6 -24
View File
@@ -45,20 +45,15 @@ import {
usageFreetier,
usageStats,
usageSummary,
usageTokenPlan,
usageCodingPlan,
pipelineRun,
pipelineValidate,
advisorRecommend,
modelList,
workspaceList,
quotaList,
quotaUpdate,
quotaRequest,
quotaHistory,
quotaCheck,
permissionList,
permissionGrant,
permissionRevoke,
datasetUpload,
datasetList,
datasetGet,
@@ -98,9 +93,10 @@ import {
skillUpdate,
skillRemove,
skillList,
skillInit,
managedAgentInit,
managedAgentValidate,
managedAgentSync,
managedAgentMigrate,
managedAgentPlan,
managedAgentApply,
managedAgentDestroy,
@@ -169,20 +165,15 @@ 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 update": quotaUpdate,
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"permission list": permissionList,
"permission grant": permissionGrant,
"permission revoke": permissionRevoke,
"dataset upload": datasetUpload,
"dataset list": datasetList,
"dataset get": datasetGet,
@@ -222,9 +213,10 @@ export const commands: Record<string, AnyCommand> = {
"skill update": skillUpdate,
"skill remove": skillRemove,
"skill list": skillList,
"skill init": skillInit,
"managed-agent init": managedAgentInit,
"managed-agent validate": managedAgentValidate,
"managed-agent sync": managedAgentSync,
"managed-agent migrate": managedAgentMigrate,
"managed-agent plan": managedAgentPlan,
"managed-agent apply": managedAgentApply,
"managed-agent destroy": managedAgentDestroy,
@@ -241,13 +233,3 @@ 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,
};
+9 -12
View File
@@ -1,5 +1,5 @@
import { createCli } from "bailian-cli-runtime";
import { commandAliases, commands } from "./commands.ts";
import { commands } from "./commands.ts";
import { commandPackPolicy } from "./command-pack-policy.ts";
import pkg from "../package.json" with { type: "json" };
@@ -10,14 +10,11 @@ const quickStartTasks = [
"Help me analyze this video and write a Xiaohongshu-style post",
] as const;
void createCli(
{ ...commands, ...commandAliases },
{
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
},
).run();
void createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
}).run();
@@ -7,15 +7,10 @@ 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/);
@@ -23,24 +18,6 @@ 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.15.1",
"version": "1.14.0",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -1,5 +1,5 @@
// Read-only discovery of locally installed AI tooling, surfaced by `config ui`:
// - Agent skills installed under ~/.agents/skills (via `bl skill add`).
// - Agent skills installed under ~/.agents/skills (via `npx skills 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. `bl skill init` /
* `bl skill add` fans skills out into each installed agent, so the same skill can
* 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
* live in several of these roots at once.
*/
function skillRoots(home: string): Array<{ source: string; dir: string }> {
@@ -61,7 +61,7 @@ export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
// without persisting a value that would pin the model.
export const UI_MODEL_DEFAULTS: Record<string, string> = {
default_text_model: "qwen3.8-max",
default_image_model: "qwen-image-3.0",
default_image_model: "qwen-image-2.0",
default_video_model: "happyhorse-1.1-t2v",
default_speech_model: "cosyvoice-v3-flash",
default_omni_model: "qwen3.5-omni-plus",
@@ -86,8 +86,7 @@ export const UI_MODEL_CATALOG: Record<string, ModelOption[]> = {
{ id: "qwen3.6-flash", role: "fast · advisor intent" },
],
default_image_model: [
{ id: "qwen-image-3.0", role: "image/generate default · sync" },
{ id: "qwen-image-2.0", role: "image/generate · sync" },
{ id: "qwen-image-2.0", role: "image/generate default · sync" },
{ id: "qwen-image-max", role: "image/generate · sync" },
{ id: "qwen-image-edit-2.0", role: "image/edit · sync" },
{ id: "wanx2.x", role: "image/generate · async series" },
@@ -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)">bl skill 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)">npx skills 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>bl skill init</code>'); return; }
if (!SKILLS.length) { pager.innerHTML = ''; renderEmpty(body, 'No skills installed.', 'Install with <code>npx skills add modelstudioai/cli --all -g</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.bailian-commerce.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`--api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`--api some.api.name --data '{"key":"value"}' --console-region cn-beijing`,
],
async run(ctx) {
@@ -23,7 +23,7 @@ export default defineCommand({
"--file photo.jpg --model qwen3-vl-plus",
"--file video.mp4 --model wan2.1-t2v-plus",
"--file audio.wav --model qwen3-asr-flash",
"--file cat.png --model qwen-image-3.0",
"--file cat.png --model qwen-image-2.0",
],
async run(ctx) {
const { settings, flags } = ctx;
@@ -1,14 +1,15 @@
import {
defineCommand,
detectOutputFormat,
fetchModelListAll,
fetchModelList,
fetchModelCapability,
listSupportedTrainingTypes,
modelSupportsTrainingType,
isTrainingTypeCli,
trainingTypeMethodVariant,
TRAINING_TYPES_CLI,
anonymousConsoleCall,
callConsoleGateway,
effectiveConsoleGatewayConfig,
UsageError,
type Settings,
type ModelCapability,
@@ -16,6 +17,8 @@ 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
@@ -23,7 +26,20 @@ import { emitResult, emitBare } from "bailian-cli-runtime";
* for filtering.
*/
async function fetchAllFoundationModels(settings: Settings): Promise<ModelCapability[]> {
const all = await fetchModelListAll(anonymousConsoleCall(settings));
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);
}
return all as ModelCapability[];
}
+2 -2
View File
@@ -47,7 +47,7 @@ const EDIT_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: "Model ID (default: qwen-image-3.0)",
description: "Model ID (default: qwen-image-2.0)",
},
size: {
type: "string",
@@ -123,7 +123,7 @@ export default defineCommand({
}
const prompt = flags.prompt;
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
const route = resolveImageEditApi(model);
// Auto-upload local files (resolve all images in parallel)
@@ -35,7 +35,7 @@ const GENERATE_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: "Model ID (default: qwen-image-3.0)",
description: "Model ID (default: qwen-image-2.0)",
},
size: {
type: "string",
@@ -105,7 +105,7 @@ export default defineCommand({
const { settings, flags } = ctx;
const prompt = flags.prompt;
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
const route = resolveImageGenerateApi(model);
const defaultSize = "1:1";
const sizeInput = flags.size || defaultSize;
@@ -0,0 +1,221 @@
import {
listCloudEnvironments,
listCloudVaults,
listFiles,
listSkills,
type ProjectRuntimeContext,
type SyncProjectResult,
} from "@openagentpack/sdk";
import { BailianError, ExitCode } from "bailian-cli-core";
/** Resource types the SDK can reverse-export (its syncable whitelist). */
export const SYNCABLE_TYPES = ["environment", "vault", "file", "skill", "agent"] as const;
export type SyncableType = (typeof SYNCABLE_TYPES)[number];
/**
* Types selectable by remote ID through the shared resolution below. Agents are
* excluded: their synced yaml keys ARE remote IDs, so the sync command narrows
* them with a direct key hit (plus referenced-skill handling) instead.
*/
export type SelectableResourceType = Exclude<SyncableType, "agent">;
/** Maps a syncable type to its top-level group key in the synced config. */
const GROUP_KEY: Record<SyncableType, string> = {
environment: "environments",
vault: "vaults",
file: "files",
skill: "skills",
agent: "agents",
};
/** Remote id lookup outcome: human label + optional `agents.resource` yaml key tag. */
interface LocatedRemote {
label?: string;
taggedKey?: string;
}
/**
* Narrow one resource group of a synced config to the single entry identified
* by its remote ID; returns the kept yaml key.
*
* Group keys are only guaranteed to be remote IDs for agents; the other types
* derive their key from the `agents.resource` metadata tag or a display-name
* slug. Resolution therefore tries, in order: direct key hit → remote lookup
* by ID (provider list API) → metadata-tag key → label match against the
* exported declarations. Misses and ambiguity fail loudly instead of guessing.
*/
export async function narrowGroupToRemoteId(
runtime: ProjectRuntimeContext,
provider: string,
result: SyncProjectResult,
type: SelectableResourceType,
remoteId: string,
): Promise<string> {
const groupKey = GROUP_KEY[type];
const group = (result.config[groupKey] ?? {}) as Record<string, Record<string, unknown>>;
let keptKey: string;
if (remoteId in group) {
keptKey = remoteId;
} else {
const located = await locateRemote(runtime, provider, type, remoteId);
const resolved =
located.taggedKey && located.taggedKey in group
? located.taggedKey
: matchByLabel(type, group, located.label);
if (!resolved) {
throw new BailianError(
`Remote ${type} '${remoteId}'${located.label ? ` (${located.label})` : ""} has no matching entry in the synced output.`,
ExitCode.GENERAL,
"The resource may be archived or renamed; run a full sync (without the id flag) to inspect the exported keys.",
);
}
keptKey = resolved;
}
result.config[groupKey] = { [keptKey]: group[keptKey]! };
if (type in result.counts) result.counts[type] = 1;
if (type === "skill" && result.skillFiles) {
for (const skillName of result.skillFiles.keys()) {
if (skillName !== keptKey) result.skillFiles.delete(skillName);
}
}
return keptKey;
}
/** Look a remote resource up by ID via the provider's list API. */
async function locateRemote(
runtime: ProjectRuntimeContext,
provider: string,
type: SelectableResourceType,
remoteId: string,
): Promise<LocatedRemote> {
if (type === "environment") {
const environments = await listCloudEnvironments(runtime, { provider });
const hit = environments.find((environment) => environment.id === remoteId);
if (!hit) {
throw notFound(
type,
remoteId,
environments.map((environment) => formatCandidate(environment.id, environment.name)),
);
}
return { label: hit.name, taggedKey: hit.metadata?.["agents.resource"] };
}
if (type === "vault") {
const vaults = await listCloudVaults(runtime, { provider });
const hit = vaults.find((vault) => vault.id === remoteId);
if (!hit) {
throw notFound(
type,
remoteId,
vaults.map((vault) => formatCandidate(vault.id, vault.display_name)),
);
}
return { label: hit.display_name, taggedKey: hit.metadata?.["agents.resource"] };
}
if (type === "file") {
const files = await listFiles(runtime, { provider });
const hit = files.find((fileInfo) => fileInfo.id === remoteId);
if (!hit) {
throw notFound(
type,
remoteId,
files.map((fileInfo) => formatCandidate(fileInfo.id, fileInfo.filename)),
);
}
return { label: hit.filename };
}
// Synced skills come from the workspace's custom catalog (the raw /skills listing).
const skills = await listSkills(runtime, { provider, source: "custom" });
const hit = skills.find((skill) => skill.id === remoteId);
if (!hit) {
throw notFound(
type,
remoteId,
skills.map((skill) => formatCandidate(skill.id, skill.name)),
);
}
return { label: hit.name };
}
/**
* Batch-resolve remote skill IDs to synced skills-group keys via the custom
* skill catalog: look each ID up for its display name, then match group keys
* on normalized label (skills keys are display-name slugs). IDs that cannot be
* resolved — or whose label matches more than one key — come back as
* unmatched for the caller to surface. One catalog call serves the whole batch.
*/
export async function resolveSkillKeysByIds(
runtime: ProjectRuntimeContext,
provider: string,
group: Record<string, Record<string, unknown>>,
remoteIds: string[],
): Promise<{ resolved: Map<string, string>; unmatched: string[] }> {
const resolved = new Map<string, string>();
const unmatched: string[] = [];
if (remoteIds.length === 0) return { resolved, unmatched };
const catalog = await listSkills(runtime, { provider, source: "custom" });
for (const remoteId of remoteIds) {
const hit = catalog.find((skill) => skill.id === remoteId);
const matches = hit
? Object.keys(group).filter((key) => normalizeLabel(key) === normalizeLabel(hit.name))
: [];
if (matches.length === 1) {
resolved.set(remoteId, matches[0]!);
} else {
unmatched.push(remoteId);
}
}
return { resolved, unmatched };
}
/**
* Match a group entry by the remote resource's human label. Vaults and files
* carry the label verbatim in their exported decl; environment and skill keys
* are display-name slugs, so those compare on normalized alphanumerics.
*/
function matchByLabel(
type: SelectableResourceType,
group: Record<string, Record<string, unknown>>,
label: string | undefined,
): string | undefined {
if (!label) return undefined;
const matches = Object.entries(group).filter(([key, decl]) => {
if (type === "vault") return decl.display_name === label;
if (type === "file") return decl.name === label || decl.source === label;
return normalizeLabel(key) === normalizeLabel(label);
});
if (matches.length > 1) {
throw new BailianError(
`Multiple synced ${type} entries match '${label}': ${matches.map(([key]) => key).join(", ")}.`,
ExitCode.GENERAL,
"Run a full sync (without the id flag) and narrow the output manually.",
);
}
return matches[0]?.[0];
}
/** Compare display labels and slug-derived keys on lowercase alphanumerics only. */
function normalizeLabel(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function notFound(type: string, remoteId: string, candidates: string[]): BailianError {
const shown = candidates.slice(0, 20);
const suffix =
candidates.length > shown.length ? `, … ${candidates.length - shown.length} more` : "";
return new BailianError(
`${type} '${remoteId}' not found on the remote workspace.`,
ExitCode.USAGE,
candidates.length > 0
? `Available: ${shown.join(", ")}${suffix}.`
: `The workspace has no syncable ${type} resources.`,
);
}
function formatCandidate(id: string, label?: string): string {
return label && label !== id ? `${id} (${label})` : id;
}
@@ -0,0 +1,123 @@
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import {
BailianError,
defineCommand,
detectOutputFormat,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
import { migrateConfig } from "@openagentpack/sdk";
import { parse as parseYaml } from "yaml";
import { withAgentErrors } from "./_engine/errors.ts";
/** bl's migrate is bailian-only: the merge target must resolve to bailian. */
const MIGRATE_PROVIDER = "bailian";
const MIGRATE_FLAGS = {
from: {
type: "string",
valueHint: "<path>",
description: "Synced config to migrate from (default: agents.synced.yaml)",
},
to: {
type: "string",
valueHint: "<path>",
description: "Target agents.yaml to merge into (default: agents.yaml)",
},
} satisfies FlagsDef;
export default defineCommand({
description: "Merge a synced config into a bailian agents.yaml",
auth: "apiKey",
usageArgs: "[--from <path>] [--to <path>]",
flags: MIGRATE_FLAGS,
exampleArgs: ["", "--from agents.synced.yaml --to agents.yaml"],
notes: [
"The merge itself runs against local files; bl's unified apiKey gate still applies — login via `bl auth login`, pass --api-key, or set DASHSCOPE_API_KEY.",
"Only a bailian-target agents.yaml is supported: migrated resources are re-pointed to provider bailian, with models/tools/environments normalized to Bailian-supported values.",
"Resources whose YAML key already exists in the target are skipped, never overwritten.",
"Run `bl managed-agent plan` afterwards to review the merged config before apply.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const fromPath = flags.from ?? "agents.synced.yaml";
const toPath = flags.to ?? "agents.yaml";
// Aligned with the other managed-agent commands: dry-run short-circuits
// first and only echoes the planned action — no file I/O, no validation.
if (settings.dryRun) {
emitResult({ would_migrate: { from: fromPath, to: toPath } }, format);
return;
}
const result = await withAgentErrors(async () => {
await assertBailianTarget(toPath);
return migrateConfig({ fromPath, toPath });
});
await writeFile(toPath, result.yaml, "utf8");
if (format === "json") {
emitResult(
{ migrated: toPath, from: fromPath, added: result.added, skipped: result.skipped },
format,
);
return;
}
const addedParts = Object.entries(result.added).map(([group, count]) => `${count} ${group}`);
const skippedParts = Object.entries(result.skipped).map(
([group, count]) => `${count} ${group}`,
);
if (addedParts.length > 0) {
emitBare(`Migrated ${addedParts.join(", ")} into ${toPath}.`);
} else {
emitBare("No new resources to migrate (all already exist in target).");
}
if (skippedParts.length > 0) {
emitBare(`Skipped (already exist): ${skippedParts.join(", ")}.`);
}
if (addedParts.length > 0) {
// `bl` prefix is safe: agent commands ship on `bl` only.
emitBare("Next: run `bl managed-agent plan` to review the merged config.");
}
},
});
/**
* Enforce the bailian-only contract before merging: the target file must exist
* and its provider (defaults.provider, else the first providers key) must be
* bailian. An undeterminable provider is left to the SDK's own error.
*/
async function assertBailianTarget(toPath: string): Promise<void> {
if (!existsSync(toPath)) {
throw new BailianError(
`Target file '${toPath}' not found.`,
ExitCode.USAGE,
// `bl` prefix is safe: agent commands ship on `bl` only.
"Create it first with `bl managed-agent init`, then re-run migrate.",
);
}
const parsed: unknown = parseYaml(await readFile(toPath, "utf8"));
if (!parsed || typeof parsed !== "object") return;
const config = parsed as Record<string, unknown>;
const defaults = config.defaults as Record<string, unknown> | undefined;
const providers = config.providers as Record<string, unknown> | undefined;
const targetProvider =
typeof defaults?.provider === "string" && defaults.provider
? defaults.provider
: Object.keys(providers ?? {})[0];
if (targetProvider && targetProvider !== MIGRATE_PROVIDER) {
throw new BailianError(
`Target provider '${targetProvider}' is not supported: migrate only targets the ${MIGRATE_PROVIDER} provider.`,
ExitCode.USAGE,
`Set defaults.provider to ${MIGRATE_PROVIDER} (or make ${MIGRATE_PROVIDER} the providers block) in '${toPath}'.`,
);
}
}
@@ -0,0 +1,465 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
BailianError,
defineCommand,
detectOutputFormat,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
import {
type ProjectRuntimeContext,
type SyncProjectResult,
syncProviderResourcesFromContext,
} from "@openagentpack/sdk";
import { stringify as stringifyYaml } from "yaml";
import {
assertProviderConfigured,
buildAgentRuntime,
CREDENTIALS_NOTE,
} from "./_engine/config-loader.ts";
import { withStdoutProtected } from "./_engine/console-capture.ts";
import { withAgentErrors } from "./_engine/errors.ts";
import {
narrowGroupToRemoteId,
resolveSkillKeysByIds,
type SelectableResourceType,
SYNCABLE_TYPES,
type SyncableType,
} from "./_engine/sync-selection.ts";
/** bl's sync is bailian-only: the reverse-export source is always AgentStudio. */
const SYNC_PROVIDER = "bailian";
const DEFAULT_SYNC_OUTPUT = "agents.synced.yaml";
const SYNC_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: "Config file path (default: agents.yaml)",
},
out: {
type: "string",
valueHint: "<path>",
description: `Output path for the synced config (default: ${DEFAULT_SYNC_OUTPUT})`,
},
types: {
type: "string",
valueHint: "<list>",
description: `Comma-separated resource types to sync: ${SYNCABLE_TYPES.join(", ")} (default: all)`,
},
agentId: {
type: "string",
valueHint: "<id>",
description: "Sync a single agent by its remote ID (plus the skills it references)",
},
environmentId: {
type: "string",
valueHint: "<id>",
description: "Sync a single environment by its remote ID",
},
vaultId: {
type: "string",
valueHint: "<id>",
description: "Sync a single vault by its remote ID",
},
fileId: {
type: "string",
valueHint: "<id>",
description: "Sync a single file resource by its remote ID",
},
skillId: {
type: "string",
valueHint: "<id>",
description: "Sync a single skill by its remote ID (overrides --agent-id skill narrowing)",
},
force: {
type: "switch",
description: "Overwrite an existing output file",
},
skipMissingFiles: {
type: "switch",
description: "Drop file resources whose local source is missing instead of keeping them",
},
} satisfies FlagsDef;
export default defineCommand({
description: "Export remote bailian resources into a local synced config",
auth: "apiKey",
usageArgs:
"[--types <list>] [--agent-id|--environment-id|--vault-id|--file-id|--skill-id <id>] [--file <path>] [--out <path>] [--force] [--skip-missing-files]",
flags: SYNC_FLAGS,
exampleArgs: [
"",
"--types agent,skill",
"--agent-id agent-abc123",
"--skill-id skill-xyz --types skill",
"--force --skip-missing-files",
],
notes: [
...CREDENTIALS_NOTE,
"Syncs from the bailian provider only: remote AgentStudio resources (environments, vaults, files, skills, agents) are exported into a local synced config for review.",
"--types narrows the export to the listed resource types (plural spellings accepted); sessions are runtime instances, not syncable resources.",
"With --agent-id, the agents group keeps only that agent (yaml key = remote agent ID) and its referenced custom skills are synced along — the skill group is exported even when --types omits it; official skill references need no local entry.",
"Bailian binds environments/vaults/files at the session level, not on the agent, so --agent-id keeps those groups as shared infrastructure.",
"Each --environment-id/--vault-id/--file-id/--skill-id narrows its own resource group to the single remote resource (yaml key resolved from the remote listing); combine with --types for a minimal output.",
"Requires an agents.yaml with a bailian provider block — run `bl managed-agent init` first.",
"Secrets are never exported: vault credentials keep ${ENV} placeholders; set those env vars locally before apply.",
"Merge the synced config into agents.yaml with `bl managed-agent migrate`.",
],
validate: (flagValues) => {
if (!flagValues.types) return undefined;
const { types, invalid } = parseSyncTypes(flagValues.types);
if (invalid.length > 0) {
return `--types contains unsupported values: ${invalid.join(", ")}. Valid types: ${SYNCABLE_TYPES.join(", ")}.`;
}
if (types.length === 0) {
return `--types must list at least one of: ${SYNCABLE_TYPES.join(", ")}.`;
}
const idFlagByType: Record<SyncableType, string | undefined> = {
agent: flagValues.agentId,
environment: flagValues.environmentId,
vault: flagValues.vaultId,
file: flagValues.fileId,
skill: flagValues.skillId,
};
for (const [selectionType, value] of Object.entries(idFlagByType)) {
if (value && !types.includes(selectionType as SyncableType)) {
return `--${selectionType}-id requires --types to include ${selectionType}.`;
}
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const file = flags.file ?? "agents.yaml";
const out = flags.out ?? DEFAULT_SYNC_OUTPUT;
// undefined → the SDK exports every syncable type.
let types = flags.types ? parseSyncTypes(flags.types).types : undefined;
// --agent-id pulls the agent's referenced skills along, so the skill group
// is exported even when --types omits it.
if (types && flags.agentId && !types.includes("skill")) types = [...types, "skill"];
// Per-group single-resource selections, resolved by remote ID after export.
const resourceSelections: Partial<Record<SelectableResourceType, string>> = {
environment: flags.environmentId,
vault: flags.vaultId,
file: flags.fileId,
skill: flags.skillId,
};
// Aligned with the other managed-agent commands: dry-run short-circuits
// first and only echoes the planned action — no guards, no file I/O.
if (settings.dryRun) {
emitResult(
{
would_sync: {
provider: SYNC_PROVIDER,
config_file: file,
out,
types,
agent_id: flags.agentId,
environment_id: flags.environmentId,
vault_id: flags.vaultId,
file_id: flags.fileId,
skill_id: flags.skillId,
},
},
format,
);
return;
}
if (existsSync(out) && !flags.force) {
throw new BailianError(
`${out} already exists.`,
ExitCode.USAGE,
"Pass --force to overwrite, or --out to write elsewhere.",
);
}
const { result, agentFilter, selectedKeys } = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file);
assertProviderConfigured(runtime, SYNC_PROVIDER);
const synced = await syncProviderResourcesFromContext(runtime, {
provider: SYNC_PROVIDER,
types,
});
// An explicit --skill-id wins over the agent's referenced-skill narrowing.
const filtered = flags.agentId
? await filterConfigToAgent(runtime, synced, flags.agentId, {
narrowSkills: !flags.skillId,
})
: undefined;
const keptKeys: Partial<Record<SelectableResourceType, string>> = {};
for (const [selectionType, remoteId] of Object.entries(resourceSelections)) {
if (!remoteId) continue;
keptKeys[selectionType as SelectableResourceType] = await narrowGroupToRemoteId(
runtime,
SYNC_PROVIDER,
synced,
selectionType as SelectableResourceType,
remoteId,
);
}
return { result: synced, agentFilter: filtered, selectedKeys: keptKeys };
}),
);
const narrowed = Boolean(agentFilter) || Object.keys(selectedKeys).length > 0;
const baseDir = dirname(out);
const removedFiles = flags.skipMissingFiles
? removeMissingFileSources(result.config, baseDir)
: [];
const yamlContent =
narrowed || removedFiles.length > 0
? stringifyYaml(result.config, { lineWidth: 0 })
: result.yaml;
await writeFile(out, yamlContent, "utf8");
const downloadedSkillFiles = await writeDownloadedSkillFiles(result, baseDir);
// Custom skills whose content could not be downloaded need local files
// before an apply would round-trip; surface them instead of prompting.
const missingSkillSources = collectMissingSkillSources(result.config, baseDir);
const secretEnvVars = (result.secretPlaceholders ?? []).map(
(placeholder) => placeholder.envVar,
);
if (format === "json") {
emitResult(
{
synced: out,
provider: SYNC_PROVIDER,
agent_id: flags.agentId,
selected_keys: Object.keys(selectedKeys).length > 0 ? selectedKeys : undefined,
counts: result.counts,
skill_files_downloaded: downloadedSkillFiles,
removed_files: removedFiles,
missing_skill_sources: missingSkillSources,
secret_env_vars: secretEnvVars,
unmatched_skill_refs: agentFilter?.unmatchedSkillIds,
},
format,
);
return;
}
const countParts = Object.entries(result.counts).map(([type, count]) => `${count} ${type}(s)`);
emitBare(
`Synced ${countParts.length > 0 ? countParts.join(", ") : "0 resources"} from ${SYNC_PROVIDER} into ${out}.`,
);
if (agentFilter) {
emitBare(
agentFilter.keptSkills
? `Narrowed to agent ${flags.agentId} (kept ${agentFilter.keptSkills.length} referenced skill(s)).`
: `Narrowed to agent ${flags.agentId}.`,
);
if (agentFilter.unmatchedSkillIds.length > 0) {
emitBare(
`Skill references without a matching skills entry (kept on the agent as-is): ${agentFilter.unmatchedSkillIds.join(", ")}.`,
);
}
}
for (const [selectionType, keptKey] of Object.entries(selectedKeys)) {
const requestedId = resourceSelections[selectionType as SelectableResourceType];
emitBare(
`Narrowed ${selectionType} to ${requestedId}${keptKey !== requestedId ? ` (key: ${keptKey})` : ""}.`,
);
}
if (downloadedSkillFiles > 0) {
emitBare(`Downloaded ${downloadedSkillFiles} skill file(s) into ./skills/.`);
}
if (removedFiles.length > 0) {
emitBare(
`Removed ${removedFiles.length} file resource(s) with missing local sources: ${removedFiles.join(", ")}.`,
);
}
if (missingSkillSources.length > 0) {
emitBare(
`Missing local skill sources (provide the files before apply): ${missingSkillSources.join(", ")}.`,
);
}
if (secretEnvVars.length > 0) {
emitBare(`Set these env vars locally before apply: ${secretEnvVars.join(", ")}.`);
}
// `bl` prefix is safe: agent commands ship on `bl` only.
emitBare(
`Next: review ${out}, then run \`bl managed-agent migrate\` to merge it into agents.yaml.`,
);
},
});
/**
* Parse the --types list. Accepts singular and plural spellings ("skills" →
* "skill") and dedupes; invalid tokens are returned verbatim so validate()
* can reject them with the original user input.
*/
function parseSyncTypes(raw: string): { types: SyncableType[]; invalid: string[] } {
const types: SyncableType[] = [];
const invalid: string[] = [];
const tokens = raw
.split(",")
.map((token) => token.trim())
.filter(Boolean);
for (const token of tokens) {
const singular = token.endsWith("s") ? token.slice(0, -1) : token;
const matched = SYNCABLE_TYPES.find(
(candidate) => candidate === token || candidate === singular,
);
if (!matched) {
invalid.push(token);
} else if (!types.includes(matched)) {
types.push(matched);
}
}
return { types, invalid };
}
/** Result of narrowing a synced config to a single agent. */
interface AgentFilterResult {
/** Undefined when skills narrowing was skipped (--skill-id takes over). */
keptSkills?: string[];
unmatchedSkillIds: string[];
}
/**
* Narrow a full synced config to a single agent: the agents group keeps only
* the entry whose yaml key equals the remote agent ID, and — unless an
* explicit --skill-id selection takes over (`narrowSkills: false`) — the
* skills group keeps only the custom skills that agent references (official
* skills live in the provider catalog and need no local entry). Skill
* references carry remote skill IDs while skills-group keys are
* metadata/display-name derived, so key misses are resolved through the
* custom skill catalog before being surfaced as unmatched (never silently
* dropped from the agent itself). Downloaded skill files and counts are
* re-scoped to what remains.
*/
async function filterConfigToAgent(
runtime: ProjectRuntimeContext,
result: SyncProjectResult,
agentId: string,
options: { narrowSkills: boolean },
): Promise<AgentFilterResult> {
const config = result.config;
const agents = (config.agents ?? {}) as Record<string, Record<string, unknown>>;
const selected = agents[agentId];
if (!selected) {
const available = Object.keys(agents);
throw new BailianError(
`Agent '${agentId}' not found on the remote workspace.`,
ExitCode.USAGE,
available.length > 0
? `Available agent IDs: ${available.join(", ")}.`
: "The workspace has no syncable (non-archived) agents.",
);
}
config.agents = { [agentId]: selected };
if ("agent" in result.counts) result.counts.agent = 1;
if (!options.narrowSkills) {
return { unmatchedSkillIds: [] };
}
const skillRefs = Array.isArray(selected.skills)
? (selected.skills as Array<Record<string, unknown>>)
: [];
// Official skills resolve against the provider catalog at apply time; only
// custom references map to synced skills-group declarations.
const referencedIds = new Set(
skillRefs
.filter((skillRef) => skillRef.type !== "official")
.map((skillRef) => skillRef.skill_id)
.filter((skillId): skillId is string => typeof skillId === "string"),
);
const skills = (config.skills ?? {}) as Record<string, Record<string, unknown>>;
const keptSkills = Object.keys(skills).filter((key) => referencedIds.has(key));
const pendingIds = Array.from(referencedIds).filter((skillId) => !(skillId in skills));
// Key misses: the ref carries a remote ID while the group key is a
// display-name slug — resolve through the custom catalog before declaring
// the reference unmatched.
const { resolved, unmatched: unmatchedSkillIds } = await resolveSkillKeysByIds(
runtime,
SYNC_PROVIDER,
skills,
pendingIds,
);
for (const resolvedKey of resolved.values()) {
if (!keptSkills.includes(resolvedKey)) keptSkills.push(resolvedKey);
}
if (keptSkills.length > 0) {
config.skills = Object.fromEntries(keptSkills.map((key) => [key, skills[key]!]));
} else {
delete config.skills;
}
if (result.skillFiles) {
const kept = new Set(keptSkills);
for (const skillName of result.skillFiles.keys()) {
if (!kept.has(skillName)) result.skillFiles.delete(skillName);
}
}
if ("skill" in result.counts) result.counts.skill = keptSkills.length;
return { keptSkills, unmatchedSkillIds };
}
/**
* Drop file resources whose `source` does not exist locally — the remote
* platform cannot hand file content back, so keeping them would make the
* synced config un-appliable. Returns the removed YAML keys.
*/
function removeMissingFileSources(config: Record<string, unknown>, baseDir: string): string[] {
const files = (config.files ?? {}) as Record<string, Record<string, unknown>>;
const removed = Object.entries(files)
.filter(
([, decl]) => typeof decl.source === "string" && !existsSync(join(baseDir, decl.source)),
)
.map(([key]) => key);
for (const key of removed) {
delete files[key];
}
if (Object.keys(files).length === 0) {
delete config.files;
}
return removed;
}
/** Persist provider-downloaded skill files under ./skills/<name>/; returns the file count. */
async function writeDownloadedSkillFiles(
result: SyncProjectResult,
baseDir: string,
): Promise<number> {
if (!result.skillFiles?.size) return 0;
let written = 0;
for (const [skillName, skillFileList] of result.skillFiles) {
for (const skillFile of skillFileList) {
const filePath = join(baseDir, "skills", skillName, skillFile.relativePath);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, skillFile.content);
written++;
}
}
return written;
}
/** Custom skills whose local source dir/file is absent or empty after sync. */
function collectMissingSkillSources(config: Record<string, unknown>, baseDir: string): string[] {
const skills = (config.skills ?? {}) as Record<string, Record<string, unknown>>;
const missing: string[] = [];
for (const [key, decl] of Object.entries(skills)) {
if (decl.origin !== "custom" || typeof decl.source !== "string") continue;
const sourcePath = join(baseDir, decl.source);
if (!existsSync(sourcePath)) {
missing.push(key);
continue;
}
const sourceStat = statSync(sourcePath);
if (sourceStat.isDirectory() && readdirSync(sourcePath).length === 0) {
missing.push(key);
}
}
return missing;
}
@@ -1,11 +1,11 @@
import { BailianError, isStreamableHttpUnsupported } from "bailian-cli-core";
import { BailianError } 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,28 +26,14 @@ 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 (!(error instanceof BailianError) || error.hint) {
throw error;
}
if (isMcpNotActivated(error)) {
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
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;
}
+7 -11
View File
@@ -36,8 +36,7 @@ const CALL_FLAGS = {
url: {
type: "string",
valueHint: "<url>",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
},
} satisfies FlagsDef;
type CallFlags = ParsedFlags<typeof CALL_FLAGS>;
@@ -115,14 +114,14 @@ export default defineCommand({
const { serverCode, toolName } = parseTarget(flags.target);
const toolArgs = buildToolArgs(flags);
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const url = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
server: serverCode,
url: previewUrl,
url,
tool: toolName,
arguments: toolArgs,
},
@@ -131,14 +130,13 @@ export default defineCommand({
return;
}
let client: { close?(): void } | undefined;
const client = ctx.client.mcp(url);
try {
const connected = await ctx.client.connectBailianMcp(serverCode, flags.url);
client = connected.client;
const result = await connected.client.callTool(toolName, toolArgs);
await client.initialize();
const result = await client.callTool(toolName, toolArgs);
if (result.isError) {
const errText = result.content.map((contentItem) => contentItem.text || "").join("\n");
const errText = result.content.map((c) => c.text || "").join("\n");
throw new BailianError(`Tool error: ${errText}`);
}
@@ -148,8 +146,6 @@ export default defineCommand({
rethrowWithMcpActivateHint(error, serverCode);
}
throw error;
} finally {
client?.close?.();
}
},
});
+7 -11
View File
@@ -16,8 +16,7 @@ export default defineCommand({
url: {
type: "string",
valueHint: "<url>",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
},
},
exampleArgs: [
@@ -29,27 +28,24 @@ export default defineCommand({
const { settings, flags } = ctx;
const code = flags.server;
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(code));
const url = flags.url || ctx.client.url(bailianMcpPath(code));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ server: code, url: previewUrl, action: "tools/list" }, format);
emitResult({ server: code, url, action: "tools/list" }, format);
return;
}
let client: { close?(): void } | undefined;
const client = ctx.client.mcp(url);
try {
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);
await client.initialize();
const tools = await client.listTools();
emitResult({ server: code, url, tools }, format);
} catch (error) {
if (!flags.url) {
rethrowWithMcpActivateHint(error, code);
}
throw error;
} finally {
client?.close?.();
}
},
});
+7 -9
View File
@@ -1,5 +1,4 @@
import {
anonymousConsoleCall,
defineCommand,
detectOutputFormat,
fetchModelDetail,
@@ -291,7 +290,7 @@ function printPredictConfigTable(entries: PredictConfigEntry[]): void {
export default defineCommand({
description: "Browse model families or show detailed model info in the Bailian model marketplace",
auth: "none",
auth: "console",
usageArgs:
"[--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]",
flags: LIST_FLAGS,
@@ -303,14 +302,10 @@ 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) {
@@ -321,7 +316,7 @@ export default defineCommand({
return;
}
const detail = await fetchModelDetail(call, modelKey);
const detail = await fetchModelDetail(ctx.client.console.bind(ctx.client), modelKey);
if (!detail) {
emitBare(`Model "${modelKey}" not found.`);
@@ -333,7 +328,10 @@ export default defineCommand({
await Promise.all(
trunkItems.map(async (item) => {
if (!item.model) return;
const config = await fetchPredictConfig(call, item.model);
const config = await fetchPredictConfig(
ctx.client.console.bind(ctx.client),
item.model,
);
if (config) item.predictConfig = config;
}),
);
@@ -363,7 +361,7 @@ export default defineCommand({
return;
}
const { total, groups } = await fetchModelGroups(call, params);
const { total, groups } = await fetchModelGroups(ctx.client.console.bind(ctx.client), params);
if (format === "json") {
emitResult(formatBrowseJson(groups, total), format);
@@ -1,41 +0,0 @@
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);
},
});
@@ -1,145 +0,0 @@
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);
},
});
@@ -1,52 +0,0 @@
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);
},
});
@@ -1,109 +0,0 @@
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,7 +1,6 @@
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";
@@ -50,6 +49,10 @@ 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);
+244 -142
View File
@@ -1,195 +1,297 @@
import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core";
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
unwrapResponse,
MODEL_LIST_API,
type Client,
} from "bailian-cli-core";
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
import { buildQuery, parseCommaList } from "../shared/params.ts";
// ---------------------------------------------------------------------------
// Types — mirror GET /api/v1/models/limits
// ---------------------------------------------------------------------------
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
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 QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
}
interface ModelQuota {
interface ModelWithQpm {
model: string;
workspace_id?: string;
model_limit?: LimitSpec | null;
workspace_limit?: LimitSpec | null;
qpmInfo?: Record<string, QpmInfoItem>;
}
interface LimitsResponse {
output?: {
total?: number;
page_no?: number;
page_size?: number;
quotas?: ModelQuota[];
};
request_id?: string;
interface MonitorPoint {
value: number;
timestamp: number;
}
// ---------------------------------------------------------------------------
// 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`;
interface MonitorMetric {
aggMethod: string;
metricName: string;
points: MonitorPoint[];
}
function formatRequestLimit(spec: LimitSpec | null | undefined): string {
if (!spec) return "-";
return formatLimit(spec.request_limit, spec.request_limit_period);
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 formatUsageLimit(spec: LimitSpec | null | undefined): string {
if (!spec) return "-";
return formatLimit(spec.usage_limit, spec.usage_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);
}
/** 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 "-";
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 };
}
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}`;
}
function printTable(quotas: ModelQuota[], total: number): void {
if (quotas.length === 0) {
process.stdout.write("No rate limits found.\n");
return;
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++;
}
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),
]);
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 lines = renderBoxTable({
headers,
rows,
align: ["left", "right", "right", "right", "right", "right"],
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 },
],
});
for (const line of lines) process.stdout.write(line + "\n");
process.stdout.write(`\nTotal: ${total}\n`);
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
export default defineCommand({
description: "View model rate limits (QPM/TPM, account and workspace level)",
auth: "apiKey",
usageArgs: "[--model <model>] [--name <name>] [--page <n>] [--page-size <n>]",
description: "View model RPM/TPM rate limits",
auth: "console",
usageArgs: "[--model <model>] [flags]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name(s), comma-separated (exact match)",
description: "Model name(s), comma-separated",
},
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-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)."],
exampleArgs: ["", "--model qwen3.6-plus", "--model qwen3.6-plus,qwen-turbo", "--output json"],
async run(ctx) {
const { settings, flags } = ctx;
const modelFlag = flags.model || undefined;
const nameFlag = flags.name || undefined;
const format = detectOutputFormat(settings.output);
const endpoint = ctx.client.url(modelsLimitsPath());
if (settings.dryRun) {
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,
);
}
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,
);
return;
}
let quotas: ModelQuota[];
let total: number;
let models = await fetchAllModelsWithQpm(ctx.client);
if (modelFlag) {
// 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 }),
}),
),
const names = new Set(
modelFlag
.split(",")
.map((n) => n.trim())
.filter(Boolean),
);
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;
models = models.filter((m) => names.has(m.model));
if (models.length === 0) {
throw new BailianError(`no matching models found for "${modelFlag}".`);
}
}
if (format === "json") {
emitResult({ items: quotas, total }, format);
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);
return;
}
printTable(quotas, total);
// For text output with gauges, we need monitor data
const monitorResults = await Promise.all(
models.map((m) => fetchMonitorData(ctx.client, m.model, 2)),
);
const rows: ListRow[] = models.map((m, idx) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
const rpmUsage = monitorResults[idx].rpm;
const tpmUsage = monitorResults[idx].tpm;
// RPM Quota Left = 1 - (rpmUsage / currentRPM) in percentage
let rpmQuotaPercent: number | null = null;
let rpmQuotaLabel: string | null = null;
if (rpmUsage >= 0 && currentRPM > 0) {
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / currentRPM) * 100);
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
}
// TPM Quota Left = 1 - (tpmUsage / currentTPM) in percentage
let tpmQuotaPercent: number | null = null;
let tpmQuotaLabel: string | null = null;
if (tpmUsage >= 0 && currentTPM > 0) {
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / currentTPM) * 100);
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
}
return {
model: m.model,
rpm: currentRPM > 0 ? formatNumber(currentRPM) : "-",
tpm: currentTPM > 0 ? formatNumber(currentTPM) : "-",
rpmQuotaLeft: rpmQuotaPercent,
tpmQuotaLeft: tpmQuotaPercent,
rpmQuotaLabel,
tpmQuotaLabel,
};
});
if (rows.length === 0) {
process.stdout.write("No models found.\n");
return;
}
printTable(rows);
},
});
@@ -0,0 +1,188 @@
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`,
);
},
});
@@ -1,100 +0,0 @@
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`);
},
});
@@ -1,4 +0,0 @@
/** Format an integer with en-US thousands separators for table / text output. */
export function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
@@ -1,21 +0,0 @@
/** 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}` : "";
}
+9 -17
View File
@@ -2,6 +2,7 @@ import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
detectInstalledAgents,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
@@ -27,31 +28,22 @@ const INSTALL_CONCURRENCY = 3;
export default defineCommand({
description: "Install skills from the Bailian skill registry into local agents",
auth: "none",
usageArgs: "--all | --name <name,...>",
usageArgs: "--name <all|name,...>",
flags: {
all: {
type: "switch",
description: "Install all skills from the registry",
},
name: {
type: "string",
valueHint: "<name,...>",
description: "Comma-separated skill names to install",
valueHint: "<all|name,...>",
description: "Skills to install: all or comma-separated skill names",
required: true,
},
},
validate(flags) {
if (flags.all && flags.name) return "Use either --all or --name, not both";
if (!flags.all && !flags.name)
return "Specify --all to install everything or --name <name,...> for specific skills";
return undefined;
},
exampleArgs: ["--all", "--name spark-video,bailian-model-recommend"],
exampleArgs: ["--name all", "--name spark-video,bailian-model-recommend"],
async run(ctx) {
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const format = detectOutputFormat(ctx.settings.output);
const requested = parseSkillNames(ctx.flags.name, false);
const index = await fetchSkillsIndex();
const remoteNames = Object.keys(index.skills);
const parsed = ctx.flags.all ? "all" : parseSkillNames(ctx.flags.name, false);
const names = parsed === "all" ? remoteNames : parsed;
const names = requested === "all" ? remoteNames : requested;
const lock = readSkillLock();
const agents = detectInstalledAgents();
@@ -1,125 +0,0 @@
import {
BailianError,
ExitCode,
defineCommand,
detectInstalledAgents,
fetchSkillsIndex,
installSkillWithFanout,
readSkillLock,
runWithConcurrency,
writeSkillLock,
} from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
/** Prefix used to identify first-party Bailian skills in the registry. */
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",
usageArgs: "",
exampleArgs: [""],
notes: [
"Fetches the registry index and installs every skill whose name starts with bailian-",
"Equivalent to: bl skill add --all (filtered to bailian-* skills)",
],
async run(ctx) {
const format = ctx.settings.outputExplicit ? ctx.settings.output : DEFAULT_FORMAT;
const index = await fetchSkillsIndex();
// Discover all bailian-* skills from the live registry index
const names = Object.keys(index.skills).filter((name) => name.startsWith(BAILIAN_PREFIX));
const lock = readSkillLock();
const agents = detectInstalledAgents();
const tasks = names.map((name) => async (): Promise<InitOutcome> => {
const entry = index.skills[name];
try {
const record = await installSkillWithFanout(
name,
entry,
agents,
lock.skills[name]?.links ?? [],
);
lock.skills[name] = record.lockEntry;
return { name, status: STATUS.success };
} catch (err) {
return {
name,
status: STATUS.failed,
reason: err instanceof Error ? err.message : String(err),
};
}
});
const results = await runWithConcurrency(tasks, INIT_CONCURRENCY);
writeSkillLock(lock);
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 {
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}`);
}
}
}
if (failed.length > 0) {
throw new BailianError(
`${failed.length}/${results.length} skill(s) failed to install`,
ExitCode.GENERAL,
"Check the reason for failed skills in the output; network failures can be retried with bl skill init",
);
}
},
});
+2 -1
View File
@@ -1,5 +1,6 @@
import {
defineCommand,
detectOutputFormat,
computeSkillStatuses,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
@@ -23,7 +24,7 @@ export default defineCommand({
"STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)",
],
async run(ctx) {
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const format = detectOutputFormat(ctx.settings.output);
// Three-way reconciliation: live remote index × skill-lock.json (installation facts) × disk
const index = await fetchSkillsIndex();
const lock = readSkillLock();
@@ -2,6 +2,7 @@ import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
listSkillDirsOnDisk,
parseSkillNames,
readSkillLock,
@@ -33,7 +34,7 @@ export default defineCommand({
exampleArgs: ["--name spark-video", "--name all"],
async run(ctx) {
// Purely local operation: no remote access, works offline
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const format = detectOutputFormat(ctx.settings.output);
const requested = parseSkillNames(ctx.flags.name, false);
const lock = readSkillLock();
const names = requested === "all" ? Object.keys(lock.skills) : requested;
+8 -15
View File
@@ -2,6 +2,7 @@ import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
detectInstalledAgents,
fanOutSkillToAgents,
fetchSkillsIndex,
@@ -28,27 +29,19 @@ const UPDATE_CONCURRENCY = 3;
export default defineCommand({
description: "Update installed skills to the latest registry versions",
auth: "none",
usageArgs: "[--all] [--name <name,...>]",
usageArgs: "[--name <all|name,...>]",
flags: {
all: {
type: "switch",
description: "Update all installed skills (default when neither --all nor --name is given)",
},
name: {
type: "string",
valueHint: "<name,...>",
description: "Comma-separated skill names to update (must be already installed)",
valueHint: "<all|name,...>",
description:
"Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills)",
},
},
validate(flags) {
if (flags.all && flags.name) return "Use either --all or --name, not both";
return undefined;
},
exampleArgs: ["", "--all", "--name spark-video"],
exampleArgs: ["", "--name spark-video"],
async run(ctx) {
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
const updateAll = ctx.flags.all || !ctx.flags.name;
const requested = updateAll ? "all" : parseSkillNames(ctx.flags.name, false);
const format = detectOutputFormat(ctx.settings.output);
const requested = parseSkillNames(ctx.flags.name, true);
const index = await fetchSkillsIndex();
const lock = readSkillLock();
const disk = new Set(listSkillDirsOnDisk());
@@ -12,13 +12,6 @@ import {
stripUndefined,
taskPath,
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
type AsrApiRoute,
type AsrFlashFamily,
type OutputFormat,
type FlagsDef,
type ParsedFlags,
@@ -34,18 +27,8 @@ 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). 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",
},
model: { type: "string", valueHint: "<model>", description: "Model ID (default: fun-asr)" },
language: { type: "string", valueHint: "<lang>", description: "Language hint (e.g. zh, en, ja)" },
diarization: { type: "switch", description: "Enable automatic speaker diarization" },
speakerCount: {
type: "number",
@@ -72,33 +55,8 @@ 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 / Qwen-ASR Flash)",
description: "Recognize speech from audio files (FunAudio-ASR)",
auth: "apiKey",
usageArgs: "--url <audio-url> [flags]",
flags: RECOGNIZE_FLAGS,
@@ -110,7 +68,6 @@ 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;
@@ -133,70 +90,22 @@ 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((url) => ctx.client.uploadFile(url, model)));
if (route.kind === "sync-flash") {
await handleSyncFlashMode(
ctx.client,
settings,
flags,
format,
model,
route,
resolvedUrls[0]!,
);
return;
}
const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model)));
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:
route.asyncInputStyle === "file_url"
? { file_url: resolvedUrls[0]! }
: { file_urls: resolvedUrls },
input: {
file_urls: resolvedUrls,
},
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
...languageFields,
language_hints: language ? [language] : undefined,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
@@ -207,7 +116,7 @@ export default defineCommand({
stripUndefined(body.parameters as Record<string, unknown>);
if (settings.dryRun) {
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
emitResult({ request: body, mode: "async" }, format);
return;
}
@@ -219,55 +128,6 @@ 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,
@@ -300,16 +160,16 @@ async function handleAsyncMode(
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: settings.timeout,
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;
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;
},
});
const results = collectAsrTranscriptionItems(result.output);
const results = result.output.results ?? [];
if (results.length === 0) {
emitResult({ task_id: taskId, status: result.output.task_status }, format);
@@ -319,14 +179,12 @@ async function handleAsyncMode(
// Collect all transcription data for --out
const allTransData: Record<string, unknown>[] = [];
for (let index = 0; index < results.length; index++) {
const subResult = results[index]!;
for (let i = 0; i < results.length; i++) {
const subResult = results[i]!;
const isMulti = fileCount > 1;
if (isMulti) {
process.stdout.write(
`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`,
);
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
}
if (subResult.subtask_status === "FAILED") {
+26 -96
View File
@@ -1,35 +1,20 @@
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",
@@ -87,31 +72,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 parsedMessage of parsed) {
if (parsedMessage.role === "system") {
system = typeof parsedMessage.content === "string" ? parsedMessage.content : "";
for (const m of parsed) {
if (m.role === "system") {
system = typeof m.content === "string" ? m.content : "";
} else {
messages.push(parsedMessage as ChatMessage);
messages.push(m as ChatMessage);
}
}
}
if (flags.message) {
const validRoles = new Set(["system", "user", "assistant"]);
const messageValues = flags.message;
for (const messageValue of messageValues) {
const colonIndex = messageValue.indexOf(":");
const maybeRole = colonIndex !== -1 ? messageValue.slice(0, colonIndex) : "";
const msgs = flags.message;
for (const m of msgs) {
const colonIdx = m.indexOf(":");
const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : "";
if (validRoles.has(maybeRole)) {
const content = messageValue.slice(colonIndex + 1);
const content = m.slice(colonIdx + 1);
if (maybeRole === "system") {
system = content;
} else {
messages.push({ role: maybeRole as "user" | "assistant", content });
}
} else {
messages.push({ role: "user", content: messageValue });
messages.push({ role: "user", content: m });
}
}
}
@@ -120,33 +105,24 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
}
export default defineCommand({
description: "Send a text model request (OpenAI compatible, DashScope)",
description: "Send a chat completion (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: (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;
},
validate: (f) =>
!f.message && !f.messagesFile ? "Provide --message or --messages-file." : 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);
@@ -158,39 +134,29 @@ export default defineCommand({
}
allMessages.push(...messages);
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,
};
}
const body: ChatRequest = {
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 (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) {
if (flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget;
}
}
if (flags.tool) {
const tools = flags.tool.map((toolValue) => {
const tools = flags.tool.map((t) => {
try {
return JSON.parse(toolValue);
return JSON.parse(t);
} catch {
const raw = readFileSync(toolValue, "utf-8");
const raw = readFileSync(t, "utf-8");
return JSON.parse(raw);
}
});
@@ -203,8 +169,8 @@ export default defineCommand({
}
if (shouldStream) {
const responseStream = await ctx.client.request({
path: api === "responses" ? responsesPath() : chatPath(),
const res = await ctx.client.request({
path: chatPath(),
method: "POST",
body,
stream: true,
@@ -212,7 +178,6 @@ export default defineCommand({
let textContent = "";
let inThinking = false;
let responsesCompleted = false;
const writesStreamingStdout = format === "text";
const isTTY = process.stdout.isTTY;
const statusOut =
@@ -220,28 +185,8 @@ export default defineCommand({
const resultOut = process.stdout;
const statusColor = ansi(statusOut);
for await (const event of parseSSE(responseStream)) {
for await (const event of parseSSE(res)) {
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;
@@ -271,7 +216,6 @@ export default defineCommand({
// Skip unparseable chunks
}
}
if (api === "responses") assertResponsesStreamCompleted(responsesCompleted);
if (inThinking) statusOut.write(statusColor.reset);
if (format === "json") {
@@ -279,20 +223,6 @@ 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(),
@@ -1,76 +0,0 @@
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,7 +20,8 @@ import {
type AnsiStyles,
} from "bailian-cli-runtime";
const SKILL_INSTALL_CMD = "bl skill init";
const SKILL_SOURCE = "modelstudioai/cli";
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
function updateAgentSkill(color: AnsiStyles): void {
process.stderr.write("\nUpdating agent skill...\n");
@@ -142,7 +143,6 @@ 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 =
@@ -1,132 +0,0 @@
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());
},
});
+6 -4
View File
@@ -1,4 +1,4 @@
import { defineCommand, detectOutputFormat, findModelByName } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, fetchModelList } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
FREE_TIER_API,
@@ -93,11 +93,13 @@ export default defineCommand({
}
requestData.queryFreeTierQuotaRequest.models = models;
} else {
const matches = await Promise.all(
models.map((name) => findModelByName((api, data) => ctx.client.console(api, data), name)),
const searchResults = await Promise.all(
models.map((name) =>
fetchModelList((api, data) => ctx.client.console(api, data), { name, pageSize: 50 }),
),
);
for (let idx = 0; idx < models.length; idx++) {
const matched = matches[idx];
const matched = searchResults[idx].models.find((item) => item.model === models[idx]);
if (matched) {
typeMap.set(models[idx], resolveModelType((matched.capabilities as string[]) || []));
}
@@ -1,22 +1,95 @@
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, fetchModelList, type Client } 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.bailian-commerce.freeTrial.batchActivateFreeTierOnly";
const DEACTIVATE_API = "zeldaEasy.bailian-commerce.freeTrial.batchDeactivateFreeTierOnly";
const ACTIVATE_API = "zeldaEasy.broadscope-bailian.freeTrial.batchActivateFreeTierOnly";
const DEACTIVATE_API = "zeldaEasy.broadscope-bailian.freeTrial.batchDeactivateFreeTierOnly";
const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
const FREE_TIER_ONLY_STATUS_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
interface FreeTierQuota {
model: string;
quotaTotal: number;
quotaInitTotal: number;
}
interface FreeTierOnlyStatus {
model: string;
freeTierOnly: boolean;
}
interface BatchResultFailure {
failureModelId: string;
errorCode: string;
}
function getNestedRecord(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
return undefined;
}
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
const data = getNestedRecord(result, "data");
if (!data) return result;
const dataV2 = getNestedRecord(data, "DataV2");
if (dataV2) {
const inner = getNestedRecord(dataV2, "data");
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
return innerData ?? inner ?? dataV2;
}
const direct = getNestedRecord(data, "data");
return direct ?? data;
}
const POLL_INTERVAL_MS = 500;
const MAX_POLLS = 20;
async function pollUntilDone(
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",
@@ -88,7 +161,7 @@ export default defineCommand({
}
if (!modelFlag) {
models = (await fetchAllModels(ctx.client)).map((model) => model.name);
models = await fetchAllModelNames(ctx.client);
}
if (off) {
@@ -99,10 +172,12 @@ export default defineCommand({
}),
]);
const quotas = extractQuotas(quotaResult);
const quotaData = extractResponseData(quotaResult as Record<string, unknown>);
const quotas = (quotaData.freeTierQuotas ?? []) as FreeTierQuota[];
const quotaMap = new Map(quotas.map((quota) => [quota.model, quota]));
const stopStatuses = extractFreeTierOnlyStatuses(stopResult);
const stopData = extractResponseData(stopResult as Record<string, unknown>);
const stopStatuses = (stopData.freeTierOnlyStatuses ?? []) as FreeTierOnlyStatus[];
const stopMap = new Map(stopStatuses.map((status) => [status.model, status.freeTierOnly]));
for (const name of models) {
@@ -117,7 +192,7 @@ export default defineCommand({
);
continue;
}
await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
await pollUntilDone(ctx.client, api, requestKey, [name]);
process.stdout.write(`Disabled auto-stop for "${name}".\n`);
}
return;
@@ -125,13 +200,13 @@ export default defineCommand({
const jsonResults: unknown[] = [];
for (const name of models) {
const result = await pollFreeTierBatch(ctx.client, api, requestKey, [name]);
const result = await pollUntilDone(ctx.client, api, requestKey, [name]);
if (format === "json") {
jsonResults.push(result);
continue;
}
if (result) {
const resultData = unwrapResponse(result as Record<string, unknown>);
const resultData = extractResponseData(result as Record<string, unknown>);
const failureModels = (resultData.failureModels as BatchResultFailure[]) ?? [];
if (failureModels.length > 0) {
process.stderr.write(
@@ -1,91 +0,0 @@
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`);
}
+28 -54
View File
@@ -1,5 +1,5 @@
import {
fetchModelListAll,
fetchModelList,
BailianError,
ExitCode,
unwrapResponse,
@@ -7,12 +7,15 @@ 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();
@@ -21,14 +24,6 @@ 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;
@@ -84,7 +79,17 @@ export interface ModelInfo {
}
export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
const allModels = await fetchModelListAll((api, data) => client.console(api, data));
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList((api, data) => client.console(api, data), {
pageNo: page,
pageSize: 50,
});
allModels.push(...result.models);
if (allModels.length >= result.total) break;
page++;
}
return allModels
.filter((item) => typeof item.model === "string" && item.model)
.map((item) => ({
@@ -97,9 +102,9 @@ export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
// Free-tier quota
// ---------------------------------------------------------------------------
export const FREE_TIER_API = "zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota";
export const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
export const FREE_TIER_ONLY_STATUS_API =
"zeldaEasy.bailian-commerce.freeTrial.queryFreeTierOnlyStatus";
"zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierOnlyStatus";
export interface FreeTierQuota {
model: string;
@@ -252,27 +257,22 @@ export interface ListStatisticResponse {
}
const POLL_INTERVAL_MS = 500;
const DEFAULT_MAX_POLLS = 30;
const MAX_POLLS = 30;
/**
* 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(
export async function pollTelemetryApi(
client: Client,
api: string,
buildRequest: (taskId: string | undefined) => Record<string, unknown>,
maxPolls = DEFAULT_MAX_POLLS,
reqDTO: Record<string, unknown>,
): Promise<unknown> {
let nextTaskId: string | undefined;
for (let attempt = 0; attempt < maxPolls; attempt++) {
const raw = await client.console(api, buildRequest(nextTaskId));
const resp = unwrapResponse(raw as Record<string, unknown>);
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;
@@ -284,32 +284,6 @@ export async function pollConsoleUntilDone(
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) {
+165 -15
View File
@@ -1,26 +1,176 @@
import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core";
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
type Settings,
type Client,
} from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
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";
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;
}
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}`;
@@ -1,77 +0,0 @@
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());
},
});
+3 -7
View File
@@ -48,20 +48,15 @@ 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 quotaUpdate } from "./commands/quota/update.ts";
export { default as quotaRequest } from "./commands/quota/request.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";
@@ -98,6 +93,8 @@ export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-se
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as managedAgentInit } from "./commands/managed-agent/init.ts";
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
export { default as managedAgentSync } from "./commands/managed-agent/sync.ts";
export { default as managedAgentMigrate } from "./commands/managed-agent/migrate.ts";
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
export { default as managedAgentApply } from "./commands/managed-agent/apply.ts";
export { default as managedAgentDestroy } from "./commands/managed-agent/destroy.ts";
@@ -122,4 +119,3 @@ export { default as skillAdd } from "./commands/skill/add.ts";
export { default as skillUpdate } from "./commands/skill/update.ts";
export { default as skillRemove } from "./commands/skill/remove.ts";
export { default as skillList } from "./commands/skill/list.ts";
export { default as skillInit } from "./commands/skill/init.ts";
@@ -1,213 +0,0 @@
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: {},
});
});
});
+2 -2
View File
@@ -89,13 +89,13 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
expect(res.json.enums.console_site).toEqual(["domestic", "international"]);
expect(res.json.booleanKeys).toContain("telemetry");
// Default field hints are surfaced as prefilled values in the UI.
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-3.0");
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-2.0");
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max");
expect(res.json.fieldDefaults.output_dir).toContain("bailian-output");
expect(res.json.fieldDefaults.timeout).toBe("300");
expect(res.json.fieldDefaults.base_url).toBe("https://dashscope.aliyuncs.com");
// Per-category model catalog (click-to-fill suggestions) is exposed too.
expect(res.json.modelCatalog.default_image_model[0]).toMatchObject({ id: "qwen-image-3.0" });
expect(res.json.modelCatalog.default_image_model[0]).toMatchObject({ id: "qwen-image-2.0" });
expect(res.json.modelCatalog.default_video_model.map((m: { id: string }) => m.id)).toContain(
"happyhorse-1.1-i2v",
);
@@ -0,0 +1,15 @@
version: "1"
providers:
claude:
api_key: ${ANTHROPIC_API_KEY}
defaults:
provider: claude
agents:
assistant:
description: "E2E claude-target fixture"
model: claude-sonnet-4-6
instructions: |
You are a helpful assistant.
@@ -0,0 +1,30 @@
version: "1"
providers:
bailian:
api_key: ${DASHSCOPE_API_KEY}
base_url: ${BAILIAN_BASE_URL}
environments:
dev:
config:
type: cloud
networking:
type: unrestricted
provider: bailian
agents:
assistant:
description: "E2E synced fixture (already exists in target)"
model: qwen3.7-max
instructions: |
You are a helpful assistant.
environment: dev
provider: bailian
reviewer:
description: "E2E synced fixture (new agent)"
model: qwen3.7-max
instructions: |
You review code.
environment: dev
provider: bailian
@@ -194,13 +194,13 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima
expect(stderr).toMatch(/--prompt|Usage:/i);
});
test("【qwen-image-3.0】图片编辑", async () => {
test("【qwen-image-2.0】图片编辑", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const gen = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
"qwen-image-3.0",
"qwen-image-2.0",
"--prompt",
"一只简笔画小猫,白底",
"--out-dir",
@@ -220,7 +220,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima
"image",
"edit",
"--model",
"qwen-image-3.0",
"qwen-image-2.0",
"--image",
imagePath!,
"--prompt",
@@ -202,19 +202,19 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"image",
"generate",
"--model",
"qwen-image-3.0",
"qwen-image-2.0",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prompt|Usage:/i);
});
test("【qwen-image-3.0】图片生成", async () => {
test("【qwen-image-2.0】图片生成", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
"qwen-image-3.0",
"qwen-image-2.0",
"--prompt",
"一只简笔画小猫,白底",
"--out-dir",
@@ -1,5 +1,6 @@
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { e2eFixturesDir, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
/**
@@ -245,3 +246,191 @@ describe("e2e: managed-agent--dry-run 短路,不联网不写盘)", () =>
expect(data.remote_id).toBe("agent-e2e");
});
});
describe("e2e: managed-agent sync / migratebailian-only", () => {
const fixturesDir = join(e2eFixturesDir, "managed-agent");
const agentsYaml = join(fixturesDir, "agents.yaml");
const agentsSyncedYaml = join(fixturesDir, "agents-synced.yaml");
const agentsClaudeYaml = join(fixturesDir, "agents-claude.yaml");
test("managed-agent sync --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--out|--force|--skip-missing-files/i);
expect(stderr).toMatch(/--agent-id/i);
expect(stderr).toMatch(/--types/i);
expect(stderr).toMatch(/--environment-id/i);
expect(stderr).toMatch(/--vault-id/i);
expect(stderr).toMatch(/--file-id/i);
expect(stderr).toMatch(/--skill-id/i);
});
test("sync 非法 --types 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--types",
"agent,sessions",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--types contains unsupported values: sessions/i);
});
test("sync --agent-id 搭配不含 agent 的 --types 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--agent-id",
"agent-e2e",
"--types",
"skill",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--agent-id requires --types to include agent/i);
});
test("sync --vault-id 搭配不含 vault 的 --types 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--vault-id",
"vault-e2e",
"--types",
"agent",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--vault-id requires --types to include vault/i);
});
test("sync 输出文件已存在且未 --force 时退出为用法错误 (2)", async () => {
// auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证,
// 命令仍会在覆盖写守卫处短路(先于构建 SDK runtime不产生任何网络请求。
const { stderr, exitCode } = await runCommandE2e(
MANAGED_AGENT_ROUTES,
["managed-agent", "sync", "--out", agentsYaml, "--quiet"],
{ DASHSCOPE_API_KEY: "sk-e2e-sync" },
);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/already exists/i);
});
test("sync --dry-run 仅回显计划,即使 --out 已存在也不报错", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--dry-run",
"--agent-id",
"agent-e2e",
"--skill-id",
"skill-e2e",
"--types",
"agents,skills",
"--out",
agentsYaml,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_sync?: {
provider?: string;
out?: string;
agent_id?: string;
skill_id?: string;
types?: string[];
};
}>(stdout);
expect(data.would_sync?.provider).toBe("bailian");
expect(data.would_sync?.out).toBe(agentsYaml);
expect(data.would_sync?.agent_id).toBe("agent-e2e");
expect(data.would_sync?.skill_id).toBe("skill-e2e");
// 复数拼写归一化为 SDK 的单数资源类型
expect(data.would_sync?.types).toEqual(["agent", "skill"]);
});
test("sync --agent-id 时 --types 自动补充 skill关联技能联动导出", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--dry-run",
"--agent-id",
"agent-e2e",
"--types",
"agent",
"--out",
"agents.synced.e2e-missing.yaml",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_sync?: { types?: string[] } }>(stdout);
expect(data.would_sync?.types).toEqual(["agent", "skill"]);
});
test("managed-agent migrate --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"migrate",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--from|--to/i);
});
test("migrate 目标文件缺失时退出为用法错误 (2)", async () => {
// auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证,
// 命令仍会在目标文件守卫处短路,不产生任何网络请求。
const { stderr, exitCode } = await runCommandE2e(
MANAGED_AGENT_ROUTES,
[
"managed-agent",
"migrate",
"--from",
agentsSyncedYaml,
"--to",
"agents.e2e-missing.yaml",
"--quiet",
],
{ DASHSCOPE_API_KEY: "sk-e2e-migrate" },
);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Target file .*agents\.e2e-missing\.yaml.*not found/i);
});
test("migrate 目标 provider 非 bailian 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(
MANAGED_AGENT_ROUTES,
["managed-agent", "migrate", "--from", agentsSyncedYaml, "--to", agentsClaudeYaml, "--quiet"],
{ DASHSCOPE_API_KEY: "sk-e2e-migrate" },
);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/only targets the bailian provider/i);
});
test("migrate --dry-run 仅回显计划,目标缺失也不报错", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"migrate",
"--dry-run",
"--from",
agentsSyncedYaml,
"--to",
"agents.e2e-missing.yaml",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_migrate?: { from?: string; to?: string };
}>(stdout);
expect(data.would_migrate?.from).toBe(agentsSyncedYaml);
expect(data.would_migrate?.to).toBe("agents.e2e-missing.yaml");
});
});
@@ -1,267 +0,0 @@
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);
});
});
+112 -209
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "vite-plus/test";
import {
isConsoleE2EReady,
isConsoleAuthFailure,
isDashScopeE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
@@ -13,31 +12,20 @@ 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-max");
expect(stderr).toContain("bl quota list --name qwen --page-size 50");
expect(stderr).toContain("bl quota list --model qwen3.6-plus");
});
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 () => {
test("quota request --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "request", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--rpm");
expect(stderr).toContain("--delete");
expect(stderr).toContain("--model");
expect(stderr).toContain("--tpm");
});
test("quota history --help 正常退出", async () => {
@@ -65,47 +53,10 @@ describe("e2e: quota", () => {
expect(exitCode).toBe(2);
expect(stderr).toContain("at least 1 minute");
});
});
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 () => {
describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
test("quota list --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
@@ -115,90 +66,105 @@ describe("e2e: quota", () => {
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
endpoint?: string;
method?: string;
query?: { page_no?: number; page_size?: number };
}>(stdout);
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 --model 多模型 --dry-run 逐模型一个请求", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"qwen3-max,qwen-plus",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
requests?: { endpoint?: string; method?: string; query?: { model?: string } }[];
}>(stdout);
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 update --dry-run 输出 OVERLAY 请求体", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
"--rpm",
"60",
"--tpm",
"100000",
"--dry-run",
"--output",
"json",
]);
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;
}[];
apis?: (string | { api: string; note?: string })[];
modelListInput?: {
input?: { queryQpmInfo?: boolean; supports?: { selfServiceLimitIncrease?: boolean } };
};
}>(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);
expect(data.apis?.[0]).toContain("listFoundationModels");
expect(data.modelListInput?.input?.queryQpmInfo).toBe(true);
expect(data.modelListInput?.input?.supports?.selfServiceLimitIncrease).toBe(true);
});
test("quota update --delete --dry-run 输出 DELETE 操作", async () => {
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 () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"request",
"--model",
"qwen-plus",
"--delete",
"qwen3.6-plus",
"--tpm",
"6000000",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { models?: { model?: string; operation_type?: string }[] };
api?: string;
data?: { input?: { model?: string; limit?: { usage_limit?: number } } };
}>(stdout);
expect(data.request?.models?.[0]?.operation_type).toBe("DELETE");
expect(data.api).toContain("updateFoundationModelLimits");
expect(data.data?.input?.model).toBe("qwen3.6-plus");
expect(data.data?.input?.limit?.usage_limit).toBeTypeOf("number");
});
test("quota request TPM 超范围报错", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"--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",
"--tpm",
"100000",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("not found");
});
test("quota history --dry-run 输出请求参数", async () => {
@@ -251,89 +217,6 @@ describe("e2e: quota", () => {
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;
@@ -378,4 +261,24 @@ 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);
});
});
+15 -23
View File
@@ -17,14 +17,12 @@ describe("e2e: skill", () => {
test("skill add --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "add", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--all/);
expect(stderr).toMatch(/--name/);
});
test("skill update --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--all/);
expect(stderr).toMatch(/--name/);
});
@@ -39,37 +37,18 @@ describe("e2e: skill", () => {
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/list|registry/i);
});
test("skill init --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "init", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/bailian/i);
});
});
// Local-only cases: auth "none" + validation happens before any network access, no gating needed
describe("e2e: skill (local, no credentials)", () => {
test("skill add without --all or --name errors as usage error (2)", async () => {
test("skill add without --name errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"add",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/--all|--name|Usage:/i);
});
test("skill add with both --all and --name errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"add",
"--all",
"--name",
"spark-video",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/--all|--name|either/i);
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
});
test("skill remove without --name errors as usage error (2)", async () => {
@@ -82,6 +61,19 @@ describe("e2e: skill (local, no credentials)", () => {
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
});
test("skill add rejects mixing all with specific names (2)", async () => {
// parseSkillNames throws UsageError before fetchSkillsIndex — offline-safe
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"add",
"--name",
"all,spark-video",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/all/i);
});
test("skill remove of a not-installed skill fails with reason (1)", async () => {
const configDir = makeTempConfigDir();
const { stdout, exitCode } = await runCommandE2e(
@@ -1,6 +1,4 @@
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 {
@@ -18,37 +16,6 @@ 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",
@@ -58,217 +25,6 @@ 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,37 +10,8 @@ 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", () => {
@@ -74,42 +45,7 @@ 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((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");
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
});
test("【qwen3.8-max】文本对话", async () => {
+3 -12
View File
@@ -100,25 +100,15 @@ export const ADVISOR_ROUTES: E2eRouteExports = {
export const QUOTA_ROUTES: E2eRouteExports = {
"quota list": "quotaList",
"quota update": "quotaUpdate",
// Backward-compatible alias of "quota update".
"quota request": "quotaUpdate",
"quota request": "quotaRequest",
"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 = {
@@ -175,12 +165,13 @@ export const SKILL_ROUTES: E2eRouteExports = {
"skill update": "skillUpdate",
"skill remove": "skillRemove",
"skill list": "skillList",
"skill init": "skillInit",
};
export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
"managed-agent init": "managedAgentInit",
"managed-agent validate": "managedAgentValidate",
"managed-agent sync": "managedAgentSync",
"managed-agent migrate": "managedAgentMigrate",
"managed-agent plan": "managedAgentPlan",
"managed-agent apply": "managedAgentApply",
"managed-agent destroy": "managedAgentDestroy",
@@ -1,80 +0,0 @@
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");
});
});
@@ -1,76 +0,0 @@
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");
});
});
@@ -159,7 +159,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"image",
"generate",
"--model",
"qwen-image-3.0",
"qwen-image-2.0",
"--prompt",
"一只简笔画小猫,白底",
"--out-dir",
@@ -169,7 +169,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
"image",
"generate",
"--model",
"qwen-image-3.0",
"qwen-image-2.0",
"--prompt",
"一片绿色的树叶,白底",
"--out-dir",
@@ -26,12 +26,6 @@ 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 广场深链", () => {
@@ -44,36 +38,6 @@ 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(
@@ -1,138 +0,0 @@
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.",
);
});
});
@@ -1,195 +0,0 @@
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 });
});
});
@@ -1,62 +0,0 @@
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.15.1",
"version": "1.14.0",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+20 -3
View File
@@ -1,9 +1,11 @@
import type { Settings } from "../../config/schema.ts";
import { anonymousConsoleCall } from "../../console/gateway.ts";
import { fetchModelListAll } from "../../console/models.ts";
import { callConsoleGateway, effectiveConsoleGatewayConfig } from "../../console/gateway.ts";
import { fetchModelList } 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;
@@ -39,7 +41,22 @@ export class ApiSource implements ModelSource {
async load(): Promise<ModelProfile[]> {
// Public model catalog — no console token (advisor runs unauthenticated).
const allRaw = await fetchModelListAll(anonymousConsoleCall(this.settings));
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);
}
return allRaw
.map(toModelProfile)
-327
View File
@@ -1,327 +0,0 @@
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 [];
}
+1 -26
View File
@@ -5,13 +5,7 @@ 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 {
bailianMcpPath,
bailianMcpSsePath,
connectBailianMcpWithFallback,
McpClient,
type McpConnectedClient,
} from "./mcp.ts";
import { McpClient } from "./mcp.ts";
import { callConsoleGateway } from "../console/gateway.ts";
import { refreshAccessToken } from "../auth/refresh-token.ts";
import { maskToken } from "../utils/token.ts";
@@ -170,25 +164,6 @@ 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);
-15
View File
@@ -6,11 +6,6 @@ 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 {
@@ -42,16 +37,6 @@ 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`;
+3 -15
View File
@@ -10,7 +10,7 @@ import { image2ImagePath, imagePath, imageSyncPath, imageText2ImagePath } from "
* - async text2image + prompt: wan2.5/2.2/2.1-t2i*, wanx*-t2i*
*
* Edit (I2I):
* - sync multimodal + messages(+images): qwen-image-3.0*, qwen-image-2.0*, qwen-image-edit*, wan2.6-image*, wan2.7-image*
* - sync multimodal + messages(+images): qwen-image-2.0*, qwen-image-edit*, wan2.6-image*, wan2.7-image*
* (pure T2I models such as z-image / qwen-image-plus / qwen-image-max are NOT edit models)
* - async image2image + prompt/images: wan2.5-i2i*
* - async image2image + function/base_image_url: *imageedit* (e.g. wanx2.1-imageedit)
@@ -60,7 +60,6 @@ const SYNC_GENERATE_PREFIXES = ["qwen-image", "wan2.7-image", "z-image"] as cons
* Pure T2I models (z-image / qwen-image-plus / qwen-image-max) are excluded.
*/
const SYNC_EDIT_PREFIXES = [
"qwen-image-3.0",
"qwen-image-2.0",
"qwen-image-edit",
"wan2.7-image",
@@ -110,12 +109,7 @@ export function isWanxFunctionImageEditModel(model: string): boolean {
}
export function resolveImageSizeProfile(model: string): ImageSizeProfile {
// 3.0 暂复用 2.0 高分比例表CLI ratio→像素便捷映射不做独立 3.0 profile。
if (
model.startsWith("qwen-image-3.0") ||
model.startsWith("qwen-image-2.0") ||
model.startsWith("qwen-image-edit")
) {
if (model.startsWith("qwen-image-2.0") || model.startsWith("qwen-image-edit")) {
return "qwen-image-2.0";
}
// Remaining qwen-image* (plus / max / bare qwen-image) share the fixed table.
@@ -139,13 +133,7 @@ export function resolveImageSizeProfile(model: string): ImageSizeProfile {
/** Official / CLI defaults for prompt_extend when the flag is omitted. */
export function resolvePromptExtendDefault(model: string): boolean | undefined {
if (
model.startsWith("qwen-image-3.0") ||
model.startsWith("qwen-image-2.0") ||
model.startsWith("qwen-image-max")
) {
return true;
}
if (model.startsWith("qwen-image-2.0") || model.startsWith("qwen-image-max")) return true;
// Z-Image docs default prompt_extend to false.
if (model.startsWith("z-image")) return false;
return undefined;
+2 -29
View File
@@ -13,10 +13,7 @@ export {
memoryNodePath,
memorySearchPath,
mcpWebSearchPath,
modelsLimitsPath,
modelsPermissionsPath,
profileSchemaPath,
responsesPath,
speechRecognizePath,
speechSynthesizePath,
taskPath,
@@ -37,18 +34,6 @@ 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";
@@ -72,19 +57,7 @@ export {
type AcsQueryParams,
type AcsSignConfig,
} from "./acs.ts";
export type {
McpTool,
McpToolResult,
McpConnectedClient,
ConnectBailianMcpOptions,
} from "./mcp.ts";
export {
McpClient,
bailianMcpPath,
bailianMcpSsePath,
isStreamableHttpUnsupported,
isUrlOverrideSseFallbackCandidate,
connectBailianMcpWithFallback,
} from "./mcp.ts";
export type { McpTool, McpToolResult } from "./mcp.ts";
export { McpClient, bailianMcpPath } from "./mcp.ts";
export type { ServerSentEvent } from "./stream.ts";
export { parseSSE } from "./stream.ts";
-474
View File
@@ -1,474 +0,0 @@
/**
* 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;
},
};
}
+2 -137
View File
@@ -15,8 +15,6 @@ 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 ----
@@ -29,7 +27,7 @@ interface JsonRpcRequest {
interface JsonRpcResponse {
jsonrpc: "2.0";
id?: number | string | null;
id: number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
@@ -63,101 +61,6 @@ 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 {
@@ -218,7 +121,7 @@ export class McpClient {
};
const response = await this.send(body);
const data = await this.readJsonRpcResponse(response, id);
const data = (await response.json()) as JsonRpcResponse;
if (data.error) {
throw new BailianError(
@@ -240,44 +143,6 @@ 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",
+46 -97
View File
@@ -7,71 +7,6 @@ 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;
@@ -79,55 +14,69 @@ 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) {
// 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;
}
if (done) 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, rest } = takeNormalizedSseLines(buffer);
buffer = rest;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let event: Partial<ServerSentEvent> = {};
for (const line of lines) {
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
event = applied.event;
if (applied.completed) {
yield applied.completed;
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;
}
}
}
// 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 };
// Flush remaining
if (buffer.trim() && buffer.includes("data:")) {
const colonIndex = buffer.indexOf(":");
if (colonIndex !== -1) {
yield { data: buffer.slice(colonIndex + 1).trimStart() };
}
}
} finally {
reader.releaseLock();
-19
View File
@@ -63,25 +63,6 @@ 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>,
+2 -13
View File
@@ -1,14 +1,5 @@
export type {
ConsoleCall,
ConsoleGatewayRequest,
ConsoleGatewayTarget,
ConsoleSite,
} from "./gateway.ts";
export {
anonymousConsoleCall,
callConsoleGateway,
effectiveConsoleGatewayConfig,
} from "./gateway.ts";
export type { ConsoleGatewayRequest, ConsoleGatewayTarget, ConsoleSite } from "./gateway.ts";
export { callConsoleGateway, effectiveConsoleGatewayConfig } from "./gateway.ts";
export type {
ModelListParams,
ModelListResult,
@@ -21,8 +12,6 @@ export type {
} from "./models.ts";
export {
fetchModelList,
fetchModelListAll,
findModelByName,
fetchModelGroups,
fetchModelDetail,
fetchPredictConfig,
+2 -32
View File
@@ -1,5 +1,3 @@
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";
@@ -8,6 +6,8 @@ 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,36 +77,6 @@ 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`
// ---------------------------------------------------------------------------
+12 -4
View File
@@ -1,6 +1,6 @@
import type { Settings } from "../config/schema.ts";
import { anonymousConsoleCall } from "../console/gateway.ts";
import { findModelByName } from "../console/models.ts";
import { callConsoleGateway, effectiveConsoleGatewayConfig } from "../console/gateway.ts";
import { fetchModelList } from "../console/models.ts";
/**
* Training-type vocabulary exposed to users.
@@ -111,6 +111,14 @@ export async function fetchModelCapability(
modelName: string,
): Promise<ModelCapability | null> {
// Public model catalog — anonymous gateway call, no console token needed.
const match = await findModelByName(anonymousConsoleCall(settings), modelName);
return match as ModelCapability | null;
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 result = await fetchModelList(call, { name: modelName, pageSize: 20 });
const match = result.models.find((item) => (item.model as string | undefined) === modelName);
return (match as ModelCapability | undefined) ?? null;
}
+7 -58
View File
@@ -108,44 +108,6 @@ export interface StreamChunk {
};
}
// ---- Responses (OpenAI Compatible) ----
export interface ResponsesRequest {
model: string;
input: ChatMessage[];
max_output_tokens?: number;
temperature?: number;
top_p?: number;
stream?: boolean;
tools?: Array<Record<string, unknown>>;
enable_thinking?: boolean;
}
export interface ResponsesOutputContent {
type: string;
text?: string;
}
export interface ResponsesOutputItem {
type: string;
content?: ResponsesOutputContent[];
[key: string]: unknown;
}
export interface ResponsesResponse {
id: string;
object: "response";
status: string;
output: ResponsesOutputItem[];
[key: string]: unknown;
}
export interface ResponsesStreamEvent {
type: string;
delta?: string;
[key: string]: unknown;
}
// ---- Image (DashScope) ----
export interface DashScopeImageRequest {
@@ -571,46 +533,33 @@ export interface DashScopeTTSStreamChunk {
export interface DashScopeASRRequest {
model: string;
input: {
file_urls?: string[];
file_url?: string;
file_urls: string[];
};
parameters?: {
channel_id?: number[];
/** Classic async models (fun-asr / paraformer / qwen-audio filetrans, etc.) */
language_hints?: string[];
/** qwen3-asr-flash-filetrans* uses singular `language` */
language?: string;
diarization_enabled?: boolean;
speaker_count?: number;
vocabulary_id?: string;
};
}
export interface DashScopeASRTranscriptionItem {
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}
export interface DashScopeASRTaskResult {
output: {
task_id: string;
task_status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "UNKNOWN";
/** Multi-file async results (fun-asr / paraformer / qwen-audio filetrans, etc.) */
results?: DashScopeASRTranscriptionItem[];
/** Singular result returned by qwen3-asr-flash-filetrans* on success */
result?: {
results?: Array<{
file_url?: string;
transcription_url?: string;
};
subtask_status?: string;
code?: string;
message?: string;
}>;
task_metrics?: {
TOTAL: number;
SUCCEEDED: number;
FAILED: number;
};
code?: string;
message?: string;
};
usage?: Record<string, unknown>;
request_id: string;
-1
View File
@@ -48,7 +48,6 @@ export type {
ChatTool,
DashScopeASRRequest,
DashScopeASRTaskResult,
DashScopeASRTranscriptionItem,
DashScopeAsyncResponse,
DashScopeImageRequest,
DashScopeImageSyncResponse,
-213
View File
@@ -1,213 +0,0 @@
import { expect, test } from "vite-plus/test";
import {
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
inferAudioFormatHint,
resolveAsrApi,
} from "../src/client/asr-routes.ts";
test("resolveAsrApi routes model families correctly", () => {
const cases = [
{
model: "fun-asr",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_urls",
},
},
{
model: "qwen3-asr-flash-filetrans-2025-11-17",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_url",
asyncLanguageStyle: "language",
},
},
{
model: "qwen-audio-3.0-asr-flash-filetrans",
expected: {
kind: "async-filetrans",
useSync: false,
asyncInputStyle: "file_urls",
asyncLanguageStyle: "language_hints",
},
},
{
model: "qwen3-asr-flash-us",
expected: {
kind: "sync-flash",
useSync: true,
flashFamily: "qwen3",
path: "/api/v1/services/aigc/multimodal-generation/generation",
},
},
{
model: "qwen-audio-3.0-asr-flash",
expected: {
kind: "sync-flash",
useSync: true,
flashFamily: "input-audio",
},
},
{
model: "qwen3-asr-flash-realtime",
expected: {
kind: "unsupported",
},
},
{
model: "foo-asr-flash",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_urls",
},
},
] as const;
for (const { model, expected } of cases) {
const route = resolveAsrApi(model);
expect(route, model).toMatchObject(expected);
if (expected.kind === "unsupported") {
expect(route.unsupportedReason, model).toMatch(/realtime|streaming|WebSocket/i);
}
}
});
test("unknown models default to async-filetrans for backward compatibility", () => {
expect(resolveAsrApi("custom-asr-model")).toMatchObject({
kind: "async-filetrans",
useSync: false,
});
});
test("inferAudioFormatHint reads extension from url", () => {
expect(inferAudioFormatHint("https://example.com/a.mp3")).toBe("mp3");
expect(inferAudioFormatHint("oss://bucket/path/file.WAV")).toBe("wav");
expect(inferAudioFormatHint("https://example.com/a.mpeg?x=1")).toBe("mp3");
expect(inferAudioFormatHint("https://example.com/noext")).toBe("wav");
expect(inferAudioFormatHint("data:audio/mpeg;base64,AAA")).toBe("mp3");
expect(inferAudioFormatHint("data:audio/x-wav;base64,AAA")).toBe("wav");
expect(inferAudioFormatHint("data:audio/ogg;codecs=opus;base64,AAA")).toBe("ogg");
});
test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => {
expect(
buildAsrFlashRequest({
model: "qwen3-asr-flash",
audioUrl: "https://example.com/a.mp3",
language: "en",
flashFamily: "qwen3",
}),
).toEqual({
model: "qwen3-asr-flash",
input: {
messages: [{ role: "user", content: [{ audio: "https://example.com/a.mp3" }] }],
},
parameters: { asr_options: { language: "en" } },
});
expect(
buildAsrFlashRequest({
model: "qwen-audio-3.0-asr-flash",
audioUrl: "https://example.com/a.wav",
language: "en",
vocabularyId: "vocab-abc",
flashFamily: "input-audio",
}),
).toEqual({
model: "qwen-audio-3.0-asr-flash",
input: {
messages: [
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
parameters: {
format: "wav",
sample_rate: "16000",
language_hints: ["en"],
vocabulary_id: "vocab-abc",
},
});
});
test("buildAsyncAsrLanguageFields maps language by async style", () => {
expect(buildAsyncAsrLanguageFields("language_hints", "zh")).toEqual({
language_hints: ["zh"],
});
expect(buildAsyncAsrLanguageFields("language", "zh")).toEqual({ language: "zh" });
expect(buildAsyncAsrLanguageFields("language", undefined)).toEqual({});
});
test("extractAsrFlashText reads qwen3 choices and input-audio text fields", () => {
expect(
extractAsrFlashText(
{
output: {
choices: [{ message: { content: [{ text: "你好" }] } }],
},
},
"qwen3",
),
).toBe("你好");
expect(
extractAsrFlashText(
{
output: {
text: "Hello World",
output: { sentence: { text: "ignored when text present" } },
},
},
"input-audio",
),
).toBe("Hello World");
expect(
extractAsrFlashText(
{
output: {
sentence: { text: "top-level sentence" },
},
},
"input-audio",
),
).toBe("top-level sentence");
expect(
extractAsrFlashText(
{
output: {
output: { sentence: { text: "nested sentence" } },
},
},
"input-audio",
),
).toBe("nested sentence");
});
test("collectAsrTranscriptionItems prefers results[] then singular result", () => {
expect(
collectAsrTranscriptionItems({
results: [{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }],
}),
).toEqual([{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }]);
expect(
collectAsrTranscriptionItems({
result: { transcription_url: "https://example.com/qwen3.json" },
}),
).toEqual([{ transcription_url: "https://example.com/qwen3.json", subtask_status: "SUCCEEDED" }]);
expect(collectAsrTranscriptionItems({})).toEqual([]);
});
-12
View File
@@ -11,7 +11,6 @@ import {
} from "../src/client/image-routes.ts";
test("sync multimodal family covers qwen-image, wan2.6/2.7 image, and z-image", () => {
expect(isSyncMultimodalImageModel("qwen-image-3.0")).toBe(true);
expect(isSyncMultimodalImageModel("qwen-image-2.0")).toBe(true);
expect(isSyncMultimodalImageModel("qwen-image-2.0-pro")).toBe(true);
expect(isSyncMultimodalImageModel("qwen-image-plus")).toBe(true);
@@ -42,7 +41,6 @@ test("legacy image2image is wan2.5-i2i only; wanx imageedit uses function protoc
});
test("size profiles are model-specific, not sync/async", () => {
expect(resolveImageSizeProfile("qwen-image-3.0")).toBe("qwen-image-2.0");
expect(resolveImageSizeProfile("qwen-image-2.0")).toBe("qwen-image-2.0");
expect(resolveImageSizeProfile("qwen-image")).toBe("qwen-image-fixed");
expect(resolveImageSizeProfile("qwen-image-plus")).toBe("qwen-image-fixed");
@@ -57,7 +55,6 @@ test("size profiles are model-specific, not sync/async", () => {
});
test("prompt_extend defaults follow model docs", () => {
expect(resolvePromptExtendDefault("qwen-image-3.0")).toBe(true);
expect(resolvePromptExtendDefault("qwen-image-2.0")).toBe(true);
expect(resolvePromptExtendDefault("qwen-image-max")).toBe(true);
expect(resolvePromptExtendDefault("z-image-turbo")).toBe(false);
@@ -88,11 +85,6 @@ test("resolveImageGenerateApi picks path, input style, and size profile", () =>
kind: "async-image-generation",
sizeProfile: "wan26",
});
expect(resolveImageGenerateApi("qwen-image-3.0")).toMatchObject({
kind: "sync-multimodal",
sizeProfile: "qwen-image-2.0",
promptExtendDefault: true,
});
expect(resolveImageGenerateApi("qwen-image-2.0")).toMatchObject({
kind: "sync-multimodal",
sizeProfile: "qwen-image-2.0",
@@ -123,10 +115,6 @@ test("resolveImageEditApi excludes pure T2I models from sync edit", () => {
kind: "sync-multimodal",
useSync: true,
});
expect(resolveImageEditApi("qwen-image-3.0")).toMatchObject({
kind: "sync-multimodal",
useSync: true,
});
expect(resolveImageEditApi("qwen-image-2.0")).toMatchObject({
kind: "sync-multimodal",
useSync: true,
-736
View File
@@ -1,736 +0,0 @@
import { expect, test } from "vite-plus/test";
import type { Identity, Settings } from "../src/index.ts";
import {
BailianError,
bailianMcpPath,
bailianMcpSsePath,
connectBailianMcpWithFallback,
isStreamableHttpUnsupported,
isUrlOverrideSseFallbackCandidate,
McpClient,
} from "../src/index.ts";
import { McpSseClient, resolveSameOriginMessageUrl } from "../src/client/mcp-sse.ts";
function testDeps(overrides?: Partial<Settings>): { identity: Identity; settings: Settings } {
return {
identity: {
binName: "bl",
version: "0.0.0-test",
npmPackage: "bailian-cli",
clientName: "bailian-cli",
},
settings: {
output: "json",
outputExplicit: true,
timeout: 5,
verbose: false,
quiet: true,
dryRun: false,
telemetry: true,
...overrides,
},
};
}
function jsonRpcResult(id: number | string, result: unknown): string {
return `event:message\ndata:${JSON.stringify({ jsonrpc: "2.0", id, result })}\n\n`;
}
function requestUrl(input: string | URL | Request): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
test("bailianMcp 路径与 isStreamableHttpUnsupported", () => {
expect(bailianMcpPath("WebParser")).toBe("/api/v1/mcps/WebParser/mcp");
expect(bailianMcpSsePath("WebParser")).toBe("/api/v1/mcps/WebParser/sse");
expect(
isStreamableHttpUnsupported(
new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
),
),
).toBe(true);
expect(
isStreamableHttpUnsupported(new BailianError("MCP request failed: 405 Method Not Allowed")),
).toBe(true);
expect(isStreamableHttpUnsupported(new BailianError("MCP request failed: 404 Not Found"))).toBe(
false,
);
expect(isStreamableHttpUnsupported(new Error("405 streamableHttp"))).toBe(false);
// JSON-RPC business 405 must not trigger HTTP transport fallback
expect(isStreamableHttpUnsupported(new BailianError("MCP error (405): Method Not Allowed"))).toBe(
false,
);
// Nested wrapper phrase in a JSON-RPC message must not trigger fallback.
expect(
isStreamableHttpUnsupported(
new BailianError("MCP error (-32000): MCP request failed: 405 Method Not Allowed"),
),
).toBe(false);
expect(
isUrlOverrideSseFallbackCandidate(new BailianError("MCP request failed: 404 Not Found")),
).toBe(true);
expect(
isUrlOverrideSseFallbackCandidate(
new BailianError("MCP request failed: 405 Method Not Allowed"),
),
).toBe(true);
expect(isUrlOverrideSseFallbackCandidate(new BailianError("MCP error (404): not found"))).toBe(
false,
);
expect(
isUrlOverrideSseFallbackCandidate(
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found"),
),
).toBe(false);
});
test("resolveSameOriginMessageUrl同源通过、跨域拒绝", () => {
expect(
resolveSameOriginMessageUrl(
"https://example.test/api/v1/mcps/WebParser/sse",
"/api/v1/mcps/WebParser/message?sessionId=x",
),
).toBe("https://example.test/api/v1/mcps/WebParser/message?sessionId=x");
expect(() =>
resolveSameOriginMessageUrl(
"https://example.test/api/v1/mcps/WebParser/sse",
"https://evil.example/steal",
),
).toThrow(/origin mismatch/i);
});
test("connectBailianMcpWithFallback成功走 Streamable405 降级 SSE", async () => {
const originalFetch = globalThis.fetch;
// Streamable success path
globalThis.fetch = async (input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
if (requestUrl(input).includes("/sse")) {
return new Response("should not hit sse", { status: 500 });
}
if (body.method === "notifications/initialized") {
return new Response(null, { status: 200 });
}
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: {} }), {
status: 200,
});
};
try {
const connected = await connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
serverCode: "WebParser",
});
expect(connected.url).toContain("/mcp");
} finally {
globalThis.fetch = originalFetch;
}
// Bare HTTP 405 (no streamableHttp body text) → SSE
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
const urls: string[] = [];
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
urls.push(`${init?.method ?? "GET"} ${url}`);
if (url.endsWith("/mcp")) {
return new Response("Method Not Allowed", {
status: 405,
statusText: "Method Not Allowed",
});
}
if (url.endsWith("/sse") && (init?.method ?? "GET") === "GET") {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
sseController = controller;
controller.enqueue(
encoder.encode(
"event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=test-session\n\n",
),
);
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
if (url.includes("/message")) {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
queueMicrotask(() => {
if (body.id != null && sseController) {
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
}
});
return new Response(null, { status: 200 });
}
return new Response("unexpected", { status: 500 });
};
try {
const connected = await connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
serverCode: "WebParser",
});
expect(connected.url).toContain("/sse");
expect(urls.some((entry) => entry.includes("GET ") && entry.includes("/sse"))).toBe(true);
connected.client.close?.();
} finally {
globalThis.fetch = originalFetch;
}
});
test("connectBailianMcpWithFallbackWebSearch 不降级urlOverride 同 URL 降级 SSE404 不降级 Bailian 路径", async () => {
const originalFetch = globalThis.fetch;
const urls: string[] = [];
globalThis.fetch = async (input) => {
urls.push(requestUrl(input));
return new Response("current mcp not support streamableHttp", {
status: 405,
statusText: "Method Not Allowed",
});
};
try {
await expect(
connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebSearch/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebSearch/sse",
serverCode: "WebSearch",
}),
).rejects.toBeInstanceOf(BailianError);
expect(urls.some((url) => url.includes("/sse"))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
// urlOverride: after POST 405, fall back with GET SSE on the same URL
urls.length = 0;
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
const overrideUrl = "https://custom.example/mcp";
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
const method = init?.method ?? "GET";
urls.push(`${method} ${url}`);
if (method === "POST" && url === overrideUrl) {
return new Response("Method Not Allowed", {
status: 405,
statusText: "Method Not Allowed",
});
}
if (method === "GET" && url === overrideUrl) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
sseController = controller;
controller.enqueue(encoder.encode("event:endpoint\ndata:/message?sessionId=x\n\n"));
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
if (url.includes("/message")) {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
queueMicrotask(() => {
if (body.id != null && sseController) {
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
}
});
return new Response(null, { status: 200 });
}
return new Response("unexpected", { status: 500 });
};
try {
const connected = await connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
serverCode: "WebParser",
urlOverride: overrideUrl,
});
expect(connected.url).toBe(overrideUrl);
expect(urls.some((entry) => entry.startsWith(`GET ${overrideUrl}`))).toBe(true);
connected.client.close?.();
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = async () =>
new Response("MCP不存在或未开通", { status: 404, statusText: "Not Found" });
try {
await expect(
connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
serverCode: "WebParser",
}),
).rejects.toMatchObject({ message: expect.stringContaining("404") });
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClient流结束后立刻失败 pending不干等到 timeout", async () => {
const originalFetch = globalThis.fetch;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
if ((init?.method ?? "GET") === "GET" || url.endsWith("/sse")) {
// Close the stream immediately after the endpoint event
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode("event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n"),
);
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
return new Response(null, { status: 200 });
};
try {
const client = new McpSseClient(
testDeps({ timeout: 5 }),
"https://example.test/sse",
"sk-test",
);
const started = Date.now();
await expect(client.initialize()).rejects.toThrow(/stream ended unexpectedly/i);
expect(Date.now() - started).toBeLessThan(2000);
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientstring JSON-RPC id 可匹配;仅认 event:endpoint", async () => {
const originalFetch = globalThis.fetch;
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
if ((init?.method ?? "GET") === "GET" || url.endsWith("/sse")) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
sseController = controller;
// Untyped events must not be treated as endpoint
controller.enqueue(
encoder.encode(`data:${JSON.stringify({ jsonrpc: "2.0", id: 99, result: {} })}\n\n`),
);
controller.enqueue(
encoder.encode("event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n"),
);
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
if (url.includes("/message")) {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
queueMicrotask(() => {
if (body.id != null && sseController) {
// Echo id as a string
sseController.enqueue(encoder.encode(jsonRpcResult(String(body.id), {})));
}
});
return new Response(null, { status: 200 });
}
return new Response("unexpected", { status: 500 });
};
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
await client.initialize();
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpClient支持 text/event-stream 响应体", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
if (body.method === "notifications/initialized") {
return new Response(null, { status: 202 });
}
const sse = `event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: {
protocolVersion: "2025-03-26",
capabilities: {},
serverInfo: { name: "x", version: "0" },
},
})}\n\n`;
return new Response(sse, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
};
try {
const client = new McpClient(testDeps(), "https://example.test/mcp", "sk-test");
await client.initialize();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClient.close 可中止挂起 GET", async () => {
const originalFetch = globalThis.fetch;
let aborted = false;
globalThis.fetch = async (_input, init) => {
const signal = init?.signal;
if (signal) {
signal.addEventListener("abort", () => {
aborted = true;
});
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
"event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n",
),
);
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
};
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
const initPromise = client.initialize().catch(() => undefined);
await new Promise((resolve) => setTimeout(resolve, 20));
client.close();
await initPromise;
expect(aborted).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClient等待响应头受 --timeout 约束", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
const signal = init?.signal;
return new Promise((_resolve, reject) => {
if (!signal) {
reject(new Error("missing signal"));
return;
}
if (signal.aborted) {
reject(new DOMException("This operation was aborted.", "AbortError"));
return;
}
signal.addEventListener(
"abort",
() => reject(new DOMException("This operation was aborted.", "AbortError")),
{ once: true },
);
});
};
try {
const client = new McpSseClient(
testDeps({ timeout: 1 }),
"https://example.test/sse",
"sk-test",
);
const started = Date.now();
await expect(client.initialize()).rejects.toThrow(/timed out waiting for response headers/i);
expect(Date.now() - started).toBeLessThan(2500);
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClient非 2xx 不产生 unhandledRejection", async () => {
const originalFetch = globalThis.fetch;
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
globalThis.fetch = async () =>
new Response("boom", { status: 500, statusText: "Internal Server Error" });
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
await expect(client.initialize()).rejects.toThrow(/MCP request failed:\s*500/i);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(unhandled).toEqual([]);
client.close();
} finally {
process.off("unhandledRejection", onUnhandled);
globalThis.fetch = originalFetch;
}
});
test("McpSseClient非 2xx 读 body 仍受 --timeout 约束", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
const signal = init?.signal;
return {
ok: false,
status: 500,
statusText: "Internal Server Error",
async text() {
return new Promise<string>((_resolve, reject) => {
if (!signal) {
reject(new Error("missing signal"));
return;
}
if (signal.aborted) {
reject(new DOMException("This operation was aborted.", "AbortError"));
return;
}
signal.addEventListener(
"abort",
() => reject(new DOMException("This operation was aborted.", "AbortError")),
{ once: true },
);
});
},
} as Response;
};
try {
const client = new McpSseClient(
testDeps({ timeout: 1 }),
"https://example.test/sse",
"sk-test",
);
const started = Date.now();
await expect(client.initialize()).rejects.toThrow(/timed out reading error response body/i);
expect(Date.now() - started).toBeLessThan(2500);
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientfetch 失败抛出原始 TypeError保留 ENOTFOUND", async () => {
const originalFetch = globalThis.fetch;
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.test"), {
code: "ENOTFOUND",
});
const fetchFailed = new TypeError("fetch failed", { cause: root });
globalThis.fetch = async () => {
throw fetchFailed;
};
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
const error = await client.initialize().catch((reason: unknown) => reason);
expect(error).toBe(fetchFailed);
expect((error as TypeError & { cause?: NodeJS.ErrnoException }).cause?.code).toBe("ENOTFOUND");
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientfetch 失败后同实例可重新 openSse", async () => {
const originalFetch = globalThis.fetch;
let attempt = 0;
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
const method = init?.method ?? "GET";
if (method === "GET" || url.endsWith("/sse")) {
attempt += 1;
if (attempt === 1) {
throw new TypeError("fetch failed");
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
sseController = controller;
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
queueMicrotask(() => {
if (body.id != null && sseController) {
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
}
});
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
};
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
await expect(client.initialize()).rejects.toThrow(/fetch failed/i);
await client.initialize();
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientclose 可中止进行中的 POST", async () => {
const originalFetch = globalThis.fetch;
let postAborted = false;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
const method = init?.method ?? "GET";
if (method === "GET" || url.endsWith("/sse")) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
const signal = init?.signal;
return new Promise((_resolve, reject) => {
if (!signal) {
reject(new Error("missing signal"));
return;
}
const onAbort = () => {
postAborted = true;
reject(new DOMException("This operation was aborted.", "AbortError"));
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
});
};
try {
const client = new McpSseClient(
testDeps({ timeout: 5 }),
"https://example.test/sse",
"sk-test",
);
const initPromise = client.initialize();
await new Promise((resolve) => setTimeout(resolve, 30));
client.close();
await expect(initPromise).rejects.toThrow(/session closed|aborted/i);
expect(postAborted).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientPOST 非 2xx 读 body 仍受 --timeout 约束", async () => {
const originalFetch = globalThis.fetch;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
const method = init?.method ?? "GET";
if (method === "GET" || url.endsWith("/sse")) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
const signal = init?.signal;
const body = new ReadableStream<Uint8Array>({
start(controller) {
if (!signal) return;
const onAbort = () => {
try {
controller.error(new DOMException("This operation was aborted.", "AbortError"));
} catch {
/* ignore */
}
};
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
},
});
return new Response(body, { status: 500, statusText: "Internal Server Error" });
};
try {
const client = new McpSseClient(
testDeps({ timeout: 1 }),
"https://example.test/sse",
"sk-test",
);
const started = Date.now();
await expect(client.initialize()).rejects.toThrow(/timed out reading error response body/i);
expect(Date.now() - started).toBeLessThan(2500);
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -1,8 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { responsesPath } from "../src/index.ts";
describe("Responses API", () => {
test("uses the OpenAI-compatible Responses endpoint", () => {
expect(responsesPath()).toBe("/compatible-mode/v1/responses");
});
});
-79
View File
@@ -1,79 +0,0 @@
import { expect, test } from "vite-plus/test";
import { parseSSE } from "../src/client/stream.ts";
async function collectEvents(
chunks: string[],
): Promise<Array<{ data: string; event?: string; id?: string }>> {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
const response = new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
const events: Array<{ data: string; event?: string; id?: string }> = [];
for await (const event of parseSSE(response)) {
events.push(event);
}
return events;
}
test("parseSSE单 chunk 完整事件保持原行为", async () => {
const events = await collectEvents([
'event: message\ndata: {"ok":true}\nid: 1\n\ndata: plain\n\n',
]);
expect(events).toEqual([{ data: '{"ok":true}', event: "message", id: "1" }, { data: "plain" }]);
});
test("parseSSE多行 data 与注释保持原行为", async () => {
const events = await collectEvents([": keep-alive\ndata: line1\ndata: line2\n\n"]);
expect(events).toEqual([{ data: "line1\nline2" }]);
});
test("parseSSE跨 chunk 保留 event 类型", async () => {
const events = await collectEvents(["event: endpoint\n", "data: /message?sessionId=abc\n\n"]);
expect(events).toEqual([{ data: "/message?sessionId=abc", event: "endpoint" }]);
});
test("parseSSE跨 chunk 保留 id且多事件连续正确", async () => {
const events = await collectEvents([
"id: a\nevent: message\n",
'data: {"n":1}\n\n',
"event: message\ndata: ",
'{"n":2}\n\n',
]);
expect(events).toEqual([
{ data: '{"n":1}', event: "message", id: "a" },
{ data: '{"n":2}', event: "message" },
]);
});
test("parseSSECRLF 行尾可解析 endpoint", async () => {
const events = await collectEvents(["event: endpoint\r\ndata: /message\r\n\r\n"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSE纯 CR 行尾可解析 endpoint", async () => {
const events = await collectEvents(["event: endpoint\rdata: /message\r\r"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSE跨 chunk 的 CRLF\\r|\\n不丢事件", async () => {
const events = await collectEvents(["event: endpoint\r", "\ndata: /message\r\n\r\n"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSEEOF without blank line keeps event type", async () => {
const events = await collectEvents(["event: endpoint\ndata: /message"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSEEOF data-only flush keeps prior behavior", async () => {
const events = await collectEvents(["data: plain"]);
expect(events).toEqual([{ data: "plain" }]);
});

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