Compare commits

..

8 Commits

Author SHA1 Message Date
lisheng.lisheng 074fb58329 feat(dsh): welcome cards send a query into the conversation
Clicking a welcome card used to POST /bailian/console and render the raw
feature result inline — a dead end: the user saw a summary with no way to
follow up, and the agent never learned the question was asked.

Now each feature declares a `query` (natural-language phrasing) and the
card drops it into the conversation input, then submits. The agent routes
it to the matching tool itself, can AskUserQuestion for missing params,
and the whole exchange stays in the transcript where the user can ask
follow-ups. This removes the inline result panel and its loading state.

Drop the `apikey` feature: it only echoed a masked key, which the settings
page already shows. `bl token-plan personal-key` stays as a CLI command.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 00:20:29 +08:00
lisheng.lisheng 98acbd7341 feat(dsh): feature catalog with per-feature tools + personal TokenPlan commands
Add a feature catalog (`packages/dsh/src/features.ts`) that drives two entry
points from one declaration: a model tool per feature (natural-language
entry) and a card-click webServer route (UI entry). Each feature declares
its `bl` argv, optional parameter flags, and a `summarize` projection, so
adding a capability means adding one catalog entry rather than wiring a
tool and a route separately.

New CLI commands backing the TokenPlan usage panel:

- `bl token-plan personal-usage` — 5h/1w usage percentage, subscription
  state, and addon credits, unwrapping the console gateway's nested
  `data.DataV2.data.data` envelope.
- `bl token-plan personal-key` — the masked personal-edition API key.

Both are `auth: "console"` and registered per the command checklist
(library export, `bl` product map, e2e topic routes, generated reference).

Two type fixes in the tool registration path:

- The parameter map was typed `Record<string, { type: string; ... }>`,
  which widens `FeatureParam["type"]` to `string` and is then unassignable
  to `ParameterSchemaSpec` (it needs the literal union). Keep the literal.
- `invokeFeature` returned `Promise<unknown>`, so the tool's `{ summary,
  data }` value failed `Record<string, JsonValue>`. It parses
  `bl --output json` output, so its return type is `JsonValue` — narrowing
  the signature is more honest than casting at the call site.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 23:42:27 +08:00
lisheng.lisheng 125be085e5 feat: enhance DSH client with new browser bundle and UI components
- Updated build process to include a new script for building the client bundle in ModuleLoader format.
- Introduced `build-client.mjs` to handle client-side bundling with esbuild.
- Added `client.ts` to implement the DSH web UI, including settings and usage pages.
- Registered new settings section for managing credentials and token usage.
- Refactored API routes to remove `/api` prefix for consistency.
- Cleaned up Vite configuration by removing unused client entry.
2026-08-16 22:07:39 +08:00
lisheng.lisheng 186c500ca4 Remove deprecated tools and related configurations
- Deleted the `tool-image`, `tool-managed-agent`, `tool-vision`, and `web-search-rag` modules from the codebase.
- Updated the Vite configuration to remove entries for the deleted tools and added a new entry for `tokenplan-usage`.
- Adjusted tests to reflect the removal of TokenPlan key handling logic from the now-deleted tools.
2026-08-16 21:18:59 +08:00
lisheng.lisheng 9ba4a9d1a3 feat(dsh): add Responses-API route for TokenPlan Qwen models and enhance event text handling 2026-08-16 11:23:57 +08:00
lisheng.lisheng 50ed680ade feat: enhance credential handling for managed-agent and memory APIs
- Updated README.md to clarify API key usage and access restrictions for TokenPlan and pay-as-you-go keys.
- Introduced shared credential validation logic to prevent TokenPlan keys from being used in incompatible contexts.
- Enhanced error messaging for credential resolution failures in managed-agent and memory plugins.
- Added tests for credential classification and workspace endpoint composition.
- Updated documentation to reflect changes in credential handling and workspace-scoped agentstudio endpoint requirements.
2026-08-15 16:50:08 +08:00
lisheng.lisheng f919ebae3c feat(dsh): remote managed-agent as on-demand tool + bl managed-agent run
Rework the managed-agent integration so a dsh user can, in plain
language, have a Bailian cloud agent created and run a task — no
hand-written agents.yaml, no prior apply.

New `bl managed-agent run --prompt <task> [--instructions] [--model]
[--agent]`: one step that idempotently materializes a cloud agent + its
environment, then opens a session and streams the result. It mirrors the
OpenAgentPack webui backend's ensure+run recipe (resolveProjectConfigFrom
Object → syncAgentResourcesWithStateBackend → readProjectRuntime +
startSessionRun) from an in-memory config, reusing the existing
credential spine in _engine/credentials.ts. State persists under the bl
config dir (~/.bailian/managed-agent/<agent>/), never the user's cwd, so
repeat runs with the same --agent reuse the materialized agent. Unlike
apply it provisions without --yes, since running is the intent.

dsh side: replace the SubagentProvider with a plain tool
`bailian_run_remote_task` (packages/dsh/src/tool-managed-agent). The
subagent seam did not fit: in the web profile every tool-subagent row is
disabled in the host plane (delegation lives in agent presets), a
provider fixes one agent identity in config, and the default numeric
maxDepth would fail-mount a no-depthLimit provider. As a tool the model
calls it directly and fills `instructions` from the user's intent, so the
remote agent's role is defined per task. Enabled by default — it creates
nothing at load, only on invocation.

LLM row: configure the base bundle's existing llm-pi-ai row instead of
mounting a second pi-ai instance (a second instance re-declares pi-ai's
global configurable-provider catalog and fails boot on a duplicate
amazon-bedrock). TokenPlan reads a dedicated BAILIAN_TOKENPLAN_API_KEY,
not DASHSCOPE_API_KEY: TokenPlan (sk-sp-) and pay-as-you-go (sk-ws-) keys
401 each other's endpoints, so sharing one var would silently break
whichever plugin lost.

Note: the ensure+run happy path could not be verified end-to-end on the
available account — agentstudio returns 404 there, and the existing
`managed-agent apply` 404s identically against the same endpoint/key, so
the failure is account/service provisioning, not this change. Command
wiring, dry-run, config assembly, credential injection and URL
construction were all verified.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 10:29:24 +08:00
lisheng.lisheng 9e9911aa86 feat(dsh): add bailian-cli-dsh plugin bundle for DeepSeek Harness
Expose Bailian capabilities to dsh through its service seams as one
package with six subpath plugin entries and a `dsh.bundle` patch:

- TokenPlan as an LLM provider
- bailian_vision_describe / bailian_image_generate tools over `bl`
- knowledge-base retrieval as a WebSearchProvider (`bailian-kb`)
- cross-session memory: tools, pre-step recall, turn-close persist
- managed-agent as a SubagentProvider

TokenPlan configures the base bundle's existing pi-ai row rather than
mounting a second `dsh-llm-pi-ai` instance. A second instance cannot
work: pi-ai re-declares its entire built-in provider catalog to
`registerConfigurableProviders`, and that directory is global, so boot
fails with a duplicate on `amazon-bedrock`.

Routes and vision support were probed against the live gateway.
qwen3.8-max, qwen3.7-plus, qwen3.6-flash and glm-5.2 read images;
qwen3.7-max rejects them with HTTP 400; the DeepSeek routes accept image
content without erroring yet stay blind. The DeepSeek entries therefore
do not declare image input — claiming it would turn a clean refusal into
a silently wrong answer — and `bailian_vision_describe` serves them by
returning text instead.

Also fix `bl memory` against the v2 API, each verified live:

- `profile get` used /profiles, which returns HTTP 500. The documented
  and working endpoint is /user_profile.
- `add` read `response.memory_ids`, which the service never returns. It
  returns `memory_nodes`, so text output always printed "IDs: none".
- `MemoryNode.created_at`/`updated_at` are unix seconds, not strings, and
  `UserProfileResponse.profile` did not match the wire shape.
- Add the missing request parameters: --meta-data, --project-id,
  --project-ids, --min-score, --enable-rerank, --plan-version,
  --enable-judge, --enable-rewrite, --timestamp.
- Add `memory profile list|detail|update|delete`, covering the four v2
  profile-schema operations the CLI was missing.

`plan_version: lite` is ignored by the service and still bills pro;
`enable_rerank: false` is what actually selects lite, which is ~50x
cheaper per search. The CLI flag and the memory plugin both send the
parameter that works.

Disable pnpm's autoInstallPeers: the @deepseek-ai/dsh-* rc line peers on
three packages that were never published to npm, which 404s the whole
workspace install. Verified the existing packages still build.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 01:47:18 +08:00
146 changed files with 7595 additions and 6490 deletions
+3
View File
@@ -52,3 +52,6 @@ packages/cli/scene/**/outputs/
# Local scratch / plan drafts (never commit)
.scratch/
# pnpm pack output
*.tgz
+1 -1
View File
@@ -35,7 +35,7 @@ packages/core/src/auth/ # apiKey / console credential 解析与落盘
packages/core/src/client/ # HTTP client / endpoints / console gateway
```
Skill / 命令手册随 `skills/bailian-*/``bl skill init` 安装(装齐 registry 中全部 `bailian-*`,含共享协议 `bailian-protocol`)。业务 skill`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts`**`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills/<skill>/reference/`(纳入 git);`tools/sync-skill-metadata.ts``packages/cli/package.json` 同步各 `skills/*/SKILL.md``metadata.version`。两者由根脚本 `pnpm run sync:skill-assets``.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。
Skill / 命令手册随 `skills/bailian-*/``npx skills add modelstudioai/cli --all -g` 安装(整包装齐,含共享协议 `bailian-protocol`)。业务 skill`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)。`tools/generate-reference.ts`**`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills/<skill>/reference/`(纳入 git);`tools/sync-skill-metadata.ts``packages/cli/package.json` 同步各 `skills/*/SKILL.md``metadata.version`。两者由根脚本 `pnpm run sync:skill-assets``.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。
约定:
-37
View File
@@ -6,43 +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.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
-37
View File
@@ -6,43 +6,6 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [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
### 新增
+31 -34
View File
@@ -1,38 +1,8 @@
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**在已有 Node.js**≥ 18.17.0**)的环境用 npm 安装;若没有可用 Node / npm再走二进制一键安装。不要臆造版本号或路径;以用户环境为准。
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**使用二进制一键安装(无需 Node若环境已有 Node / 需要 Command Pack再用 npm。不要臆造版本号或路径;以用户环境为准。
## 1. 推荐:npm 安装(要求 **≥ 18.17.0**
1. `node -v` 确认版本 ≥ 18.17.0。
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn
3. 执行:
```bash
npm install -g bailian-cli
```
4. 校验:`bl --version`
安装 skillsCLI 内置,无需 Git / npx skills
```bash
bl skill init
```
**Supported** `bl skill init` 一次装齐 registry 中全部 `bailian-*`(含共享协议 `bailian-protocol`)。
**Advanced / 按需子集:**
```bash
bl skill add --name bailian-protocol,bailian-gen
```
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
## 2. 备选:二进制安装(无需 Node
当环境没有 Node / npm或 Node 版本过低无法走 npm 时,使用二进制安装脚本。脚本安装 CLI 成功后会自动执行 `bl skill init`
## 1. 推荐:二进制安装(无需 Node
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
@@ -67,9 +37,36 @@ bl --version
which bl # Windows: where.exe bl
```
若自动 skill 安装失败,再手动执行:`bl skill init`
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装
> CDN / GitHub Release 未就绪或下载失败时,若本机已有合格 Node回退到上方 npm 安装。
## 2. 备选npm 安装(要求 **≥ 18.17.0**
1. `node -v` 确认版本。
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn
3. 执行:
```bash
npm install -g bailian-cli
```
4. 校验:`bl --version`
可选 skills与 CLI 本体无关,按需):
```bash
npx skills add modelstudioai/cli --all -g
```
**Supported** 始终使用 `--all -g`,一次装齐整套 `bailian-*`(含共享协议 `bailian-protocol`。Agent Skills / `npx skills` **不会**按 metadata 自动拉依赖。
**Advanced / 不推荐:** 子集 `-s` 时 skills CLI 不会自动带上 `bailian-protocol`;若坚持子集,必须手动同时指定,例如:
```bash
# Advanced: you MUST include bailian-protocol yourself — installer does not pull it
npx skills add modelstudioai/cli -g -s bailian-protocol -s bailian-gen
```
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
---
+2 -18
View File
@@ -82,31 +82,15 @@ Send the following to your Agent — it will detect your environment, then insta
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
```
**Install with NPM**
**Manual install (npm)**
```bash
npm install -g bailian-cli
bl skill init
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 18.17.
**Install on macOS/Linux**
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> No Node.js required. The installer automatically installs Bailian Skills.
**Install on Windows**
```powershell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
```
> No Node.js required. The installer automatically installs Bailian Skills.
## Quick Start
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
+2 -18
View File
@@ -81,31 +81,15 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
请阅读https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
```
**NPM 安装**
**手动安装npm**
```bash
npm install -g bailian-cli
bl skill init
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 18.17。
**macOS/Linux 安装**
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
**Windows 安装**
```powershell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
## 快速开始
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
+11 -12
View File
@@ -11,17 +11,16 @@
## 统一口径(安装)
1. **Supported install** `bl skill init`(装齐 registry 中全部 `bailian-*`,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它
1. **Supported install** `npx skills add modelstudioai/cli --all -g`(整包装齐,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它Agent Skills / `npx skills` **不会**按 frontmatter 自动拉依赖
3. **不要**在 frontmatter 写 `companions`也不要对外说「companions = 安装器硬依赖」
4. 子集安装`bl skill add --name bailian-protocol,<skill>`;漏装 protocol 会导致相对路径 Read 失败
5. **`bl skill add --all`** 安装 registry 全量(含 `spark-video` 等非 bailian 技能);一键安装 / `bl update``skill init`,不要用 `--all`
4. 子集安装`-s`)为 **advanced / 不推荐**skills CLI 不会自动带上 protocol;漏装会导致相对路径 Read 失败
## 概念图
```text
bailian-protocol ← 共享协议consent / 鉴权 / 版本 / 错误上报)
▲ 靠 `bl skill init` 与业务 skill 同装;非安装器强制 companions
▲ 靠 --all -g 与业务 skill 同装;非安装器强制 companions
┌───────┴────────┬────────────────┬──────────────────┐
bailian-gen bailian-finetune bailian-managed-agent
@@ -38,8 +37,8 @@ bailian-gen bailian-finetune bailian-managed-agent
### A. 分层边界
- [ ] **整包装齐**:安装/升级文案主推 `bl skill init`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init`
- [ ] **整包装齐**:安装/升级文案主推 `--all -g`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `npx skills add modelstudioai/cli --all -g`
- [ ] **软 hand-off**:兄弟业务 skill **只写 skill 名**;已安装则 Read未安装则 `bl … --help` 或提示整包安装;**不要**把 `../bailian-gen/…` 等写成执行前提
- [ ] **Hub vs 领域**`bailian-cli` 的「When to use which command」只列 hub 拥有的意图;媒体 / 精调 / managed-agent 各留 hand-off 行,**不抄**领域默认模型与子命令明细
- [ ] **渐进披露**SKILL 写意图路由与领域硬规则flags / usage / examples 以 `reference/``bl <command> --help` 为准,表后保留「勿猜 flag」指向句
@@ -47,9 +46,9 @@ bailian-gen bailian-finetune bailian-managed-agent
### B. 文案与落款一致性
- [ ] 领域 skillgen / finetune / managed-agent路由或命令表后有指向 `reference/` 的句;文末 `## references`protocol + reference与家族对齐
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `bl skill init`,不写 companions 必装
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `--all -g`,不写 companions 必装
- [ ] Quick examples 只演示本 skill 职责hub 不示范 `bl image` / `bl video` 等)
- [ ] 若改了安装方式:同步 `README.md` / `README.zh.md` / `INSTALL.md` / `skills/*/README*` / `skills/bailian-protocol/assets/setup.md` 中的 `bl skill init` / `bl skill add …` 示例(改 `INSTALL.md` 时按 [install-doc-change.md](install-doc-change.md) 同步静态页)
- [ ] 若改了安装方式:同步 `README.md` / `README.zh.md` / `INSTALL.md` / `skills/*/README*` / `skills/bailian-protocol/assets/setup.md` 中的 `npx skills add …` 示例(改 `INSTALL.md` 时按 [install-doc-change.md](install-doc-change.md) 同步静态页)
### C. 归属与生成
@@ -61,8 +60,8 @@ bailian-gen bailian-finetune bailian-managed-agent
```sh
pnpm run sync:skill-assets
# 已发布版本试装
bl skill init
# 本地试装(测本仓库改动,勿只拉远端)
npx skills add "$(pwd)" --all -g -y
```
抽查:打开 `skills/bailian-cli/SKILL.md` 确认无领域子命令明细表、无 `companions`;打开对应领域 skill 确认有「勿猜 flag」与 hand-off。
@@ -70,7 +69,7 @@ bl skill init
## 常见漏点
- ✗ hub 路由表再次抄回 image / video / finetune / managed-agent 明细 → token 膨胀且与领域 skill 双份漂移
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 `bl skill add` 合同不符
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 Agent Skills / `npx skills` 合同不符
- ✗ 软 hand-off 写成硬路径 `../bailian-*/SKILL.md` 当执行前提 → 子集安装断链
- ✗ 只改 SKILL、忘改 `GROUP_OWNER_SKILL` → reference 落错 skill
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖
+2 -18
View File
@@ -82,31 +82,15 @@ Send the following to your Agent — it will detect your environment, then insta
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
```
**Install with NPM**
**Manual install (npm)**
```bash
npm install -g bailian-cli
bl skill init
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 18.17.
**Install on macOS/Linux**
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> No Node.js required. The installer automatically installs Bailian Skills.
**Install on Windows**
```powershell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
```
> No Node.js required. The installer automatically installs Bailian Skills.
## Quick Start
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
+2 -18
View File
@@ -81,31 +81,15 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
请阅读https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
```
**NPM 安装**
**手动安装npm**
```bash
npm install -g bailian-cli
bl skill init
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 18.17。
**macOS/Linux 安装**
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
**Windows 安装**
```powershell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
```
> 无需预先安装 Node.js安装脚本会自动安装 Bailian Skills。
## 快速开始
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.15.0",
"version": "1.14.2",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+14 -4
View File
@@ -30,6 +30,10 @@ import {
memoryDelete,
memoryProfileCreate,
memoryProfileGet,
memoryProfileList,
memoryProfileDetail,
memoryProfileUpdate,
memoryProfileDelete,
knowledgeRetrieve,
knowledgeSearch,
knowledgeChat,
@@ -45,8 +49,6 @@ import {
usageFreetier,
usageStats,
usageSummary,
usageTokenPlan,
usageCodingPlan,
pipelineRun,
pipelineValidate,
advisorRecommend,
@@ -86,6 +88,8 @@ import {
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
tokenPlanPersonalUsage,
tokenPlanPersonalKey,
workspaceInit,
pluginInstall,
pluginLink,
@@ -100,6 +104,7 @@ import {
managedAgentValidate,
managedAgentPlan,
managedAgentApply,
managedAgentRun,
managedAgentDestroy,
managedAgentStateList,
managedAgentStateShow,
@@ -151,6 +156,10 @@ export const commands: Record<string, AnyCommand> = {
"memory delete": memoryDelete,
"memory profile create": memoryProfileCreate,
"memory profile get": memoryProfileGet,
"memory profile list": memoryProfileList,
"memory profile detail": memoryProfileDetail,
"memory profile update": memoryProfileUpdate,
"memory profile delete": memoryProfileDelete,
"knowledge retrieve": knowledgeRetrieve,
"knowledge search": knowledgeSearch,
"knowledge chat": knowledgeChat,
@@ -166,8 +175,6 @@ 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,
@@ -207,6 +214,8 @@ export const commands: Record<string, AnyCommand> = {
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
"token-plan add-member": tokenPlanAddMember,
"token-plan personal-usage": tokenPlanPersonalUsage,
"token-plan personal-key": tokenPlanPersonalKey,
"workspace init": workspaceInit,
"plugin install": pluginInstall,
"plugin link": pluginLink,
@@ -221,6 +230,7 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent validate": managedAgentValidate,
"managed-agent plan": managedAgentPlan,
"managed-agent apply": managedAgentApply,
"managed-agent run": managedAgentRun,
"managed-agent destroy": managedAgentDestroy,
"managed-agent state list": managedAgentStateList,
"managed-agent state show": managedAgentStateShow,
@@ -7,15 +7,10 @@ const commandPaths = Object.keys(commands).sort();
const groupPaths = deriveGroupPaths(commandPaths);
describe("e2e: bl registry smoke", () => {
test("根帮助展示 bl、逐命令鉴权域与全局 flag", async () => {
test("根帮助展示 bl 与全局 flag", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/\bbl\b/i);
expect(stderr).not.toMatch(/COMMAND\s+AUTH\s+DESCRIPTION/);
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
expect(stderr).toMatch(/token-plan create-key\s+\[AK\/SK\]\s+Create a Token Plan API key/);
expect(stderr).toMatch(/config show\s+\[No Auth\]\s+Display current configuration/);
expect(stderr).toMatch(/--base-url/);
expect(stderr).toMatch(/--console-region/);
expect(stderr).toMatch(/--console-site/);
@@ -23,24 +18,6 @@ describe("e2e: bl registry smoke", () => {
expect(stderr).not.toMatch(/^\s*--region\s/m);
});
test("分组帮助按叶子命令展示不同鉴权域", async () => {
const { stderr, exitCode } = await runCli(["app", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/app call\s+\[API Key\]\s+Call a Bailian application/);
expect(stderr).toMatch(/app list\s+\[Console\]\s+List Bailian applications/);
});
test.each([
[["text", "chat"], "API Key"],
[["app", "list"], "Console"],
[["token-plan", "list-seats"], "AK/SK"],
[["config", "show"], "No Auth"],
] as const)("%s --help 明确展示鉴权域 %s", async (commandPath, authLabel) => {
const { stderr, exitCode } = await runCli([...commandPath, "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain(`Authentication: ${authLabel}`);
});
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
expect(exitCode, stderr).toBe(0);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.15.0",
"version": "1.14.2",
"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 }> {
@@ -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) {
@@ -50,6 +50,7 @@ export interface CredentialHost {
*/
export const CREDENTIALS_NOTE = [
"Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).",
"The agentstudio endpoint is workspace-scoped: the base URL is composed from the workspace id (agents.yaml workspace_id > $BAILIAN_WORKSPACE_ID > bl's configured workspace_id) as https://{workspace}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio, and the key must belong to that workspace.",
"Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.",
"Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.",
];
@@ -85,13 +86,19 @@ export function prepareProviderEnv(): void {
* the block references them and the interpolated value is empty (a literal in
* agents.yaml is respected).
*
* `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource
* paths onto it verbatim; a value already ending in the suffix is left as-is.
* It is filled even without a credential — `client.baseUrl` is readable
* credential-less (defaults to the CLI's model-domain base URL) — so offline
* commands (which skip the credential assert) still satisfy the SDK's
* "workspace_id or base_url" schema. With no credential the `api_key` is left
* untouched: online commands reject it via {@link assertProviderCredentials}.
* `base_url` is composed from the workspace when one is known — block
* `workspace_id` (agents.yaml literal or interpolated `${BAILIAN_WORKSPACE_ID}`)
* first, then bl's configured `workspace_id` — because agentstudio is served
* only on the workspace-scoped host; the bare model-domain origin 404s it
* (managed-agents API overview: `https://{workspace_id}.cn-beijing.maas.
* aliyuncs.com/api/v1/agentstudio`, region cn-beijing only). Only with no
* workspace at all does the model-domain origin get {@link AGENTSTUDIO_API_PATH}
* suffixed. A value already ending in the suffix is left as-is. base_url is
* filled even without a credential — `client.baseUrl` is readable
* credential-less — so offline commands (which skip the credential assert)
* still satisfy the SDK's "workspace_id or base_url" schema. With no
* credential the `api_key` is left untouched: online commands reject it via
* {@link assertProviderCredentials}.
*/
export function injectProviderCredentials(
providers: Record<string, unknown>,
@@ -103,16 +110,27 @@ export function injectProviderCredentials(
const cred = host.client.exportApiCredential();
if (cred) block.api_key = cred.token;
if ("base_url" in block && !block.base_url) {
// Defensive normalization: the auth chain already normalizes base_url to
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
const origin = host.client.baseUrl.replace(/\/+$/, "");
block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH)
? origin
: `${origin}${AGENTSTUDIO_API_PATH}`;
if ("workspace_id" in block && !block.workspace_id) {
// agents.yaml interpolation already replaced `${BAILIAN_WORKSPACE_ID}` in
// file-based flows; the inline runtime passes an object config that never
// interpolates, so read the env var here too (prepareProviderEnv
// placeholders it to "" when unset). bl's configured workspace_id is the
// last resort.
block.workspace_id =
process.env.BAILIAN_WORKSPACE_ID?.trim() || host.settings.workspaceId || "";
}
if ("workspace_id" in block && !block.workspace_id && host.settings.workspaceId) {
block.workspace_id = host.settings.workspaceId;
if ("base_url" in block && !block.base_url) {
const workspaceId = typeof block.workspace_id === "string" ? block.workspace_id.trim() : "";
if (workspaceId) {
block.base_url = `https://${workspaceId}.cn-beijing.maas.aliyuncs.com${AGENTSTUDIO_API_PATH}`;
} else {
// Defensive normalization: the auth chain already normalizes base_url to
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
const origin = host.client.baseUrl.replace(/\/+$/, "");
block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH)
? origin
: `${origin}${AGENTSTUDIO_API_PATH}`;
}
}
}
@@ -0,0 +1,124 @@
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import {
type BackendRuntimeInput,
LocalFileStateBackend,
resolveProjectConfigFromObject,
} from "@openagentpack/sdk";
import { getConfigDir } from "bailian-cli-core";
import {
assertProviderCredentials,
type CredentialHost,
injectProviderCredentials,
normalizeInterpolatedProviderBlocks,
prepareProviderEnv,
scrubCredentialEnv,
} from "./credentials.ts";
import { type HostContext, installSdkTransport } from "./transport.ts";
/** Default agent identity `bl managed-agent run` materializes and reuses. */
export const DEFAULT_INLINE_AGENT = "dsh-remote-runner";
/** Default model for the materialized agent. */
export const DEFAULT_INLINE_MODEL = "qwen3.8-max";
/** Default role when the caller supplies no `--instructions`. */
export const DEFAULT_INLINE_INSTRUCTIONS = "You are a helpful assistant. Complete the task.";
/** Environment name declared in the inline config; one cloud env per agent. */
const INLINE_ENVIRONMENT = "cloud";
export interface InlineAgentOptions {
agentName: string;
instructions: string;
model: string;
/** Override the persisted state location (defaults under the bl config dir). */
statePath?: string;
}
/**
* Slugify an agent name into a filesystem- and project-id-safe token. The state
* for each distinct agent lives in its own directory so repeat runs reuse the
* same materialized remote agent.
*/
function slugify(agentName: string): string {
const slug = agentName
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : "agent";
}
/** Where a materialized agent's state is persisted (not the user's cwd). */
export function inlineStatePath(agentName: string): string {
return join(getConfigDir(), "managed-agent", slugify(agentName), "state.json");
}
/**
* The minimal in-memory project config that materializes into one cloud agent.
* `providers.bailian` carries empty `api_key`/`base_url`/`workspace_id`
* placeholders so {@link injectProviderCredentials} fills them from bl's auth
* chain and workspace sources (it only writes fields the block already
* declares). `workspace_id` lets injection compose the workspace-scoped
* agentstudio host instead of the model-domain origin.
*/
export function buildInlineConfig(opts: InlineAgentOptions): Record<string, unknown> {
return {
version: "1",
providers: {
bailian: { api_key: "", base_url: "", workspace_id: "" },
},
defaults: { provider: "bailian" },
environments: {
[INLINE_ENVIRONMENT]: {
description: "Bailian CLI cloud environment",
config: { type: "cloud", networking: { type: "unrestricted" } },
},
},
agents: {
[opts.agentName]: {
description: opts.agentName,
model: opts.model,
instructions: opts.instructions,
environment: INLINE_ENVIRONMENT,
provider: "bailian",
},
},
};
}
/**
* Build the `BackendRuntimeInput` shared by ensure (`syncAgentResourcesWith
* StateBackend`) and run (`readProjectRuntime` + `startSessionRun`). Mirrors the
* credential spine of {@link buildAgentRuntime} but sources config from an
* in-memory object instead of a file, so no `agents.yaml` or `apply` is required.
*/
export async function buildInlineBackendInput(
host: HostContext & CredentialHost,
opts: InlineAgentOptions,
): Promise<BackendRuntimeInput> {
installSdkTransport(host);
prepareProviderEnv();
const rawConfig = buildInlineConfig(opts);
const { config, projectName } = await resolveProjectConfigFromObject(rawConfig, {
projectName: slugify(opts.agentName),
});
normalizeInterpolatedProviderBlocks(config.providers);
injectProviderCredentials(config.providers, host);
scrubCredentialEnv();
assertProviderCredentials(config.providers);
const statePath = opts.statePath ?? inlineStatePath(opts.agentName);
mkdirSync(dirname(statePath), { recursive: true });
const stateBackend = new LocalFileStateBackend({ statePath });
return {
projectName,
config,
stateBackend,
stateScope: { projectId: slugify(opts.agentName) },
providers: config.providers,
};
}
@@ -0,0 +1,137 @@
import {
BailianError,
defineCommand,
detectOutputFormat,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
readProjectRuntime,
startSessionRun,
startSessionRunPolling,
syncAgentResourcesWithStateBackend,
} from "@openagentpack/sdk";
import { CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
import { withStdoutProtected } from "./_engine/console-capture.ts";
import { withAgentErrors } from "./_engine/errors.ts";
import {
buildInlineBackendInput,
DEFAULT_INLINE_AGENT,
DEFAULT_INLINE_INSTRUCTIONS,
DEFAULT_INLINE_MODEL,
} from "./_engine/inline-runtime.ts";
import { renderCollectedEvents, streamAndRenderEvents } from "./_engine/session-render.ts";
const RUN_FLAGS = {
prompt: {
type: "string",
valueHint: "<text>",
description: "Task to run (required)",
required: true,
},
instructions: {
type: "string",
valueHint: "<text>",
description: "Role/system instructions for the remote agent (default: generic assistant)",
},
model: {
type: "string",
valueHint: "<id>",
description: `Model for the remote agent (default: ${DEFAULT_INLINE_MODEL})`,
},
agent: {
type: "string",
valueHint: "<name>",
description: `Agent identity to create/reuse (default: ${DEFAULT_INLINE_AGENT})`,
},
noStream: {
type: "switch",
description: "Use polling instead of SSE streaming",
},
} satisfies FlagsDef;
export default defineCommand({
description: "Provision (if needed) a cloud agent and run a task in one step",
auth: "apiKey",
usageArgs: "--prompt <text> [--instructions <text>] [--model <id>] [--agent <name>]",
flags: RUN_FLAGS,
exampleArgs: [
'--prompt "Summarize the latest AI news"',
'--prompt "Audit this dependency tree" --instructions "You are a security expert" --model qwen3.8-max',
],
notes: [
...CREDENTIALS_NOTE,
"Unlike `apply`, this creates/updates the cloud agent + environment on demand without --yes. The first run provisions cloud resources (may incur cost and take longer to start); later runs with the same --agent reuse them.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const asJson = format === "json";
const agentName = flags.agent ?? DEFAULT_INLINE_AGENT;
const model = flags.model ?? DEFAULT_INLINE_MODEL;
const instructions = flags.instructions ?? DEFAULT_INLINE_INSTRUCTIONS;
if (settings.dryRun) {
emitResult(
{
would_run: {
prompt: flags.prompt,
agent: agentName,
model,
instructions,
mode: flags.noStream ? "polling" : "streaming",
},
},
format,
);
return;
}
await withAgentErrors(() =>
withStdoutProtected(async () => {
const input = await buildInlineBackendInput(ctx, { agentName, instructions, model });
// Ensure the remote agent + its cloud environment exist. Idempotent:
// a repeat run with the same agent name reuses the materialized state.
if (!asJson) process.stderr.write(`Ensuring cloud agent "${agentName}"…\n`);
const sync = await syncAgentResourcesWithStateBackend(input, agentName, {
policy: "force",
quiet: true,
});
if (sync.status !== "completed") {
const detail =
sync.error ??
sync.diagnostics.find((diag) => diag.severity === "error")?.message ??
`provisioning ended with status "${sync.status}"`;
throw new BailianError(
`Failed to provision cloud agent "${agentName}": ${detail}`,
ExitCode.GENERAL,
);
}
// Run the task inside a runtime bound to the just-materialized state.
await readProjectRuntime(input, async (runtime) => {
if (flags.noStream) {
const run = await startSessionRunPolling(runtime, flags.prompt, { agent: agentName });
if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`);
renderCollectedEvents(run, asJson, {
session_id: run.session.id,
provider: run.provider,
agent: run.agentName,
});
} else {
const run = await startSessionRun(runtime, flags.prompt, { agent: agentName });
if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`);
await streamAndRenderEvents(run.events, asJson, {
session_id: run.session.id,
provider: run.provider,
agent: run.agentName,
});
}
});
}),
);
},
});
@@ -1,11 +1,11 @@
import { BailianError, isStreamableHttpUnsupported } from "bailian-cli-core";
import { BailianError } from "bailian-cli-core";
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
/** Detect MCP-not-activated / invalid 404 errors (CLI-wrapped server message). */
export function isMcpNotActivated(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
const message = error.message;
if (!/^MCP request failed:\s*404\b/i.test(message)) return false;
if (!/MCP request failed:\s*404\b/i.test(message)) return false;
return /未开通|MCP不存在|MCP_IS_INVALID/i.test(message);
}
@@ -26,28 +26,14 @@ export function mcpActivateHint(serverCode: string): string {
/**
* For not-activated errors, keep the original message / exitCode and append a hint only.
* Do not replace the server error message.
* WebSearch + 405 streamableHttp: do not fall back; attach a re-activate / upgrade hint.
*/
export function rethrowWithMcpActivateHint(error: unknown, serverCode: string): never {
if (!(error instanceof BailianError) || error.hint) {
throw error;
}
if (isMcpNotActivated(error)) {
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
if (serverCode === "WebSearch" && isStreamableHttpUnsupported(error)) {
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
throw error;
}
+7 -11
View File
@@ -36,8 +36,7 @@ const CALL_FLAGS = {
url: {
type: "string",
valueHint: "<url>",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
},
} satisfies FlagsDef;
type CallFlags = ParsedFlags<typeof CALL_FLAGS>;
@@ -115,14 +114,14 @@ export default defineCommand({
const { serverCode, toolName } = parseTarget(flags.target);
const toolArgs = buildToolArgs(flags);
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const url = flags.url || ctx.client.url(bailianMcpPath(serverCode));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
server: serverCode,
url: previewUrl,
url,
tool: toolName,
arguments: toolArgs,
},
@@ -131,14 +130,13 @@ export default defineCommand({
return;
}
let client: { close?(): void } | undefined;
const client = ctx.client.mcp(url);
try {
const connected = await ctx.client.connectBailianMcp(serverCode, flags.url);
client = connected.client;
const result = await connected.client.callTool(toolName, toolArgs);
await client.initialize();
const result = await client.callTool(toolName, toolArgs);
if (result.isError) {
const errText = result.content.map((contentItem) => contentItem.text || "").join("\n");
const errText = result.content.map((c) => c.text || "").join("\n");
throw new BailianError(`Tool error: ${errText}`);
}
@@ -148,8 +146,6 @@ export default defineCommand({
rethrowWithMcpActivateHint(error, serverCode);
}
throw error;
} finally {
client?.close?.();
}
},
});
+7 -11
View File
@@ -16,8 +16,7 @@ export default defineCommand({
url: {
type: "string",
valueHint: "<url>",
description:
"Override the MCP endpoint URL (non-Bailian). Tries Streamable HTTP first, then classic SSE on the same URL.",
description: "Override the MCP endpoint URL (for non-Bailian servers)",
},
},
exampleArgs: [
@@ -29,27 +28,24 @@ export default defineCommand({
const { settings, flags } = ctx;
const code = flags.server;
const previewUrl = flags.url || ctx.client.url(bailianMcpPath(code));
const url = flags.url || ctx.client.url(bailianMcpPath(code));
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ server: code, url: previewUrl, action: "tools/list" }, format);
emitResult({ server: code, url, action: "tools/list" }, format);
return;
}
let client: { close?(): void } | undefined;
const client = ctx.client.mcp(url);
try {
const connected = await ctx.client.connectBailianMcp(code, flags.url);
client = connected.client;
const tools = await connected.client.listTools();
emitResult({ server: code, url: connected.url, tools }, format);
await client.initialize();
const tools = await client.listTools();
emitResult({ server: code, url, tools }, format);
} catch (error) {
if (!flags.url) {
rethrowWithMcpActivateHint(error, code);
}
throw error;
} finally {
client?.close?.();
}
},
});
+28 -2
View File
@@ -28,6 +28,16 @@ const ADD_FLAGS = {
valueHint: "<id>",
description: "Memory library ID (isolate memory space)",
},
projectId: {
type: "string",
valueHint: "<id>",
description: "Memory extraction rule ID (defaults to the library's default rule)",
},
metaData: {
type: "string",
valueHint: "<json>",
description: 'Custom metadata JSON object: {"location":"Beijing"}',
},
} satisfies FlagsDef;
type AddFlags = ParsedFlags<typeof ADD_FLAGS>;
@@ -40,6 +50,7 @@ export default defineCommand({
'--user-id user1 --content "The user likes Python programming"',
'--user-id user1 --messages \'[{"role":"user","content":"I like traveling"}]\'',
'--user-id user1 --content "Lives in Beijing" --profile-schema schema_xxx',
'--user-id user1 --content "Lives in Beijing" --meta-data \'{"source":"onboarding"}\'',
],
validate: (f: AddFlags) =>
!f.messages && !f.content ? "Provide --messages or --content." : undefined,
@@ -63,6 +74,15 @@ export default defineCommand({
if (flags.profileSchema) body.profile_schema = flags.profileSchema;
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
if (flags.projectId) body.project_id = flags.projectId;
if (flags.metaData) {
try {
body.meta_data = JSON.parse(flags.metaData);
} catch {
throw new UsageError("--meta-data must be valid JSON object");
}
}
const format = detectOutputFormat(settings.output);
@@ -78,8 +98,14 @@ export default defineCommand({
});
if (settings.quiet || format === "text") {
const ids = response.memory_ids?.join(", ") || "none";
emitBare(`Memory added. IDs: ${ids}`);
const nodes = response.memory_nodes ?? [];
if (nodes.length === 0) {
emitBare("No memory fragments were extracted.");
} else {
for (const node of nodes) {
emitBare(`[${node.event ?? "ADD"}] ${node.memory_node_id} ${node.content}`);
}
}
} else {
emitResult(response, format);
}
@@ -24,6 +24,11 @@ export default defineCommand({
},
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
memoryLibraryId: { type: "string", valueHint: "<id>", description: "Memory library ID" },
projectId: {
type: "string",
valueHint: "<id>",
description: "Memory extraction rule ID (defaults to the library's default rule)",
},
},
exampleArgs: ["--user-id user1", "--user-id user1 --page-size 20 --page 2"],
async run(ctx) {
@@ -36,6 +41,7 @@ export default defineCommand({
if (flags.pageSize !== undefined) params.set("page_size", String(flags.pageSize));
if (flags.page !== undefined) params.set("page_num", String(flags.page));
if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId);
if (flags.projectId) params.set("project_id", flags.projectId);
const path = `${memoryListPath()}?${params.toString()}`;
@@ -0,0 +1,44 @@
import { defineCommand, profileSchemaItemPath, detectOutputFormat } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Delete a profile schema",
auth: "apiKey",
usageArgs: "--schema-id <id> [flags]",
flags: {
schemaId: {
type: "string",
valueHint: "<id>",
description: "Profile schema ID (required)",
required: true,
},
memoryLibraryId: { type: "string", valueHint: "<id>", description: "Memory library ID" },
},
exampleArgs: ["--schema-id schema_xxx"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const params = new URLSearchParams();
if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId);
const query = params.toString();
const base = profileSchemaItemPath(flags.schemaId);
const path = query ? `${base}?${query}` : base;
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(path), method: "DELETE" }, format);
return;
}
const response = await ctx.client.requestJson<{ request_id: string }>({
path,
method: "DELETE",
});
if (settings.quiet || format === "text") {
emitBare(`Profile schema ${flags.schemaId} deleted.`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,52 @@
import {
defineCommand,
profileSchemaItemPath,
detectOutputFormat,
type ProfileSchemaGetResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Show a profile schema and its attribute IDs",
auth: "apiKey",
usageArgs: "--schema-id <id> [flags]",
flags: {
schemaId: {
type: "string",
valueHint: "<id>",
description: "Profile schema ID (required)",
required: true,
},
memoryLibraryId: { type: "string", valueHint: "<id>", description: "Memory library ID" },
},
exampleArgs: ["--schema-id schema_xxx"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const params = new URLSearchParams();
if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId);
const query = params.toString();
const base = profileSchemaItemPath(flags.schemaId);
const path = query ? `${base}?${query}` : base;
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(path), method: "GET" }, format);
return;
}
const response = await ctx.client.requestJson<ProfileSchemaGetResponse>({
path,
method: "GET",
});
if (settings.quiet || format === "text") {
emitBare(`${response.name}${response.description ? `${response.description}` : ""}`);
for (const attribute of response.attributes ?? []) {
emitBare(` [${attribute.attribute_id}] ${attribute.name}`);
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,55 @@
import {
defineCommand,
profileSchemaPath,
detectOutputFormat,
type ProfileSchemaListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "List profile schemas",
auth: "apiKey",
usageArgs: "[flags]",
flags: {
memoryLibraryId: { type: "string", valueHint: "<id>", description: "Memory library ID" },
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 10)" },
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
},
exampleArgs: ["", "--page-size 20 --page 2"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const params = new URLSearchParams();
if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId);
if (flags.pageSize !== undefined) params.set("page_size", String(flags.pageSize));
if (flags.page !== undefined) params.set("page_num", String(flags.page));
const query = params.toString();
const path = query ? `${profileSchemaPath()}?${query}` : profileSchemaPath();
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(path), method: "GET" }, format);
return;
}
const response = await ctx.client.requestJson<ProfileSchemaListResponse>({
path,
method: "GET",
});
if (settings.quiet || format === "text") {
const schemas = response.profile_schemas ?? [];
if (schemas.length === 0) {
emitBare("No profile schemas found.");
} else {
for (const schema of schemas) {
emitBare(`[${schema.profile_schema_id}] ${schema.name}`);
}
if (response.total !== undefined) emitBare(`\nTotal: ${response.total}`);
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,81 @@
import {
defineCommand,
UsageError,
profileSchemaItemPath,
detectOutputFormat,
type ProfileSchemaUpdateRequest,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import type { FlagsDef, ParsedFlags } from "bailian-cli-core";
const UPDATE_FLAGS = {
schemaId: {
type: "string",
valueHint: "<id>",
description: "Profile schema ID (required)",
required: true,
},
name: { type: "string", valueHint: "<name>", description: "New schema name" },
description: { type: "string", valueHint: "<text>", description: "New schema description" },
attributeOps: {
type: "string",
valueHint: "<json>",
description:
'Attribute operations JSON array: [{"op":"add","name":"plan"},{"op":"delete","attribute_id":"attr_1"}]',
},
memoryLibraryId: { type: "string", valueHint: "<id>", description: "Memory library ID" },
} satisfies FlagsDef;
type UpdateFlags = ParsedFlags<typeof UPDATE_FLAGS>;
export default defineCommand({
description: "Update a profile schema's name, description, or attributes",
auth: "apiKey",
usageArgs: "--schema-id <id> [--name <name>] [--attribute-ops <json>] [flags]",
flags: UPDATE_FLAGS,
notes: ["Attribute IDs for update/delete operations come from `memory profile detail`."],
exampleArgs: [
'--schema-id schema_xxx --name "user_basic_v2"',
'--schema-id schema_xxx --attribute-ops \'[{"op":"add","name":"plan","description":"subscription plan"}]\'',
'--schema-id schema_xxx --attribute-ops \'[{"op":"delete","attribute_id":"attr_1"}]\'',
],
validate: (f: UpdateFlags) =>
!f.name && !f.description && !f.attributeOps
? "Provide --name, --description, or --attribute-ops."
: undefined,
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const body: ProfileSchemaUpdateRequest = {};
if (flags.name) body.name = flags.name;
if (flags.description) body.description = flags.description;
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
if (flags.attributeOps) {
try {
body.attributes_operations = JSON.parse(flags.attributeOps);
} catch {
throw new UsageError("--attribute-ops must be valid JSON array");
}
}
const path = profileSchemaItemPath(flags.schemaId);
if (settings.dryRun) {
emitResult({ endpoint: ctx.client.url(path), method: "PATCH", request: body }, format);
return;
}
const response = await ctx.client.requestJson<{ request_id: string }>({
path,
method: "PATCH",
body,
});
if (settings.quiet || format === "text") {
emitBare(`Profile schema ${flags.schemaId} updated.`);
} else {
emitResult(response, format);
}
},
});
@@ -24,6 +24,38 @@ const SEARCH_FLAGS = {
description: "Number of results to return (default: 10)",
},
memoryLibraryId: { type: "string", valueHint: "<id>", description: "Memory library ID" },
projectIds: {
type: "array",
valueHint: "<id>",
description: "Memory extraction rule ID for hybrid retrieval (repeatable)",
},
minScore: {
type: "number",
valueHint: "<n>",
description: "Minimum similarity score, 0-1 (default: 0.3)",
},
enableRerank: {
type: "boolean",
valueHint: "<bool>",
description:
"Rerank results. Also selects the billing tier: false bills lite, true bills pro (~50x). (default: true)",
},
planVersion: {
type: "string",
valueHint: "<lite|pro>",
description:
"Documented billing tier. The service currently honors --enable-rerank instead, so prefer that flag",
},
enableJudge: {
type: "boolean",
valueHint: "<bool>",
description: "Enable the intent-discrimination callback (default: false)",
},
enableRewrite: {
type: "boolean",
valueHint: "<bool>",
description: "Enable query rewriting (default: false)",
},
} satisfies FlagsDef;
type SearchFlags = ParsedFlags<typeof SEARCH_FLAGS>;
@@ -35,6 +67,7 @@ export default defineCommand({
exampleArgs: [
'--user-id user1 --query "programming preferences"',
'--user-id user1 --messages \'[{"role":"user","content":"recommend a book"}]\' --top-k 5',
'--user-id user1 --query "preferences" --enable-rerank false --min-score 0.5',
],
validate: (f: SearchFlags) =>
!f.query && !f.messages ? "Provide --query or --messages." : undefined,
@@ -61,6 +94,21 @@ export default defineCommand({
if (flags.topK !== undefined) body.top_k = flags.topK;
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
if (flags.projectIds && flags.projectIds.length > 0) body.project_ids = flags.projectIds;
if (flags.minScore !== undefined) body.min_score = flags.minScore;
if (flags.enableRerank !== undefined) body.enable_rerank = flags.enableRerank;
if (flags.enableJudge !== undefined) body.enable_judge = flags.enableJudge;
if (flags.enableRewrite !== undefined) body.enable_rewrite = flags.enableRewrite;
if (flags.planVersion) {
if (flags.planVersion !== "lite" && flags.planVersion !== "pro") {
throw new UsageError("--plan-version must be lite or pro");
}
body.plan_version = flags.planVersion;
// The service ignores plan_version on its own, so mirror the intent onto
// the flag it does honor unless the caller set that one explicitly.
if (flags.enableRerank === undefined) body.enable_rerank = flags.planVersion === "pro";
}
const format = detectOutputFormat(settings.output);
@@ -1,5 +1,6 @@
import {
defineCommand,
UsageError,
memoryNodePath,
detectOutputFormat,
type MemoryNodeUpdateRequest,
@@ -34,6 +35,16 @@ export default defineCommand({
valueHint: "<id>",
description: "Memory library ID (non-default library)",
},
timestamp: {
type: "number",
valueHint: "<unix-seconds>",
description: "When the remembered event happened (default: now)",
},
metaData: {
type: "string",
valueHint: "<json>",
description: 'Custom metadata JSON object, merged incrementally: {"source":"manual"}',
},
},
exampleArgs: ['--node-id node_xxx --user-id user1 --content "updated memory content"'],
async run(ctx) {
@@ -47,6 +58,15 @@ export default defineCommand({
custom_content: content,
};
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId;
if (flags.timestamp !== undefined) body.timestamp = flags.timestamp;
if (flags.metaData) {
try {
body.meta_data = JSON.parse(flags.metaData);
} catch {
throw new UsageError("--meta-data must be valid JSON object");
}
}
const format = detectOutputFormat(settings.output);
@@ -12,13 +12,6 @@ import {
stripUndefined,
taskPath,
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
type AsrApiRoute,
type AsrFlashFamily,
type OutputFormat,
type FlagsDef,
type ParsedFlags,
@@ -34,18 +27,8 @@ const RECOGNIZE_FLAGS = {
description: "Audio file URL or local file path (repeatable, max 100)",
required: true,
},
model: {
type: "string",
valueHint: "<model>",
description:
"Model ID (default: fun-asr). Async: fun-asr / *-filetrans / paraformer-*; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash",
},
language: {
type: "string",
valueHint: "<lang>",
description:
"Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language",
},
model: { type: "string", valueHint: "<model>", description: "Model ID (default: fun-asr)" },
language: { type: "string", valueHint: "<lang>", description: "Language hint (e.g. zh, en, ja)" },
diarization: { type: "switch", description: "Enable automatic speaker diarization" },
speakerCount: {
type: "number",
@@ -72,33 +55,8 @@ const RECOGNIZE_FLAGS = {
} satisfies FlagsDef;
type RecognizeFlags = ParsedFlags<typeof RECOGNIZE_FLAGS>;
function assertSyncFlashFlagsAllowed(
flags: RecognizeFlags,
model: string,
flashFamily: AsrFlashFamily,
): void {
const unsupported: string[] = [];
if (flags.diarization === true) unsupported.push("--diarization");
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
// qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does
if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) {
unsupported.push("--vocabulary-id");
}
if (flags.channelId !== undefined) unsupported.push("--channel-id");
if (flags.async === true) unsupported.push("--async");
if (flags.pollInterval !== undefined) unsupported.push("--poll-interval");
if (unsupported.length > 0) {
throw new BailianError(
`Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` +
`Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`,
ExitCode.USAGE,
);
}
}
export default defineCommand({
description: "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
description: "Recognize speech from audio files (FunAudio-ASR)",
auth: "apiKey",
usageArgs: "--url <audio-url> [flags]",
flags: RECOGNIZE_FLAGS,
@@ -110,7 +68,6 @@ export default defineCommand({
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
"--url https://example.com/audio.mp3 --out result.json",
"--url https://example.com/audio.mp3 --async --quiet",
"--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en",
],
async run(ctx) {
const { settings, flags } = ctx;
@@ -133,70 +90,22 @@ export default defineCommand({
}
const model = flags.model || "fun-asr";
const route = resolveAsrApi(model);
if (route.kind === "unsupported") {
throw new BailianError(
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
ExitCode.USAGE,
);
}
if (route.kind === "sync-flash") {
assertSyncFlashFlagsAllowed(flags, model, route.flashFamily!);
if (rawUrls.length !== 1) {
throw new BailianError(
`Model "${model}" is a sync Flash ASR model and accepts exactly one --url (got ${rawUrls.length}).\n` +
`Hint: Pass a single audio URL, or use an async filetrans model for batch files.`,
ExitCode.USAGE,
);
}
}
if (
route.kind === "async-filetrans" &&
route.asyncInputStyle === "file_url" &&
rawUrls.length !== 1
) {
throw new BailianError(
`Model "${model}" accepts exactly one --url (got ${rawUrls.length}).\n` +
"Hint: qwen3-asr-flash-filetrans* requires a single file_url.",
ExitCode.USAGE,
);
}
const format = detectOutputFormat(settings.output);
// Auto-upload local files in parallel
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
if (route.kind === "sync-flash") {
await handleSyncFlashMode(
ctx.client,
settings,
flags,
format,
model,
route,
resolvedUrls[0]!,
);
return;
}
const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model)));
const channelId = flags.channelId;
const language = flags.language;
const vocabularyId = flags.vocabularyId;
const languageFields = buildAsyncAsrLanguageFields(
route.asyncLanguageStyle ?? "language_hints",
flags.language,
);
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url"
? { file_url: resolvedUrls[0]! }
: { file_urls: resolvedUrls },
input: {
file_urls: resolvedUrls,
},
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
...languageFields,
language_hints: language ? [language] : undefined,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
@@ -207,7 +116,7 @@ export default defineCommand({
stripUndefined(body.parameters as Record<string, unknown>);
if (settings.dryRun) {
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
emitResult({ request: body, mode: "async" }, format);
return;
}
@@ -219,55 +128,6 @@ export default defineCommand({
},
});
async function handleSyncFlashMode(
client: Client,
settings: Settings,
flags: RecognizeFlags,
format: OutputFormat,
model: string,
route: AsrApiRoute,
audioUrl: string,
): Promise<void> {
const flashFamily = route.flashFamily as AsrFlashFamily;
const body = buildAsrFlashRequest({
model,
audioUrl,
language: flags.language,
vocabularyId: flags.vocabularyId,
flashFamily,
});
if (settings.dryRun) {
emitResult({ request: body, mode: "sync", path: route.path }, format);
return;
}
if (!settings.quiet) {
process.stderr.write(`[Model: ${model}] [Mode: sync] [Files: 1]\n`);
}
const response = await client.requestJson<Record<string, unknown>>({
path: route.path,
method: "POST",
headers: { "X-DashScope-SSE": "disable" },
body,
});
const text = extractAsrFlashText(response, flashFamily);
if (text) {
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
} else {
emitBare(JSON.stringify(response));
}
if (flags.out) {
writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n");
if (!settings.quiet) {
process.stderr.write(`Full result saved to: ${flags.out}\n`);
}
}
}
async function handleAsyncMode(
client: Client,
settings: Settings,
@@ -300,16 +160,16 @@ async function handleAsyncMode(
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: settings.timeout,
isComplete: (data) => (data as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
isFailed: (data) => (data as DashScopeASRTaskResult).output.task_status === "FAILED",
getStatus: (data) => (data as DashScopeASRTaskResult).output.task_status,
getErrorMessage: (data) => {
const output = (data as DashScopeASRTaskResult).output;
return (output as unknown as Record<string, unknown>).message as string | undefined;
isComplete: (d) => (d as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
isFailed: (d) => (d as DashScopeASRTaskResult).output.task_status === "FAILED",
getStatus: (d) => (d as DashScopeASRTaskResult).output.task_status,
getErrorMessage: (d) => {
const o = (d as DashScopeASRTaskResult).output;
return (o as unknown as Record<string, unknown>).message as string | undefined;
},
});
const results = collectAsrTranscriptionItems(result.output);
const results = result.output.results ?? [];
if (results.length === 0) {
emitResult({ task_id: taskId, status: result.output.task_status }, format);
@@ -319,14 +179,12 @@ async function handleAsyncMode(
// Collect all transcription data for --out
const allTransData: Record<string, unknown>[] = [];
for (let index = 0; index < results.length; index++) {
const subResult = results[index]!;
for (let i = 0; i < results.length; i++) {
const subResult = results[i]!;
const isMulti = fileCount > 1;
if (isMulti) {
process.stdout.write(
`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`,
);
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
}
if (subResult.subtask_status === "FAILED") {
+26 -96
View File
@@ -1,35 +1,20 @@
import {
defineCommand,
chatPath,
responsesPath,
parseSSE,
detectOutputFormat,
readTextFromPathOrStdin,
type ChatMessage,
type ChatRequest,
type ChatResponse,
type ResponsesRequest,
type ResponsesResponse,
type ResponsesStreamEvent,
type StreamChunk,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
import { readFileSync } from "fs";
import {
assertResponsesStreamCompleted,
inspectResponsesStreamEvent,
extractResponsesText,
} from "./responses.ts";
const CHAT_FLAGS = {
api: {
type: "string",
valueHint: "<chat|responses>",
choices: ["chat", "responses"] as const,
description: "API to call (default: chat)",
},
model: { type: "string", valueHint: "<model>", description: "Model ID (default: qwen3.8-max)" },
message: {
type: "array",
@@ -87,31 +72,31 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
if (flags.messagesFile) {
const raw = readTextFromPathOrStdin(flags.messagesFile);
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
for (const parsedMessage of parsed) {
if (parsedMessage.role === "system") {
system = typeof parsedMessage.content === "string" ? parsedMessage.content : "";
for (const m of parsed) {
if (m.role === "system") {
system = typeof m.content === "string" ? m.content : "";
} else {
messages.push(parsedMessage as ChatMessage);
messages.push(m as ChatMessage);
}
}
}
if (flags.message) {
const validRoles = new Set(["system", "user", "assistant"]);
const messageValues = flags.message;
for (const messageValue of messageValues) {
const colonIndex = messageValue.indexOf(":");
const maybeRole = colonIndex !== -1 ? messageValue.slice(0, colonIndex) : "";
const msgs = flags.message;
for (const m of msgs) {
const colonIdx = m.indexOf(":");
const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : "";
if (validRoles.has(maybeRole)) {
const content = messageValue.slice(colonIndex + 1);
const content = m.slice(colonIdx + 1);
if (maybeRole === "system") {
system = content;
} else {
messages.push({ role: maybeRole as "user" | "assistant", content });
}
} else {
messages.push({ role: "user", content: messageValue });
messages.push({ role: "user", content: m });
}
}
}
@@ -120,33 +105,24 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
}
export default defineCommand({
description: "Send a text model request (OpenAI compatible, DashScope)",
description: "Send a chat completion (OpenAI compatible, DashScope)",
auth: "apiKey",
usageArgs: "--message <text> [flags]",
flags: CHAT_FLAGS,
exampleArgs: [
'--message "What is Qwen?"',
`--api responses --model qwen3.8-max --tool '{"type":"web_search"}' --message "Search for recent Alibaba Cloud news"`,
'--model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python"',
'--message "Hello" --message "assistant:Hi!" --message "How are you?"',
"--messages-file - --stream",
'--message "Hello" --output json',
'--model qwq-plus --message "Solve 1+1" --enable-thinking',
],
validate: (flags) => {
if (!flags.message && !flags.messagesFile) {
return "Provide --message or --messages-file.";
}
if (flags.api === "responses" && flags.thinkingBudget !== undefined) {
return "--thinking-budget is not supported by the Responses API.";
}
return undefined;
},
validate: (f) =>
!f.message && !f.messagesFile ? "Provide --message or --messages-file." : undefined,
async run(ctx) {
const { settings, flags } = ctx;
const { system, messages } = parseMessages(flags);
const api = flags.api ?? "chat";
const model = flags.model || settings.defaultTextModel || "qwen3.8-max";
const shouldStream = flags.stream || process.stdout.isTTY;
const format = detectOutputFormat(settings.output);
@@ -158,39 +134,29 @@ export default defineCommand({
}
allMessages.push(...messages);
let body: ChatRequest | ResponsesRequest;
if (api === "responses") {
body = {
model,
input: allMessages,
max_output_tokens: flags.maxTokens ?? 4096,
stream: shouldStream,
};
} else {
body = {
model,
messages: allMessages,
max_tokens: flags.maxTokens ?? 4096,
stream: shouldStream,
};
}
const body: ChatRequest = {
model,
messages: allMessages,
max_tokens: flags.maxTokens ?? 4096,
stream: shouldStream,
};
if (flags.temperature !== undefined) body.temperature = flags.temperature;
if (flags.topP !== undefined) body.top_p = flags.topP;
if (flags.enableThinking) {
body.enable_thinking = true;
if (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) {
if (flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget;
}
}
if (flags.tool) {
const tools = flags.tool.map((toolValue) => {
const tools = flags.tool.map((t) => {
try {
return JSON.parse(toolValue);
return JSON.parse(t);
} catch {
const raw = readFileSync(toolValue, "utf-8");
const raw = readFileSync(t, "utf-8");
return JSON.parse(raw);
}
});
@@ -203,8 +169,8 @@ export default defineCommand({
}
if (shouldStream) {
const responseStream = await ctx.client.request({
path: api === "responses" ? responsesPath() : chatPath(),
const res = await ctx.client.request({
path: chatPath(),
method: "POST",
body,
stream: true,
@@ -212,7 +178,6 @@ export default defineCommand({
let textContent = "";
let inThinking = false;
let responsesCompleted = false;
const writesStreamingStdout = format === "text";
const isTTY = process.stdout.isTTY;
const statusOut =
@@ -220,28 +185,8 @@ export default defineCommand({
const resultOut = process.stdout;
const statusColor = ansi(statusOut);
for await (const event of parseSSE(responseStream)) {
for await (const event of parseSSE(res)) {
if (event.data === "[DONE]") break;
if (api === "responses") {
let parsedEvent: ResponsesStreamEvent;
try {
parsedEvent = JSON.parse(event.data) as ResponsesStreamEvent;
} catch {
continue;
}
const update = inspectResponsesStreamEvent(parsedEvent);
if (update.delta) {
textContent += update.delta;
if (writesStreamingStdout) resultOut.write(update.delta);
}
if (update.completed) {
responsesCompleted = true;
break;
}
continue;
}
try {
const parsed = JSON.parse(event.data) as StreamChunk;
@@ -271,7 +216,6 @@ export default defineCommand({
// Skip unparseable chunks
}
}
if (api === "responses") assertResponsesStreamCompleted(responsesCompleted);
if (inThinking) statusOut.write(statusColor.reset);
if (format === "json") {
@@ -279,20 +223,6 @@ export default defineCommand({
} else {
resultOut.write("\n");
}
} else if (api === "responses") {
const response = await ctx.client.requestJson<ResponsesResponse>({
path: responsesPath(),
method: "POST",
body,
});
const text = extractResponsesText(response);
if (settings.quiet || format === "text") {
emitBare(text);
} else {
emitResult(response, format);
}
} else {
const response = await ctx.client.requestJson<ChatResponse>({
path: chatPath(),
@@ -1,76 +0,0 @@
import {
BailianError,
ExitCode,
type ResponsesResponse,
type ResponsesStreamEvent,
} from "bailian-cli-core";
export interface ResponsesStreamUpdate {
delta: string;
completed: boolean;
}
export function extractResponsesText(response: ResponsesResponse): string {
return response.output
.filter((outputItem) => outputItem.type === "message")
.flatMap((outputItem) => outputItem.content ?? [])
.filter((contentItem) => contentItem.type === "output_text")
.map((contentItem) => contentItem.text ?? "")
.join("");
}
export function extractResponsesStreamDelta(event: ResponsesStreamEvent): string {
return event.type === "response.output_text.delta" ? (event.delta ?? "") : "";
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: undefined;
}
function stringProperty(record: Record<string, unknown> | undefined, property: string) {
const value = record?.[property];
return typeof value === "string" && value.trim() ? value : undefined;
}
function responsesErrorMessage(event: ResponsesStreamEvent): string | undefined {
const response = asRecord(event.response);
const responseError = asRecord(response?.error);
const eventError = asRecord(event.error);
return (
stringProperty(responseError, "message") ??
stringProperty(eventError, "message") ??
stringProperty(event, "message")
);
}
export function inspectResponsesStreamEvent(event: ResponsesStreamEvent): ResponsesStreamUpdate {
if (event.type === "response.failed" || event.type === "error") {
throw new BailianError(responsesErrorMessage(event) ?? "Response failed.", ExitCode.GENERAL);
}
if (event.type === "response.incomplete") {
const response = asRecord(event.response);
const incompleteDetails = asRecord(response?.incomplete_details);
const reason = stringProperty(incompleteDetails, "reason");
throw new BailianError(
responsesErrorMessage(event) ??
(reason ? `Response incomplete: ${reason}` : "Response incomplete."),
ExitCode.GENERAL,
);
}
return {
delta: extractResponsesStreamDelta(event),
completed: event.type === "response.completed",
};
}
export function assertResponsesStreamCompleted(completed: boolean): void {
if (completed) return;
throw new BailianError(
"Stream disconnected before completion: stream closed before response.completed.",
ExitCode.GENERAL,
);
}
@@ -0,0 +1,18 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const GET_KEY_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/api-keys/getKeyByUid";
export default defineCommand({
description: "Get the personal-edition TokenPlan API key (masked) for the current account",
auth: "console",
usageArgs: "[flags]",
flags: {},
exampleArgs: [""],
async run(ctx) {
const { settings } = ctx;
const format = detectOutputFormat(settings.output);
const result = await ctx.client.console(GET_KEY_API, {});
emitResult(result, format);
},
});
@@ -0,0 +1,62 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
const SUBSCRIPTION_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription";
const ADDON_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/addon/summary";
const COMMODITY_CN = "sfm_tokenplansolo_public_cn";
const COMMODITY_INTL = "sfm_tokenplansolo_public_intl";
const ADDON_CN = "sfm_tokenplansoloaddon_public_cn";
const ADDON_INTL = "sfm_tokenplansoloaddon_public_intl";
function nested(obj: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
const val = obj[key];
return val && typeof val === "object" && !Array.isArray(val)
? (val as Record<string, unknown>)
: undefined;
}
/** Unwrap the console gateway `data.DataV2.data.data` envelope to the business payload. */
function extract(result: Record<string, unknown>): Record<string, unknown> {
const data = nested(result, "data");
if (!data) return result;
const dataV2 = nested(data, "DataV2");
if (dataV2) {
const inner = nested(dataV2, "data");
const innerData = inner ? nested(inner, "data") : undefined;
return innerData ?? inner ?? dataV2;
}
return nested(data, "data") ?? data;
}
export default defineCommand({
description:
"Query personal-edition TokenPlan usage (5h/1w percentage, subscription, addon credits)",
auth: "console",
usageArgs: "[flags]",
flags: {},
exampleArgs: [""],
async run(ctx) {
const { settings } = ctx;
const format = detectOutputFormat(settings.output);
const intl = settings.consoleSite === "international";
const [usage, subscription, addon] = await Promise.all([
ctx.client.console(USAGE_API, {}),
ctx.client.console(SUBSCRIPTION_API, {
queryInstanceInfoRequest: { commodityCode: intl ? COMMODITY_INTL : COMMODITY_CN },
}),
ctx.client.console(ADDON_API, { commodityCode: intl ? ADDON_INTL : ADDON_CN }),
]);
emitResult(
{
usage: extract(usage as Record<string, unknown>),
subscription: extract(subscription as Record<string, unknown>),
addonSummary: extract(addon as Record<string, unknown>),
},
format,
);
},
});
+2 -2
View File
@@ -20,7 +20,8 @@ import {
type AnsiStyles,
} from "bailian-cli-runtime";
const SKILL_INSTALL_CMD = "bl skill init";
const SKILL_SOURCE = "modelstudioai/cli";
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
function updateAgentSkill(color: AnsiStyles): void {
process.stderr.write("\nUpdating agent skill...\n");
@@ -142,7 +143,6 @@ export default defineCommand({
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
);
writeUpdateState(newVer);
updateAgentSkill(color);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const reinstall =
@@ -1,132 +0,0 @@
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts";
import { formatNumber } from "./shared.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,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`);
}
+12 -51
View File
@@ -24,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;
@@ -110,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;
@@ -265,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;
@@ -297,32 +284,6 @@ export async function pollConsoleUntilDone(
return null;
}
/** Telemetry APIs wrap the payload in `reqDTO` and echo the task id as `asyncTaskId`. */
export async function pollTelemetryApi(
client: Client,
api: string,
reqDTO: Record<string, unknown>,
): Promise<unknown> {
return pollConsoleUntilDone(client, api, (taskId) =>
taskId ? { reqDTO: { ...reqDTO, asyncTaskId: taskId } } : { reqDTO },
);
}
/** Free-tier batch activate/deactivate wrap the payload in `requestKey` and echo `taskId`. */
export async function pollFreeTierBatch(
client: Client,
api: string,
requestKey: string,
models: string[],
): Promise<unknown> {
return pollConsoleUntilDone(
client,
api,
(taskId) => ({ [requestKey]: taskId ? { taskId } : { models } }),
20,
);
}
export function extractOverviewData(result: unknown): OverviewStatistic | undefined {
const resp = extractResponseData(result as Record<string, unknown>);
if (resp.callSuccessCount !== undefined || resp.usages !== undefined) {
+165 -15
View File
@@ -1,26 +1,176 @@
import { defineCommand, BailianError, ExitCode, detectOutputFormat } from "bailian-cli-core";
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
type Settings,
type Client,
} from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
import {
LIST_API,
OVERVIEW_API,
USAGE_KEY_LABELS,
extractListData,
extractOverviewData,
formatDate,
formatNumber,
pollTelemetryApi,
requireWorkspaceId,
resolveUsageMap,
type ModelStatisticItem,
type OverviewStatistic,
} from "./shared.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());
},
});
+7 -2
View File
@@ -33,6 +33,10 @@ export { default as memoryUpdate } from "./commands/memory/update.ts";
export { default as memoryDelete } from "./commands/memory/delete.ts";
export { default as memoryProfileCreate } from "./commands/memory/profile-create.ts";
export { default as memoryProfileGet } from "./commands/memory/profile-get.ts";
export { default as memoryProfileList } from "./commands/memory/profile-list.ts";
export { default as memoryProfileDetail } from "./commands/memory/profile-detail.ts";
export { default as memoryProfileUpdate } from "./commands/memory/profile-update.ts";
export { default as memoryProfileDelete } from "./commands/memory/profile-delete.ts";
export { default as knowledgeRetrieve } from "./commands/knowledge/retrieve.ts";
export { default as knowledgeSearch } from "./commands/knowledge/search.ts";
export { default as knowledgeChat } from "./commands/knowledge/chat.ts";
@@ -48,8 +52,6 @@ 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";
@@ -93,10 +95,13 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as tokenPlanPersonalUsage } from "./commands/token-plan/personal-usage.ts";
export { default as tokenPlanPersonalKey } from "./commands/token-plan/personal-key.ts";
export { default as managedAgentInit } from "./commands/managed-agent/init.ts";
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
export { default as managedAgentApply } from "./commands/managed-agent/apply.ts";
export { default as managedAgentRun } from "./commands/managed-agent/run.ts";
export { default as managedAgentDestroy } from "./commands/managed-agent/destroy.ts";
export { default as managedAgentStateList } from "./commands/managed-agent/state-list.ts";
export { default as managedAgentStateShow } from "./commands/managed-agent/state-show.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: {},
});
});
});
@@ -124,7 +124,8 @@ test("inject:已带后缀且尾斜杠的 base_url 去斜杠后原样保留", ()
expect(providers.bailian.base_url).toBe("https://x.maas.aliyuncs.com/api/v1/agentstudio");
});
test("inject:workspace_id 引用且为空时 settings 填充;有字面量则保留", () => {
test("inject:workspace_id 引用且为空时按 env > settings 填充;有字面量则保留", () => {
delete process.env.BAILIAN_WORKSPACE_ID;
const empty = { bailian: { api_key: "", workspace_id: "" } };
injectProviderCredentials(
empty,
@@ -132,6 +133,16 @@ test("inject:workspace_id 引用且为空时用 settings 填充;有字面量则
);
expect(empty.bailian.workspace_id).toBe("ws-settings");
// 内联运行时(对象配置)不做 ${} 插值,env 变量在此补读。
process.env.BAILIAN_WORKSPACE_ID = "ws-env";
const fromEnv = { bailian: { api_key: "", workspace_id: "" } };
injectProviderCredentials(
fromEnv,
makeHost({ apiCred: bailianCred(), workspaceId: "ws-settings" }),
);
expect(fromEnv.bailian.workspace_id).toBe("ws-env");
delete process.env.BAILIAN_WORKSPACE_ID;
const literal = { bailian: { api_key: "", workspace_id: "ws-yaml" } };
injectProviderCredentials(
literal,
@@ -140,6 +151,37 @@ test("inject:workspace_id 引用且为空时用 settings 填充;有字面量则
expect(literal.bailian.workspace_id).toBe("ws-yaml");
});
test("inject:workspace 已知时 base_url 拼工作空间主机,而非模型域 origin", () => {
// agents.yaml 字面量 workspace_id + 空 base_url。
const literal = { bailian: { api_key: "", base_url: "", workspace_id: "ws-yaml" } };
injectProviderCredentials(literal, makeHost({ apiCred: bailianCred() }));
expect(literal.bailian.base_url).toBe(
"https://ws-yaml.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio",
);
// 内联块:workspace_id 由 settings 填充后同样走工作空间主机。
const inline = { bailian: { api_key: "", base_url: "", workspace_id: "" } };
injectProviderCredentials(
inline,
makeHost({ apiCred: bailianCred(), workspaceId: "ws-settings" }),
);
expect(inline.bailian.workspace_id).toBe("ws-settings");
expect(inline.bailian.base_url).toBe(
"https://ws-settings.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio",
);
// 显式 base_url 字面量永远优先于拼装。
const explicit = {
bailian: {
api_key: "",
base_url: "https://custom.example.com/api/v1/agentstudio",
workspace_id: "ws-yaml",
},
};
injectProviderCredentials(explicit, makeHost({ apiCred: bailianCred() }));
expect(explicit.bailian.base_url).toBe("https://custom.example.com/api/v1/agentstudio");
});
test("inject:无凭证时 api_key 保持不变,base_url 仍用 client 默认域名补齐(离线/范围外 schema 可用)", () => {
const providers = { bailian: { api_key: "", base_url: "" } };
injectProviderCredentials(providers, makeHost({}));
@@ -1,6 +1,4 @@
import { readFileSync } from "node:fs";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import {
@@ -18,37 +16,6 @@ import { SPEECH_ROUTES } from "./topic-routes.ts";
*/
describe("e2e: speech recognize", () => {
async function runRecognizeDryRun(args: string[]) {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
...args,
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
return parseStdoutJson<{
mode?: string;
path?: string;
request?: {
model?: string;
parameters?: {
format?: string;
language_hints?: string[];
language?: string;
vocabulary_id?: string;
};
input?: {
file_url?: string;
file_urls?: string[];
messages?: Array<{ content?: Array<{ type?: string }> }>;
};
};
}>(stdout);
}
test("speech recognize --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
@@ -58,217 +25,6 @@ describe("e2e: speech recognize", () => {
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/recognize|--url|model|audio/i);
});
test("speech recognize sync-flash dry-run 走 multimodal-generation", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
"--language",
"en",
"--vocabulary-id",
"vocab-e2e",
]);
expect(body.mode).toBe("sync");
expect(body.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(body.request?.model).toBe("qwen-audio-3.0-asr-flash");
expect(body.request?.parameters?.format).toBe("wav");
expect(body.request?.parameters?.language_hints).toEqual(["en"]);
expect(body.request?.parameters?.vocabulary_id).toBe("vocab-e2e");
expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize qwen3 filetrans dry-run 使用 file_url 与 language", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen3-asr-flash-filetrans",
"--url",
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
"--language",
"zh",
]);
expect(body.mode).toBe("async");
expect(body.path).toBe("/api/v1/services/audio/asr/transcription");
expect(body.request?.input?.file_url?.startsWith("https://")).toBe(true);
expect(body.request?.input?.file_urls).toBeUndefined();
expect(body.request?.parameters?.language).toBe("zh");
expect(body.request?.parameters?.language_hints).toBeUndefined();
});
test("speech recognize realtime 模型报用法错误", async () => {
// Use --dry-run to skip auth so CI without API keys still hits USAGE(2)
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash-realtime",
"--url",
"https://example.com/a.wav",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/realtime|WebSocket|unsupported/i);
});
test("speech recognize flash 真实请求走 sync endpoint 并落盘 --out", async () => {
let requestPath = "";
let requestBody: Record<string, unknown> = {};
let sseHeader: string | undefined;
const server = http.createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
requestPath = request.url ?? "";
requestBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
sseHeader = request.headers["x-dashscope-sse"] as string | undefined;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
output: { text: "flash recognition works" },
request_id: "request-146",
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const outDir = makeE2eOutputDir("speech-recognize-flash-sync");
const outPath = join(outDir, "result.json");
try {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"fun-asr-flash-2026-06-15",
"--url",
"https://example.com/sample.wav",
"--api-key",
"sk-e2e-placeholder",
"--base-url",
`http://127.0.0.1:${address.port}`,
"--out",
outPath,
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("flash recognition works");
expect(requestPath).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(sseHeader).toBe("disable");
expect(requestBody).toMatchObject({
model: "fun-asr-flash-2026-06-15",
parameters: { format: "wav" },
});
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
output: { text: "flash recognition works" },
request_id: "request-146",
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test("speech recognize flash 多 --url 在发请求前报用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://example.com/a.wav",
"--url",
"https://example.com/b.wav",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/exactly one --url|sync Flash/i);
});
test("speech recognize qwen3-filetrans 轮询成功后下载 result.transcription_url", async () => {
const server = http.createServer((request, response) => {
const url = request.url ?? "";
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
response.writeHead(200, { "Content-Type": "application/json" });
if (url.startsWith("/api/v1/services/audio/asr/transcription")) {
response.end(
JSON.stringify({
output: { task_id: "task-qwen3", task_status: "PENDING" },
request_id: "req-submit",
}),
);
return;
}
if (url.startsWith("/api/v1/tasks/")) {
const address = server.address() as AddressInfo;
response.end(
JSON.stringify({
output: {
task_id: "task-qwen3",
task_status: "SUCCEEDED",
result: {
transcription_url: `http://127.0.0.1:${address.port}/transcription.json`,
},
},
request_id: "req-poll",
}),
);
return;
}
if (url.startsWith("/transcription.json")) {
response.end(
JSON.stringify({
file_url: "https://example.com/a.wav",
transcripts: [{ text: "你好世界", sentences: [{ text: "你好世界" }] }],
}),
);
return;
}
response.writeHead(404);
response.end(JSON.stringify({ message: `unexpected path: ${url}` }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
const outDir = makeE2eOutputDir("speech-recognize-qwen3-filetrans");
const outPath = join(outDir, "result.json");
try {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--language",
"zh",
"--api-key",
"sk-e2e-placeholder",
"--base-url",
`http://127.0.0.1:${address.port}`,
"--poll-interval",
"1",
"--out",
outPath,
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("你好世界");
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
transcripts: [{ text: "你好世界" }],
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
@@ -10,37 +10,8 @@ describe("e2e: text chat", () => {
test("text chat --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, ["text", "chat", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--api\s+<chat\|responses>/i);
expect(stderr).toMatch(/chat|--message|model|stream/i);
});
test("text chat 拒绝未知的 --api 值", async () => {
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--api",
"legacy",
"--message",
"hello",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--api|chat.*responses/i);
});
test("text chat 在 Responses 模式拒绝 --thinking-budget", async () => {
const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--api",
"responses",
"--message",
"hello",
"--thinking-budget",
"8",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/thinking-budget.*Responses/i);
});
});
describe.skipIf(!isDashScopeE2EReady())("e2e: text chatDashScope", () => {
@@ -74,42 +45,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chatDashScope", () => {
request?: { model?: string; messages?: Array<{ content?: string }> };
}>(stdout);
expect(data.request?.model).toBe("qwen3.8-max");
expect(data.request?.messages?.some((message) => message.content === "干跑")).toBe(true);
});
test("text chat --api responses --dry-run 生成 Responses 请求", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--dry-run",
"--api",
"responses",
"--model",
"qwen3.8-max",
"--system",
"system",
"--message",
"hello",
"--max-tokens",
"8",
"--tool",
'{"type":"web_search"}',
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: Record<string, unknown> }>(stdout);
expect(data.request).toMatchObject({
model: "qwen3.8-max",
input: [
{ role: "system", content: "system" },
{ role: "user", content: "hello" },
],
max_output_tokens: 8,
tools: [{ type: "web_search" }],
});
expect(data.request).not.toHaveProperty("messages");
expect(data.request).not.toHaveProperty("max_tokens");
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
});
test("【qwen3.8-max】文本对话", async () => {
+7 -2
View File
@@ -33,6 +33,10 @@ export const MEMORY_ROUTES: E2eRouteExports = {
"memory delete": "memoryDelete",
"memory profile create": "memoryProfileCreate",
"memory profile get": "memoryProfileGet",
"memory profile list": "memoryProfileList",
"memory profile detail": "memoryProfileDetail",
"memory profile update": "memoryProfileUpdate",
"memory profile delete": "memoryProfileDelete",
};
export const KNOWLEDGE_ROUTES: E2eRouteExports = {
@@ -109,8 +113,6 @@ 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 = {
@@ -160,6 +162,8 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = {
"token-plan create-key": "tokenPlanCreateKey",
"token-plan assign-seats": "tokenPlanAssignSeats",
"token-plan add-member": "tokenPlanAddMember",
"token-plan personal-usage": "tokenPlanPersonalUsage",
"token-plan personal-key": "tokenPlanPersonalKey",
};
export const SKILL_ROUTES: E2eRouteExports = {
@@ -175,6 +179,7 @@ export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
"managed-agent validate": "managedAgentValidate",
"managed-agent plan": "managedAgentPlan",
"managed-agent apply": "managedAgentApply",
"managed-agent run": "managedAgentRun",
"managed-agent destroy": "managedAgentDestroy",
"managed-agent state list": "managedAgentStateList",
"managed-agent state rm": "managedAgentStateRm",
@@ -1,80 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import {
isConsoleAuthFailure,
isConsoleE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
describe("e2e: usage coding-plan", () => {
test("usage coding-plan --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"coding-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Coding Plan|quota/i);
});
test("usage coding-plan --help 包含 --output json 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"coding-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage coding-plan --output json");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage coding-planConsole", () => {
test("usage coding-plan --dry-run 输出网关请求计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"coding-plan",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { queryCodingPlanInstanceInfoRequest?: Record<string, unknown> };
}>(stdout);
expect(data.api).toBe("zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2");
expect(data.data?.queryCodingPlanInstanceInfoRequest).toEqual({
commodityCode: "sfm_codingplan_public_cn",
onlyLatestOne: true,
});
});
test("usage coding-plan --output json 返回窗口结构", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
const data = parseStdoutJson<{
per5Hour?: { percentage?: number };
perWeek?: { percentage?: number };
perBillMonth?: { percentage?: number };
}>(result.stdout);
// 无有效订阅返回 {};有订阅时三个窗口必须存在
if (Object.keys(data).length > 0) {
expect(data.per5Hour).toBeTypeOf("object");
expect(data.perWeek).toBeTypeOf("object");
expect(data.perBillMonth).toBeTypeOf("object");
}
});
test("usage coding-plan 默认渲染生成时间与额度窗口或无订阅提示", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "coding-plan"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
if (result.stdout.includes("No active Coding Plan subscription found.")) return;
expect(result.stdout).toContain("Generated at:");
expect(result.stdout).toContain("5-hour quota");
expect(result.stdout).toContain("1-week quota");
expect(result.stdout).toContain("Monthly quota");
});
});
@@ -1,76 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import {
isConsoleAuthFailure,
isConsoleE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
describe("e2e: usage token-plan", () => {
test("usage token-plan --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Token Plan|quota/i);
});
test("usage token-plan --help 包含 --output json 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl usage token-plan --output json");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: usage token-planConsole", () => {
test("usage token-plan --dry-run 输出网关请求计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(USAGE_ROUTES, [
"usage",
"token-plan",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string; data?: Record<string, unknown> }>(stdout);
expect(data.api).toBe("zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage");
expect(data.data).toEqual({});
});
test("usage token-plan --output json 返回可用的额度字段", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
const data = parseStdoutJson<{
per5HourPercentage?: number;
per5HourResetTime?: number;
per1WeekPercentage?: number;
per1WeekResetTime?: number;
}>(result.stdout);
const fields = [
data.per5HourPercentage,
data.per5HourResetTime,
data.per1WeekPercentage,
data.per1WeekResetTime,
];
for (const field of fields) {
if (field !== undefined) expect(field).toBeTypeOf("number");
}
});
test("usage token-plan 默认渲染生成时间与两个额度窗口", async () => {
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain("Generated at:");
expect(result.stdout).toContain("5-hour quota");
expect(result.stdout).toContain("1-week quota");
});
});
@@ -26,12 +26,6 @@ describe("mcp-activate-hint", () => {
false,
);
expect(isMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false);
// Nested wrapper phrase must not match (anchored at start).
expect(
isMcpNotActivated(
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found - 未开通"),
),
).toBe(false);
});
test("hint 含对应 server 的 MCP 广场深链", () => {
@@ -44,36 +38,6 @@ describe("mcp-activate-hint", () => {
expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i);
});
test("WebSearch + 405 streamableHttp 补重开通 hint", () => {
const original = new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
ExitCode.GENERAL,
);
try {
rethrowWithMcpActivateHint(original, "WebSearch");
expect.unreachable("should throw");
} catch (error) {
expect(error).toBeInstanceOf(BailianError);
const wrapped = error as BailianError;
expect(wrapped.message).toBe(original.message);
expect(wrapped.hint).toMatch(/SSE|Streamable HTTP|Activate|re-activate/i);
expect(wrapped.hint).toContain(mcpMarketplaceDetailPage("WebSearch"));
}
});
test("非 WebSearch 的 405 streamableHttp 不补 hint由 fallback 处理)", () => {
const original = new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
ExitCode.GENERAL,
);
try {
rethrowWithMcpActivateHint(original, "WebParser");
expect.unreachable("should throw");
} catch (error) {
expect(error).toBe(original);
}
});
test("rethrow 保留原 message补 hint", () => {
const serverCode = "market-cmapi00073529";
const original = new BailianError(
@@ -1,138 +0,0 @@
import { BailianError, ExitCode } from "bailian-cli-core";
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import chatCommand from "../src/commands/text/chat.ts";
import {
extractResponsesStreamDelta,
extractResponsesText,
} from "../src/commands/text/responses.ts";
afterEach(() => {
vi.restoreAllMocks();
});
function responsesStreamResponse(events: unknown[]): Response {
const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
return new Response(body, { headers: { "content-type": "text/event-stream" } });
}
async function runResponsesStream(events: unknown[]): Promise<BailianError | undefined> {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
await chatCommand.run({
settings: { output: "json" },
flags: {
api: "responses",
message: ["hello"],
stream: true,
},
client: {
request: async () => responsesStreamResponse(events),
},
} as never);
return undefined;
} catch (error) {
expect(error).toBeInstanceOf(BailianError);
return error as BailianError;
}
}
describe("Responses output", () => {
test("extracts only output_text from message items", () => {
const text = extractResponsesText({
id: "resp_1",
object: "response",
status: "completed",
output: [
{ type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }] },
{ type: "web_search_call", status: "completed" },
{ type: "message", content: [{ type: "output_text", text: "first" }] },
{
type: "message",
content: [
{ type: "refusal", text: "ignored" },
{ type: "output_text", text: " second" },
],
},
],
});
expect(text).toBe("first second");
});
test("extracts only response.output_text.delta stream events", () => {
expect(extractResponsesStreamDelta({ type: "response.output_text.delta", delta: "hi" })).toBe(
"hi",
);
expect(extractResponsesStreamDelta({ type: "response.web_search_call.completed" })).toBe("");
});
test("streaming response.completed finishes successfully", async () => {
const error = await runResponsesStream([
{ type: "response.output_text.delta", sequence_number: 1, delta: "done" },
{
type: "response.completed",
sequence_number: 2,
response: {
id: "resp_completed",
object: "response",
status: "completed",
output: [],
error: null,
incomplete_details: null,
},
},
]);
expect(error).toBeUndefined();
});
test("streaming response.failed throws the original service message", async () => {
const error = await runResponsesStream([
{
type: "response.failed",
sequence_number: 1,
response: {
id: "resp_failed",
object: "response",
status: "failed",
output: [],
error: { code: "quota_exceeded", message: "provider quota exceeded" },
},
},
]);
expect(error?.exitCode).toBe(ExitCode.GENERAL);
expect(error?.message).toBe("provider quota exceeded");
});
test("streaming response.incomplete exits non-zero with the service reason", async () => {
const error = await runResponsesStream([
{
type: "response.incomplete",
sequence_number: 1,
response: {
id: "resp_incomplete",
object: "response",
status: "incomplete",
output: [],
incomplete_details: { reason: "max_output_tokens" },
},
},
]);
expect(error?.exitCode).toBe(ExitCode.GENERAL);
expect(error?.message).toBe("Response incomplete: max_output_tokens");
});
test("stream ending before response.completed exits non-zero", async () => {
const error = await runResponsesStream([
{ type: "response.output_text.delta", sequence_number: 1, delta: "partial" },
]);
expect(error?.exitCode).toBe(ExitCode.GENERAL);
expect(error?.message).toBe(
"Stream disconnected before completion: stream closed before response.completed.",
);
});
});
@@ -1,195 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import tokenPlanUsage from "../src/commands/usage/token-plan.ts";
const originalNoColor = process.env.NO_COLOR;
const originalForceColor = process.env.FORCE_COLOR;
const originalIsTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
afterEach(() => {
if (originalNoColor === undefined) delete process.env.NO_COLOR;
else process.env.NO_COLOR = originalNoColor;
if (originalForceColor === undefined) delete process.env.FORCE_COLOR;
else process.env.FORCE_COLOR = originalForceColor;
if (originalIsTty) Object.defineProperty(process.stdout, "isTTY", originalIsTty);
else delete (process.stdout as { isTTY?: boolean }).isTTY;
vi.restoreAllMocks();
});
function captureStdout(): string[] {
const output: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
return output;
}
async function runTokenPlan(response: Record<string, unknown>, output?: string): Promise<void> {
await tokenPlanUsage.run({
client: { console: vi.fn().mockResolvedValue(response) },
flags: {},
settings: { dryRun: false, output },
} as never);
}
function makeUsageResponse(
per5HourPercentage?: number,
per1WeekPercentage = per5HourPercentage,
): Record<string, unknown> {
const usage: Record<string, number> = {};
if (per5HourPercentage !== undefined) {
usage.per5HourPercentage = per5HourPercentage;
if (per5HourPercentage !== 0) usage.per5HourResetTime = 1_786_000_000_000;
}
if (per1WeekPercentage !== undefined) {
usage.per1WeekPercentage = per1WeekPercentage;
if (per1WeekPercentage !== 0) usage.per1WeekResetTime = 1_786_100_000_000;
}
return wrapResponse(usage);
}
function wrapResponse(usage: Record<string, unknown>): Record<string, unknown> {
return {
data: {
DataV2: {
data: {
data: usage,
},
},
},
};
}
describe("usage token-plan view", () => {
test("renders the gauge with the usage-free brand fill color", async () => {
delete process.env.NO_COLOR;
process.env.FORCE_COLOR = "3";
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5));
// Brand-cyan fill cell, same as the `usage free` gauge column
expect(output.join("")).toContain("\u001B[38;2;0;150;160m\u2588");
});
test("renders proportional gauge cells with a transparent track", async () => {
process.env.NO_COLOR = "1";
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5));
const renderedOutput = output.join("");
// 50% of the default 20-cell gauge → 10 filled cells + 10 track spaces
expect(renderedOutput).toContain(`${"\u2588".repeat(10)}${" ".repeat(10)}`);
expect(renderedOutput).not.toContain("\u2588".repeat(11));
});
test("accepts missing reset times when the quota usage is zero", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0));
expect(output.join("")).toContain("Resets: not applicable (no usage yet)");
});
test("allows one unused quota window without masking another reset time", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0, 0.5));
const renderedOutput = output.join("");
expect(renderedOutput).toContain("Resets: not applicable (no usage yet)");
expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
test("renders missing quota windows as possibly unlimited", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse());
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
test("renders only the missing quota window as possibly unlimited", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(undefined, 0.5));
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).not.toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toMatch(/Resets: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
test("renders a window with a missing percentage as possibly unlimited even when its reset time is present", async () => {
const output = captureStdout();
await runTokenPlan(wrapResponse({ per5HourResetTime: 1_786_000_000_000 }));
expect(output.join("")).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
test("treats non-numeric quota fields as absent instead of failing", async () => {
const output = captureStdout();
await runTokenPlan(
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: Number.NaN }),
);
const renderedOutput = output.join("");
expect(renderedOutput).toContain(
"The 5-hour limit may be unlimited; verify in the Bailian Token Plan console.",
);
expect(renderedOutput).toContain(
"The 1-week limit may be unlimited; verify in the Bailian Token Plan console.",
);
});
});
describe("usage token-plan json", () => {
test("outputs the four core usage fields with --output json", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(0.5, 0.25), "json");
expect(JSON.parse(output.join(""))).toEqual({
per5HourPercentage: 0.5,
per5HourResetTime: 1_786_000_000_000,
per1WeekPercentage: 0.25,
per1WeekResetTime: 1_786_100_000_000,
});
});
test("returns an empty JSON object when no quota fields are available", async () => {
const output = captureStdout();
await runTokenPlan(makeUsageResponse(), "json");
expect(output.join("").trim()).toBe("{}");
});
test("omits non-numeric quota fields from the JSON output", async () => {
const output = captureStdout();
await runTokenPlan(
wrapResponse({ per5HourPercentage: "not-a-number", per1WeekPercentage: 0 }),
"json",
);
expect(JSON.parse(output.join(""))).toEqual({ per1WeekPercentage: 0 });
});
});
@@ -1,62 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
const runtimeMocks = vi.hoisted(() => ({
performBinaryUpdate: vi.fn(),
}));
const childProcessMocks = vi.hoisted(() => ({
execSync: vi.fn(),
}));
vi.mock("bailian-cli-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("bailian-cli-runtime")>();
return { ...actual, performBinaryUpdate: runtimeMocks.performBinaryUpdate };
});
vi.mock("child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("child_process")>();
return { ...actual, execSync: childProcessMocks.execSync };
});
import updateCommand from "../src/commands/update.ts";
let configDir: string;
let previousConfigDir: string | undefined;
let previousInstallMethod: string | undefined;
beforeEach(() => {
configDir = mkdtempSync(join(tmpdir(), "bl-update-binary-"));
previousConfigDir = process.env.BAILIAN_CONFIG_DIR;
previousInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
process.env.BAILIAN_CONFIG_DIR = configDir;
process.env.BAILIAN_INSTALL_METHOD = "binary";
runtimeMocks.performBinaryUpdate.mockResolvedValue("1.15.0");
});
afterEach(() => {
if (previousConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
else process.env.BAILIAN_CONFIG_DIR = previousConfigDir;
if (previousInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
else process.env.BAILIAN_INSTALL_METHOD = previousInstallMethod;
rmSync(configDir, { recursive: true, force: true });
vi.clearAllMocks();
});
test("binary bl update syncs bailian skills after the CLI update succeeds", async () => {
await updateCommand.run({
identity: {
binName: "bl",
clientName: "bailian-cli",
npmPackage: "bailian-cli",
version: "1.14.3",
},
flags: { to: "1.15.0" },
settings: {},
} as never);
expect(runtimeMocks.performBinaryUpdate).toHaveBeenCalledWith("1.15.0");
expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill init", { stdio: "inherit" });
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.15.0",
"version": "1.14.2",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
-327
View File
@@ -1,327 +0,0 @@
import { imageSyncPath, speechRecognizePath } from "./endpoints.ts";
/**
* DashScope ASR APIs differ by model family:
*
* - async file transcription (`.../audio/asr/transcription`):
* fun-asr*, paraformer* (non-realtime), *-filetrans, sensevoice*
* language via `parameters.language_hints`
* - sync multimodal (`.../aigc/multimodal-generation/generation`):
* - qwen3: `{ content: [{ audio }] }` + optional `asr_options.language`
* (qwen3-asr-flash*)
* - input-audio: `{ type: input_audio, input_audio.data }` +
* `format`/`sample_rate` + optional `language_hints`
* (fun-asr-flash*, qwen-audio-*-asr-flash*)
* - realtime / streaming: WebSocket — not supported by `speech recognize`
*/
export type AsrApiKind = "async-filetrans" | "sync-flash" | "unsupported";
/** Sync-flash request body shape differs by Flash protocol family. */
export type AsrFlashFamily = "qwen3" | "input-audio";
export interface AsrApiRoute {
kind: AsrApiKind;
path: string;
/** True when the call is synchronous (no X-DashScope-Async / task poll). */
useSync: boolean;
/**
* Async transcription request input style.
* - `file_urls`: classic async models (fun-asr / paraformer / qwen-audio filetrans...)
* - `file_url`: qwen3-asr-flash-filetrans family
*/
asyncInputStyle?: "file_urls" | "file_url";
/**
* Async transcription language field style.
* - `language_hints`: fun-asr / paraformer / qwen-audio filetrans...
* - `language`: qwen3-asr-flash-filetrans*
*/
asyncLanguageStyle?: "language_hints" | "language";
flashFamily?: AsrFlashFamily;
/** Human-readable reason when kind is unsupported. */
unsupportedReason?: string;
}
function isRealtimeOrStreaming(model: string): boolean {
return /realtime|streaming/i.test(model);
}
function isFiletransModel(model: string): boolean {
return /filetrans/i.test(model);
}
function isQwen3FiletransModel(model: string): boolean {
return /^qwen3-asr-flash-filetrans(?:-|$)/i.test(model);
}
const INPUT_AUDIO_FLASH_PREFIXES = ["fun-asr-flash", "qwen-audio"] as const;
/**
* Fun-ASR-Flash / Qwen-Audio-*-ASR-Flash share the input_audio + format protocol.
* Examples: fun-asr-flash-2026-06-15, qwen-audio-3.0-asr-flash
*/
function isInputAudioFlashModel(model: string): boolean {
if (isRealtimeOrStreaming(model) || isFiletransModel(model)) return false;
if (model.startsWith(INPUT_AUDIO_FLASH_PREFIXES[0])) return true;
if (model.startsWith(INPUT_AUDIO_FLASH_PREFIXES[1]) && /asr-flash/i.test(model)) return true;
return false;
}
/**
* Qwen3-ASR-Flash sync models use content.audio + asr_options.
* Examples: qwen3-asr-flash, qwen3-asr-flash-2025-09-08, qwen3-asr-flash-us
*/
function isQwen3AsrFlashModel(model: string): boolean {
if (!/^qwen3-asr-flash(?:-|$)/i.test(model)) return false;
if (isFiletransModel(model) || isRealtimeOrStreaming(model)) return false;
if (isInputAudioFlashModel(model)) return false;
return true;
}
/**
* Resolve which DashScope ASR API a model should use for file recognition.
* Unknown models default to async-filetrans (preserves existing CLI behavior).
*/
export function resolveAsrApi(model: string): AsrApiRoute {
if (isRealtimeOrStreaming(model)) {
return {
kind: "unsupported",
path: "",
useSync: false,
unsupportedReason:
`Model "${model}" is a realtime/streaming ASR model and requires a WebSocket API. ` +
`Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) or a sync flash model ` +
`(e.g. qwen3-asr-flash, qwen-audio-3.0-asr-flash) with this command.`,
};
}
if (isFiletransModel(model)) {
const isQwen3Filetrans = isQwen3FiletransModel(model);
return {
kind: "async-filetrans",
path: speechRecognizePath(),
useSync: false,
asyncInputStyle: isQwen3Filetrans ? "file_url" : "file_urls",
asyncLanguageStyle: isQwen3Filetrans ? "language" : "language_hints",
};
}
if (isInputAudioFlashModel(model)) {
return {
kind: "sync-flash",
path: imageSyncPath(),
useSync: true,
flashFamily: "input-audio",
};
}
if (isQwen3AsrFlashModel(model)) {
return {
kind: "sync-flash",
path: imageSyncPath(),
useSync: true,
flashFamily: "qwen3",
};
}
// fun-asr / paraformer / sensevoice / unknown → keep legacy async path
return {
kind: "async-filetrans",
path: speechRecognizePath(),
useSync: false,
asyncInputStyle: "file_urls",
asyncLanguageStyle: "language_hints",
};
}
/** Infer audio container hint for input-audio Flash `parameters.format`. */
export function inferAudioFormatHint(audioUrl: string): string {
// data URI: data:audio/mpeg;base64,... → mp3; data:audio/x-wav;... → wav
const dataType = /^data:audio\/([^;,]+)/i.exec(audioUrl)?.[1]?.toLowerCase();
if (dataType) {
if (dataType === "mpeg") return "mp3";
if (dataType === "x-wav" || dataType === "wave") return "wav";
return dataType;
}
const pathPart = audioUrl.split(/[?#]/, 1)[0] ?? audioUrl;
const match = pathPart.match(/\.([a-zA-Z0-9]+)$/);
const extension = match?.[1]?.toLowerCase();
if (!extension) return "wav";
if (extension === "mpeg") return "mp3";
return extension;
}
export interface BuildAsrFlashRequestOpts {
model: string;
audioUrl: string;
language?: string;
/** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */
vocabularyId?: string;
flashFamily: AsrFlashFamily;
}
/**
* Build language fields for async ASR routes.
* qwen3-asr-flash-filetrans* → `language`; other async models → `language_hints`.
*/
export function buildAsyncAsrLanguageFields(
languageStyle: "language_hints" | "language",
language?: string,
): { language_hints?: string[]; language?: string } {
if (!language) return {};
if (languageStyle === "language") {
return { language };
}
return { language_hints: [language] };
}
/** Build a sync multimodal ASR request body for Flash models. */
export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<string, unknown> {
const { model, audioUrl, language, vocabularyId, flashFamily } = opts;
if (flashFamily === "input-audio") {
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id
const parameters: Record<string, unknown> = {
format: inferAudioFormatHint(audioUrl),
sample_rate: "16000",
};
if (language) {
parameters.language_hints = [language];
}
if (vocabularyId) {
parameters.vocabulary_id = vocabularyId;
}
return {
model,
input: {
messages: [
{
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
},
],
},
parameters,
};
}
const asrOptions: Record<string, unknown> = {};
if (language) {
asrOptions.language = language;
}
const parameters: Record<string, unknown> = {};
if (Object.keys(asrOptions).length > 0) {
parameters.asr_options = asrOptions;
}
const body: Record<string, unknown> = {
model,
input: {
messages: [
{
role: "user",
content: [{ audio: audioUrl }],
},
],
},
};
if (Object.keys(parameters).length > 0) {
body.parameters = parameters;
}
return body;
}
/**
* Extract recognition text from a sync Flash ASR response.
* Qwen3 uses choices[].message.content; input-audio Flash uses output.text /
* output.sentence.text / output.output.sentence.text.
*/
export function extractAsrFlashText(
response: Record<string, unknown>,
flashFamily: AsrFlashFamily,
): string {
const output = response.output as Record<string, unknown> | undefined;
if (!output) return "";
if (flashFamily === "input-audio") {
if (typeof output.text === "string" && output.text.length > 0) {
return output.text;
}
const topSentence = output.sentence as Record<string, unknown> | undefined;
if (typeof topSentence?.text === "string" && topSentence.text.length > 0) {
return topSentence.text;
}
const nested = output.output as Record<string, unknown> | undefined;
const nestedSentence = nested?.sentence as Record<string, unknown> | undefined;
if (typeof nestedSentence?.text === "string") {
return nestedSentence.text;
}
return "";
}
const choices = output.choices as Array<Record<string, unknown>> | undefined;
if (!choices?.length) return "";
const texts: string[] = [];
for (const choice of choices) {
const message = choice.message as Record<string, unknown> | undefined;
if (!message) continue;
const content = message.content;
if (typeof content === "string") {
texts.push(content);
continue;
}
if (!Array.isArray(content)) continue;
for (const item of content) {
if (typeof item === "string") {
texts.push(item);
continue;
}
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
if (typeof record.text === "string") {
texts.push(record.text);
}
}
}
}
return texts.join("");
}
/**
* Normalize async ASR task transcription items:
* - classic models: `output.results[]`
* - qwen3-asr-flash-filetrans*: `output.result.transcription_url`
*/
export function collectAsrTranscriptionItems(output: {
results?: Array<{
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}>;
result?: { transcription_url?: string };
}): Array<{
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}> {
if (output.results && output.results.length > 0) {
return output.results;
}
const transcriptionUrl = output.result?.transcription_url;
if (typeof transcriptionUrl === "string" && transcriptionUrl.length > 0) {
return [{ transcription_url: transcriptionUrl, subtask_status: "SUCCEEDED" }];
}
return [];
}
+1 -26
View File
@@ -5,13 +5,7 @@ import { ExitCode } from "../errors/codes.ts";
import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts";
import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts";
import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts";
import {
bailianMcpPath,
bailianMcpSsePath,
connectBailianMcpWithFallback,
McpClient,
type McpConnectedClient,
} from "./mcp.ts";
import { McpClient } from "./mcp.ts";
import { callConsoleGateway } from "../console/gateway.ts";
import { refreshAccessToken } from "../auth/refresh-token.ts";
import { maskToken } from "../utils/token.ts";
@@ -170,25 +164,6 @@ export class Client {
return new McpClient(this.http, url, this.deps.apiCred?.token);
}
/**
* Connect to a Bailian MCP: try Streamable HTTP, then SSE on 405 (except WebSearch).
* `urlOverride` maps to `--url`: Streamable first, then classic SSE on the same URL (405/404).
*/
connectBailianMcp(
serverCode: string,
urlOverride?: string,
): Promise<{ client: McpConnectedClient; url: string }> {
this.requireApi();
return connectBailianMcpWithFallback({
deps: this.http,
authToken: this.deps.apiCred?.token,
httpUrl: this.url(bailianMcpPath(serverCode)),
sseUrl: this.url(bailianMcpSsePath(serverCode)),
serverCode,
urlOverride,
});
}
async console<T>(api: string, data: Record<string, unknown>): Promise<T> {
if (!this.deps.consoleCred) {
throw new BailianError("This command needs a console access token.", ExitCode.AUTH);
+5 -6
View File
@@ -6,11 +6,6 @@ export function chatPath(): string {
return "/compatible-mode/v1/chat/completions";
}
// ---- Responses (OpenAI Compatible) ----
export function responsesPath(): string {
return "/compatible-mode/v1/responses";
}
// ---- Image Generation (DashScope) ----
/** Async image API used by wan2.6-t2i / wan2.6-image (T2I) and similar message-format models. */
export function imagePath(): string {
@@ -80,7 +75,11 @@ export function profileSchemaPath(): string {
}
export function userProfilePath(schemaId: string): string {
return `/api/v2/apps/memory/profile_schemas/${encodeURIComponent(schemaId)}/profiles`;
return `/api/v2/apps/memory/profile_schemas/${encodeURIComponent(schemaId)}/user_profile`;
}
export function profileSchemaItemPath(schemaId: string): string {
return `/api/v2/apps/memory/profile_schemas/${encodeURIComponent(schemaId)}`;
}
// ---- Knowledge Base Retrieve (DashScope) ----
+3 -27
View File
@@ -13,8 +13,8 @@ export {
memoryNodePath,
memorySearchPath,
mcpWebSearchPath,
profileSchemaItemPath,
profileSchemaPath,
responsesPath,
speechRecognizePath,
speechSynthesizePath,
taskPath,
@@ -35,18 +35,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";
@@ -70,19 +58,7 @@ export {
type AcsQueryParams,
type AcsSignConfig,
} from "./acs.ts";
export type {
McpTool,
McpToolResult,
McpConnectedClient,
ConnectBailianMcpOptions,
} from "./mcp.ts";
export {
McpClient,
bailianMcpPath,
bailianMcpSsePath,
isStreamableHttpUnsupported,
isUrlOverrideSseFallbackCandidate,
connectBailianMcpWithFallback,
} from "./mcp.ts";
export type { McpTool, McpToolResult } from "./mcp.ts";
export { McpClient, bailianMcpPath } from "./mcp.ts";
export type { ServerSentEvent } from "./stream.ts";
export { parseSSE } from "./stream.ts";
-474
View File
@@ -1,474 +0,0 @@
/**
* MCP classic HTTP+SSE client (protocol 2024-11-05 transport).
*
* Flow: GET /sse → endpoint event → POST JSON-RPC to message URL;
* responses arrive as SSE `message` events matched by JSON-RPC id.
*/
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { HttpDeps } from "./http.ts";
import { trackingHeaders } from "./headers.ts";
import type { McpTool, McpToolResult } from "./mcp.ts";
import { parseSSE } from "./stream.ts";
interface JsonRpcResponse {
jsonrpc: "2.0";
id?: number | string | null;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
type PendingResolver = {
resolve: (value: JsonRpcResponse) => void;
reject: (reason: unknown) => void;
};
/** Match JSON-RPC ids with string keys (number or string echo from server). */
function pendingKey(id: number | string): string {
return String(id);
}
export class McpSseClient {
private sseUrl: string;
private messageUrl: string | undefined;
private nextId = 1;
private deps: HttpDeps;
private authToken: string | undefined;
private abortController: AbortController | undefined;
private pending = new Map<string, PendingResolver>();
private endpointReady: Promise<void>;
private resolveEndpoint: (() => void) | undefined;
private rejectEndpoint: ((reason: unknown) => void) | undefined;
private closed = false;
/** Set when the SSE GET ends without an intentional close(); later RPCs fail fast. */
private streamEnded = false;
constructor(deps: HttpDeps, sseUrl: string, authToken?: string) {
this.deps = deps;
this.sseUrl = sseUrl;
this.authToken = authToken;
this.endpointReady = new Promise<void>((resolve, reject) => {
this.resolveEndpoint = resolve;
this.rejectEndpoint = reject;
});
}
/** Open the SSE session and run initialize / notifications/initialized. */
async initialize(): Promise<void> {
if (!this.authToken) {
throw new BailianError("This command needs a model-domain API key.", ExitCode.AUTH);
}
await this.openSse();
const result = await this.rpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: {
name: this.deps.identity.clientName,
version: this.deps.identity.version,
},
});
if (this.deps.settings.verbose) {
console.error(`[MCP SSE] Session initialized`);
console.error(`[MCP SSE] Server: ${JSON.stringify(result)}`);
}
await this.notify("notifications/initialized");
}
async listTools(): Promise<McpTool[]> {
const result = (await this.rpc("tools/list")) as { tools: McpTool[] };
return result.tools || [];
}
async callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
const result = (await this.rpc("tools/call", { name, arguments: args })) as McpToolResult;
return result;
}
/** Abort the hanging GET /sse so the CLI process can exit. */
close(): void {
if (this.closed) return;
this.closed = true;
this.abortController?.abort();
this.failPending(new BailianError("MCP SSE session closed.", ExitCode.GENERAL));
this.messageUrl = undefined;
}
private failPending(reason: unknown): void {
for (const [, waiter] of this.pending) {
waiter.reject(reason);
}
this.pending.clear();
}
private markStreamEnded(reason: BailianError): void {
this.streamEnded = true;
this.messageUrl = undefined;
this.failPending(reason);
}
private async openSse(): Promise<void> {
if (this.abortController) return;
// One abortController for header/error-body wait; clear timer before the long-lived stream.
this.abortController = new AbortController();
const timeoutMs = this.deps.settings.timeout * 1000;
let headerTimedOut = false;
const headerTimer = setTimeout(() => {
headerTimedOut = true;
this.abortController?.abort();
}, timeoutMs);
const headers: Record<string, string> = {
Accept: "text/event-stream",
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
...trackingHeaders(this.deps.identity),
};
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
if (this.deps.settings.verbose) {
console.error(`> GET ${this.sseUrl}`);
}
let response: Response;
try {
response = await fetch(this.sseUrl, {
method: "GET",
headers,
signal: this.abortController.signal,
});
} catch (error) {
clearTimeout(headerTimer);
// Allow a later initialize() to openSse again on this instance.
this.abortController = undefined;
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (headerTimedOut) {
throw new BailianError("MCP SSE timed out waiting for response headers.", ExitCode.TIMEOUT);
}
// Rethrow fetch failures so runtime can surface errno (e.g. ENOTFOUND) in JSON/text.
throw error;
}
if (this.deps.settings.verbose) {
console.error(`< ${response.status} ${response.statusText}`);
}
if (!response.ok) {
// Keep headerTimer until error body is read (or times out).
let errMsg = `MCP request failed: ${response.status} ${response.statusText}`;
try {
const errBody = await response.text();
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
} catch (error) {
clearTimeout(headerTimer);
this.abortController = undefined;
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (headerTimedOut) {
throw new BailianError(
"MCP SSE timed out reading error response body.",
ExitCode.TIMEOUT,
);
}
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
}
clearTimeout(headerTimer);
this.abortController = undefined;
// Do not rejectEndpoint — openSse never awaits endpointReady on this path.
throw new BailianError(errMsg, ExitCode.GENERAL);
}
clearTimeout(headerTimer);
void this.consumeSse(response).catch((error) => {
if (this.closed) return;
const reason =
error instanceof BailianError
? error
: new BailianError(
`MCP SSE stream failed: ${error instanceof Error ? error.message : String(error)}`,
ExitCode.GENERAL,
);
this.rejectEndpoint?.(reason);
// consumeSse already markStreamEnded on a clean end; cover parse/read failures here.
if (!this.streamEnded) {
this.markStreamEnded(reason);
}
});
const endpointTimeout = cancellableTimeoutReject(
timeoutMs,
"MCP SSE timed out waiting for endpoint event.",
);
try {
await Promise.race([this.endpointReady, endpointTimeout.promise]);
} finally {
endpointTimeout.cancel();
}
}
private async consumeSse(response: Response): Promise<void> {
for await (const event of parseSSE(response)) {
if (this.closed) break;
// Spec requires event: endpoint; ignore unnamed events so JSON is not treated as a URL.
if (event.event === "endpoint") {
const raw = event.data.trim();
if (!raw) continue;
// Only accept same-origin message URLs so we never forward the Bearer token cross-origin.
this.messageUrl = resolveSameOriginMessageUrl(this.sseUrl, raw);
this.resolveEndpoint?.();
this.resolveEndpoint = undefined;
this.rejectEndpoint = undefined;
continue;
}
// Omitted SSE event type defaults to "message".
if (event.event === "message" || event.event === undefined) {
let payload: JsonRpcResponse;
try {
payload = JSON.parse(event.data) as JsonRpcResponse;
} catch {
continue;
}
if (typeof payload.id !== "number" && typeof payload.id !== "string") continue;
const key = pendingKey(payload.id);
const waiter = this.pending.get(key);
if (!waiter) continue;
this.pending.delete(key);
waiter.resolve(payload);
}
}
if (this.closed) return;
if (!this.messageUrl) {
const error = new BailianError(
"MCP SSE stream ended before endpoint event.",
ExitCode.GENERAL,
);
this.rejectEndpoint?.(error);
throw error;
}
// After endpoint: mark dead and wake pending; don't throw (avoid unhandledRejection).
this.markStreamEnded(new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL));
}
private async rpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
const id = this.nextId++;
const key = pendingKey(id);
const body = {
jsonrpc: "2.0" as const,
id,
method,
...(params ? { params } : {}),
};
const timeoutMs = this.deps.settings.timeout * 1000;
const responsePromise = new Promise<JsonRpcResponse>((resolve, reject) => {
this.pending.set(key, { resolve, reject });
});
// Stream may end and reject pending before Promise.race; attach catch to avoid unhandledRejection.
void responsePromise.catch(() => undefined);
const responseTimeout = cancellableTimeoutReject(
timeoutMs,
`MCP SSE timed out waiting for response to ${method}.`,
);
try {
await this.postMessage(body);
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
const data = await Promise.race([responsePromise, responseTimeout.promise]);
if (data.error) {
throw new BailianError(
`MCP error (${data.error.code}): ${data.error.message}`,
ExitCode.GENERAL,
);
}
return data.result;
} catch (error) {
this.pending.delete(key);
throw error;
} finally {
responseTimeout.cancel();
}
}
private async notify(method: string, params?: Record<string, unknown>): Promise<void> {
const body = {
jsonrpc: "2.0" as const,
method,
...(params ? { params } : {}),
};
await this.postMessage(body);
}
private async postMessage(body: unknown): Promise<void> {
if (this.closed || this.streamEnded) {
throw new BailianError("MCP SSE stream ended unexpectedly.", ExitCode.GENERAL);
}
if (!this.messageUrl) {
throw new BailianError("MCP SSE message endpoint is not ready.", ExitCode.GENERAL);
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
...trackingHeaders(this.deps.identity),
};
// Bearer is only sent to a messageUrl that already passed the same-origin check.
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
if (this.deps.settings.verbose) {
console.error(`> POST ${this.messageUrl}`);
console.error(`> Method: ${(body as { method?: string }).method}`);
}
const timeoutMs = this.deps.settings.timeout * 1000;
// Combine per-RPC timeout with session abort so close() cancels in-flight POSTs.
const requestSignal = createLinkedAbortSignal(timeoutMs, this.abortController?.signal);
let res: Response;
try {
try {
res = await fetch(this.messageUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: requestSignal.signal,
});
} catch (error) {
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
throw error;
}
if (this.deps.settings.verbose) {
console.error(`< ${res.status} ${res.statusText}`);
}
if (!res.ok) {
// Keep signal until error body is read (same class of bug as GET openSse).
let errMsg = `MCP request failed: ${res.status} ${res.statusText}`;
try {
const errBody = await res.text();
if (errBody) errMsg += ` - ${errBody.slice(0, 500)}`;
} catch (error) {
if (this.closed) {
throw new BailianError("MCP SSE session closed.", ExitCode.GENERAL);
}
if (requestSignal.timedOut) {
throw new BailianError(
"MCP SSE timed out reading error response body.",
ExitCode.TIMEOUT,
);
}
throw new BailianError(errMsg, ExitCode.GENERAL, undefined, { cause: error });
}
throw new BailianError(errMsg, ExitCode.GENERAL);
}
} finally {
requestSignal.cleanup();
}
}
}
/** Resolve the SSE endpoint data to an absolute URL and require same origin as sseUrl. */
export function resolveSameOriginMessageUrl(sseUrl: string, endpointData: string): string {
let resolved: URL;
let base: URL;
try {
base = new URL(sseUrl);
resolved = new URL(endpointData, sseUrl);
} catch {
throw new BailianError(
`MCP SSE endpoint is not a valid URL: ${endpointData}`,
ExitCode.GENERAL,
);
}
if (resolved.origin !== base.origin) {
throw new BailianError(
`MCP SSE endpoint origin mismatch: expected ${base.origin}, got ${resolved.origin}`,
ExitCode.GENERAL,
);
}
return resolved.toString();
}
/**
* Cancellable timeout rejection: after Promise.race settles, call cancel()
* to clear the timer and avoid unhandledRejection.
*/
function cancellableTimeoutReject(
timeoutMs: number,
message: string,
): { promise: Promise<never>; cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined;
const promise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
timer = undefined;
reject(new BailianError(message, ExitCode.TIMEOUT));
}, timeoutMs);
});
// Swallow late rejects after cancel to avoid unhandledRejection.
void promise.catch(() => undefined);
return {
promise,
cancel: () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
},
};
}
/** Timeout + optional parent abort without AbortSignal.any (Node 18). */
function createLinkedAbortSignal(
timeoutMs: number,
parentSignal?: AbortSignal,
): { signal: AbortSignal; cleanup: () => void; timedOut: boolean } {
const controller = new AbortController();
const state = { timedOut: false };
const timeout = setTimeout(() => {
state.timedOut = true;
controller.abort();
}, timeoutMs);
const abortFromParent = () => controller.abort(parentSignal?.reason);
const cleanup = () => {
clearTimeout(timeout);
parentSignal?.removeEventListener("abort", abortFromParent);
};
if (parentSignal?.aborted) abortFromParent();
else parentSignal?.addEventListener("abort", abortFromParent, { once: true });
controller.signal.addEventListener("abort", cleanup, { once: true });
return {
signal: controller.signal,
cleanup,
get timedOut() {
return state.timedOut;
},
};
}
+2 -137
View File
@@ -15,8 +15,6 @@ import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { HttpDeps } from "./http.ts";
import { trackingHeaders } from "./headers.ts";
import { McpSseClient } from "./mcp-sse.ts";
import { parseSSE } from "./stream.ts";
// ---- JSON-RPC 2.0 Types ----
@@ -29,7 +27,7 @@ interface JsonRpcRequest {
interface JsonRpcResponse {
jsonrpc: "2.0";
id?: number | string | null;
id: number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
@@ -63,101 +61,6 @@ export function bailianMcpPath(serverCode: string): string {
return `/api/v1/mcps/${serverCode}/mcp`;
}
/** Classic SSE path: `/api/v1/mcps/<serverCode>/sse`. */
export function bailianMcpSsePath(serverCode: string): string {
return `/api/v1/mcps/${serverCode}/sse`;
}
/**
* True when Streamable HTTP is unsupported and classic SSE fallback should be tried.
* Anchored to HTTP wrapper text only (not JSON-RPC / nested copies). Bailian 404 excluded.
*/
export function isStreamableHttpUnsupported(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
return /^MCP request failed:\s*405\b/i.test(error.message);
}
/**
* SSE fallback for `--url` (official backwards-compat: same URL, HTTP 405/404 then GET SSE).
*/
export function isUrlOverrideSseFallbackCandidate(error: unknown): boolean {
if (!(error instanceof BailianError)) return false;
return /^MCP request failed:\s*(405|404)\b/i.test(error.message);
}
export type McpConnectedClient = {
initialize(): Promise<void>;
listTools(): Promise<McpTool[]>;
callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult>;
close?(): void;
};
export type ConnectBailianMcpOptions = {
deps: HttpDeps;
authToken: string | undefined;
/** Full Streamable HTTP URL (/mcp). */
httpUrl: string;
/** Full classic SSE URL (/sse). */
sseUrl: string;
serverCode: string;
/**
* Explicit `--url` override: try Streamable on that URL first;
* on 405/404 fall back to classic SSE on the same URL.
*/
urlOverride?: string;
};
/**
* Connect via Streamable HTTP first; on 405 (except WebSearch), fall back to SSE.
* `--url` uses the same URL for Streamable then classic SSE (official backwards-compat).
* For WebSearch, rethrow the original error so commands can attach a re-activate hint.
*/
export async function connectBailianMcpWithFallback(
options: ConnectBailianMcpOptions,
): Promise<{ client: McpConnectedClient; url: string }> {
const { deps, authToken, httpUrl, sseUrl, serverCode, urlOverride } = options;
if (urlOverride) {
const httpClient = new McpClient(deps, urlOverride, authToken);
try {
await httpClient.initialize();
return { client: httpClient, url: urlOverride };
} catch (error) {
if (!isUrlOverrideSseFallbackCandidate(error)) {
throw error;
}
}
const sseClient = new McpSseClient(deps, urlOverride, authToken);
try {
await sseClient.initialize();
return { client: sseClient, url: urlOverride };
} catch (error) {
sseClient.close();
throw error;
}
}
const httpClient = new McpClient(deps, httpUrl, authToken);
try {
await httpClient.initialize();
return { client: httpClient, url: httpUrl };
} catch (error) {
if (!isStreamableHttpUnsupported(error) || serverCode === "WebSearch") {
throw error;
}
}
const sseClient = new McpSseClient(deps, sseUrl, authToken);
try {
await sseClient.initialize();
return { client: sseClient, url: sseUrl };
} catch (error) {
sseClient.close();
throw error;
}
}
// ---- MCP Client ----
export class McpClient {
@@ -218,7 +121,7 @@ export class McpClient {
};
const response = await this.send(body);
const data = await this.readJsonRpcResponse(response, id);
const data = (await response.json()) as JsonRpcResponse;
if (data.error) {
throw new BailianError(
@@ -240,44 +143,6 @@ export class McpClient {
await this.send(body);
}
/**
* Read a JSON-RPC response by Content-Type: application/json or text/event-stream.
*/
private async readJsonRpcResponse(
response: Response,
expectedId: number,
): Promise<JsonRpcResponse> {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
return await this.readJsonRpcFromSse(response, expectedId);
}
return (await response.json()) as JsonRpcResponse;
}
private async readJsonRpcFromSse(
response: Response,
expectedId: number,
): Promise<JsonRpcResponse> {
const expectedKey = String(expectedId);
for await (const event of parseSSE(response)) {
if (event.event && event.event !== "message") continue;
let payload: JsonRpcResponse;
try {
payload = JSON.parse(event.data) as JsonRpcResponse;
} catch {
continue;
}
if (payload.id == null) continue;
if (String(payload.id) !== expectedKey) continue;
return payload;
}
throw new BailianError(
"MCP SSE response stream ended without a matching JSON-RPC response.",
ExitCode.GENERAL,
);
}
private async send(body: unknown): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
+46 -97
View File
@@ -7,71 +7,6 @@ export interface ServerSentEvent {
id?: string;
}
/** Normalize CRLF/CR to LF; hold a trailing `\r` so a split CRLF is not double-broken. */
function takeNormalizedSseLines(buffer: string): { lines: string[]; rest: string } {
let text = buffer;
let holdTrailingCr = false;
if (text.endsWith("\r")) {
holdTrailingCr = true;
text = text.slice(0, -1);
}
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const parts = text.split("\n");
const incomplete = parts.pop() ?? "";
return {
lines: parts,
rest: holdTrailingCr ? `${incomplete}\r` : incomplete,
};
}
function applySseLine(
line: string,
event: Partial<ServerSentEvent>,
maxBuffer: number,
): { event: Partial<ServerSentEvent>; completed?: ServerSentEvent } {
if (line === "") {
if (event.data === undefined) {
return { event: {} };
}
return {
event: {},
completed: { data: event.data, event: event.event, id: event.id },
};
}
if (line.startsWith(":")) {
return { event };
}
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
return { event };
}
const field = line.slice(0, colonIndex);
const fieldValue = line.slice(colonIndex + 1).trimStart();
const nextEvent: Partial<ServerSentEvent> = { ...event };
switch (field) {
case "data":
nextEvent.data =
nextEvent.data !== undefined ? `${nextEvent.data}\n${fieldValue}` : fieldValue;
if (nextEvent.data.length > maxBuffer) {
throw new BailianError("SSE event exceeded the maximum buffer size.", ExitCode.GENERAL);
}
break;
case "event":
nextEvent.event = fieldValue;
break;
case "id":
nextEvent.id = fieldValue;
break;
}
return { event: nextEvent };
}
export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEvent> {
const reader = response.body?.getReader();
if (!reader) return;
@@ -79,55 +14,69 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
const decoder = new TextDecoder();
let buffer = "";
// Guard against a hostile or malfunctioning stream that never emits a newline
// (or builds a single absurdly large event): bound the in-memory buffer so the
// parser cannot be driven to exhaust process memory.
const MAX_SSE_BUFFER = 16 * 1024 * 1024; // 16 MiB
try {
// Keep partial event fields across chunks.
let event: Partial<ServerSentEvent> = {};
while (true) {
const { done, value } = await reader.read();
if (done) {
// EOF: treat any held `\r` as a line ending.
if (buffer.length > 0) {
const finalText = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const parts = finalText.split("\n");
buffer = parts.pop() ?? "";
for (const line of parts) {
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
event = applied.event;
if (applied.completed) {
yield applied.completed;
}
}
}
break;
}
if (done) break;
buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER) {
throw new BailianError("SSE stream exceeded the maximum buffer size.", ExitCode.GENERAL);
}
const { lines, rest } = takeNormalizedSseLines(buffer);
buffer = rest;
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let event: Partial<ServerSentEvent> = {};
for (const line of lines) {
const applied = applySseLine(line, event, MAX_SSE_BUFFER);
event = applied.event;
if (applied.completed) {
yield applied.completed;
if (line === "") {
if (event.data !== undefined) {
yield { data: event.data, event: event.event, id: event.id };
}
event = {};
continue;
}
if (line.startsWith(":")) continue; // comment
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const field = line.slice(0, colonIndex);
const value = line.slice(colonIndex + 1).trimStart();
switch (field) {
case "data":
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
if (event.data.length > MAX_SSE_BUFFER) {
throw new BailianError(
"SSE event exceeded the maximum buffer size.",
ExitCode.GENERAL,
);
}
break;
case "event":
event.event = value;
break;
case "id":
event.id = value;
break;
}
}
}
// Legacy EOF flush: apply trailing field line and dispatch with event/id intact.
if (buffer.length > 0) {
const applied = applySseLine(buffer, event, MAX_SSE_BUFFER);
event = applied.event;
}
if (event.data !== undefined) {
yield { data: event.data, event: event.event, id: event.id };
// Flush remaining
if (buffer.trim() && buffer.includes("data:")) {
const colonIndex = buffer.indexOf(":");
if (colonIndex !== -1) {
yield { data: buffer.slice(colonIndex + 1).trimStart() };
}
}
} finally {
reader.releaseLock();
+9 -1
View File
@@ -199,7 +199,15 @@ export function buildSources(flags: Partial<SourceFlags>): ResolutionSources {
const raw = readRawConfigObject();
const configExplicit = flags.config !== undefined;
const activeConfigName = readStoredActiveConfigName(raw, !configExplicit);
const configName = configExplicit ? normalizeConfigName(flags.config) : activeConfigName;
// Config selection: --config flag > BAILIAN_CONFIG env > persisted active_config.
// The env lets a host (e.g. dsh) pin a named profile for all child `bl`
// calls without rewriting --config or the user's active_config.
const envConfig = process.env.BAILIAN_CONFIG;
const configName = configExplicit
? normalizeConfigName(flags.config)
: envConfig
? normalizeConfigName(envConfig)
: activeConfigName;
return {
flags,
file: parseConfigFile(readRawConfigBlock(raw, configName)),
+1 -1
View File
@@ -58,7 +58,7 @@ export function effectiveConsoleGatewayConfig(
}
export interface ConsoleGatewayRequest {
/** Console API name, e.g. zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota */
/** Console API name, e.g. zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota */
api: string;
data: Record<string, unknown>;
}
+89 -65
View File
@@ -108,44 +108,6 @@ export interface StreamChunk {
};
}
// ---- Responses (OpenAI Compatible) ----
export interface ResponsesRequest {
model: string;
input: ChatMessage[];
max_output_tokens?: number;
temperature?: number;
top_p?: number;
stream?: boolean;
tools?: Array<Record<string, unknown>>;
enable_thinking?: boolean;
}
export interface ResponsesOutputContent {
type: string;
text?: string;
}
export interface ResponsesOutputItem {
type: string;
content?: ResponsesOutputContent[];
[key: string]: unknown;
}
export interface ResponsesResponse {
id: string;
object: "response";
status: string;
output: ResponsesOutputItem[];
[key: string]: unknown;
}
export interface ResponsesStreamEvent {
type: string;
delta?: string;
[key: string]: unknown;
}
// ---- Image (DashScope) ----
export interface DashScopeImageRequest {
@@ -343,11 +305,22 @@ export interface MemoryAddRequest {
custom_content?: string;
profile_schema?: string;
memory_library_id?: string;
project_id?: string;
meta_data?: Record<string, unknown>;
}
/** 变更的记忆片段;`event` 为 ADD / UPDATE / DELETE。 */
export interface MemoryAddNode {
memory_node_id: string;
content: string;
event?: string;
/** 仅 `event` 为 UPDATE 时有效。 */
old_content?: string;
}
export interface MemoryAddResponse {
request_id: string;
memory_ids?: string[];
memory_nodes?: MemoryAddNode[];
}
export interface MemorySearchRequest {
@@ -356,6 +329,17 @@ export interface MemorySearchRequest {
query?: string;
top_k?: number;
memory_library_id?: string;
project_ids?: string[];
min_score?: number;
/**
* 计费档位的**有效**开关。服务端当前忽略单独传入的 `plan_version`,
* 只有 `enable_rerank: false` 才会按 lite 计费(pro 约为 lite 的 50 倍)。
*/
enable_rerank?: boolean;
/** 文档所述的档位字段;当前服务端未按文档生效,与 `enable_rerank` 一起传。 */
plan_version?: "lite" | "pro";
enable_judge?: boolean;
enable_rewrite?: boolean;
}
export interface MemoryNode {
@@ -363,13 +347,19 @@ export interface MemoryNode {
content: string;
user_id?: string;
meta_data?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
project_id?: string;
/** 秒级 Unix 时间戳。 */
created_at?: number;
/** 秒级 Unix 时间戳。 */
updated_at?: number;
timestamp?: number;
}
export interface MemorySearchResponse {
request_id: string;
memory_nodes: MemoryNode[];
/** 本次检索实际计费的档位。 */
billing_plan?: string;
}
export interface MemoryNodeListResponse {
@@ -385,13 +375,18 @@ export interface MemoryNodeUpdateRequest {
custom_content: string;
/** 非默认记忆库时必填(与控制台记忆库 ID 一致) */
memory_library_id?: string;
/** 记忆片段对应事件发生时的秒级 Unix 时间戳。 */
timestamp?: number;
/** 增量更新。 */
meta_data?: Record<string, unknown>;
}
// ---- Memory Profile (DashScope v2) ----
export interface ProfileAttribute {
name: string;
description: string;
description?: string;
default_value?: string;
value?: string;
}
@@ -399,6 +394,8 @@ export interface ProfileSchemaCreateRequest {
name: string;
description?: string;
attributes: ProfileAttribute[];
memory_library_id?: string;
plan_version?: "lite" | "pro";
}
export interface ProfileSchemaCreateResponse {
@@ -406,12 +403,52 @@ export interface ProfileSchemaCreateResponse {
profile_schema_id: string;
}
export interface ProfileSchemaSummary {
profile_schema_id: string;
name: string;
description?: string;
}
export interface ProfileSchemaListResponse {
request_id: string;
profile_schemas: ProfileSchemaSummary[];
total?: number;
}
/** 画像模板详情;`attributes[].attribute_id` 是更新/删除属性时的定位键。 */
export interface ProfileSchemaGetResponse {
request_id: string;
name: string;
description?: string;
attributes: Array<ProfileAttribute & { attribute_id: string }>;
}
export interface ProfileSchemaAttributeOperation {
op: "add" | "update" | "delete";
/** `update` / `delete` 必填。 */
attribute_id?: string;
/** `add` 必填。 */
name?: string;
description?: string;
default_value?: string | null;
}
export interface ProfileSchemaUpdateRequest {
name?: string;
description?: string;
memory_library_id?: string;
attributes_operations?: ProfileSchemaAttributeOperation[];
}
/**
* 用户画像。服务端返回的是模板名称/描述与属性值,不回传 schema_id / user_id。
*/
export interface UserProfileResponse {
request_id: string;
profile: {
schema_id: string;
user_id: string;
attributes: ProfileAttribute[];
schema_name?: string;
schema_description?: string;
attributes: Array<{ id: string; name: string; value?: string }>;
};
}
@@ -571,46 +608,33 @@ export interface DashScopeTTSStreamChunk {
export interface DashScopeASRRequest {
model: string;
input: {
file_urls?: string[];
file_url?: string;
file_urls: string[];
};
parameters?: {
channel_id?: number[];
/** Classic async models (fun-asr / paraformer / qwen-audio filetrans, etc.) */
language_hints?: string[];
/** qwen3-asr-flash-filetrans* uses singular `language` */
language?: string;
diarization_enabled?: boolean;
speaker_count?: number;
vocabulary_id?: string;
};
}
export interface DashScopeASRTranscriptionItem {
file_url?: string;
transcription_url?: string;
subtask_status?: string;
code?: string;
message?: string;
}
export interface DashScopeASRTaskResult {
output: {
task_id: string;
task_status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "UNKNOWN";
/** Multi-file async results (fun-asr / paraformer / qwen-audio filetrans, etc.) */
results?: DashScopeASRTranscriptionItem[];
/** Singular result returned by qwen3-asr-flash-filetrans* on success */
result?: {
results?: Array<{
file_url?: string;
transcription_url?: string;
};
subtask_status?: string;
code?: string;
message?: string;
}>;
task_metrics?: {
TOTAL: number;
SUCCEEDED: number;
FAILED: number;
};
code?: string;
message?: string;
};
usage?: Record<string, unknown>;
request_id: string;
-1
View File
@@ -48,7 +48,6 @@ export type {
ChatTool,
DashScopeASRRequest,
DashScopeASRTaskResult,
DashScopeASRTranscriptionItem,
DashScopeAsyncResponse,
DashScopeImageRequest,
DashScopeImageSyncResponse,
-213
View File
@@ -1,213 +0,0 @@
import { expect, test } from "vite-plus/test";
import {
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
inferAudioFormatHint,
resolveAsrApi,
} from "../src/client/asr-routes.ts";
test("resolveAsrApi routes model families correctly", () => {
const cases = [
{
model: "fun-asr",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_urls",
},
},
{
model: "qwen3-asr-flash-filetrans-2025-11-17",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_url",
asyncLanguageStyle: "language",
},
},
{
model: "qwen-audio-3.0-asr-flash-filetrans",
expected: {
kind: "async-filetrans",
useSync: false,
asyncInputStyle: "file_urls",
asyncLanguageStyle: "language_hints",
},
},
{
model: "qwen3-asr-flash-us",
expected: {
kind: "sync-flash",
useSync: true,
flashFamily: "qwen3",
path: "/api/v1/services/aigc/multimodal-generation/generation",
},
},
{
model: "qwen-audio-3.0-asr-flash",
expected: {
kind: "sync-flash",
useSync: true,
flashFamily: "input-audio",
},
},
{
model: "qwen3-asr-flash-realtime",
expected: {
kind: "unsupported",
},
},
{
model: "foo-asr-flash",
expected: {
kind: "async-filetrans",
useSync: false,
path: "/api/v1/services/audio/asr/transcription",
asyncInputStyle: "file_urls",
},
},
] as const;
for (const { model, expected } of cases) {
const route = resolveAsrApi(model);
expect(route, model).toMatchObject(expected);
if (expected.kind === "unsupported") {
expect(route.unsupportedReason, model).toMatch(/realtime|streaming|WebSocket/i);
}
}
});
test("unknown models default to async-filetrans for backward compatibility", () => {
expect(resolveAsrApi("custom-asr-model")).toMatchObject({
kind: "async-filetrans",
useSync: false,
});
});
test("inferAudioFormatHint reads extension from url", () => {
expect(inferAudioFormatHint("https://example.com/a.mp3")).toBe("mp3");
expect(inferAudioFormatHint("oss://bucket/path/file.WAV")).toBe("wav");
expect(inferAudioFormatHint("https://example.com/a.mpeg?x=1")).toBe("mp3");
expect(inferAudioFormatHint("https://example.com/noext")).toBe("wav");
expect(inferAudioFormatHint("data:audio/mpeg;base64,AAA")).toBe("mp3");
expect(inferAudioFormatHint("data:audio/x-wav;base64,AAA")).toBe("wav");
expect(inferAudioFormatHint("data:audio/ogg;codecs=opus;base64,AAA")).toBe("ogg");
});
test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => {
expect(
buildAsrFlashRequest({
model: "qwen3-asr-flash",
audioUrl: "https://example.com/a.mp3",
language: "en",
flashFamily: "qwen3",
}),
).toEqual({
model: "qwen3-asr-flash",
input: {
messages: [{ role: "user", content: [{ audio: "https://example.com/a.mp3" }] }],
},
parameters: { asr_options: { language: "en" } },
});
expect(
buildAsrFlashRequest({
model: "qwen-audio-3.0-asr-flash",
audioUrl: "https://example.com/a.wav",
language: "en",
vocabularyId: "vocab-abc",
flashFamily: "input-audio",
}),
).toEqual({
model: "qwen-audio-3.0-asr-flash",
input: {
messages: [
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
parameters: {
format: "wav",
sample_rate: "16000",
language_hints: ["en"],
vocabulary_id: "vocab-abc",
},
});
});
test("buildAsyncAsrLanguageFields maps language by async style", () => {
expect(buildAsyncAsrLanguageFields("language_hints", "zh")).toEqual({
language_hints: ["zh"],
});
expect(buildAsyncAsrLanguageFields("language", "zh")).toEqual({ language: "zh" });
expect(buildAsyncAsrLanguageFields("language", undefined)).toEqual({});
});
test("extractAsrFlashText reads qwen3 choices and input-audio text fields", () => {
expect(
extractAsrFlashText(
{
output: {
choices: [{ message: { content: [{ text: "你好" }] } }],
},
},
"qwen3",
),
).toBe("你好");
expect(
extractAsrFlashText(
{
output: {
text: "Hello World",
output: { sentence: { text: "ignored when text present" } },
},
},
"input-audio",
),
).toBe("Hello World");
expect(
extractAsrFlashText(
{
output: {
sentence: { text: "top-level sentence" },
},
},
"input-audio",
),
).toBe("top-level sentence");
expect(
extractAsrFlashText(
{
output: {
output: { sentence: { text: "nested sentence" } },
},
},
"input-audio",
),
).toBe("nested sentence");
});
test("collectAsrTranscriptionItems prefers results[] then singular result", () => {
expect(
collectAsrTranscriptionItems({
results: [{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }],
}),
).toEqual([{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }]);
expect(
collectAsrTranscriptionItems({
result: { transcription_url: "https://example.com/qwen3.json" },
}),
).toEqual([{ transcription_url: "https://example.com/qwen3.json", subtask_status: "SUCCEEDED" }]);
expect(collectAsrTranscriptionItems({})).toEqual([]);
});
-736
View File
@@ -1,736 +0,0 @@
import { expect, test } from "vite-plus/test";
import type { Identity, Settings } from "../src/index.ts";
import {
BailianError,
bailianMcpPath,
bailianMcpSsePath,
connectBailianMcpWithFallback,
isStreamableHttpUnsupported,
isUrlOverrideSseFallbackCandidate,
McpClient,
} from "../src/index.ts";
import { McpSseClient, resolveSameOriginMessageUrl } from "../src/client/mcp-sse.ts";
function testDeps(overrides?: Partial<Settings>): { identity: Identity; settings: Settings } {
return {
identity: {
binName: "bl",
version: "0.0.0-test",
npmPackage: "bailian-cli",
clientName: "bailian-cli",
},
settings: {
output: "json",
outputExplicit: true,
timeout: 5,
verbose: false,
quiet: true,
dryRun: false,
telemetry: true,
...overrides,
},
};
}
function jsonRpcResult(id: number | string, result: unknown): string {
return `event:message\ndata:${JSON.stringify({ jsonrpc: "2.0", id, result })}\n\n`;
}
function requestUrl(input: string | URL | Request): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
test("bailianMcp 路径与 isStreamableHttpUnsupported", () => {
expect(bailianMcpPath("WebParser")).toBe("/api/v1/mcps/WebParser/mcp");
expect(bailianMcpSsePath("WebParser")).toBe("/api/v1/mcps/WebParser/sse");
expect(
isStreamableHttpUnsupported(
new BailianError(
"MCP request failed: 405 Method Not Allowed - current mcp not support streamableHttp",
),
),
).toBe(true);
expect(
isStreamableHttpUnsupported(new BailianError("MCP request failed: 405 Method Not Allowed")),
).toBe(true);
expect(isStreamableHttpUnsupported(new BailianError("MCP request failed: 404 Not Found"))).toBe(
false,
);
expect(isStreamableHttpUnsupported(new Error("405 streamableHttp"))).toBe(false);
// JSON-RPC business 405 must not trigger HTTP transport fallback
expect(isStreamableHttpUnsupported(new BailianError("MCP error (405): Method Not Allowed"))).toBe(
false,
);
// Nested wrapper phrase in a JSON-RPC message must not trigger fallback.
expect(
isStreamableHttpUnsupported(
new BailianError("MCP error (-32000): MCP request failed: 405 Method Not Allowed"),
),
).toBe(false);
expect(
isUrlOverrideSseFallbackCandidate(new BailianError("MCP request failed: 404 Not Found")),
).toBe(true);
expect(
isUrlOverrideSseFallbackCandidate(
new BailianError("MCP request failed: 405 Method Not Allowed"),
),
).toBe(true);
expect(isUrlOverrideSseFallbackCandidate(new BailianError("MCP error (404): not found"))).toBe(
false,
);
expect(
isUrlOverrideSseFallbackCandidate(
new BailianError("MCP error (-32000): MCP request failed: 404 Not Found"),
),
).toBe(false);
});
test("resolveSameOriginMessageUrl同源通过、跨域拒绝", () => {
expect(
resolveSameOriginMessageUrl(
"https://example.test/api/v1/mcps/WebParser/sse",
"/api/v1/mcps/WebParser/message?sessionId=x",
),
).toBe("https://example.test/api/v1/mcps/WebParser/message?sessionId=x");
expect(() =>
resolveSameOriginMessageUrl(
"https://example.test/api/v1/mcps/WebParser/sse",
"https://evil.example/steal",
),
).toThrow(/origin mismatch/i);
});
test("connectBailianMcpWithFallback成功走 Streamable405 降级 SSE", async () => {
const originalFetch = globalThis.fetch;
// Streamable success path
globalThis.fetch = async (input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
if (requestUrl(input).includes("/sse")) {
return new Response("should not hit sse", { status: 500 });
}
if (body.method === "notifications/initialized") {
return new Response(null, { status: 200 });
}
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: {} }), {
status: 200,
});
};
try {
const connected = await connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
serverCode: "WebParser",
});
expect(connected.url).toContain("/mcp");
} finally {
globalThis.fetch = originalFetch;
}
// Bare HTTP 405 (no streamableHttp body text) → SSE
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
const urls: string[] = [];
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
urls.push(`${init?.method ?? "GET"} ${url}`);
if (url.endsWith("/mcp")) {
return new Response("Method Not Allowed", {
status: 405,
statusText: "Method Not Allowed",
});
}
if (url.endsWith("/sse") && (init?.method ?? "GET") === "GET") {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
sseController = controller;
controller.enqueue(
encoder.encode(
"event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=test-session\n\n",
),
);
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
if (url.includes("/message")) {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
queueMicrotask(() => {
if (body.id != null && sseController) {
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
}
});
return new Response(null, { status: 200 });
}
return new Response("unexpected", { status: 500 });
};
try {
const connected = await connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
serverCode: "WebParser",
});
expect(connected.url).toContain("/sse");
expect(urls.some((entry) => entry.includes("GET ") && entry.includes("/sse"))).toBe(true);
connected.client.close?.();
} finally {
globalThis.fetch = originalFetch;
}
});
test("connectBailianMcpWithFallbackWebSearch 不降级urlOverride 同 URL 降级 SSE404 不降级 Bailian 路径", async () => {
const originalFetch = globalThis.fetch;
const urls: string[] = [];
globalThis.fetch = async (input) => {
urls.push(requestUrl(input));
return new Response("current mcp not support streamableHttp", {
status: 405,
statusText: "Method Not Allowed",
});
};
try {
await expect(
connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebSearch/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebSearch/sse",
serverCode: "WebSearch",
}),
).rejects.toBeInstanceOf(BailianError);
expect(urls.some((url) => url.includes("/sse"))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
// urlOverride: after POST 405, fall back with GET SSE on the same URL
urls.length = 0;
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
const overrideUrl = "https://custom.example/mcp";
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
const method = init?.method ?? "GET";
urls.push(`${method} ${url}`);
if (method === "POST" && url === overrideUrl) {
return new Response("Method Not Allowed", {
status: 405,
statusText: "Method Not Allowed",
});
}
if (method === "GET" && url === overrideUrl) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
sseController = controller;
controller.enqueue(encoder.encode("event:endpoint\ndata:/message?sessionId=x\n\n"));
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
if (url.includes("/message")) {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
queueMicrotask(() => {
if (body.id != null && sseController) {
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
}
});
return new Response(null, { status: 200 });
}
return new Response("unexpected", { status: 500 });
};
try {
const connected = await connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
serverCode: "WebParser",
urlOverride: overrideUrl,
});
expect(connected.url).toBe(overrideUrl);
expect(urls.some((entry) => entry.startsWith(`GET ${overrideUrl}`))).toBe(true);
connected.client.close?.();
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = async () =>
new Response("MCP不存在或未开通", { status: 404, statusText: "Not Found" });
try {
await expect(
connectBailianMcpWithFallback({
deps: testDeps(),
authToken: "sk-test",
httpUrl: "https://example.test/api/v1/mcps/WebParser/mcp",
sseUrl: "https://example.test/api/v1/mcps/WebParser/sse",
serverCode: "WebParser",
}),
).rejects.toMatchObject({ message: expect.stringContaining("404") });
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClient流结束后立刻失败 pending不干等到 timeout", async () => {
const originalFetch = globalThis.fetch;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
if ((init?.method ?? "GET") === "GET" || url.endsWith("/sse")) {
// Close the stream immediately after the endpoint event
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode("event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n"),
);
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
return new Response(null, { status: 200 });
};
try {
const client = new McpSseClient(
testDeps({ timeout: 5 }),
"https://example.test/sse",
"sk-test",
);
const started = Date.now();
await expect(client.initialize()).rejects.toThrow(/stream ended unexpectedly/i);
expect(Date.now() - started).toBeLessThan(2000);
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientstring JSON-RPC id 可匹配;仅认 event:endpoint", async () => {
const originalFetch = globalThis.fetch;
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
if ((init?.method ?? "GET") === "GET" || url.endsWith("/sse")) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
sseController = controller;
// Untyped events must not be treated as endpoint
controller.enqueue(
encoder.encode(`data:${JSON.stringify({ jsonrpc: "2.0", id: 99, result: {} })}\n\n`),
);
controller.enqueue(
encoder.encode("event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n"),
);
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
if (url.includes("/message")) {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
queueMicrotask(() => {
if (body.id != null && sseController) {
// Echo id as a string
sseController.enqueue(encoder.encode(jsonRpcResult(String(body.id), {})));
}
});
return new Response(null, { status: 200 });
}
return new Response("unexpected", { status: 500 });
};
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
await client.initialize();
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpClient支持 text/event-stream 响应体", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
if (body.method === "notifications/initialized") {
return new Response(null, { status: 202 });
}
const sse = `event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: {
protocolVersion: "2025-03-26",
capabilities: {},
serverInfo: { name: "x", version: "0" },
},
})}\n\n`;
return new Response(sse, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
};
try {
const client = new McpClient(testDeps(), "https://example.test/mcp", "sk-test");
await client.initialize();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClient.close 可中止挂起 GET", async () => {
const originalFetch = globalThis.fetch;
let aborted = false;
globalThis.fetch = async (_input, init) => {
const signal = init?.signal;
if (signal) {
signal.addEventListener("abort", () => {
aborted = true;
});
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
"event:endpoint\ndata:/api/v1/mcps/WebParser/message?sessionId=x\n\n",
),
);
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
};
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
const initPromise = client.initialize().catch(() => undefined);
await new Promise((resolve) => setTimeout(resolve, 20));
client.close();
await initPromise;
expect(aborted).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClient等待响应头受 --timeout 约束", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
const signal = init?.signal;
return new Promise((_resolve, reject) => {
if (!signal) {
reject(new Error("missing signal"));
return;
}
if (signal.aborted) {
reject(new DOMException("This operation was aborted.", "AbortError"));
return;
}
signal.addEventListener(
"abort",
() => reject(new DOMException("This operation was aborted.", "AbortError")),
{ once: true },
);
});
};
try {
const client = new McpSseClient(
testDeps({ timeout: 1 }),
"https://example.test/sse",
"sk-test",
);
const started = Date.now();
await expect(client.initialize()).rejects.toThrow(/timed out waiting for response headers/i);
expect(Date.now() - started).toBeLessThan(2500);
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClient非 2xx 不产生 unhandledRejection", async () => {
const originalFetch = globalThis.fetch;
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
globalThis.fetch = async () =>
new Response("boom", { status: 500, statusText: "Internal Server Error" });
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
await expect(client.initialize()).rejects.toThrow(/MCP request failed:\s*500/i);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(unhandled).toEqual([]);
client.close();
} finally {
process.off("unhandledRejection", onUnhandled);
globalThis.fetch = originalFetch;
}
});
test("McpSseClient非 2xx 读 body 仍受 --timeout 约束", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
const signal = init?.signal;
return {
ok: false,
status: 500,
statusText: "Internal Server Error",
async text() {
return new Promise<string>((_resolve, reject) => {
if (!signal) {
reject(new Error("missing signal"));
return;
}
if (signal.aborted) {
reject(new DOMException("This operation was aborted.", "AbortError"));
return;
}
signal.addEventListener(
"abort",
() => reject(new DOMException("This operation was aborted.", "AbortError")),
{ once: true },
);
});
},
} as Response;
};
try {
const client = new McpSseClient(
testDeps({ timeout: 1 }),
"https://example.test/sse",
"sk-test",
);
const started = Date.now();
await expect(client.initialize()).rejects.toThrow(/timed out reading error response body/i);
expect(Date.now() - started).toBeLessThan(2500);
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientfetch 失败抛出原始 TypeError保留 ENOTFOUND", async () => {
const originalFetch = globalThis.fetch;
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.test"), {
code: "ENOTFOUND",
});
const fetchFailed = new TypeError("fetch failed", { cause: root });
globalThis.fetch = async () => {
throw fetchFailed;
};
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
const error = await client.initialize().catch((reason: unknown) => reason);
expect(error).toBe(fetchFailed);
expect((error as TypeError & { cause?: NodeJS.ErrnoException }).cause?.code).toBe("ENOTFOUND");
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientfetch 失败后同实例可重新 openSse", async () => {
const originalFetch = globalThis.fetch;
let attempt = 0;
let sseController: ReadableStreamDefaultController<Uint8Array> | undefined;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
const method = init?.method ?? "GET";
if (method === "GET" || url.endsWith("/sse")) {
attempt += 1;
if (attempt === 1) {
throw new TypeError("fetch failed");
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
sseController = controller;
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
queueMicrotask(() => {
if (body.id != null && sseController) {
sseController.enqueue(encoder.encode(jsonRpcResult(body.id, {})));
}
});
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
};
try {
const client = new McpSseClient(testDeps(), "https://example.test/sse", "sk-test");
await expect(client.initialize()).rejects.toThrow(/fetch failed/i);
await client.initialize();
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientclose 可中止进行中的 POST", async () => {
const originalFetch = globalThis.fetch;
let postAborted = false;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
const method = init?.method ?? "GET";
if (method === "GET" || url.endsWith("/sse")) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
const signal = init?.signal;
return new Promise((_resolve, reject) => {
if (!signal) {
reject(new Error("missing signal"));
return;
}
const onAbort = () => {
postAborted = true;
reject(new DOMException("This operation was aborted.", "AbortError"));
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
});
};
try {
const client = new McpSseClient(
testDeps({ timeout: 5 }),
"https://example.test/sse",
"sk-test",
);
const initPromise = client.initialize();
await new Promise((resolve) => setTimeout(resolve, 30));
client.close();
await expect(initPromise).rejects.toThrow(/session closed|aborted/i);
expect(postAborted).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
test("McpSseClientPOST 非 2xx 读 body 仍受 --timeout 约束", async () => {
const originalFetch = globalThis.fetch;
const encoder = new TextEncoder();
globalThis.fetch = async (input, init) => {
const url = requestUrl(input);
const method = init?.method ?? "GET";
if (method === "GET" || url.endsWith("/sse")) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("event: endpoint\ndata: /message\n\n"));
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
const signal = init?.signal;
const body = new ReadableStream<Uint8Array>({
start(controller) {
if (!signal) return;
const onAbort = () => {
try {
controller.error(new DOMException("This operation was aborted.", "AbortError"));
} catch {
/* ignore */
}
};
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
},
});
return new Response(body, { status: 500, statusText: "Internal Server Error" });
};
try {
const client = new McpSseClient(
testDeps({ timeout: 1 }),
"https://example.test/sse",
"sk-test",
);
const started = Date.now();
await expect(client.initialize()).rejects.toThrow(/timed out reading error response body/i);
expect(Date.now() - started).toBeLessThan(2500);
client.close();
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -1,8 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { responsesPath } from "../src/index.ts";
describe("Responses API", () => {
test("uses the OpenAI-compatible Responses endpoint", () => {
expect(responsesPath()).toBe("/compatible-mode/v1/responses");
});
});
-79
View File
@@ -1,79 +0,0 @@
import { expect, test } from "vite-plus/test";
import { parseSSE } from "../src/client/stream.ts";
async function collectEvents(
chunks: string[],
): Promise<Array<{ data: string; event?: string; id?: string }>> {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
const response = new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
const events: Array<{ data: string; event?: string; id?: string }> = [];
for await (const event of parseSSE(response)) {
events.push(event);
}
return events;
}
test("parseSSE单 chunk 完整事件保持原行为", async () => {
const events = await collectEvents([
'event: message\ndata: {"ok":true}\nid: 1\n\ndata: plain\n\n',
]);
expect(events).toEqual([{ data: '{"ok":true}', event: "message", id: "1" }, { data: "plain" }]);
});
test("parseSSE多行 data 与注释保持原行为", async () => {
const events = await collectEvents([": keep-alive\ndata: line1\ndata: line2\n\n"]);
expect(events).toEqual([{ data: "line1\nline2" }]);
});
test("parseSSE跨 chunk 保留 event 类型", async () => {
const events = await collectEvents(["event: endpoint\n", "data: /message?sessionId=abc\n\n"]);
expect(events).toEqual([{ data: "/message?sessionId=abc", event: "endpoint" }]);
});
test("parseSSE跨 chunk 保留 id且多事件连续正确", async () => {
const events = await collectEvents([
"id: a\nevent: message\n",
'data: {"n":1}\n\n',
"event: message\ndata: ",
'{"n":2}\n\n',
]);
expect(events).toEqual([
{ data: '{"n":1}', event: "message", id: "a" },
{ data: '{"n":2}', event: "message" },
]);
});
test("parseSSECRLF 行尾可解析 endpoint", async () => {
const events = await collectEvents(["event: endpoint\r\ndata: /message\r\n\r\n"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSE纯 CR 行尾可解析 endpoint", async () => {
const events = await collectEvents(["event: endpoint\rdata: /message\r\r"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSE跨 chunk 的 CRLF\\r|\\n不丢事件", async () => {
const events = await collectEvents(["event: endpoint\r", "\ndata: /message\r\n\r\n"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSEEOF without blank line keeps event type", async () => {
const events = await collectEvents(["event: endpoint\ndata: /message"]);
expect(events).toEqual([{ data: "/message", event: "endpoint" }]);
});
test("parseSSEEOF data-only flush keeps prior behavior", async () => {
const events = await collectEvents(["data: plain"]);
expect(events).toEqual([{ data: "plain" }]);
});
+4
View File
@@ -0,0 +1,4 @@
# build artifacts (regenerated by `pnpm build`)
client.bundle.js
dist/
*.tgz
+232
View File
@@ -0,0 +1,232 @@
# bailian-cli-dsh
把阿里云百炼Model Studio的能力接入 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)`dsh`)的 profile bundle。
本包提供两项能力:
| 能力 | 说明 |
| ------------------ | --------------------------------------------------------------------------------------------------------------------- |
| **Bailian 设置页** | 通用的百炼凭证配置AK/SK 存入 `dsh` bl profile + DashScope API Key+ TokenPlan 用量展示 + 记忆库配置 + 新会话欢迎页 |
| **跨会话长期记忆** | 自动检索注入 + 自动落库,模型可主动 search/add/list。按量计费默认停用 |
---
## 1. 前置条件
- Node ≥ 22.19`dsh` 的要求)
- `bl`(用量展示通过子进程调用 `bl console call`
```sh
npm install -g bailian-cli
```
- **阿里云 AK/SK**AccessKey ID + AccessKey Secret—— 用于控制台鉴权,查询用量信息。在 webui 设置页填入即可,无需环境变量。
- **DashScope API Key**`sk-` 前缀,按量付费)—— 用于记忆库等 DashScope API 调用。在设置页「凭证配置」填入,与 AK/SK 并列为通用凭证。
获取方式:[阿里云控制台 → AccessKey 管理](https://ram.console.aliyun.com/manage/ak)
---
## 2. 安装到 `web` profile
`npx @deepseek-ai/dsh web` 是 `dsh --profile web` 的别名,配置目录是 `~/.dsh/profiles/web/`。
```sh
pnpm -F bailian-cli-dsh build # vp packhost+ esbuildclient.bundle.js
cd packages/dsh && pnpm pack
npx @deepseek-ai/dsh plugin --profile web add /absolute/path/to/bailian-cli-dsh-<version>.tgz
```
确认 bailian 行都在:
```sh
npx @deepseek-ai/dsh --profile web --dump-config | grep -E 'bailian'
```
启动:
```sh
npx @deepseek-ai/dsh web
```
Web UI 在 http://127.0.0.1:3080。
---
## 3. Bailian 设置页 + 欢迎页
安装并重启后:
- **Settings → Bailian**:通用设置页(凭证配置 / TokenPlan 用量 / 记忆库)。
- **新会话欢迎页**每个新会话blank在输入框上方显示「百炼 Agent」欢迎页Tab + 功能卡片),发出第一条消息后自动隐藏。
### 凭证配置(通用)
1. 在「凭证配置」区填入 **AccessKey ID** 和 **AccessKey Secret**
2. 点击 **「保存凭证」**
Host 会执行 `bl auth login --open-api --config dsh`,将 AK/SK 和新生成的 access_token 存入 bl 的 `dsh` 专属 profile。**所有后续百炼插件共用此凭证**,无需重复配置。
### TokenPlan 用量
1. 选择区域和站点
2. 点击 **「查询用量」**
Host 执行 `bl console call --config dsh` 调用 3 个个人版控制台接口,返回:
- **用量百分比** —— 5 小时窗口 / 1 周窗口的用量百分比和重置时间
- **套餐信息** —— 套餐类型(基础版/标准版/高级版)、状态、剩余天数、到期时间、自动续费
- **额外用量包** —— Credits 总量、剩余量、生效中数量
### 凭证解析优先级
凭证保存到 bl 的 `dsh` profile 后,所有百炼插件通过 `--config dsh` 读取。行内 config 的 `accessKeyId`/`accessKeySecret` 作为兜底(未通过 UI 保存时自动使用)。
### 行内配置(可选)
如果不想在 UI 里每次输入,可以在 profile 的 `cordis.patch.yml` 里固化凭证:
```yaml
- id: bailian-tokenplan-usage
config:
# accessKeyId / accessKeySecret: 兜底凭证(未通过 UI 保存时使用)
# consoleRegion: cn-beijing
# consoleSite: domestic
# profile: dsh # 默认用 dsh 专属 profile
```
配置后 UI 表单会留空,但点击「查询用量」会使用行内凭证。
---
## 4. 跨会话长期记忆
默认停用(按量计费)。在 `cordis.patch.yml` 中设 `disabled: false` 启用,然后在设置页配置 API Key 和参数。
### 功能
- **自动检索注入**:新会话首轮,用用户消息搜索记忆,将结果注入上下文(`autoInject`,默认开启)
- **自动落库**:每轮结束,将该轮新消息发送到记忆库 add API`autoPersist`,默认开启)
- **模型工具**`bailian_memory_search`(检索)、`bailian_memory_add`(存储)、`bailian_memory_list`(浏览)
### 触发机制
| 时机 | 触发方式 |
| ---------- | --------------------------------------------------------- |
| 新会话首轮 | 自动检索记忆注入上下文(`agent/pre-step` 事件) |
| 对话中 | 模型主动调用 `bailian_memory_search`/`bailian_memory_add` |
| 轮次结束 | 自动落库新消息(`agent/turn-stopping` 事件) |
### 凭证与配置
- **API Key**DashScope 按量付费 Key`sk-`),在设置页「凭证配置」填入
- **Base URL**:默认 `https://dashscope.aliyuncs.com/api/v2/apps/memory/`
- **User ID**:记忆归属 ID默认读系统用户名
- **Plan Version**`lite`(便宜,关闭 rerank或 `pro`(开启 rerank约 50 倍成本)。注意:实际计费由 `enable_rerank` 控制
- **Top K**检索返回数量1-100默认 10
- **Memory Library ID**:记忆库 ID留空用默认
### 计费
- Add120 QPM
- Search300 QPMLite ¥0.00002/次Pro ¥0.001/次)
- 总计不超过 3000 QPM
### 启用
```yaml
- id: bailian-memory
disabled: false
config:
baseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/memory/"
planVersion: "lite"
topK: 10
autoInject: true
autoPersist: true
```
启用后在设置页「记忆库」section 配置 API Key 和参数。
> 记忆库调用 DashScope memory v2 API非 `bl memory`),因为 v2 API 暴露了 `min_score`、`enable_rerank`、`plan_version`、`memory_library_id` 等参数 `bl memory` 不支持。
## 5. 验证
```sh
# 配置合成
npx @deepseek-ai/dsh --profile web --dump-config | grep bailian
# bl 就绪
bl auth status
```
启动后验证:
- **欢迎页**:新开一个会话,输入框上方出现「百炼 Agent」欢迎页
- **凭证配置**:打开 Settings → Bailian → 填入 AK/SK → 保存凭证
- **用量展示**:同页面选择区域 → 查询用量
- **记忆库**:启用 `bailian-memory` 后,同页面配置 API Key
---
## 6. 常见问题
| 现象 | 原因 |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 用量查询报 `bl auth login failed` | AK/SK 无效或无权限;确认 AK 有百炼控制台访问权限 |
| 用量查询报 `NotLogined` 或 token 过期 | bl 的 access token 已过期Host 会自动通过 AK/SK 刷新,确认 AK/SK 正确 |
| 用量查询报 `bl console call failed` | 控制台接口调用失败;检查 region/site 是否匹配你的账号 |
| 用量查询报 `Workspace.NotAuthorised` | bl 用了其他 profile 的旧 access_tokenHost 默认用 `--config dsh` 专属 profile 隔离,首次 login 会生成新 token |
| 工具报找不到 `bl` | `bl` 不在 PATH`npm install -g bailian-cli` |
| 设置页/欢迎页看不到 Bailian | 需**重启 `dsh web`**bundle 在启动时加载);确认 `dump-config` 有 `bailian-client` 行,且 `client.bundle.js` 为 ModuleLoader 格式 |
| 启动报 `invalid plugin ... apply` | 包根 `dist/index.mjs` 必须导出 `apply`no-op 插件);重新 `pnpm build` 再装 |
---
## 7. 卸载
```sh
npx @deepseek-ai/dsh plugin --profile web remove bailian-cli-dsh
```
---
## 架构说明
### Host 半
- `src/tokenplan-usage/index.ts` —— 凭证 + TokenPlan 用量。`inject: ['subprocess']`,所有 bl 命令带 `--config dsh` 隔离凭证。两个 webServer 路由:
- `POST /bailian/credentials` — 保存 AK/SK`bl auth login --open-api --config dsh`,生成新 token
- `POST /bailian/tokenplan/usage` — 查询用量(`bl console call --config dsh`3 个个人版接口)
- `src/memory/index.ts` —— 记忆库(默认停用)。直接调 DashScope memory v2 API注册 tools + auto-inject/persist。路由 `/bailian/memory/config`、`/bailian/memory/status`。
- `src/index.ts` —— 包根 no-op 插件,供 `bailian-client` 行加载(该行只为了让 client-modules 服务浏览器 bundle
> 路由用 `/bailian/*` 而非 `/api/*``/api` 前缀被 dsh 的 RPC 网关apiProxy占用自定义路由会被遮蔽。
调用链路:**AK/SK → `bl auth login --open-api --config dsh`(存入 dsh profile→ `bl console call --config dsh`(读 dsh profile token → 控制台网关)→ 个人版 TokenPlan 接口**
### Client 半(`src/client.ts`
- 唯一的浏览器源码,构建为 DSH ModuleLoader 格式(见下)。
- 注册 `settings.section`id: `bailian`label: `Bailian`),渲染通用百炼设置页(凭证配置 / TokenPlan 用量 / 记忆库)。
- 注册 `conversation.input.dock`id: `bailian-welcome`):当 `session.blank === true`(新会话)渲染「百炼 Agent」欢迎页Tab + 功能卡片),开始对话后自动隐藏。
- 通过 `fetch('/bailian/*')` 调 Host 路由。
### Client 构建ModuleLoader 格式)
DSH 浏览器只加载 `window.__ModuleLoader__.load({ id, factory })` 格式的 bundle`require('react')` 由浏览器 ModuleLoader 提供。vite-plus 产出裸 ES module格式不对所以 client 单独用 esbuild 构建:
- `scripts/build-client.mjs` —— 把 `src/client.ts` 构建为 CJS + browser + `react` external包上 ModuleLoader banner/footer输出 `client.bundle.js`。
- `package.json` 的 `build` = `vp pack && node scripts/build-client.mjs`。
- `package.json` 的 `exports["./client"]` 与 `dsh.client: { platform: "web" }` 指向 `client.bundle.js`,被 client-modules 扫描并服务。
- `cordis.patch.yml` 的 `bailian-client` 行 `name` 必须是**包根**`bailian-cli-dsh`无子路径client-modules 才能 `require.resolve("<name>/package.json")` 识别 `dsh.client`。
改 client UI 只需编辑 `src/client.ts``pnpm build` 自动重新生成 `client.bundle.js`。
### 共享模块(`src/shared/`
- `bl.ts` —— `bl` 子进程调用封装env 转发、stdout/stderr 收集、JSON 解析)
- `credentials.ts` —— TokenPlan / 按量付费 Key 分类工具
- `http.ts` —— DashScope HTTP 客户端
这些模块来自早期版本vision / image / managed-agent / RAG / memory 工具),已移除工具实现但保留共享逻辑作为参考。
+53
View File
@@ -0,0 +1,53 @@
# bailian-cli-dsh — Aliyun Model Studio (Bailian) as a dsh profile bundle.
#
# Inserts Bailian plugin rows: TokenPlan usage display + cross-session memory.
# Every inserted id is `bailian-`-prefixed so a user profile can address,
# reconfigure, or disable any single capability without touching the others.
# Remember that a later patch REPLACES a row's whole `config` rather than
# merging into it, so restate the complete config when overriding.
- insert:
# Client-only row: name is the package ROOT (no subpath) so client-modules
# can resolve "<name>/package.json" and detect the dsh.client declaration.
# Its node half (dist/index.mjs) is a no-op; the row exists to serve the
# browser bundle (client.bundle.js) that renders the Bailian settings page
# and the new-session welcome page.
- id: bailian-client
name: bailian-cli-dsh
# TokenPlan usage display (dual-face: Host provides two webServer routes,
# Client renders a general "Bailian" settings.section page). All bl commands
# use `--config dsh` to isolate credentials in a dedicated bl profile.
#
# Two routes:
# POST /api/bailian/credentials — saves AK/SK to dsh profile
# (bl auth login --open-api --config dsh). Generates fresh access_token.
# POST /api/bailian/tokenplan/usage — fetches personal-edition usage
# using the dsh profile (no AK/SK in body; credentials already saved).
#
# Users configure AK/SK once on the settings page; all future Bailian
# plugins reuse the same dsh profile credentials.
#
# Config fields:
# accessKeyId / accessKeySecret: fallback when not provided via UI.
# consoleRegion: default region (cn-beijing).
# consoleSite: domestic | international (default: domestic).
# profile: bl config profile name (default: dsh).
- id: bailian-tokenplan-usage
name: bailian-cli-dsh/tokenplan-usage
config: {}
# Disabled by default: memory add/search are billed per call. Enable in
# the profile patch and configure API Key + parameters on the Bailian
# settings page. Calls DashScope memory v2 API directly (not bl memory)
# for full parameter control (min_score, enable_rerank, plan_version,
# memory_library_id, enable_judge, enable_rewrite).
- id: bailian-memory
name: bailian-cli-dsh/memory
disabled: true
config:
baseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/memory/"
planVersion: "lite"
topK: 10
autoInject: true
autoPersist: true
+102
View File
@@ -0,0 +1,102 @@
{
"name": "bailian-cli-dsh",
"version": "1.14.2",
"description": "Aliyun Model Studio (Bailian) plugin bundle for DeepSeek Harness (dsh): TokenPlan LLM provider and personal-edition TokenPlan usage display in the webui.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
"url": "https://github.com/modelstudioai/cli/issues"
},
"license": "Apache-2.0",
"author": "Aliyun Model Studio",
"repository": {
"type": "git",
"url": "git+https://github.com/modelstudioai/cli.git",
"directory": "packages/dsh"
},
"files": [
"README.md",
"dist",
"client.bundle.js",
"cordis.patch.yml"
],
"type": "module",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./tokenplan-usage": {
"types": "./src/tokenplan-usage/index.ts",
"default": "./dist/tokenplan-usage/index.mjs"
},
"./memory": {
"types": "./src/memory/index.ts",
"default": "./dist/memory/index.mjs"
},
"./client": "./client.bundle.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": "./dist/index.mjs",
"./tokenplan-usage": "./dist/tokenplan-usage/index.mjs",
"./memory": "./dist/memory/index.mjs",
"./client": "./client.bundle.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "vp pack && node scripts/build-client.mjs",
"dev": "vp pack --watch",
"test": "vp test",
"check": "vp check"
},
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
"@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/dsh-web": "^0.1.0-rc.6",
"@types/node": "catalog:",
"typescript": "^6.0.2",
"vite-plus": "catalog:"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
"@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/dsh-web": "^0.1.0-rc.6"
},
"engines": {
"node": ">=22.19.0"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web"
}
}
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Build the browser client bundle in the DSH ModuleLoader closure format.
*
* The DSH web shell only loads client plugins that call
* `window.__ModuleLoader__.load({ id, factory })`, resolving externals (react)
* through the injected `require`. vite-plus emits plain ESM (wrong format), so
* the client is built separately with esbuild: CJS + browser platform + react
* external, wrapped in the ModuleLoader banner/footer.
*
* Run after `vp pack` (see package.json "build").
*/
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
const esbuild = join(pkgDir, "node_modules", ".bin", "esbuild");
const banner =
'window.__ModuleLoader__.load({ id: "bailian-cli-dsh", factory: (require) => { ' +
"var module = { exports: {} }; var exports = module.exports;";
const footer = "return module.exports; } });";
const result = spawnSync(
esbuild,
[
"src/client.ts",
"--bundle",
"--format=cjs",
"--platform=browser",
"--external:react",
`--banner:js=${banner}`,
`--footer:js=${footer}`,
"--outfile=client.bundle.js",
],
{ cwd: pkgDir, stdio: "inherit" },
);
if (result.status !== 0) {
// Throw rather than process.exit: an uncaught top-level error still yields a
// non-zero exit (so `pnpm build` fails), and it carries esbuild's own status.
throw new Error(`build-client: esbuild failed with status ${result.status ?? "unknown"}`);
}
console.log("build-client: client.bundle.js (ModuleLoader format) written");
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
/**
* Bailian feature registry — single source of truth mapping a welcome-page
* card to a **bailian-cli command**. The console-API knowledge lives in
* bailian-cli (packages/commands); this bundle only shells out to `bl`, so a
* feature added there is reusable here for free.
*
* Each entry is exposed two ways by the Host:
* 1. a **model tool** `bailian_<id>` (natural-language entry: the LLM reads
* `intent` and calls the tool when the user asks in plain language);
* 2. the **generic route** `POST /bailian/console { featureId }` (card-click
* entry: the client renders `summarize`/`data`).
*
* Adding a feature = (a) add a `bl` command in bailian-cli, (b) add one record
* here. Tool + card come for free.
*
* Browser-safe (no node imports) so both the vite host build and the esbuild
* client bundle can import it.
*
* @module bailian-cli-dsh/features
*/
export interface BailianFeature {
/** Stable id; tool name is `bailian_<id>`. */
id: string;
/** Card title (matched against welcome cards). */
title: string;
/** Card description. */
desc: string;
/** Tool description: tells the LLM which user utterances should use it. */
intent: string;
/** Natural-language query sent into the conversation when the card is clicked. */
query: string;
/** `bl` command args (without `--output`); the Host appends `--output json`. */
argv: string[];
/** Args appended when the user supplies no params (e.g. ["--all"]). */
defaultArgs?: string[];
/** Optional params the LLM (or UI) may supply; mapped to bl flags. */
paramFlags?: FeatureParam[];
/** Human/LLM summary of the command's JSON output. */
summarize: (data: any) => string;
}
export interface FeatureParam {
/** Tool parameter name (LLM fills it). */
name: string;
/** bl flag it maps to (e.g. --model). */
flag: string;
type: "string" | "number" | "boolean";
description: string;
}
function pick(obj: any, ...keys: string[]): any {
for (const k of keys) if (obj && obj[k] !== undefined && obj[k] !== null) return obj[k];
return undefined;
}
function pct(v: any): string {
if (v === undefined || v === null) return "—";
const n = (typeof v === "number" ? v : Number(v)) * 100;
return (isNaN(n) ? 0 : n).toFixed(1) + "%";
}
export const FEATURES: BailianFeature[] = [
{
id: "free-tier",
title: "免费额度一键防护",
desc: "查询免费额度用量,一键开启「用完即停」,额度耗尽自动停止调用,不再产生意外扣费",
intent:
"查询百炼免费额度用量与『用完即停』防护状态。当用户提到免费额度、额度耗尽、意外扣费、用完即停、额度防护时使用。",
query: "帮我查看百炼免费额度用量,并告诉我怎么开启「用完即停」防护",
argv: ["usage", "freetier"],
defaultArgs: ["--all"],
paramFlags: [
{
name: "models",
flag: "--model",
type: "string",
description:
"逗号分隔的模型列表;不填则查询全部(--all。若用户只关心特定模型且未说明可先用 AskUserQuestion 询问。",
},
],
summarize: (d) => {
if (!d || typeof d !== "object") return "未获取到免费额度数据。";
const list = pick(d, "quotas", "quotaList", "models", "list");
if (Array.isArray(list)) {
const lines = list.slice(0, 8).map((m: any) => {
const model = pick(m, "model", "modelName", "modelId") ?? "?";
const total = pick(m, "quotaTotal", "totalQuota", "total");
const used = pick(m, "quotaUsed", "usedQuota", "used");
const on = pick(m, "freeTierOnly");
return `- ${model}: 已用 ${used ?? "?"} / 共 ${total ?? "?"}${on !== undefined ? `,用完即停 ${on ? "开" : "关"}` : ""}`;
});
return lines.length
? `免费额度:\n${lines.join("\n")}`
: "免费额度: " + JSON.stringify(d).slice(0, 300);
}
return "免费额度: " + JSON.stringify(d).slice(0, 300);
},
},
{
id: "usage",
title: "模型用量统计",
desc: "各模型/TokenPlan 的用量与百分比一次查清,自动生成用量分析",
intent:
"查询百炼 TokenPlan 个人版用量5 小时/1 周窗口百分比、重置时间、套餐、用量包。当用户问用量、用了多少、额度百分比、TokenPlan 使用情况时使用。",
query: "帮我查询百炼 TokenPlan 个人版用量5 小时/1 周窗口、套餐与用量包)",
argv: ["token-plan", "personal-usage"],
summarize: (d) => {
if (!d || typeof d !== "object") return "未获取到用量数据。";
const u = d.usage ?? d;
const parts: string[] = [];
if (u.per5HourPercentage !== undefined)
parts.push(`5 小时窗口已用 ${pct(u.per5HourPercentage)}`);
if (u.per1WeekPercentage !== undefined)
parts.push(`1 周窗口已用 ${pct(u.per1WeekPercentage)}`);
const sub = d.subscription;
if (sub && sub.remainingDays !== undefined) parts.push(`套餐剩余 ${sub.remainingDays}`);
const add = d.addonSummary;
if (add && add.remainingCredits !== undefined)
parts.push(`用量包剩余 ${add.remainingCredits}/${add.totalCredits}`);
return parts.length
? `TokenPlan 用量: ${parts.join("")}`
: "用量: " + JSON.stringify(d).slice(0, 300);
},
},
];
export function featureById(id: string): BailianFeature | undefined {
return FEATURES.find((f) => f.id === id);
}
export function featureByTitle(title: string): BailianFeature | undefined {
return FEATURES.find((f) => f.title === title);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* bailian-cli-dsh — Aliyun Model Studio capabilities as a DeepSeek Harness
* profile bundle. The package's substance is `cordis.patch.yml`, declared by
* the `dsh.bundle.patch` manifest field and resolved by the profile composer.
*
* This root module is the no-op node half loaded by the `bailian-client` row
* (whose purpose is to make `client-modules` serve the browser bundle
* `client.bundle.js`). Cordis requires every row to resolve to a plugin with
* an `apply` method, so this exports a minimal one. The real Host logic lives
* in `./tokenplan-usage` and `./memory`; the browser UI lives in
* `client.bundle.js`.
*
* @module bailian-cli-dsh
*/
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-cli-dsh";
/** No-op: this row exists only to serve the client bundle. */
export function apply(): void {}
+621
View File
@@ -0,0 +1,621 @@
/**
* `bailian-cli-dsh/memory` (Host half): cross-session long-term memory backed
* by Bailian's hosted memory library (DashScope memory v2 API).
*
* Provides:
* - Two model tools: `bailian_memory_search` (recall) + `bailian_memory_add`
* (store), plus `bailian_memory_list` (browse).
* - Auto-inject: on the first turn of each session (or every turn if
* configured), search memory and inject relevant facts into context.
* - Auto-persist: when a turn closes, send new user/assistant messages to
* the add API so future sessions can recall them.
* - webServer routes for the Client settings page to configure memory
* parameters (apiKey, baseUrl, userId, planVersion, etc.).
*
* Calls go straight to DashScope rather than through `bl memory`, because
* the v2 API exposes retrieval controls (`min_score`, `plan_version`,
* `enable_rerank`, `memory_library_id`, `enable_judge`, `enable_rewrite`)
* the CLI does not surface.
*
* BILLING: add and search are charged per call. `pro` costs ~50x `lite` per
* search. The `enable_rerank` flag is what actually selects the billing tier
* (verified: sending `plan_version: lite` alone still bills `pro`).
*
* @module bailian-cli-dsh/memory
*/
import { userInfo } from "node:os";
import type { Context } from "@deepseek-ai/cordis";
import type { Agent, PreStepDecision } from "@deepseek-ai/dsh-agent";
import type {} from "@deepseek-ai/dsh-agent";
import type { ContentBlock, Message } from "@deepseek-ai/dsh-llm";
import { createUserMessage } from "@deepseek-ai/dsh-llm";
import { defineTool } from "@deepseek-ai/dsh-tools";
import z from "@deepseek-ai/schemastery";
import type { IncomingMessage, ServerResponse } from "node:http";
import { isTokenPlanKey, tokenPlanKeyRejection } from "../shared/credentials.ts";
import { dashScopeFetch } from "../shared/http.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-memory";
/** Seams this plugin registers into. */
export const inject = ["tools", "agents", "webServer"];
export interface Config {
/** DashScope API key (pay-as-you-go sk-ws-). Falls back to $DASHSCOPE_API_KEY. */
apiKey?: string;
/** Memory API base URL (default: https://dashscope.aliyuncs.com/api/v2/apps/memory/). */
baseUrl?: string;
/** Memory entity id. Falls back to $BAILIAN_MEMORY_USER_ID, then OS user. */
userId?: string;
/** Memory library id; defaults to the account default. */
memoryLibraryId?: string;
/** Memory extraction rule id. */
projectId?: string;
/** Profile template id; omitting skips profile extraction (and its cost). */
profileSchema?: string;
/** Search strategy; pro enables rerank at ~50x the cost. */
planVersion?: "lite" | "pro";
topK?: number;
minScore?: number;
/** Retrieve relevant memories and inject into the conversation. */
autoInject?: boolean;
/** Retrieve every turn instead of once per session. */
injectEveryTurn?: boolean;
/** Persist each turn's new messages when the turn closes. */
autoPersist?: boolean;
}
export const Config: z<Config> = z.object({
apiKey: z.string().role("secret").description("Pay-as-you-go DashScope API key (sk-)."),
baseUrl: z.string().description("Memory API base URL."),
userId: z.string().description("Memory entity id owning these memories."),
memoryLibraryId: z.string().description("Memory library id."),
projectId: z.string().description("Memory extraction rule id."),
profileSchema: z.string().description("Profile template id; enables profile extraction."),
planVersion: z.union(["lite", "pro"] as const).description("Search strategy; pro ~50x cost."),
topK: z.natural().description("Maximum memories to recall (1-100)."),
minScore: z.number().description("Minimum similarity score, 0-1."),
autoInject: z.boolean().description("Inject recalled memories automatically."),
injectEveryTurn: z.boolean().description("Retrieve every turn instead of once per session."),
autoPersist: z.boolean().description("Persist new messages when a turn closes."),
});
const DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/api/v2/apps/memory/";
const DEFAULT_TOP_K = 10;
const DEFAULT_PLAN_VERSION = "lite";
const CONFIG_ROUTE = "/bailian/memory/config";
const STATUS_ROUTE = "/bailian/memory/status";
interface MemoryNode {
memory_node_id?: string;
content?: string;
event?: string;
old_content?: string;
created_at?: number;
updated_at?: number;
meta_data?: Record<string, unknown>;
}
interface MemoryResponse {
request_id?: string;
memory_nodes?: readonly MemoryNode[];
total?: number;
page_num?: number;
page_size?: number;
billing_plan?: string;
}
interface ChatTurn {
role: "user" | "assistant";
content: string;
}
/** Mutable runtime config — updated via webServer route, initialized from Cordis config. */
interface MemoryRuntimeConfig {
apiKey: string | undefined;
baseUrl: string;
userId: string;
memoryLibraryId: string | undefined;
projectId: string | undefined;
profileSchema: string | undefined;
planVersion: "lite" | "pro";
topK: number;
minScore: number | undefined;
autoInject: boolean;
injectEveryTurn: boolean;
autoPersist: boolean;
}
/** Resolution order: explicit config, then env, then OS user. */
function resolveUserId(ctx: Context, config: Config): string {
if (config.userId !== undefined && config.userId.length > 0) return config.userId;
const fromEnv = ctx.get("launchEnvironment")?.get("BAILIAN_MEMORY_USER_ID")?.value;
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
return userInfo().username;
}
function textOf(content: readonly ContentBlock[]): string {
return content
.filter((block): block is Extract<ContentBlock, { type: "text" }> => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
}
/** Plain user/assistant exchanges; tool traffic and injected context are not memories. */
function conversationTurns(messages: readonly Message[]): ChatTurn[] {
const turns: ChatTurn[] = [];
for (const message of messages) {
if (message.role !== "user" && message.role !== "assistant") continue;
if (message.role === "user" && message.source.kind !== "user") continue;
const text = textOf(message.content);
if (text.length > 0) turns.push({ role: message.role, content: text });
}
return turns;
}
/** Read a UTF-8 POST body up to a size limit. */
function readJsonBody(req: IncomingMessage, maxBytes: number = 16384): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
req.destroy();
reject(new Error("body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
if (text.length === 0) return resolve({});
try {
resolve(JSON.parse(text));
} catch {
reject(new Error("invalid JSON"));
}
});
req.on("error", reject);
});
}
function sendJson(res: ServerResponse, status: number, data: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(data));
}
class MemoryClient {
constructor(
private readonly apiKey: string,
private readonly baseUrl: string,
private readonly cfg: MemoryRuntimeConfig,
private readonly userId: string,
) {}
private shared(): Record<string, unknown> {
return {
user_id: this.userId,
...(this.cfg.memoryLibraryId !== undefined
? { memory_library_id: this.cfg.memoryLibraryId }
: {}),
};
}
async add(
messages: readonly ChatTurn[],
signal: AbortSignal | undefined,
overrides?: { customContent?: string; metaData?: Record<string, unknown> },
): Promise<MemoryResponse> {
return dashScopeFetch<MemoryResponse>({
url: `${this.baseUrl}add`,
method: "POST",
apiKey: this.apiKey,
signal,
body: {
...this.shared(),
...(overrides?.customContent !== undefined
? { custom_content: overrides.customContent }
: { messages }),
...(this.cfg.projectId !== undefined ? { project_id: this.cfg.projectId } : {}),
...(this.cfg.profileSchema !== undefined ? { profile_schema: this.cfg.profileSchema } : {}),
...(overrides?.metaData !== undefined ? { meta_data: overrides.metaData } : {}),
},
});
}
async search(
messages: readonly ChatTurn[],
signal: AbortSignal | undefined,
overrides?: { topK?: number; minScore?: number; planVersion?: "lite" | "pro" },
): Promise<MemoryResponse> {
const planVersion = overrides?.planVersion ?? this.cfg.planVersion ?? DEFAULT_PLAN_VERSION;
return dashScopeFetch<MemoryResponse>({
url: `${this.baseUrl}memory_nodes/search`,
method: "POST",
apiKey: this.apiKey,
signal,
body: {
...this.shared(),
messages,
top_k: overrides?.topK ?? this.cfg.topK ?? DEFAULT_TOP_K,
...((overrides?.minScore ?? this.cfg.minScore) !== undefined
? { min_score: overrides?.minScore ?? this.cfg.minScore }
: {}),
// enable_rerank is what actually selects the billing tier (verified:
// plan_version alone still bills pro). Send both for safety.
enable_rerank: planVersion === "pro",
plan_version: planVersion,
...(this.cfg.projectId !== undefined ? { project_ids: [this.cfg.projectId] } : {}),
},
});
}
async list(
signal: AbortSignal | undefined,
overrides?: { pageNum?: number; pageSize?: number },
): Promise<MemoryResponse> {
const params = new URLSearchParams({
user_id: this.userId,
page_num: String(overrides?.pageNum ?? 1),
page_size: String(overrides?.pageSize ?? 10),
...(this.cfg.memoryLibraryId !== undefined
? { memory_library_id: this.cfg.memoryLibraryId }
: {}),
});
return dashScopeFetch<MemoryResponse>({
url: `${this.baseUrl}memory_nodes?${params.toString()}`,
method: "GET",
apiKey: this.apiKey,
signal,
});
}
}
function formatMemories(nodes: readonly MemoryNode[]): string {
const items = nodes
.map((node) => node.content?.trim())
.filter((content): content is string => content !== undefined && content.length > 0);
if (items.length === 0) return "";
return `What you remember about this user from earlier sessions:\n${items.map((item) => `- ${item}`).join("\n")}`;
}
/** Register model tools for deliberate memory operations. */
function registerTools(ctx: Context, client: () => MemoryClient | undefined): void {
ctx.tools.register(
defineTool({
name: "bailian_memory_search",
description:
"Recall facts stored about this user in earlier sessions. Use when the user refers to prior context, preferences, or decisions you have no record of in this session.",
parameters: {
query: { type: "string", required: true, description: "What to recall." },
top_k: { type: "integer", description: "Maximum memories to return (1-100)." },
min_score: { type: "number", description: "Minimum similarity score, 0-1." },
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
memories: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string", required: true },
content: { type: "string", required: true },
},
},
},
},
},
render: (_args, value) => [
{
type: "text",
text:
value.memories.length === 0
? "No relevant memories."
: value.memories.map((m: any) => `- ${m.content}`).join("\n"),
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const mem = client();
if (mem === undefined)
throw new Error(
"bailian-memory: not configured. Set apiKey in the Bailian settings page or config.",
);
const result = await mem.search([{ role: "user", content: args.query }], exec.signal, {
...(args.top_k !== undefined ? { topK: args.top_k } : {}),
...(args.min_score !== undefined ? { minScore: args.min_score } : {}),
});
return {
memories: (result.memory_nodes ?? []).map((node) => ({
id: node.memory_node_id ?? "",
content: node.content ?? "",
})),
};
},
}),
);
ctx.tools.register(
defineTool({
name: "bailian_memory_add",
description:
"Store a durable fact about this user so later sessions can recall it. Use for stable preferences, decisions, and context — not for transient task state.",
parameters: {
content: { type: "string", required: true, description: "The fact to remember." },
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: { stored: { type: "integer", required: true } },
},
render: (_args, value) => [
{ type: "text", text: `Stored ${value.stored} memory fragment(s).` },
],
},
async execute(args, exec) {
const mem = client();
if (mem === undefined)
throw new Error(
"bailian-memory: not configured. Set apiKey in the Bailian settings page or config.",
);
const result = await mem.add([], exec.signal, { customContent: args.content });
return { stored: (result.memory_nodes ?? []).length };
},
}),
);
ctx.tools.register(
defineTool({
name: "bailian_memory_list",
description:
"List all stored memory fragments for this user. Use to review what the system already knows.",
parameters: {
page_size: { type: "integer", description: "Results per page (default 10)." },
page_num: { type: "integer", description: "Page number, starting from 1." },
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
memories: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string", required: true },
content: { type: "string", required: true },
},
},
},
total: { type: "integer", required: true },
},
},
render: (_args, value) => [
{
type: "text",
text: `${value.total} memory fragment(s):\n${value.memories.map((m: any) => `- ${m.content}`).join("\n")}`,
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const mem = client();
if (mem === undefined) throw new Error("bailian-memory: not configured.");
const result = await mem.list(exec.signal, {
...(args.page_size !== undefined ? { pageSize: args.page_size } : {}),
...(args.page_num !== undefined ? { pageNum: args.page_num } : {}),
});
return {
memories: (result.memory_nodes ?? []).map((node) => ({
id: node.memory_node_id ?? "",
content: node.content ?? "",
})),
total: result.total ?? 0,
};
},
}),
);
}
/** Auto-inject (search on first turn) + auto-persist (add on turn end). */
function registerAutoBehavior(
ctx: Context,
client: () => MemoryClient | undefined,
cfg: () => MemoryRuntimeConfig,
): void {
const injectedSessions = new WeakSet<Agent>();
const persistedCursor = new WeakMap<Agent, number>();
const currentCfg = cfg();
if (currentCfg.autoInject !== false) {
ctx.on(
"agent/pre-step",
async (
{
agent,
messages,
signal,
}: { agent: Agent; messages: readonly Message[]; signal: AbortSignal },
next: () => Promise<PreStepDecision>,
) => {
const decision = await next();
if (decision.kind !== "enter") return decision;
if (injectedSessions.has(agent) && currentCfg.injectEveryTurn !== true) return decision;
const query = textOf(messages.flatMap((m) => m.content));
if (query.length === 0) return decision;
const mem = client();
if (mem === undefined) return decision;
let nodes: readonly MemoryNode[] = [];
try {
const result = await mem.search([{ role: "user", content: query }], signal);
nodes = result.memory_nodes ?? [];
} catch {
return decision;
}
injectedSessions.add(agent);
if (nodes.length === 0) return decision;
const text = formatMemories(nodes);
return {
...decision,
messages: [
...decision.messages,
createUserMessage({
content: [{ type: "text", text }],
source: {
kind: "plugin",
plugin: name,
form: "snapshot",
sections: [{ name, text }],
},
}),
],
};
},
{ prepend: true },
);
}
if (currentCfg.autoPersist !== false) {
ctx.on(
"agent/turn-stopping",
async ({ agent, signal }: { agent: Agent; signal: AbortSignal }) => {
const mem = client();
if (mem === undefined) return;
const turns = conversationTurns(agent.session.deriveMessages());
const cursor = persistedCursor.get(agent) ?? 0;
const newTurns = turns.slice(cursor);
if (newTurns.length === 0) return;
persistedCursor.set(agent, turns.length);
try {
await mem.add(newTurns, signal);
} catch {
persistedCursor.set(agent, cursor);
}
},
);
}
}
export function apply(ctx: Context, config: Config): void {
const webServer = ctx.get("webServer");
// Mutable runtime config — initialized from Cordis config, updatable via webServer route.
let runtime: MemoryRuntimeConfig = {
apiKey: config.apiKey,
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
userId: resolveUserId(ctx, config),
memoryLibraryId: config.memoryLibraryId,
projectId: config.projectId,
profileSchema: config.profileSchema,
planVersion: config.planVersion ?? DEFAULT_PLAN_VERSION,
topK: config.topK ?? DEFAULT_TOP_K,
minScore: config.minScore,
autoInject: config.autoInject ?? true,
injectEveryTurn: config.injectEveryTurn ?? false,
autoPersist: config.autoPersist ?? true,
};
/** Build a MemoryClient from the current runtime config, or undefined if no API key. */
function buildClient(): MemoryClient | undefined {
if (runtime.apiKey === undefined || runtime.apiKey.length === 0) return undefined;
if (isTokenPlanKey(runtime.apiKey)) {
throw new Error(tokenPlanKeyRejection(name, "the memory API"));
}
return new MemoryClient(runtime.apiKey, runtime.baseUrl, runtime, runtime.userId);
}
// Register tools + auto behavior.
registerTools(ctx, buildClient);
registerAutoBehavior(ctx, buildClient, () => runtime);
// webServer routes for the Client settings page.
if (webServer !== undefined) {
ctx.effect(() =>
webServer.register({
kind: "exact",
path: STATUS_ROUTE,
handler: async (_req: IncomingMessage, res: ServerResponse) => {
sendJson(res, 200, {
configured: runtime.apiKey !== undefined && runtime.apiKey.length > 0,
userId: runtime.userId,
baseUrl: runtime.baseUrl,
planVersion: runtime.planVersion,
topK: runtime.topK,
autoInject: runtime.autoInject,
injectEveryTurn: runtime.injectEveryTurn,
autoPersist: runtime.autoPersist,
memoryLibraryId: runtime.memoryLibraryId,
});
},
}),
);
ctx.effect(() =>
webServer.register({
kind: "exact",
path: CONFIG_ROUTE,
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "use POST" });
return;
}
let body: Record<string, unknown>;
try {
body = (await readJsonBody(req)) as Record<string, unknown>;
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : "bad request" });
return;
}
// Update mutable fields from the request body.
if (typeof body.apiKey === "string") runtime.apiKey = body.apiKey || undefined;
if (typeof body.baseUrl === "string" && body.baseUrl.length > 0)
runtime.baseUrl = body.baseUrl;
if (typeof body.userId === "string" && body.userId.length > 0)
runtime.userId = body.userId;
if (typeof body.memoryLibraryId === "string")
runtime.memoryLibraryId = body.memoryLibraryId || undefined;
if (typeof body.projectId === "string") runtime.projectId = body.projectId || undefined;
if (typeof body.profileSchema === "string")
runtime.profileSchema = body.profileSchema || undefined;
if (body.planVersion === "lite" || body.planVersion === "pro")
runtime.planVersion = body.planVersion;
if (typeof body.topK === "number") runtime.topK = body.topK;
if (typeof body.minScore === "number") runtime.minScore = body.minScore;
if (typeof body.autoInject === "boolean") runtime.autoInject = body.autoInject;
if (typeof body.injectEveryTurn === "boolean")
runtime.injectEveryTurn = body.injectEveryTurn;
if (typeof body.autoPersist === "boolean") runtime.autoPersist = body.autoPersist;
sendJson(res, 200, {
ok: true,
configured: runtime.apiKey !== undefined && runtime.apiKey.length > 0,
});
},
}),
);
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Shared `bl` invocation for the plugins that delegate to the Bailian CLI
* rather than calling DashScope directly — the ones whose CLI implementation
* carries real substance (async task polling, artifact download, SSE session
* streaming, `agents.yaml` resolution) that a plugin should not restate.
* @module bailian-cli-dsh/shared/bl
*/
import type { Context } from "@deepseek-ai/cordis";
import type { SubprocessSpawnSpec } from "@deepseek-ai/dsh-subprocess";
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
const DEFAULT_STDOUT_MAX_BYTES = 4 * 1024 * 1024;
const DEFAULT_STDERR_MAX_BYTES = 64 * 1024;
const DEFAULT_GRACE_MS = 5_000;
/**
* Environment names `bl` reads for credentials, endpoint routing, and profile
* selection. `scrubbedParentEnv()` strips credential-shaped names from every
* harness child, so the key would never reach `bl` unless forwarded here.
*/
const FORWARDED_ENV_NAMES = [
"DASHSCOPE_API_KEY",
"DASHSCOPE_BASE_URL",
"DASHSCOPE_TIMEOUT",
"BAILIAN_WORKSPACE_ID",
"BAILIAN_CONFIG_DIR",
"ALIBABA_CLOUD_ACCESS_KEY_ID",
"ALIBABA_CLOUD_ACCESS_KEY_SECRET",
"ALIBABA_CLOUD_SECURITY_TOKEN",
] as const;
/** A `bl` invocation that exited non-zero or produced unreadable output. */
export class BlError extends Error {
constructor(
message: string,
readonly detail: { argv: readonly string[]; exitCode: number | null; stderr: string },
options?: { cause?: unknown },
) {
super(message, options);
this.name = "BlError";
}
}
export interface RunBlOptions {
/** Working directory for the child; callers pass the session cwd. */
cwd: string;
signal: AbortSignal;
/** Extra entries layered after the forwarded Bailian names. */
env?: NodeJS.ProcessEnv;
stdoutMaxBytes?: number;
graceMs?: number;
}
export interface BlOutcome {
stdout: string;
stderr: string;
exitCode: number | null;
terminatedBy: NodeJS.Signals | null;
}
function abortError(): DOMException {
return new DOMException("bl invocation aborted", "AbortError");
}
function forwardedEnv(ctx: Context, extra: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
const launchEnvironment = launchEnvironmentOf(ctx);
const env: NodeJS.ProcessEnv = {};
for (const name of FORWARDED_ENV_NAMES) {
const entry = launchEnvironment.get(name);
if (entry !== undefined) env[name] = entry.value;
}
return { ...env, ...extra };
}
/**
* Run `bl` to completion and collect its output.
* @throws {BlError} when the executable cannot be resolved.
* @throws {DOMException} `AbortError` when the caller's signal fires.
*/
export async function runBl(
ctx: Context,
argv: readonly string[],
options: RunBlOptions,
): Promise<BlOutcome> {
if (options.signal.aborted) throw abortError();
const env = forwardedEnv(ctx, options.env);
let executable: string;
try {
executable = await ctx.subprocess.resolveExecutable(
"bl",
env as Readonly<Record<string, string>>,
options.signal,
);
} catch (error) {
throw new BlError(
"the `bl` executable was not found on PATH; install it with `npm install -g bailian-cli`",
{ argv, exitCode: null, stderr: "" },
{ cause: error },
);
}
const spec: SubprocessSpawnSpec = {
argv: [executable, ...argv],
cwd: options.cwd,
stdio: {
stdin: "ignore",
stdout: { maxBytes: options.stdoutMaxBytes ?? DEFAULT_STDOUT_MAX_BYTES },
stderr: { maxBytes: DEFAULT_STDERR_MAX_BYTES },
},
graceMs: options.graceMs ?? DEFAULT_GRACE_MS,
signal: options.signal,
env,
};
const handle = ctx.subprocess.spawn(spec);
if (options.signal.aborted) throw abortError();
const outcome = await handle.done;
if (options.signal.aborted) throw abortError();
return {
stdout: handle.collected.stdout?.readFrom(0).text ?? "",
stderr: handle.collected.stderr?.readFrom(0).text ?? "",
exitCode: outcome.exitCode,
terminatedBy: outcome.signal,
};
}
/**
* Run `bl … --output json` and parse stdout.
* @throws {BlError} on non-zero exit or unparseable stdout.
*/
export async function runBlJson<T>(
ctx: Context,
argv: readonly string[],
options: RunBlOptions,
): Promise<T> {
const withJson = [...argv, "--output", "json"];
const outcome = await runBl(ctx, withJson, options);
if (outcome.exitCode !== 0) {
// bl passes service errors through verbatim; surface them unchanged.
const reason = outcome.stderr.trim() || outcome.stdout.trim() || "no diagnostics on stderr";
throw new BlError(`bl ${argv.join(" ")} failed: ${reason}`, {
argv: withJson,
exitCode: outcome.exitCode,
stderr: outcome.stderr,
});
}
try {
return JSON.parse(outcome.stdout) as T;
} catch (error) {
throw new BlError(
`bl ${argv.join(" ")} did not emit JSON on stdout`,
{ argv: withJson, exitCode: outcome.exitCode, stderr: outcome.stderr },
{ cause: error },
);
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Pure credential classification and pairing shared by the plugins that call
* pay-as-you-go DashScope APIs directly (memory, knowledge base) or through
* `bl managed-agent` (agentstudio). No runtime imports — this module is safe
* to load from tests and its rules are locked by `tests/credentials.test.ts`.
*
* TokenPlan keys (`sk-sp-`) and pay-as-you-go keys (`sk-ws-`) are not
* interchangeable: the TokenPlan gateway 401s a pay-as-you-go key, and the
* service APIs this package calls 401 or 404 a TokenPlan key. The LLM
* provider row keeps its TokenPlan key under a dedicated env name
* (`BAILIAN_TOKENPLAN_API_KEY`); every other plugin needs a pay-as-you-go key
* and rejects a TokenPlan one up front instead of failing at request time.
*
* @module bailian-cli-dsh/shared/credentials
*/
/**
* Standard DashScope model-domain endpoint. It serves the model APIs plus the
* memory v2 and knowledge indices the plugins call directly — but NOT
* `/api/v1/agentstudio`, which lives on the workspace-scoped host.
*/
export const DASHSCOPE_DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com";
/** Key prefix that marks a TokenPlan key (which service APIs reject). */
export const TOKEN_PLAN_KEY_PREFIX = "sk-sp-";
/** Whether a key is shaped like a TokenPlan key (which service APIs reject). */
export function isTokenPlanKey(apiKey: string): boolean {
return apiKey.startsWith(TOKEN_PLAN_KEY_PREFIX);
}
/**
* Whether a base URL points at the TokenPlan gateway. That gateway serves the
* model-inference routes only — none of the service APIs this package calls,
* including `/api/v1/agentstudio`, so requests to it 404.
*/
export function isTokenPlanEndpoint(baseUrl: string): boolean {
try {
return new URL(baseUrl).hostname.startsWith("token-plan.");
} catch {
// An unparseable URL fails the request later with its own diagnostics;
// this check only classifies well-formed endpoints.
return false;
}
}
/**
* The standard error wording every plugin uses when it resolves a TokenPlan
* key, so all three surfaces fail with one recognizable, actionable message.
*/
export function tokenPlanKeyRejection(plugin: string, capability: string): string {
return (
`${plugin}: the resolved API key is a TokenPlan key (${TOKEN_PLAN_KEY_PREFIX}…), which ` +
`${capability} rejects. Use a pay-as-you-go key (sk-ws-): set \`apiKey\` in this row's ` +
"config or $DASHSCOPE_API_KEY. TokenPlan keys belong on $BAILIAN_TOKENPLAN_API_KEY, " +
"which only the `bailian-tokenplan` LLM provider reads."
);
}
/**
* Build the `--api-key` / `--base-url` flags handed to `bl managed-agent run`.
* Each resolved half ships independently:
*
* - A resolved key becomes `--api-key`, overriding bl's auth chain so an
* active TokenPlan profile cannot substitute its own key.
* - A resolved endpoint becomes `--base-url`, overriding the ACTIVE PROFILE's
* base_url — the half that fixes the classic `Bailian API 404`, where a
* TokenPlan (or bare model-domain) origin does not serve
* `/api/v1/agentstudio`.
*
* There is deliberately NO fallback endpoint: agentstudio is only served on
* the workspace-scoped host (see {@link workspaceEndpoint}), and an unknown
* workspace is a configuration gap, not a defaultable value. Unresolved halves
* emit nothing and bl's own auth chain decides them.
*/
export function credentialFlags(apiKey: string | undefined, baseUrl: string | undefined): string[] {
const flags: string[] = [];
if (baseUrl !== undefined && baseUrl.length > 0) flags.push("--base-url", baseUrl);
if (apiKey !== undefined && apiKey.length > 0) flags.push("--api-key", apiKey);
return flags;
}
/**
* Compose the workspace-scoped agentstudio host for a workspace id. The
* managed-agent API is served only from
* `https://{workspace}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio`
* (bl/the SDK append the resource path onto this origin); the plain
* dashscope origin 404s it, and a key only unlocks its own workspace's host
* (a mismatched one 403s `Endpoint.AccessDenied`).
*/
export function workspaceEndpoint(workspaceId: string): string {
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com`;
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Direct DashScope HTTP for the plugins whose CLI counterpart does not expose
* the full parameter surface (long-term memory, knowledge-base retrieval).
* Service errors pass through verbatim — this layer classifies nothing.
* @module bailian-cli-dsh/shared/http
*/
import type { Context } from "@deepseek-ai/cordis";
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
import { DASHSCOPE_DEFAULT_BASE_URL } from "./credentials.ts";
export { DASHSCOPE_DEFAULT_BASE_URL } from "./credentials.ts";
/** A non-2xx DashScope response, carrying the server's own wording. */
export class DashScopeError extends Error {
constructor(
message: string,
readonly detail: { status: number; code?: string; requestId?: string },
options?: { cause?: unknown },
) {
super(message, options);
this.name = "DashScopeError";
}
}
/**
* Resolve the DashScope key: explicit row config first, then the launch
* environment (process env, project `.env`, harness-home `.env`). Callers
* that get `undefined` decide their own failure mode — opt-in plugins reject
* at boot, the managed-agent tool falls through to bl's own auth chain.
*/
export function resolveApiKey(ctx: Context, explicit?: string): string | undefined {
if (explicit !== undefined && explicit.length > 0) return explicit;
const entry = launchEnvironmentOf(ctx).get("DASHSCOPE_API_KEY");
return entry !== undefined && entry.value.length > 0 ? entry.value : undefined;
}
export function resolveBaseUrl(ctx: Context, explicit?: string): string {
if (explicit !== undefined && explicit.length > 0) return explicit;
const entry = launchEnvironmentOf(ctx).get("DASHSCOPE_BASE_URL");
return entry !== undefined && entry.value.length > 0 ? entry.value : DASHSCOPE_DEFAULT_BASE_URL;
}
export interface DashScopeRequest {
url: string;
method: "GET" | "POST" | "PATCH" | "DELETE";
apiKey: string;
body?: unknown;
signal?: AbortSignal | undefined;
}
interface DashScopeErrorBody {
code?: string;
message?: string;
request_id?: string;
error?: { code?: string; message?: string };
}
/**
* Issue one DashScope request and parse its JSON body.
* @throws {DashScopeError} on a non-2xx response or an unreadable body.
*/
export async function dashScopeFetch<T>(request: DashScopeRequest): Promise<T> {
const response = await fetch(request.url, {
method: request.method,
headers: {
Authorization: `Bearer ${request.apiKey}`,
"Content-Type": "application/json",
},
...(request.body !== undefined ? { body: JSON.stringify(request.body) } : {}),
...(request.signal !== undefined ? { signal: request.signal } : {}),
redirect: "error",
});
const text = await response.text();
if (!response.ok) {
let parsed: DashScopeErrorBody = {};
try {
parsed = JSON.parse(text) as DashScopeErrorBody;
} catch {
// A non-JSON error body is still worth surfacing as-is.
}
const code = parsed.code ?? parsed.error?.code;
const message = parsed.message ?? parsed.error?.message ?? text.trim();
throw new DashScopeError(message.length > 0 ? message : `HTTP ${response.status}`, {
status: response.status,
...(code !== undefined ? { code } : {}),
...(parsed.request_id !== undefined ? { requestId: parsed.request_id } : {}),
});
}
try {
return JSON.parse(text) as T;
} catch (error) {
throw new DashScopeError(
"DashScope returned a non-JSON success body",
{ status: response.status },
{ cause: error },
);
}
}
+423
View File
@@ -0,0 +1,423 @@
/**
* `bailian-cli-dsh/tokenplan-usage` (Host half): provides two webServer
* routes for the Client's "Bailian" settings page:
*
* 1. `POST /api/bailian/credentials` — saves AK/SK to the dedicated `dsh`
* bl profile via `bl auth login --open-api --config dsh`. This generates
* a fresh access_token and stores AK/SK + token in the profile. All
* subsequent console calls read this profile.
*
* 2. `POST /api/bailian/tokenplan/usage` — fetches personal-edition
* TokenPlan usage (3 console APIs) using the `dsh` profile credentials.
* Takes only `{ region, site }`; AK/SK are already saved in the profile.
*
* Configuration UX: users save AK/SK once on the settings page. All future
* Bailian plugins reuse the same `dsh` profile credentials.
*
* @module bailian-cli-dsh/tokenplan-usage
*/
import type { Context } from "@deepseek-ai/cordis";
import type { IncomingMessage, ServerResponse } from "node:http";
import { runBl } from "../shared/bl.ts";
import { defineTool } from "@deepseek-ai/dsh-tools";
import type { JsonValue } from "@deepseek-ai/dsh-session";
import { FEATURES, featureById, type FeatureParam } from "../features.ts";
import z from "@deepseek-ai/schemastery";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-tokenplan-usage";
/** Hard deps: bl via subprocess; routes need webServer; feature tools need tools. */
export const inject = ["subprocess", "webServer", "tools"];
export interface Config {
/** Alibaba Cloud Access Key ID. Fallback when not provided via UI. */
accessKeyId?: string;
/** Alibaba Cloud Access Key Secret. Fallback when not provided via UI. */
accessKeySecret?: string;
/** Console gateway region (default: cn-beijing). */
consoleRegion?: string;
/** Console site: domestic or international (default: domestic). */
consoleSite?: "domestic" | "international";
/** Dedicated bl config profile name (default: dsh). */
profile?: string;
}
export const Config = z.object({
accessKeyId: z.string().description("Alibaba Cloud Access Key ID (fallback)."),
accessKeySecret: z.string().description("Alibaba Cloud Access Key Secret (fallback)."),
consoleRegion: z.string().description("Console gateway region (default: cn-beijing)."),
consoleSite: z.string().description("Console site: domestic or international."),
profile: z.string().description("Dedicated bl config profile name (default: dsh)."),
});
/** Personal-edition console API names (from bailian-tokenplan frontend). */
const PERSONAL_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
const PERSONAL_SUBSCRIPTION_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription";
const PERSONAL_ADDON_SUMMARY_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/addon/summary";
const PERSONAL_SUB_COMMODITY_CN = "sfm_tokenplansolo_public_cn";
const PERSONAL_SUB_COMMODITY_INTL = "sfm_tokenplansolo_public_intl";
const PERSONAL_ADDON_COMMODITY_CN = "sfm_tokenplansoloaddon_public_cn";
const PERSONAL_ADDON_COMMODITY_INTL = "sfm_tokenplansoloaddon_public_intl";
const CREDENTIALS_ROUTE = "/bailian/credentials";
const USAGE_ROUTE = "/bailian/tokenplan/usage";
const CONSOLE_ROUTE = "/bailian/console";
const BL_LOGIN_TIMEOUT_MS = 30_000;
const BL_CALL_TIMEOUT_MS = 90_000;
const BL_LOGIN_GRACE_MS = 20_000;
const BL_CALL_GRACE_MS = 60_000;
const DEFAULT_PROFILE = "dsh";
interface FetchResult {
usage: unknown;
subscription: unknown;
addonSummary: unknown;
errors: Array<{ api: string; message: string }>;
}
/** Extract the business payload from a console gateway response. */
function extractData(response: unknown): unknown {
if (response === null || typeof response !== "object") return response;
const outer = (response as Record<string, unknown>).data;
if (outer !== null && typeof outer === "object") {
const dataV2 = (outer as Record<string, unknown>).DataV2;
if (dataV2 !== null && typeof dataV2 === "object") {
const inner = (dataV2 as Record<string, unknown>).data;
if (inner !== null && typeof inner === "object") {
const payload = (inner as Record<string, unknown>).data;
if (payload !== undefined) return payload;
return inner;
}
}
const fallback = (outer as Record<string, unknown>).data;
if (fallback !== undefined) return fallback;
}
return response;
}
/** Read a UTF-8 POST body up to a size limit. */
function readJsonBody(req: IncomingMessage, maxBytes: number = 8192): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
req.destroy();
reject(new Error("request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
if (text.length === 0) return resolve({});
try {
resolve(JSON.parse(text));
} catch {
reject(new Error("invalid JSON body"));
}
});
req.on("error", reject);
});
}
/** Send a JSON response with a status code. */
function sendJson(res: ServerResponse, status: number, data: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(data));
}
export function apply(ctx: Context, config: Config): void {
const webServer = ctx.get("webServer");
if (webServer === undefined) return;
const profile = config.profile || DEFAULT_PROFILE;
/** Save AK/SK to the dsh profile (bl auth login --open-api --config dsh). */
async function saveCredentials(accessKeyId: string, accessKeySecret: string): Promise<void> {
const loginArgs = [
"auth",
"login",
"--open-api",
"--config",
profile,
"--access-key-id",
accessKeyId,
"--access-key-secret",
accessKeySecret,
];
const loginOutcome = await runBl(ctx, loginArgs, {
cwd: process.cwd(),
signal: AbortSignal.timeout(BL_LOGIN_TIMEOUT_MS),
graceMs: BL_LOGIN_GRACE_MS,
});
if (loginOutcome.exitCode !== 0) {
const reason =
loginOutcome.stderr.trim() || loginOutcome.stdout.trim() || `exit ${loginOutcome.exitCode}`;
throw new Error(`bl auth login failed: ${reason}`);
}
}
/** Call a console API using the dsh profile (credentials already saved). */
async function consoleCall(
region: string,
site: string,
api: string,
data: Record<string, unknown>,
): Promise<unknown> {
const callArgs = [
"console",
"call",
"--config",
profile,
"--api",
api,
"--data",
JSON.stringify(data),
"--console-region",
region,
"--console-site",
site,
"--output",
"json",
];
const callOutcome = await runBl(ctx, callArgs, {
cwd: process.cwd(),
signal: AbortSignal.timeout(BL_CALL_TIMEOUT_MS),
graceMs: BL_CALL_GRACE_MS,
});
if (callOutcome.exitCode !== 0) {
const reason =
callOutcome.stderr.trim() || callOutcome.stdout.trim() || `exit ${callOutcome.exitCode}`;
throw new Error(`bl console call failed (${api}): ${reason}`);
}
try {
return JSON.parse(callOutcome.stdout);
} catch {
return { raw: callOutcome.stdout };
}
}
/** Fetch all personal-edition TokenPlan usage (3 console calls). */
async function fetchUsage(region: string, site: string): Promise<FetchResult> {
const isIntl = site === "international";
const subCommodity = isIntl ? PERSONAL_SUB_COMMODITY_INTL : PERSONAL_SUB_COMMODITY_CN;
const addonCommodity = isIntl ? PERSONAL_ADDON_COMMODITY_INTL : PERSONAL_ADDON_COMMODITY_CN;
const errors: Array<{ api: string; message: string }> = [];
let usage = null;
let subscription = null;
let addonSummary = null;
try {
usage = extractData(await consoleCall(region, site, PERSONAL_USAGE_API, {}));
} catch (error) {
errors.push({
api: "usage",
message: error instanceof Error ? error.message : String(error),
});
}
try {
subscription = extractData(
await consoleCall(region, site, PERSONAL_SUBSCRIPTION_API, {
queryInstanceInfoRequest: { commodityCode: subCommodity },
}),
);
} catch (error) {
errors.push({
api: "subscription",
message: error instanceof Error ? error.message : String(error),
});
}
try {
addonSummary = extractData(
await consoleCall(region, site, PERSONAL_ADDON_SUMMARY_API, {
commodityCode: addonCommodity,
}),
);
} catch (error) {
errors.push({
api: "addonSummary",
message: error instanceof Error ? error.message : String(error),
});
}
return { usage, subscription, addonSummary, errors };
}
// Route 1: Save credentials to the dsh bl profile.
ctx.effect(() =>
webServer.register({
kind: "exact",
path: CREDENTIALS_ROUTE,
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "method not allowed, use POST" });
return;
}
let body: Record<string, unknown>;
try {
body = (await readJsonBody(req)) as Record<string, unknown>;
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : "bad request" });
return;
}
const accessKeyId = (body.accessKeyId as string) || config.accessKeyId;
const accessKeySecret = (body.accessKeySecret as string) || config.accessKeySecret;
if (!accessKeyId || !accessKeySecret) {
sendJson(res, 400, { error: "accessKeyId and accessKeySecret are required." });
return;
}
try {
await saveCredentials(accessKeyId, accessKeySecret);
sendJson(res, 200, { ok: true, profile });
} catch (error) {
sendJson(res, 500, { error: error instanceof Error ? error.message : "internal error" });
}
},
}),
);
// Route 2: Fetch TokenPlan usage using the dsh profile credentials.
ctx.effect(() =>
webServer.register({
kind: "exact",
path: USAGE_ROUTE,
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "method not allowed, use POST" });
return;
}
let body: Record<string, unknown>;
try {
body = (await readJsonBody(req)) as Record<string, unknown>;
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : "bad request" });
return;
}
// If AK/SK are provided in the body, save them first (auto-provision).
const bodyKeyId = (body.accessKeyId as string) || undefined;
const bodyKeySecret = (body.accessKeySecret as string) || undefined;
if (bodyKeyId && bodyKeySecret) {
try {
await saveCredentials(bodyKeyId, bodyKeySecret);
} catch (error) {
sendJson(res, 500, {
error: error instanceof Error ? error.message : "credential save failed",
});
return;
}
}
const region = (body.region as string) || config.consoleRegion || "cn-beijing";
const site = (body.site as string) || config.consoleSite || "domestic";
try {
const result = await fetchUsage(region, site);
sendJson(res, 200, result);
} catch (error) {
sendJson(res, 500, { error: error instanceof Error ? error.message : "internal error" });
}
},
}),
);
// ── Feature layer: reuse bailian-cli commands as model tools + a generic route ──
/** Run a feature's `bl` command with the dsh profile; returns parsed JSON. */
async function invokeFeature(
feature: (typeof FEATURES)[number],
params?: Record<string, unknown>,
): Promise<JsonValue> {
const extra: string[] = [];
for (const pf of feature.paramFlags ?? []) {
const val = params?.[pf.name];
if (val !== undefined && val !== null && val !== "") extra.push(pf.flag, String(val));
}
if (extra.length === 0 && feature.defaultArgs) extra.push(...feature.defaultArgs);
const args = [...feature.argv, ...extra, "--config", profile, "--output", "json"];
const outcome = await runBl(ctx, args, {
cwd: process.cwd(),
signal: AbortSignal.timeout(BL_CALL_TIMEOUT_MS),
graceMs: BL_CALL_GRACE_MS,
});
if (outcome.exitCode !== 0) {
const reason = outcome.stderr.trim() || outcome.stdout.trim() || `exit ${outcome.exitCode}`;
throw new Error(`bl ${feature.argv.join(" ")} failed: ${reason}`);
}
try {
return JSON.parse(outcome.stdout);
} catch {
return { raw: outcome.stdout };
}
}
// Natural-language entry: one model tool per feature.
const tools = ctx.get("tools");
if (tools !== undefined) {
for (const feature of FEATURES) {
// Keep FeatureParam's literal `type` union: widening it to `string`
// makes the map unassignable to ParameterSchemaSpec.
const parameters: Record<string, { type: FeatureParam["type"]; description: string }> = {};
for (const pf of feature.paramFlags ?? []) {
parameters[pf.name] = { type: pf.type, description: pf.description };
}
ctx.effect(() =>
tools.register(
defineTool({
name: `bailian_${feature.id}`,
description: `${feature.title}${feature.intent}`,
parameters,
output: {
schema: { type: "object", additionalProperties: true },
render: (_a, value) => [
{ type: "text", text: String((value as any).summary ?? JSON.stringify(value)) },
],
},
async execute(args) {
const data = await invokeFeature(feature, args as Record<string, unknown>);
return { summary: feature.summarize(data), data };
},
}),
),
);
}
}
// Card-click entry: generic route dispatching to a feature by id.
ctx.effect(() =>
webServer.register({
kind: "exact",
path: CONSOLE_ROUTE,
handler: async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "method not allowed, use POST" });
return;
}
let body: Record<string, unknown>;
try {
body = (await readJsonBody(req)) as Record<string, unknown>;
} catch (error) {
sendJson(res, 400, { error: error instanceof Error ? error.message : "bad request" });
return;
}
const feature = featureById(String(body.featureId ?? ""));
if (feature === undefined) {
sendJson(res, 400, { error: `unknown featureId: ${String(body.featureId)}` });
return;
}
try {
const data = await invokeFeature(
feature,
body.params as Record<string, unknown> | undefined,
);
sendJson(res, 200, { summary: feature.summarize(data), data });
} catch (error) {
sendJson(res, 500, { error: error instanceof Error ? error.message : "internal error" });
}
},
}),
);
}
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from "vite-plus/test";
import {
credentialFlags,
DASHSCOPE_DEFAULT_BASE_URL,
isTokenPlanEndpoint,
isTokenPlanKey,
workspaceEndpoint,
} from "../src/shared/credentials.ts";
// 行为锁定:两类 Key(sk-sp- TokenPlan / sk-ws- 按量付费)不可混用。
// TokenPlan 网关 401 按量付费 Key,TokenPlan 网关只提供模型推理,不提供
// 服务 API。managed-agent 的凭证两半独立下发:解析出 key 就显式
// --api-key(不让 bl 用活动 profile 的 key),解析出端点就显式 --base-url
// (不让 bl 用活动 profile 的端点)。agentstudio 只在工作空间前缀主机上提供,
// 因此绝不存在"默认端点"——工作空间未知就是配置缺口,该报错而不是猜。
// 这些共享函数来自早期版本(vision / image / managed-agent 等工具),
// 工具已移除但凭证分类逻辑保留作为参考。
test("isTokenPlanKey classifies by prefix", () => {
expect(isTokenPlanKey("sk-sp-abc123")).toBe(true);
expect(isTokenPlanKey("sk-ws-abc123")).toBe(false);
expect(isTokenPlanKey("")).toBe(false);
});
test("isTokenPlanEndpoint classifies the gateway host", () => {
expect(isTokenPlanEndpoint("https://token-plan.cn-beijing.maas.aliyuncs.com")).toBe(true);
expect(
isTokenPlanEndpoint("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"),
).toBe(true);
expect(isTokenPlanEndpoint(DASHSCOPE_DEFAULT_BASE_URL)).toBe(false);
expect(isTokenPlanEndpoint(workspaceEndpoint("llm-x"))).toBe(false);
// 不可解析的 URL 交给后续请求自己报错,这里只做形状分类。
expect(isTokenPlanEndpoint("not a url")).toBe(false);
});
test("workspaceEndpoint composes the workspace-scoped agentstudio host", () => {
expect(workspaceEndpoint("llm-kpgesh4vqzf5gzv9")).toBe(
"https://llm-kpgesh4vqzf5gzv9.cn-beijing.maas.aliyuncs.com",
);
expect(workspaceEndpoint("ws_abc")).toBe("https://ws_abc.cn-beijing.maas.aliyuncs.com");
});
test("credentialFlags: each resolved half ships independently, no defaults", () => {
expect(credentialFlags(undefined, undefined)).toEqual([]);
expect(credentialFlags("", "")).toEqual([]);
// 只有 key:端点留给 bl 解析,绝不塞一个会 404 的默认主机。
expect(credentialFlags("sk-ws-abc", undefined)).toEqual(["--api-key", "sk-ws-abc"]);
// 只有端点:也下发,key 留给 bl 的 auth chain。
expect(credentialFlags(undefined, "https://ws.example.com")).toEqual([
"--base-url",
"https://ws.example.com",
]);
expect(credentialFlags("sk-ws-abc", "https://ws.example.com")).toEqual([
"--base-url",
"https://ws.example.com",
"--api-key",
"sk-ws-abc",
]);
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["es2023"],
"moduleDetection": "force",
"module": "nodenext",
"moduleResolution": "nodenext",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"declaration": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite-plus";
export default defineConfig({
pack: {
entry: ["src/index.ts", "src/tokenplan-usage/index.ts", "src/memory/index.ts"],
minify: true,
dts: {
tsgo: true,
},
},
lint: {
options: {
typeAware: true,
typeCheck: true,
},
},
fmt: {},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "knowledge-studio-cli",
"version": "1.15.0",
"version": "1.14.2",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.15.0",
"version": "1.14.2",
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+1 -2
View File
@@ -78,12 +78,11 @@ function fromFetchFailed(err: TypeError): BailianError {
if (causeMsg && causeMsg !== code) detailParts.push(causeMsg);
const detail = detailParts.length > 0 ? detailParts.join(": ") : "unknown cause";
// Prefer the errno (ENOTFOUND, …) so JSON toJSON() exposes cause.code for agents.
return new BailianError(
`Network request failed: ${detail}`,
ExitCode.NETWORK,
pickNetworkHint(code),
{ cause: cause ?? err },
{ cause: err },
);
}
+1 -7
View File
@@ -42,13 +42,7 @@ export {
// Output facilities consumed by commands
export { emitResult, emitBare, emitRequestId } from "./output/output.ts";
export { formatTable } from "./output/table.ts";
export {
renderBoxTable,
renderGauge,
type BoxTableOptions,
type BarColumn,
type GaugeCell,
} from "./output/box-table.ts";
export { renderBoxTable, type BoxTableOptions, type BarColumn } from "./output/box-table.ts";
export { createSpinner, createProgressBar } from "./output/progress.ts";
export { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
export { maybeShowStatusBar } from "./output/status-bar.ts";
-20
View File
@@ -136,26 +136,6 @@ interface RenderedCell {
colored: string;
}
/** A standalone gauge cell (bar + label) for non-table layouts, e.g. the usage quota box. */
export interface GaugeCell {
plain: string;
colored: string;
}
/**
* Render a single gauge cell in the `usage free` table style: brand-cyan fill,
* transparent track, and a light-blue label after the bar. `percent` is 0-100;
* null renders an empty gauge with a neutral label.
*/
export function renderGauge(
percent: number | null,
label: string,
width: number = DEFAULT_BAR_WIDTH,
out: NodeJS.WriteStream = process.stdout,
): GaugeCell {
return buildBarCell(percent, label, width, colorLevel(out));
}
function buildBarCell(
percent: number | null,
label: string,
+14 -180
View File
@@ -10,11 +10,6 @@ import {
taskPath,
speechSynthesizePath,
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
stripUndefined,
resolveBooleanFlag,
resolveWatermark,
@@ -28,7 +23,6 @@ import {
type DashScopeTTSRequest,
type DashScopeTTSResponse,
type DashScopeASRRequest,
type DashScopeASRTaskResult,
type ChatMessageContent,
isLocalFile,
} from "bailian-cli-core";
@@ -579,103 +573,27 @@ export async function speechRecognize(
});
}
const model = input.model || "fun-asr";
const route = resolveAsrApi(model);
if (route.kind === "unsupported") {
throw new PipelineError(
"invalid_input",
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
{
step: "speech/recognize",
},
);
}
if (route.kind === "sync-flash") {
if (rawUrls.length !== 1) {
throw new PipelineError(
"invalid_input",
`Model "${model}" is a sync Flash ASR model and accepts exactly one url (got ${rawUrls.length})`,
{ step: "speech/recognize" },
);
}
const unsupportedFlags: string[] = [];
if (input.diarization) unsupportedFlags.push("diarization");
if (input["speaker-count"] !== undefined) unsupportedFlags.push("speaker-count");
// input-audio Flash supports vocabulary_id; qwen3 sync Flash does not
if (route.flashFamily === "qwen3" && input["vocabulary-id"] !== undefined) {
unsupportedFlags.push("vocabulary-id");
}
if (input["channel-id"] !== undefined) unsupportedFlags.push("channel-id");
if (unsupportedFlags.length > 0) {
throw new PipelineError(
"invalid_input",
`Model "${model}" uses sync Flash ASR and does not support: ${unsupportedFlags.join(", ")}`,
{ step: "speech/recognize" },
);
}
}
if (
route.kind === "async-filetrans" &&
route.asyncInputStyle === "file_url" &&
rawUrls.length !== 1
) {
throw new PipelineError(
"invalid_input",
`Model "${model}" accepts exactly one url (got ${rawUrls.length})`,
{ step: "speech/recognize" },
);
}
// Resolve local files to upload URLs
const fileUrls: string[] = [];
for (const audioUrl of rawUrls) {
if (isLocalFile(audioUrl)) {
for (const u of rawUrls) {
if (isLocalFile(u)) {
fileUrls.push(
await env.client.uploadFile(audioUrl, model, {
await env.client.uploadFile(u, input.model || "fun-asr", {
signal: ctx.signal,
}),
);
} else {
fileUrls.push(audioUrl);
fileUrls.push(u);
}
}
if (route.kind === "sync-flash") {
const flashFamily = route.flashFamily!;
const body = buildAsrFlashRequest({
model,
audioUrl: fileUrls[0]!,
language: input.language,
vocabularyId: input["vocabulary-id"],
flashFamily,
});
const response = await env.client.requestJson<Record<string, unknown>>({
path: route.path,
method: "POST",
headers: { "X-DashScope-SSE": "disable" },
body,
signal: ctx.signal,
});
return {
text: extractAsrFlashText(response, flashFamily),
model,
mode: "sync",
raw: response,
};
}
const languageFields = buildAsyncAsrLanguageFields(
route.asyncLanguageStyle ?? "language_hints",
input.language,
);
const model = input.model || "fun-asr";
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url" ? { file_url: fileUrls[0]! } : { file_urls: fileUrls },
input: { file_urls: fileUrls },
parameters: {
channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined,
...languageFields,
language_hints: input.language ? [input.language] : undefined,
diarization_enabled: input.diarization,
speaker_count: input["speaker-count"],
vocabulary_id: input["vocabulary-id"],
@@ -683,8 +601,9 @@ export async function speechRecognize(
};
stripUndefined(body.parameters as Record<string, unknown>);
const url = speechRecognizePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
path: speechRecognizePath(),
path: url,
method: "POST",
body,
async: true,
@@ -695,65 +614,7 @@ export async function speechRecognize(
const pollIntervalMs = (input["poll-interval"] ?? 2) * 1000;
const timeoutMs = (ctx.timeoutSeconds ?? 300) * 1000;
// ASR polling reads original output, avoids generic flatten (avoids transcription_url polluting media urls)
const asrTask = await pollAsrTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
const transcriptionItems = collectAsrTranscriptionItems(asrTask.output);
const base: Record<string, unknown> = {
task_id: asrTask.output.task_id,
task_status: asrTask.output.task_status,
request_id: asrTask.request_id,
mode: "async",
model,
};
if (asrTask.output.results) base.results = asrTask.output.results;
if (asrTask.output.result) {
base.result = asrTask.output.result;
if (typeof asrTask.output.result.transcription_url === "string") {
base.transcription_url = asrTask.output.result.transcription_url;
}
}
if (asrTask.output.task_metrics) base.task_metrics = asrTask.output.task_metrics;
if (asrTask.usage) base.usage = asrTask.usage;
if (transcriptionItems.length === 0) {
return base;
}
const texts: string[] = [];
const transcripts: Record<string, unknown>[] = [];
for (const item of transcriptionItems) {
if (!item.transcription_url) continue;
const transRes = await fetch(item.transcription_url, { signal: ctx.signal });
if (!transRes.ok) {
throw new PipelineError(
"async_task_failed",
`Failed to download transcription: HTTP ${transRes.status}`,
{ step: "speech/recognize", details: { taskId, url: item.transcription_url } },
);
}
const transData = (await transRes.json()) as Record<string, unknown>;
transcripts.push(transData);
const transcriptList = transData.transcripts as
| Array<{ text?: string; sentences?: Array<{ text?: string }> }>
| undefined;
if (!transcriptList?.length) continue;
for (const transcript of transcriptList) {
if (transcript.sentences?.length) {
for (const sentence of transcript.sentences) {
if (sentence.text) texts.push(sentence.text);
}
} else if (transcript.text) {
texts.push(transcript.text);
}
}
}
return {
...base,
text: texts.join("\n"),
transcripts,
};
return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
}
// --- Shared: task polling ---
@@ -779,7 +640,7 @@ function flattenTaskResponse(resp: DashScopeTaskResponse): Record<string, unknow
if (urls.length > 0) flat.urls = urls;
}
if (output.results) {
const urls = output.results.map((item) => item.url).filter(Boolean);
const urls = output.results.map((r) => r.url).filter(Boolean);
if (urls.length > 0 && !flat.urls) flat.urls = urls;
}
if (output.task_metrics) flat.task_metrics = output.task_metrics;
@@ -797,13 +658,13 @@ async function pollTask(
return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx);
}
async function pollUntilSucceeded(
async function pollTaskWithOptions(
env: PipelineEnv,
taskId: string,
pollIntervalMs: number,
timeoutMs: number,
ctx?: StepContext,
): Promise<DashScopeTaskResponse> {
): Promise<Record<string, unknown>> {
const started = Date.now();
let attempt = 0;
@@ -826,7 +687,7 @@ async function pollUntilSucceeded(
const status = result.output.task_status;
if (status === "SUCCEEDED") {
return result;
return flattenTaskResponse(result);
}
if (status === "FAILED") {
@@ -857,33 +718,6 @@ async function pollUntilSucceeded(
}
}
async function pollTaskWithOptions(
env: PipelineEnv,
taskId: string,
pollIntervalMs: number,
timeoutMs: number,
ctx?: StepContext,
): Promise<Record<string, unknown>> {
return flattenTaskResponse(await pollUntilSucceeded(env, taskId, pollIntervalMs, timeoutMs, ctx));
}
/** ASR task polling: preserve original output (includes results[] / result.transcription_url). */
async function pollAsrTaskWithOptions(
env: PipelineEnv,
taskId: string,
pollIntervalMs: number,
timeoutMs: number,
ctx?: StepContext,
): Promise<DashScopeASRTaskResult> {
return (await pollUntilSucceeded(
env,
taskId,
pollIntervalMs,
timeoutMs,
ctx,
)) as DashScopeASRTaskResult;
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve, reject) => {
+14 -51
View File
@@ -25,13 +25,6 @@ interface CommandNode {
children: Map<string, CommandNode>;
}
const AUTH_LABELS = {
apiKey: "API Key",
console: "Console",
openapi: "AK/SK",
none: "No Auth",
} satisfies Record<AuthRequirement, string>;
/**
* What a command path resolves to in the registry. The single judgement that
* feeds `resolve()` no scattered `isGroupPath` + throwing `resolve`.
@@ -164,37 +157,14 @@ export class CommandRegistry {
};
}
private buildCommandLines(
entries: Array<{ path: string; auth: AuthRequirement; desc: string }>,
accent: (text: string) => string,
dim: (text: string) => string,
): string {
const maxPathLength = Math.max(...entries.map((entry) => entry.path.length));
const maxAuthLength = Math.max(
...entries.map((entry) => `[${AUTH_LABELS[entry.auth]}]`.length),
);
const rows = entries.map((entry) => {
const authLabel = `[${AUTH_LABELS[entry.auth]}]`;
return ` ${accent(entry.path.padEnd(maxPathLength + 2))} ${accent(authLabel.padEnd(maxAuthLength + 2))} ${dim(entry.desc)}`;
});
return rows.join("\n");
}
private buildResourceLines(
accent: (text: string) => string,
dim: (text: string) => string,
): string {
const entries: Array<{ path: string; auth: AuthRequirement; desc: string }> = [];
private buildResourceLines(a: (s: string) => string, d: (s: string) => string): string {
const entries: Array<{ path: string; desc: string }> = [];
const collect = (node: CommandNode, prefix: string) => {
for (const [name, child] of node.children) {
const fullPath = prefix ? `${prefix} ${name}` : name;
if (child.command) {
entries.push({
path: fullPath,
auth: child.command.auth,
desc: child.command.description,
});
entries.push({ path: fullPath, desc: child.command.description });
}
if (child.children.size > 0) {
collect(child, fullPath);
@@ -203,7 +173,8 @@ export class CommandRegistry {
};
collect(this.root, "");
return this.buildCommandLines(entries, accent, dim);
const maxLen = Math.max(...entries.map((e) => e.path.length));
return entries.map((e) => ` ${a(e.path.padEnd(maxLen + 2))} ${d(e.desc)}`).join("\n");
}
private buildFlagLines(
@@ -370,7 +341,6 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
out.write(`\n${cmd.description}\n`);
out.write(`${b("Usage:")} ${prefix}${cmd.usageArgs ? ` ${cmd.usageArgs}` : ""}\n`);
out.write(`${b("Authentication:")} ${a(AUTH_LABELS[cmd.auth])}\n`);
const flagEntries = [
...Object.entries(cmd.flags ?? {}),
...Object.entries(credentialFlagDefs(cmd)),
@@ -403,25 +373,18 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b("Getting Help:")}
}
private printChildren(node: CommandNode, prefix: string, out: NodeJS.WriteStream): void {
const entries: Array<{ path: string; auth: AuthRequirement; desc: string }> = [];
const collect = (currentNode: CommandNode, currentPath: string) => {
for (const [name, child] of currentNode.children) {
const entries: Array<{ fullName: string; description: string }> = [];
const collect = (n: CommandNode, p: string) => {
for (const [name, child] of n.children) {
if (child.command)
entries.push({
path: `${currentPath} ${name}`,
auth: child.command.auth,
desc: child.command.description,
});
if (child.children.size > 0) collect(child, `${currentPath} ${name}`);
entries.push({ fullName: `${p} ${name}`, description: child.command.description });
if (child.children.size > 0) collect(child, `${p} ${name}`);
}
};
collect(node, prefix);
out.write(
this.buildCommandLines(
entries,
(text) => this.accent(text, out),
(text) => this.dim(text, out),
) + "\n",
);
const maxLen = Math.max(...entries.map((e) => e.fullName.length));
for (const { fullName, description } of entries) {
out.write(` ${this.accent(fullName.padEnd(maxLen), out)} ${this.dim(description, out)}\n`);
}
}
}
+10 -21
View File
@@ -209,25 +209,6 @@ function errorMessage(err: unknown): string {
return String(err);
}
async function syncAgentSkillsAfterUpdate(
dim: string,
green: string,
yellow: string,
reset: string,
): Promise<void> {
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
const { execSync } = await import("child_process");
execSync("bl skill init", { stdio: "inherit" });
process.stderr.write(` ${green}\u2713 Agent skill updated.${reset}\n\n`);
} catch (error) {
process.stderr.write(
` ${yellow}\u26a0 Agent skill sync failed: ${errorMessage(error)}${reset}\n`,
);
process.stderr.write(` ${yellow} Run manually: bl skill init${reset}\n\n`);
}
}
/**
* Perform auto-update for npm or binary installs.
* Returns true if update succeeded, false otherwise.
@@ -275,7 +256,6 @@ export async function performAutoUpdate(
writeState({ lastChecked: Date.now(), latestVersion: newVer });
process.stderr.write(` ${green}✓ Update complete: ${currentVersion}${newVer}${reset}\n`);
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
await syncAgentSkillsAfterUpdate(dim, green, yellow, reset);
pendingNotification = null;
return true;
} catch (err) {
@@ -317,7 +297,16 @@ export async function performAutoUpdate(
);
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
await syncAgentSkillsAfterUpdate(dim, green, yellow, reset);
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}
pendingNotification = null;
return true;
@@ -1,85 +0,0 @@
import { ExitCode } from "bailian-cli-core";
import { expect, test } from "vite-plus/test";
import { handleError } from "../src/error-handler.ts";
test("handleError: fetch failed JSON includes cause.code from errno", () => {
const previousOutput = process.env.DASHSCOPE_OUTPUT;
process.env.DASHSCOPE_OUTPUT = "json";
let stderr = "";
const originalWrite = process.stderr.write.bind(process.stderr);
const originalExit = process.exit;
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit;
const root = Object.assign(new Error("getaddrinfo ENOTFOUND example.invalid"), {
code: "ENOTFOUND",
});
const fetchFailed = new TypeError("fetch failed", { cause: root });
try {
expect(() => handleError(fetchFailed, "bl")).toThrow(
new RegExp(`process\\.exit:${ExitCode.NETWORK}`),
);
const payload = JSON.parse(stderr.trim()) as {
error: { code: number; message: string; cause?: { message: string; code?: string } };
};
expect(payload.error.code).toBe(ExitCode.NETWORK);
expect(payload.error.message).toMatch(/ENOTFOUND/);
expect(payload.error.cause).toEqual({
message: root.message,
code: "ENOTFOUND",
});
} finally {
process.stderr.write = originalWrite;
process.exit = originalExit;
if (previousOutput === undefined) {
delete process.env.DASHSCOPE_OUTPUT;
} else {
process.env.DASHSCOPE_OUTPUT = previousOutput;
}
}
});
test("handleError: fetch failed without nested cause still maps to NETWORK", () => {
const previousOutput = process.env.DASHSCOPE_OUTPUT;
process.env.DASHSCOPE_OUTPUT = "json";
let stderr = "";
const originalWrite = process.stderr.write.bind(process.stderr);
const originalExit = process.exit;
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit;
const fetchFailed = new TypeError("fetch failed");
try {
expect(() => handleError(fetchFailed, "bl")).toThrow(
new RegExp(`process\\.exit:${ExitCode.NETWORK}`),
);
const payload = JSON.parse(stderr.trim()) as {
error: { code: number; message: string; cause?: { message: string; code?: string } };
};
expect(payload.error.code).toBe(ExitCode.NETWORK);
expect(payload.error.message).toMatch(/unknown cause/);
expect(payload.error.cause).toEqual({ message: "fetch failed" });
} finally {
process.stderr.write = originalWrite;
process.exit = originalExit;
if (previousOutput === undefined) {
delete process.env.DASHSCOPE_OUTPUT;
} else {
process.env.DASHSCOPE_OUTPUT = previousOutput;
}
}
});
@@ -1,184 +0,0 @@
import { expect, test } from "vite-plus/test";
import type { Client } from "bailian-cli-core";
import { PipelineError } from "../src/pipeline/errors.ts";
import type { PipelineEnv } from "../src/pipeline/bl-config.ts";
import { speechRecognize } from "../src/pipeline/steps/bl-api.ts";
import type { StepContext } from "../src/pipeline/types.ts";
type CapturedRequest = {
path?: string;
method?: string;
headers?: Record<string, string>;
body?: Record<string, unknown>;
async?: boolean;
};
function makeEnv(requestJsonImpl?: (opts: CapturedRequest) => Promise<unknown>): {
env: PipelineEnv;
captured: CapturedRequest[];
} {
const captured: CapturedRequest[] = [];
const client = {
uploadFile: async (source: string) => source,
requestJson: async (opts: CapturedRequest) => {
captured.push(opts);
if (requestJsonImpl) return requestJsonImpl(opts);
return { output: { text: "ok" } };
},
} as unknown as Client;
return {
env: {
client,
settings: { quiet: true, output: "json" } as PipelineEnv["settings"],
},
captured,
};
}
function makeCtx(): StepContext {
return { dryRun: false, signal: new AbortController().signal };
}
test("pipeline speechRecognize routes input-audio flash to sync multimodal endpoint", async () => {
const { env, captured } = makeEnv();
const result = (await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen-audio-3.0-asr-flash",
language: "en",
"vocabulary-id": "vocab-1",
},
makeCtx(),
)) as { mode?: string; text?: string };
expect(result.mode).toBe("sync");
expect(result.text).toBe("ok");
expect(captured).toHaveLength(1);
expect(captured[0]?.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
expect(captured[0]?.headers?.["X-DashScope-SSE"]).toBe("disable");
expect(captured[0]?.body).toMatchObject({
model: "qwen-audio-3.0-asr-flash",
parameters: {
format: "wav",
language_hints: ["en"],
vocabulary_id: "vocab-1",
},
});
});
test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", async () => {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
return { output: { task_id: "task-1", task_status: "PENDING" } };
}
return {
output: { task_id: "task-1", task_status: "SUCCEEDED", results: [] },
request_id: "r1",
};
});
await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen3-asr-flash-filetrans",
language: "zh",
"poll-interval": 0,
},
makeCtx(),
);
expect(captured[0]?.path).toBe("/api/v1/services/audio/asr/transcription");
expect(captured[0]?.async).toBe(true);
expect(captured[0]?.body).toMatchObject({
model: "qwen3-asr-flash-filetrans",
input: { file_url: "https://example.com/a.wav" },
parameters: { language: "zh" },
});
expect(
(captured[0]?.body?.parameters as Record<string, unknown> | undefined)?.language_hints,
).toBeUndefined();
});
test("pipeline speechRecognize rejects realtime models before requesting", async () => {
const { env, captured } = makeEnv();
await expect(
speechRecognize(
env,
{ url: "https://example.com/a.wav", model: "qwen3-asr-flash-realtime" },
makeCtx(),
),
).rejects.toBeInstanceOf(PipelineError);
expect(captured).toHaveLength(0);
});
test("pipeline speechRecognize rejects multiple urls for sync flash", async () => {
const { env, captured } = makeEnv();
await expect(
speechRecognize(
env,
{
url: ["https://example.com/a.wav", "https://example.com/b.wav"],
model: "fun-asr-flash-2026-06-15",
},
makeCtx(),
),
).rejects.toBeInstanceOf(PipelineError);
expect(captured).toHaveLength(0);
});
test("pipeline speechRecognize downloads qwen3 singular result.transcription_url", async () => {
const originalFetch = globalThis.fetch;
const transcriptionUrl = "https://example.com/transcription.json";
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
expect(url).toBe(transcriptionUrl);
return new Response(
JSON.stringify({
transcripts: [{ text: "pipeline hello", sentences: [{ text: "pipeline hello" }] }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
try {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
return { output: { task_id: "task-1", task_status: "PENDING" } };
}
return {
output: {
task_id: "task-1",
task_status: "SUCCEEDED",
result: { transcription_url: transcriptionUrl },
},
request_id: "r1",
};
});
const result = (await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen3-asr-flash-filetrans",
"poll-interval": 0,
},
makeCtx(),
)) as {
mode?: string;
text?: string;
transcription_url?: string;
result?: { transcription_url?: string };
};
expect(captured[0]?.async).toBe(true);
expect(result.mode).toBe("async");
expect(result.text).toBe("pipeline hello");
expect(result.transcription_url).toBe(transcriptionUrl);
expect(result.result?.transcription_url).toBe(transcriptionUrl);
} finally {
globalThis.fetch = originalFetch;
}
});
+1 -54
View File
@@ -1,58 +1,13 @@
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 binaryUpdateMocks = vi.hoisted(() => ({
performBinaryUpdate: vi.fn(),
}));
const childProcessMocks = vi.hoisted(() => ({
execSync: vi.fn(),
}));
vi.mock("../src/utils/binary-update.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/utils/binary-update.ts")>();
return { ...actual, performBinaryUpdate: binaryUpdateMocks.performBinaryUpdate };
});
vi.mock("child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("child_process")>();
return { ...actual, execSync: childProcessMocks.execSync };
});
import { expect, test } from "vite-plus/test";
import {
compareVersion,
isMajorUpgrade,
isNewerVersion,
isPrerelease,
parseVersion,
performAutoUpdate,
shouldAutoUpdate,
} from "../src/utils/update-checker.ts";
let configDir: string;
let previousConfigDir: string | undefined;
let previousInstallMethod: string | undefined;
beforeEach(() => {
configDir = mkdtempSync(join(tmpdir(), "bl-auto-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";
binaryUpdateMocks.performBinaryUpdate.mockResolvedValue("2.0.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("parseVersion strips pre-release and build metadata", () => {
expect(parseVersion("1.4.2")).toEqual([1, 4, 2]);
expect(parseVersion("2.0.0-beta.1")).toEqual([2, 0, 0]);
@@ -169,11 +124,3 @@ test("shouldAutoUpdate only targets stable releases with a significant gap", ()
// Same core, release over its pre-release: notify only (no major gap).
expect(shouldAutoUpdate("1.4.2", "1.4.2-beta.1")).toBe(false);
});
test("binary auto-update syncs bailian skills after the CLI update succeeds", async () => {
const updated = await performAutoUpdate("1.14.3", "2.0.0");
expect(updated).toBe(true);
expect(binaryUpdateMocks.performBinaryUpdate).toHaveBeenCalledWith("2.0.0");
expect(childProcessMocks.execSync).toHaveBeenCalledWith("bl skill init", { stdio: "inherit" });
});
+1876 -337
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -24,6 +24,10 @@ catalogMode: prefer
overrides:
vite: "catalog:"
vitest: "catalog:"
# The @deepseek-ai/dsh-* rc line (used only by bailian-cli-dsh) peers on
# packages that were never published: dsh-type-meta, dsh-environment,
# dsh-tasks. Auto-installing peers therefore 404s the whole workspace.
autoInstallPeers: false
peerDependencyRules:
allowAny:
- vite

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