From 470fc07d62ea6e0edead053fb505a367d06bea10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Mon, 31 Aug 2026 19:08:23 +0800 Subject: [PATCH 1/3] feat: add observability commands (monitor/log/alert) and model search - monitor: overview / models / metrics / errors / delivery status & enable - log: list / get / count / status / enable / disable + trace list/get/stats - alert: rule and template management (list/create/update/delete/enable/disable/history) - model: search and code - require --yes confirmation for destructive deletes (finetune/deploy/dataset/alert) --- README.md | 3 +- README.zh.md | 3 +- docs/agents/command-add-remove.md | 1 + packages/cli/src/commands.ts | 58 +++ .../commands/src/commands/alert/create.ts | 74 +++ .../commands/src/commands/alert/delete.ts | 71 +++ .../commands/src/commands/alert/disable.ts | 59 +++ .../commands/src/commands/alert/enable.ts | 57 ++ .../commands/src/commands/alert/history.ts | 175 +++++++ packages/commands/src/commands/alert/list.ts | 157 ++++++ .../commands/src/commands/alert/metrics.ts | 76 +++ .../commands/src/commands/alert/shared.ts | 306 +++++++++++ .../src/commands/alert/template-create.ts | 155 ++++++ .../src/commands/alert/template-delete.ts | 77 +++ .../src/commands/alert/template-list.ts | 136 +++++ .../src/commands/alert/template-update.ts | 92 ++++ .../commands/src/commands/alert/update.ts | 70 +++ .../commands/src/commands/dataset/delete.ts | 25 +- .../commands/src/commands/deploy/delete.ts | 25 +- .../commands/src/commands/finetune/delete.ts | 19 +- packages/commands/src/commands/log/count.ts | 68 +++ packages/commands/src/commands/log/disable.ts | 54 ++ packages/commands/src/commands/log/enable.ts | 126 +++++ packages/commands/src/commands/log/get.ts | 153 ++++++ packages/commands/src/commands/log/list.ts | 220 ++++++++ packages/commands/src/commands/log/status.ts | 85 +++ .../commands/src/commands/log/trace-get.ts | 118 +++++ .../commands/src/commands/log/trace-list.ts | 163 ++++++ .../commands/src/commands/log/trace-stats.ts | 115 +++++ packages/commands/src/commands/model/code.ts | 263 ++++++++++ packages/commands/src/commands/model/list.ts | 174 ++++--- .../commands/src/commands/model/search.ts | 284 ++++++++++ .../commands/src/commands/model/shared.ts | 243 +++++++++ .../src/commands/monitor/delivery-enable.ts | 121 +++++ .../src/commands/monitor/delivery-status.ts | 58 +++ .../commands/src/commands/monitor/errors.ts | 110 ++++ .../commands/src/commands/monitor/metrics.ts | 204 ++++++++ .../commands/src/commands/monitor/models.ts | 212 ++++++++ .../commands/src/commands/monitor/overview.ts | 130 +++++ .../commands/src/commands/shared/format.ts | 15 + .../commands/src/commands/shared/telemetry.ts | 337 ++++++++++++ .../commands/src/commands/usage/shared.ts | 47 +- packages/commands/src/index.ts | 29 ++ packages/commands/tests/e2e/alert.e2e.test.ts | 394 ++++++++++++++ .../commands/tests/e2e/dataset.e2e.test.ts | 36 ++ .../commands/tests/e2e/deploy.e2e.test.ts | 19 + .../commands/tests/e2e/finetune.e2e.test.ts | 21 + packages/commands/tests/e2e/log.e2e.test.ts | 184 +++++++ packages/commands/tests/e2e/model.e2e.test.ts | 160 ++++++ .../commands/tests/e2e/monitor.e2e.test.ts | 157 ++++++ packages/commands/tests/e2e/topic-routes.ts | 42 ++ packages/commands/tests/model-catalog.test.ts | 238 +++++++++ packages/commands/tests/telemetry.test.ts | 194 +++++++ packages/core/src/console/index.ts | 6 + packages/core/src/console/models.ts | 58 +++ skills/bailian-cli/SKILL.md | 53 +- skills/bailian-cli/reference/alert.md | 487 ++++++++++++++++++ skills/bailian-cli/reference/index.md | 34 +- skills/bailian-cli/reference/log.md | 367 +++++++++++++ skills/bailian-cli/reference/model.md | 148 +++++- skills/bailian-cli/reference/monitor.md | 280 ++++++++++ skills/bailian-finetune/reference/dataset.md | 31 +- skills/bailian-finetune/reference/deploy.md | 21 +- skills/bailian-finetune/reference/finetune.md | 28 +- 64 files changed, 7723 insertions(+), 203 deletions(-) create mode 100644 packages/commands/src/commands/alert/create.ts create mode 100644 packages/commands/src/commands/alert/delete.ts create mode 100644 packages/commands/src/commands/alert/disable.ts create mode 100644 packages/commands/src/commands/alert/enable.ts create mode 100644 packages/commands/src/commands/alert/history.ts create mode 100644 packages/commands/src/commands/alert/list.ts create mode 100644 packages/commands/src/commands/alert/metrics.ts create mode 100644 packages/commands/src/commands/alert/shared.ts create mode 100644 packages/commands/src/commands/alert/template-create.ts create mode 100644 packages/commands/src/commands/alert/template-delete.ts create mode 100644 packages/commands/src/commands/alert/template-list.ts create mode 100644 packages/commands/src/commands/alert/template-update.ts create mode 100644 packages/commands/src/commands/alert/update.ts create mode 100644 packages/commands/src/commands/log/count.ts create mode 100644 packages/commands/src/commands/log/disable.ts create mode 100644 packages/commands/src/commands/log/enable.ts create mode 100644 packages/commands/src/commands/log/get.ts create mode 100644 packages/commands/src/commands/log/list.ts create mode 100644 packages/commands/src/commands/log/status.ts create mode 100644 packages/commands/src/commands/log/trace-get.ts create mode 100644 packages/commands/src/commands/log/trace-list.ts create mode 100644 packages/commands/src/commands/log/trace-stats.ts create mode 100644 packages/commands/src/commands/model/code.ts create mode 100644 packages/commands/src/commands/model/search.ts create mode 100644 packages/commands/src/commands/model/shared.ts create mode 100644 packages/commands/src/commands/monitor/delivery-enable.ts create mode 100644 packages/commands/src/commands/monitor/delivery-status.ts create mode 100644 packages/commands/src/commands/monitor/errors.ts create mode 100644 packages/commands/src/commands/monitor/metrics.ts create mode 100644 packages/commands/src/commands/monitor/models.ts create mode 100644 packages/commands/src/commands/monitor/overview.ts create mode 100644 packages/commands/src/commands/shared/telemetry.ts create mode 100644 packages/commands/tests/e2e/alert.e2e.test.ts create mode 100644 packages/commands/tests/e2e/log.e2e.test.ts create mode 100644 packages/commands/tests/e2e/model.e2e.test.ts create mode 100644 packages/commands/tests/e2e/monitor.e2e.test.ts create mode 100644 packages/commands/tests/model-catalog.test.ts create mode 100644 packages/commands/tests/telemetry.test.ts create mode 100644 skills/bailian-cli/reference/alert.md create mode 100644 skills/bailian-cli/reference/log.md create mode 100644 skills/bailian-cli/reference/monitor.md diff --git a/README.md b/README.md index f937bc92b..39711b4fa 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble | Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." | | Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." | | Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." | +| Monitoring & alerts | "Show my model call stats, failures and logs, and create an alert rule." | | Model selection | "Recommend a model for image understanding and customer support." | | About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." | @@ -139,7 +140,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx ### Console Login (OAuth) -Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in. +Required for console capability commands (app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in. The model catalog commands (`bl model list` / `search` / `code`) read a public endpoint and need no login. ```bash bl auth login --console diff --git a/README.zh.md b/README.zh.md index 4679a5539..5bb43a6a6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -115,6 +115,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex | Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” | | 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” | | 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” | +| 监控与告警 | “查看我的模型调用统计、失败明细和调用日志,并创建一条告警规则。” | | 模型选型 | “推荐一个适合图片理解和智能客服的模型。” | | 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” | @@ -138,7 +139,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx ### 控制台登录(OAuth) -控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。 +控制台能力命令(应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。模型目录命令(`bl model list` / `search` / `code`)读取公开接口,无需登录。 ```bash bl auth login --console diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index bbea87b1d..f625eb39b 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -75,6 +75,7 @@ packages/commands/src/index.ts - `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令 - `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口 - [ ] 用户可见 Help 文案在命令文件中就近提供 `en-US` / `zh-CN`:命令 `description`、flag `description`、`notes` 和包含自然语言的 `exampleArgs`;纯命令语法示例可保留为字符串,服务端错误不翻译 +- [ ] 如果命令执行不可逆的删除/销毁(远端资源删除、永久移除),必须提供 `yes` switch flag,并在 `run` 中于 `dry-run` 分支之后、任何网络调用之前调用 runtime 的 `confirmDangerousAction(summary, flags.yes ?? false)`(参考 `knowledge/*-delete.ts`);`--yes` 的 flag description 用「Skip the confirmation prompt / 跳过确认提示」,`notes` 里声明不可撤销 - [ ] `packages/commands/src/index.ts`:新增或移除对应 export - [ ] 如果命令调用 Console Gateway,设置 `auth: "console"`;不要重复声明 console 凭证域 flags - [ ] 如果命令不需要网络或自己管理配置/登录,设置 `auth: "none"`;不要绕过 runtime auth stage diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 27422935b..8282e6811 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -82,11 +82,40 @@ import { pipelineValidate, advisorRecommend, modelList, + modelSearch, + modelCode, workspaceList, quotaList, quotaUpdate, quotaHistory, quotaCheck, + monitorOverview, + monitorModels, + monitorMetrics, + monitorErrors, + monitorDeliveryStatus, + monitorDeliveryEnable, + logList, + logGet, + logCount, + logStatus, + logEnable, + logDisable, + logTraceList, + logTraceGet, + logTraceStats, + alertMetrics, + alertTemplateList, + alertTemplateCreate, + alertTemplateUpdate, + alertTemplateDelete, + alertList, + alertCreate, + alertUpdate, + alertDelete, + alertEnable, + alertDisable, + alertHistory, permissionList, permissionGrant, permissionRevoke, @@ -243,11 +272,40 @@ export const commands: Record = { "pipeline validate": pipelineValidate, "advisor recommend": advisorRecommend, "model list": modelList, + "model search": modelSearch, + "model code": modelCode, "workspace list": workspaceList, "quota list": quotaList, "quota update": quotaUpdate, "quota history": quotaHistory, "quota check": quotaCheck, + "monitor overview": monitorOverview, + "monitor models": monitorModels, + "monitor metrics": monitorMetrics, + "monitor errors": monitorErrors, + "monitor delivery status": monitorDeliveryStatus, + "monitor delivery enable": monitorDeliveryEnable, + "log list": logList, + "log get": logGet, + "log count": logCount, + "log status": logStatus, + "log enable": logEnable, + "log disable": logDisable, + "log trace list": logTraceList, + "log trace get": logTraceGet, + "log trace stats": logTraceStats, + "alert metrics": alertMetrics, + "alert template list": alertTemplateList, + "alert template create": alertTemplateCreate, + "alert template update": alertTemplateUpdate, + "alert template delete": alertTemplateDelete, + "alert list": alertList, + "alert create": alertCreate, + "alert update": alertUpdate, + "alert delete": alertDelete, + "alert enable": alertEnable, + "alert disable": alertDisable, + "alert history": alertHistory, "permission list": permissionList, "permission grant": permissionGrant, "permission revoke": permissionRevoke, diff --git a/packages/commands/src/commands/alert/create.ts b/packages/commands/src/commands/alert/create.ts new file mode 100644 index 000000000..e4da6bf78 --- /dev/null +++ b/packages/commands/src/commands/alert/create.ts @@ -0,0 +1,74 @@ +import { + defineCommand, + detectOutputFormat, + effectiveConsoleGatewayConfig, + unwrapResponse, +} from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { + ALERT_RULE_WRITE_FLAGS, + buildAlertRuleReqDTO, + ensureAlertReady, + validateAlertRuleFlags, +} from "./shared.ts"; + +const CREATE_RULE_API = "zeldaEasy.bailian-telemetry.alertRule.createAlertRule"; + +export default defineCommand({ + description: { + "en-US": "Create a model alert rule from an alert template", + "zh-CN": "基于告警模板创建模型告警规则", + }, + auth: "console", + usageArgs: "--name --template-id --model [flags]", + flags: ALERT_RULE_WRITE_FLAGS, + notes: [ + { + "en-US": + "Alert conditions come from the template; browse `alert template list` first. Notification contacts/contact groups are created in the CloudMonitor console — pass their IDs via --contact/--contact-group.", + "zh-CN": + "告警条件来自模板,请先用 `alert template list` 挑选。通知联系人/联系组需在云监控控制台创建,本命令通过 --contact/--contact-group 接收其 ID。", + }, + ], + exampleArgs: [ + "--name high-failure-rate --template-id 123 --model qwen3.6-plus", + "--name latency --template-id 456 --model qwen3.6-plus,qwen-turbo --level ERROR --contact-group 4004200", + "--name nightly --template-id 123 --model qwen3.6-plus --notify-window 09:00-18:00 --notify-days 1,2,3,4,5 --silence 3600", + "--name test --template-id 123 --model qwen3.6-plus --dry-run", + ], + validate: (flags) => validateAlertRuleFlags(flags), + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { + ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), + ...buildAlertRuleReqDTO(flags), + }; + + if (settings.dryRun) { + emitResult( + { + api: CREATE_RULE_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + const raw = await ctx.client.console(CREATE_RULE_API, { reqDTO }); + const resp = unwrapResponse(raw as Record); + const ruleId = (resp.data ?? resp.ruleId ?? resp) as string | undefined; + + if (format === "json") { + emitResult({ created: true, ruleId }, format); + return; + } + + process.stdout.write(`Alert rule created${ruleId ? `: ${ruleId}` : "."}\n`); + }, +}); diff --git a/packages/commands/src/commands/alert/delete.ts b/packages/commands/src/commands/alert/delete.ts new file mode 100644 index 000000000..4178c0ede --- /dev/null +++ b/packages/commands/src/commands/alert/delete.ts @@ -0,0 +1,71 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult, confirmDangerousAction } from "bailian-cli-runtime"; +import { parseCommaList } from "../shared/params.ts"; +import { ensureAlertReady } from "./shared.ts"; + +const DELETE_RULES_API = "zeldaEasy.bailian-telemetry.alertRule.deleteAlertRules"; + +export default defineCommand({ + description: { + "en-US": "Delete model alert rules", + "zh-CN": "删除模型告警规则", + }, + auth: "console", + usageArgs: "--rule-id [,...] [--yes]", + flags: { + ruleId: { + type: "string", + valueHint: "[,...]", + required: true, + description: { + "en-US": "Rule ID(s) to delete, comma-separated", + "zh-CN": "要删除的规则 ID,多个以逗号分隔", + }, + }, + yes: { + type: "switch", + description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, + }, + }, + exampleArgs: ["--rule-id 789", "--rule-id 789,790 --dry-run", "--rule-id 789 --yes"], + notes: [ + { + "en-US": "Irreversible — the alert rules are permanently removed.", + "zh-CN": "该操作不可撤销——告警规则将被永久删除。", + }, + ], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { ruleIds: parseCommaList(flags.ruleId) }; + + if (settings.dryRun) { + emitResult( + { + api: DELETE_RULES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await confirmDangerousAction( + `Delete ${reqDTO.ruleIds.length} alert rule(s): ${reqDTO.ruleIds.join(", ")}.\nThe rules are permanently removed. This cannot be undone.`, + flags.yes ?? false, + ); + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + await ctx.client.console(DELETE_RULES_API, { reqDTO }); + + if (format === "json") { + emitResult({ deleted: reqDTO.ruleIds }, format); + return; + } + + process.stdout.write(`Deleted ${reqDTO.ruleIds.length} alert rule(s).\n`); + }, +}); diff --git a/packages/commands/src/commands/alert/disable.ts b/packages/commands/src/commands/alert/disable.ts new file mode 100644 index 000000000..dd244aff2 --- /dev/null +++ b/packages/commands/src/commands/alert/disable.ts @@ -0,0 +1,59 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { ensureAlertReady } from "./shared.ts"; + +const DISABLE_RULE_API = "zeldaEasy.bailian-telemetry.alertRule.disableAlertRule"; + +export default defineCommand({ + description: { + "en-US": "Disable a model alert rule (keeps the rule, stops notifications)", + "zh-CN": "停用模型告警规则(保留规则,停止通知)", + }, + auth: "console", + usageArgs: "--rule-id [flags]", + flags: { + ruleId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Alert rule ID", + "zh-CN": "告警规则 ID", + }, + }, + }, + exampleArgs: ["--rule-id 789", "--rule-id 789 --dry-run"], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { + workspaceId: settings.workspaceId, + bizSource: "bailian", + ruleId: flags.ruleId, + }; + + if (settings.dryRun) { + emitResult( + { + api: DISABLE_RULE_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + await ctx.client.console(DISABLE_RULE_API, { reqDTO }); + + if (format === "json") { + emitResult({ enabled: false, ruleId: flags.ruleId }, format); + return; + } + + process.stdout.write(`Alert rule disabled: ${flags.ruleId}\n`); + }, +}); diff --git a/packages/commands/src/commands/alert/enable.ts b/packages/commands/src/commands/alert/enable.ts new file mode 100644 index 000000000..7950568e0 --- /dev/null +++ b/packages/commands/src/commands/alert/enable.ts @@ -0,0 +1,57 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { ensureAlertReady } from "./shared.ts"; + +export default defineCommand({ + description: { + "en-US": "Enable a model alert rule", + "zh-CN": "启用模型告警规则", + }, + auth: "console", + usageArgs: "--rule-id [flags]", + flags: { + ruleId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Alert rule ID", + "zh-CN": "告警规则 ID", + }, + }, + }, + exampleArgs: ["--rule-id 789", "--rule-id 789 --dry-run"], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { + workspaceId: settings.workspaceId, + bizSource: "bailian", + ruleId: flags.ruleId, + }; + + if (settings.dryRun) { + emitResult( + { + api: "zeldaEasy.bailian-telemetry.alertRule.enableAlertRule", + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + await ctx.client.console("zeldaEasy.bailian-telemetry.alertRule.enableAlertRule", { reqDTO }); + + if (format === "json") { + emitResult({ enabled: true, ruleId: flags.ruleId }, format); + return; + } + + process.stdout.write(`Alert rule enabled: ${flags.ruleId}\n`); + }, +}); diff --git a/packages/commands/src/commands/alert/history.ts b/packages/commands/src/commands/alert/history.ts new file mode 100644 index 000000000..a2e19302d --- /dev/null +++ b/packages/commands/src/commands/alert/history.ts @@ -0,0 +1,175 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { formatDateTime } from "../shared/format.ts"; +import { TELEMETRY_TIME_FLAGS, resolveTimeRange } from "../shared/telemetry.ts"; +import { ALERT_LEVELS, ensureAlertReady, extractAlertPage } from "./shared.ts"; + +const LIST_HISTORIES_API = "zeldaEasy.bailian-telemetry.alertRule.listAlertHistories"; + +interface AlertHistoryItem { + alertHistoryId: string; + alertRuleId: string; + ruleDisplayName: string; + status: string; + maxLevel: string; + latestLevel?: string; + count: number; + message: string; + startTime: number; + endTime?: number; + instanceDetail?: Record; +} + +function instanceText(item: AlertHistoryItem): string { + const detail = item.instanceDetail; + if (!detail) return "-"; + return ( + Object.entries(detail) + .map(([key, value]) => `${key}:${value}`) + .join(",") || "-" + ); +} + +export default defineCommand({ + description: { + "en-US": "List model alert history (firing and recovered events)", + "zh-CN": "查看模型告警历史(告警中与已恢复事件)", + }, + auth: "console", + usageArgs: "[--rule-id ] [--status ] [flags]", + flags: { + ...TELEMETRY_TIME_FLAGS, + ruleId: { + type: "string", + valueHint: "", + description: { + "en-US": "Filter by alert rule ID", + "zh-CN": "按告警规则 ID 过滤", + }, + }, + status: { + type: "string", + valueHint: "", + choices: ["ALARM", "OK"] as const, + description: { + "en-US": "Alert state: ALARM (firing), OK (recovered)", + "zh-CN": "告警状态:ALARM(告警中)、OK(已恢复)", + }, + }, + level: { + type: "string", + valueHint: "", + choices: ALERT_LEVELS, + description: { + "en-US": "Filter by highest alert level", + "zh-CN": "按最高告警等级过滤", + }, + }, + maxResults: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows per page, 1-50 (default: 10)", + "zh-CN": "每页数量,范围 1-50(默认:10)", + }, + }, + skip: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows to skip (default: 0)", + "zh-CN": "跳过的记录数(默认:0)", + }, + }, + nextToken: { + type: "string", + valueHint: "", + description: { + "en-US": "Pagination token from a previous response", + "zh-CN": "上一次响应返回的分页标记", + }, + }, + }, + exampleArgs: [ + "", + "--status ALARM --days 1", + "--rule-id 789 --days 30", + "--level ERROR --output json", + ], + validate: (flags) => { + if (flags.maxResults != null && (flags.maxResults < 1 || flags.maxResults > 50)) { + return "--max-results must be between 1 and 50."; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const { startTime, endTime } = resolveTimeRange(flags); + + const reqDTO = { + workspaceId: settings.workspaceId, + bizSource: "bailian", + resourceType: "model", + alertRuleId: flags.ruleId, + status: flags.status, + maxLevel: flags.level, + startTimeFrom: startTime, + startTimeTo: endTime, + maxResults: flags.maxResults ?? 10, + skip: flags.skip ?? 0, + nextToken: flags.nextToken, + }; + + if (settings.dryRun) { + emitResult( + { + api: LIST_HISTORIES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + const raw = await ctx.client.console(LIST_HISTORIES_API, { reqDTO }); + const page = extractAlertPage(raw); + + if (format === "json") { + emitResult(page, format); + return; + } + + if (page.list.length === 0) { + process.stdout.write("No alert history found in this range.\n"); + return; + } + + const color = ansi(process.stdout); + const lines = renderBoxTable({ + headers: ["Start Time", "Rule", "Status", "Max Level", "Count", "Instance"], + rows: page.list.map((item) => [ + item.startTime ? formatDateTime(item.startTime) : "-", + item.ruleDisplayName ?? item.alertRuleId, + item.status ?? "-", + item.maxLevel ?? "-", + String(item.count ?? 0), + instanceText(item), + ]), + align: ["left", "left", "left", "left", "right", "left"], + cellColor: (_rowIndex, colIndex, value) => { + if (colIndex === 2) return value === "ALARM" ? color.red(value) : color.green(value); + if (colIndex === 3 && value === "ERROR") return color.red(value); + return undefined; + }, + }); + for (const line of lines) process.stdout.write(line + "\n"); + + if (page.nextToken) { + process.stdout.write(`Next page: --next-token ${page.nextToken}\n`); + } + }, +}); diff --git a/packages/commands/src/commands/alert/list.ts b/packages/commands/src/commands/alert/list.ts new file mode 100644 index 000000000..bec7f8210 --- /dev/null +++ b/packages/commands/src/commands/alert/list.ts @@ -0,0 +1,157 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { ensureAlertReady, extractAlertPage, ALERT_LEVELS } from "./shared.ts"; + +const LIST_RULES_API = "zeldaEasy.bailian-telemetry.alertRule.listAlertRules"; + +interface AlertRuleItem { + ruleId: string; + name: string; + level: string; + enabled: boolean; + resourceIds: string[]; + interval: number; + duration: number; + gmtModified?: number; +} + +function printTable(list: AlertRuleItem[]): void { + const color = ansi(process.stdout); + + if (list.length === 0) { + process.stdout.write("No alert rules found.\n"); + return; + } + + const lines = renderBoxTable({ + headers: ["Rule ID", "Name", "Level", "Enabled", "Models", "Interval"], + rows: list.map((rule) => [ + rule.ruleId, + rule.name, + rule.level ?? "-", + rule.enabled ? "ON" : "OFF", + (rule.resourceIds ?? []).join(", ") || "-", + rule.interval != null ? `${rule.interval}s` : "-", + ]), + align: ["left", "left", "left", "left", "left", "right"], + cellColor: (_rowIndex, colIndex, value) => { + if (colIndex === 3) return value === "ON" ? color.green(value) : color.yellow(value); + if (colIndex === 2 && value === "ERROR") return color.red(value); + return undefined; + }, + }); + for (const line of lines) process.stdout.write(line + "\n"); +} + +export default defineCommand({ + description: { + "en-US": "List model alert rules", + "zh-CN": "查看模型告警规则列表", + }, + auth: "console", + usageArgs: "[--name ] [--enabled true|false] [flags]", + flags: { + ruleId: { + type: "string", + valueHint: "", + description: { + "en-US": "Exact rule ID filter", + "zh-CN": "按规则 ID 精确过滤", + }, + }, + name: { + type: "string", + valueHint: "", + description: { + "en-US": "Fuzzy filter by rule name", + "zh-CN": "按规则名称模糊过滤", + }, + }, + enabled: { + type: "boolean", + valueHint: "", + description: { + "en-US": "Filter by enabled state", + "zh-CN": "按启用状态过滤", + }, + }, + level: { + type: "string", + valueHint: "", + choices: ALERT_LEVELS, + description: { + "en-US": "Filter by alert level", + "zh-CN": "按告警等级过滤", + }, + }, + maxResults: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows per page (default: 20)", + "zh-CN": "每页数量(默认:20)", + }, + }, + skip: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows to skip (default: 0)", + "zh-CN": "跳过的记录数(默认:0)", + }, + }, + nextToken: { + type: "string", + valueHint: "", + description: { + "en-US": "Pagination token from a previous response", + "zh-CN": "上一次响应返回的分页标记", + }, + }, + }, + exampleArgs: ["", "--enabled true", "--name 失败率 --level ERROR", "--output json"], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { + workspaceId: settings.workspaceId, + bizSource: "bailian", + resourceType: "model", + ruleId: flags.ruleId, + name: flags.name, + enabled: flags.enabled, + level: flags.level, + maxResults: flags.maxResults ?? 20, + skip: flags.skip ?? 0, + nextToken: flags.nextToken, + }; + + if (settings.dryRun) { + emitResult( + { + api: LIST_RULES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + const raw = await ctx.client.console(LIST_RULES_API, { reqDTO }); + const page = extractAlertPage(raw); + + if (format === "json") { + emitResult(page, format); + return; + } + + printTable(page.list); + if (page.nextToken) { + process.stdout.write(`Next page: --next-token ${page.nextToken}\n`); + } + }, +}); diff --git a/packages/commands/src/commands/alert/metrics.ts b/packages/commands/src/commands/alert/metrics.ts new file mode 100644 index 000000000..32a2e86a5 --- /dev/null +++ b/packages/commands/src/commands/alert/metrics.ts @@ -0,0 +1,76 @@ +import { + defineCommand, + detectOutputFormat, + effectiveConsoleGatewayConfig, + unwrapResponse, +} from "bailian-cli-core"; +import { emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { ensureAlertReady } from "./shared.ts"; + +const LIST_METRICS_API = "zeldaEasy.bailian-telemetry.alertMetric.listMetrics"; + +interface AlertMetric { + metricName: string; + aggregators: string[]; + desc: string; + unit?: string; + resourceType?: string; +} + +export default defineCommand({ + description: { + "en-US": "List metrics that alert rules can be created on, with supported aggregations", + "zh-CN": "查看可创建告警的指标及其支持的聚合方式", + }, + auth: "console", + usageArgs: "[flags]", + exampleArgs: ["", "--output json"], + async run(ctx) { + const { settings } = ctx; + const format = detectOutputFormat(settings.output); + + if (settings.dryRun) { + emitResult( + { + api: LIST_METRICS_API, + data: { reqDTO: { workspaceId: settings.workspaceId, resourceType: "model" } }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + const raw = await ctx.client.console(LIST_METRICS_API, { + reqDTO: { workspaceId: settings.workspaceId, resourceType: "model" }, + }); + const resp = unwrapResponse(raw as Record); + const list = ( + Array.isArray(resp) ? resp : (((resp.list ?? resp.result) as unknown[]) ?? []) + ) as AlertMetric[]; + + if (format === "json") { + emitResult(list, format); + return; + } + + if (list.length === 0) { + process.stdout.write("No alertable metrics found.\n"); + return; + } + + const lines = renderBoxTable({ + headers: ["Metric", "Aggregations", "Unit", "Description"], + rows: list.map((metric) => [ + metric.metricName, + (metric.aggregators ?? []).join(", "), + metric.unit ?? "-", + metric.desc ?? "-", + ]), + align: ["left", "left", "left", "left"], + }); + for (const line of lines) process.stdout.write(line + "\n"); + }, +}); diff --git a/packages/commands/src/commands/alert/shared.ts b/packages/commands/src/commands/alert/shared.ts new file mode 100644 index 000000000..7e62505d7 --- /dev/null +++ b/packages/commands/src/commands/alert/shared.ts @@ -0,0 +1,306 @@ +import { UsageError, unwrapResponse, type Client, type FlagsDef } from "bailian-cli-core"; +import { parseCommaList } from "../shared/params.ts"; +import { ensureTelemetryReady } from "../shared/telemetry.ts"; + +// --------------------------------------------------------------------------- +// Alert rule write payload (shared by create / update) +// --------------------------------------------------------------------------- + +/** Same default notification template as the console. */ +export const DEFAULT_ALERT_MESSAGE = + '业务空间({{$tags.workspace_id}})下的模型({{$tags.model}})发生告警,当前值为{{ printf "%.2f" $value }}'; + +/** Frontend "no silence" sentinel: max int32 means never re-notify. */ +export const NO_SILENCE = 2147483647; + +export const ALERT_LEVELS = ["INFO", "WARNING", "ERROR"] as const; + +/** Rule field flags shared by `alert create` and `alert update`. */ +export const ALERT_RULE_WRITE_FLAGS = { + name: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Alert rule name (max 64 chars)", + "zh-CN": "告警规则名称(最长 64 字符)", + }, + }, + templateId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Alert template ID (from `alert template list`)", + "zh-CN": "告警模板 ID(可由 alert template list 获得)", + }, + }, + model: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Model name(s) to alert on, comma-separated", + "zh-CN": "告警生效的模型名称,多个以逗号分隔", + }, + }, + message: { + type: "string", + valueHint: "", + description: { + "en-US": "Alert notification content (Go template); defaults to the console template", + "zh-CN": "告警通知内容(Go 模板语法),默认与控制台一致", + }, + }, + level: { + type: "string", + valueHint: "", + choices: ALERT_LEVELS, + description: { + "en-US": "Alert level (default: INFO)", + "zh-CN": "告警等级(默认:INFO)", + }, + }, + interval: { + type: "number", + valueHint: "", + description: { + "en-US": "Check interval in seconds (default: 60)", + "zh-CN": "告警检查周期(秒,默认:60)", + }, + }, + duration: { + type: "number", + valueHint: "", + description: { + "en-US": "How long the condition must hold before alerting; 0 = immediately (default: 60)", + "zh-CN": "条件持续多久后告警(秒),0 表示立即告警(默认:60)", + }, + }, + contact: { + type: "string", + valueHint: "", + description: { + "en-US": "CMS alert contact ID(s), comma-separated; create them in the CloudMonitor console", + "zh-CN": "云监控告警联系人 ID,多个以逗号分隔;需在云监控控制台创建", + }, + }, + contactGroup: { + type: "string", + valueHint: "", + description: { + "en-US": "CMS alert contact group ID(s), comma-separated", + "zh-CN": "云监控告警联系组 ID,多个以逗号分隔", + }, + }, + notifyWindow: { + type: "string", + valueHint: "", + description: { + "en-US": "Daily notification window (default: 00:00-23:59)", + "zh-CN": "每日通知时间窗口(默认:00:00-23:59)", + }, + }, + notifyDays: { + type: "string", + valueHint: "", + description: { + "en-US": "Days of week to notify, 1-7 comma-separated (default: every day)", + "zh-CN": "每周通知日,1-7 逗号分隔(默认:每天)", + }, + }, + silence: { + type: "number", + valueHint: "", + description: { + "en-US": "Silence period between repeat notifications (default: never repeat)", + "zh-CN": "重复通知的静默时间(秒,默认:不重复通知)", + }, + }, + gmtOffset: { + type: "string", + valueHint: "", + description: { + "en-US": "Timezone offset for the notify window (default: +0800)", + "zh-CN": "通知窗口的时区偏移(默认:+0800)", + }, + }, +} satisfies FlagsDef; + +export interface AlertRuleFlags { + name?: string; + templateId?: string; + model?: string; + message?: string; + level?: "INFO" | "WARNING" | "ERROR"; + interval?: number; + duration?: number; + contact?: string; + contactGroup?: string; + notifyWindow?: string; + notifyDays?: string; + silence?: number; + gmtOffset?: string; +} + +/** Validate the notification-related flags shared by create/update. */ +export function validateAlertRuleFlags(flags: AlertRuleFlags): string | undefined { + if (flags.notifyWindow) { + const match = /^(\d{2}:\d{2})-(\d{2}:\d{2})$/.exec(flags.notifyWindow); + if (!match) return "--notify-window must be in HH:mm-HH:mm format, e.g. 09:00-18:00."; + } + if (flags.notifyDays) { + const days = parseCommaList(flags.notifyDays); + const invalid = days.filter((day) => !/^[1-7]$/.test(day)); + if (invalid.length > 0) return "--notify-days accepts values 1-7, e.g. 1,2,3,4,5."; + } + if (flags.interval != null && flags.interval < 1) return "--interval must be positive seconds."; + if (flags.duration != null && flags.duration < 0) return "--duration must be >= 0 seconds."; + if (flags.silence != null && flags.silence < 0) return "--silence must be >= 0 seconds."; + if (flags.gmtOffset != null && !/^[+-]\d{4}$/.test(flags.gmtOffset)) { + return "--gmt-offset must look like +0800."; + } + return undefined; +} + +/** Build the alertRule.createAlertRule / updateAlertRule reqDTO from CLI flags. */ +export function buildAlertRuleReqDTO( + flags: AlertRuleFlags & { name: string; templateId: string; model: string }, +): Record { + if (flags.interval === 0) throw new UsageError("--interval must be positive seconds."); + + const windowMatch = flags.notifyWindow + ? /^(\d{2}:\d{2})-(\d{2}:\d{2})$/.exec(flags.notifyWindow) + : null; + + return { + name: flags.name, + templateId: flags.templateId, + resourceIds: parseCommaList(flags.model), + message: flags.message ?? DEFAULT_ALERT_MESSAGE, + level: flags.level ?? "INFO", + interval: flags.interval ?? 60, + duration: flags.duration ?? 60, + notification: { + startTime: windowMatch?.[1] ?? "00:00", + endTime: windowMatch?.[2] ?? "23:59", + gmtOffset: flags.gmtOffset ?? "+0800", + dayOfWeek: flags.notifyDays + ? parseCommaList(flags.notifyDays).map(Number) + : [1, 2, 3, 4, 5, 6, 7], + silenceTime: flags.silence ?? NO_SILENCE, + contacts: flags.contact ? parseCommaList(flags.contact) : undefined, + contactGroups: flags.contactGroup ? parseCommaList(flags.contactGroup) : undefined, + }, + }; +} + +// --------------------------------------------------------------------------- +// CMS preflight for alert commands +// --------------------------------------------------------------------------- + +/** + * Alert rules live on CMS, which the console gates with the ModelMonitor + * service status. Fail fast with an actionable hint when it is not activated. + */ +export async function ensureAlertReady( + client: Client, + workspaceId: string | undefined, + binName: string, +): Promise { + await ensureTelemetryReady(client, { + serviceType: "ModelMonitor", + workspaceId, + enableCommand: `${binName} monitor delivery enable`, + requireInstance: false, + }); +} + +// --------------------------------------------------------------------------- +// Alert template conditions +// --------------------------------------------------------------------------- + +export const COMPARE_TYPES = [">", ">=", "<", "<=", "==", "!="] as const; + +export interface TemplateCondition { + metricName: string; + aggregator: string; + compareType: string; + compareValue: string; + period: number; +} + +/** + * Parse one --condition value: `metricName:aggregator:compareType:value:period`, + * e.g. `model_call_failed_count:sum:>:10:60`. + */ +export function parseCondition(raw: string): TemplateCondition { + const parts = raw.split(":"); + if (parts.length !== 5) { + throw new UsageError( + `Invalid --condition "${raw}". Expected metricName:aggregator:compareType:value:period.`, + ); + } + const [metricName, aggregator, compareType, compareValue, periodRaw] = parts as [ + string, + string, + string, + string, + string, + ]; + if (!metricName || !aggregator) { + throw new UsageError(`Invalid --condition "${raw}": metricName and aggregator are required.`); + } + if (!(COMPARE_TYPES as readonly string[]).includes(compareType)) { + throw new UsageError( + `Invalid --condition "${raw}": compareType must be one of ${COMPARE_TYPES.join(" ")}.`, + ); + } + const period = Number(periodRaw); + if (!Number.isFinite(period) || period < 1) { + throw new UsageError(`Invalid --condition "${raw}": period must be positive seconds.`); + } + return { metricName, aggregator, compareType, compareValue, period }; +} + +/** Templates accept 1-10 conditions; validate() runs before run, parse errors surface there too. */ +export function validateTemplateConditions( + conditions: string[] | undefined, + allowFrom: boolean, +): string | undefined { + if (!conditions || conditions.length === 0) { + return allowFrom + ? undefined + : "At least one --condition is required (or use --from to copy a template)."; + } + if (conditions.length > 10) { + return "At most 10 --condition entries are allowed."; + } + for (const raw of conditions) { + try { + parseCondition(raw); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Response helpers +// --------------------------------------------------------------------------- + +export interface AlertPage { + list: T[]; + totalCount: number; + nextToken?: string; +} + +export function extractAlertPage(raw: unknown): AlertPage { + const resp = unwrapResponse(raw as Record); + return { + list: (resp.list as T[]) ?? [], + totalCount: (resp.totalCount as number) ?? 0, + nextToken: resp.nextToken as string | undefined, + }; +} diff --git a/packages/commands/src/commands/alert/template-create.ts b/packages/commands/src/commands/alert/template-create.ts new file mode 100644 index 000000000..06835c0f0 --- /dev/null +++ b/packages/commands/src/commands/alert/template-create.ts @@ -0,0 +1,155 @@ +import { + defineCommand, + BailianError, + ExitCode, + detectOutputFormat, + effectiveConsoleGatewayConfig, + unwrapResponse, +} from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { + ensureAlertReady, + extractAlertPage, + parseCondition, + validateTemplateConditions, + type TemplateCondition, +} from "./shared.ts"; + +const CREATE_TEMPLATE_API = "zeldaEasy.bailian-telemetry.alertTemplate.createAlertTemplate"; +const LIST_TEMPLATES_API = "zeldaEasy.bailian-telemetry.alertTemplate.listAlertTemplates"; + +interface SourceTemplate { + templateId: string; + conditions?: TemplateCondition[]; + logicalOperator?: string; +} + +/** Copy conditions from the source template when --from is given without --condition. */ +async function resolveConditions( + ctx: { client: Parameters[0] }, + flags: { condition?: string[]; from?: string }, +): Promise { + if (flags.condition?.length) return flags.condition.map(parseCondition); + if (!flags.from) return []; + + const raw = await ctx.client.console(LIST_TEMPLATES_API, { + reqDTO: { resourceType: "model", templateId: flags.from, maxResults: 1, skip: 0 }, + }); + const source = extractAlertPage(raw).list[0]; + if (!source) { + throw new BailianError( + `Source template not found: ${flags.from}`, + ExitCode.GENERAL, + "Check the ID with `alert template list`.", + ); + } + return (source.conditions ?? []).map((condition) => ({ + metricName: condition.metricName, + aggregator: condition.aggregator, + compareType: condition.compareType, + compareValue: condition.compareValue, + period: condition.period, + })); +} + +export default defineCommand({ + description: { + "en-US": "Create a custom alert template (or copy an official one with --from)", + "zh-CN": "创建自定义告警模板(或用 --from 复制官方模板)", + }, + auth: "console", + usageArgs: + "--name [--condition ...] [--from ] [flags]", + flags: { + name: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Template name (max 64 chars)", + "zh-CN": "模板名称(最长 64 字符)", + }, + }, + condition: { + type: "array", + valueHint: "", + description: { + "en-US": + "Alert condition, repeatable (1-10). Example: model_call_failed_count:sum:>:10:60. See `alert metrics` for metric names", + "zh-CN": + "告警条件,可重复(1-10 条)。示例:model_call_failed_count:sum:>:10:60。指标名见 `alert metrics`", + }, + }, + from: { + type: "string", + valueHint: "", + description: { + "en-US": "Copy conditions from an existing (e.g. official) template", + "zh-CN": "复制已有(如官方)模板的条件", + }, + }, + logicalOperator: { + type: "string", + valueHint: "", + choices: ["or", "and"] as const, + description: { + "en-US": "How multiple conditions combine (default: or)", + "zh-CN": "多条件组合逻辑(默认:or)", + }, + }, + }, + exampleArgs: [ + "--name 失败率告警 --condition model_call_failed_count:sum:>:10:60", + "--name 高延迟 --condition model_call_duration:p99:>:3000:300 --condition model_call_5xx_count:sum:>:5:60 --logical-operator and", + "--name 我的模板 --from 42", + "--name test --condition model_call_count:sum:>:100:60 --dry-run", + ], + validate: (flags) => validateTemplateConditions(flags.condition, Boolean(flags.from)), + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const conditions = flags.condition?.length ? flags.condition.map(parseCondition) : undefined; + + if (settings.dryRun) { + emitResult( + { + api: CREATE_TEMPLATE_API, + data: { + reqDTO: { + templateName: flags.name, + resourceType: "model", + logicalOperator: flags.logicalOperator ?? "or", + srcTemplateId: flags.from, + conditions: conditions ?? "(copied from --from template)", + }, + }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + const reqDTO = { + templateName: flags.name, + resourceType: "model", + logicalOperator: flags.logicalOperator ?? "or", + srcTemplateId: flags.from, + conditions: conditions ?? (await resolveConditions(ctx, flags)), + }; + + const raw = await ctx.client.console(CREATE_TEMPLATE_API, { reqDTO }); + const resp = unwrapResponse(raw as Record); + const templateId = (resp.data ?? resp.templateId ?? resp) as string | undefined; + + if (format === "json") { + emitResult({ created: true, templateId }, format); + return; + } + + process.stdout.write(`Alert template created${templateId ? `: ${templateId}` : "."}\n`); + }, +}); diff --git a/packages/commands/src/commands/alert/template-delete.ts b/packages/commands/src/commands/alert/template-delete.ts new file mode 100644 index 000000000..b6c310d02 --- /dev/null +++ b/packages/commands/src/commands/alert/template-delete.ts @@ -0,0 +1,77 @@ +import { + defineCommand, + detectOutputFormat, + effectiveConsoleGatewayConfig, + unwrapResponse, +} from "bailian-cli-core"; +import { emitResult, confirmDangerousAction } from "bailian-cli-runtime"; +import { parseCommaList } from "../shared/params.ts"; +import { ensureAlertReady } from "./shared.ts"; + +const DELETE_TEMPLATES_API = "zeldaEasy.bailian-telemetry.alertTemplate.deleteAlertTemplates"; + +export default defineCommand({ + description: { + "en-US": "Delete custom alert templates (official templates cannot be deleted)", + "zh-CN": "删除自定义告警模板(官方模板不可删除)", + }, + auth: "console", + usageArgs: "--template-id [,...] [--yes]", + flags: { + templateId: { + type: "string", + valueHint: "[,...]", + required: true, + description: { + "en-US": "Template ID(s) to delete, comma-separated", + "zh-CN": "要删除的模板 ID,多个以逗号分隔", + }, + }, + yes: { + type: "switch", + description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, + }, + }, + exampleArgs: ["--template-id 123", "--template-id 123,124 --dry-run", "--template-id 123 --yes"], + notes: [ + { + "en-US": "Irreversible — the alert templates are permanently removed.", + "zh-CN": "该操作不可撤销——告警模板将被永久删除。", + }, + ], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { templateIds: parseCommaList(flags.templateId) }; + + if (settings.dryRun) { + emitResult( + { + api: DELETE_TEMPLATES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await confirmDangerousAction( + `Delete ${reqDTO.templateIds.length} alert template(s): ${reqDTO.templateIds.join(", ")}.\nThe templates are permanently removed. This cannot be undone.`, + flags.yes ?? false, + ); + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + const raw = await ctx.client.console(DELETE_TEMPLATES_API, { reqDTO }); + const resp = unwrapResponse(raw as Record); + + if (format === "json") { + emitResult({ deleted: reqDTO.templateIds, result: resp }, format); + return; + } + + process.stdout.write(`Deleted ${reqDTO.templateIds.length} alert template(s).\n`); + }, +}); diff --git a/packages/commands/src/commands/alert/template-list.ts b/packages/commands/src/commands/alert/template-list.ts new file mode 100644 index 000000000..ab99d7dc1 --- /dev/null +++ b/packages/commands/src/commands/alert/template-list.ts @@ -0,0 +1,136 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { ensureAlertReady, extractAlertPage } from "./shared.ts"; + +const LIST_TEMPLATES_API = "zeldaEasy.bailian-telemetry.alertTemplate.listAlertTemplates"; + +interface AlertCondition { + alertMetric?: string; + metricName?: string; + aggregator?: string; + compareType: string; + compareValue: string; + period: number; +} + +interface AlertTemplate { + templateId: string; + templateName: string; + source: string; + logicalOperator?: string; + conditions?: AlertCondition[]; + gmtModified?: number; +} + +function formatCondition(condition: AlertCondition): string { + const metric = condition.metricName ?? condition.alertMetric ?? "?"; + const agg = condition.aggregator ? `${condition.aggregator} ` : ""; + return `${agg}${metric} ${condition.compareType} ${condition.compareValue} (${condition.period}s)`; +} + +export default defineCommand({ + description: { + "en-US": "List alert templates (official and custom); pass --template-id for details", + "zh-CN": "查看告警模板(官方与自定义);传 --template-id 查看单个模板详情", + }, + auth: "console", + usageArgs: "[--name ] [--source ] [flags]", + flags: { + templateId: { + type: "string", + valueHint: "", + description: { + "en-US": "Exact template ID (view a single template)", + "zh-CN": "精确模板 ID(查看单个模板)", + }, + }, + name: { + type: "string", + valueHint: "", + description: { + "en-US": "Fuzzy filter by template name", + "zh-CN": "按模板名称模糊过滤", + }, + }, + source: { + type: "string", + valueHint: "", + choices: ["Official", "Customize"] as const, + description: { + "en-US": "Template source: Official, Customize; omit for all", + "zh-CN": "模板来源:Official、Customize;省略表示全部", + }, + }, + maxResults: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows per page (default: 50)", + "zh-CN": "每页数量(默认:50)", + }, + }, + skip: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows to skip (default: 0)", + "zh-CN": "跳过的记录数(默认:0)", + }, + }, + }, + exampleArgs: ["", "--source Official", "--name 失败率", "--template-id 123 --output json"], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { + resourceType: "model", + templateId: flags.templateId, + templateName: flags.name, + source: flags.source, + maxResults: flags.maxResults ?? 50, + skip: flags.skip ?? 0, + }; + + if (settings.dryRun) { + emitResult( + { + api: LIST_TEMPLATES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + const raw = await ctx.client.console(LIST_TEMPLATES_API, { reqDTO }); + const page = extractAlertPage(raw); + + if (format === "json") { + emitResult(page, format); + return; + } + + if (page.list.length === 0) { + process.stdout.write("No alert templates found.\n"); + return; + } + + const lines = renderBoxTable({ + headers: ["Template ID", "Name", "Source", "Conditions"], + rows: page.list.map((template) => [ + template.templateId, + template.templateName, + template.source === "Official" ? "Official" : "Custom", + (template.conditions ?? []) + .map(formatCondition) + .join(template.logicalOperator === "and" ? " AND " : " OR ") || "-", + ]), + align: ["left", "left", "left", "left"], + }); + for (const line of lines) process.stdout.write(line + "\n"); + }, +}); diff --git a/packages/commands/src/commands/alert/template-update.ts b/packages/commands/src/commands/alert/template-update.ts new file mode 100644 index 000000000..ee8cf3138 --- /dev/null +++ b/packages/commands/src/commands/alert/template-update.ts @@ -0,0 +1,92 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { ensureAlertReady, parseCondition, validateTemplateConditions } from "./shared.ts"; + +const UPDATE_TEMPLATE_API = "zeldaEasy.bailian-telemetry.alertTemplate.updateAlertTemplate"; + +export default defineCommand({ + description: { + "en-US": "Update a custom alert template (full replacement of conditions)", + "zh-CN": "更新自定义告警模板(整体替换条件)", + }, + auth: "console", + usageArgs: + "--template-id --name --condition ... [flags]", + flags: { + templateId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Template ID (from `alert template list`)", + "zh-CN": "模板 ID(可由 alert template list 获得)", + }, + }, + name: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Template name (max 64 chars)", + "zh-CN": "模板名称(最长 64 字符)", + }, + }, + condition: { + type: "array", + valueHint: "", + description: { + "en-US": "Alert condition, repeatable (1-10). Example: model_call_failed_count:sum:>:10:60", + "zh-CN": "告警条件,可重复(1-10 条)。示例:model_call_failed_count:sum:>:10:60", + }, + }, + logicalOperator: { + type: "string", + valueHint: "", + choices: ["or", "and"] as const, + description: { + "en-US": "How multiple conditions combine (default: or)", + "zh-CN": "多条件组合逻辑(默认:or)", + }, + }, + }, + exampleArgs: [ + "--template-id 123 --name 失败率告警 --condition model_call_failed_count:sum:>:20:60", + "--template-id 123 --name test --condition model_call_count:sum:>:100:60 --dry-run", + ], + validate: (flags) => validateTemplateConditions(flags.condition, false), + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { + templateId: flags.templateId, + templateName: flags.name, + resourceType: "model", + logicalOperator: flags.logicalOperator ?? "or", + conditions: (flags.condition ?? []).map(parseCondition), + }; + + if (settings.dryRun) { + emitResult( + { + api: UPDATE_TEMPLATE_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + await ctx.client.console(UPDATE_TEMPLATE_API, { reqDTO }); + + if (format === "json") { + emitResult({ updated: true, templateId: flags.templateId }, format); + return; + } + + process.stdout.write(`Alert template updated: ${flags.templateId}\n`); + }, +}); diff --git a/packages/commands/src/commands/alert/update.ts b/packages/commands/src/commands/alert/update.ts new file mode 100644 index 000000000..9fe951d0a --- /dev/null +++ b/packages/commands/src/commands/alert/update.ts @@ -0,0 +1,70 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { + ALERT_RULE_WRITE_FLAGS, + buildAlertRuleReqDTO, + ensureAlertReady, + validateAlertRuleFlags, +} from "./shared.ts"; + +const UPDATE_RULE_API = "zeldaEasy.bailian-telemetry.alertRule.updateAlertRule"; + +export default defineCommand({ + description: { + "en-US": "Update a model alert rule (full replacement; same fields as create)", + "zh-CN": "更新模型告警规则(整体替换,字段与 create 一致)", + }, + auth: "console", + usageArgs: "--rule-id --name --template-id --model [flags]", + flags: { + ruleId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Alert rule ID (from `alert list`)", + "zh-CN": "告警规则 ID(可由 alert list 获得)", + }, + }, + ...ALERT_RULE_WRITE_FLAGS, + }, + exampleArgs: [ + "--rule-id 789 --name high-failure-rate --template-id 123 --model qwen3.6-plus --level WARNING", + "--rule-id 789 --name nightly --template-id 123 --model qwen3.6-plus --silence 1800", + "--rule-id 789 --name test --template-id 123 --model qwen3.6-plus --dry-run", + ], + validate: (flags) => validateAlertRuleFlags(flags), + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { + ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), + ruleId: flags.ruleId, + ...buildAlertRuleReqDTO(flags), + }; + + if (settings.dryRun) { + emitResult( + { + api: UPDATE_RULE_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName); + + await ctx.client.console(UPDATE_RULE_API, { reqDTO }); + + if (format === "json") { + emitResult({ updated: true, ruleId: flags.ruleId }, format); + return; + } + + process.stdout.write(`Alert rule updated: ${flags.ruleId}\n`); + }, +}); diff --git a/packages/commands/src/commands/dataset/delete.ts b/packages/commands/src/commands/dataset/delete.ts index 520cfaf4e..cc8115c89 100644 --- a/packages/commands/src/commands/dataset/delete.ts +++ b/packages/commands/src/commands/dataset/delete.ts @@ -1,5 +1,5 @@ import { defineCommand, deleteDataset, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; const DELETE_FLAGS = { fileId: { @@ -8,14 +8,28 @@ const DELETE_FLAGS = { description: { "en-US": "Dataset file ID (required)", "zh-CN": "数据集文件 ID(必填)" }, required: true, }, + yes: { + type: "switch", + description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, + }, } satisfies FlagsDef; export default defineCommand({ description: { "en-US": "Delete a dataset file by ID", "zh-CN": "通过 ID 删除数据集文件" }, auth: "apiKey", - usageArgs: "--file-id ", + usageArgs: "--file-id [--yes]", flags: DELETE_FLAGS, - exampleArgs: ["--file-id file-id-xxx", "--file-id file-id-xxx --dry-run"], + exampleArgs: [ + "--file-id file-id-xxx", + "--file-id file-id-xxx --dry-run", + "--file-id file-id-xxx --yes", + ], + notes: [ + { + "en-US": "Irreversible — the dataset file is permanently removed.", + "zh-CN": "该操作不可撤销——数据集文件将被永久删除。", + }, + ], async run(ctx) { const { settings, flags } = ctx; const fileId = flags.fileId; @@ -25,6 +39,11 @@ export default defineCommand({ return; } + await confirmDangerousAction( + `Delete dataset file ${fileId}.\nThe file is permanently removed. This cannot be undone.`, + flags.yes ?? false, + ); + const response = await deleteDataset(ctx.client, fileId); if (settings.quiet) { diff --git a/packages/commands/src/commands/deploy/delete.ts b/packages/commands/src/commands/deploy/delete.ts index bb31298ae..60cb809be 100644 --- a/packages/commands/src/commands/deploy/delete.ts +++ b/packages/commands/src/commands/deploy/delete.ts @@ -6,7 +6,7 @@ import { ExitCode, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; const DELETE_FLAGS = { deployedModel: { @@ -25,6 +25,10 @@ const DELETE_FLAGS = { "zh-CN": "跳过本地 STOPPED/FAILED 状态预检查", }, }, + yes: { + type: "switch", + description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, + }, } satisfies FlagsDef; /** @@ -40,9 +44,19 @@ export default defineCommand({ "zh-CN": "删除模型部署(状态必须为 STOPPED 或 FAILED)", }, auth: "apiKey", - usageArgs: "--deployed-model [--skip-precheck]", + usageArgs: "--deployed-model [--skip-precheck] [--yes]", flags: DELETE_FLAGS, - exampleArgs: ["--deployed-model dep-...", "--deployed-model dep-... --dry-run"], + notes: [ + { + "en-US": "Irreversible — the deployment is permanently destroyed.", + "zh-CN": "该操作不可撤销——部署将被永久销毁。", + }, + ], + exampleArgs: [ + "--deployed-model dep-...", + "--deployed-model dep-... --dry-run", + "--deployed-model dep-... --yes", + ], async run(ctx) { const { settings, flags } = ctx; const deployedModel = flags.deployedModel; @@ -73,6 +87,11 @@ export default defineCommand({ } } + await confirmDangerousAction( + `Delete deployment ${deployedModel}.\nThe deployment is permanently destroyed. This cannot be undone.`, + flags.yes ?? false, + ); + const response = await deleteDeployment(ctx.client, deployedModel); if (settings.quiet) { diff --git a/packages/commands/src/commands/finetune/delete.ts b/packages/commands/src/commands/finetune/delete.ts index c5d7c8f90..19c1caae3 100644 --- a/packages/commands/src/commands/finetune/delete.ts +++ b/packages/commands/src/commands/finetune/delete.ts @@ -1,5 +1,5 @@ import { defineCommand, deleteFineTune, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime"; const DELETE_FLAGS = { jobId: { @@ -8,15 +8,23 @@ const DELETE_FLAGS = { description: { "en-US": "Fine-tune job ID (required)", "zh-CN": "微调任务 ID(必填)" }, required: true, }, + yes: { + type: "switch", + description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, + }, } satisfies FlagsDef; export default defineCommand({ description: { "en-US": "Delete a fine-tune job record", "zh-CN": "删除微调任务记录" }, auth: "apiKey", - usageArgs: "--job-id ", + usageArgs: "--job-id [--yes]", flags: DELETE_FLAGS, - exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --dry-run"], + exampleArgs: ["--job-id ft-xxx", "--job-id ft-xxx --dry-run", "--job-id ft-xxx --yes"], notes: [ + { + "en-US": "Irreversible — the job record is permanently removed.", + "zh-CN": "该操作不可撤销——任务记录将被永久删除。", + }, { "en-US": "Cancel a RUNNING job first via `finetune cancel` — the platform refuses to delete jobs that are still in flight.", @@ -32,6 +40,11 @@ export default defineCommand({ return; } + await confirmDangerousAction( + `Delete fine-tune job record ${jobId}.\nThe job record is permanently removed. This cannot be undone.`, + flags.yes ?? false, + ); + const response = await deleteFineTune(ctx.client, jobId); if (settings.quiet) { diff --git a/packages/commands/src/commands/log/count.ts b/packages/commands/src/commands/log/count.ts new file mode 100644 index 000000000..c08cb7252 --- /dev/null +++ b/packages/commands/src/commands/log/count.ts @@ -0,0 +1,68 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { formatNumber, formatDateTime } from "../shared/format.ts"; +import { + TELEMETRY_LOG_TIME_FLAGS, + TELEMETRY_FILTER_FLAGS, + buildTelemetryFilters, + resolveTimeRange, + unwrapConsolePrimitive, +} from "../shared/telemetry.ts"; + +const COUNT_LOGS_API = "zeldaEasy.bailian-telemetry.model.countModelLogs"; + +export default defineCommand({ + description: { + "en-US": "Count model call logs in a time range (useful before exporting)", + "zh-CN": "统计时间范围内的模型调用日志条数(导出前预估量级)", + }, + auth: "console", + usageArgs: "[--model ] [--hours ] [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + ...TELEMETRY_FILTER_FLAGS, + }, + exampleArgs: ["", "--model qwen3.6-plus --hours 24", "--output json"], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); + + const reqDTO = { + ...buildTelemetryFilters(flags, settings.workspaceId), + filterWorkspaceId: settings.workspaceId, + startTime, + endTime, + }; + + if (settings.dryRun) { + emitResult( + { + api: COUNT_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + const raw = await ctx.client.console(COUNT_LOGS_API, { reqDTO }); + const count = unwrapConsolePrimitive(raw); + + if (format === "json") { + emitResult( + { + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + count, + }, + format, + ); + return; + } + + process.stdout.write( + `Logs: ${formatNumber(count)} (${formatDateTime(startTime)} ~ ${formatDateTime(endTime)})\n`, + ); + }, +}); diff --git a/packages/commands/src/commands/log/disable.ts b/packages/commands/src/commands/log/disable.ts new file mode 100644 index 000000000..df9167e09 --- /dev/null +++ b/packages/commands/src/commands/log/disable.ts @@ -0,0 +1,54 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; + +const DISABLE_GROUP_API = "zeldaEasy.bailian-telemetry.telemetryGroup.disableTelemetryGroup"; + +export default defineCommand({ + description: { + "en-US": "Disable inference log delivery for all models in the workspace", + "zh-CN": "关闭当前业务空间全部模型的推理日志投递", + }, + auth: "console", + usageArgs: "[flags]", + notes: [ + { + "en-US": "Audit logs stay on; only the inference log (request/response content) is disabled.", + "zh-CN": "仅关闭推理日志(请求/响应内容),审计日志保持开启。", + }, + ], + exampleArgs: ["", "--dry-run", "--output json"], + async run(ctx) { + const { settings } = ctx; + const format = detectOutputFormat(settings.output); + + const reqDTO = { + ...(settings.workspaceId + ? { workspaceId: settings.workspaceId, filterWorkspaceId: settings.workspaceId } + : {}), + resourceId: "all", + resourceType: "model", + telemetryType: "InferenceLog", + }; + + if (settings.dryRun) { + emitResult( + { + api: DISABLE_GROUP_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ctx.client.console(DISABLE_GROUP_API, { reqDTO }); + + if (format === "json") { + emitResult({ disabled: true }, format); + return; + } + + process.stdout.write("Inference log delivery disabled.\n"); + }, +}); diff --git a/packages/commands/src/commands/log/enable.ts b/packages/commands/src/commands/log/enable.ts new file mode 100644 index 000000000..0746eb60f --- /dev/null +++ b/packages/commands/src/commands/log/enable.ts @@ -0,0 +1,126 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, emitResult } from "bailian-cli-runtime"; +import { + getTelemetryServiceStatus, + pollTelemetryData, + unwrapConsolePrimitive, +} from "../shared/telemetry.ts"; +import { fetchLogServiceStatus, LOG_SERVICE_TYPES } from "./status.ts"; + +const CREATE_SLR_API = "zeldaEasy.bailian-telemetry.activate.createTelemetrySlr"; +const INIT_STORE_API = "zeldaEasy.bailian-telemetry.activate.initTelemetryStoreInstance"; +const ENABLE_GROUP_API = "zeldaEasy.bailian-telemetry.telemetryGroup.enableTelemetryGroup"; + +const WAIT_INTERVAL_MS = 3000; +const WAIT_TIMEOUT_MS = 180_000; + +export default defineCommand({ + description: { + "en-US": "Enable inference log delivery to SLS (SLR authorization → SLS instance → log switch)", + "zh-CN": "开启推理日志投递到 SLS(SLR 授权 → SLS 实例初始化 → 打开日志开关)", + }, + auth: "console", + usageArgs: "[--no-wait] [flags]", + flags: { + noWait: { + type: "switch", + description: { + "en-US": "Return right after submitting, without waiting for the SLS instance", + "zh-CN": "提交后立即返回,不等待 SLS 实例就绪", + }, + }, + }, + notes: [ + { + "en-US": + "Steps: 1) authorize the SLS service-linked role, 2) initialize the SLS store instance (async), 3) turn on inference log delivery for all models in the workspace.", + "zh-CN": + "开启链路:1)授权 SLS 服务关联角色;2)初始化 SLS 存储实例(异步);3)为当前业务空间全部模型打开推理日志开关。", + }, + ], + exampleArgs: ["", "--no-wait", "--output json"], + async run(ctx) { + const { settings, flags, identity } = ctx; + const format = detectOutputFormat(settings.output); + const color = ansi(process.stderr); + + const groupReqDTO = { + ...(settings.workspaceId + ? { workspaceId: settings.workspaceId, filterWorkspaceId: settings.workspaceId } + : {}), + resourceId: "all", + resourceType: "model", + telemetryType: "InferenceLog", + }; + + if (settings.dryRun) { + emitResult( + { + apis: [CREATE_SLR_API, INIT_STORE_API, ENABLE_GROUP_API], + data: { reqDTO: groupReqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + // Step 1: SLS service-linked role. + const before = await fetchLogServiceStatus(ctx.client, settings.workspaceId); + if (!before.slrAuthorized) { + process.stderr.write(color.dim("Authorizing the SLS service-linked role...\n")); + const slrRaw = await ctx.client.console(CREATE_SLR_API, { + reqDTO: { + ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), + slrType: "Log", + }, + }); + if (unwrapConsolePrimitive(slrRaw) !== true) { + process.stderr.write(color.dim("SLR authorization submitted; continuing.\n")); + } + } + + // Step 2: SLS store instance (async creation, poll until Ready). + let inferenceStatus = before.inference; + if (inferenceStatus.instanceStatus === "NotExist" || !inferenceStatus.openStatus) { + process.stderr.write(color.dim("Initializing the SLS store instance...\n")); + await pollTelemetryData(ctx.client, INIT_STORE_API, { + ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), + serviceType: LOG_SERVICE_TYPES.inference, + }); + + if (!flags.noWait) { + process.stderr.write(color.dim("Waiting for the SLS instance to become ready...\n")); + const deadline = Date.now() + WAIT_TIMEOUT_MS; + inferenceStatus = await getTelemetryServiceStatus( + ctx.client, + LOG_SERVICE_TYPES.inference, + settings.workspaceId, + ); + while (Date.now() < deadline && inferenceStatus.instanceStatus !== "Ready") { + await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS)); + inferenceStatus = await getTelemetryServiceStatus( + ctx.client, + LOG_SERVICE_TYPES.inference, + settings.workspaceId, + ); + } + } + } + + // Step 3: turn on inference log delivery for all models. + await ctx.client.console(ENABLE_GROUP_API, { reqDTO: groupReqDTO }); + + if (format === "json") { + emitResult({ enabled: true, instanceStatus: inferenceStatus.instanceStatus }, format); + return; + } + + process.stdout.write("Inference log delivery enabled.\n"); + if (flags.noWait) { + process.stdout.write( + `The SLS instance may still be initializing; check \`${identity.binName} log status\` later.\n`, + ); + } + }, +}); diff --git a/packages/commands/src/commands/log/get.ts b/packages/commands/src/commands/log/get.ts new file mode 100644 index 000000000..284ab51b7 --- /dev/null +++ b/packages/commands/src/commands/log/get.ts @@ -0,0 +1,153 @@ +import { + defineCommand, + BailianError, + ExitCode, + detectOutputFormat, + effectiveConsoleGatewayConfig, +} from "bailian-cli-core"; +import { ansi, emitResult } from "bailian-cli-runtime"; +import { formatDateTime } from "../shared/format.ts"; +import { + TELEMETRY_LOG_TIME_FLAGS, + pollTelemetryData, + resolveTimeRange, +} from "../shared/telemetry.ts"; +import { parseLogUsage, type ModelLogEntry } from "./list.ts"; + +const LIST_LOGS_API = "zeldaEasy.bailian-telemetry.platform-model.listModelLogs"; +const ORIGIN_LOG_API = "zeldaEasy.bailian-telemetry.model.getModelOriginLog"; + +/** Request IDs are UUID-shaped; the gateway accepts 32-36 chars. */ +const REQUEST_ID_LENGTH = { min: 32, max: 36 }; + +function printLogDetail(entry: ModelLogEntry): void { + const color = ansi(process.stdout); + const usage = parseLogUsage(entry.usage); + + const rows: [string, string][] = [ + ["Request ID", entry.modelRequestId], + ["Model", entry.model ?? "-"], + ["Time", entry.startTime ? formatDateTime(entry.startTime) : "-"], + ["Status", entry.httpStatusCode != null ? String(entry.httpStatusCode) : "-"], + ["Duration", entry.callDuration != null ? `${entry.callDuration} ms` : "-"], + ["First Token", entry.firstTokenDuration != null ? `${entry.firstTokenDuration} ms` : "-"], + [ + "Tokens (in/out/total)", + `${usage.input_tokens ?? "-"}/${usage.output_tokens ?? "-"}/${usage.total_tokens ?? "-"}`, + ], + ]; + if (entry.apikeyId) rows.push(["API Key ID", entry.apikeyId]); + if (entry.errorCode) rows.push(["Error Code", entry.errorCode]); + if (entry.errorMessage) rows.push(["Error Message", entry.errorMessage]); + + for (const [label, value] of rows) { + process.stdout.write(`${color.bold(label)}: ${value}\n`); + } + if (entry.request) { + process.stdout.write(`\n${color.bold("Request:")}\n${entry.request}\n`); + } + if (entry.response) { + process.stdout.write(`\n${color.bold("Response:")}\n${entry.response}\n`); + } +} + +export default defineCommand({ + description: { + "en-US": "Show a single call log with full request/response content", + "zh-CN": "查看单次调用日志详情(含完整请求/响应内容)", + }, + auth: "console", + usageArgs: "--request-id [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + requestId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Model request ID (from `log list`)", + "zh-CN": "模型请求 ID(可由 log list 获得)", + }, + }, + model: { + type: "string", + valueHint: "", + description: { + "en-US": "Model name (narrows the search)", + "zh-CN": "模型名称(缩小查询范围)", + }, + }, + }, + exampleArgs: [ + "--request-id 6f6b2f1e-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "--request-id 6f6b2f1e-xxxx-xxxx-xxxx-xxxxxxxxxxxx --hours 24", + "--request-id 6f6b2f1e-xxxx-xxxx-xxxx-xxxxxxxxxxxx --output json", + ], + validate: (flags) => { + const length = flags.requestId.length; + if (length < REQUEST_ID_LENGTH.min || length > REQUEST_ID_LENGTH.max) { + return `--request-id must be ${REQUEST_ID_LENGTH.min}-${REQUEST_ID_LENGTH.max} characters.`; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + // A single request may sit far back; default window is 24h here. + const { startTime, endTime } = resolveTimeRange(flags, 1); + + const baseReqDTO: Record = { + ...(settings.workspaceId + ? { workspaceId: settings.workspaceId, filterWorkspaceId: settings.workspaceId } + : {}), + startTime, + endTime, + modelRequestId: flags.requestId, + }; + if (flags.model) baseReqDTO.models = [flags.model]; + + if (settings.dryRun) { + emitResult( + { + apis: [LIST_LOGS_API, ORIGIN_LOG_API], + data: { reqDTO: { ...baseReqDTO, needFullContent: true } }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + const resp = await pollTelemetryData(ctx.client, LIST_LOGS_API, { + ...baseReqDTO, + maxResults: 1, + skip: 0, + needFullContent: true, + }); + const entry = ((resp.list as ModelLogEntry[]) ?? [])[0]; + if (!entry) { + throw new BailianError( + `No log found for request ${flags.requestId} in the selected range.`, + ExitCode.GENERAL, + "Widen the window with --hours/--start-time, or check the request ID.", + ); + } + + const originResp = await pollTelemetryData(ctx.client, ORIGIN_LOG_API, baseReqDTO); + const originLog = originResp.originLog ?? originResp; + + if (format === "json") { + emitResult({ ...entry, originLog }, format); + return; + } + + printLogDetail(entry); + if (!entry.request && !entry.response) { + process.stdout.write( + ansi(process.stdout).dim( + "\nRequest/response content is only available when inference log delivery is enabled.\n", + ), + ); + } + }, +}); diff --git a/packages/commands/src/commands/log/list.ts b/packages/commands/src/commands/log/list.ts new file mode 100644 index 000000000..276f5f3c7 --- /dev/null +++ b/packages/commands/src/commands/log/list.ts @@ -0,0 +1,220 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { formatNumber, formatDateTime } from "../shared/format.ts"; +import { parseCommaList } from "../shared/params.ts"; +import { + TELEMETRY_LOG_TIME_FLAGS, + TELEMETRY_FILTER_FLAGS, + buildTelemetryFilters, + pollTelemetryData, + resolveTimeRange, +} from "../shared/telemetry.ts"; + +export const LIST_LOGS_API = "zeldaEasy.bailian-telemetry.platform-model.listModelLogs"; + +const STATUS_CODE_TYPES = ["SUCCESS", "CLIENT_ERROR", "SERVER_ERROR", "CANCEL"] as const; + +export interface ModelLogEntry { + modelRequestId: string; + model: string; + apiKey?: string; + apikeyId?: string; + request?: string; + response?: string; + startTime: number; + callDuration: number; + httpStatusCode: number; + errorCode?: string; + errorMessage?: string; + firstTokenDuration?: number; + usage?: { input_tokens?: string; output_tokens?: string; total_tokens?: string } | string; + channel?: string; + source?: string; +} + +/** usage arrives as a JSON string on some paths, an object on others. */ +export function parseLogUsage(usage: ModelLogEntry["usage"]): { + input_tokens?: string; + output_tokens?: string; + total_tokens?: string; +} { + if (!usage) return {}; + if (typeof usage === "string") { + try { + return JSON.parse(usage) as Record; + } catch { + return {}; + } + } + return usage; +} + +export function printLogTable(list: ModelLogEntry[]): void { + const color = ansi(process.stdout); + + if (list.length === 0) { + process.stdout.write("No logs found in this range.\n"); + return; + } + + const lines = renderBoxTable({ + headers: ["Time", "Request ID", "Model", "Status", "Duration", "First Token", "Tokens"], + rows: list.map((entry) => { + const usage = parseLogUsage(entry.usage); + const tokens = + usage.input_tokens != null || usage.output_tokens != null + ? `${usage.input_tokens ?? "-"}/${usage.output_tokens ?? "-"}` + : "-"; + return [ + formatDateTime(entry.startTime), + entry.modelRequestId ?? "-", + entry.model ?? "-", + entry.httpStatusCode != null ? String(entry.httpStatusCode) : "-", + entry.callDuration != null ? `${formatNumber(entry.callDuration)} ms` : "-", + entry.firstTokenDuration != null ? `${formatNumber(entry.firstTokenDuration)} ms` : "-", + tokens, + ]; + }), + align: ["left", "left", "left", "right", "right", "right", "right"], + cellColor: (_rowIndex, colIndex, value) => { + if (colIndex !== 3) return undefined; + if (value.startsWith("2")) return color.green(value); + if (value.startsWith("5")) return color.red(value); + if (value === "-") return undefined; + return color.yellow(value); + }, + }); + for (const line of lines) process.stdout.write(line + "\n"); +} + +export default defineCommand({ + description: { + "en-US": "Query model call logs (audit trail; use --full for request/response content)", + "zh-CN": "查询模型调用日志(审计口径;--full 携带请求/响应内容)", + }, + auth: "console", + usageArgs: "[--model ] [--hours ] [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + ...TELEMETRY_FILTER_FLAGS, + requestId: { + type: "string", + valueHint: "", + description: { + "en-US": "Exact model request ID", + "zh-CN": "精确匹配模型请求 ID", + }, + }, + statusCode: { + type: "string", + valueHint: "", + description: { + "en-US": `Status filter(s), comma-separated: ${STATUS_CODE_TYPES.join(", ")}`, + "zh-CN": `状态过滤,多个以逗号分隔:${STATUS_CODE_TYPES.join("、")}`, + }, + }, + full: { + type: "switch", + description: { + "en-US": "Include full request/response content (requires inference log delivery)", + "zh-CN": "携带完整请求/响应内容(需已开启推理日志投递)", + }, + }, + maxResults: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows per page (default: 20)", + "zh-CN": "每页数量(默认:20)", + }, + }, + skip: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows to skip (default: 0)", + "zh-CN": "跳过的记录数(默认:0)", + }, + }, + nextToken: { + type: "string", + valueHint: "", + description: { + "en-US": "Pagination token from a previous response", + "zh-CN": "上一次响应返回的分页标记", + }, + }, + }, + exampleArgs: [ + "", + "--model qwen3.6-plus --hours 3", + "--status-code SERVER_ERROR,CLIENT_ERROR", + "--request-id 6f6b2f1e-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "--full --model qwen3.6-plus --output json", + ], + validate: (flags) => { + if (flags.statusCode) { + const unknown = parseCommaList(flags.statusCode).filter( + (item) => !(STATUS_CODE_TYPES as readonly string[]).includes(item), + ); + if (unknown.length > 0) { + return `Unknown status code type: ${unknown.join(", ")}. Valid: ${STATUS_CODE_TYPES.join(", ")}.`; + } + } + if (flags.hours != null && flags.hours <= 0) { + return "--hours must be positive."; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); + + const reqDTO: Record = { + ...buildTelemetryFilters(flags, settings.workspaceId), + filterWorkspaceId: settings.workspaceId, + startTime, + endTime, + maxResults: flags.maxResults ?? 20, + skip: flags.skip ?? 0, + nextToken: flags.nextToken, + modelRequestId: flags.requestId, + needFullContent: flags.full || undefined, + statusCodeTypes: flags.statusCode ? parseCommaList(flags.statusCode) : undefined, + }; + + if (settings.dryRun) { + emitResult( + { + api: LIST_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + const resp = await pollTelemetryData(ctx.client, LIST_LOGS_API, reqDTO); + const list = (resp.list as ModelLogEntry[]) ?? []; + const nextToken = resp.nextToken as string | undefined; + + if (format === "json") { + emitResult( + { + totalCount: resp.totalCount ?? 0, + nextToken, + list, + }, + format, + ); + return; + } + + printLogTable(list); + if (nextToken) { + process.stdout.write(`Next page: --next-token ${nextToken}\n`); + } + }, +}); diff --git a/packages/commands/src/commands/log/status.ts b/packages/commands/src/commands/log/status.ts new file mode 100644 index 000000000..6519d0cd9 --- /dev/null +++ b/packages/commands/src/commands/log/status.ts @@ -0,0 +1,85 @@ +import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { ansi, emitResult, displayWidth, padEnd } from "bailian-cli-runtime"; +import { + getTelemetryServiceStatus, + unwrapConsolePrimitive, + type TelemetryServiceStatus, +} from "../shared/telemetry.ts"; + +const SLR_STATUS_API = "zeldaEasy.bailian-telemetry.activate.getTelemetrySlrStatus"; + +export const LOG_SERVICE_TYPES = { + audit: "ModelAuditLog", + inference: "ModelInferenceLog", +} as const; + +export interface LogServiceStatus { + slrAuthorized: boolean; + audit: TelemetryServiceStatus; + inference: TelemetryServiceStatus; +} + +export async function fetchLogServiceStatus( + client: Parameters[0], + workspaceId?: string, +): Promise { + const slrRaw = await client.console(SLR_STATUS_API, { + reqDTO: { ...(workspaceId ? { workspaceId } : {}), slrType: "Log" }, + }); + const slrAuthorized = unwrapConsolePrimitive(slrRaw) === true; + + const audit = await getTelemetryServiceStatus(client, LOG_SERVICE_TYPES.audit, workspaceId); + const inference = await getTelemetryServiceStatus( + client, + LOG_SERVICE_TYPES.inference, + workspaceId, + ); + + return { slrAuthorized, audit, inference }; +} + +function statusText(status: TelemetryServiceStatus): string { + if (!status.openStatus) return "Not activated"; + return status.instanceStatus ?? "-"; +} + +export default defineCommand({ + description: { + "en-US": "Show model log delivery status (SLS authorization, audit / inference log)", + "zh-CN": "查看模型日志投递状态(SLS 授权、审计日志 / 推理日志)", + }, + auth: "console", + usageArgs: "[flags]", + exampleArgs: ["", "--output json"], + async run(ctx) { + const { settings, identity } = ctx; + const format = detectOutputFormat(settings.output); + + const status = await fetchLogServiceStatus(ctx.client, settings.workspaceId); + + if (format === "json") { + emitResult(status, format); + return; + } + + const color = ansi(process.stdout); + const rows: [string, string][] = [ + ["SLS Authorization (SLR)", status.slrAuthorized ? "Authorized" : "Not authorized"], + ["Audit Log", statusText(status.audit)], + ["Inference Log", statusText(status.inference)], + ]; + const instanceUrl = status.inference.instanceInfo?.instanceUrl; + if (instanceUrl) rows.push(["SLS Instance URL", instanceUrl]); + + const maxLabel = Math.max(...rows.map(([label]) => displayWidth(label))); + for (const [label, value] of rows) { + process.stdout.write(`${color.bold(padEnd(label, maxLabel + 2))}${value}\n`); + } + + if (!status.inference.openStatus) { + process.stdout.write( + color.dim(`\nRun \`${identity.binName} log enable\` to enable inference log delivery.\n`), + ); + } + }, +}); diff --git a/packages/commands/src/commands/log/trace-get.ts b/packages/commands/src/commands/log/trace-get.ts new file mode 100644 index 000000000..a49212a58 --- /dev/null +++ b/packages/commands/src/commands/log/trace-get.ts @@ -0,0 +1,118 @@ +import { + defineCommand, + BailianError, + ExitCode, + detectOutputFormat, + effectiveConsoleGatewayConfig, +} from "bailian-cli-core"; +import { ansi, emitResult } from "bailian-cli-runtime"; +import { formatNumber, formatDateTime } from "../shared/format.ts"; +import { + TELEMETRY_LOG_TIME_FLAGS, + pollTelemetryData, + resolveOssPayload, + resolveTimeRange, +} from "../shared/telemetry.ts"; +import { TRACE_STATUS_LABELS, type TraceEntry } from "./trace-list.ts"; + +const GET_TRACE_API = "zeldaEasy.bailian-telemetry.trace.getTraceWithOss"; + +function printSpan(span: TraceEntry, depth: number): void { + const color = ansi(process.stdout); + const indent = " ".repeat(depth); + const status = TRACE_STATUS_LABELS[span.statusCode ?? "0"] ?? "-"; + const duration = + span.callDuration != null ? `${formatNumber(span.callDuration)} ms` : (span.duration ?? "-"); + const tokens = span.totalTokens != null ? `, ${formatNumber(span.totalTokens)} tokens` : ""; + const statusText = status === "Error" ? color.red(status) : status; + + process.stdout.write( + `${indent}${span.spanName ?? "-"} ${color.dim(`[${statusText}, ${duration}${tokens}]`)}\n`, + ); + if (span.statusCode === "2" && span.statusMessage) { + process.stdout.write(`${indent} ${color.red(span.statusMessage)}\n`); + } + for (const child of span.children ?? []) { + printSpan(child, depth + 1); + } +} + +export default defineCommand({ + description: { + "en-US": "Show a single call trace with its span tree", + "zh-CN": "查看单条调用链详情(含 span 树)", + }, + auth: "console", + usageArgs: "--trace-id [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + traceId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Trace ID (from `log trace list`)", + "zh-CN": "调用链 ID(可由 log trace list 获得)", + }, + }, + resourceId: { + type: "string", + valueHint: "", + description: { + "en-US": "Model name or app ID (narrows the search)", + "zh-CN": "模型名称或应用 ID(缩小查询范围)", + }, + }, + }, + exampleArgs: [ + "--trace-id 0a1b2c3d4e5f --hours 24", + "--trace-id 0a1b2c3d4e5f --resource-id qwen3.6-plus", + "--trace-id 0a1b2c3d4e5f --output json", + ], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const { startTime, endTime } = resolveTimeRange(flags, 1); + + const reqDTO = { + ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), + traceId: flags.traceId, + resourceId: flags.resourceId, + startTime, + endTime, + }; + + if (settings.dryRun) { + emitResult( + { + api: GET_TRACE_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + const resp = await pollTelemetryData(ctx.client, GET_TRACE_API, reqDTO); + const trace = await resolveOssPayload(resp); + if (!trace || !trace.traceId) { + throw new BailianError( + `No trace found for ${flags.traceId} in the selected range.`, + ExitCode.GENERAL, + "Widen the window with --hours/--start-time, or check the trace ID.", + ); + } + + if (format === "json") { + emitResult(trace, format); + return; + } + + const color = ansi(process.stdout); + process.stdout.write( + `${color.bold("Trace")} ${trace.traceId} ${color.dim(formatDateTime(trace.startTime))}\n\n`, + ); + printSpan(trace, 0); + }, +}); diff --git a/packages/commands/src/commands/log/trace-list.ts b/packages/commands/src/commands/log/trace-list.ts new file mode 100644 index 000000000..d869dd162 --- /dev/null +++ b/packages/commands/src/commands/log/trace-list.ts @@ -0,0 +1,163 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { formatNumber, formatDateTime } from "../shared/format.ts"; +import { + TELEMETRY_LOG_TIME_FLAGS, + pollTelemetryData, + resolveOssPayload, + resolveTimeRange, +} from "../shared/telemetry.ts"; + +const LIST_TRACES_API = "zeldaEasy.bailian-telemetry.trace.listTracesWithOss"; + +/** '0' = unset, '1' = ok, '2' = error (OpenTelemetry status code). */ +export const TRACE_STATUS_LABELS: Record = { + "0": "Unset", + "1": "OK", + "2": "Error", +}; + +export interface TraceEntry { + traceId: string; + spanId: string; + parentSpanId?: string; + spanName: string; + spanKind?: string; + resourceId?: string; + startTime: number; + duration?: string; + callDuration?: number; + totalTokens?: number; + statusCode?: "0" | "1" | "2"; + statusMessage?: string; + traceRequestId?: string; + children?: TraceEntry[]; +} + +interface TraceListPayload { + totalCount?: number; + list?: TraceEntry[]; +} + +function printTraceTable(list: TraceEntry[]): void { + const color = ansi(process.stdout); + + if (list.length === 0) { + process.stdout.write("No traces found in this range.\n"); + return; + } + + const lines = renderBoxTable({ + headers: ["Time", "Trace ID", "Span", "Status", "Duration", "Tokens"], + rows: list.map((trace) => [ + formatDateTime(trace.startTime), + trace.traceId ?? "-", + trace.spanName ?? "-", + TRACE_STATUS_LABELS[trace.statusCode ?? "0"] ?? trace.statusCode ?? "-", + trace.callDuration != null + ? `${formatNumber(trace.callDuration)} ms` + : (trace.duration ?? "-"), + trace.totalTokens != null ? formatNumber(trace.totalTokens) : "-", + ]), + align: ["left", "left", "left", "left", "right", "right"], + cellColor: (_rowIndex, colIndex, value) => { + if (colIndex !== 3) return undefined; + if (value === "Error") return color.red(value); + if (value === "OK") return color.green(value); + return undefined; + }, + }); + for (const line of lines) process.stdout.write(line + "\n"); +} + +export default defineCommand({ + description: { + "en-US": "List call traces for a model or app", + "zh-CN": "查询模型或应用的调用链列表", + }, + auth: "console", + usageArgs: "--resource-id [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + resourceId: { + type: "string", + valueHint: "", + required: true, + description: { + "en-US": "Model name or app ID to query traces for", + "zh-CN": "要查询调用链的模型名称或应用 ID", + }, + }, + resourceType: { + type: "string", + valueHint: "", + choices: ["model", "app"] as const, + description: { + "en-US": "Resource type: model, app (default: model)", + "zh-CN": "资源类型:model、app(默认:model)", + }, + }, + maxResults: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows per page (default: 20)", + "zh-CN": "每页数量(默认:20)", + }, + }, + skip: { + type: "number", + valueHint: "", + description: { + "en-US": "Rows to skip (default: 0)", + "zh-CN": "跳过的记录数(默认:0)", + }, + }, + }, + exampleArgs: [ + "--resource-id qwen3.6-plus", + "--resource-id qwen3.6-plus --hours 24 --max-results 50", + "--resource-id 123456 --resource-type app --output json", + ], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); + + const reqDTO = { + ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), + resourceId: flags.resourceId, + resourceType: flags.resourceType ?? "model", + startTime, + endTime, + maxResults: flags.maxResults ?? 20, + skip: flags.skip ?? 0, + nextToken: "", + }; + + if (settings.dryRun) { + emitResult( + { + api: LIST_TRACES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + const resp = await pollTelemetryData(ctx.client, LIST_TRACES_API, reqDTO); + // The payload is either the trace array itself or a { totalCount, list } wrapper. + const payload = await resolveOssPayload(resp); + const list = Array.isArray(payload) ? payload : (payload?.list ?? []); + const totalCount = Array.isArray(payload) ? undefined : payload?.totalCount; + + if (format === "json") { + emitResult({ totalCount, list }, format); + return; + } + + printTraceTable(list); + }, +}); diff --git a/packages/commands/src/commands/log/trace-stats.ts b/packages/commands/src/commands/log/trace-stats.ts new file mode 100644 index 000000000..0892a6c3f --- /dev/null +++ b/packages/commands/src/commands/log/trace-stats.ts @@ -0,0 +1,115 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult, renderBoxTable } from "bailian-cli-runtime"; +import { formatNumber, formatDateTime } from "../shared/format.ts"; +import { parseCommaList } from "../shared/params.ts"; +import { + TELEMETRY_LOG_TIME_FLAGS, + pollTelemetryData, + resolveTimeRange, +} from "../shared/telemetry.ts"; + +const TRACE_STATISTIC_API = "zeldaEasy.bailian-telemetry.trace.getTraceStatistic"; + +interface TraceStatistic { + resourceId: string; + resourceType: string; + resourceCount?: string | null; + callCount?: string; + totalTokens?: string; + avgDuration?: string; + avgFirstPacketLatency?: string; + avgLlmFirstTokenDuration?: string; + avgUserFirstTokenDuration?: string; +} + +export default defineCommand({ + description: { + "en-US": "Show per-resource trace statistics (calls, tokens, latency)", + "zh-CN": "查看调用链维度统计(调用量、Token、延迟)", + }, + auth: "console", + usageArgs: "[--resource-id ] [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + resourceId: { + type: "string", + valueHint: "", + description: { + "en-US": "Model name(s) or app ID(s), comma-separated; omit for all", + "zh-CN": "模型名称或应用 ID,多个以逗号分隔;省略表示全部", + }, + }, + resourceType: { + type: "string", + valueHint: "", + choices: ["model", "app"] as const, + description: { + "en-US": "Resource type: model, app (default: model)", + "zh-CN": "资源类型:model、app(默认:model)", + }, + }, + }, + exampleArgs: ["", "--resource-id qwen3.6-plus --hours 24", "--resource-type app --output json"], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); + + const reqDTO = { + ...(settings.workspaceId + ? { workspaceId: settings.workspaceId, filterWorkspaceId: settings.workspaceId } + : {}), + resourceIdList: flags.resourceId ? parseCommaList(flags.resourceId) : undefined, + resourceType: flags.resourceType ?? "model", + startTime, + endTime, + }; + + if (settings.dryRun) { + emitResult( + { + api: TRACE_STATISTIC_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + const resp = await pollTelemetryData(ctx.client, TRACE_STATISTIC_API, reqDTO); + // Keyed by resource id. + const items = Object.values(resp) as TraceStatistic[]; + + if (format === "json") { + emitResult( + { + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + list: items, + }, + format, + ); + return; + } + + if (items.length === 0) { + process.stdout.write("No trace statistics found in this range.\n"); + return; + } + + const lines = renderBoxTable({ + headers: ["Resource", "Calls", "Total Tokens", "Avg Duration", "Avg First Token"], + rows: items.map((item) => [ + item.resourceId ?? "-", + formatNumber(Number(item.callCount ?? 0)), + formatNumber(Number(item.totalTokens ?? 0)), + item.avgDuration ? `${formatNumber(Math.round(Number(item.avgDuration)))} ms` : "-", + item.avgLlmFirstTokenDuration + ? `${formatNumber(Math.round(Number(item.avgLlmFirstTokenDuration)))} ms` + : "-", + ]), + align: ["left", "right", "right", "right", "right"], + }); + for (const line of lines) process.stdout.write(line + "\n"); + }, +}); diff --git a/packages/commands/src/commands/model/code.ts b/packages/commands/src/commands/model/code.ts new file mode 100644 index 000000000..eb8995290 --- /dev/null +++ b/packages/commands/src/commands/model/code.ts @@ -0,0 +1,263 @@ +import { + BailianError, + ExitCode, + anonymousConsoleCall, + defineCommand, + detectOutputFormat, + fetchModelDetail, + type ModelGroupItem, + type ModelSampleCodeV2, + type ModelSampleSnippet, +} from "bailian-cli-core"; +import { emitBare, emitResult } from "bailian-cli-runtime"; +import { pickTrunkItems } from "./shared.ts"; + +/** `--api` values map onto the API-style keys used inside `sampleCodeV2`. */ +const API_STYLE_KEYS: Record = { + completions: "completionsAPI", + responses: "responsesAPI", +}; + +/** + * The Node.js snippet key is published as `node` on some models and `nodejs` on + * others, so accept either spelling regardless of which one a model uses. + */ +const LANG_ALIASES: Record = { node: "nodejs", nodejs: "node" }; + +const PREFERRED_SDK = "openai"; +const PREFERRED_API_STYLE = "completionsAPI"; +const PREFERRED_LANG = "python"; + +export interface ResolvedSample { + sdk: string; + apiStyle: string; + lang: string; + code: string; +} + +/** Human-readable inventory of what the payload actually offers. */ +export function describeSampleAvailability(sampleCode: ModelSampleCodeV2): string { + return Object.entries(sampleCode) + .map(([sdk, apiStyles]) => { + const styles = Object.entries(apiStyles ?? {}) + .map(([apiStyle, languages]) => `${apiStyle}: ${Object.keys(languages ?? {}).join(",")}`) + .join(" | "); + return `${sdk} (${styles})`; + }) + .join("; "); +} + +function firstKey(keys: string[], preferred: string): string | undefined { + return keys.includes(preferred) ? preferred : keys[0]; +} + +/** Honour the requested language, falling back to its alias spelling if published. */ +function resolveLangKey(languages: Record, requested: string): string { + if (languages[requested]) return requested; + const alias = LANG_ALIASES[requested]; + return alias && languages[alias] ? alias : requested; +} + +/** + * Pick one snippet out of the `sdk → apiStyle → lang` tree. Every requested + * level is validated against the payload, and a miss reports what *is* + * available rather than silently substituting a different language. + */ +export function resolveSample( + sampleCode: ModelSampleCodeV2, + requested: { sdk?: string; api?: string; lang?: string }, +): ResolvedSample { + const availability = describeSampleAvailability(sampleCode); + const sdkKeys = Object.keys(sampleCode); + if (sdkKeys.length === 0) { + throw new BailianError("This model publishes no SDK sample code.", ExitCode.GENERAL); + } + + const sdk = requested.sdk ?? firstKey(sdkKeys, PREFERRED_SDK) ?? sdkKeys[0]!; + if (!sampleCode[sdk]) { + throw new BailianError( + `Unknown --sdk "${requested.sdk}". Available: ${availability}`, + ExitCode.USAGE, + ); + } + + const apiStyles = sampleCode[sdk]!; + const styleKeys = Object.keys(apiStyles); + let apiStyle: string; + if (requested.api) { + apiStyle = API_STYLE_KEYS[requested.api] ?? requested.api; + if (!apiStyles[apiStyle]) { + throw new BailianError( + `Unknown --api "${requested.api}" for sdk "${sdk}". Available: ${availability}`, + ExitCode.USAGE, + ); + } + } else { + apiStyle = firstKey(styleKeys, PREFERRED_API_STYLE) ?? styleKeys[0]!; + } + + const languages = apiStyles[apiStyle]!; + const langKeys = Object.keys(languages); + const lang = requested.lang + ? resolveLangKey(languages, requested.lang) + : (firstKey(langKeys, PREFERRED_LANG) ?? langKeys[0]!); + if (!languages[lang]) { + throw new BailianError( + `Unknown --lang "${requested.lang}" for ${sdk}/${apiStyle}. Available: ${availability}`, + ExitCode.USAGE, + ); + } + + const code = languages[lang]?.code; + if (!code) { + throw new BailianError( + `No snippet published for ${sdk}/${apiStyle}/${lang}. Available: ${availability}`, + ExitCode.GENERAL, + ); + } + + return { sdk, apiStyle, lang, code }; +} + +/** Prefer the exact model asked for, then any trunk item that carries samples. */ +function pickSampleBearingItem( + items: ModelGroupItem[], + modelKey: string, +): ModelGroupItem | undefined { + const exact = items.find((item) => item.model === modelKey); + if (exact?.sampleCodeV2) return exact; + const trunk = pickTrunkItems(items).find((item) => item.sampleCodeV2); + return trunk ?? items.find((item) => item.sampleCodeV2); +} + +export default defineCommand({ + description: { + "en-US": "Print a ready-to-run SDK sample for calling a model", + "zh-CN": "打印调用指定模型的 SDK 示例代码", + }, + auth: "none", + usageArgs: "--model [--sdk ] [--api