From f30d7f2009ac05ac94927604925d79a4e880eeb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 12 Nov 2026 11:43:05 +0800 Subject: [PATCH 1/3] feat: add dataset version command --- README.md | 14 + README.zh.md | 14 + packages/cli/src/commands.ts | 8 + .../commands/src/commands/dataset/groups.ts | 111 +++ .../commands/src/commands/dataset/version.ts | 189 ++++ .../commands/src/commands/deploy/instances.ts | 116 +++ packages/commands/src/commands/file/url.ts | 76 ++ .../src/commands/shared/console-read.ts | 43 + .../commands/src/commands/shared/workspace.ts | 10 +- packages/commands/src/index.ts | 4 + .../tests/e2e/console-read.e2e.test.ts | 634 +++++++++++++ packages/commands/tests/e2e/topic-routes.ts | 7 + .../tests/output/alert-output.test.ts | 2 +- .../tests/output/console-read-output.test.ts | 856 ++++++++++++++++++ packages/core/src/console/gateway.ts | 24 +- packages/core/src/console/index.ts | 1 + packages/core/src/console/response.ts | 130 +++ packages/core/src/dataset/console.ts | 204 +++++ packages/core/src/dataset/index.ts | 1 + packages/core/src/deploy/index.ts | 1 + packages/core/src/deploy/instances.ts | 77 ++ packages/core/src/files/index.ts | 1 + packages/core/src/files/url.ts | 37 + packages/core/tests/console-file-url.test.ts | 184 ++++ packages/core/tests/console-response.test.ts | 221 +++++ packages/core/tests/dataset-console.test.ts | 247 +++++ packages/core/tests/deploy-instances.test.ts | 253 ++++++ packages/core/tests/index.test.ts | 8 +- packages/runtime/src/create-cli.ts | 8 + .../runtime/tests/console-aksk-auth.test.ts | 2 +- skills/bailian-cli/SKILL.md | 73 +- skills/bailian-cli/reference/file.md | 38 + skills/bailian-cli/reference/index.md | 3 +- skills/bailian-finetune/SKILL.md | 46 +- skills/bailian-finetune/reference/dataset.md | 94 ++ skills/bailian-finetune/reference/deploy.md | 43 + skills/bailian-finetune/reference/index.md | 7 +- 37 files changed, 3719 insertions(+), 68 deletions(-) create mode 100644 packages/commands/src/commands/dataset/groups.ts create mode 100644 packages/commands/src/commands/dataset/version.ts create mode 100644 packages/commands/src/commands/deploy/instances.ts create mode 100644 packages/commands/src/commands/file/url.ts create mode 100644 packages/commands/src/commands/shared/console-read.ts create mode 100644 packages/commands/tests/e2e/console-read.e2e.test.ts create mode 100644 packages/commands/tests/output/console-read-output.test.ts create mode 100644 packages/core/src/console/response.ts create mode 100644 packages/core/src/dataset/console.ts create mode 100644 packages/core/src/deploy/instances.ts create mode 100644 packages/core/src/files/url.ts create mode 100644 packages/core/tests/console-file-url.test.ts create mode 100644 packages/core/tests/console-response.test.ts create mode 100644 packages/core/tests/dataset-console.test.ts create mode 100644 packages/core/tests/deploy-instances.test.ts diff --git a/README.md b/README.md index 0a1c88c12..5723b130a 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,20 @@ Knowledge bases accrue running-time charges from creation, even without queries. Keep the returned IDs and recovery record. Closing the CLI or a later failure does not stop billing; follow the reported cleanup actions to delete unused knowledge bases. See the [Knowledge Studio quick start](packages/kscli/README.md#quick-start) for retrieval and cleanup. The `bailian-knowledge` Skill is available through `bl skill init`. +### Console dataset and deployment queries + +```bash +bl dataset groups --page 1 --page-size 10 --output json +bl dataset version --dataset-group-id --output json +bl dataset version --dataset-id --include-status --output json +bl file url --file-key --output json +bl deploy instances --page 1 --page-size 10 --output json +``` + +Use `dataset groups` (`--name` optionally filters on the server) to obtain `records[].datasetGroupId`, then `dataset version --dataset-group-id ` to get `{result:[...]}` with `result[].datasetId` and its optional `fileId`. Use `dataset version --dataset-id ` for details; `--include-status` is available only in detail mode. Exactly one of `--dataset-group-id` and `--dataset-id` is required; they cannot be combined. `fileId` is not `datasetId` and must not be passed as a version ID. Groups fetch one page with one embedded version preview per group; `version --dataset-group-id` does not promise exhaustive results or expose pagination. The API Key / `file_id` semantics of `dataset list` are unchanged. + +These read-only commands use Console authentication. An optional `--workspace-id` resolves the workspace's Console identity before querying; resolution failure stops the request. `dataset version --dataset-id` keeps metadata and version/task state separate and does not infer validation success or training readiness. `file url` only resolves a download address. `deploy instances` returns one page of independent services (`records` is the list, `items` the total), not capacity instances. The API-key `dataset get --file-id` and `deploy list` commands are unchanged. Add `--dry-run` to preview without business requests; framework update checks/telemetry follow their settings. + ### RAG audio and video `bl knowledge doc import --index-id --doc-id ` imports an existing file into a knowledge base; `doc list --details` shows per-file processing settings. Media support includes parser options, streaming uploads, and media references in search/chunk output. Chat JSON separates the final answer from phases, tools, docs, usage, and raw events; quiet mode hides progress and informational messages while preserving the final result, including every JSON field. See the [command guide](docs/knowledge/knowledge-cli-guide.md). diff --git a/README.zh.md b/README.zh.md index a07c3898c..74ecd3331 100644 --- a/README.zh.md +++ b/README.zh.md @@ -138,6 +138,20 @@ bl knowledge init --workspace-id --dry-run 保留返回的 ID 和恢复记录。退出 CLI 或后续步骤失败不会停止计费;按返回的清理动作删除不再需要的知识库。检索及清理示例见 [Knowledge Studio 快速开始](packages/kscli/README.zh.md#快速开始)。知识库 Skill `bailian-knowledge` 通过 `bl skill init` 安装。 +### Console 数据集与部署查询 + +```bash +bl dataset groups --page 1 --page-size 10 --output json +bl dataset version --dataset-group-id <组ID> --output json +bl dataset version --dataset-id <版本ID> --include-status --output json +bl file url --file-key --output json +bl deploy instances --page 1 --page-size 10 --output json +``` + +先用 `dataset groups`(可加 `--name` 服务端筛选)获取 `records[].datasetGroupId`,再用 `dataset version --dataset-group-id <组ID>` 获取 `{result:[...]}`,其中包含 `result[].datasetId` 及可能返回的 `fileId`。查询详情用 `dataset version --dataset-id <版本ID>`,`--include-status` 仅详情模式可用。`--dataset-group-id` 与 `--dataset-id` 必选一个且不能同传。`fileId` 不是 `datasetId`,不能作为版本 ID 传入。组列表仅取一页,每组附带一条版本预览;`version --dataset-group-id` 不承诺返回全部版本,也不提供分页选项。原有 `dataset list` 的 API Key / `file_id` 语义保持不变。 + +以上只读命令使用 Console 鉴权。可选 `--workspace-id` 会在查询前解析对应空间的 Console 身份,解析失败即停止。`dataset version --dataset-id` 分开保留元信息与版本/任务状态,不推导校验成功或训练就绪;`file url` 仅解析下载地址;`deploy instances` 返回独立部署服务的单页数据(`records` 为列表、`items` 为总数),不是容量实例。原有 API Key 域的 `dataset get --file-id` 与 `deploy list` 保持不变。添加 `--dry-run` 可预览且不发送业务请求;框架更新检查和埋点遵循其配置。 + ### RAG 音视频接入 `bl knowledge doc import --index-id --doc-id ` 将历史文件导入已有库;`doc list --details` 查看文件级处理参数。支持音视频 parser、流式上传,以及检索/切片中的媒体来源展示。问答 JSON 将最终 answer 与 phases/tools/docs/usage/events 分开保存,quiet 只隐藏进度和过程提示,保留最终结果及完整 JSON 字段。参阅 [命令指南](docs/knowledge/knowledge-cli-guide.md)。 diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index ff6a457b3..9c1c6924d 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -97,6 +97,7 @@ import { speechVocabularyUpdate, speechVocabularyDelete, fileUpload, + fileUrl, consoleCall, securityOverview, securityAlerts, @@ -163,6 +164,8 @@ import { datasetUpload, datasetList, datasetGet, + datasetVersion, + datasetGroups, datasetDelete, datasetValidate, finetuneTextCreate, @@ -193,6 +196,7 @@ import { deployAudioCreate, deployImageCreate, deployList, + deployInstances, deployGet, deployCapacityList, deployCapacityGet, @@ -439,6 +443,7 @@ export const commands: Record = { "speech vocabulary update": speechVocabularyUpdate, "speech vocabulary delete": speechVocabularyDelete, "file upload": fileUpload, + "file url": fileUrl, "console call": consoleCall, "agents security overview": securityOverview, "agents security alerts": securityAlerts, @@ -505,6 +510,8 @@ export const commands: Record = { "dataset upload": datasetUpload, "dataset list": datasetList, "dataset get": datasetGet, + "dataset version": datasetVersion, + "dataset groups": datasetGroups, "dataset delete": datasetDelete, "dataset validate": datasetValidate, "finetune text create": finetuneTextCreate, @@ -535,6 +542,7 @@ export const commands: Record = { "deploy audio create": deployAudioCreate, "deploy image create": deployImageCreate, "deploy list": deployList, + "deploy instances": deployInstances, "deploy get": deployGet, "deploy capacity list": deployCapacityList, "deploy capacity get": deployCapacityGet, diff --git a/packages/commands/src/commands/dataset/groups.ts b/packages/commands/src/commands/dataset/groups.ts new file mode 100644 index 000000000..40e7b97c2 --- /dev/null +++ b/packages/commands/src/commands/dataset/groups.ts @@ -0,0 +1,111 @@ +import { + CONSOLE_DATASET_GROUPS_API, + buildConsoleDatasetGroupsRequest, + listConsoleDatasetGroups, +} from "bailian-cli-core"; +import { defineConsoleReadCommand, consoleReadTargetPreview } from "../shared/console-read.ts"; + +export default defineConsoleReadCommand({ + auth: "console", + description: { + "en-US": "List Console dataset groups", + "zh-CN": "查询 Console 数据集组列表", + }, + usageArgs: "[--page ] [--page-size ] [--name ]", + flags: { + page: { + type: "number", + valueHint: "", + description: { + "en-US": "Page number, a positive safe integer (default: 1)", + "zh-CN": "页码,正安全整数(默认:1)", + }, + }, + pageSize: { + type: "number", + valueHint: "", + description: { + "en-US": "Groups per page, a positive safe integer (default: 10)", + "zh-CN": "每页组数,正安全整数(默认:10)", + }, + }, + name: { + type: "string", + valueHint: "", + description: { + "en-US": "Nonblank dataset group name for server-side filtering; trimmed before sending", + "zh-CN": "用于服务端筛选的非空白数据集组名称,去除首尾空白后传递", + }, + }, + }, + exampleArgs: ["", "--page 2 --page-size 20", "--name example-dataset --dry-run"], + notes: [ + { + "en-US": + "Fetches only the requested page. records contains dataset groups; items is the server's total count for the query. All business-response fields are preserved. The API Key / file_id semantics of dataset list are unchanged.", + "zh-CN": + "仅获取请求的当前页。records 为数据集组列表,items 为服务端返回的查询总数。保留所有业务响应字段,不改变 dataset list 的 API Key / file_id 语义。", + }, + { + "en-US": + "Each group's datasets is only a one-version preview, not its complete version list. Pass datasetGroupId to dataset version --dataset-group-id for {result:[...]} (no completeness guarantee or pagination); use dataset version --dataset-id with a version's datasetId for details (--include-status is detail-only). Exactly one ID flag is required; they cannot be combined. fileId is not datasetId.", + "zh-CN": + "各组的 datasets 仅是一条版本预览,不是完整版本列表。将 datasetGroupId 传给 dataset version --dataset-group-id,返回 {result:[...]}(不保证全量,无分页);查询详情用 dataset version --dataset-id,传入版本的 datasetId,仅此模式可加 --include-status。两个 ID 参数必选一个且不能同传。fileId 不是 datasetId。", + }, + { + "en-US": + "--name is trimmed and sent to the server without local filtering. No type filter is hardcoded, but server-side scope and defaults still affect results; this is not an account-wide exhaustive listing. There is no --all option.", + "zh-CN": + "--name 去除首尾空白后交由服务端筛选,不做本地筛选。不硬编码类型过滤,但服务端作用域与默认条件仍影响结果,不代表整个账号的全量列表。不提供 --all 选项。", + }, + { + "en-US": + "The preview requests one import task per version, without guaranteeing the latest task or all tasks. Status codes are preserved as returned; no release, validation, or training readiness is inferred.", + "zh-CN": + "预览为每个版本请求一条导入任务,不保证是最新任务或涵盖全部任务。保留返回的状态原码,不据此判断发布、校验或训练就绪。", + }, + ], + validate(flags) { + if (flags.page !== undefined && (!Number.isSafeInteger(flags.page) || flags.page < 1)) { + return { + "en-US": "--page must be a positive safe integer.", + "zh-CN": "--page 必须为正安全整数。", + }; + } + if ( + flags.pageSize !== undefined && + (!Number.isSafeInteger(flags.pageSize) || flags.pageSize < 1) + ) { + return { + "en-US": "--page-size must be a positive safe integer.", + "zh-CN": "--page-size 必须为正安全整数。", + }; + } + if (flags.name !== undefined && !flags.name.trim()) { + return { + "en-US": "--name must not be blank when provided.", + "zh-CN": "传入的 --name 不能仅包含空白或为空。", + }; + } + return undefined; + }, + async run(ctx) { + const params = { + page: ctx.flags.page ?? 1, + pageSize: ctx.flags.pageSize ?? 10, + name: ctx.flags.name?.trim(), + }; + if (ctx.settings.dryRun) { + const preview = { + action: "dataset.groups", + api: CONSOLE_DATASET_GROUPS_API, + data: buildConsoleDatasetGroupsRequest(params), + ...consoleReadTargetPreview(ctx.settings), + }; + ctx.output.result(preview, { text: JSON.stringify(preview, null, 2) }); + return; + } + const response = await listConsoleDatasetGroups(ctx.client, params); + ctx.output.result(response, { text: JSON.stringify(response, null, 2) }); + }, +}); diff --git a/packages/commands/src/commands/dataset/version.ts b/packages/commands/src/commands/dataset/version.ts new file mode 100644 index 000000000..b82ab92d1 --- /dev/null +++ b/packages/commands/src/commands/dataset/version.ts @@ -0,0 +1,189 @@ +import { + CONSOLE_DATASET_BY_ID_API, + CONSOLE_DATASET_VERSIONS_API, + buildConsoleDatasetVersionsRequest, + consoleDatasetStatusAvailability, + getConsoleDatasetById, + listConsoleDatasetVersions, + queryConsoleDatasetVersion, +} from "bailian-cli-core"; +import { defineConsoleReadCommand, consoleReadTargetPreview } from "../shared/console-read.ts"; + +export default defineConsoleReadCommand({ + auth: "console", + description: { + "en-US": "List Console dataset versions by group or get one version by ID", + "zh-CN": "按组列出 Console 数据集版本,或按版本 ID 查询详情", + }, + usageArgs: "(--dataset-group-id | --dataset-id [--include-status])", + flags: { + datasetGroupId: { + type: "string", + valueHint: "", + description: { + "en-US": + "List versions of this Console dataset group; mutually exclusive with --dataset-id", + "zh-CN": "列出此 Console 数据集组的版本;与 --dataset-id 互斥", + }, + }, + datasetId: { + type: "string", + valueHint: "", + description: { + "en-US": "Console dataset version ID, not an API file ID", + "zh-CN": "Console 数据集版本 ID,不是 API 文件 ID", + }, + }, + includeStatus: { + type: "switch", + description: { + "en-US": "Also query import/release state; only valid with --dataset-id", + "zh-CN": "追加查询导入和发布状态;仅可与 --dataset-id 一起使用", + }, + }, + }, + exampleArgs: [ + "--dataset-group-id example-group-id", + "--dataset-group-id example-group-id --dry-run", + "--dataset-id example-version-id", + "--dataset-id example-version-id --include-status --output json", + ], + notes: [ + { + "en-US": + "Exactly one of --dataset-group-id or --dataset-id is required; they cannot be combined. --include-status is only valid with --dataset-id, not group listing. IDs are never inferred from their format.", + "zh-CN": + "--dataset-group-id 与 --dataset-id 必选一个,不能同时传入。--include-status 仅可与 --dataset-id 一起使用,不用于按组列版本。不会根据 ID 格式推断类型。", + }, + { + "en-US": + "Find datasetGroupId with dataset groups, then list versions with --dataset-group-id and get details with a returned datasetId via --dataset-id. An optional fileId belongs to the file/training workflow and cannot substitute for either Console ID; dataset list still returns API file IDs.", + "zh-CN": + "先用 dataset groups 获取 datasetGroupId,再用 --dataset-group-id 列版本,将返回的 datasetId 传给 --dataset-id 查详情。可选 fileId 用于文件或训练流程,不能替代这两个 Console ID;dataset list 仍返回 API 文件 ID。", + }, + { + "en-US": + "Group listing returns result and preserves all other service fields; a raw array is wrapped as result. There are no pagination flags and no guarantee of exhaustive results. Version/task fields are included when returned; listing does not issue individual detail queries.", + "zh-CN": + "按组查询返回 result 并保留其他服务端字段;原始数组包装为 result。不提供分页选项,也不保证返回全部版本。保留服务端返回的版本和任务字段,不逐条追加详情查询。", + }, + { + "en-US": + "Uses the .m_efm APIs without guessing a corpus suffix. dataset contains metadata; --include-status adds version and status_query. The file query using --file-id remains separate.", + "zh-CN": + "使用 .m_efm 接口,不推测语料后缀。dataset 为元信息;--include-status 增加 version 和 status_query。使用 --file-id 的文件查询仍是独立能力。", + }, + { + "en-US": + "Version releaseStatus: 0 draft, 1 publishing, 2 published, 3 failed. Import taskStatus: 0 initializing, 3 importing, 10 failed, 20 succeeded, 23 partially succeeded. Codes are preserved; unknown/missing states never imply success. No separate validation result or training readiness is inferred.", + "zh-CN": + "版本 releaseStatus:0 草稿、1 发布中、2 已发布、3 失败。导入 taskStatus:0 初始化、3 导入中、10 失败、20 成功、23 部分成功。原始码保持不变;未知或缺失状态不代表成功,不推导独立校验结果或训练就绪。", + }, + { + "en-US": + "The status query requests one import task; it does not assert that task is the latest or includes all tasks. No exact match means status was not obtained, not that the version does not exist. A second-query failure preserves metadata and exits nonzero; a returned failed resource state is still a successful query.", + "zh-CN": + "状态查询请求一条导入任务,不承诺它是最新任务或涵盖全部任务。未精确匹配仅表示未取得状态,不证明版本不存在。第二次查询失败时保留元信息并非零退出;查到失败资源状态仍属于查询成功。", + }, + ], + validate(flags) { + if (flags.datasetId !== undefined && !flags.datasetId.trim()) + return { "en-US": "--dataset-id must not be blank.", "zh-CN": "--dataset-id 不能为空。" }; + if (flags.datasetGroupId !== undefined && !flags.datasetGroupId.trim()) + return { + "en-US": "--dataset-group-id must not be blank.", + "zh-CN": "--dataset-group-id 不能为空。", + }; + if ((flags.datasetId === undefined) === (flags.datasetGroupId === undefined)) + return { + "en-US": "Provide exactly one of --dataset-group-id or --dataset-id.", + "zh-CN": "--dataset-group-id 与 --dataset-id 必须且只能提供一个。", + }; + if (flags.includeStatus && flags.datasetId === undefined) + return { + "en-US": "--include-status is only valid with --dataset-id.", + "zh-CN": "--include-status 仅可与 --dataset-id 一起使用。", + }; + return undefined; + }, + async run(ctx) { + if (ctx.flags.datasetGroupId !== undefined) { + const groupId = ctx.flags.datasetGroupId; + if (ctx.settings.dryRun) { + const preview = { + action: "dataset.version", + api: CONSOLE_DATASET_VERSIONS_API, + data: buildConsoleDatasetVersionsRequest(groupId), + ...consoleReadTargetPreview(ctx.settings), + }; + ctx.output.result(preview, { text: JSON.stringify(preview, null, 2) }); + return; + } + const response = await listConsoleDatasetVersions(ctx.client, groupId); + ctx.output.result(response, { text: JSON.stringify(response, null, 2) }); + return; + } + // validate guarantees a version ID when the group-list branch is not selected. + const datasetId = ctx.flags.datasetId!; + const { includeStatus } = ctx.flags; + if (ctx.settings.dryRun) { + const plan = { + action: "dataset.version", + api: CONSOLE_DATASET_BY_ID_API, + data: { input: { datasetId } }, + ...consoleReadTargetPreview(ctx.settings), + ...(includeStatus + ? { + status_query: { + api: CONSOLE_DATASET_VERSIONS_API, + input: { + datasetId, + page: 1, + itemsPerPage: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + datasetGroupIdFrom: "dataset.datasetGroupId", + }, + } + : {}), + }; + ctx.output.result(plan, { text: JSON.stringify(plan, null, 2) }); + return; + } + const dataset = await getConsoleDatasetById(ctx.client, datasetId); + if (!includeStatus) { + const result = { dataset }; + ctx.output.result(result, { text: JSON.stringify(result, null, 2) }); + return; + } + const datasetGroupId = dataset.datasetGroupId; + if (typeof datasetGroupId !== "string" || !datasetGroupId.trim()) { + const result = { + dataset, + version: null, + status_query: { state: "unavailable", reason: "missing_dataset_group_id" }, + }; + ctx.output.result(result, { text: JSON.stringify(result, null, 2) }); + return; + } + let version: Record | null; + try { + version = await queryConsoleDatasetVersion(ctx.client, datasetId, datasetGroupId); + } catch (error) { + const result = { dataset, version: null, status_query: { state: "failed" } }; + ctx.output.result(result, { text: JSON.stringify(result, null, 2) }); + throw error; + } + const result = { + dataset, + version, + status_query: { + state: version ? "matched" : "unavailable", + ...(!version ? { reason: "no_exact_match_in_response" } : {}), + ...consoleDatasetStatusAvailability(version), + }, + }; + ctx.output.result(result, { text: JSON.stringify(result, null, 2) }); + }, +}); diff --git a/packages/commands/src/commands/deploy/instances.ts b/packages/commands/src/commands/deploy/instances.ts new file mode 100644 index 000000000..c7abe23d7 --- /dev/null +++ b/packages/commands/src/commands/deploy/instances.ts @@ -0,0 +1,116 @@ +import { + CONSOLE_DEPLOY_INSTANCES_API, + buildConsoleDeploymentInstancesRequest, + listConsoleDeploymentInstances, + type FlagsDef, +} from "bailian-cli-core"; +import { defineConsoleReadCommand, consoleReadTargetPreview } from "../shared/console-read.ts"; + +const FLAGS = { + page: { + type: "number", + valueHint: "", + description: { "en-US": "Page number (default: 1)", "zh-CN": "页码(默认:1)" }, + }, + pageSize: { + type: "number", + valueHint: "", + description: { "en-US": "Results per page (default: 10)", "zh-CN": "每页条数(默认:10)" }, + }, + keyword: { + type: "string", + valueHint: "", + description: { "en-US": "Server-side name keyword filter", "zh-CN": "服务端名称关键词筛选" }, + }, + modelServiceId: { + type: "string", + valueHint: "", + description: { + "en-US": "Server-side model service ID filter", + "zh-CN": "服务端模型服务 ID 筛选", + }, + }, +} satisfies FlagsDef; + +export default defineConsoleReadCommand({ + auth: "console", + defaultOutput: "json", + description: { + "en-US": "List Console independent deployment services", + "zh-CN": "查询 Console 独立部署服务列表", + }, + flags: FLAGS, + usageArgs: "[--page ] [--page-size ] [--keyword ] [--model-service-id ]", + exampleArgs: [ + "", + "--page 2 --page-size 20", + "--keyword example --model-service-id example-service-id", + ], + notes: [ + { + "en-US": + "Lists Console independent deployment services, not capacity instances or replicas. Fetches only the requested page; records is the list and items is the server's total count. Preserves all business-response fields without deriving callable status.", + "zh-CN": + "查询 Console 独立部署服务,非容量实例或副本。仅获取请求的当前页;records 为列表,items 为服务端总数。保留所有业务响应字段,不推导 callable 状态。", + }, + { + "en-US": + "--keyword and --model-service-id may be supplied together and are forwarded to the API without local filtering. Successful reading does not prove inference or pause permission. This command does not change the API-key deployment list contract.", + "zh-CN": + "--keyword 与 --model-service-id 可同时传入,原样交由 API 筛选,不做本地筛选。读取成功不证明具有推理或暂停权限。本命令不改变 API Key 部署列表的契约。", + }, + ], + validate(flags) { + if (flags.page !== undefined && (!Number.isSafeInteger(flags.page) || flags.page < 1)) { + return { + "en-US": "--page must be a positive safe integer.", + "zh-CN": "--page 必须为正安全整数。", + }; + } + if ( + flags.pageSize !== undefined && + (!Number.isSafeInteger(flags.pageSize) || flags.pageSize < 1) + ) { + return { + "en-US": "--page-size must be a positive safe integer.", + "zh-CN": "--page-size 必须为正安全整数。", + }; + } + if ( + [flags.keyword, flags.modelServiceId].some((value) => value !== undefined && !value.trim()) + ) { + return { + "en-US": "--keyword and --model-service-id must not be blank when provided.", + "zh-CN": "传入的 --keyword 和 --model-service-id 不能仅包含空白或为空。", + }; + } + return undefined; + }, + async run(ctx) { + const { flags, settings } = ctx; + const params = { + page: flags.page, + pageSize: flags.pageSize, + keyword: flags.keyword, + modelServiceId: flags.modelServiceId, + }; + if (settings.dryRun) { + const preview = { + action: "deploy.instances", + api: CONSOLE_DEPLOY_INSTANCES_API, + data: buildConsoleDeploymentInstancesRequest(params), + ...consoleReadTargetPreview(settings), + }; + ctx.output.result( + preview, + settings.output === "text" ? { text: JSON.stringify(preview, null, 2) } : undefined, + ); + return; + } + const response = await listConsoleDeploymentInstances(ctx.client, params); + ctx.output.result( + response, + settings.output === "text" ? { text: JSON.stringify(response, null, 2) } : undefined, + ); + }, +}); diff --git a/packages/commands/src/commands/file/url.ts b/packages/commands/src/commands/file/url.ts new file mode 100644 index 000000000..cfcc44378 --- /dev/null +++ b/packages/commands/src/commands/file/url.ts @@ -0,0 +1,76 @@ +import { CONSOLE_FILE_URL_API, getConsoleFileUrl } from "bailian-cli-core"; +import { defineConsoleReadCommand, consoleReadTargetPreview } from "../shared/console-read.ts"; + +export default defineConsoleReadCommand({ + description: { + "en-US": "Resolve a Console business file key to a file URL", + "zh-CN": "将 Console 业务文件键解析为文件 URL", + }, + auth: "console", + defaultOutput: "json", + usageArgs: "--file-key ", + flags: { + fileKey: { + type: "string", + valueHint: "", + description: { + "en-US": "Console business file key, not an existing URL or oss:// URI", + "zh-CN": "Console 业务文件键,不是已有 URL 或 oss:// URI", + }, + required: true, + }, + }, + notes: [ + { + "en-US": "Uses Console-domain authentication and the selected Console scope.", + "zh-CN": "使用 Console 域鉴权及所选 Console 作用域。", + }, + { + "en-US": + "The business file key is not the DashScope temporary oss:// URI returned by file upload.", + "zh-CN": "业务文件键不等同于 file upload 返回的 DashScope 临时 oss:// URI。", + }, + { + "en-US": "If you already have a file URL, use it directly; this command is unnecessary.", + "zh-CN": "如果已有文件 URL,直接使用即可,无需此命令。", + }, + { + "en-US": + "Only resolves the address and returns the service fields; it does not download files, write local files, or calculate a fixed expiry time.", + "zh-CN": "仅解析地址并返回服务端字段;不自动下载、不写本地文件,也不计算固定到期时间。", + }, + ], + exampleArgs: ["--file-key example-file-key", "--file-key example-file-key --dry-run"], + validate(flags) { + if (!flags.fileKey.trim()) { + return { + "en-US": "--file-key must not be empty or whitespace.", + "zh-CN": "--file-key 不能为空或仅包含空白字符。", + }; + } + if (/^(?:https?|oss):\/\//i.test(flags.fileKey.trim())) { + return { + "en-US": + "--file-key expects a business file key, not an existing HTTP(S) URL or oss:// URI.", + "zh-CN": "--file-key 需要业务文件键,而不是已有 HTTP(S) URL 或 oss:// URI。", + }; + } + return undefined; + }, + async run(ctx) { + const fileKey = ctx.flags.fileKey; + if (ctx.settings.dryRun) { + const plan = { + action: "file.url", + api: CONSOLE_FILE_URL_API, + data: { fileKey }, + ...consoleReadTargetPreview(ctx.settings), + }; + ctx.output.result(plan, { text: JSON.stringify(plan, null, 2) }); + return; + } + + const result = await getConsoleFileUrl(ctx.client, fileKey); + ctx.output.result(result, { text: JSON.stringify(result, null, 2) }); + }, +}); diff --git a/packages/commands/src/commands/shared/console-read.ts b/packages/commands/src/commands/shared/console-read.ts new file mode 100644 index 000000000..585f43387 --- /dev/null +++ b/packages/commands/src/commands/shared/console-read.ts @@ -0,0 +1,43 @@ +import { + defineCommand, + effectiveConsoleGatewayConfig, + type Command, + type FlagsDef, + type Settings, +} from "bailian-cli-core"; +import { consoleWorkspaceIdentityPreview, resolveConsoleWorkspaceAgent } from "./workspace.ts"; + +/** Bind the whole read chain to one explicitly selected Console workspace. */ +export function defineConsoleReadCommand( + spec: Command, +): Command { + return defineCommand({ + ...spec, + auth: "console", + defaultOutput: "json", + notes: [ + ...(spec.notes ?? []), + { + "en-US": + "A selected workspace is resolved to its Console identity before business requests, overriding --console-switch-agent for this invocation only. Resolution failure stops the query; dry-run only previews this step.", + "zh-CN": + "选定工作空间时,先解析其 Console 身份,仅对本次执行覆盖 --console-switch-agent。解析失败即停止查询;dry-run 仅预览此步骤。", + }, + ], + async run(ctx) { + let client = ctx.client; + if (!ctx.settings.dryRun) { + const switchAgent = await resolveConsoleWorkspaceAgent(ctx); + if (switchAgent !== undefined) client = client.withConsoleSwitchAgent(switchAgent); + } + await spec.run({ ...ctx, client }); + }, + }); +} + +export function consoleReadTargetPreview(settings: Settings) { + const target = effectiveConsoleGatewayConfig(settings); + if (!settings.workspaceId) return target; + const { consoleSwitchAgent: _configuredAgent, ...gateway } = target; + return { ...gateway, ...consoleWorkspaceIdentityPreview(settings) }; +} diff --git a/packages/commands/src/commands/shared/workspace.ts b/packages/commands/src/commands/shared/workspace.ts index e8f85149f..89d15c4a5 100644 --- a/packages/commands/src/commands/shared/workspace.ts +++ b/packages/commands/src/commands/shared/workspace.ts @@ -6,7 +6,7 @@ import { formatCommand, BailianError, ExitCode, - unwrapResponse, + unwrapConsoleResponse, type Client, type CommandContext, type FlagsDef, @@ -53,8 +53,12 @@ export interface ConsoleWorkspaceInfo { /** 复用 workspace list 的只读接口;保留用于查询身份映射的 tenantId。 */ export async function listConsoleWorkspaces(client: Client): Promise { const result = await client.console>(LIST_WORKSPACES_API, {}); - const response = unwrapResponse(result); - const workspaces = Array.isArray(response) ? response : response.data; + const response = unwrapConsoleResponse(result); + const workspaces = Array.isArray(response) + ? response + : response && typeof response === "object" && "data" in response + ? response.data + : undefined; return Array.isArray(workspaces) ? workspaces.filter((workspace) => workspace && typeof workspace === "object") : []; diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index cf1d3c344..441b1ca44 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -98,6 +98,7 @@ export { default as speechVocabularyGet } from "./commands/speech/vocabulary/get export { default as speechVocabularyUpdate } from "./commands/speech/vocabulary/update.ts"; export { default as speechVocabularyDelete } from "./commands/speech/vocabulary/delete.ts"; export { default as fileUpload } from "./commands/file/upload.ts"; +export { default as fileUrl } from "./commands/file/url.ts"; export { default as consoleCall } from "./commands/console/call.ts"; export { default as securityOverview } from "./commands/security/overview.ts"; export { default as securityAlerts } from "./commands/security/alerts.ts"; @@ -164,6 +165,8 @@ export { default as permissionRevoke } from "./commands/permission/revoke.ts"; export { default as datasetUpload } from "./commands/dataset/upload.ts"; export { default as datasetList } from "./commands/dataset/list.ts"; export { default as datasetGet } from "./commands/dataset/get.ts"; +export { default as datasetVersion } from "./commands/dataset/version.ts"; +export { default as datasetGroups } from "./commands/dataset/groups.ts"; export { default as datasetDelete } from "./commands/dataset/delete.ts"; export { default as datasetValidate } from "./commands/dataset/validate.ts"; export { @@ -198,6 +201,7 @@ export { deployImageCreate, } from "./commands/deploy/create.ts"; export { default as deployList } from "./commands/deploy/list.ts"; +export { default as deployInstances } from "./commands/deploy/instances.ts"; export { default as deployGet } from "./commands/deploy/get.ts"; export { default as deployCapacityList } from "./commands/deploy/capacity-list.ts"; export { default as deployCapacityGet } from "./commands/deploy/capacity-get.ts"; diff --git a/packages/commands/tests/e2e/console-read.e2e.test.ts b/packages/commands/tests/e2e/console-read.e2e.test.ts new file mode 100644 index 000000000..160a0ed37 --- /dev/null +++ b/packages/commands/tests/e2e/console-read.e2e.test.ts @@ -0,0 +1,634 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; +import { httpTransport } from "bailian-cli-core"; +import type { CommandSchema } from "bailian-cli-runtime"; +import { buildIsolatedCliEnv } from "e2e/isolation"; +import { runCommandE2e, runCommandHelp, runCommandInProcess } from "./helpers.ts"; +import { CONSOLE_READ_ROUTES } from "./topic-routes.ts"; + +// Offline only: real Console credentials and online acceptance are deliberately not used. +const offlineEnvironment: NodeJS.ProcessEnv = { + DASHSCOPE_API_KEY: undefined, + DASHSCOPE_ACCESS_TOKEN: undefined, + DASHSCOPE_BASE_URL: undefined, + BAILIAN_ACCESS_KEY_ID: undefined, + BAILIAN_ACCESS_KEY_SECRET: undefined, + BAILIAN_SECURITY_TOKEN: undefined, + ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, + ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, + ALIBABA_CLOUD_SECURITY_TOKEN: undefined, + BAILIAN_WORKSPACE_ID: undefined, + DASHSCOPE_OUTPUT: undefined, + DASHSCOPE_VERBOSE: "0", + DASHSCOPE_QUIET: "0", + HTTP_PROXY: undefined, + HTTPS_PROXY: undefined, + ALL_PROXY: undefined, + NO_PROXY: undefined, + http_proxy: undefined, + https_proxy: undefined, + all_proxy: undefined, + no_proxy: undefined, + NODE_USE_ENV_PROXY: "0", + NODE_OPTIONS: undefined, + NO_UPDATE_NOTIFIER: "1", + DO_NOT_TRACK: "1", + NO_COLOR: "1", + FORCE_COLOR: undefined, +}; +const datasetApi = "zeldaEasy.broadscope-platform.dataset.queryByDatasetId.m_efm"; +const groupsApi = "zeldaEasy.broadscope-platform.datasetGroup.query.m_efm"; +const versionsApi = "zeldaEasy.broadscope-platform.dataset.query.m_efm"; +const fileApi = "zeldaEasy.bmp.ossRpcService.download"; +const instancesApi = + "zeldaEasy.broadscope-platform.modelInstance.listIndependentDeployedModel.m_efm"; +const workspaceApi = "zeldaEasy.bailian-dash-workspace.space.listWorkspaces"; +const leaves = [ + { + path: "dataset version", + flags: ["--dataset-group-id", "--dataset-id", "--include-status"], + english: "List Console dataset versions by group or get one version by ID", + chinese: "按组列出 Console 数据集版本,或按版本 ID 查询详情", + }, + { + path: "file url", + flags: ["--file-key"], + english: "Resolve a Console business file key", + chinese: "Console 业务文件键解析为文件 URL", + }, + { + path: "deploy instances", + flags: ["--page", "--page-size", "--keyword", "--model-service-id"], + english: "List Console independent deployment services", + chinese: "查询 Console 独立部署服务列表", + }, + { + path: "dataset groups", + flags: ["--page", "--page-size", "--name"], + english: "List Console dataset groups", + chinese: "查询 Console 数据集组列表", + }, +]; +const previews = [ + { + name: "dataset metadata", + args: ["dataset", "version", "--dataset-id", "fixture-version"], + plan: { + action: "dataset.version", + api: datasetApi, + data: { input: { datasetId: "fixture-version" } }, + }, + }, + { + name: "dataset with status", + args: ["dataset", "version", "--dataset-id", "fixture-version", "--include-status"], + plan: { + action: "dataset.version", + api: datasetApi, + data: { input: { datasetId: "fixture-version" } }, + status_query: { + api: versionsApi, + input: { + datasetId: "fixture-version", + page: 1, + itemsPerPage: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + datasetGroupIdFrom: "dataset.datasetGroupId", + }, + }, + }, + { + name: "file URL", + args: ["file", "url", "--file-key", "fixture folder/object.jsonl"], + plan: { + action: "file.url", + api: fileApi, + data: { fileKey: "fixture folder/object.jsonl" }, + }, + }, + { + name: "deployment defaults", + args: ["deploy", "instances"], + plan: { + action: "deploy.instances", + api: instancesApi, + data: { input: { page: 1, itemsPerPage: 10 } }, + }, + }, + { + name: "deployment second page with intersecting filters", + args: [ + "deploy", + "instances", + "--page", + "2", + "--page-size", + "101", + "--keyword", + "fixture service", + "--model-service-id", + "fixture-service", + ], + plan: { + action: "deploy.instances", + api: instancesApi, + data: { + input: { + page: 2, + itemsPerPage: 101, + searchNameKeyword: "fixture service", + modelServiceId: "fixture-service", + }, + }, + }, + }, + { + name: "dataset groups without type filters", + args: ["dataset", "groups"], + plan: { + action: "dataset.groups", + api: groupsApi, + data: { + input: { + datasetGroupPageNo: 1, + datasetGroupPageSize: 10, + datasetPageSize: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }, + }, + }, + { + name: "dataset groups second page with trimmed name", + args: [ + "dataset", + "groups", + "--page", + "2", + "--page-size", + "101", + "--name", + " \t Fixture group \t ", + ], + plan: { + action: "dataset.groups", + api: groupsApi, + data: { + input: { + datasetGroupPageNo: 2, + datasetGroupPageSize: 101, + name: "Fixture group", + datasetPageSize: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }, + }, + }, + { + name: "dataset version list by group without pagination", + args: ["dataset", "version", "--dataset-group-id", "fixture-group"], + plan: { + action: "dataset.version", + api: versionsApi, + data: { + input: { + datasetGroupId: "fixture-group", + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }, + }, + }, +]; +let isolated: ReturnType; + +beforeEach(() => { + isolated = buildIsolatedCliEnv({ overrides: offlineEnvironment }); + writeFileSync( + join(isolated.env.BAILIAN_CONFIG_DIR!, "config.json"), + JSON.stringify({ language: "en-US", telemetry: false }), + ); + // Guard the process-internal dry-run/validation paths against accidental HTTP. + vi.spyOn(httpTransport, "fetch").mockRejectedValue(new Error("Unexpected Console HTTP")); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected offline HTTP")); +}); +afterEach(() => { + try { + expect(httpTransport.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + isolated.dispose(); + } +}); + +function offline(args: string[]) { + // Preserve explicit undefined overrides when the runner rebuilds its isolated environment. + return runCommandInProcess(CONSOLE_READ_ROUTES, args, { ...isolated.env, ...offlineEnvironment }); +} + +describe("e2e: Console read offline contracts", () => { + test.each(previews)( + "$name rejects an explicit blank workspace before auth or fallback", + async (scenario) => { + for (const workspaceId of ["", " "]) { + const result = await offline([ + ...scenario.args, + "--workspace-id", + workspaceId, + "--console-switch-agent", + "111", + "--output", + "json", + ]); + expect(result.exitCode).toBe(2); + expect(JSON.parse(result.stderr).error.message).toContain("--workspace-id"); + expect(result.stdout).toBe(""); + } + }, + ); + test.each(leaves)("$path leaf help exposes its Console scope in English", async (leaf) => { + const result = await runCommandHelp(CONSOLE_READ_ROUTES, [...leaf.path.split(" "), "--help"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`bl ${leaf.path}`); + expect(result.stderr).toContain(leaf.english); + for (const flag of [ + ...leaf.flags, + "--console-region", + "--console-site", + "--console-switch-agent", + "--workspace-id", + ]) + expect(result.stderr).toContain(flag); + }); + + test.each(leaves)("$path leaf help honors an isolated Chinese profile", async (leaf) => { + writeFileSync( + join(isolated.env.BAILIAN_CONFIG_DIR!, "config.json"), + JSON.stringify({ language: "zh-CN", telemetry: false }), + ); + const result = await offline([...leaf.path.split(" "), "--help"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(leaf.chinese); + for (const flag of [...leaf.flags, "--workspace-id"]) expect(result.stderr).toContain(flag); + }); + + test("dataset version introspection exposes optional IDs and bilingual cross-flag constraints", async () => { + const result = await offline(["dataset", "version", "--introspect"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + const schema = JSON.parse(result.stdout) as CommandSchema; + expect(schema.scope).toEqual(["dataset", "version"]); + expect(schema.commands).toHaveLength(1); + const command = schema.commands[0]!; + expect(command.path).toEqual(["dataset", "version"]); + expect(command.auth).toBe("console"); + expect(command.description).toEqual({ + "en-US": "List Console dataset versions by group or get one version by ID", + "zh-CN": "按组列出 Console 数据集版本,或按版本 ID 查询详情", + }); + expect(command.usage).toBe( + "bl dataset version (--dataset-group-id | --dataset-id [--include-status])", + ); + expect(Object.keys(command.flags).sort()).toEqual([ + "--dataset-group-id", + "--dataset-id", + "--include-status", + ]); + for (const name of ["--dataset-group-id", "--dataset-id"]) { + expect(command.flags[name]).toMatchObject({ type: "string", valueHint: "" }); + expect(command.flags[name]).not.toHaveProperty("required", true); + } + expect(command.flags["--include-status"]).toMatchObject({ type: "switch" }); + expect(command.constraints).toEqual({ crossFlagValidation: "not-exported" }); + for (const language of ["en-US", "zh-CN"] as const) { + const notes = command.notes.map((note) => (typeof note === "string" ? note : note[language])); + expect( + notes.some( + (note) => + note.includes("--dataset-group-id") && + note.includes("--dataset-id") && + (language === "en-US" + ? /exactly one|mutually exclusive|not both|either .* or /i.test(note) + : /二选一|互斥|不能同时|只能.*(?:一个|一种)/.test(note)), + ), + ).toBe(true); + expect( + notes.some( + (note) => + note.includes("--include-status") && + note.includes("--dataset-id") && + (language === "en-US" ? /only/i.test(note) : /仅|只/.test(note)), + ), + ).toBe(true); + expect(notes.join("\n")).not.toContain("dataset versions --dataset-group-id"); + } + }); + + test.each([{ extraArgs: [] }, { extraArgs: ["--include-status"] }])( + "dataset version requires at least one ID before authentication: $extraArgs", + async ({ extraArgs }) => { + const result = await offline([ + "dataset", + "version", + ...extraArgs, + "--quiet", + "--output", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr).error; + expect(error.message).toContain("--dataset-id"); + expect(error.message).toContain("--dataset-group-id"); + }, + ); + + test("missing --file-key fails before authentication", async () => { + const result = await offline(["file", "url", "--quiet", "--output", "json"]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("--file-key"); + }); + + test.each([ + { path: ["dataset", "version"], flag: "--dataset-id" }, + { path: ["dataset", "version"], flag: "--dataset-group-id" }, + { path: ["file", "url"], flag: "--file-key" }, + { path: ["dataset", "groups"], flag: "--name" }, + { path: ["deploy", "instances"], flag: "--keyword" }, + { path: ["deploy", "instances"], flag: "--model-service-id" }, + ])("$flag rejects empty and whitespace values", async ({ path, flag }) => { + for (const value of ["", " \t "]) { + const result = await offline([...path, flag, value, "--dry-run", "--output", "json"]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(flag); + } + }); + + test.each( + ["fixture-version", "", " \t "].flatMap((datasetId) => + ["fixture-group", "", " \t "].map((datasetGroupId) => ({ datasetId, datasetGroupId })), + ), + )("dataset version rejects both supplied IDs, including blanks: %j", async (ids) => { + for (const dryRun of [false, true]) { + const result = await offline([ + "dataset", + "version", + "--dataset-id", + ids.datasetId, + "--dataset-group-id", + ids.datasetGroupId, + "--workspace-id", + "fixture-workspace", + ...(dryRun ? ["--dry-run"] : []), + "--output", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.message).toMatch(/--dataset-(?:group-)?id/); + expect(httpTransport.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + } + }); + + test.each([false, true])( + "dataset version rejects group-only --include-status before any request (dry-run=%s)", + async (dryRun) => { + const result = await offline([ + "dataset", + "version", + "--dataset-group-id", + "fixture-group", + "--include-status", + "--workspace-id", + "fixture-workspace", + ...(dryRun ? ["--dry-run"] : []), + "--output", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr).error; + expect(error.message).toContain("--include-status"); + expect(error.message).toContain("--dataset-id"); + expect(httpTransport.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }, + ); + + test.each([ + { extraArgs: [] }, + { extraArgs: ["--dataset-group-id", "fixture-group"] }, + { extraArgs: ["--introspect"] }, + { extraArgs: ["--help"] }, + ])("removed dataset versions path is not an alias: $extraArgs", async ({ extraArgs }) => { + // Intentionally absent from the route map: exercise CLI rejection, not the help-only runner. + expect(CONSOLE_READ_ROUTES).not.toHaveProperty("dataset versions"); + expect(Object.values(CONSOLE_READ_ROUTES)).not.toContain("datasetVersions"); + const result = await offline(["dataset", "versions", ...extraArgs, "--output", "json"]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr).error; + expect(error.message).toMatch(/Unknown command/i); + expect(error.message).toContain("dataset versions"); + }); + + test.each([ + "https://files.example.invalid/already-signed?Signature=fixture-signature", + "http://files.example.invalid/object", + "oss://fixture-bucket/object.jsonl", + " HTTPS://files.example.invalid/object ", + " OSS://fixture-bucket/object ", + ])("file-key rejects existing URL/URI: %s", async (fileKey) => { + const result = await offline(["file", "url", "--file-key", fileKey, "--dry-run"]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("--file-key"); + expect(result.stderr).not.toContain("fixture-signature"); + }); + + for (const command of [ + { path: ["deploy", "instances"], pageField: "page", pageSizeField: "itemsPerPage" }, + { + path: ["dataset", "groups"], + pageField: "datasetGroupPageNo", + pageSizeField: "datasetGroupPageSize", + }, + ]) { + for (const flag of ["--page", "--page-size"]) { + test.each(["0", "-1", "1.5", "9007199254740992", "NaN", "Infinity"])( + `${command.path.join(" ")} ${flag} rejects non-positive/non-safe-integer %s`, + async (value) => { + const result = await offline([...command.path, flag, value, "--dry-run"]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(flag); + }, + ); + } + + test(`${command.path.join(" ")} pagination accepts the positive safe-integer boundary without an invented max100`, async () => { + const result = await offline([ + ...command.path, + "--page", + String(Number.MAX_SAFE_INTEGER), + "--page-size", + String(Number.MAX_SAFE_INTEGER), + "--dry-run", + "--output", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + data: { + input: { + [command.pageField]: Number.MAX_SAFE_INTEGER, + [command.pageSizeField]: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + } + + test.each(["--page", "--page-size", "--list"])( + "dataset version group query rejects unsupported %s before auth or HTTP", + async (flag) => { + const result = await offline([ + "dataset", + "version", + "--dataset-group-id", + "fixture-group", + flag, + ...(flag === "--list" ? [] : ["1"]), + "--dry-run", + "--output", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.message).toContain(flag); + }, + ); + + test.each([ + { flag: "--dataset-id", field: "datasetId", api: datasetApi }, + { flag: "--dataset-group-id", field: "datasetGroupId", api: versionsApi }, + ])( + "dataset version dispatches only by $flag, never by ID shape", + async ({ flag, field, api }) => { + for (const identifier of ["file-fixture", "000123"]) { + const result = await offline([ + "dataset", + "version", + flag, + identifier, + "--dry-run", + "--output", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + action: "dataset.version", + api, + data: { + input: { + [field]: identifier, + ...(field === "datasetGroupId" + ? { datasetTaskTypeList: [0], datasetTaskPageSize: 1 } + : {}), + }, + }, + consoleRegion: "cn-beijing", + consoleSite: "domestic", + }); + } + }, + ); + + for (const scoped of [false, true]) { + test.each(previews)( + `$name static preview without credentials (workspace=${scoped})`, + async (scenario) => { + const result = await offline([ + ...scenario.args, + "--dry-run", + "--output", + "json", + "--console-region", + "ap-southeast-1", + "--console-site", + "international", + "--console-switch-agent", + "111", + ...(scoped ? ["--workspace-id", "fixture-workspace"] : []), + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + ...scenario.plan, + consoleRegion: "ap-southeast-1", + consoleSite: "international", + ...(scoped + ? { + workspaceIdentity: { + api: workspaceApi, + workspaceId: "fixture-workspace", + switchAgentFrom: "tenantId", + }, + } + : { consoleSwitchAgent: 111 }), + }); + }, + ); + } + + test.each(previews)("$name defaults to a complete JSON preview", async (scenario) => { + const result = await offline([...scenario.args, "--dry-run"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + ...scenario.plan, + consoleRegion: "cn-beijing", + consoleSite: "domestic", + }); + }); + + test("one subprocess preserves argv with spaces, stdout/stderr and process exit", async () => { + // The only child: verifies the real process/stdio boundary; all matrices stay in-worker. + const args = [ + "file", + "url", + "--file-key", + "fixture folder/object.jsonl", + "--dry-run", + "--output", + "json", + "--workspace-id", + "fixture-workspace", + "--console-switch-agent", + "111", + ]; + const expected = await offline(args); + expect(expected.exitCode, expected.stderr).toBe(0); + const child = await runCommandE2e(CONSOLE_READ_ROUTES, args, { + ...isolated.env, + ...offlineEnvironment, + }); + expect(child).toEqual(expected); + expect(child.stderr).toBe(""); + expect(JSON.parse(child.stdout)).toMatchObject({ + data: { fileKey: "fixture folder/object.jsonl" }, + workspaceIdentity: { workspaceId: "fixture-workspace", switchAgentFrom: "tenantId" }, + }); + }); +}); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index dc6a9f3a6..bb46dbaa3 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -172,6 +172,13 @@ export const FILE_UPLOAD_ROUTES: E2eRouteExports = { "file upload": "fileUpload", }; +export const CONSOLE_READ_ROUTES: E2eRouteExports = { + "dataset version": "datasetVersion", + "file url": "fileUrl", + "deploy instances": "deployInstances", + "dataset groups": "datasetGroups", +}; + export const ADVISOR_ROUTES: E2eRouteExports = { "advisor recommend": "advisorRecommend", }; diff --git a/packages/commands/tests/output/alert-output.test.ts b/packages/commands/tests/output/alert-output.test.ts index e2da35a24..6da536a1b 100644 --- a/packages/commands/tests/output/alert-output.test.ts +++ b/packages/commands/tests/output/alert-output.test.ts @@ -407,7 +407,7 @@ for (const scenario of cases) { expect(result.strayOut + result.strayErr).toBe(""); expect(diagnostics(result.stderr)).toHaveLength(1); expect(diagnostics(result.stderr)[0].error.message).toBe( - "Console gateway error: Original.Rejected", + "original failure (HTTP 200; code=Original.Rejected)", ); expect(calls.map((call) => call.api)).toEqual([workspaceApi, statusApi, scenario.api]); }); diff --git a/packages/commands/tests/output/console-read-output.test.ts b/packages/commands/tests/output/console-read-output.test.ts new file mode 100644 index 000000000..331ac8eeb --- /dev/null +++ b/packages/commands/tests/output/console-read-output.test.ts @@ -0,0 +1,856 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { + BailianError, + buildSettings, + Client, + httpTransport, + type AnyCommand, + type LocalizedText, +} from "bailian-cli-core"; +import { createOutputReporter } from "bailian-cli-runtime"; +import { enterIsolatedHome } from "e2e/isolation"; +import datasetVersion from "../../src/commands/dataset/version.ts"; +import datasetGroups from "../../src/commands/dataset/groups.ts"; +import fileUrl from "../../src/commands/file/url.ts"; +import deployInstances from "../../src/commands/deploy/instances.ts"; + +const identity = { binName: "bl", version: "test", clientName: "test", npmPackage: "test" }; +const datasetApi = "zeldaEasy.broadscope-platform.dataset.queryByDatasetId.m_efm"; +const groupsApi = "zeldaEasy.broadscope-platform.datasetGroup.query.m_efm"; +const versionsApi = "zeldaEasy.broadscope-platform.dataset.query.m_efm"; +const fileApi = "zeldaEasy.bmp.ossRpcService.download"; +const instancesApi = + "zeldaEasy.broadscope-platform.modelInstance.listIndependentDeployedModel.m_efm"; +const workspaceApi = "zeldaEasy.bailian-dash-workspace.space.listWorkspaces"; +const retained = { future: { enabled: false, count: 0, missing: null, values: [] } }; +const dataset = { + datasetId: "fixture-version", + datasetGroupId: "fixture-group", + name: "Fixture dataset", + data: { count: 0, optional: null }, + ...retained, +}; +const version = { + datasetId: "fixture-version", + datasetGroupId: "fixture-group", + releaseStatus: 0, + datasetTaskDTO: { taskId: "fixture-import", taskStatus: 0, ...retained }, + ...retained, +}; +const otherVersion = { + ...version, + datasetId: "fixture-other-version", + fileId: "fixture-file", + releaseStatus: 2, +}; +const groupsPage = { + records: [ + { + datasetGroupId: "fixture-group", + name: "Server-returned group", + datasets: [otherVersion], + ...retained, + }, + ], + items: 405, + page: 2, + itemsPerPage: 101, + pageCount: 5, + next: null, + ...retained, +}; +const matchedStatus = { state: "matched", release_status: "returned", import_status: "returned" }; +const signedUrl = + "https://files.example.invalid/fixture.jsonl?OSSAccessKeyId=fixture-id&Signature=fixture-signature&Expires=1234567890"; +const fileResult = { + url: signedUrl, + expiresIn: 0, + fileName: null, + downloadable: false, + ...retained, +}; +const instancesPage = { + records: [ + { modelServiceId: "fixture-service", status: 9876, replicas: 0, paused: false, ...retained }, + ], + items: 405, + itemsPerPage: 101, + page: 2, + pageCount: 5, + next: null, + ...retained, +}; +const datasetRequest = { input: { datasetId: "fixture-version" } }; +const versionRequest = { + input: { + datasetId: "fixture-version", + datasetGroupId: "fixture-group", + page: 1, + itemsPerPage: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, +}; +interface Scenario { + name: string; + command: AnyCommand; + flags: Record; + requests: Array<{ api: string; data: Record }>; + want: unknown; +} +const groupsScenario: Scenario = { + name: "dataset groups second page with trimmed name", + command: datasetGroups, + flags: { page: 2, pageSize: 101, name: " \t Fixture group \t " }, + requests: [ + { + api: groupsApi, + data: { + input: { + datasetGroupPageNo: 2, + datasetGroupPageSize: 101, + name: "Fixture group", + datasetPageSize: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }, + }, + ], + want: groupsPage, +}; +const versionsScenario: Scenario = { + name: "dataset version list by group from a raw array", + command: datasetVersion, + flags: { datasetGroupId: "fixture-group" }, + requests: [ + { + api: versionsApi, + data: { + input: { + datasetGroupId: "fixture-group", + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }, + }, + ], + want: { result: [otherVersion, version] }, +}; +const scenarios: Scenario[] = [ + { + name: "dataset metadata by default", + command: datasetVersion, + flags: { datasetId: "fixture-version" }, + requests: [{ api: datasetApi, data: datasetRequest }], + want: { dataset }, + }, + { + name: "dataset with exact status", + command: datasetVersion, + flags: { datasetId: "fixture-version", includeStatus: true }, + requests: [ + { api: datasetApi, data: datasetRequest }, + { api: versionsApi, data: versionRequest }, + ], + want: { dataset, version, status_query: matchedStatus }, + }, + { + name: "file signed URL", + command: fileUrl, + flags: { fileKey: "fixture folder/object.jsonl" }, + requests: [{ api: fileApi, data: { fileKey: "fixture folder/object.jsonl" } }], + want: fileResult, + }, + { + name: "deployment second page", + command: deployInstances, + flags: { + page: 2, + pageSize: 101, + keyword: "fixture service", + modelServiceId: "fixture-service", + }, + requests: [ + { + api: instancesApi, + data: { + input: { + page: 2, + itemsPerPage: 101, + searchNameKeyword: "fixture service", + modelServiceId: "fixture-service", + }, + }, + }, + ], + want: instancesPage, + }, + groupsScenario, + versionsScenario, +]; +interface GatewayCall { + url: URL; + method: string | undefined; + headers: Headers; + form: URLSearchParams; + api: string; + version: string; + data: Record; + cornerstone: Record; +} +interface Display { + format: "json" | "text"; + quiet?: boolean; + verbose?: boolean; + dryRun?: boolean; + workspaceId?: string; + language?: "en-US" | "zh-CN"; +} +let isolated: ReturnType; +let calls: GatewayCall[]; +let payloads: Map; +let rawResponses: Map; +let transportFailure: { api: string; error: Error } | undefined; + +beforeEach(() => { + isolated = enterIsolatedHome(); + for (const name of [ + "DASHSCOPE_API_KEY", + "DASHSCOPE_ACCESS_TOKEN", + "DASHSCOPE_BASE_URL", + "BAILIAN_WORKSPACE_ID", + "BAILIAN_ACCESS_KEY_ID", + "BAILIAN_ACCESS_KEY_SECRET", + "BAILIAN_SECURITY_TOKEN", + "ALIBABA_CLOUD_ACCESS_KEY_ID", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET", + "ALIBABA_CLOUD_SECURITY_TOKEN", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "NODE_OPTIONS", + "FORCE_COLOR", + "DASHSCOPE_OUTPUT", + ]) + vi.stubEnv(name, undefined); + vi.stubEnv("NODE_USE_ENV_PROXY", "0"); + vi.stubEnv("DASHSCOPE_QUIET", "0"); + vi.stubEnv("DASHSCOPE_VERBOSE", "0"); + vi.stubEnv("NO_UPDATE_NOTIFIER", "1"); + vi.stubEnv("DO_NOT_TRACK", "1"); + vi.stubEnv("NO_COLOR", "1"); + calls = []; + payloads = new Map([ + [ + workspaceApi, + [ + { workspaceId: "fixture-unrelated", tenantId: "7654322" }, + { workspaceId: "fixture-workspace", tenantId: "7654321" }, + ], + ], + [datasetApi, dataset], + [groupsApi, groupsPage], + [versionsApi, [otherVersion, version]], + [fileApi, fileResult], + [instancesApi, instancesPage], + ]); + rawResponses = new Map(); + transportFailure = undefined; + // Only the real Client's transport is replaced. Never contact an external service. + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected unmocked HTTP")); + vi.spyOn(httpTransport, "fetch").mockImplementation(async (input, init) => { + if (typeof init?.body !== "string") throw new Error("Expected form-encoded Console request"); + const form = new URLSearchParams(init.body); + const params = JSON.parse(form.get("params")!) as { + Api: string; + V: string; + Data: { cornerstoneParam: Record; [key: string]: unknown }; + }; + const { cornerstoneParam, ...data } = params.Data; + calls.push({ + url: new URL(input instanceof Request ? input.url : input), + method: init.method, + headers: new Headers(init.headers), + form, + api: params.Api, + version: params.V, + data, + cornerstone: cornerstoneParam, + }); + if (transportFailure?.api === params.Api) throw transportFailure.error; + if (rawResponses.has(params.Api)) return Response.json(rawResponses.get(params.Api)); + if (!payloads.has(params.Api)) throw new Error(`Unexpected API: ${params.Api}`); + const payload = payloads.get(params.Api); + // Preserve the real gateway's metadata and workspace pagination shape; + // simplified envelopes hid a production workspace-resolution regression. + const responseData = + params.Api === workspaceApi && Array.isArray(payload) + ? { data: payload, maxResults: 100, totalCount: payload.length } + : payload; + return Response.json({ + code: "200", + httpStatusCode: "200", + requestId: "outer-request-fixture", + successResponse: true, + data: { + success: true, + httpStatus: 200, + errorCode: "", + errorMsg: "", + api: params.Api, + DataV2: { + ret: ["SUCCESS::ok"], + data: { + code: "200", + data: responseData, + success: true, + failed: false, + requestId: "inner-request-fixture", + httpStatusCode: 200, + }, + }, + }, + }); + }); +}); +afterEach(() => { + try { + expect(globalThis.fetch).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + isolated.restore(); + } +}); + +async function invoke(scenario: Scenario, display: Display = { format: "json" }) { + let stdout = ""; + let stderr = ""; + let stdoutAtError: string | undefined; + let thrown: unknown; + const language = display.language ?? "en-US"; + const configPath = join(isolated.configDir, "config.json"); + const config = { + language, + telemetry: false, + console_switch_agent: 111, + ...(display.workspaceId ? { workspace_id: display.workspaceId } : {}), + }; + const savedConfig = JSON.stringify(config); + writeFileSync(configPath, savedConfig); + const settings = buildSettings({ + flags: { + output: display.format, + quiet: display.quiet, + verbose: display.verbose, + dryRun: display.dryRun, + consoleRegion: "cn-beijing", + consoleSite: "domestic", + consoleSwitchAgent: 111, + workspaceId: display.workspaceId, + }, + file: config, + env: {}, + configPath, + }); + const originalSettings = structuredClone(settings); + const localize = (text: LocalizedText) => (typeof text === "string" ? text : text[language]); + // Inject scoped streams into the real reporter; do not implement a CLI/global capture layer. + const reporter = createOutputReporter({ + settings, + localize, + stdout: { + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }); + const credential = { + token: "fixture-console-token", + source: "flag" as const, + region: "cn-beijing", + site: "domestic" as const, + switchAgent: 111, + }; + const client = new Client({ + identity, + settings, + baseUrl: "https://api.example.invalid", + ...(display.dryRun ? {} : { consoleCred: credential }), + debug: (diagnostic) => reporter.output.debug(diagnostic), + }); + let exitCode = 0; + try { + await scenario.command.run({ + flags: scenario.flags, + settings, + identity, + localize, + client, + output: reporter.output, + } as never); + } catch (error) { + thrown = error; + stdoutAtError = stdout; + exitCode = reporter.error(error); + } + expect(settings).toEqual(originalSettings); + expect(credential.switchAgent).toBe(111); + expect(readFileSync(configPath, "utf8")).toBe(savedConfig); + return { stdout, stderr, stdoutAtError, thrown, exitCode, client }; +} + +function diagnostics(stderr: string): Array<{ debug?: unknown; error: { message: string } }> { + return stderr.trim() + ? stderr + .trimEnd() + .split("\n") + .map((line) => JSON.parse(line)) + : []; +} +function expectGatewayCalls(scenario: Scenario, scoped = false) { + const expected = [...(scoped ? [{ api: workspaceApi, data: {} }] : []), ...scenario.requests]; + expect(calls).toHaveLength(expected.length); + for (const [index, request] of expected.entries()) { + const call = calls[index]!; + expect(call.method).toBe("POST"); + expect(call.url.origin).toBe("https://bailian-cs.console.aliyun.com"); + expect(call.url.pathname).toBe("/cli/api.json"); + expect(Object.fromEntries(call.url.searchParams)).toEqual({ + action: "BroadScopeAspnGateway", + product: "sfm_bailian", + api: request.api, + }); + expect(call.headers.get("authorization")).toBe("Bearer fixture-console-token"); + expect(call.headers.get("content-type")).toBe("application/x-www-form-urlencoded"); + expect(call.headers.get("accept")).toBe("*/*"); + expect([...call.form.keys()].sort()).toEqual(["params", "region"]); + expect(call.form.get("region")).toBe("cn-beijing"); + expect(call.api).toBe(request.api); + expect(call.version).toBe("1.0"); + expect(call.data).toEqual(request.data); + expect(call.cornerstone).toEqual({ + protocol: "V2", + console: "ONE_CONSOLE", + productCode: "p_efm", + switchUserType: 3, + consoleSite: "BAILIAN_ALIYUN", + switchAgent: scoped && index > 0 ? 7654321 : 111, + }); + } +} +const displays = (["json", "text"] as const).flatMap((format) => + [false, true].flatMap((quiet) => [false, true].map((verbose) => ({ format, quiet, verbose }))), +); + +for (const scenario of scenarios) { + test.each(displays)( + `${scenario.name} retains the full result and gateway protocol: %j`, + async (display) => { + const result = await invoke(scenario, display); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.thrown).toBeUndefined(); + // These commands deliberately render the same full JSON object as readable text. + if (display.format === "json") expect(JSON.parse(result.stdout)).toEqual(scenario.want); + else expect(result.stdout).toBe(JSON.stringify(scenario.want, null, 2) + "\n"); + if (display.verbose) { + expect(result.stderr).not.toBe(""); + if (display.format === "json") { + const records = diagnostics(result.stderr); + expect(records.length).toBeGreaterThan(0); + expect(records.every((record) => record.debug !== undefined)).toBe(true); + } + } else expect(result.stderr).toBe(""); + expect(result.stdout + result.stderr).not.toContain("fixture-console-token"); + expect(result.stderr).not.toContain(signedUrl); + expect(result.stderr).not.toContain("fixture-signature"); + expect(result.stderr).not.toContain("OSSAccessKeyId"); + expectGatewayCalls(scenario); + }, + ); + + test(`${scenario.name} binds every request to the explicitly resolved workspace without mutating the original client`, async () => { + const result = await invoke(scenario, { format: "json", workspaceId: "fixture-workspace" }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(scenario.want); + expectGatewayCalls(scenario, true); + // Reuse the original client: the per-invocation clone must not leak its tenant identity. + await result.client.console(fileApi, { fileKey: "fixture-probe" }); + expect(calls.at(-1)!.cornerstone.switchAgent).toBe(111); + }); + + test.each(["none", "multiple"])( + `${scenario.name} blocks %s workspace matches before business HTTP`, + async (matches) => { + payloads.set( + workspaceApi, + matches === "none" + ? [{ workspaceId: "fixture-unrelated", tenantId: "7654321" }] + : [ + { workspaceId: "fixture-workspace", tenantId: "7654321" }, + { workspaceId: "fixture-workspace", tenantId: "7654322" }, + ], + ); + const result = await invoke(scenario, { format: "json", workspaceId: "fixture-workspace" }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(calls.map((call) => call.api)).toEqual([workspaceApi]); + expect(calls[0]!.cornerstone.switchAgent).toBe(111); + expect(diagnostics(result.stderr)).toHaveLength(1); + expect(diagnostics(result.stderr)[0]!.error.message).toContain("Cannot uniquely resolve"); + }, + ); + + test(`${scenario.name} dry-run never resolves workspace or credentials`, async () => { + const result = await invoke(scenario, { + format: "json", + dryRun: true, + workspaceId: "fixture-workspace", + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(calls).toEqual([]); + const preview = JSON.parse(result.stdout); + if (scenario.command === datasetVersion) { + expect(preview).toMatchObject({ + action: "dataset.version", + api: scenario.requests[0]!.api, + data: scenario.requests[0]!.data, + }); + if (scenario.flags.datasetGroupId) expect(preview).not.toHaveProperty("status_query"); + } + expect(preview).toMatchObject({ + consoleRegion: "cn-beijing", + consoleSite: "domestic", + workspaceIdentity: { + api: workspaceApi, + workspaceId: "fixture-workspace", + switchAgentFrom: "tenantId", + }, + }); + expect(preview).not.toHaveProperty("consoleSwitchAgent"); + expect(result.stderr).toBe(""); + }); +} + +test("deployment empty second page retains records/items and all unknown falsy metadata", async () => { + const response = { ...instancesPage, records: [], items: 0, pageCount: 0 }; + payloads.set(instancesApi, response); + const result = await invoke(scenarios[3]!, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(response); + expectGatewayCalls(scenarios[3]!); +}); + +test("dataset groups defaults send no name or type filters and fetch only one page", async () => { + const response = { ...groupsPage, page: 1, itemsPerPage: 10, pageCount: 41 }; + payloads.set(groupsApi, response); + const scenario: Scenario = { + ...groupsScenario, + flags: {}, + requests: [ + { + api: groupsApi, + data: { + input: { + datasetGroupPageNo: 1, + datasetGroupPageSize: 10, + datasetPageSize: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }, + }, + ], + want: response, + }; + const result = await invoke(scenario); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(response); + expectGatewayCalls(scenario); +}); + +test.each([ + { + name: "empty group page", + response: { ...groupsPage, records: [], items: 0, pageCount: 0 }, + }, + { + name: "group with no version preview", + response: { + ...groupsPage, + records: [{ ...groupsPage.records[0]!, datasets: [] }], + }, + }, +])("dataset groups accepts $name without discarding metadata", async ({ response }) => { + payloads.set(groupsApi, response); + const result = await invoke(groupsScenario, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(response); + expect(result.stderr).toBe(""); + expectGatewayCalls(groupsScenario); +}); + +test.each(["array", "object"])( + "dataset version group query preserves Console IDs, optional file IDs and unknown fields (%s)", + async (shape) => { + const withoutGroup = { datasetId: "fixture-version-without-group", ...retained }; + const records = [otherVersion, version, withoutGroup]; + const response = + shape === "array" ? records : { result: records, items: 3, next: null, ...retained }; + payloads.set(versionsApi, response); + const result = await invoke(versionsScenario, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + const output = JSON.parse(result.stdout); + expect(output).toEqual(shape === "array" ? { result: records } : response); + expect(output.result[0]).toMatchObject({ + datasetId: "fixture-other-version", + datasetGroupId: "fixture-group", + fileId: "fixture-file", + }); + expect(output.result[1]).toMatchObject({ + datasetId: "fixture-version", + datasetGroupId: "fixture-group", + }); + expect(output.result[1]).not.toHaveProperty("fileId"); + expect(output.result[2]).not.toHaveProperty("fileId"); + expect(output.result[2]).not.toHaveProperty("datasetGroupId"); + expect(result.stderr).toBe(""); + expectGatewayCalls(versionsScenario); + }, +); + +test.each(["array", "object"])( + "dataset version group query accepts an empty %s list without filling missing fields", + async (shape) => { + const response = shape === "array" ? [] : { result: [], items: 0, ...retained }; + payloads.set(versionsApi, response); + const result = await invoke(versionsScenario); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(shape === "array" ? { result: [] } : response); + expect(result.stderr).toBe(""); + expectGatewayCalls(versionsScenario); + }, +); + +test.each(["array", "object"])( + "dataset version group query rejects a wrong group anywhere in the %s list without partial output", + async (shape) => { + const records = [version, { ...otherVersion, datasetGroupId: "fixture-wrong-group" }]; + payloads.set(versionsApi, shape === "array" ? records : { result: records, ...retained }); + const result = await invoke(versionsScenario); + expect(result.exitCode).toBe(1); + expect(result.thrown).toBeInstanceOf(BailianError); + expect(result.stdout).toBe(""); + expect(result.stdoutAtError).toBe(""); + const errors = diagnostics(result.stderr); + expect(errors).toHaveLength(1); + expect(errors[0]!.error.message).toContain("another dataset group"); + expectGatewayCalls(versionsScenario); + }, +); + +test.each([ + { name: "missing result array", response: { items: 0, ...retained } }, + { name: "non-array result", response: { result: {}, ...retained } }, + { name: "null array entry", response: [version, null] }, + { name: "primitive result entry", response: { result: [version, 0], ...retained } }, + { name: "nested array entry", response: [version, []] }, + { + name: "missing version ID", + response: { result: [version, { datasetGroupId: "fixture-group" }], ...retained }, + }, + { name: "empty version ID", response: [version, { ...otherVersion, datasetId: "" }] }, + { + name: "whitespace version ID", + response: { result: [version, { ...otherVersion, datasetId: " \t " }], ...retained }, + }, + { name: "non-string version ID", response: [version, { ...otherVersion, datasetId: 123 }] }, +])("dataset version group query rejects $name without partial output", async ({ response }) => { + payloads.set(versionsApi, response); + const result = await invoke(versionsScenario); + expect(result.exitCode).toBe(1); + expect(result.thrown).toBeInstanceOf(BailianError); + expect(result.stdout).toBe(""); + expect(result.stdoutAtError).toBe(""); + const errors = diagnostics(result.stderr); + expect(errors).toHaveLength(1); + expect(errors[0]!.error.message).toContain("Console dataset version"); + expectGatewayCalls(versionsScenario); +}); + +test("dataset include-status retains exact matching for the legacy result object", async () => { + payloads.set(versionsApi, { result: [otherVersion, version], items: 2 }); + const result = await invoke(scenarios[1]!); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(scenarios[1]!.want); + expectGatewayCalls(scenarios[1]!); +}); + +test("dataset include-status cannot fall back to result[0] when no ID matches", async () => { + payloads.set(versionsApi, { result: [otherVersion], items: 1 }); + const result = await invoke(scenarios[1]!); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + dataset, + version: null, + status_query: { + state: "unavailable", + reason: "no_exact_match_in_response", + release_status: "missing", + import_status: "missing", + }, + }); + expectGatewayCalls(scenarios[1]!); +}); + +test.each([undefined, "", " \t "])( + "dataset group %j makes status explicitly unavailable with no second API", + async (datasetGroupId) => { + const metadata = { ...dataset, datasetGroupId }; + payloads.set(datasetApi, metadata); + const result = await invoke(scenarios[1]!); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + dataset: JSON.parse(JSON.stringify(metadata)), + version: null, + status_query: { state: "unavailable", reason: "missing_dataset_group_id" }, + }); + expectGatewayCalls(scenarios[0]!); + }, +); + +test.each([ + { releaseStatus: 3, taskStatus: 10 }, + { releaseStatus: 9876, taskStatus: 8765 }, +])("failed or unknown resource states stay raw and do not fail the query: %j", async (state) => { + const returnedVersion = { + ...version, + releaseStatus: state.releaseStatus, + datasetTaskDTO: { + ...version.datasetTaskDTO, + taskStatus: state.taskStatus, + errorMsg: "original import failure", + }, + errorMsg: "original release failure", + }; + payloads.set(versionsApi, { result: [returnedVersion] }); + const result = await invoke(scenarios[1]!, { format: "json", quiet: true }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.thrown).toBeUndefined(); + expect(JSON.parse(result.stdout)).toEqual({ + dataset, + version: returnedVersion, + status_query: matchedStatus, + }); + expect(result.stderr).toBe(""); + expectGatewayCalls(scenarios[1]!); +}); + +test.each(["json", "text"] as const)( + "second request exception preserves metadata before propagating the same error (%s)", + async (format) => { + const originalError = new Error("original fixture second-request failure"); + transportFailure = { api: versionsApi, error: originalError }; + const result = await invoke(scenarios[1]!, { + format, + quiet: true, + workspaceId: "fixture-workspace", + }); + expect(result.exitCode).toBe(1); + expect(result.thrown).toBe(originalError); + expect(result.stdoutAtError).toBe(result.stdout); + expect(JSON.parse(result.stdoutAtError!)).toEqual({ + dataset, + version: null, + status_query: { state: "failed" }, + }); + if (format === "json") { + expect(diagnostics(result.stderr)).toHaveLength(1); + expect(diagnostics(result.stderr)[0]!.error.message).toBe(originalError.message); + } else expect(result.stderr).toContain(originalError.message); + expectGatewayCalls(scenarios[1]!, true); + }, +); + +test("dataset NotFound from the observed DataV2 error shape is not replaced by metadata validation", async () => { + rawResponses.set(datasetApi, { + successResponse: true, + requestId: "fixture-not-found-request", + data: { + success: true, + DataV2: { + ret: ["SUCCESS::ok"], + data: { + codeEnum: "Train.NotFound.Data", + code: "10130404", + success: false, + appName: "fixture-app", + __statusCode__: 404, + message: "找不到相关数据", + }, + }, + }, + }); + const result = await invoke(scenarios[0]!, { format: "json", workspaceId: "fixture-workspace" }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(diagnostics(result.stderr)[0]!.error.message).toContain("找不到相关数据"); + expect(result.stderr).not.toContain("no usable metadata"); + expect((result.thrown as BailianError).api).toEqual({ + httpStatus: 200, + apiCode: "10130404", + requestId: "fixture-not-found-request", + }); + expectGatewayCalls(scenarios[0]!, true); +}); + +for (const scenario of scenarios) { + test(`${scenario.name} does not turn a nested service rejection into an empty success`, async () => { + const rejectedApi = scenario.requests.at(-1)!.api; + rawResponses.set(rejectedApi, { + data: { + requestId: "fixture-rejected-request", + DataV2: { + data: { + success: false, + errorCode: "Fixture.OriginalRejected", + errorMsg: "原始服务错误 / original service rejection", + data: null, + }, + }, + }, + }); + const result = await invoke(scenario, { format: "json", quiet: true }); + expect(result.exitCode).toBe(1); + expect(result.thrown).toBeInstanceOf(BailianError); + expect((result.thrown as BailianError).api).toEqual({ + httpStatus: 200, + apiCode: "Fixture.OriginalRejected", + requestId: "fixture-rejected-request", + }); + const errors = diagnostics(result.stderr); + expect(errors).toHaveLength(1); + expect(errors[0]!.error.message).toContain("原始服务错误 / original service rejection"); + if (scenario.flags.includeStatus) { + expect(JSON.parse(result.stdout)).toEqual({ + dataset, + version: null, + status_query: { state: "failed" }, + }); + } else expect(result.stdout).toBe(""); + expectGatewayCalls(scenario); + }); +} diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index 7a7e0bdcb..7ffbe5bc6 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -183,9 +183,27 @@ export async function callConsoleGateway( { rawResponse }, ); } - throw new BailianError(`Console gateway error: ${errorCode}`, ExitCode.GENERAL, undefined, { - rawResponse, - }); + const serviceMessage = + typeof innerData.errorMsg === "string" && innerData.errorMsg + ? innerData.errorMsg + : typeof innerData.message === "string" && innerData.message + ? innerData.message + : errorCode; + const requestId = [ + json.requestId, + json.request_id, + innerData.requestId, + innerData.request_id, + ].find((value): value is string => typeof value === "string" && value.length > 0); + throw new BailianError( + `${serviceMessage} (HTTP ${res.status}; code=${errorCode})`, + ExitCode.GENERAL, + undefined, + { + api: { httpStatus: res.status, apiCode: errorCode, requestId }, + rawResponse, + }, + ); } return json; diff --git a/packages/core/src/console/index.ts b/packages/core/src/console/index.ts index 7f4932655..07d732371 100644 --- a/packages/core/src/console/index.ts +++ b/packages/core/src/console/index.ts @@ -1,3 +1,4 @@ +export { unwrapConsoleResponse } from "./response.ts"; export type { ConsoleCall, ConsoleGatewayRequest, diff --git a/packages/core/src/console/response.ts b/packages/core/src/console/response.ts new file mode 100644 index 000000000..be4a006f0 --- /dev/null +++ b/packages/core/src/console/response.ts @@ -0,0 +1,130 @@ +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const ENVELOPE_KEYS = new Set([ + "data", + "DataV2", + "success", + "successResponse", + "failed", + "code", + "errorCode", + "errorMsg", + "message", + "requestId", + "request_id", + "traceId", + // Transport metadata belongs to the envelope, not to the business payload. + "httpStatusCode", + "httpStatus", + "api", + "ret", +]); + +function isEnvelope(value: Record, protocolLayer = false): boolean { + return ( + // DataV2 and its result are known protocol positions. Their status markers + // remain authoritative when the gateway adds metadata such as appName. + (protocolLayer && + (typeof value.success === "boolean" || + typeof value.failed === "boolean" || + Array.isArray(value.ret))) || + Object.hasOwn(value, "DataV2") || + typeof value.successResponse === "boolean" || + (Object.keys(value).every((key) => ENVELOPE_KEYS.has(key)) && + (Object.hasOwn(value, "data") || + Object.hasOwn(value, "errorCode") || + Array.isArray(value.ret) || + typeof value.success === "boolean" || + typeof value.failed === "boolean")) + ); +} + +function stringField(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number") return String(value); + return undefined; +} + +/** + * Read the known Console protocol layers, not arbitrary business `data` fields. + * Unlike the legacy unwrapResponse, failures are checked before discarding the + * envelope and a successful null payload remains null. Used by the typed reads. + */ +export function unwrapConsoleResponse(raw: unknown): unknown { + const layers: Record[] = []; + let payload = raw; + let protocolLayer: "dataV2" | "result" | undefined; + while (isRecord(payload) && isEnvelope(payload, protocolLayer !== undefined)) { + layers.push(payload); + if (layers.length > 6) { + throw new BailianError( + "Invalid Console response envelope. / Console 响应信封无效。", + ExitCode.GENERAL, + ); + } + if (Object.hasOwn(payload, "DataV2")) { + payload = payload.DataV2; + protocolLayer = "dataV2"; + } else if (Object.hasOwn(payload, "data")) { + protocolLayer = protocolLayer === "dataV2" ? "result" : undefined; + payload = payload.data; + } else break; + } + + const failedRet = layers.flatMap((layer) => + Array.isArray(layer.ret) + ? layer.ret.filter( + (entry): entry is string => typeof entry === "string" && !entry.startsWith("SUCCESS::"), + ) + : [], + ); + const failedLayers = layers.filter((layer) => { + const errorCode = stringField(layer.errorCode); + return ( + layer.success === false || + layer.successResponse === false || + layer.failed === true || + (layer.success !== true && + layer.successResponse !== true && + errorCode !== undefined && + errorCode !== "0" && + errorCode !== "200") + ); + }); + if (failedLayers.length > 0 || failedRet.length > 0) { + const innerFirst = [...failedLayers].reverse(); + const message = + innerFirst + .map((layer) => stringField(layer.errorMsg) ?? stringField(layer.message)) + .find(Boolean) ?? + (failedRet.join("; ") || + "Console service failed without a message. / Console 服务失败,未提供错误消息。"); + const apiCode = innerFirst + .map( + (layer) => + stringField(layer.errorCode) ?? + (layer.success === false || layer.failed === true ? stringField(layer.code) : undefined), + ) + .find(Boolean); + const requestId = layers + .map((layer) => stringField(layer.requestId) ?? stringField(layer.request_id)) + .find(Boolean); + const details = [ + "HTTP 200", + apiCode ? `code=${apiCode}` : "", + requestId ? `request_id=${requestId}` : "", + ] + .filter(Boolean) + .join("; "); + throw new BailianError(`${message} (${details})`, ExitCode.GENERAL, undefined, { + api: { httpStatus: 200, apiCode, requestId }, + rawResponse: JSON.stringify(raw), + }); + } + return payload; +} diff --git a/packages/core/src/dataset/console.ts b/packages/core/src/dataset/console.ts new file mode 100644 index 000000000..7aa25133a --- /dev/null +++ b/packages/core/src/dataset/console.ts @@ -0,0 +1,204 @@ +import type { Client } from "../client/client.ts"; +import { unwrapConsoleResponse } from "../console/response.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + +export const CONSOLE_DATASET_BY_ID_API = + "zeldaEasy.broadscope-platform.dataset.queryByDatasetId.m_efm"; +export const CONSOLE_DATASET_VERSIONS_API = "zeldaEasy.broadscope-platform.dataset.query.m_efm"; +export const CONSOLE_DATASET_GROUPS_API = "zeldaEasy.broadscope-platform.datasetGroup.query.m_efm"; + +export interface ListConsoleDatasetGroupsParams { + page?: number; + pageSize?: number; + name?: string; +} + +export interface ConsoleDatasetGroupsPage extends Record { + records: Record[]; + page: number; + itemsPerPage: number; + items: number; +} + +export interface ConsoleDatasetVersionsResult extends Record { + result: Record[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonblankString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isIntegerAtLeast(value: unknown, minimum: number): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum; +} + +export function buildConsoleDatasetGroupsRequest(params: ListConsoleDatasetGroupsParams = {}) { + return { + input: { + datasetGroupPageNo: params.page ?? 1, + datasetGroupPageSize: params.pageSize ?? 10, + ...(params.name !== undefined ? { name: params.name } : {}), + datasetPageSize: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }; +} + +/** One page of Console groups with one embedded version preview per group. */ +export async function listConsoleDatasetGroups( + client: Pick, + params: ListConsoleDatasetGroupsParams = {}, +): Promise { + const raw = await client.console( + CONSOLE_DATASET_GROUPS_API, + buildConsoleDatasetGroupsRequest(params), + ); + const response = unwrapConsoleResponse(raw); + if ( + !isRecord(response) || + !Array.isArray(response.records) || + !response.records.every((group) => isRecord(group) && isNonblankString(group.datasetGroupId)) || + !isIntegerAtLeast(response.page, 1) || + !isIntegerAtLeast(response.itemsPerPage, 1) || + !isIntegerAtLeast(response.items, 0) || + (response.pageCount !== undefined && !isIntegerAtLeast(response.pageCount, 0)) + ) { + throw new BailianError( + "Invalid Console dataset groups page: expected records with datasetGroupId and valid page/itemsPerPage/items. / Console 数据集组分页响应无效:需要包含 datasetGroupId 的 records 以及有效的 page/itemsPerPage/items。", + ExitCode.GENERAL, + ); + } + return response as ConsoleDatasetGroupsPage; +} + +export function buildConsoleDatasetVersionsRequest(datasetGroupId: string) { + return { input: { datasetGroupId, datasetTaskTypeList: [0], datasetTaskPageSize: 1 } }; +} + +function parseConsoleDatasetVersions(response: unknown): ConsoleDatasetVersionsResult { + // The live Console API returns an array; frontend consumers also support result. + const normalized = Array.isArray(response) ? { result: response } : response; + if ( + !isRecord(normalized) || + !Array.isArray(normalized.result) || + !normalized.result.every(isRecord) + ) { + throw new BailianError( + "Console dataset version response must be an object array or contain a result array of objects. / Console 数据集版本响应必须为对象数组或包含对象数组 result。", + ExitCode.GENERAL, + ); + } + return normalized as ConsoleDatasetVersionsResult; +} + +/** Query one group; expose version IDs without inventing pagination/completeness. */ +export async function listConsoleDatasetVersions( + client: Pick, + datasetGroupId: string, +): Promise { + const raw = await client.console( + CONSOLE_DATASET_VERSIONS_API, + buildConsoleDatasetVersionsRequest(datasetGroupId), + ); + const response = parseConsoleDatasetVersions(unwrapConsoleResponse(raw)); + if ( + !response.result.every( + (version) => + isNonblankString(version.datasetId) && + (version.datasetGroupId === undefined || version.datasetGroupId === datasetGroupId), + ) + ) { + throw new BailianError( + "Console dataset versions have missing version IDs or belong to another dataset group. / Console 数据集版本缺少版本 ID 或属于其他数据集组。", + ExitCode.GENERAL, + ); + } + return response; +} + +/** datasetId identifies a Console version, not an API-domain file. */ +export async function getConsoleDatasetById( + client: Pick, + datasetId: string, +): Promise> { + const raw = await client.console(CONSOLE_DATASET_BY_ID_API, { input: { datasetId } }); + const dataset = unwrapConsoleResponse(raw); + if ( + !isRecord(dataset) || + ![dataset.datasetId, dataset.datasetGroupId, dataset.datasetName].some( + (value) => typeof value === "string" && value.trim(), + ) + ) { + throw new BailianError( + "Console dataset query returned no usable metadata. / Console 数据集查询未返回可用元信息。", + ExitCode.GENERAL, + ); + } + if (dataset.datasetId !== undefined && dataset.datasetId !== datasetId) { + throw new BailianError( + "Console dataset response does not match the requested version ID. / Console 数据集响应与请求的版本 ID 不匹配。", + ExitCode.GENERAL, + ); + } + return dataset; +} + +export function buildConsoleDatasetVersionRequest(datasetId: string, datasetGroupId: string) { + return { + input: { + datasetId, + datasetGroupId, + page: 1, + itemsPerPage: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }; +} + +/** Exact match only: never fall back to the first or a different version. */ +export async function queryConsoleDatasetVersion( + client: Pick, + datasetId: string, + datasetGroupId: string, +): Promise | null> { + const raw = await client.console( + CONSOLE_DATASET_VERSIONS_API, + buildConsoleDatasetVersionRequest(datasetId, datasetGroupId), + ); + const response = parseConsoleDatasetVersions(unwrapConsoleResponse(raw)); + const matches = response.result.filter((version) => version.datasetId === datasetId); + if (matches.length > 1) { + throw new BailianError( + "Console dataset query returned multiple matches for one version ID. / Console 数据集查询为同一版本 ID 返回了多个匹配项。", + ExitCode.GENERAL, + ); + } + const version = matches[0] ?? null; + if ( + version && + version.datasetGroupId !== undefined && + version.datasetGroupId !== datasetGroupId + ) { + throw new BailianError( + "Console dataset version belongs to a different dataset group. / Console 数据集版本所属组与查询目标不匹配。", + ExitCode.GENERAL, + ); + } + return version; +} + +/** A presence report, not a release, validation, or training-readiness decision. */ +export function consoleDatasetStatusAvailability(version: Record | null) { + const task = version && isRecord(version.datasetTaskDTO) ? version.datasetTaskDTO : undefined; + return { + release_status: version?.releaseStatus != null ? "returned" : "missing", + import_status: task?.taskStatus != null ? "returned" : "missing", + }; +} diff --git a/packages/core/src/dataset/index.ts b/packages/core/src/dataset/index.ts index ea214c9fb..14c76b70d 100644 --- a/packages/core/src/dataset/index.ts +++ b/packages/core/src/dataset/index.ts @@ -1,5 +1,6 @@ export * from "./types.ts"; export * from "./api.ts"; +export * from "./console.ts"; export { detectModality } from "./inspect.ts"; export { validateDataset, diff --git a/packages/core/src/deploy/index.ts b/packages/core/src/deploy/index.ts index 0df788797..63486242c 100644 --- a/packages/core/src/deploy/index.ts +++ b/packages/core/src/deploy/index.ts @@ -4,6 +4,7 @@ export * from "./constants.ts"; export * from "./plans.ts"; export * from "./lifecycle.ts"; export * from "./reservation.ts"; +export * from "./instances.ts"; export { deploymentCapacityInstancesPath, deploymentCapacityInstancePath, diff --git a/packages/core/src/deploy/instances.ts b/packages/core/src/deploy/instances.ts new file mode 100644 index 000000000..050cf292a --- /dev/null +++ b/packages/core/src/deploy/instances.ts @@ -0,0 +1,77 @@ +import type { Client } from "../client/client.ts"; +import { unwrapConsoleResponse } from "../console/response.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + +export const CONSOLE_DEPLOY_INSTANCES_API = + "zeldaEasy.broadscope-platform.modelInstance.listIndependentDeployedModel.m_efm"; + +export interface ListConsoleDeploymentInstancesParams { + page?: number; + pageSize?: number; + keyword?: string; + modelServiceId?: string; +} + +/** One raw page of independent deployment services, not capacity instances or replicas. */ +export interface ConsoleDeploymentInstancesPage { + records: Record[]; + /** Total number of matching services, not the records array. */ + items: number; + itemsPerPage: number; + page: number; + pageCount: number; + [key: string]: unknown; +} + +export function buildConsoleDeploymentInstancesRequest( + params: ListConsoleDeploymentInstancesParams = {}, +) { + return { + input: { + page: params.page ?? 1, + itemsPerPage: params.pageSize ?? 10, + ...(params.keyword !== undefined ? { searchNameKeyword: params.keyword } : {}), + ...(params.modelServiceId !== undefined ? { modelServiceId: params.modelServiceId } : {}), + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSafeIntegerAtLeast(value: unknown, minimum: number): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum; +} + +function isInstancesPage(value: unknown): value is ConsoleDeploymentInstancesPage { + return ( + isRecord(value) && + Array.isArray(value.records) && + value.records.every(isRecord) && + isSafeIntegerAtLeast(value.items, 0) && + isSafeIntegerAtLeast(value.itemsPerPage, 1) && + isSafeIntegerAtLeast(value.page, 1) && + isSafeIntegerAtLeast(value.pageCount, 0) + ); +} + +/** Fetch only the requested page and preserve every business-response field verbatim. */ +export async function listConsoleDeploymentInstances( + client: Pick, + params: ListConsoleDeploymentInstancesParams = {}, +): Promise { + const raw = await client.console( + CONSOLE_DEPLOY_INSTANCES_API, + buildConsoleDeploymentInstancesRequest(params), + ); + const response = unwrapConsoleResponse(raw); + if (!isInstancesPage(response)) { + throw new BailianError( + "Invalid Console deployment instances page: records must be an array of objects; items and pageCount must be non-negative safe integers; page and itemsPerPage must be positive safe integers. / Console 部署实例分页响应无效:records 必须为对象数组;items 和 pageCount 必须为非负安全整数;page 和 itemsPerPage 必须为正安全整数。", + ExitCode.GENERAL, + ); + } + return response; +} diff --git a/packages/core/src/files/index.ts b/packages/core/src/files/index.ts index 4e582ec43..89c55520c 100644 --- a/packages/core/src/files/index.ts +++ b/packages/core/src/files/index.ts @@ -6,3 +6,4 @@ export { audioFileToDataUri, redactDataUri, } from "./upload.ts"; +export { CONSOLE_FILE_URL_API, getConsoleFileUrl } from "./url.ts"; diff --git a/packages/core/src/files/url.ts b/packages/core/src/files/url.ts new file mode 100644 index 000000000..df9fe6c7e --- /dev/null +++ b/packages/core/src/files/url.ts @@ -0,0 +1,37 @@ +import type { Client } from "../client/client.ts"; +import { unwrapConsoleResponse } from "../console/response.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + +export const CONSOLE_FILE_URL_API = "zeldaEasy.bmp.ossRpcService.download"; + +function isHttpUrl(value: unknown): value is string { + if (typeof value !== "string" || !/^https?:\/\//i.test(value)) return false; + try { + const parsedUrl = new URL(value); + return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:"; + } catch { + return false; + } +} + +export async function getConsoleFileUrl( + client: Pick, + fileKey: string, +): Promise & { url: string }> { + const raw = await client.console(CONSOLE_FILE_URL_API, { fileKey }); + const result = unwrapConsoleResponse(raw); + if ( + result === null || + typeof result !== "object" || + Array.isArray(result) || + !("url" in result) || + !isHttpUrl(result.url) + ) { + throw new BailianError( + "Console file URL response must be an object with a valid HTTP(S) URL. / Console 文件地址响应必须是包含有效 HTTP(S) URL 的对象。", + ExitCode.GENERAL, + ); + } + return result as Record & { url: string }; +} diff --git a/packages/core/tests/console-file-url.test.ts b/packages/core/tests/console-file-url.test.ts new file mode 100644 index 000000000..ea6908c3a --- /dev/null +++ b/packages/core/tests/console-file-url.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import type { Client } from "../src/client/client.ts"; +import { BailianError } from "../src/errors/base.ts"; +import { ExitCode } from "../src/errors/codes.ts"; +import { CONSOLE_FILE_URL_API, getConsoleFileUrl } from "../src/files/url.ts"; + +// Pure module calls: no CLI subprocess, credential resolution, HOME access, or file fixtures. +function fakeClient(response: unknown) { + const consoleCall = vi.fn().mockResolvedValue(response); + const client: Pick = { + console: async (api: string, data: Record) => + (await consoleCall(api, data)) as Result, + }; + return { client, consoleCall }; +} + +function gatewayResponse(payload: unknown) { + return { + code: "200", + data: { + success: true, + DataV2: { + ret: ["SUCCESS::接口调用成功"], + data: { code: "200", success: true, data: payload }, + }, + }, + }; +} + +const signedUrl = + "https://files.example.test/report%20name.pdf?Signature=fake%2Bsignature&Expires=0"; + +const responseShapes = [ + { name: "bare object", wrap: (payload: unknown) => payload }, + { name: "data envelope", wrap: (payload: unknown) => ({ code: "200", data: payload }) }, + { + name: "double data envelope", + wrap: (payload: unknown) => ({ data: { success: true, data: payload } }), + }, + { name: "DataV2 envelope", wrap: gatewayResponse }, +]; + +describe("getConsoleFileUrl", () => { + test("calls the exact API with only the top-level fileKey and preserves its characters", async () => { + const fileKey = " folder/业务文件 ?#%&+=.pdf "; + const { client, consoleCall } = fakeClient(gatewayResponse({ url: signedUrl })); + + await getConsoleFileUrl(client, fileKey); + + expect(CONSOLE_FILE_URL_API).toBe("zeldaEasy.bmp.ossRpcService.download"); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith("zeldaEasy.bmp.ossRpcService.download", { + fileKey, + }); + expect(consoleCall.mock.calls[0]?.[1]).toStrictEqual({ fileKey }); + }); + + test.each(responseShapes)("unwraps $name without dropping business fields", async ({ wrap }) => { + const payload = { + url: signedUrl, + fileName: "report.pdf", + enabled: false, + size: 0, + expiresAt: null, + data: null, + extra: { available: false, count: 0, value: null }, + }; + const { client } = fakeClient(wrap(payload)); + + const result = await getConsoleFileUrl(client, "example-file-key"); + + expect(result).toStrictEqual(payload); + expect(result.url).toBe(signedUrl); + }); + + test.each(["http://files.example.test/file", "https://files.example.test/file"])( + "accepts an absolute HTTP(S) URL: %s", + async (url) => { + const { client } = fakeClient(gatewayResponse({ url })); + await expect(getConsoleFileUrl(client, "example-file-key")).resolves.toStrictEqual({ url }); + }, + ); + + test.each([ + { name: "null result", payload: null }, + { name: "false result", payload: false }, + { name: "zero result", payload: 0 }, + { name: "string result", payload: signedUrl }, + { name: "array result", payload: [{ url: signedUrl }] }, + { name: "missing URL", payload: { signature: "private-response-marker" } }, + { name: "null URL", payload: { url: null } }, + { name: "non-string URL", payload: { url: 123 } }, + { name: "empty URL", payload: { url: "" } }, + { name: "whitespace URL", payload: { url: " \t\n" } }, + { name: "relative URL", payload: { url: "/file?Signature=private-response-marker" } }, + { name: "OSS URI", payload: { url: "oss://bucket/private-response-marker" } }, + { name: "FTP URL", payload: { url: "ftp://files.example.test/private-response-marker" } }, + { name: "missing host", payload: { url: "https://" } }, + { name: "malformed host", payload: { url: "https://bad host/private-response-marker" } }, + { name: "malformed scheme", payload: { url: "https:private-response-marker" } }, + ])("rejects $name with a bilingual GENERAL error and no raw response", async ({ payload }) => { + const { client } = fakeClient(gatewayResponse(payload)); + const error = await getConsoleFileUrl(client, "example-file-key").catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(BailianError); + if (!(error instanceof BailianError)) throw new Error("Expected a BailianError"); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toContain("valid HTTP(S) URL"); + expect(error.message).toContain("有效 HTTP(S) URL"); + expect(error.message).not.toContain(signedUrl); + expect(JSON.stringify(error.toJSON())).not.toContain("private-response-marker"); + expect(error.rawResponse).toBeUndefined(); + expect(error.cause).toBeUndefined(); + }); + + test.each([ + { + name: "HTTP failure", + httpStatus: 403, + apiCode: "FileDenied", + message: "original HTTP error", + }, + { + name: "business failure", + httpStatus: 200, + apiCode: "FileUnavailable", + message: "原始业务错误", + }, + ])("preserves the client's $name unchanged", async ({ httpStatus, apiCode, message }) => { + const serviceError = new BailianError( + `HTTP ${httpStatus}; code=${apiCode}; request_id=request-test - ${message}`, + ExitCode.GENERAL, + undefined, + { api: { httpStatus, apiCode, requestId: "request-test" } }, + ); + const { client, consoleCall } = fakeClient(undefined); + consoleCall.mockRejectedValueOnce(serviceError); + + await expect(getConsoleFileUrl(client, "example-file-key")).rejects.toBe(serviceError); + expect(consoleCall).toHaveBeenCalledTimes(1); + }); + + test("surfaces an envelope business error before validating the missing URL", async () => { + const serviceMessage = "原始服务错误 original service error"; + const { client } = fakeClient({ + code: "200", + data: { + success: true, + DataV2: { + ret: ["SUCCESS::接口调用成功"], + data: { success: false, code: "FileDenied", message: serviceMessage, data: null }, + }, + }, + }); + const error = await getConsoleFileUrl(client, "example-file-key").catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(BailianError); + if (!(error instanceof BailianError)) throw new Error("Expected a BailianError"); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toContain(serviceMessage); + expect(error.message).toContain("FileDenied"); + expect(error.message).not.toContain("valid HTTP(S) URL"); + }); + + test("rejects a failed protocol envelope even if its payload contains a valid URL", async () => { + const serviceMessage = "FAIL_BIZ::original protocol failure"; + const { client } = fakeClient({ + data: { + DataV2: { ret: [serviceMessage], data: { data: { url: signedUrl } } }, + }, + }); + const error = await getConsoleFileUrl(client, "example-file-key").catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(BailianError); + if (!(error instanceof BailianError)) throw new Error("Expected a BailianError"); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toContain(serviceMessage); + }); +}); diff --git a/packages/core/tests/console-response.test.ts b/packages/core/tests/console-response.test.ts new file mode 100644 index 000000000..785db4e82 --- /dev/null +++ b/packages/core/tests/console-response.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "vite-plus/test"; +import { unwrapConsoleResponse } from "../src/console/response.ts"; +import { BailianError } from "../src/errors/base.ts"; +import { ExitCode } from "../src/errors/codes.ts"; + +const envelope = (data: unknown) => ({ + code: "200", + successResponse: true, + requestId: "request-fixture", + data: { + success: true, + DataV2: { ret: ["SUCCESS::ok"], data: { success: true, failed: false, data } }, + }, +}); + +describe("typed Console response envelopes", () => { + test("a missing transport ret does not hide result failure metadata", () => { + expect(() => + unwrapConsoleResponse({ + DataV2: { data: { success: false, appName: "fixture-app", message: "original rejection" } }, + }), + ).toThrow("original rejection"); + }); + test("a repeated transport ret cannot extend protocol handling into dataset state", () => { + const dataset = { datasetId: "version-fixture", failed: true }; + const ret = ["SUCCESS::ok"]; + expect(unwrapConsoleResponse({ DataV2: { ret, data: { ret, data: dataset } } })).toBe(dataset); + }); + test("honors the observed DataV2 failure even when transport ret says SUCCESS", () => { + const response = { + successResponse: true, + requestId: "request-fixture", + data: { + success: true, + DataV2: { + ret: ["SUCCESS::ok"], + data: { + codeEnum: "Train.NotFound.Data", + code: "10130404", + success: false, + appName: "fixture-app", + __statusCode__: 404, + message: "找不到相关数据", + }, + }, + }, + }; + expect(() => unwrapConsoleResponse(response)).toThrow("找不到相关数据"); + try { + unwrapConsoleResponse(response); + } catch (error) { + expect(error).toMatchObject({ + exitCode: ExitCode.GENERAL, + api: { apiCode: "10130404", requestId: "request-fixture" }, + }); + } + }); + test("unknown DataV2 metadata does not block unwrapping or consume business data", () => { + const dataset = { + datasetId: "version-fixture", + success: false, + errorCode: "Import.Failed", + data: null, + }; + const response = { + data: { + DataV2: { + ret: ["SUCCESS::ok"], + extraTransportMetadata: "fixture", + data: { success: true, failed: false, appName: "fixture-app", data: dataset }, + }, + }, + }; + expect(unwrapConsoleResponse(response)).toBe(dataset); + }); + test("unwraps the observed gateway transport metadata but preserves business pagination", () => { + const page = { + data: [{ workspaceId: "workspace-fixture", tenantId: "7654321" }], + maxResults: 100, + totalCount: 1, + }; + // Sanitized shape from a live workspace list response; all identifiers are fixtures. + const response = { + code: "200", + httpStatusCode: "200", + requestId: "outer-request-fixture", + successResponse: true, + data: { + success: true, + httpStatus: 200, + errorCode: "", + errorMsg: "", + api: "fixture.workspace.list", + DataV2: { + ret: ["SUCCESS::ok"], + data: { + code: "200", + data: page, + success: true, + failed: false, + requestId: "inner-request-fixture", + httpStatusCode: 200, + }, + }, + }, + }; + expect(unwrapConsoleResponse(response)).toBe(page); + }); + test("checks a failure containing HTTP transport metadata before unwrapping", () => { + const response = { + data: { + DataV2: { + ret: ["SUCCESS::ok"], + data: { + code: "ReadDenied", + data: null, + success: false, + failed: true, + message: "original fixture rejection", + requestId: "inner-request-fixture", + httpStatusCode: 200, + }, + }, + }, + }; + expect(() => unwrapConsoleResponse(response)).toThrow("original fixture rejection"); + }); + test("a business errorCode or data field does not make a dataset into another envelope", () => { + const dataset = { datasetId: "version-fixture", errorCode: null, data: null }; + expect(unwrapConsoleResponse({ success: true, data: dataset })).toBe(dataset); + const failedImport = { + datasetId: "version-fixture", + releaseStatus: 3, + errorCode: "Import.Failed", + data: null, + }; + expect(unwrapConsoleResponse(envelope(failedImport))).toBe(failedImport); + }); + test("successful inner diagnostics cannot replace the actual failed ret", () => { + const response = { + data: { + DataV2: { + ret: ["FAIL::denied"], + data: { success: true, message: "ok", errorCode: "0", data: null }, + }, + }, + }; + expect(() => unwrapConsoleResponse(response)).toThrow("FAIL::denied (HTTP 200)"); + }); + test.each([null, false, 0, "", [], { value: 0 }].map((payload) => ({ payload })))( + "preserves the exact payload $payload", + ({ payload }) => { + expect(unwrapConsoleResponse(envelope(payload))).toBe(payload); + }, + ); + test("does not descend into a business data field or task errors", () => { + const payload = { + records: [], + data: { success: false }, + datasetTaskDTO: { taskStatus: 10 }, + value: null, + }; + expect(unwrapConsoleResponse(envelope(payload))).toBe(payload); + expect(unwrapConsoleResponse(payload)).toBe(payload); + }); + test.each([ + { data: { DataV2: { data: { data: { url: "https://example.test/file" } } } } }, + { data: { success: true, data: { url: "https://example.test/file" } } }, + { success: true, data: { url: "https://example.test/file" } }, + { code: "200", data: { url: "https://example.test/file" } }, + ])("accepts supported minimal and legacy envelopes", (response) => { + expect(unwrapConsoleResponse(response)).toEqual({ url: "https://example.test/file" }); + }); + test.each([ + { success: false, errorCode: "Denied", errorMsg: "original denial" }, + { errorCode: "Denied", errorMsg: "original denial" }, + { + successResponse: false, + data: { errorCode: "Denied", errorMsg: "original denial", success: false }, + }, + { data: { success: false, errorCode: "Denied", message: "original denial" } }, + envelope({ success: false, code: "Denied", message: "original denial", data: null }), + ])("retains service error text and GENERAL classification", (response) => { + try { + unwrapConsoleResponse(response); + throw new Error("Expected service rejection"); + } catch (error) { + expect(error).toBeInstanceOf(BailianError); + expect(error).toMatchObject({ + exitCode: ExitCode.GENERAL, + message: expect.stringContaining("original denial"), + api: { httpStatus: 200, apiCode: "Denied" }, + }); + } + }); + test("mixed success/failure ret entries cannot become success", () => { + expect(() => + unwrapConsoleResponse({ + data: { DataV2: { ret: ["SUCCESS::ok", "FAIL::original denial"], data: { data: [] } } }, + }), + ).toThrow("FAIL::original denial"); + }); + test("preserves a failed request ID without walking arbitrary nested fields", () => { + const response = envelope({ + success: false, + errorCode: "Denied", + errorMsg: "original denial", + data: null, + }); + try { + unwrapConsoleResponse(response); + } catch (error) { + expect(error).toMatchObject({ + api: { requestId: "request-fixture" }, + rawResponse: JSON.stringify(response), + }); + return; + } + throw new Error("Expected service rejection"); + }); +}); diff --git a/packages/core/tests/dataset-console.test.ts b/packages/core/tests/dataset-console.test.ts new file mode 100644 index 000000000..5c21e6151 --- /dev/null +++ b/packages/core/tests/dataset-console.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import type { Client } from "../src/client/client.ts"; +import { + CONSOLE_DATASET_BY_ID_API, + CONSOLE_DATASET_VERSIONS_API, + CONSOLE_DATASET_GROUPS_API, + buildConsoleDatasetGroupsRequest, + listConsoleDatasetGroups, + buildConsoleDatasetVersionsRequest, + listConsoleDatasetVersions, + getConsoleDatasetById, + queryConsoleDatasetVersion, + consoleDatasetStatusAvailability, +} from "../src/dataset/console.ts"; + +function fixtureClient(payload: unknown) { + const consoleCall = vi + .fn() + .mockResolvedValue({ data: { DataV2: { data: { data: payload } } } }); + const client: Pick = { + console: async (api: string, data: Record) => + (await consoleCall(api, data)) as Result, + }; + return { client, consoleCall }; +} + +describe("Console dataset versions", () => { + test("live array payload also supports exact status lookup without taking another version", async () => { + const version = { + datasetId: "version-test", + datasetGroupId: "group-test", + releaseStatus: 0, + fileId: "file-test", + }; + const { client } = fixtureClient([{ datasetId: "other-version" }, version]); + expect(await queryConsoleDatasetVersion(client, "version-test", "group-test")).toBe(version); + expect(await queryConsoleDatasetVersion(client, "absent-version", "group-test")).toBeNull(); + }); + test("builds group requests without frontend-only type restrictions or reload counts", async () => { + expect(buildConsoleDatasetGroupsRequest()).toEqual({ + input: { + datasetGroupPageNo: 1, + datasetGroupPageSize: 10, + datasetPageSize: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }); + const page = { + records: [ + { + datasetGroupId: "group-test", + datasets: [{ datasetId: "version-test", fileId: "file-test" }], + }, + ], + page: 2, + itemsPerPage: 3, + items: 7, + pageCount: 3, + metadata: { enabled: false, value: null }, + }; + const { client, consoleCall } = fixtureClient(page); + expect( + await listConsoleDatasetGroups(client, { page: 2, pageSize: 3, name: "fixture name" }), + ).toBe(page); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith(CONSOLE_DATASET_GROUPS_API, { + input: { + datasetGroupPageNo: 2, + datasetGroupPageSize: 3, + name: "fixture name", + datasetPageSize: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }); + }); + test("empty group page remains a page, not an API failure", async () => { + const page = { records: [], page: 1, itemsPerPage: 10, items: 0, pageCount: 0 }; + const { client } = fixtureClient(page); + expect(await listConsoleDatasetGroups(client)).toBe(page); + }); + test.each([ + null, + {}, + { records: [] }, + { records: [{ name: "missing id" }], page: 1, itemsPerPage: 10, items: 1 }, + { records: [], page: 0, itemsPerPage: 10, items: 0 }, + { records: [], page: 1, itemsPerPage: 0, items: 0 }, + { records: [], page: 1, itemsPerPage: 10, items: "0" }, + { records: [], page: 1, itemsPerPage: 10, items: 0, pageCount: -1 }, + ])("rejects malformed group pages without fabricating IDs or empty success %j", async (page) => { + const { client } = fixtureClient(page); + await expect(listConsoleDatasetGroups(client)).rejects.toMatchObject({ exitCode: 1 }); + }); + test("normalizes real version arrays and retains version/file binding without coercion", async () => { + const versions = [ + { + datasetId: "0000123", + datasetGroupId: "group-test", + fileId: "file-test", + datasetVersion: 0, + releaseStatus: 3, + data: null, + enabled: false, + }, + { datasetId: "second-version", datasetGroupId: "group-test" }, + ]; + const { client, consoleCall } = fixtureClient(versions); + const result = await listConsoleDatasetVersions(client, "group-test"); + expect(result.result).toBe(versions); + expect(result.result[1]).not.toHaveProperty("fileId"); + expect(buildConsoleDatasetVersionsRequest("group-test")).toEqual({ + input: { datasetGroupId: "group-test", datasetTaskTypeList: [0], datasetTaskPageSize: 1 }, + }); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith( + CONSOLE_DATASET_VERSIONS_API, + buildConsoleDatasetVersionsRequest("group-test"), + ); + }); + test.each([ + { result: [], next: null, total: 0 }, + { result: [{ datasetId: "version-test", fileId: null }], future: [false, 0, null] }, + ])("preserves result-object metadata %j", async (payload) => { + const { client } = fixtureClient(payload); + expect(await listConsoleDatasetVersions(client, "group-test")).toBe(payload); + }); + test("empty array normalizes to an empty result", async () => { + const { client } = fixtureClient([]); + await expect(listConsoleDatasetVersions(client, "group-test")).resolves.toEqual({ result: [] }); + }); + test.each([ + null, + {}, + { result: null }, + { result: [null] }, + { result: [{ fileId: "not-a-version" }] }, + { result: [{ datasetId: 123 }] }, + { result: [{ datasetId: " " }] }, + { result: [{ datasetId: "version-test", datasetGroupId: "other-group" }] }, + ])("rejects unusable version lists or cross-group records %j", async (payload) => { + const { client } = fixtureClient(payload); + await expect(listConsoleDatasetVersions(client, "group-test")).rejects.toMatchObject({ + exitCode: 1, + }); + }); + test("list operations retain nested service errors rather than treating them as empty lists", async () => { + const { client } = fixtureClient({ + success: false, + code: "Denied", + message: "original rejection", + data: null, + }); + await expect(listConsoleDatasetGroups(client)).rejects.toThrow("original rejection"); + await expect(listConsoleDatasetVersions(client, "group-test")).rejects.toThrow( + "original rejection", + ); + }); + test("metadata uses the exact version ID and preserves unknown fields", async () => { + const datasetId = "000900719925474099312345"; + const payload = { datasetId, datasetGroupId: "group-test", data: null, extra: [false, 0] }; + const { client, consoleCall } = fixtureClient(payload); + expect(await getConsoleDatasetById(client, datasetId)).toBe(payload); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith(CONSOLE_DATASET_BY_ID_API, { + input: { datasetId }, + }); + }); + test.each( + [null, false, 0, [], {}, { taskId: "pending-task" }, { datasetId: "wrong-id" }].map( + (payload) => ({ payload }), + ), + )("rejects missing or mismatched metadata $payload", async ({ payload }) => { + const { client } = fixtureClient(payload); + await expect(getConsoleDatasetById(client, "version-test")).rejects.toMatchObject({ + exitCode: 1, + }); + }); + test("requests one import task for the exact version and keeps all returned state", async () => { + const version = { + datasetId: "version-test", + datasetGroupId: "group-test", + releaseStatus: 3, + fileId: "file-test", + datasetTaskDTO: { taskStatus: 23, detail: null }, + extra: [0, false], + }; + const { client, consoleCall } = fixtureClient({ result: [version] }); + expect(await queryConsoleDatasetVersion(client, "version-test", "group-test")).toBe(version); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith(CONSOLE_DATASET_VERSIONS_API, { + input: { + datasetId: "version-test", + datasetGroupId: "group-test", + page: 1, + itemsPerPage: 1, + datasetTaskTypeList: [0], + datasetTaskPageSize: 1, + }, + }); + }); + test.each( + [[], [{ datasetId: "other-version" }], [{ fileId: "version-test" }]].map((result) => ({ + result, + })), + )("never falls back to a nonmatching record $result", async ({ result }) => { + const { client } = fixtureClient({ result }); + expect(await queryConsoleDatasetVersion(client, "version-test", "group-test")).toBeNull(); + }); + test.each([ + null, + {}, + { result: null }, + { result: [null] }, + { result: [{ datasetId: "version-test" }, { datasetId: "version-test" }] }, + { result: [{ datasetId: "version-test", datasetGroupId: "wrong-group" }] }, + ])("rejects malformed, ambiguous or wrong-group responses %j", async (payload) => { + const { client } = fixtureClient(payload); + await expect( + queryConsoleDatasetVersion(client, "version-test", "group-test"), + ).rejects.toMatchObject({ exitCode: 1 }); + }); + test("status availability is presence only, including zero and unknown future codes", () => { + expect( + consoleDatasetStatusAvailability({ releaseStatus: 0, datasetTaskDTO: { taskStatus: 99 } }), + ).toEqual({ release_status: "returned", import_status: "returned" }); + expect(consoleDatasetStatusAvailability({ releaseStatus: null, datasetTaskDTO: null })).toEqual( + { release_status: "missing", import_status: "missing" }, + ); + expect(consoleDatasetStatusAvailability(null)).toEqual({ + release_status: "missing", + import_status: "missing", + }); + }); + test("both reads propagate service failures without retry", async () => { + const { client, consoleCall } = fixtureClient({ + success: false, + errorCode: "ReadDenied", + errorMsg: "original server denial", + data: null, + }); + await expect(getConsoleDatasetById(client, "version-test")).rejects.toThrow( + "original server denial", + ); + await expect(queryConsoleDatasetVersion(client, "version-test", "group-test")).rejects.toThrow( + "original server denial", + ); + expect(consoleCall).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/tests/deploy-instances.test.ts b/packages/core/tests/deploy-instances.test.ts new file mode 100644 index 000000000..818a31228 --- /dev/null +++ b/packages/core/tests/deploy-instances.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, expectTypeOf, test, vi } from "vite-plus/test"; +import type { Client } from "../src/client/client.ts"; +import { + CONSOLE_DEPLOY_INSTANCES_API, + buildConsoleDeploymentInstancesRequest, + listConsoleDeploymentInstances, + type ConsoleDeploymentInstancesPage, + type ListConsoleDeploymentInstancesParams, +} from "../src/deploy/instances.ts"; +import { BailianError } from "../src/errors/base.ts"; +import { ExitCode } from "../src/errors/codes.ts"; + +function makePage(): ConsoleDeploymentInstancesPage { + return { + records: [{ modelServiceId: "example-service-id", status: "FUTURE_STATE" }], + items: 21, + itemsPerPage: 10, + page: 1, + pageCount: 3, + }; +} + +function consoleEnvelope(data: unknown) { + return { data: { DataV2: { data: { data } } } }; +} + +function fakeClient(response: unknown = consoleEnvelope(makePage())) { + const consoleCall = vi.fn().mockResolvedValue(response); + const client: Pick = { + console: async (api: string, data: Record) => + (await consoleCall(api, data)) as Result, + }; + return { client, consoleCall }; +} + +describe("Console deployment instances request", () => { + test("uses the exact Console API with default pagination and no absent filters", async () => { + const { client, consoleCall } = fakeClient(); + const result = await listConsoleDeploymentInstances(client); + expectTypeOf(result).toEqualTypeOf(); + expect(CONSOLE_DEPLOY_INSTANCES_API).toBe( + "zeldaEasy.broadscope-platform.modelInstance.listIndependentDeployedModel.m_efm", + ); + expect(buildConsoleDeploymentInstancesRequest()).toStrictEqual({ + input: { page: 1, itemsPerPage: 10 }, + }); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith(CONSOLE_DEPLOY_INSTANCES_API, { + input: { page: 1, itemsPerPage: 10 }, + }); + }); + + test("treats explicit undefined params as absent", async () => { + const { client, consoleCall } = fakeClient(); + const params: ListConsoleDeploymentInstancesParams = { + page: undefined, + pageSize: undefined, + keyword: undefined, + modelServiceId: undefined, + }; + await listConsoleDeploymentInstances(client, params); + expect(buildConsoleDeploymentInstancesRequest(params)).toStrictEqual({ + input: { page: 1, itemsPerPage: 10 }, + }); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith(CONSOLE_DEPLOY_INSTANCES_API, { + input: { page: 1, itemsPerPage: 10 }, + }); + }); + + test("fetches only the second page without following pageCount or imposing max 100", async () => { + const page = { ...makePage(), items: 901, itemsPerPage: 101, page: 2, pageCount: 9 }; + const { client, consoleCall } = fakeClient(consoleEnvelope(page)); + const params = { page: 2, pageSize: 101 }; + expect(await listConsoleDeploymentInstances(client, params)).toBe(page); + expect(buildConsoleDeploymentInstancesRequest(params)).toStrictEqual({ + input: { page: 2, itemsPerPage: 101 }, + }); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith(CONSOLE_DEPLOY_INSTANCES_API, { + input: { page: 2, itemsPerPage: 101 }, + }); + }); + + test.each([ + { + name: "keyword", + params: { keyword: "example /?&+ 名称" }, + filters: { searchNameKeyword: "example /?&+ 名称" }, + }, + { + name: "model service ID", + params: { modelServiceId: "000900719925474099312345" }, + filters: { modelServiceId: "000900719925474099312345" }, + }, + { + name: "intersection of keyword and model service ID", + params: { keyword: "example", modelServiceId: "example-service-id" }, + filters: { searchNameKeyword: "example", modelServiceId: "example-service-id" }, + }, + ])("passes the $name filter to the API without local filtering", async ({ params, filters }) => { + const page = makePage(); + const { client, consoleCall } = fakeClient(consoleEnvelope(page)); + const expectedRequest = { input: { page: 1, itemsPerPage: 10, ...filters } }; + expect(await listConsoleDeploymentInstances(client, params)).toBe(page); + expect(buildConsoleDeploymentInstancesRequest(params)).toStrictEqual(expectedRequest); + expect(consoleCall).toHaveBeenCalledExactlyOnceWith( + CONSOLE_DEPLOY_INSTANCES_API, + expectedRequest, + ); + }); +}); + +describe("Console deployment instances response", () => { + test("preserves complete raw pagination, records, false, zero, null and unknown fields", async () => { + const record = { + modelServiceId: "000900719925474099312345", + deployedModel: "example-deployed-model", + status: "FUTURE_STATE", + enabled: false, + capacity: 0, + targetCapacity: null, + data: { success: false, count: 0, value: null }, + futureField: [false, 0, null], + }; + const page = { + ...makePage(), + records: [record], + futureFlag: false, + futureCount: 0, + futureValue: null, + metadata: { enabled: false, count: 0, value: null }, + }; + const { client } = fakeClient(consoleEnvelope(page)); + const result = await listConsoleDeploymentInstances(client); + expect(result).toBe(page); + expect(result).toStrictEqual(page); + expect(result.records).toBe(page.records); + expect(result.records[0]).toBe(record); + expect(result.items).toBe(21); + expect(result.records[0]).not.toHaveProperty("callable"); + expect(result).not.toHaveProperty("total"); + }); + + test("accepts an empty page with zero items and zero pageCount", async () => { + const page = { records: [], items: 0, itemsPerPage: 10, page: 1, pageCount: 0 }; + const { client } = fakeClient(consoleEnvelope(page)); + expect(await listConsoleDeploymentInstances(client)).toBe(page); + }); + + test.each([undefined, null, false, 0, "invalid page", []].map((response) => ({ response })))( + "rejects a non-object business page: $response", + async ({ response }) => { + const { client } = fakeClient(consoleEnvelope(response)); + await expect(listConsoleDeploymentInstances(client)).rejects.toMatchObject({ + name: "BailianError", + exitCode: ExitCode.GENERAL, + }); + }, + ); + + test.each( + [ + undefined, + null, + false, + 0, + "records", + {}, + [null], + [false], + [0], + ["record"], + [[]], + [{}, null], + ].map((records) => ({ records })), + )("rejects invalid records without fabricating an empty list: $records", async ({ records }) => { + const { client } = fakeClient(consoleEnvelope({ ...makePage(), records })); + await expect(listConsoleDeploymentInstances(client)).rejects.toMatchObject({ + name: "BailianError", + exitCode: ExitCode.GENERAL, + message: expect.stringMatching(/Invalid Console.*分页响应无效/), + }); + }); + + test.each(["records", "items", "itemsPerPage", "page", "pageCount"])( + "rejects a missing required field: %s", + async (field) => { + const page = Object.fromEntries( + Object.entries(makePage()).filter(([name]) => name !== field), + ); + const { client } = fakeClient(consoleEnvelope(page)); + await expect(listConsoleDeploymentInstances(client)).rejects.toMatchObject({ + name: "BailianError", + exitCode: ExitCode.GENERAL, + message: expect.stringMatching(/Invalid Console.*分页响应无效/), + }); + }, + ); + + describe.each(["items", "itemsPerPage", "page", "pageCount"])("invalid %s", (field) => { + test.each([ + undefined, + null, + false, + "1", + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ])("rejects %s without coercion or a fallback number", async (value) => { + const { client } = fakeClient(consoleEnvelope({ ...makePage(), [field]: value })); + await expect(listConsoleDeploymentInstances(client)).rejects.toMatchObject({ + name: "BailianError", + exitCode: ExitCode.GENERAL, + message: expect.stringMatching(/Invalid Console.*分页响应无效/), + }); + }); + }); + + test.each(["page", "itemsPerPage"])("rejects zero for %s", async (field) => { + const { client } = fakeClient(consoleEnvelope({ ...makePage(), [field]: 0 })); + await expect(listConsoleDeploymentInstances(client)).rejects.toMatchObject({ + name: "BailianError", + exitCode: ExitCode.GENERAL, + message: expect.stringMatching(/Invalid Console.*分页响应无效/), + }); + }); + + test("preserves nested service error text before validating or unwrapping its data", async () => { + const message = "Original service denial: 模型服务暂不可读; diagnostic=opaque"; + const response = consoleEnvelope({ + success: false, + code: "Deployment.ReadDenied", + message, + data: makePage(), + }); + const { client, consoleCall } = fakeClient(response); + const request = listConsoleDeploymentInstances(client); + await expect(request).rejects.toBeInstanceOf(BailianError); + await expect(request).rejects.toMatchObject({ + exitCode: ExitCode.GENERAL, + message: expect.stringContaining(message), + }); + expect(consoleCall).toHaveBeenCalledTimes(1); + }); + + test("propagates client errors without replacing the message or retrying", async () => { + const error = new BailianError("original console error", ExitCode.GENERAL); + const { client, consoleCall } = fakeClient(); + consoleCall.mockRejectedValueOnce(error); + await expect(listConsoleDeploymentInstances(client)).rejects.toBe(error); + expect(consoleCall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index fb1120061..d39fff769 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -308,6 +308,7 @@ test("callConsoleGateway keeps readable message and raw gateway response separat success: false, errorCode: "BailianGateway.Team.NotAuthorised", errorMsg: "team not authorised", + requestId: "inner-request-fixture", }, }; @@ -327,7 +328,12 @@ test("callConsoleGateway keeps readable message and raw gateway response separat data: {}, }), ).rejects.toMatchObject({ - message: "Console gateway error: BailianGateway.Team.NotAuthorised", + message: "team not authorised (HTTP 200; code=BailianGateway.Team.NotAuthorised)", + api: { + httpStatus: 200, + apiCode: "BailianGateway.Team.NotAuthorised", + requestId: "inner-request-fixture", + }, rawResponse: JSON.stringify(responseBody), exitCode: ExitCode.GENERAL, }); diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index cbaa26a6e..aea85b41a 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -281,6 +281,14 @@ export function createCli(commands: Record, opts: CliOptions ...Object.keys(GLOBAL_FLAGS), ...Object.keys(credDefs), ]) as Partial; + if (typeof globalFlags.workspaceId === "string" && !globalFlags.workspaceId.trim()) { + throw new UsageError( + localize({ + "en-US": "--workspace-id must not be empty or whitespace.", + "zh-CN": "--workspace-id 不能为空或仅包含空白字符。", + }), + ); + } const ownFlags = pick( parsedFlags, Object.keys(res.command.flags ?? {}), diff --git a/packages/runtime/tests/console-aksk-auth.test.ts b/packages/runtime/tests/console-aksk-auth.test.ts index de76bf612..50dba2765 100644 --- a/packages/runtime/tests/console-aksk-auth.test.ts +++ b/packages/runtime/tests/console-aksk-auth.test.ts @@ -226,7 +226,7 @@ test("permission errors are not treated as expired tokens", async () => { authStage(ctx, () => ctx.client.console("test.api", {}).then(() => {})), ).rejects.toMatchObject({ exitCode: 1, - message: "Console gateway error: Original.Permission.Denied", + message: "Original.Permission.Denied (HTTP 200; code=Original.Permission.Denied)", }); expect(requests.generations()).toHaveLength(0); }); diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 081003df6..eae6c25dd 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -66,42 +66,43 @@ Do not guess flags — use the reference files or `--help`. Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml / Sandbox, soft hand-off to the domain skill. -| User intent | Command | Notes | -| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | -| 百炼异步文件批量推理 / CSV 转换 | `bl batch convert` / `create` / `list` / `get` / `cancel` / `download` | CSV → JSONL → 异步任务 → 原始结果,可额外导出 CSV;格式样例见 `reference/batch.md` | -| 百炼单次同步 Batch Chat | `bl text chat --batch` | 等待单次响应,不创建任务 ID;messages-file 是同一请求的对话上下文,边界见 `reference/text.md` | -| Typed decision model inference | `bl decision` | Workspace endpoint; returns `choice` / `noul` / `score` values and probabilities, not generated text | -| Explicit ParseX parsing / structured field extraction | `bl parsex parse submit` / `result`; `bl parsex extract submit` / `result` | Workspace + API key; submit returns biz_id, result --follow waits. HTTP(S) source URLs; extraction can reuse a parsed document. See `reference/parsex.md`. | -| Bailian agent / workflow | `bl app call` | Needs `--app-id` | -| Find app by name | `bl app list` then `bl app call` | Console auth | -| Personal memory / Bailian memory resources | → skill `bailian-memory` | User-enabled personal memory; explicit resource operations use `bl memory --help` as fallback. | -| Personal documents / Bailian knowledge workflows | Read skill `bailian-knowledge` | If missing: `bl knowledge --help` or `bl skill init` | -| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | -| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | -| Bailian model catalog / pricing / params | `bl model list` | No auth (public catalog); `--model ` for detail, `--enrich` for input params | -| Find a model by keyword / modality | `bl model search` | Weighted ranking; `--input-modality` / `--output-modality`; offline models hidden by default | -| SDK sample code for calling a model | `bl model code` | `--sdk openai\|dashscope`, `--lang`; text output is the snippet alone, safe to redirect | -| Install / list / update / remove registry skills | `bl skill add` / `list` / `update` / `remove` | Bailian skill registry; see [`reference/skill.md`](reference/skill.md) | -| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — | -| Register or remove a Bailian MCP in an Agent | `bl mcp connect` / `disconnect` | Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork, Qwen Code, Gemini, OpenCode, OpenClaw, DeepSeek Harness, ZCode, WorkBuddy | -| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | -| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | -| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | -| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | -| Token Plan harness entitlement quota | `bl token-plan harness-quota` | Console auth; issued quota usage + still-issuing harnesses | -| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | -| Model monitoring (calls, latency, errors, delivery) | `bl monitor overview` / `models` / `metrics` / `errors` / `delivery *` | Console auth; see [`reference/monitor.md`](reference/monitor.md) | -| Model call logs / traces | `bl log audit *` / `bl log inference *` / `bl log status` / `bl log trace *` | Console auth; audit and inference logs have separate APIs and switches; see [`reference/log.md`](reference/log.md) | -| Model alert rules / templates / history | `bl alert list` / `create` / `update` / `delete` / `enable` / `disable` / `history` / `metrics` / `template *` | Console auth; contacts are managed in the CloudMonitor console; see [`reference/alert.md`](reference/alert.md) | -| Console API (advanced) | `bl console call` | Console auth | -| Bailian workspace listing | `bl workspace list` | Console auth | -| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | -| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | -| Training dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | -| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` need `--yes` after `plan` | -| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | -| Web search (mode-aware routing) | → skill `bailian-web-search` | Default model mode vs explicit `--mode mcp` + fallback; fallback: `bl search web --help` | +| User intent | Command | Notes | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | +| 百炼异步文件批量推理 / CSV 转换 | `bl batch convert` / `create` / `list` / `get` / `cancel` / `download` | CSV → JSONL → 异步任务 → 原始结果,可额外导出 CSV;格式样例见 `reference/batch.md` | +| 百炼单次同步 Batch Chat | `bl text chat --batch` | 等待单次响应,不创建任务 ID;messages-file 是同一请求的对话上下文,边界见 `reference/text.md` | +| Typed decision model inference | `bl decision` | Workspace endpoint; returns `choice` / `noul` / `score` values and probabilities, not generated text | +| Explicit ParseX parsing / structured field extraction | `bl parsex parse submit` / `result`; `bl parsex extract submit` / `result` | Workspace + API key; submit returns biz_id, result --follow waits. HTTP(S) source URLs; extraction can reuse a parsed document. See `reference/parsex.md`. | +| Bailian agent / workflow | `bl app call` | Needs `--app-id` | +| Find app by name | `bl app list` then `bl app call` | Console auth | +| Personal memory / Bailian memory resources | → skill `bailian-memory` | User-enabled personal memory; explicit resource operations use `bl memory --help` as fallback. | +| Personal documents / Bailian knowledge workflows | Read skill `bailian-knowledge` | If missing: `bl knowledge --help` or `bl skill init` | +| Resolve a Console file key / error-report URL | `bl file url` | Console auth; use the returned business file key, not a file ID or upload URI. Resolves only; no download or fixed expiry. Existing URLs need no resolution. | +| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | +| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | +| Bailian model catalog / pricing / params | `bl model list` | No auth (public catalog); `--model ` for detail, `--enrich` for input params | +| Find a model by keyword / modality | `bl model search` | Weighted ranking; `--input-modality` / `--output-modality`; offline models hidden by default | +| SDK sample code for calling a model | `bl model code` | `--sdk openai\|dashscope`, `--lang`; text output is the snippet alone, safe to redirect | +| Install / list / update / remove registry skills | `bl skill add` / `list` / `update` / `remove` | Bailian skill registry; see [`reference/skill.md`](reference/skill.md) | +| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — | +| Register or remove a Bailian MCP in an Agent | `bl mcp connect` / `disconnect` | Codex, Claude Code, Cursor, Qoder, Qoder Work, QwenWork, Qwen Code, Gemini, OpenCode, OpenClaw, DeepSeek Harness, ZCode, WorkBuddy | +| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | +| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | +| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | +| Bailian Token Plan quota usage | `bl usage token-plan` | Console auth; class 2 — ask which product first if unnamed | +| Token Plan harness entitlement quota | `bl token-plan harness-quota` | Console auth; issued quota usage + still-issuing harnesses | +| Bailian Coding Plan quota usage | `bl usage coding-plan` | Console auth; class 2 — ask which product first if unnamed | +| Model monitoring (calls, latency, errors, delivery) | `bl monitor overview` / `models` / `metrics` / `errors` / `delivery *` | Console auth; see [`reference/monitor.md`](reference/monitor.md) | +| Model call logs / traces | `bl log audit *` / `bl log inference *` / `bl log status` / `bl log trace *` | Console auth; audit and inference logs have separate APIs and switches; see [`reference/log.md`](reference/log.md) | +| Model alert rules / templates / history | `bl alert list` / `create` / `update` / `delete` / `enable` / `disable` / `history` / `metrics` / `template *` | Console auth; contacts are managed in the CloudMonitor console; see [`reference/alert.md`](reference/alert.md) | +| Console API (advanced) | `bl console call` | Console auth | +| Bailian workspace listing | `bl workspace list` | Console auth | +| Switch CLI Help / Quick Start language | `bl config set --key language --value zh-CN` | Use `en-US` to switch back; follows the active config profile | +| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | +| Training dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | +| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` need `--yes` after `plan` | +| Bailian Sandbox instance / template lifecycle | → skill `bailian-sandbox` | Fallback: `bl sandbox --help` | +| Web search (mode-aware routing) | → skill `bailian-web-search` | Default model mode vs explicit `--mode mcp` + fallback; fallback: `bl search web --help` | Flags, usage, and examples: see hub [`reference/`](reference/index.md) or `bl --help` — do not guess flags. Domain command details live in the owning skill's `reference/`. Examples in `reference/` stay bare; wrap with Call attribution when running. diff --git a/skills/bailian-cli/reference/file.md b/skills/bailian-cli/reference/file.md index baef30a49..e911a1411 100644 --- a/skills/bailian-cli/reference/file.md +++ b/skills/bailian-cli/reference/file.md @@ -10,6 +10,7 @@ Index: [index.md](index.md) | Command | Authentication | Description | | ---------------- | -------------- | -------------------------------------------------------- | | `bl file upload` | API Key | Upload a local file to DashScope temporary storage (48h) | +| `bl file url` | Console | Resolve a Console business file key to a file URL | ## Command details @@ -48,3 +49,40 @@ bl file upload --file audio.wav --model qwen3-asr-flash ```bash bl file upload --file cat.png --model qwen-image-3.0 ``` + +### `bl file url` + +| Field | Value | +| ------------------ | ------------------------------------------------- | +| **Name** | `file url` | +| **Description** | Resolve a Console business file key to a file URL | +| **Authentication** | Console | +| **Usage** | `bl file url --file-key ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ------------------------------------------------------------ | +| `--file-key ` | string | yes | Console business file key, not an existing URL or oss:// URI | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | + +#### Notes + +- Uses Console-domain authentication and the selected Console scope. +- The business file key is not the DashScope temporary oss:// URI returned by file upload. +- If you already have a file URL, use it directly; this command is unnecessary. +- Only resolves the address and returns the service fields; it does not download files, write local files, or calculate a fixed expiry time. +- A selected workspace is resolved to its Console identity before business requests, overriding --console-switch-agent for this invocation only. Resolution failure stops the query; dry-run only previews this step. + +#### Examples + +```bash +bl file url --file-key example-file-key +``` + +```bash +bl file url --file-key example-file-key --dry-run +``` diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 2b4b94924..d78c7e4d5 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -53,6 +53,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl console call` | Console | Call a Bailian console API via the CLI gateway | [console.md](console.md) | | `bl decision` | API Key | Run the decision model: typed choice / noul / score answers with probabilities | [decision.md](decision.md) | | `bl file upload` | API Key | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) | +| `bl file url` | Console | Resolve a Console business file key to a file URL | [file.md](file.md) | | `bl log audit count` | Console | Count model audit logs in a time range (useful before exporting) | [log.md](log.md) | | `bl log audit disable` | Console | Disable audit log delivery for all models in the workspace | [log.md](log.md) | | `bl log audit enable` | Console | Enable audit log delivery to SLS (SLR authorization → SLS instance → log switch) | [log.md](log.md) | @@ -136,7 +137,7 @@ Use this index for the skill-scoped quick index and global flags. | `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) | | `console` | `call` | [console.md](console.md) | | `decision` | `(root)` | [decision.md](decision.md) | -| `file` | `upload` | [file.md](file.md) | +| `file` | `upload`, `url` | [file.md](file.md) | | `log` | `audit count`, `audit disable`, `audit enable`, `audit get`, `audit list`, `inference count`, `inference disable`, `inference enable`, `inference get`, `inference list`, `status`, `trace get`, `trace list`, `trace stats` | [log.md](log.md) | | `mcp` | `call`, `connect`, `disconnect`, `list`, `tools` | [mcp.md](mcp.md) | | `model` | `code`, `list`, `search` | [model.md](model.md) | diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 648a50511..677dfb97f 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -9,6 +9,7 @@ description: >- 覆盖文本、语音、图像)、校验或上传训练数据集、看训练进度和日志、挑 checkpoint、导出精调产物、 把专属模型部署成服务时使用 `bl dataset` / `bl finetune` / `bl deploy`。链路是 validate 校验数据 → upload 拿 file-id → finetune create 建任务 → watch 看进度 → export 导出 → 可选 compress 量化压缩 → deploy 上线,需要 API key; + Console 版本状态与独立部署服务查询使用 Console 鉴权。 写操作先用 `--dry-run` 预览。反触发:用户点名火山方舟/ark 的精调不走本 skill;只是要选哪个模型走 bailian-model-recommend;用现成模型生图生视频走 bailian-gen;百炼其他资源管理走 bailian-cli。 官方安装:`bl skill init`(与共享协议 bailian-protocol 同装)。 @@ -16,7 +17,7 @@ description: >- # Bailian fine-tuning pipeline (`bl dataset` / `bl finetune` / `bl deploy`) -**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Version & updates (pre-flight checklist), Call attribution (every Agent `bl` command), Setup & auth, and CLI errors: report an issue. Command details are authoritative in [`reference/`](reference/index.md) (dataset / finetune / deploy) and `bl --help` — do not guess flags. Examples in `reference/` stay bare; wrap with Call attribution when running. The whole pipeline requires an API key. If that protocol file is missing, stop and run `bl skill init`; do not guess auth/consent.** +**CRITICAL — Before executing, MUST read the shared protocol in [`../bailian-protocol/SKILL.md`](../bailian-protocol/SKILL.md): Version & updates (pre-flight checklist), Call attribution (every Agent `bl` command), Setup & auth, and CLI errors: report an issue. Command details are authoritative in [`reference/`](reference/index.md) (dataset / finetune / deploy) and `bl --help` — do not guess flags. Examples in `reference/` stay bare; wrap with Call attribution when running. The training pipeline uses an API key; Console dataset-version and independent-deployment queries use Console authentication. If that protocol file is missing, stop and run `bl skill init`; do not guess auth/consent.** **Call attribution:** Examples below and in `reference/` stay bare `bl …`. When running a command, wrap it with [`bailian-protocol` → Call attribution](../bailian-protocol/SKILL.md#call-attribution-mandatory-for-every-agent-bl-command) (bash or PowerShell). One sample for this skill (`BAILIAN_SKILL_NAME=bailian-finetune`; replace `codex` with this host). After deploy, `bl text chat` is owned by `bailian-cli`. **When copying commands into a reusable script / template / task definition, switch to the protocol `script` template (never keep `agent`); the sample below is for this-turn inline execution only. Strip attribution when handing a command to the user to paste.** @@ -46,30 +47,35 @@ BAILIAN_CALLER_TYPE=agent BAILIAN_CALLER_NAME=codex BAILIAN_SKILL_NAME=bailian-f ## When to use which command -| Intent | Command | -| -------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Validate / upload training data | `bl dataset validate` / `upload` (`.jsonl` or `.zip`) | -| Dataset list / detail / delete | `bl dataset list` / `get` / `delete` | -| Create a fine-tuning job | `bl finetune text\|audio\|image create` | -| Job list / detail / follow | `bl finetune list` / `get` / `watch` / `logs` | -| Artifacts and export | `bl finetune checkpoints` / `export` | -| Quantize / compress a full model | `bl finetune compress templates` / `create` / `watch` / `get` / `logs` / `list` | -| Cancel / delete a compression job | `bl finetune compress cancel` / `delete` | -| Cancel / delete a fine-tuning job | `bl finetune cancel` / `delete` | -| Import an external OSS LoRA model | Hand-off: `bl cmodel import *` — hub-owned commands; see `bailian-cli` reference or `bl cmodel --help` | -| Trainable capability lookup | `bl finetune capability` | -| Deploy / lifecycle | `bl deploy text\|audio\|image create`, `list` / `get` / `update` / `scale` / `delete` / `models` | -| Query throughput reservations | `bl deploy list --plan ptu` / `bl deploy get` | -| Query capacity instances | `bl deploy capacity list` / `get` | -| Query / wait for a capacity operation | `bl deploy operation get` / `wait` | -| Buy / scale / renew / release capacity | `bl deploy capacity create` / `scale` / `renew` / `delete` | -| Unsubscribe a prepaid instance | `bl deploy capacity unsubscribe` (builds the billing console refund link) | -| Configure ModelCode overflow strategy | `bl deploy overflow` | +| Intent | Command | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| Validate / upload training data | `bl dataset validate` / `upload` (`.jsonl` or `.zip`) | +| Dataset list / detail / delete | `bl dataset list` / `get` / `delete` | +| Console dataset groups / discover version and file IDs | `bl dataset groups` → `bl dataset version --dataset-group-id ` (Console auth) | +| Console version detail / import and release state | `bl dataset version --dataset-id ` (Console auth; optional `--include-status`) | +| Console independent deployment services | `bl deploy instances` (Console auth; not capacity instances or replicas) | +| Create a fine-tuning job | `bl finetune text\|audio\|image create` | +| Job list / detail / follow | `bl finetune list` / `get` / `watch` / `logs` | +| Artifacts and export | `bl finetune checkpoints` / `export` | +| Quantize / compress a full model | `bl finetune compress templates` / `create` / `watch` / `get` / `logs` / `list` | +| Cancel / delete a compression job | `bl finetune compress cancel` / `delete` | +| Cancel / delete a fine-tuning job | `bl finetune cancel` / `delete` | +| Import an external OSS LoRA model | Hand-off: `bl cmodel import *` — hub-owned commands; see `bailian-cli` reference or `bl cmodel --help` | +| Trainable capability lookup | `bl finetune capability` | +| Deploy / lifecycle | `bl deploy text\|audio\|image create`, `list` / `get` / `update` / `scale` / `delete` / `models` | +| Query throughput reservations | `bl deploy list --plan ptu` / `bl deploy get` | +| Query capacity instances | `bl deploy capacity list` / `get` | +| Query / wait for a capacity operation | `bl deploy operation get` / `wait` | +| Buy / scale / renew / release capacity | `bl deploy capacity create` / `scale` / `renew` / `delete` | +| Unsubscribe a prepaid instance | `bl deploy capacity unsubscribe` (builds the billing console refund link) | +| Configure ModelCode overflow strategy | `bl deploy overflow` | The `capacity list` / `get`, `operation get` / `wait` and `deploy list` / `get` queries are read-only. Capacity values are kTPM; effective, configured and target capacities are distinct. `deploy list --status` filters only the fetched page locally, with the server total left unfiltered. `operation get` reports status as data; `operation wait` exits non-zero on failure or timeout and refreshes capacity after success. Use IDs returned by the API; waiting never retries a write. The `capacity create` / `scale` / `renew` / `delete` and `overflow` commands are high-risk writes: preview with `--dry-run`, then confirm with the runtime-injected `--yes`. Each write is submitted once and never auto-retried; HTTP 200 is not success, so pass `--wait` or check `operation_status`. Scale values are one instance's absolute kTPM, not deltas or ModelCode totals, and zero is not release. `capacity delete` releases an instance but keeps the ModelCode; active prepaid instances cannot be DELETEd — run `capacity unsubscribe --instance-id ` to get the billing console refund link and finish there (no API exists for refunds). Release is confirmed by `deleted=true`. `overflow` applies to the whole ModelCode, not one instance. There is no estimator or standalone auto-renewal endpoint — renewal settings ride along purchase/scale/renew. +Console reads are separate from API-key file/deployment queries; the API Key / `file_id` semantics of `dataset list` are unchanged. Discover `records[].datasetGroupId` with `dataset groups`, then pass it to `dataset version --dataset-group-id ` to get `{result:[...]}` with `result[].datasetId` and an optional `fileId`. Use `dataset version --dataset-id ` for details; exactly one of `--dataset-group-id` and `--dataset-id` is required, and they cannot be combined. `fileId` is not `datasetId`; never pass a file ID from `dataset list` as a Console version ID. Groups are paginated and contain only one nested version preview; `version --dataset-group-id` normalizes array responses to `result` without promising exhaustive results or exposing pagination. Keep the same workspace throughout this chain. `--include-status` is available only in detail mode (`dataset version --dataset-id --include-status`) and preserves raw version and task state; missing/unknown state never means validation success or training readiness, and the returned task is not guaranteed to be the latest. A failed follow-up query retains metadata and exits nonzero. `deploy instances` returns one page (`records` is the list, `items` the total); reading does not establish inference or pause permission. With `--workspace-id`, these commands resolve that workspace's Console identity before business requests and stop on resolution failure. For an error-report file key, hand off to `bailian-cli` (`file url`); an existing report URL can be used directly. + Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl --help` — do not guess flags. ## Quick examples diff --git a/skills/bailian-finetune/reference/dataset.md b/skills/bailian-finetune/reference/dataset.md index c52cedfeb..5424c46c6 100644 --- a/skills/bailian-finetune/reference/dataset.md +++ b/skills/bailian-finetune/reference/dataset.md @@ -11,9 +11,11 @@ Index: [index.md](index.md) | --------------------- | -------------- | ------------------------------------------------------------------ | | `bl dataset delete` | API Key | Delete a dataset file by ID | | `bl dataset get` | API Key | Get details of a single dataset file | +| `bl dataset groups` | Console | List Console dataset groups | | `bl dataset list` | API Key | List uploaded dataset files | | `bl dataset upload` | API Key | Upload a dataset file (.jsonl or .zip) to Bailian | | `bl dataset validate` | No Auth | Locally validate a dataset file (.jsonl or .zip) without uploading | +| `bl dataset version` | Console | List Console dataset versions by group or get one version by ID | ## Command details @@ -81,6 +83,49 @@ bl dataset get --file-id file-xxx bl dataset get --file-id file-xxx --output json ``` +### `bl dataset groups` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------ | +| **Name** | `dataset groups` | +| **Description** | List Console dataset groups | +| **Authentication** | Console | +| **Usage** | `bl dataset groups [--page ] [--page-size ] [--name ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ----------------------------------------------------------------------------- | +| `--page ` | number | no | Page number, a positive safe integer (default: 1) | +| `--page-size ` | number | no | Groups per page, a positive safe integer (default: 10) | +| `--name ` | string | no | Nonblank dataset group name for server-side filtering; trimmed before sending | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | + +#### Notes + +- Fetches only the requested page. records contains dataset groups; items is the server's total count for the query. All business-response fields are preserved. The API Key / file_id semantics of dataset list are unchanged. +- Each group's datasets is only a one-version preview, not its complete version list. Pass datasetGroupId to dataset version --dataset-group-id for {result:[...]} (no completeness guarantee or pagination); use dataset version --dataset-id with a version's datasetId for details (--include-status is detail-only). Exactly one ID flag is required; they cannot be combined. fileId is not datasetId. +- --name is trimmed and sent to the server without local filtering. No type filter is hardcoded, but server-side scope and defaults still affect results; this is not an account-wide exhaustive listing. There is no --all option. +- The preview requests one import task per version, without guaranteeing the latest task or all tasks. Status codes are preserved as returned; no release, validation, or training readiness is inferred. +- A selected workspace is resolved to its Console identity before business requests, overriding --console-switch-agent for this invocation only. Resolution failure stops the query; dry-run only previews this step. + +#### Examples + +```bash +bl dataset groups +``` + +```bash +bl dataset groups --page 2 --page-size 20 +``` + +```bash +bl dataset groups --name example-dataset --dry-run +``` + ### `bl dataset list` | Field | Value | @@ -232,3 +277,52 @@ bl dataset validate --file eval.jsonl --full-validate ```bash bl dataset validate --file train.jsonl --output json ``` + +### `bl dataset version` + +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------------- | +| **Name** | `dataset version` | +| **Description** | List Console dataset versions by group or get one version by ID | +| **Authentication** | Console | +| **Usage** | `bl dataset version (--dataset-group-id \| --dataset-id [--include-status])` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | --------------------------------------------------------------------------------- | +| `--dataset-group-id ` | string | no | List versions of this Console dataset group; mutually exclusive with --dataset-id | +| `--dataset-id ` | string | no | Console dataset version ID, not an API file ID | +| `--include-status` | switch | no | Also query import/release state; only valid with --dataset-id | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | + +#### Notes + +- Exactly one of --dataset-group-id or --dataset-id is required; they cannot be combined. --include-status is only valid with --dataset-id, not group listing. IDs are never inferred from their format. +- Find datasetGroupId with dataset groups, then list versions with --dataset-group-id and get details with a returned datasetId via --dataset-id. An optional fileId belongs to the file/training workflow and cannot substitute for either Console ID; dataset list still returns API file IDs. +- Group listing returns result and preserves all other service fields; a raw array is wrapped as result. There are no pagination flags and no guarantee of exhaustive results. Version/task fields are included when returned; listing does not issue individual detail queries. +- Uses the .m_efm APIs without guessing a corpus suffix. dataset contains metadata; --include-status adds version and status_query. The file query using --file-id remains separate. +- Version releaseStatus: 0 draft, 1 publishing, 2 published, 3 failed. Import taskStatus: 0 initializing, 3 importing, 10 failed, 20 succeeded, 23 partially succeeded. Codes are preserved; unknown/missing states never imply success. No separate validation result or training readiness is inferred. +- The status query requests one import task; it does not assert that task is the latest or includes all tasks. No exact match means status was not obtained, not that the version does not exist. A second-query failure preserves metadata and exits nonzero; a returned failed resource state is still a successful query. +- A selected workspace is resolved to its Console identity before business requests, overriding --console-switch-agent for this invocation only. Resolution failure stops the query; dry-run only previews this step. + +#### Examples + +```bash +bl dataset version --dataset-group-id example-group-id +``` + +```bash +bl dataset version --dataset-group-id example-group-id --dry-run +``` + +```bash +bl dataset version --dataset-id example-version-id +``` + +```bash +bl dataset version --dataset-id example-version-id --include-status --output json +``` diff --git a/skills/bailian-finetune/reference/deploy.md b/skills/bailian-finetune/reference/deploy.md index 24b80dbb6..3be79d37d 100644 --- a/skills/bailian-finetune/reference/deploy.md +++ b/skills/bailian-finetune/reference/deploy.md @@ -20,6 +20,7 @@ Index: [index.md](index.md) | `bl deploy delete` | API Key | Delete a model deployment (PTU must be STOPPED with all capacity released) | | `bl deploy get` | API Key | Get details of a single model deployment | | `bl deploy image create` | API Key | Create an image generation model deployment | +| `bl deploy instances` | Console | List Console independent deployment services | | `bl deploy list` | API Key | List model deployments and throughput reservations | | `bl deploy models` | API Key | List models available for deployment | | `bl deploy operation get` | API Key | Query a throughput reservation capacity operation once | @@ -512,6 +513,48 @@ bl deploy image create --model-name my-wan-ft --display-name my-wan-mu --plan mu bl deploy image create --model-name my-wan-ft --display-name my-wan --dry-run ``` +### `bl deploy instances` + +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------- | +| **Name** | `deploy instances` | +| **Description** | List Console independent deployment services | +| **Authentication** | Console | +| **Usage** | `bl deploy instances [--page ] [--page-size ] [--keyword ] [--model-service-id ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------- | +| `--page ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Results per page (default: 10) | +| `--keyword ` | string | no | Server-side name keyword filter | +| `--model-service-id ` | string | no | Server-side model service ID filter | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | +| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | + +#### Notes + +- Lists Console independent deployment services, not capacity instances or replicas. Fetches only the requested page; records is the list and items is the server's total count. Preserves all business-response fields without deriving callable status. +- --keyword and --model-service-id may be supplied together and are forwarded to the API without local filtering. Successful reading does not prove inference or pause permission. This command does not change the API-key deployment list contract. +- A selected workspace is resolved to its Console identity before business requests, overriding --console-switch-agent for this invocation only. Resolution failure stops the query; dry-run only previews this step. + +#### Examples + +```bash +bl deploy instances +``` + +```bash +bl deploy instances --page 2 --page-size 20 +``` + +```bash +bl deploy instances --keyword example --model-service-id example-service-id +``` + ### `bl deploy list` | Field | Value | diff --git a/skills/bailian-finetune/reference/index.md b/skills/bailian-finetune/reference/index.md index ea1fc9ce1..619590888 100644 --- a/skills/bailian-finetune/reference/index.md +++ b/skills/bailian-finetune/reference/index.md @@ -13,9 +13,11 @@ Use this index for the skill-scoped quick index and global flags. | -------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | `bl dataset delete` | API Key | Delete a dataset file by ID | [dataset.md](dataset.md) | | `bl dataset get` | API Key | Get details of a single dataset file | [dataset.md](dataset.md) | +| `bl dataset groups` | Console | List Console dataset groups | [dataset.md](dataset.md) | | `bl dataset list` | API Key | List uploaded dataset files | [dataset.md](dataset.md) | | `bl dataset upload` | API Key | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) | | `bl dataset validate` | No Auth | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) | +| `bl dataset version` | Console | List Console dataset versions by group or get one version by ID | [dataset.md](dataset.md) | | `bl deploy audio create` | API Key | Create an audio (TTS) model deployment | [deploy.md](deploy.md) | | `bl deploy capacity create` | API Key | Purchase an additional capacity instance for a ModelCode | [deploy.md](deploy.md) | | `bl deploy capacity delete` | API Key | Release a capacity instance without deleting its ModelCode | [deploy.md](deploy.md) | @@ -27,6 +29,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl deploy delete` | API Key | Delete a model deployment (PTU must be STOPPED with all capacity released) | [deploy.md](deploy.md) | | `bl deploy get` | API Key | Get details of a single model deployment | [deploy.md](deploy.md) | | `bl deploy image create` | API Key | Create an image generation model deployment | [deploy.md](deploy.md) | +| `bl deploy instances` | Console | List Console independent deployment services | [deploy.md](deploy.md) | | `bl deploy list` | API Key | List model deployments and throughput reservations | [deploy.md](deploy.md) | | `bl deploy models` | API Key | List models available for deployment | [deploy.md](deploy.md) | | `bl deploy operation get` | API Key | Query a throughput reservation capacity operation once | [deploy.md](deploy.md) | @@ -66,8 +69,8 @@ Use this index for the skill-scoped quick index and global flags. | Group | Commands | Reference | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -| `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) | -| `deploy` | `audio create`, `capacity create`, `capacity delete`, `capacity get`, `capacity list`, `capacity renew`, `capacity scale`, `capacity unsubscribe`, `delete`, `get`, `image create`, `list`, `models`, `operation get`, `operation wait`, `overflow`, `pause`, `resume`, `scale`, `text create`, `update` | [deploy.md](deploy.md) | +| `dataset` | `delete`, `get`, `groups`, `list`, `upload`, `validate`, `version` | [dataset.md](dataset.md) | +| `deploy` | `audio create`, `capacity create`, `capacity delete`, `capacity get`, `capacity list`, `capacity renew`, `capacity scale`, `capacity unsubscribe`, `delete`, `get`, `image create`, `instances`, `list`, `models`, `operation get`, `operation wait`, `overflow`, `pause`, `resume`, `scale`, `text create`, `update` | [deploy.md](deploy.md) | | `finetune` | `audio create`, `cancel`, `capability`, `checkpoints`, `compress cancel`, `compress create`, `compress delete`, `compress get`, `compress list`, `compress logs`, `compress templates`, `compress watch`, `delete`, `export`, `get`, `image create`, `list`, `logs`, `metrics`, `params`, `price`, `text create`, `video create`, `watch` | [finetune.md](finetune.md) | ## Global flags From 11ee2074b52f7af88a153c026a39082d15d97916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 12 Nov 2026 11:52:23 +0800 Subject: [PATCH 2/3] chore(release): bump version to 2.8.6 --- packages/bailian-kb-dsh/package.json | 2 +- packages/bailian-memo-dsh/package.json | 2 +- packages/cli/package.json | 2 +- packages/commands/package.json | 2 +- packages/core/package.json | 2 +- packages/kscli/package.json | 2 +- packages/runtime/package.json | 2 +- skills/bailian-cli/SKILL.md | 2 +- skills/bailian-finetune/SKILL.md | 2 +- skills/bailian-gen/SKILL.md | 2 +- skills/bailian-knowledge/SKILL.md | 2 +- skills/bailian-managed-agent/SKILL.md | 2 +- skills/bailian-memory/SKILL.md | 2 +- skills/bailian-protocol/SKILL.md | 2 +- skills/bailian-sandbox/SKILL.md | 2 +- skills/bailian-web-search/SKILL.md | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/bailian-kb-dsh/package.json b/packages/bailian-kb-dsh/package.json index 915a8bad8..5121bd2d5 100644 --- a/packages/bailian-kb-dsh/package.json +++ b/packages/bailian-kb-dsh/package.json @@ -1,6 +1,6 @@ { "name": "bailian-kb-dsh", - "version": "2.8.5", + "version": "2.8.6", "description": "Bailian knowledge-base tools for DeepSeek Harness: kb_search and kb_chat over the DashScope RAG API, plus the bl CLI management skill.", "keywords": [ "alibaba-cloud", diff --git a/packages/bailian-memo-dsh/package.json b/packages/bailian-memo-dsh/package.json index 29e62c52f..9351280f4 100644 --- a/packages/bailian-memo-dsh/package.json +++ b/packages/bailian-memo-dsh/package.json @@ -1,6 +1,6 @@ { "name": "bailian-memo-dsh", - "version": "2.8.5", + "version": "2.8.6", "description": "Bailian personal memory for DeepSeek Harness: automatic recall, silent curation, and user-controlled remember/forget.", "keywords": [ "alibaba-cloud", diff --git a/packages/cli/package.json b/packages/cli/package.json index 747805895..a92119f2b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "2.8.5", + "version": "2.8.6", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/commands/package.json b/packages/commands/package.json index 1c25f0649..e3f98408f 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-commands", - "version": "2.8.5", + "version": "2.8.6", "description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/core/package.json b/packages/core/package.json index e5350fc9d..a1d094d34 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "2.8.5", + "version": "2.8.6", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/kscli/package.json b/packages/kscli/package.json index 137da2e0c..067c5db99 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-studio-cli", - "version": "2.8.5", + "version": "2.8.6", "description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.", "keywords": [ "alibaba-cloud", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7238cfda2..af4549fe9 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-runtime", - "version": "2.8.5", + "version": "2.8.6", "description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index eae6c25dd..a97999d5a 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-cli metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 677dfb97f..4aaaf7ee6 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-finetune metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index ae0027ea0..7af4a9628 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-gen metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-knowledge/SKILL.md b/skills/bailian-knowledge/SKILL.md index f674de4dd..2b41c275b 100644 --- a/skills/bailian-knowledge/SKILL.md +++ b/skills/bailian-knowledge/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-knowledge metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 9b9a2f735..19235fb84 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-managed-agent metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-memory/SKILL.md b/skills/bailian-memory/SKILL.md index 6a8ef3029..3b73ae464 100644 --- a/skills/bailian-memory/SKILL.md +++ b/skills/bailian-memory/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-memory metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 04350f0bd..4c8e257cc 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-protocol metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index 8dae07e62..332746cb6 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-sandbox metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-web-search/SKILL.md b/skills/bailian-web-search/SKILL.md index c359dfc22..dea10f6c8 100644 --- a/skills/bailian-web-search/SKILL.md +++ b/skills/bailian-web-search/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-web-search metadata: - version: "2.8.5" + version: "2.8.6" requires: bins: ["bl"] description: >- From 31920257e713cbc7e43e57ab2d2b056aa9163d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 12 Nov 2026 14:24:48 +0800 Subject: [PATCH 3/3] feat: add changelog --- CHANGELOG.md | 13 ++++ CHANGELOG.zh.md | 13 ++++ tools/knowledge-quickstart.test.ts | 119 +++++++++++++++++++++-------- 3 files changed, 113 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 978e42d76..0b78d4422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md) +## [2.8.6] - 2026-11-12 + +### Added + +- **Console dataset queries**: Add `bl dataset groups` with pagination and name filtering, and `bl dataset version` to list versions by `--dataset-group-id` or query one version by `--dataset-id`, with optional `--include-status`. +- **Console file URLs**: Add `bl file url --file-key` to resolve a Console file key to a download URL without downloading the file. +- **Console deployment queries**: Add `bl deploy instances` with pagination, keyword and model-service-ID filters. Existing API-key `dataset list/get` and `deploy list` commands remain unchanged. + +### Fixed + +- **Console response handling**: Correctly parse nested success and failure responses, preserve service error messages, codes and request IDs, and avoid treating valid workspace lists as empty. +- **Workspace validation**: Reject an explicitly blank `--workspace-id` instead of silently falling back to another configured workspace. + ## [2.8.5] - 2026-11-10 ### Fixed diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index a88dec8ef..a3c049f98 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -6,6 +6,19 @@ [English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md) +## [2.8.6] - 2026-11-12 + +### 新增 + +- **Console 数据集查询**:新增 `bl dataset groups`,支持分页和名称筛选;新增 `bl dataset version`,通过 `--dataset-group-id` 列出组内版本,或通过 `--dataset-id` 查询单版本详情,可选 `--include-status` 补充状态。 +- **Console 文件地址解析**:新增 `bl file url --file-key`,将 Console 文件键解析为下载 URL,不自动下载文件。 +- **Console 部署查询**:新增 `bl deploy instances`,支持分页、关键词和模型服务 ID 筛选。原有 API Key 域的 `dataset list/get` 与 `deploy list` 保持不变。 + +### 修复 + +- **Console 响应处理**:正确解析嵌套成功与失败响应,保留服务端错误消息、错误码及请求 ID,避免将有效工作空间列表误判为空。 +- **工作空间参数校验**:拒绝显式传入空白 `--workspace-id`,避免静默回退到其他已配置工作空间。 + ## [2.8.5] - 2026-11-10 ### 修复 diff --git a/tools/knowledge-quickstart.test.ts b/tools/knowledge-quickstart.test.ts index fbb3f73ae..09a2ebfd0 100644 --- a/tools/knowledge-quickstart.test.ts +++ b/tools/knowledge-quickstart.test.ts @@ -2,9 +2,62 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vite-plus/test"; -import { knowledgeReferenceSchemas } from "./generate-knowledge-reference.ts"; +import { commands as blCommands } from "../packages/cli/src/commands.ts"; +import { commands as kscliCommands } from "../packages/kscli/src/commands.ts"; +import blPackage from "../packages/cli/package.json" with { type: "json" }; +import kscliPackage from "../packages/kscli/package.json" with { type: "json" }; +import { CommandRegistry } from "../packages/runtime/src/registry.ts"; +import { buildCommandSchema, type CommandSchema } from "../packages/runtime/src/introspect.ts"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +// README Quick Start sections can mix domains. Reference generation intentionally +// scopes bl to knowledge, but these examples need each product's complete map. +// Build metadata only, once: no CLI execution, credentials, or subprocesses. +const schemas = { + bl: buildCommandSchema(new CommandRegistry(blCommands, "bl"), { + binName: "bl", + version: blPackage.version, + npmPackage: blPackage.name, + clientName: blPackage.name, + }), + kscli: buildCommandSchema(new CommandRegistry(kscliCommands, "kscli"), { + binName: "kscli", + version: kscliPackage.version, + npmPackage: kscliPackage.name, + clientName: kscliPackage.name, + }), +}; + +function expectCommandMatchesSchema(line: string, schema: CommandSchema) { + const tokens = line.match(/"[^"]*"|'[^']*'|\S+/g)!.slice(1); + if (tokens[0] === "--introspect") return; + const command = schema.commands.find((candidate) => + candidate.path.every((part, index) => tokens[index] === part), + ); + expect(command, line).toBeDefined(); + if (!command) return; + const flags = { + ...schema.globalFlags, + ...schema.credentialFlags[command.auth], + ...command.flags, + }; + const supplied = new Set(); + for (let offset = command.path.length; offset < tokens.length; offset++) { + const name = tokens[offset]; + expect(flags[name], `${line}: unknown ${name}`).toBeDefined(); + const flag = flags[name]; + supplied.add(name); + if (flag?.type !== "switch") { + const value = tokens[++offset]; + expect(value, `${line}: missing value for ${name}`).toBeDefined(); + if (flag?.choices) expect(flag.choices).toContain(value); + } + } + for (const [name, flag] of Object.entries(flags)) { + if (flag.type !== "switch" && flag.required) + expect(supplied.has(name), `${line}: required ${name}`).toBe(true); + } +} test.each([ { filename: "packages/kscli/README.md", product: "kscli", minimum: 4 }, @@ -17,40 +70,42 @@ test.each([ const readme = readFileSync(resolve(root, filename), "utf8"); const section = readme.split(/## (?:Quick Start|快速开始)\n/)[1]?.split(/\n## /)[0]; expect(section).toBeDefined(); - const schema = knowledgeReferenceSchemas()[product]; + const schema = schemas[product]; const commands = [...section!.matchAll(/```bash\n([\s\S]*?)\n```/g)] .flatMap((match) => match[1].replace(/\\\n\s*/g, " ").split("\n")) .filter((line) => line.startsWith(`${product} `)); expect(commands.length).toBeGreaterThanOrEqual(minimum); - for (const line of commands) { - const tokens = line.match(/"[^"]*"|'[^']*'|\S+/g)!.slice(1); - if (tokens[0] === "--introspect") continue; - const command = schema.commands.find((candidate) => - candidate.path.every((part, index) => tokens[index] === part), - ); - expect(command, line).toBeDefined(); - if (!command) continue; - const flags = { - ...schema.globalFlags, - ...schema.credentialFlags[command.auth], - ...command.flags, - }; - const supplied = new Set(); - for (let offset = command.path.length; offset < tokens.length; offset++) { - const name = tokens[offset]; - expect(flags[name], `${line}: unknown ${name}`).toBeDefined(); - const flag = flags[name]; - supplied.add(name); - if (flag?.type !== "switch") { - const value = tokens[++offset]; - expect(value, `${line}: missing value for ${name}`).toBeDefined(); - if (flag?.choices) expect(flag.choices).toContain(value); - } - } - for (const [name, flag] of Object.entries(flags)) { - if (flag.type !== "switch" && flag.required) - expect(supplied.has(name), `${line}: required ${name}`).toBe(true); - } - } + // Other domains must not hide an accidentally removed knowledge quick start. + if (product === "bl") + expect( + commands.some((line) => line.startsWith("bl knowledge ")), + filename, + ).toBe(true); + for (const line of commands) expectCommandMatchesSchema(line, schema); }, ); + +test.each([ + "bl knowledge init --workspace-id --dry-run", + "bl dataset groups --page 1 --page-size 10 --output json", + "bl dataset version --dataset-group-id --output json", + "bl dataset version --dataset-id --include-status --output json", + "bl file url --file-key --output json", + "bl deploy instances --page 1 --page-size 10 --output json", +])("mixed-domain bl quick starts remain checked: %s", (line) => { + expect(schemas.bl.scope).toEqual([]); + expect(() => expectCommandMatchesSchema(line, schemas.bl)).not.toThrow(); +}); + +test.each([ + { product: "bl", line: "bl knowledge unknown-command" }, + { product: "bl", line: "bl knowledge init --workspace-id --unknown-flag" }, + { product: "bl", line: "bl dataset unknown-command" }, + { product: "bl", line: "bl dataset groups --unknown-flag" }, + { product: "bl", line: "bl dataset groups --page-size" }, + { product: "bl", line: "bl file url" }, + { product: "kscli", line: "kscli dataset groups" }, + { product: "kscli", line: "kscli init --workspace-id --unknown-flag" }, +] as const)("invalid examples must not be silently skipped: $line", ({ product, line }) => { + expect(() => expectCommandMatchesSchema(line, schemas[product])).toThrow(); +});