mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac48c3ec23 |
@@ -37,7 +37,6 @@ tools/generated
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.cursor/
|
||||
.qoder/
|
||||
.qwen/
|
||||
.qoder
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -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)。
|
||||
|
||||
约定:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`。
|
||||
|
||||
安装 skills(CLI 内置,无需 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 及用户可做什么。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **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
@@ -22,16 +22,28 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
|
||||
- **素材理解** — 图像、文档、音频、长视频的解析与问答
|
||||
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
|
||||
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
|
||||
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
|
||||
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
|
||||
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
- **文本对话** — Qwen3.8-max:Agentic 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
|
||||
# Windows(PowerShell)
|
||||
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)。
|
||||
|
||||
@@ -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
@@ -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. 文案与落款一致性
|
||||
|
||||
- [ ] 领域 skill(gen / 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
@@ -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 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **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
@@ -22,16 +22,28 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
|
||||
- **素材理解** — 图像、文档、音频、长视频的解析与问答
|
||||
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
|
||||
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
|
||||
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
|
||||
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
|
||||
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
- **文本对话** — Qwen3.8-max:Agentic 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
|
||||
# Windows(PowerShell)
|
||||
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,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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}` : "";
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 / migrate(bailian-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),无需凭证即可断言请求形状。
|
||||
// 不传 --output:permission 命令组默认 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: permission(DashScope)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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: quota(Console)", () => {
|
||||
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: quota(DashScope)", () => {
|
||||
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: quota(Console)", () => {
|
||||
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: quota(Console)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 chat(DashScope)", () => {
|
||||
@@ -74,42 +45,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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-plan(Console)", () => {
|
||||
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-plan(Console)", () => {
|
||||
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,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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,7 +48,6 @@ export type {
|
||||
ChatTool,
|
||||
DashScopeASRRequest,
|
||||
DashScopeASRTaskResult,
|
||||
DashScopeASRTranscriptionItem,
|
||||
DashScopeAsyncResponse,
|
||||
DashScopeImageRequest,
|
||||
DashScopeImageSyncResponse,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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:成功走 Streamable;405 降级 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("connectBailianMcpWithFallback:WebSearch 不降级;urlOverride 同 URL 降级 SSE;404 不降级 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("McpSseClient:string 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("McpSseClient:fetch 失败抛出原始 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("McpSseClient:fetch 失败后同实例可重新 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("McpSseClient:close 可中止进行中的 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("McpSseClient:POST 非 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");
|
||||
});
|
||||
});
|
||||
@@ -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("parseSSE:CRLF 行尾可解析 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("parseSSE:EOF without blank line keeps event type", async () => {
|
||||
const events = await collectEvents(["event: endpoint\ndata: /message"]);
|
||||
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
|
||||
});
|
||||
|
||||
test("parseSSE:EOF 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
Reference in New Issue
Block a user