Compare commits

...

6 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
30 changed files with 2586 additions and 1083 deletions
+4
View File
@@ -88,6 +88,8 @@ import {
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
tokenPlanPersonalUsage,
tokenPlanPersonalKey,
workspaceInit,
pluginInstall,
pluginLink,
@@ -212,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,
@@ -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}`;
}
}
}
@@ -56,15 +56,17 @@ export function inlineStatePath(agentName: string): string {
/**
* The minimal in-memory project config that materializes into one cloud agent.
* `providers.bailian` carries empty `api_key`/`base_url` placeholders so
* {@link injectProviderCredentials} fills them from bl's auth chain (it only
* writes fields the block already declares).
* `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: "" },
bailian: { api_key: "", base_url: "", workspace_id: "" },
},
defaults: { provider: "bailian" },
environments: {
@@ -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
View File
@@ -95,6 +95,8 @@ 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";
@@ -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({}));
@@ -162,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 = {
+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)),
+4
View File
@@ -0,0 +1,4 @@
# build artifacts (regenerated by `pnpm build`)
client.bundle.js
dist/
*.tgz
+147 -165
View File
@@ -2,79 +2,46 @@
把阿里云百炼Model Studio的能力接入 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)`dsh`)的 profile bundle。
一个包提供 5 个插件行,外加对 base bundle 的 `llm-pi-ai` 行做一次配置覆盖
包提供两项能力
| row id | 能力 | 默认 | 依赖 |
| ---------------------------- | --------------------------------------------------------------- | ---- | --------------------- |
| `llm-pi-ai`(覆盖 base 行) | 把百炼 TokenPlan 网关注册成 LLM provider`bailian-tokenplan` | 启用 | TokenPlan Key |
| `bailian-tool-vision` | `bailian_vision_describe`:图片/视频理解 | 启用 | `bl` |
| `bailian-tool-image` | `bailian_image_generate`:文生图 | 启用 | `bl` |
| `bailian-tool-managed-agent` | `bailian_run_remote_task`:按需在云端创建 agent 并跑任务 | 启用 | `bl` + 按量付费 Key |
| `bailian-web-search-rag` | 百炼知识库检索,注册为 `web_search` 的后端 | 停用 | 按量付费 Key + 知识库 |
| `bailian-memory` | 跨会话长期记忆tools + 自动检索/落库) | 停用 | 按量付费 Key |
`web-search-rag``memory` 默认停用是有意的:它们要么需要部署方特有的资源 ID要么按次计费不该在用户没配置时就生效。`tool-managed-agent` 默认启用——它加载时不建任何资源,只有模型真正调用时才在云端创建 agent。
| 能力 | 说明 |
| ------------------ | --------------------------------------------------------------------------------------------------------------------- |
| **Bailian 设置页** | 通用的百炼凭证配置AK/SK 存入 `dsh` bl profile + DashScope API Key+ TokenPlan 用量展示 + 记忆库配置 + 新会话欢迎页 |
| **跨会话长期记忆** | 自动检索注入 + 自动落库,模型可主动 search/add/list。按量计费默认停用 |
---
## 1. 前置条件
- Node ≥ 22.19`dsh` 的要求)
- `bl`vision / image / 远程任务 三个工具通过子进程调它
- `bl`用量展示通过子进程调用 `bl console call`
```sh
npm install -g bailian-cli
```
- 百炼 API Key。**注意有两类且不可混用**
- **阿里云 AK/SK**AccessKey ID + AccessKey Secret—— 用于控制台鉴权,查询用量信息。在 webui 设置页填入即可,无需环境变量。
- **DashScope API Key**`sk-` 前缀,按量付费)—— 用于记忆库等 DashScope API 调用。在设置页「凭证配置」填入,与 AK/SK 并列为通用凭证。
| 类型 | 前缀 | 能访问 | 不能访问 |
| --------- | -------- | --------------------------------------- | -------------- |
| TokenPlan | `sk-sp-` | TokenPlan 网关LLM / vision / 文生图) | 记忆库、知识库 |
| 按量付费 | `sk-ws-` | 记忆库、知识库、DashScope 全量接口 | TokenPlan 网关 |
两者互相返回 `401 InvalidApiKey`,所以本包用**两个不同的环境变量**,不会互相踩:
```sh
export BAILIAN_TOKENPLAN_API_KEY=sk-sp-xxx # 只给 bailian-tokenplan provider
export DASHSCOPE_API_KEY=sk-ws-xxx # 给 bl、memory、RAG
```
只有一类 Key 也能用,只是能力范围相应缩小。若只有 TokenPlan Key
```sh
export BAILIAN_TOKENPLAN_API_KEY=sk-sp-xxx
export DASHSCOPE_API_KEY=sk-sp-xxx
export DASHSCOPE_BASE_URL=https://token-plan.cn-beijing.maas.aliyuncs.com
```
这样 LLM / vision / 文生图可用memory 与 RAG 不可用(保持停用即可)。
获取方式:[阿里云控制台 → AccessKey 管理](https://ram.console.aliyun.com/manage/ak)
---
## 2. 安装到 `web` profile
`npx @deepseek-ai/dsh web``dsh --profile web` 的别名,所以要装进**名为 `web` 的 profile**配置目录是 `~/.dsh/profiles/web/``$DSH_HOME` 可覆盖)
本包尚未发布到 npm先在本仓库打包
`npx @deepseek-ai/dsh web``dsh --profile web` 的别名,配置目录是 `~/.dsh/profiles/web/`
```sh
pnpm -F bailian-cli-dsh build
cd packages/dsh && pnpm pack # 产出 bailian-cli-dsh-<version>.tgz
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
```
装入 profile`dsh plugin` 是 pnpm 的转发器,接受本地路径 / tarball / npm 包名 / git
确认 bailian 行都在
```sh
npx @deepseek-ai/dsh plugin --profile web add /absolute/path/to/bailian-cli-dsh-1.14.2.tgz
```
因为 `package.json` 声明了 `dsh.bundle`,安装后会自动加入该 profile 的 bundle 层,无需手动改 `cordis.patch.yml`
确认 5 个插入行都在,且 TokenPlan provider 已配到 `llm-pi-ai` 上:
```sh
npx @deepseek-ai/dsh --profile web --dump-config | grep -E 'bailian|tokenplan'
npx @deepseek-ai/dsh --profile web --dump-config | grep -E 'bailian'
```
启动:
@@ -85,159 +52,134 @@ npx @deepseek-ai/dsh web
Web UI 在 http://127.0.0.1:3080。
> 发布到 npm 后直接 `npx @deepseek-ai/dsh plugin --profile web add bailian-cli-dsh`,跳过打包步骤。
---
## 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 表单会留空,但点击「查询用量」会使用行内凭证。
---
## 3. 开箱能用的部分
## 4. 跨会话长期记忆
装完不做任何配置就生效:
默认停用(按量计费)。在 `cordis.patch.yml` 中设 `disabled: false` 启用,然后在设置页配置 API Key 和参数。
**LLM provider** — 模型选择器里出现 `bailian-tokenplan`,可选模型(已逐个实测):
### 功能
| 模型 | 读图 |
| ------------------------ | ------------------ |
| `qwen3.8-max` | 是 |
| `qwen3.7-plus` | 是 |
| `qwen3.6-flash` | 是 |
| `glm-5.2` | 是 |
| `qwen3.7-max` | 否(传图直接 400 |
| `deepseek-v4-pro` | 否 |
| `deepseek-v4-flash-0731` | 否 |
- **自动检索注入**:新会话首轮,用用户消息搜索记忆,将结果注入上下文(`autoInject`,默认开启)
- **自动落库**:每轮结束,将该轮新消息发送到记忆库 add API`autoPersist`,默认开启)
- **模型工具**`bailian_memory_search`(检索)、`bailian_memory_add`(存储)、`bailian_memory_list`(浏览)
**三个工具** — `bailian_vision_describe``bailian_image_generate``bailian_run_remote_task`
### 触发机制
前两个走 TokenPlanvision/image`bailian_run_remote_task` 见 [§3.1](#31-远程任务-bailian_run_remote_task)——它默认启用但用的是**按量付费 Key + dashscope 端点**,与 TokenPlan 那两个不同。
| 时机 | 触发方式 |
| ---------- | --------------------------------------------------------- |
| 新会话首轮 | 自动检索记忆注入上下文(`agent/pre-step` 事件) |
| 对话中 | 模型主动调用 `bailian_memory_search`/`bailian_memory_add` |
| 轮次结束 | 自动落库新消息(`agent/turn-stopping` 事件) |
### 关于看图,有个坑值得知道
### 凭证与配置
dsh 会在两处**提前**拦截图片Web UI 粘图前会查当前模型的输入模态,`read_image` 也有同样的门禁。所以主模型选 DeepSeek 时,图片根本进不到对话里。
- **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留空用默认
- 主模型选 `qwen3.8-max` 等标着"是"的 → 直接粘图,原生看图,不需要任何工具
- 主模型选 DeepSeek → 让它调 `bailian_vision_describe`,工具返回**文字描述**,绕过模态门禁
### 计费
DeepSeek 那两个模型在 TokenPlan 网关上传图**不报错但也看不见**(实测会回答 "None"),所以本包坚决没给它们声明 `input: [image]`——否则会从"明确拒绝"退化成"静默失明",更难排查。
- Add120 QPM
- Search300 QPMLite ¥0.00002/次Pro ¥0.001/次)
- 总计不超过 3000 QPM
`bailian_image_generate` 同理:模型能看图时返回内联图片,不能看图时降级为返回落盘路径,你可以接着用 vision 工具读它。文件不会被删除,正是为了这个衔接。
### 3.1 远程任务(`bailian_run_remote_task`
把一个任务甩到百炼云端的托管 agent 上跑,不占本地会话。**无需预先写 `agents.yaml``apply`**:工具首次被调用时,`bl managed-agent run` 会在你的账号里幂等创建一个 agent + cloud environment之后复用。
- 模型自己按用户意图填 `instructions`(远程 agent 的角色),`task` 是要它做的事。例如你说「在云端帮我审计这个依赖树,它该懂安全」→ 模型调 `bailian_run_remote_task(task="审计依赖树", instructions="你是安全专家")`
- **前提**:这条路走的是 managed-agentagentstudio服务需要**按量付费 Key**`sk-ws-`+ dashscope 端点,且账号已开通 managed-agent。TokenPlan Key 不适用。若 `DASHSCOPE_API_KEY`/端点没配好,首次调用会返回 `Bailian API 404`
- 首次会创建云资源(可能计费、启动有延迟);同名 agent 后续复用。默认 agent 名 `dsh-remote-runner`,可在配置里改。
需要非默认的 agent 名 / 模型时:
```yaml
- id: bailian-tool-managed-agent
config:
agent: my-runner
model: qwen3.8-max
timeoutMs: 600000
```
---
## 4. 开启可选插件
用户层配置写在 `~/.dsh/profiles/web/cordis.patch.yml`,按 row `id` 覆盖 bundle 的默认值。
> **一个必须记住的语义**patch 是按 row **整体替换 `config`**,不是深合并。所以覆盖一行时要把该行完整的 config 重写一遍。
### 知识库检索RAG
注册 id 为 `bailian-kb` 的搜索后端,模型用它熟悉的 `web_search` 就能检索私域文档。
```yaml
- id: bailian-web-search-rag
disabled: false
config:
workspaceId: llm-xxxxxxxx # 百炼控制台工作空间 ID
agentId: aid-xxxxxxxx # 知识库"检索服务"ID
maxResults: 10
# apiKey 省略则读 $DASHSCOPE_API_KEY
```
一个实例对一个知识库(`WebSearchRequest` 只带 `query` / `maxResults`agentId 只能来自配置)。要多个知识库就插多行不同 `id`
**如果 profile 里还有别的搜索 provider**base bundle 默认带 `web-search-deepseek`),必须显式指定用哪个,否则 dsh 报 `WEB_PROVIDER_AMBIGUOUS`
```yaml
- id: web
config:
searchProvider: bailian-kb
```
### 长期记忆
dsh 自身没有跨会话记忆(`ctx.compaction` 只在单会话内压缩上下文)。开启后:两个工具 `bailian_memory_search` / `bailian_memory_add`,加上每个会话首轮自动检索注入、每轮结束自动落库。
### 启用
```yaml
- id: bailian-memory
disabled: false
config:
userId: your-name # 省略则读 $BAILIAN_MEMORY_USER_ID再退到系统用户名
planVersion: lite
baseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/memory/"
planVersion: "lite"
topK: 10
autoInject: true
injectEveryTurn: false # 开启会变成每轮一次检索,成本相应上升
autoPersist: true
```
**费用**:记忆库自 2026-08-20 起商业化add 与 search 按次计费pro 档约为 lite 档的 50 倍
启用后在设置页「记忆库」section 配置 API Key 和参数
实测发现一个与文档不符的地方:单独传 `plan_version: lite` 会被服务端忽略、仍按 pro 计费,真正生效的开关是 `enable_rerank: false`。本插件已按此处理——`planVersion: lite`(默认)会同时下发 `enable_rerank: false`,所以默认就是便宜的那档
不想要自动行为、只保留手动工具:
```yaml
- id: bailian-memory
disabled: false
config:
userId: your-name
autoInject: false
autoPersist: false
```
> 远程任务(`bailian_run_remote_task`)默认启用,配置见 [§3.1](#31-远程任务-bailian_run_remote_task)。
---
> 记忆库调用 DashScope memory v2 API`bl memory`),因为 v2 API 暴露了 `min_score``enable_rerank``plan_version``memory_library_id` 等参数 `bl memory` 不支持
## 5. 验证
```sh
# 配置是否被正确合成(改完 patch 后先看这个)
npx @deepseek-ai/dsh --profile web --dump-config | grep -A5 bailian-memory
# 配置合成
npx @deepseek-ai/dsh --profile web --dump-config | grep bailian
# bl 是否就绪
# bl 就绪
bl auth status
```
启动后逐项试
启动后验证
- **LLM**切到 `bailian-tokenplan / qwen3.8-max`,随便发一句
- **原生看图**同上模型,直接粘一张图提问
- **间接看图**切到 `deepseek-v4-pro`,让它用 `bailian_vision_describe` 读同一张图
- **文生图**让模型生成一张图
- **RAG**:问一个只有知识库里才有答案的问题
- **记忆**:会话 A 告诉它一个事实 → 关掉 → 新开会话 B 提问,看是否命中
- **远程任务**:说「在云端帮我跑一个任务:<something>,它该擅长 <role>」→ 确认模型调用 `bailian_run_remote_task``instructions` 由模型按 role 填)→ 首次触发云端创建 → 返回远程会话结果(需按量付费 Key + 已开通 agentstudio
- **欢迎页**新开一个会话,输入框上方出现「百炼 Agent」欢迎页
- **凭证配置**打开 Settings → Bailian → 填入 AK/SK → 保存凭证
- **用量展示**同页面选择区域 → 查询用量
- **记忆库**启用 `bailian-memory` 后,同页面配置 API Key
---
## 6. 常见问题
| 现象 | 原因 |
| -------------------------------------- | ---------------------------------------------------------------------- |
| LLM 路由 `401 InvalidApiKey` | `BAILIAN_TOKENPLAN_API_KEY` 没设,或误填了 `sk-ws-` 的按量付费 Key |
| memory / RAG `401 InvalidApiKey` | `DASHSCOPE_API_KEY` 误填了 `sk-sp-` 的 TokenPlan Key |
| `WEB_PROVIDER_AMBIGUOUS` | 有多个搜索 provider需在 `web` 行 pin `searchProvider` |
| 粘图报 `MODEL_DOES_NOT_SUPPORT_IMAGES` | 当前模型不支持图片输入,换成上表标"是"的,或改用 vision 工具 |
| 工具报找不到 `bl` | `bl` 不在 PATH`npm install -g bailian-cli` |
| 改了 patch 但没生效 | `config` 是整体替换,检查是否漏写了原有字段;再用 `--dump-config` 确认 |
| `memoryLibraryId does not exist` | 记忆库 ID 属于另一个账号,与当前 Key 不匹配 |
| 现象 | 原因 |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 用量查询报 `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` 再装 |
---
@@ -247,4 +189,44 @@ bl auth status
npx @deepseek-ai/dsh plugin --profile web remove bailian-cli-dsh
```
移除后 bundle 层会自动从 `dsh.profile.bundles` 摘掉;`~/.dsh/profiles/web/cordis.patch.yml` 里你手写的覆盖行需要自己清理。
---
## 架构说明
### 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 工具),已移除工具实现但保留共享逻辑作为参考。
+39 -83
View File
@@ -1,97 +1,53 @@
# bailian-cli-dsh — Aliyun Model Studio (Bailian) as a dsh profile bundle.
#
# Applied over whatever the earlier layers composed: one config override of the
# base bundle's dormant pi-ai adapter, then one insert of the Bailian tool rows.
# 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.
# TokenPlan configures the base bundle's existing `llm-pi-ai` row instead of
# inserting a second @deepseek-ai/dsh-llm-pi-ai instance. That plugin declares
# pi-ai's entire built-in provider catalog to
# `ctx.llm.registerConfigurableProviders` on every apply, and that registry
# refuses an already-declared provider, so a second instance always fails the
# whole tree with DUPLICATE_DIRECTORY on `amazon-bedrock`. Only adapter routes
# are per-instance; the configurable-provider directory is global.
#
# Replacing this row's whole `config` costs nothing: the base mounts it dormant
# with no config of its own. A profile that needs to drop or re-aim TokenPlan
# restates this row's config rather than disabling an id.
- id: llm-pi-ai
config:
providers:
bailian-tokenplan:
displayName: Aliyun Bailian TokenPlan
api: openai-completions
baseURL: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
# A dedicated name, not DASHSCOPE_API_KEY. TokenPlan keys (sk-sp-)
# and pay-as-you-go keys (sk-ws-) are not interchangeable: this
# gateway 401s a pay-as-you-go key, and the memory / knowledge-base
# endpoints 401 a TokenPlan key. Sharing one variable would make
# whichever plugin loses silently fail to authenticate.
apiKeyEnv: BAILIAN_TOKENPLAN_API_KEY
compat:
thinkingFormat: qwen
supportsReasoningEffort: true
# Verified against GET /compatible-mode/v1/models plus a per-model
# image probe on 2026-08-14. Only `id` is required; context window
# and max tokens fall back to this provider's defaults.
#
# `input: [text, image]` is a claim about the endpoint, not a
# checked fact, and it is what opens the Web UI paste path and
# `read_image`. Every entry carrying it answered a colour question
# about a test PNG correctly. The DeepSeek routes deliberately do
# NOT carry it: they accept image content without erroring and then
# answer "None", so declaring vision would turn a clean refusal
# into a silently blind reply. Use `bailian_vision_describe` there.
#
# Omitted on purpose: wan2.7-image / wan2.7-image-pro are
# generation-only (reach them through `bailian_image_generate`) and
# qwen-audio-3.0-* are audio endpoints, not chat completions.
models:
- id: qwen3.8-max
input: [text, image]
- id: qwen3.7-plus
input: [text, image]
- id: qwen3.6-flash
input: [text, image]
# Rejects image content outright with HTTP 400.
- id: qwen3.7-max
- id: glm-5.2
input: [text, image]
compat:
thinkingFormat: deepseek
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
- id: deepseek-v4-flash-0731
compat:
thinkingFormat: deepseek
- insert:
- id: bailian-tool-vision
name: bailian-cli-dsh/tool-vision
# 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
- id: bailian-tool-image
name: bailian-cli-dsh/tool-image
# Enabled by default: the tool creates no resources at load time. It only
# provisions a cloud agent when the model actually calls it, and reuses it
# after — no deployment-specific ID to configure up front.
- id: bailian-tool-managed-agent
name: bailian-cli-dsh/tool-managed-agent
# Disabled by default: the knowledge base to query is deployment-specific,
# and an enabled provider with no agentId would make `web_search` ambiguous
# for everyone. Set workspaceId + agentId and flip `disabled` to use it.
- id: bailian-web-search-rag
name: bailian-cli-dsh/web-search-rag
disabled: true
# 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.
# 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: {}
config:
baseUrl: "https://dashscope.aliyuncs.com/api/v2/apps/memory/"
planVersion: "lite"
topK: 10
autoInject: true
autoPersist: true
+14 -23
View File
@@ -1,7 +1,7 @@
{
"name": "bailian-cli-dsh",
"version": "1.14.2",
"description": "Aliyun Model Studio (Bailian) plugin bundle for DeepSeek Harness (dsh): TokenPlan LLM provider, knowledge-base RAG search, vision, image generation, long-term memory, and managed-agent subagents.",
"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"
@@ -16,6 +16,7 @@
"files": [
"README.md",
"dist",
"client.bundle.js",
"cordis.patch.yml"
],
"type": "module",
@@ -23,28 +24,17 @@
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
"default": "./dist/index.mjs"
},
"./tool-vision": {
"types": "./src/tool-vision/index.ts",
"default": "./src/tool-vision/index.ts"
},
"./tool-image": {
"types": "./src/tool-image/index.ts",
"default": "./src/tool-image/index.ts"
},
"./tool-managed-agent": {
"types": "./src/tool-managed-agent/index.ts",
"default": "./src/tool-managed-agent/index.ts"
},
"./web-search-rag": {
"types": "./src/web-search-rag/index.ts",
"default": "./src/web-search-rag/index.ts"
"./tokenplan-usage": {
"types": "./src/tokenplan-usage/index.ts",
"default": "./dist/tokenplan-usage/index.mjs"
},
"./memory": {
"types": "./src/memory/index.ts",
"default": "./src/memory/index.ts"
"default": "./dist/memory/index.mjs"
},
"./client": "./client.bundle.js",
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
},
@@ -52,18 +42,16 @@
"access": "public",
"exports": {
".": "./dist/index.mjs",
"./tool-vision": "./dist/tool-vision/index.mjs",
"./tool-image": "./dist/tool-image/index.mjs",
"./tool-managed-agent": "./dist/tool-managed-agent/index.mjs",
"./web-search-rag": "./dist/web-search-rag/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",
"build": "vp pack && node scripts/build-client.mjs",
"dev": "vp pack --watch",
"test": "vp test",
"check": "vp check"
@@ -106,6 +94,9 @@
"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);
}
+14 -3
View File
@@ -1,9 +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
* through that field; this module carries no runtime API.
* 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
*/
export {};
/** 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 {}
+353 -101
View File
@@ -1,20 +1,25 @@
/**
* `bailian-cli-dsh/memory`: cross-session long-term memory backed by Bailian's
* hosted memory library.
* `bailian-cli-dsh/memory` (Host half): cross-session long-term memory backed
* by Bailian's hosted memory library (DashScope memory v2 API).
*
* dsh has no memory seam `ctx.compaction` only summarizes within one
* session's context window and never writes across sessions so this plugin
* supplies the whole capability: two tools for deliberate reads and writes,
* plus automatic retrieval and persistence around each turn.
* 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`, `meta_data`) the CLI does not surface.
* 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, and `pro` costs roughly fifty
* times `lite` per search. Automatic behaviour therefore defaults to `lite`,
* retrieves once per session rather than once per turn, and never requests
* profile extraction unless a schema is configured.
* 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
*/
@@ -26,45 +31,49 @@ 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 { dashScopeFetch, resolveApiKey, resolveBaseUrl } from "../shared/http.ts";
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"];
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 the OS user. */
/** 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 it skips profile extraction (and its cost). */
/** Profile template id; omitting skips profile extraction (and its cost). */
profileSchema?: string;
/** `lite` disables rerank and is ~50x cheaper per search. */
/** Search strategy; pro enables rerank at ~50x the cost. */
planVersion?: "lite" | "pro";
topK?: number;
minScore?: number;
/** Retrieve relevant memories and inject them into the conversation. */
/** Retrieve relevant memories and inject into the conversation. */
autoInject?: boolean;
/** Retrieve on every turn instead of once per session. Costs one search per turn. */
/** 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("DashScope key; defaults to $DASHSCOPE_API_KEY."),
baseUrl: z.string().description("DashScope base URL override."),
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; defaults to the account default."),
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 enables rerank at ~50x the cost."),
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."),
@@ -72,9 +81,12 @@ export const Config: z<Config> = z.object({
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 MEMORY_PATH = "/api/v2/apps/memory";
const CONFIG_ROUTE = "/bailian/memory/config";
const STATUS_ROUTE = "/bailian/memory/status";
interface MemoryNode {
memory_node_id?: string;
@@ -83,11 +95,16 @@ interface MemoryNode {
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 {
@@ -95,7 +112,23 @@ interface ChatTurn {
content: string;
}
/** Resolution order: explicit config, then environment, then the OS user. */
/** 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;
@@ -123,19 +156,52 @@ function conversationTurns(messages: readonly Message[]): ChatTurn[] {
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 config: Config,
private readonly cfg: MemoryRuntimeConfig,
private readonly userId: string,
) {}
private shared(): Record<string, unknown> {
return {
user_id: this.userId,
...(this.config.memoryLibraryId !== undefined
? { memory_library_id: this.config.memoryLibraryId }
...(this.cfg.memoryLibraryId !== undefined
? { memory_library_id: this.cfg.memoryLibraryId }
: {}),
};
}
@@ -146,7 +212,7 @@ class MemoryClient {
overrides?: { customContent?: string; metaData?: Record<string, unknown> },
): Promise<MemoryResponse> {
return dashScopeFetch<MemoryResponse>({
url: `${this.baseUrl}${MEMORY_PATH}/add`,
url: `${this.baseUrl}add`,
method: "POST",
apiKey: this.apiKey,
signal,
@@ -155,10 +221,8 @@ class MemoryClient {
...(overrides?.customContent !== undefined
? { custom_content: overrides.customContent }
: { messages }),
...(this.config.projectId !== undefined ? { project_id: this.config.projectId } : {}),
...(this.config.profileSchema !== undefined
? { profile_schema: this.config.profileSchema }
: {}),
...(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 } : {}),
},
});
@@ -169,47 +233,64 @@ class MemoryClient {
signal: AbortSignal | undefined,
overrides?: { topK?: number; minScore?: number; planVersion?: "lite" | "pro" },
): Promise<MemoryResponse> {
const planVersion = overrides?.planVersion ?? this.config.planVersion ?? DEFAULT_PLAN_VERSION;
const planVersion = overrides?.planVersion ?? this.cfg.planVersion ?? DEFAULT_PLAN_VERSION;
return dashScopeFetch<MemoryResponse>({
url: `${this.baseUrl}${MEMORY_PATH}/memory_nodes/search`,
url: `${this.baseUrl}memory_nodes/search`,
method: "POST",
apiKey: this.apiKey,
signal,
body: {
...this.shared(),
messages,
top_k: overrides?.topK ?? this.config.topK ?? DEFAULT_TOP_K,
...((overrides?.minScore ?? this.config.minScore) !== undefined
? { min_score: overrides?.minScore ?? this.config.minScore }
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. Sending
// `plan_version: lite` alone still bills `pro` (verified against the
// live API), despite the documented precedence, and pro costs ~50x
// more per search. Send both: the flag that works, plus the
// documented field in case the server-side precedence is fixed.
// 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.config.projectId !== undefined ? { project_ids: [this.config.projectId] } : {}),
...(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 renderMemories(nodes: readonly MemoryNode[]): string {
const lines = nodes
function formatMemories(nodes: readonly MemoryNode[]): string {
const items = nodes
.map((node) => node.content?.trim())
.filter((content): content is string => content !== undefined && content.length > 0)
.map((content) => `- ${content}`);
return `What you remember about this user from earlier sessions:\n${lines.join("\n")}`;
.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")}`;
}
function registerTools(ctx: Context, client: MemoryClient): void {
/** 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.",
"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)." },
@@ -240,18 +321,23 @@ function registerTools(ctx: Context, client: MemoryClient): void {
text:
value.memories.length === 0
? "No relevant memories."
: value.memories.map((memory) => `- ${memory.content}`).join("\n"),
: value.memories.map((m: any) => `- ${m.content}`).join("\n"),
},
],
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const response = await client.search([{ role: "user", content: args.query }], exec.signal, {
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: (response.memory_nodes ?? []).map((node) => ({
memories: (result.memory_nodes ?? []).map((node) => ({
id: node.memory_node_id ?? "",
content: node.content ?? "",
})),
@@ -264,8 +350,7 @@ function registerTools(ctx: Context, client: MemoryClient): void {
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.",
"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." },
},
@@ -280,40 +365,117 @@ function registerTools(ctx: Context, client: MemoryClient): void {
],
},
async execute(args, exec) {
const response = await client.add([], exec.signal, { customContent: args.content });
return { stored: (response.memory_nodes ?? []).length };
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,
};
},
}),
);
}
function registerLifecycle(ctx: Context, client: MemoryClient, config: Config): void {
const injected = new WeakSet<Agent>();
const persistedUpTo = new WeakMap<Agent, number>();
/** 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>();
if (config.autoInject !== false) {
const currentCfg = cfg();
if (currentCfg.autoInject !== false) {
ctx.on(
"agent/pre-step",
async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
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 (injected.has(agent) && config.injectEveryTurn !== true) return decision;
if (injectedSessions.has(agent) && currentCfg.injectEveryTurn !== true) return decision;
const query = textOf(messages.flatMap((message) => message.content));
const query = textOf(messages.flatMap((m) => m.content));
if (query.length === 0) return decision;
let nodes: readonly MemoryNode[];
const mem = client();
if (mem === undefined) return decision;
let nodes: readonly MemoryNode[] = [];
try {
const response = await client.search([{ role: "user", content: query }], signal);
nodes = response.memory_nodes ?? [];
const result = await mem.search([{ role: "user", content: query }], signal);
nodes = result.memory_nodes ?? [];
} catch {
// Recall is an enhancement; a memory-service outage must not stop the turn.
return decision;
}
injected.add(agent);
injectedSessions.add(agent);
if (nodes.length === 0) return decision;
const text = renderMemories(nodes);
const text = formatMemories(nodes);
return {
...decision,
messages: [
@@ -334,36 +496,126 @@ function registerLifecycle(ctx: Context, client: MemoryClient, config: Config):
);
}
if (config.autoPersist !== false) {
ctx.on("agent/turn-stopping", async ({ agent, signal }): Promise<void> => {
const turns = conversationTurns(agent.session.deriveMessages());
const from = persistedUpTo.get(agent) ?? 0;
const fresh = turns.slice(from);
if (fresh.length === 0) return;
persistedUpTo.set(agent, turns.length);
try {
await client.add(fresh, signal);
} catch {
// Persistence is best-effort; never fail a turn over it.
persistedUpTo.set(agent, from);
}
});
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 apiKey = resolveApiKey(ctx, config.apiKey);
if (apiKey === undefined) {
throw new Error(
"bailian-memory: no DashScope API key. Set $DASHSCOPE_API_KEY or configure `apiKey`.",
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,
});
},
}),
);
}
const client = new MemoryClient(
apiKey,
resolveBaseUrl(ctx, config.baseUrl),
config,
resolveUserId(ctx, config),
);
registerTools(ctx, client);
registerLifecycle(ctx, client, config);
}
+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`;
}
+6 -3
View File
@@ -6,8 +6,9 @@
*/
import type { Context } from "@deepseek-ai/cordis";
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
import { DASHSCOPE_DEFAULT_BASE_URL } from "./credentials.ts";
export const DASHSCOPE_DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com";
export { DASHSCOPE_DEFAULT_BASE_URL } from "./credentials.ts";
/** A non-2xx DashScope response, carrying the server's own wording. */
export class DashScopeError extends Error {
@@ -22,8 +23,10 @@ export class DashScopeError extends Error {
}
/**
* Resolve the DashScope key from explicit config, then the launch environment
* (process env, project `.env`, harness-home `.env`).
* 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;
+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" });
}
},
}),
);
}
-239
View File
@@ -1,239 +0,0 @@
/**
* `bailian-cli-dsh/tool-image`: image generation through `bl image generate`.
*
* Delegating to the CLI keeps the async-task polling, model-to-endpoint
* routing, and artifact download in one place rather than restating them here.
*
* Generated files are committed to `ctx.attachments` and returned as
* `ImageBlock`s when the calling route declares image input. When it does not
* DeepSeek routes never do the tool degrades to reporting the saved paths
* instead of failing, so the model can hand one to `bailian_vision_describe`.
* For that fallback to work the files must survive the call, so this tool
* deliberately does not delete what the CLI wrote.
*
* @module bailian-cli-dsh/tool-image
*/
import type { Context } from "@deepseek-ai/cordis";
import type { ImageAttachmentRef, ImageMediaType } from "@deepseek-ai/dsh-attachment";
import { AttachmentId } from "@deepseek-ai/dsh-attachment";
import type { ContentBlock } from "@deepseek-ai/dsh-llm";
import type {} from "@deepseek-ai/dsh-fs";
import { defineTool } from "@deepseek-ai/dsh-tools";
import type { ToolExecution } from "@deepseek-ai/dsh-tools";
import z from "@deepseek-ai/schemastery";
import { runBlJson } from "../shared/bl.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-tool-image";
/** Seams this plugin registers into. */
export const inject = ["tools", "subprocess", "fs"];
export interface Config {
/** Image model passed to `bl image generate --model`. */
model?: string;
/** Directory for generated files; defaults to the CLI's own output dir. */
outDir?: string;
/** Cooperative budget; async models poll until the task succeeds. */
timeoutMs?: number;
}
export const Config: z<Config> = z.object({
model: z.string().description("Image model; defaults to the CLI's own default."),
outDir: z.string().description("Directory for generated files."),
timeoutMs: z.natural().description("Cooperative timeout budget in milliseconds."),
});
const DEFAULT_TIMEOUT_MS = 300_000;
const MAX_IMAGES = 6;
const MEDIA_TYPE_BY_EXTENSION: Readonly<Record<string, ImageMediaType>> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/gif",
};
interface ImageGenerateResponse {
urls?: readonly string[];
saved?: readonly string[];
total?: number;
}
/** One committed image, stored as plain JSON so `render` stays pure. */
interface CommittedImage {
attachmentId: string;
mediaType: ImageMediaType;
bytes: number;
width: number;
height: number;
path: string;
}
function mediaTypeOf(path: string): ImageMediaType | undefined {
const extension = path.split(".").pop()?.toLowerCase();
return extension === undefined ? undefined : MEDIA_TYPE_BY_EXTENSION[extension];
}
function attachmentRefOf(image: CommittedImage): ImageAttachmentRef {
return {
attachmentId: AttachmentId(image.attachmentId),
mediaType: image.mediaType,
bytes: image.bytes,
width: image.width,
height: image.height,
};
}
/**
* Whether the calling route declares image input. Unlike `read_image`'s hard
* gate this only reports, because an unroutable or text-only model is a reason
* to fall back to paths rather than to refuse generating anything.
*/
async function routeAcceptsImages(ctx: Context, exec: ToolExecution): Promise<boolean> {
const routed = exec.agent?.session.requestHeader()?.config;
const provider = routed?.provider ?? exec.agent?.options.provider;
const model = routed?.model ?? exec.agent?.options.model;
const llm = ctx.get("llm");
if (provider === undefined || model === undefined || llm === undefined) return false;
try {
const active = await llm.resolveModelInfo(provider, model, exec.signal);
return active.inputModalities?.includes("image") === true;
} catch {
return false;
}
}
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(
defineTool({
name: "bailian_image_generate",
description:
"Generate images from a text prompt using Aliyun Bailian (Qwen-Image / Wan). " +
"Files are written to disk and returned inline when the active model can view " +
"images; otherwise the saved paths are reported and you can inspect one with " +
"`bailian_vision_describe`.",
parameters: {
prompt: {
type: "string",
required: true,
description: "What to depict. Be specific about subject, style, and composition.",
},
model: { type: "string", description: "Override the configured image model." },
size: {
type: "string",
description: 'Aspect ratio such as "1:1" / "16:9", or explicit pixels as "1024*1024".',
},
n: {
type: "integer",
description: `How many images to generate (1-${MAX_IMAGES}).`,
},
negative_prompt: { type: "string", description: "What to avoid depicting." },
seed: { type: "integer", description: "Seed for reproducible generation." },
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
images: {
type: "array",
required: true,
items: {
type: "object",
additionalProperties: false,
properties: {
attachmentId: { type: "string", required: true },
mediaType: { type: "string", required: true },
bytes: { type: "integer", required: true },
width: { type: "integer", required: true },
height: { type: "integer", required: true },
path: { type: "string", required: true },
},
},
},
paths: { type: "array", required: true, items: { type: "string" } },
urls: { type: "array", required: true, items: { type: "string" } },
},
},
render: (_args, value) => {
const paths = value.paths.join("\n");
if (value.images.length === 0) {
return [
{
type: "text",
text:
`Generated ${value.paths.length} image(s); the active model cannot view ` +
`images, so they are on disk only. Use bailian_vision_describe to inspect ` +
`one.\n${paths}`,
},
];
}
const blocks: ContentBlock[] = [
{ type: "text", text: `Generated ${value.images.length} image(s):\n${paths}` },
];
for (const image of value.images) {
blocks.push({ type: "image", attachment: attachmentRefOf(image as CommittedImage) });
}
return blocks;
},
},
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
async execute(args, exec) {
if (args.n !== undefined && (args.n < 1 || args.n > MAX_IMAGES)) {
throw new Error(`bailian_image_generate accepts n between 1 and ${MAX_IMAGES}.`);
}
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
const argv = ["image", "generate", "--prompt", args.prompt];
const model = args.model ?? config.model;
if (model !== undefined) argv.push("--model", model);
if (args.size !== undefined) argv.push("--size", args.size);
if (args.n !== undefined) argv.push("--n", String(args.n));
if (args.negative_prompt !== undefined)
argv.push("--negative-prompt", args.negative_prompt);
if (args.seed !== undefined) argv.push("--seed", String(args.seed));
if (config.outDir !== undefined) argv.push("--out-dir", config.outDir);
const response = await runBlJson<ImageGenerateResponse>(ctx, argv, {
cwd,
signal: exec.signal,
});
const paths = [...(response.saved ?? [])];
const urls = [...(response.urls ?? [])];
if (paths.length === 0) {
throw new Error("bl image generate reported no saved files.");
}
const attachments = ctx.get("attachments");
const images: CommittedImage[] = [];
if (attachments !== undefined && (await routeAcceptsImages(ctx, exec))) {
const byteCap = Math.min(
attachments.imageLimits.maxImageBytes,
attachments.imageLimits.maxMessageImageBytes,
);
for (const path of paths) {
const mediaType = mediaTypeOf(path);
if (mediaType === undefined || !attachments.imageLimits.mediaTypes.includes(mediaType))
continue;
const target = await ctx.fs.resolve(path, { cwd, signal: exec.signal });
const data = await ctx.fs.readBytes(target, exec.signal, byteCap);
const ref = await attachments.saveImage({ data, mediaType, name: target.displayPath });
images.push({
attachmentId: ref.attachmentId,
mediaType: ref.mediaType,
bytes: ref.bytes,
width: ref.width,
height: ref.height,
path,
});
}
}
return { images, paths, urls };
},
}),
);
}
@@ -1,131 +0,0 @@
/**
* `bailian-cli-dsh/tool-managed-agent`: run a task on a Bailian-hosted managed
* agent, provisioned on demand, through `bl managed-agent run`.
*
* A plain tool rather than a `SubagentProvider`: in dsh's `web` profile every
* `tool-subagent` row is disabled in the host plane (delegation tools live in
* agent presets), and a subagent provider fixes one agent identity in config
* neither fits "the model describes an intent and a remote agent is created for
* it". 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.
*
* The CLI does ensure+run in one step: it materializes (idempotently) a cloud
* agent + environment under the given `agent` name on first use and reuses them
* after, so no `agents.yaml` or prior `apply` is required. First use provisions
* cloud resources it may incur cost and take longer to start.
*
* @module bailian-cli-dsh/tool-managed-agent
*/
import type { Context } from "@deepseek-ai/cordis";
import { defineTool } from "@deepseek-ai/dsh-tools";
import type {} from "@deepseek-ai/dsh-tools";
import z from "@deepseek-ai/schemastery";
import { runBlJson } from "../shared/bl.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-tool-managed-agent";
/** Seams this plugin registers into. */
export const inject = ["tools", "subprocess"];
/** Default agent identity provisioned and reused across calls. */
const DEFAULT_AGENT = "dsh-remote-runner";
export interface Config {
/** Agent identity to create/reuse; distinct names get distinct remote agents. */
agent?: string;
/** Model for the remote agent. */
model?: string;
/** Cooperative budget; first-run provisioning of a cloud environment is slow. */
timeoutMs?: number;
}
export const Config: z<Config> = z.object({
agent: z.string().description("Remote agent identity to create/reuse."),
model: z.string().description("Model for the remote agent."),
timeoutMs: z.natural().description("Cooperative timeout budget in milliseconds."),
});
const DEFAULT_TIMEOUT_MS = 600_000;
/** The `bl managed-agent run --output json` envelope: a session-event list. */
interface SessionRunResponse {
session_id?: string;
agent?: string;
events?: readonly { type?: string; content?: unknown; role?: string }[];
}
/** Assistant-visible text of a finished remote session. */
function assistantText(response: SessionRunResponse): string {
return (response.events ?? [])
.filter((event) => event.type === "message" && typeof event.content === "string")
.map((event) => event.content as string)
.join("\n")
.trim();
}
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(
defineTool({
name: "bailian_run_remote_task",
description:
"Run a task on a Bailian-hosted cloud agent. Use for long-running or isolated work you " +
"want executed remotely rather than in this session. A remote agent is created on demand " +
"(and reused) — describe the role it should play through `instructions`, and the concrete " +
"task through `task`. Returns the remote agent's final answer.",
parameters: {
task: {
type: "string",
required: true,
description: "The concrete task for the remote agent to carry out.",
},
instructions: {
type: "string",
description:
"Role/system instructions defining what the remote agent is good at. " +
"Defaults to a generic assistant.",
},
model: {
type: "string",
description: "Override the configured model for this task.",
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
answer: { type: "string", required: true },
sessionId: { type: "string", required: true },
},
},
render: (_args, value) => [{ type: "text", text: value.answer }],
},
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
async execute(args, exec) {
const argv = [
"managed-agent",
"run",
"--prompt",
args.task,
"--agent",
config.agent ?? DEFAULT_AGENT,
];
if (args.instructions !== undefined) argv.push("--instructions", args.instructions);
const model = args.model ?? config.model;
if (model !== undefined) argv.push("--model", model);
const response = await runBlJson<SessionRunResponse>(ctx, argv, {
cwd: exec.agent?.session.header.cwd ?? process.cwd(),
signal: exec.signal,
});
const answer = assistantText(response);
if (answer.length === 0) {
throw new Error("the remote agent produced no assistant output.");
}
return { answer, sessionId: response.session_id ?? "" };
},
}),
);
}
-135
View File
@@ -1,135 +0,0 @@
/**
* `bailian-cli-dsh/tool-vision`: image and video understanding through
* `bl vision describe` (Qwen-VL).
*
* The tool returns TEXT, never an `ImageBlock` that is deliberate. dsh gates
* image content on the active route's declared input modalities in two places
* before a plugin ever sees it (the Web UI paste pre-check and `read_image`),
* so a text-only main model such as DeepSeek cannot receive pictures at all.
* Handing back a description instead gives those routes vision indirectly.
* A genuinely multimodal route does not need this tool and should paste images
* directly.
*
* @module bailian-cli-dsh/tool-vision
*/
import type { Context } from "@deepseek-ai/cordis";
import { defineTool } from "@deepseek-ai/dsh-tools";
import type {} from "@deepseek-ai/dsh-tools";
import z from "@deepseek-ai/schemastery";
import { runBlJson } from "../shared/bl.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-tool-vision";
/** Seams this plugin registers into. */
export const inject = ["tools", "subprocess"];
export interface Config {
/** Vision model passed to `bl vision describe --model`. */
model?: string;
/** Cooperative budget; video understanding uploads and is slow. */
timeoutMs?: number;
}
export const Config: z<Config> = z.object({
model: z.string().description("Vision model; defaults to the CLI's own default."),
timeoutMs: z.natural().description("Cooperative timeout budget in milliseconds."),
});
const DEFAULT_TIMEOUT_MS = 180_000;
/** The OpenAI-shaped body `bl vision describe --output json` passes through. */
interface VisionResponse {
model?: string;
request_id?: string;
choices?: readonly {
message?: { content?: unknown };
}[];
}
/** Chat content is a string or an array of typed parts; keep only the text. */
function readContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) =>
typeof part === "object" && part !== null && "text" in part
? String((part as { text: unknown }).text)
: "",
)
.join("")
.trim();
}
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(
defineTool({
name: "bailian_vision_describe",
description:
"Understand an image or video using Aliyun Bailian's Qwen-VL models. " +
"Accepts a local file path or a URL and returns a text description, so it works " +
"even when the active model cannot take image input. Ask a specific question " +
"through `prompt` (for example OCR, chart reading, or object identification) " +
"instead of relying on the generic default.",
parameters: {
image: {
type: "string",
description: "Local image path or http(s)/oss URL. Provide this or `video`.",
},
video: {
type: "array",
items: { type: "string" },
description:
"Video file paths or URLs (mp4/mov/avi/mkv/webm). Local files are uploaded first.",
},
prompt: {
type: "string",
description: "Question about the content. Defaults to a plain description request.",
},
model: {
type: "string",
description: "Override the configured vision model.",
},
},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
description: { type: "string", required: true },
model: { type: "string", required: true },
},
},
render: (_args, value) => [{ type: "text", text: value.description }],
},
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
isConcurrencySafe: () => true,
async execute(args, exec) {
const videos = args.video ?? [];
if (args.image === undefined && videos.length === 0) {
throw new Error("bailian_vision_describe requires `image` or `video`.");
}
const argv = ["vision", "describe"];
if (args.image !== undefined) argv.push("--image", args.image);
for (const video of videos) argv.push("--video", video);
if (args.prompt !== undefined) argv.push("--prompt", args.prompt);
const model = args.model ?? config.model;
if (model !== undefined) argv.push("--model", model);
const response = await runBlJson<VisionResponse>(ctx, argv, {
cwd: exec.agent?.session.header.cwd ?? process.cwd(),
signal: exec.signal,
});
const description = readContent(response.choices?.[0]?.message?.content);
if (description.length === 0) {
throw new Error("Qwen-VL returned an empty description.");
}
return { description, model: response.model ?? model ?? "" };
},
}),
);
}
-161
View File
@@ -1,161 +0,0 @@
/**
* `bailian-cli-dsh/web-search-rag`: registers a Bailian knowledge-base
* `WebSearchProvider` with `ctx.web`.
*
* Retrieval is modelled as a search provider rather than a bespoke tool so the
* model reaches private corpora through the `web_search` it already knows
* no new tool, no new prompting. Calls go straight to DashScope because the
* seam needs per-call control the CLI does not surface.
*
* One instance serves one knowledge base: `WebSearchRequest` carries only
* `query` and `maxResults`, so the agent id has to come from config. Insert
* additional rows with distinct ids to expose more than one.
*
* @module bailian-cli-dsh/web-search-rag
*/
import type { Context } from "@deepseek-ai/cordis";
import type {
WebSearchProvider,
WebSearchRequest,
WebSearchResult,
WebSearchSource,
} from "@deepseek-ai/dsh-web";
import { WebError } from "@deepseek-ai/dsh-web";
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
import z from "@deepseek-ai/schemastery";
import { dashScopeFetch, resolveApiKey } from "../shared/http.ts";
/** Cordis plugin name used by loader diagnostics. */
export const name = "bailian-web-search-rag";
/** The web seam this provider registers into. */
export const inject = ["web"];
/** Stable provider id; pin it as `searchProvider` to disambiguate. */
export const BAILIAN_KB_PROVIDER_ID = "bailian-kb";
export interface Config {
/** DashScope key; falls back to `DASHSCOPE_API_KEY`. */
apiKey?: string;
/** Workspace id; also the retrieval host prefix. Falls back to `BAILIAN_WORKSPACE_ID`. */
workspaceId?: string;
/** Retrieval service id from the console's knowledge retrieval page. */
agentId?: string;
/** Default upper bound when the caller sets none. */
maxResults?: number;
}
export const Config: z<Config> = z.object({
apiKey: z
.string()
.role("secret")
.description("DashScope API key; defaults to $DASHSCOPE_API_KEY."),
workspaceId: z.string().description("Bailian workspace id; defaults to $BAILIAN_WORKSPACE_ID."),
agentId: z.string().description("Retrieval service (agent) id identifying the knowledge base."),
maxResults: z.natural().description("Default source cap when the caller sets none."),
});
const DEFAULT_MAX_RESULTS = 10;
interface KnowledgeSearchNode {
score?: number;
text?: string;
metadata?: {
title?: string;
doc_id?: string;
doc_name?: string;
doc_url?: string;
page_number?: number;
};
}
interface KnowledgeSearchResponse {
data?: { total?: number; nodes?: readonly KnowledgeSearchNode[] };
}
export interface BailianKbProviderOptions {
apiKey: string;
workspaceId: string;
agentId: string;
maxResults: number;
}
function isAbort(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
/** The seam requires a URL; documents without one still deserve a stable identity. */
function sourceUrl(node: KnowledgeSearchNode, index: number): string {
const url = node.metadata?.doc_url;
if (url !== undefined && url.length > 0) return url;
return `bailian-kb://${node.metadata?.doc_id ?? `node-${index}`}`;
}
export class BailianKbSearchProvider implements WebSearchProvider {
readonly id = BAILIAN_KB_PROVIDER_ID;
constructor(private readonly options: BailianKbProviderOptions) {}
available(): boolean {
return (
this.options.apiKey.length > 0 &&
this.options.workspaceId.length > 0 &&
this.options.agentId.length > 0
);
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const limit = request.maxResults ?? this.options.maxResults;
let response: KnowledgeSearchResponse;
try {
response = await dashScopeFetch<KnowledgeSearchResponse>({
url: `https://${this.options.workspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/indices/knowledge/search`,
method: "POST",
apiKey: this.options.apiKey,
body: { query: request.query, agent_id: this.options.agentId },
signal,
});
} catch (error) {
if (isAbort(error))
throw new WebError("knowledge base search aborted", "WEB_ABORTED", { cause: error });
const reason = error instanceof Error ? error.message : String(error);
throw new WebError(`knowledge base search failed: ${reason}`, "WEB_PROVIDER_ERROR", {
cause: error,
});
}
const nodes = (response.data?.nodes ?? []).slice(0, limit);
const sources: WebSearchSource[] = nodes.map((node, index) => {
const metadata = node.metadata ?? {};
const title = metadata.doc_name ?? metadata.title;
const text = node.text ?? "";
return {
url: sourceUrl(node, index),
...(title !== undefined ? { title } : {}),
...(text.length > 0 ? { snippet: text } : {}),
};
});
const content = nodes
.map((node) => node.text ?? "")
.filter((text) => text.length > 0)
.join("\n\n");
// Truncation is the seam's job; report what this provider returned.
return { ...(content.length > 0 ? { content } : {}), sources, truncated: false };
}
}
export function apply(ctx: Context, config: Config): void {
const workspaceId =
config.workspaceId ?? launchEnvironmentOf(ctx).get("BAILIAN_WORKSPACE_ID")?.value ?? "";
ctx.web.registerSearchProvider(
new BailianKbSearchProvider({
apiKey: resolveApiKey(ctx, config.apiKey) ?? "",
workspaceId,
agentId: config.agentId ?? "",
maxResults: config.maxResults ?? DEFAULT_MAX_RESULTS,
}),
);
}
+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",
]);
});
+1 -10
View File
@@ -2,16 +2,7 @@ import { defineConfig } from "vite-plus";
export default defineConfig({
pack: {
// One entry per `exports` subpath: each dsh plugin row imports its own
// module specifier, so they cannot share a bundle.
entry: [
"src/index.ts",
"src/tool-vision/index.ts",
"src/tool-image/index.ts",
"src/tool-managed-agent/index.ts",
"src/web-search-rag/index.ts",
"src/memory/index.ts",
],
entry: ["src/index.ts", "src/tokenplan-usage/index.ts", "src/memory/index.ts"],
minify: true,
dts: {
tsgo: true,
+3 -1
View File
@@ -65,6 +65,8 @@ Use this index for the skill-scoped quick index and global flags.
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
| `bl token-plan personal-key` | Get the personal-edition TokenPlan API key (masked) for the current account | [token-plan.md](token-plan.md) |
| `bl token-plan personal-usage` | Query personal-edition TokenPlan usage (5h/1w percentage, subscription, addon credits) | [token-plan.md](token-plan.md) |
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
@@ -93,7 +95,7 @@ Use this index for the skill-scoped quick index and global flags.
| `search` | `web` | [search.md](search.md) |
| `skill` | `add`, `init`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `text` | `chat` | [text.md](text.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats`, `personal-key`, `personal-usage` | [token-plan.md](token-plan.md) |
| `update` | `(root)` | [update.md](update.md) |
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
+54 -6
View File
@@ -7,12 +7,14 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ---------------------------- | ----------------------------------------- |
| `bl token-plan add-member` | Add a member to a Token Plan organization |
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members |
| `bl token-plan create-key` | Create a Token Plan API key for a seat |
| `bl token-plan list-seats` | List Token Plan subscription seat details |
| Command | Description |
| ------------------------------ | -------------------------------------------------------------------------------------- |
| `bl token-plan add-member` | Add a member to a Token Plan organization |
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members |
| `bl token-plan create-key` | Create a Token Plan API key for a seat |
| `bl token-plan list-seats` | List Token Plan subscription seat details |
| `bl token-plan personal-key` | Get the personal-edition TokenPlan API key (masked) for the current account |
| `bl token-plan personal-usage` | Query personal-edition TokenPlan usage (5h/1w percentage, subscription, addon credits) |
## Command details
@@ -153,3 +155,49 @@ bl token-plan list-seats --page-size 20 --status NORMAL
```bash
bl token-plan list-seats --query-assigned true --seat-type standard
```
### `bl token-plan personal-key`
| Field | Value |
| --------------- | --------------------------------------------------------------------------- |
| **Name** | `token-plan personal-key` |
| **Description** | Get the personal-edition TokenPlan API key (masked) for the current account |
| **Usage** | `bl token-plan personal-key [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Examples
```bash
bl token-plan personal-key
```
### `bl token-plan personal-usage`
| Field | Value |
| --------------- | -------------------------------------------------------------------------------------- |
| **Name** | `token-plan personal-usage` |
| **Description** | Query personal-edition TokenPlan usage (5h/1w percentage, subscription, addon credits) |
| **Usage** | `bl token-plan personal-usage [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
#### Examples
```bash
bl token-plan personal-usage
```
@@ -53,6 +53,7 @@ Index: [index.md](index.md)
#### Notes
- 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.
@@ -87,6 +88,7 @@ bl managed-agent apply --provider bailian --yes
#### Notes
- 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.
@@ -153,6 +155,7 @@ bl managed-agent init --provider all
#### Notes
- 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.
- --no-refresh and --dry-run plan offline from local config and state: no remote requests, no state writes, provider keys are not checked.
@@ -194,6 +197,7 @@ bl managed-agent plan --no-refresh
#### Notes
- 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.
- 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.
@@ -233,6 +237,7 @@ bl managed-agent run --prompt "Audit this dependency tree" --instructions "You a
#### Notes
- 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.
@@ -271,6 +276,7 @@ bl managed-agent session create --agent assistant --title 'debug run'
#### Notes
- 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.
@@ -303,6 +309,7 @@ bl managed-agent session delete --session-id sess_abc123
#### Notes
- 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.
@@ -337,6 +344,7 @@ bl managed-agent session events --session-id sess_abc123 --all
#### Notes
- 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.
@@ -368,6 +376,7 @@ bl managed-agent session get --session-id sess_abc123
#### Notes
- 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.
@@ -412,6 +421,7 @@ bl managed-agent session list --all
#### Notes
- 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.
- --output json emits one envelope: { session_id, provider, agent, events } — read session_id to chain `session send/get/events/delete`.
@@ -449,6 +459,7 @@ bl managed-agent session run --agent assistant --prompt "summarize this repo"
#### Notes
- 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.
@@ -479,6 +490,7 @@ bl managed-agent session send --session-id sess_abc123 --message "continue"
#### Notes
- 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.
- Providers without a skill listing API (e.g. ark) return an empty list.
@@ -525,6 +537,7 @@ bl managed-agent skill-list --source custom --provider bailian
#### Notes
- 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.