diff --git a/README.md b/README.md index a041ca8f9..fe1550a21 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble | Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." | | Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." | | 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." | diff --git a/README.zh.md b/README.zh.md index a82226756..e64a3da4c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -116,6 +116,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex | 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” | | 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” | | 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” | +| 监控与告警 | “查看我的模型调用统计、失败明细和调用日志,并创建一条告警规则。” | | 模型选型 | “推荐一个适合图片理解和智能客服的模型。” | | 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” | diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index 0d8ea2c30..be97d0e64 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -77,6 +77,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 22cccf0e1..b69cec669 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -87,12 +87,47 @@ import { pipelineValidate, advisorRecommend, modelList, + modelSearch, + modelCode, workspaceList, quotaList, quotaUpdate, quotaDelete, quotaHistory, quotaCheck, + monitorOverview, + monitorModels, + monitorMetrics, + monitorErrors, + monitorDeliveryStatus, + monitorDeliveryEnable, + monitorDeliveryDisable, + logStatus, + logAuditList, + logAuditGet, + logAuditCount, + logAuditEnable, + logAuditDisable, + logInferenceList, + logInferenceGet, + logInferenceCount, + logInferenceEnable, + logInferenceDisable, + logTraceList, + logTraceGet, + logTraceStats, + alertMetrics, + alertTemplateList, + alertTemplateCreate, + alertTemplateUpdate, + alertTemplateDelete, + alertList, + alertCreate, + alertUpdate, + alertDelete, + alertEnable, + alertDisable, + alertHistory, permissionList, permissionGrant, permissionRevoke, @@ -324,12 +359,47 @@ 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 delete": quotaDelete, "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, + "monitor delivery disable": monitorDeliveryDisable, + "log status": logStatus, + "log audit list": logAuditList, + "log audit get": logAuditGet, + "log audit count": logAuditCount, + "log audit enable": logAuditEnable, + "log audit disable": logAuditDisable, + "log inference list": logInferenceList, + "log inference get": logInferenceGet, + "log inference count": logInferenceCount, + "log inference enable": logInferenceEnable, + "log inference disable": logInferenceDisable, + "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..860aed12f --- /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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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..29ccc7519 --- /dev/null +++ b/packages/commands/src/commands/alert/delete.ts @@ -0,0 +1,69 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } 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", + risk: { + level: "high", + message: { + "en-US": "This permanently deletes the specified alert rules and cannot be undone.", + "zh-CN": "该操作会永久删除指定的告警规则,且无法撤销。", + }, + }, + usageArgs: "--rule-id [,...] [--yes]", + flags: { + ruleId: { + type: "string", + valueHint: "[,...]", + required: true, + description: { + "en-US": "Rule ID(s) to delete, comma-separated", + "zh-CN": "要删除的规则 ID,多个以逗号分隔", + }, + }, + }, + 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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + const reqDTO = { ruleIds: parseCommaList(flags.ruleId) }; + + if (settings.dryRun) { + emitResult( + { + api: DELETE_RULES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + + 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..93e35619f --- /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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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..775da3aac --- /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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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..5a310a7eb --- /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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + 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, settings); + + 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..2e390e7e8 --- /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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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..9c742ac29 --- /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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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..ddcce2a90 --- /dev/null +++ b/packages/commands/src/commands/alert/shared.ts @@ -0,0 +1,309 @@ +import { UsageError, unwrapResponse, type Client, type FlagsDef } from "bailian-cli-core"; +import { parseCommaList } from "../shared/params.ts"; +import { ensureTelemetryReady, ensureTelemetryRegionSupported } 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, + settings: Parameters[0], +): Promise { + ensureTelemetryRegionSupported(settings); + 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. ` + + "If the value contains > or <, wrap the whole condition in quotes so the shell does not treat it as redirection.", + ); + } + 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..6dddaecd1 --- /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' (quote it: > is a shell metacharacter). See `alert metrics` for metric names", + "zh-CN": + "告警条件,可重复(1-10 条)。示例:'model_call_failed_count:sum:>:10:60'(含 > 等 shell 特殊字符,需加引号)。指标名见 `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:avg:>:3000:300' --condition 'model_call_5xx_count:sum:>:5:60' --logical-operator and", + "--name 我的模板 --from ", + "--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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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..72dd41bca --- /dev/null +++ b/packages/commands/src/commands/alert/template-delete.ts @@ -0,0 +1,75 @@ +import { + defineCommand, + detectOutputFormat, + effectiveConsoleGatewayConfig, + unwrapResponse, +} from "bailian-cli-core"; +import { emitResult } 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", + risk: { + level: "high", + message: { + "en-US": "This permanently deletes the specified alert templates and cannot be undone.", + "zh-CN": "该操作会永久删除指定的告警模板,且无法撤销。", + }, + }, + usageArgs: "--template-id [,...] [--yes]", + flags: { + templateId: { + type: "string", + valueHint: "[,...]", + required: true, + description: { + "en-US": "Template ID(s) to delete, comma-separated", + "zh-CN": "要删除的模板 ID,多个以逗号分隔", + }, + }, + }, + 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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + const reqDTO = { templateIds: parseCommaList(flags.templateId) }; + + if (settings.dryRun) { + emitResult( + { + api: DELETE_TEMPLATES_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + await ensureAlertReady(ctx.client, settings.workspaceId, ctx.identity.binName, settings); + + 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..32b408b1f --- /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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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..d73972ab7 --- /dev/null +++ b/packages/commands/src/commands/alert/template-update.ts @@ -0,0 +1,94 @@ +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' (quote it: > is a shell metacharacter)", + "zh-CN": + "告警条件,可重复(1-10 条)。示例:'model_call_failed_count:sum:>:10:60'(含 > 等 shell 特殊字符,需加引号)", + }, + }, + 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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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..07cf85e98 --- /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 = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; + + 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, settings); + + 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/finetune/delete.ts b/packages/commands/src/commands/finetune/delete.ts index e95ed9ce6..3cb7e474f 100644 --- a/packages/commands/src/commands/finetune/delete.ts +++ b/packages/commands/src/commands/finetune/delete.ts @@ -24,6 +24,10 @@ export default defineCommand({ flags: DELETE_FLAGS, 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.", diff --git a/packages/commands/src/commands/log/audit-count.ts b/packages/commands/src/commands/log/audit-count.ts new file mode 100644 index 000000000..5198260a5 --- /dev/null +++ b/packages/commands/src/commands/log/audit-count.ts @@ -0,0 +1,42 @@ +import { defineCommand, type FlagsDef } from "bailian-cli-core"; +import { TELEMETRY_LOG_TIME_FLAGS, resolveTimeRange } from "../shared/telemetry.ts"; +import { countLogs, validateHoursFlag, type LogCountFlags } from "./shared.ts"; + +const COUNT_FILTER_FLAGS = { + model: { + type: "string", + valueHint: "", + description: { + "en-US": "Model name(s), comma-separated", + "zh-CN": "模型名称,多个名称以逗号分隔", + }, + }, + apiKeyId: { + type: "string", + valueHint: "", + description: { + "en-US": "API key ID(s), comma-separated", + "zh-CN": "API Key ID,多个以逗号分隔", + }, + }, +} satisfies FlagsDef; + +export default defineCommand({ + description: { + "en-US": "Count model audit logs in a time range (useful before exporting)", + "zh-CN": "统计时间范围内的模型审计日志条数(导出前预估量级)", + }, + auth: "console", + usageArgs: "[--model ] [--hours ] [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + ...COUNT_FILTER_FLAGS, + }, + exampleArgs: ["", "--model qwen3.6-plus --hours 24", "--output json"], + validate: (flags) => validateHoursFlag(flags.hours as number | undefined), + async run(ctx) { + await countLogs(ctx, "audit", ctx.flags as LogCountFlags, () => + resolveTimeRange(ctx.flags as LogCountFlags, 1 / 24), + ); + }, +}); diff --git a/packages/commands/src/commands/log/audit-disable.ts b/packages/commands/src/commands/log/audit-disable.ts new file mode 100644 index 000000000..67a8c866b --- /dev/null +++ b/packages/commands/src/commands/log/audit-disable.ts @@ -0,0 +1,32 @@ +import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { disableLogDelivery } from "./shared.ts"; + +export default defineCommand({ + description: { + "en-US": "Disable audit log delivery for all models in the workspace", + "zh-CN": "关闭当前业务空间全部模型的审计日志投递", + }, + auth: "console", + usageArgs: "[flags]", + notes: [ + { + "en-US": + "Refused while the inference log is still enabled — disable the inference log first (the console enforces the same rule).", + "zh-CN": "推理日志仍开启时不允许单独关闭审计日志——请先关闭推理日志(与控制台规则一致)。", + }, + ], + exampleArgs: ["", "--dry-run", "--output json"], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + await disableLogDelivery( + { + client: ctx.client, + settings: ctx.settings, + binName: ctx.identity.binName, + format, + }, + "audit", + true, + ); + }, +}); diff --git a/packages/commands/src/commands/log/audit-enable.ts b/packages/commands/src/commands/log/audit-enable.ts new file mode 100644 index 000000000..909ec0c86 --- /dev/null +++ b/packages/commands/src/commands/log/audit-enable.ts @@ -0,0 +1,41 @@ +import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { LOG_NO_WAIT_FLAG, enableLogDelivery } from "./shared.ts"; +import { LOG_SERVICE_TYPES } from "./status.ts"; + +export default defineCommand({ + description: { + "en-US": "Enable audit log delivery to SLS (SLR authorization → SLS instance → log switch)", + "zh-CN": "开启审计日志投递到 SLS(SLR 授权 → SLS 实例初始化 → 打开日志开关)", + }, + auth: "console", + usageArgs: "[--no-wait] [flags]", + flags: LOG_NO_WAIT_FLAG, + notes: [ + { + "en-US": + "Steps: 1) authorize the SLS service-linked role, 2) initialize the SLS store instance (async), 3) turn on audit log delivery for all models in the workspace.", + "zh-CN": + "开启链路:1)授权 SLS 服务关联角色;2)初始化 SLS 存储实例(异步);3)为当前业务空间全部模型打开审计日志开关。", + }, + { + "en-US": + "The audit log records call metadata only and is required before enabling the inference log.", + "zh-CN": "审计日志只记录调用元数据,且是开启推理日志的前置条件。", + }, + ], + exampleArgs: ["", "--no-wait", "--output json"], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + await enableLogDelivery( + { + client: ctx.client, + settings: ctx.settings, + binName: ctx.identity.binName, + format, + noWait: ctx.flags.noWait, + }, + "audit", + LOG_SERVICE_TYPES.audit, + ); + }, +}); diff --git a/packages/commands/src/commands/log/audit-get.ts b/packages/commands/src/commands/log/audit-get.ts new file mode 100644 index 000000000..85ac1a095 --- /dev/null +++ b/packages/commands/src/commands/log/audit-get.ts @@ -0,0 +1,120 @@ +import { + defineCommand, + BailianError, + ExitCode, + detectOutputFormat, + effectiveConsoleGatewayConfig, +} from "bailian-cli-core"; +import { ansi, emitResult } from "bailian-cli-runtime"; +import { + TELEMETRY_LOG_TIME_FLAGS, + ensureTelemetryRegionSupported, + pollTelemetryData, + resolveTimeRange, +} from "../shared/telemetry.ts"; +import { + type ModelLogEntry, + printLogDetail, + validateHoursFlag, + validateRequestIdLength, +} from "./shared.ts"; +import { LIST_AUDIT_LOGS_API } from "./audit-list.ts"; + +export default defineCommand({ + description: { + "en-US": "Show a single audit log entry with its raw origin record", + "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 audit list`)", + "zh-CN": "模型请求 ID(可由 log audit 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", + ], + notes: [ + { + "en-US": + "Audit entries carry call metadata plus the raw audit record; request/response content lives in the inference log (`log inference get`).", + "zh-CN": + "审计详情包含调用元数据与原始审计记录;请求/响应内容在推理日志中(`log inference get`)。", + }, + ], + validate: (flags) => { + return validateRequestIdLength(flags.requestId) ?? validateHoursFlag(flags.hours); + }, + 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 reqDTO: Record = { + ...(settings.workspaceId + ? { workspaceId: settings.workspaceId, filterWorkspaceId: settings.workspaceId } + : {}), + startTime, + endTime, + modelRequestId: flags.requestId, + maxResults: 1, + skip: 0, + needFullContent: false, + }; + if (flags.model) reqDTO.models = [flags.model]; + + if (settings.dryRun) { + emitResult( + { + api: LIST_AUDIT_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + ensureTelemetryRegionSupported(settings); + const resp = await pollTelemetryData(ctx.client, LIST_AUDIT_LOGS_API, reqDTO); + const entry = ((resp.list as ModelLogEntry[]) ?? [])[0]; + if (!entry) { + throw new BailianError( + `No audit log found for request ${flags.requestId} in the selected range.`, + ExitCode.GENERAL, + "Widen the window with --hours/--start-time, or check the request ID.", + ); + } + + if (format === "json") { + emitResult(entry, format); + return; + } + + printLogDetail(entry); + if (entry.originLog) { + process.stdout.write( + `\n${ansi(process.stdout).bold("Origin record:")}\n${JSON.stringify(entry.originLog, null, 2)}\n`, + ); + } + }, +}); diff --git a/packages/commands/src/commands/log/audit-list.ts b/packages/commands/src/commands/log/audit-list.ts new file mode 100644 index 000000000..6977ddf75 --- /dev/null +++ b/packages/commands/src/commands/log/audit-list.ts @@ -0,0 +1,142 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { parseCommaList } from "../shared/params.ts"; +import { + TELEMETRY_LOG_TIME_FLAGS, + TELEMETRY_FILTER_FLAGS, + buildTelemetryFilters, + ensureTelemetryRegionSupported, + pollTelemetryData, + resolveTimeRange, +} from "../shared/telemetry.ts"; +import { + type ModelLogEntry, + printLogTable, + validateHoursFlag, + validateStatusCodeTypes, +} from "./shared.ts"; + +export const LIST_AUDIT_LOGS_API = "zeldaEasy.bailian-telemetry.platform-model.listModelLogs"; + +export default defineCommand({ + description: { + "en-US": "Query model audit logs (call metadata; request/response content is not recorded)", + "zh-CN": "查询模型审计日志(调用元数据;不含请求/响应内容)", + }, + 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: SUCCESS, CLIENT_ERROR, SERVER_ERROR, CANCEL", + "zh-CN": "状态过滤,多个以逗号分隔:SUCCESS、CLIENT_ERROR、SERVER_ERROR、CANCEL", + }, + }, + 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", + "--output json", + ], + notes: [ + { + "en-US": + "Audit logs carry call metadata only. For full request/response content, enable and query the inference log (`log inference list` / `log inference get`).", + "zh-CN": + "审计日志只记录调用元数据。需要完整请求/响应内容时,请开启并查询推理日志(`log inference list` / `log inference get`)。", + }, + ], + validate: (flags) => { + return validateStatusCodeTypes(flags.statusCode) ?? validateHoursFlag(flags.hours); + }, + 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: false, + statusCodeTypes: flags.statusCode ? parseCommaList(flags.statusCode) : undefined, + }; + + if (settings.dryRun) { + emitResult( + { + api: LIST_AUDIT_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + ensureTelemetryRegionSupported(settings); + const resp = await pollTelemetryData(ctx.client, LIST_AUDIT_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/inference-count.ts b/packages/commands/src/commands/log/inference-count.ts new file mode 100644 index 000000000..cf123c198 --- /dev/null +++ b/packages/commands/src/commands/log/inference-count.ts @@ -0,0 +1,48 @@ +import { defineCommand, type FlagsDef } from "bailian-cli-core"; +import { TELEMETRY_LOG_TIME_FLAGS, resolveTimeRange } from "../shared/telemetry.ts"; +import { countLogs, validateHoursFlag, type LogCountFlags } from "./shared.ts"; + +const COUNT_FILTER_FLAGS = { + model: { + type: "string", + valueHint: "", + description: { + "en-US": "Model name(s), comma-separated", + "zh-CN": "模型名称,多个名称以逗号分隔", + }, + }, + apiKeyId: { + type: "string", + valueHint: "", + description: { + "en-US": "API key ID(s), comma-separated", + "zh-CN": "API Key ID,多个以逗号分隔", + }, + }, +} satisfies FlagsDef; + +export default defineCommand({ + description: { + "en-US": "Count model inference logs in a time range (useful before exporting)", + "zh-CN": "统计时间范围内的模型推理日志条数(导出前预估量级)", + }, + auth: "console", + usageArgs: "[--model ] [--hours ] [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + ...COUNT_FILTER_FLAGS, + }, + exampleArgs: ["", "--model qwen3.6-plus --hours 24", "--output json"], + notes: [ + { + "en-US": "Only calls made while inference log delivery was enabled are counted.", + "zh-CN": "仅统计推理日志投递开启期间产生的调用。", + }, + ], + validate: (flags) => validateHoursFlag(flags.hours as number | undefined), + async run(ctx) { + await countLogs(ctx, "inference", ctx.flags as LogCountFlags, () => + resolveTimeRange(ctx.flags as LogCountFlags, 1 / 24), + ); + }, +}); diff --git a/packages/commands/src/commands/log/inference-disable.ts b/packages/commands/src/commands/log/inference-disable.ts new file mode 100644 index 000000000..d4ee4608a --- /dev/null +++ b/packages/commands/src/commands/log/inference-disable.ts @@ -0,0 +1,31 @@ +import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { disableLogDelivery } from "./shared.ts"; + +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": + "Only the inference log (request/response content) is disabled; the audit log keeps its own switch (`log audit disable`).", + "zh-CN": "仅关闭推理日志(请求/响应内容);审计日志有独立开关(`log audit disable`)。", + }, + ], + exampleArgs: ["", "--dry-run", "--output json"], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + await disableLogDelivery( + { + client: ctx.client, + settings: ctx.settings, + binName: ctx.identity.binName, + format, + }, + "inference", + ); + }, +}); diff --git a/packages/commands/src/commands/log/inference-enable.ts b/packages/commands/src/commands/log/inference-enable.ts new file mode 100644 index 000000000..895ea5846 --- /dev/null +++ b/packages/commands/src/commands/log/inference-enable.ts @@ -0,0 +1,43 @@ +import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { LOG_NO_WAIT_FLAG, enableLogDelivery, requireAuditLogEnabled } from "./shared.ts"; +import { LOG_SERVICE_TYPES } from "./status.ts"; + +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: LOG_NO_WAIT_FLAG, + 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)为当前业务空间全部模型打开推理日志开关。", + }, + { + "en-US": + "Requires the audit log to be enabled first (`log audit enable`) — the console enforces the same rule.", + "zh-CN": "需先开启审计日志(`log audit enable`)——与控制台规则一致。", + }, + ], + exampleArgs: ["", "--no-wait", "--output json"], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const enableContext = { + client: ctx.client, + settings: ctx.settings, + binName: ctx.identity.binName, + format, + noWait: ctx.flags.noWait, + }; + + if (!ctx.settings.dryRun) { + await requireAuditLogEnabled(ctx.client, ctx.settings.workspaceId, ctx.identity.binName); + } + + await enableLogDelivery(enableContext, "inference", LOG_SERVICE_TYPES.inference); + }, +}); diff --git a/packages/commands/src/commands/log/inference-get.ts b/packages/commands/src/commands/log/inference-get.ts new file mode 100644 index 000000000..1ad879abf --- /dev/null +++ b/packages/commands/src/commands/log/inference-get.ts @@ -0,0 +1,138 @@ +import { + defineCommand, + BailianError, + ExitCode, + detectOutputFormat, + effectiveConsoleGatewayConfig, +} from "bailian-cli-core"; +import { ansi, emitResult } from "bailian-cli-runtime"; +import { + TELEMETRY_LOG_TIME_FLAGS, + ensureTelemetryRegionSupported, + pollTelemetryData, + resolveTimeRange, +} from "../shared/telemetry.ts"; +import { + type ModelLogEntry, + printLogDetail, + validateHoursFlag, + validateRequestIdLength, +} from "./shared.ts"; +import { LIST_INFERENCE_LOGS_API } from "./inference-list.ts"; + +const ORIGIN_LOG_API = "zeldaEasy.bailian-telemetry.model.getModelOriginLog"; + +export default defineCommand({ + description: { + "en-US": "Show a single inference log entry 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 inference list`)", + "zh-CN": "模型请求 ID(可由 log inference 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", + ], + notes: [ + { + "en-US": + "The origin content is fetched back by request ID from the inference log store, so inference log delivery must have been enabled when the call happened.", + "zh-CN": "原始内容按请求 ID 从推理日志存储回捞,因此调用发生时须已开启推理日志投递。", + }, + ], + validate: (flags) => { + return validateRequestIdLength(flags.requestId) ?? validateHoursFlag(flags.hours); + }, + 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 } + : {}), + modelRequestId: flags.requestId, + }; + + if (settings.dryRun) { + emitResult( + { + apis: [LIST_INFERENCE_LOGS_API, ORIGIN_LOG_API], + data: { + reqDTO: { ...baseReqDTO, startTime: String(startTime), endTime: String(endTime) }, + }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + ensureTelemetryRegionSupported(settings); + const resp = await pollTelemetryData(ctx.client, LIST_INFERENCE_LOGS_API, { + ...baseReqDTO, + startTime: String(startTime), + endTime: String(endTime), + model: flags.model, + maxResults: 1, + skip: 0, + needFullContent: false, + }); + const entry = ((resp.list as ModelLogEntry[]) ?? [])[0]; + if (!entry) { + throw new BailianError( + `No inference log found for request ${flags.requestId} in the selected range.`, + ExitCode.GENERAL, + "Widen the window with --hours/--start-time, or check the request ID.", + ); + } + + // The list API never returns full content; fetch it back by request ID. + const originResp = await pollTelemetryData(ctx.client, ORIGIN_LOG_API, { + ...baseReqDTO, + startTime: String(startTime), + endTime: String(endTime), + ...(entry.taskUuid ? { taskUuid: entry.taskUuid } : {}), + }); + const origin = originResp.originLog as ModelLogEntry | undefined; + entry.request = entry.request ?? origin?.request; + entry.response = entry.response ?? origin?.response; + + if (format === "json") { + emitResult(entry, 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 was enabled at call time.\n", + ), + ); + } + }, +}); diff --git a/packages/commands/src/commands/log/inference-list.ts b/packages/commands/src/commands/log/inference-list.ts new file mode 100644 index 000000000..0c5b49011 --- /dev/null +++ b/packages/commands/src/commands/log/inference-list.ts @@ -0,0 +1,158 @@ +import { defineCommand, detectOutputFormat, effectiveConsoleGatewayConfig } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; +import { parseCommaList } from "../shared/params.ts"; +import { + TELEMETRY_LOG_TIME_FLAGS, + ensureTelemetryRegionSupported, + pollTelemetryData, + resolveTimeRange, +} from "../shared/telemetry.ts"; +import { + type ModelLogEntry, + printLogTable, + validateHoursFlag, + validateStatusCodeTypes, +} from "./shared.ts"; + +export const LIST_INFERENCE_LOGS_API = "zeldaEasy.bailian-telemetry.model.listModelLogs"; + +const STATUS_CODE_TYPES = ["SUCCESS", "CLIENT_ERROR", "SERVER_ERROR", "CANCEL"] as const; + +export default defineCommand({ + description: { + "en-US": + "Query model inference logs (requires inference log delivery; use `log inference get` for request/response content)", + "zh-CN": "查询模型推理日志(需已开启推理日志投递;请求/响应内容请用 `log inference get` 查看)", + }, + auth: "console", + usageArgs: "[--model ] [--hours ] [flags]", + flags: { + ...TELEMETRY_LOG_TIME_FLAGS, + model: { + type: "string", + valueHint: "", + description: { + "en-US": "Model name (the inference log API accepts a single model)", + "zh-CN": "模型名称(推理日志接口仅支持单个模型)", + }, + }, + apiKeyId: { + type: "string", + valueHint: "", + description: { + "en-US": "API key ID (the inference log API accepts a single ID)", + "zh-CN": "API Key ID(推理日志接口仅支持单个 ID)", + }, + }, + callSource: { + type: "string", + valueHint: "", + choices: ["Online", "Offline"] as const, + description: { + "en-US": "Inference type: Online, Offline", + "zh-CN": "推理类型:Online、Offline", + }, + }, + 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("、")}`, + }, + }, + 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: [ + "", + "--model qwen3.6-plus --hours 3", + "--status-code SERVER_ERROR,CLIENT_ERROR", + "--request-id 6f6b2f1e-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "--output json", + ], + notes: [ + { + "en-US": + "Requires inference log delivery (`log inference enable`), which itself requires the audit log. Entries here mirror the audit trail; full request/response content is fetched per request via `log inference get`.", + "zh-CN": + "需先开启推理日志投递(`log inference enable`),且推理日志依赖审计日志。列表与审计口径同构;完整请求/响应内容按请求 ID 通过 `log inference get` 回捞。", + }, + ], + validate: (flags) => { + return validateStatusCodeTypes(flags.statusCode) ?? validateHoursFlag(flags.hours); + }, + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + const { startTime, endTime } = resolveTimeRange(flags, 1 / 24); + + // The inference log API takes single model/apikey values and string timestamps. + const reqDTO: Record = { + ...(settings.workspaceId + ? { workspaceId: settings.workspaceId, filterWorkspaceId: settings.workspaceId } + : {}), + startTime: String(startTime), + endTime: String(endTime), + model: flags.model, + apikeyId: flags.apiKeyId, + modelCallSource: flags.callSource, + modelRequestId: flags.requestId, + maxResults: flags.maxResults ?? 20, + skip: flags.skip ?? 0, + needFullContent: false, + statusCodeTypes: flags.statusCode ? parseCommaList(flags.statusCode) : undefined, + }; + + if (settings.dryRun) { + emitResult( + { + api: LIST_INFERENCE_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + ensureTelemetryRegionSupported(settings); + const resp = await pollTelemetryData(ctx.client, LIST_INFERENCE_LOGS_API, reqDTO); + const list = (resp.list as ModelLogEntry[]) ?? []; + + if (format === "json") { + emitResult( + { + totalCount: resp.totalCount ?? 0, + list, + }, + format, + ); + return; + } + + printLogTable(list); + }, +}); diff --git a/packages/commands/src/commands/log/shared.ts b/packages/commands/src/commands/log/shared.ts new file mode 100644 index 000000000..1f63feaf0 --- /dev/null +++ b/packages/commands/src/commands/log/shared.ts @@ -0,0 +1,428 @@ +import { + BailianError, + ExitCode, + detectOutputFormat, + effectiveConsoleGatewayConfig, + type Client, + type FlagsDef, +} 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 { + buildGroupSwitchReqDTO, + ensureTelemetryRegionSupported, + ensureTelemetrySlrAuthorized, + ENABLE_GROUP_API, + DISABLE_GROUP_API, + getTelemetryGroupSwitch, + getTelemetryServiceStatus, + pollTelemetryData, + unwrapConsolePrimitive, +} from "../shared/telemetry.ts"; +import type { Settings } from "bailian-cli-core"; + +// --------------------------------------------------------------------------- +// Log query types (shared by audit / inference list & get) +// --------------------------------------------------------------------------- + +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; + /** Async-task correlation id echoed on some rows; needed to fetch origin logs. */ + taskUuid?: string; + /** Raw audit record attached to platform-model rows (audit detail view). */ + originLog?: Record; +} + +/** 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 const STATUS_CODE_TYPES = ["SUCCESS", "CLIENT_ERROR", "SERVER_ERROR", "CANCEL"] as const; + +export function validateStatusCodeTypes(statusCode: string | undefined): string | undefined { + if (!statusCode) return undefined; + const unknown = parseCommaList(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(", ")}.`; + } + return undefined; +} + +/** Request IDs are UUID-shaped; the gateway accepts 32-36 chars. */ +export const REQUEST_ID_LENGTH = { min: 32, max: 36 }; + +export function validateRequestIdLength(requestId: string): string | undefined { + const length = 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; +} + +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 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`); + } +} + +// --------------------------------------------------------------------------- +// Log delivery switches (telemetry group) +// --------------------------------------------------------------------------- + +/** telemetryGroup switch types; distinct from the activate serviceType names. */ +export const LOG_GROUP_TYPES = { + audit: "AuditLog", + inference: "InferenceLog", +} as const; + +export type LogKind = keyof typeof LOG_GROUP_TYPES; + +const CREATE_SLR_API = "zeldaEasy.bailian-telemetry.activate.createTelemetrySlr"; +const INIT_STORE_API = "zeldaEasy.bailian-telemetry.activate.initTelemetryStoreInstance"; + +const WAIT_INTERVAL_MS = 3000; +const WAIT_TIMEOUT_MS = 180_000; + +/** + * Read the delivery switch of one log kind. The audit/inference kinds map + * onto group telemetryType names distinct from the activate serviceType names. + */ +export async function getLogSwitch( + client: Client, + kind: LogKind, + workspaceId?: string, +): Promise { + return getTelemetryGroupSwitch(client, LOG_GROUP_TYPES[kind], workspaceId); +} + +/** + * Inference log delivery builds on the audit log; gate it like the console + * does. The switch state can lag a few seconds right after `log audit enable` + * returns, so retry briefly before declaring the audit log off. + */ +export async function requireAuditLogEnabled( + client: Client, + workspaceId: string | undefined, + binName: string, +): Promise { + let auditSwitch = await getLogSwitch(client, "audit", workspaceId); + for (let attempt = 0; attempt < 3 && auditSwitch !== true; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + auditSwitch = await getLogSwitch(client, "audit", workspaceId); + } + if (auditSwitch === true) return; + throw new BailianError( + "Model audit log is not enabled yet.", + ExitCode.USAGE, + `Inference log delivery requires the audit log. Run \`${binName} log audit enable\` first, then check \`${binName} log status\`.`, + ); +} + +function groupReqDTO(settings: Settings, kind: LogKind): Record { + return buildGroupSwitchReqDTO(settings, LOG_GROUP_TYPES[kind]); +} + +// --------------------------------------------------------------------------- +// Enable / disable orchestration +// --------------------------------------------------------------------------- + +export interface LogEnableContext { + client: Client; + settings: Settings; + binName: string; + format: "json" | "text"; + noWait?: boolean; +} + +/** + * Enable one log delivery switch, mirroring the console flow: + * 1) authorize the SLS service-linked role, 2) initialize the SLS store + * instance when missing (async), 3) flip the telemetry group switch. + */ +export async function enableLogDelivery( + ctx: LogEnableContext, + kind: LogKind, + serviceType: string, +): Promise { + const { client, settings, format } = ctx; + const color = ansi(process.stderr); + + if (settings.dryRun) { + emitResult( + { + apis: [CREATE_SLR_API, INIT_STORE_API, ENABLE_GROUP_API], + data: { reqDTO: groupReqDTO(settings, kind) }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + ensureTelemetryRegionSupported(settings); + + // Step 1: SLS service-linked role. + await ensureTelemetrySlrAuthorized(client, "Log", settings.workspaceId, { + pollRetries: 5, + onProgress: (message) => process.stderr.write(color.dim(message + "\n")), + }); + + // Step 2: SLS store instance (async creation, poll until Ready). + let serviceStatus = await getTelemetryServiceStatus(client, serviceType, settings.workspaceId); + if (serviceStatus.instanceStatus === "NotExist") { + process.stderr.write(color.dim("Initializing the SLS store instance...\n")); + await pollTelemetryData(client, INIT_STORE_API, { + ...(settings.workspaceId ? { workspaceId: settings.workspaceId } : {}), + serviceType, + }); + + if (!ctx.noWait) { + process.stderr.write(color.dim("Waiting for the SLS instance to become ready...\n")); + const deadline = Date.now() + WAIT_TIMEOUT_MS; + while (Date.now() < deadline && serviceStatus.instanceStatus !== "Ready") { + await new Promise((resolve) => setTimeout(resolve, WAIT_INTERVAL_MS)); + serviceStatus = await getTelemetryServiceStatus(client, serviceType, settings.workspaceId); + } + } + } + + // Step 3: flip the delivery switch for every model in the workspace. + await client.console(ENABLE_GROUP_API, { reqDTO: groupReqDTO(settings, kind) }); + + if (format === "json") { + emitResult({ enabled: true, kind, instanceStatus: serviceStatus.instanceStatus }, format); + return; + } + + process.stdout.write(`${kind === "audit" ? "Audit" : "Inference"} log delivery enabled.\n`); + if (ctx.noWait) { + process.stdout.write( + `The SLS instance may still be initializing; check \`${ctx.binName} log status\` later.\n`, + ); + } +} + +/** + * Disable one log delivery switch. The console refuses to turn off the audit + * log while inference is still on — enforce the same rule client-side. + */ +export async function disableLogDelivery( + ctx: LogEnableContext, + kind: LogKind, + guardInferenceOn?: boolean, +): Promise { + const { client, settings, format } = ctx; + + if (settings.dryRun) { + emitResult( + { + api: DISABLE_GROUP_API, + data: { reqDTO: groupReqDTO(settings, kind) }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + ensureTelemetryRegionSupported(settings); + + if (guardInferenceOn) { + const inferenceSwitch = await getLogSwitch(client, "inference", settings.workspaceId); + if (inferenceSwitch === true) { + throw new BailianError( + "The audit log cannot be disabled while the inference log is enabled.", + ExitCode.USAGE, + `Run \`${ctx.binName} log inference disable\` first, then disable the audit log.`, + ); + } + } + + await client.console(DISABLE_GROUP_API, { reqDTO: groupReqDTO(settings, kind) }); + + if (format === "json") { + emitResult({ disabled: true, kind }, format); + return; + } + + process.stdout.write(`${kind === "audit" ? "Audit" : "Inference"} log delivery disabled.\n`); +} + +/** Shared --no-wait flag used by both enable commands. */ +export const LOG_NO_WAIT_FLAG = { + noWait: { + type: "switch", + description: { + "en-US": "Return right after submitting, without waiting for the SLS instance", + "zh-CN": "提交后立即返回,不等待 SLS 实例就绪", + }, + }, +} satisfies FlagsDef; + +export function validateHoursFlag(hours: number | undefined): string | undefined { + if (hours != null && hours <= 0) return "--hours must be positive."; + return undefined; +} + +// --------------------------------------------------------------------------- +// Log count (shared by audit / inference) +// --------------------------------------------------------------------------- + +export const COUNT_LOGS_API = "zeldaEasy.bailian-telemetry.model.countModelLogs"; + +export interface LogCountFlags { + hours?: number; + startTime?: string; + endTime?: string; + model?: string; + apiKeyId?: string; +} + +/** Count logs in a range for one log kind (the API takes a telemetryType). */ +export async function countLogs( + ctx: { client: Client; settings: Settings }, + kind: LogKind, + flags: LogCountFlags, + resolveRange: () => { startTime: number; endTime: number }, +): Promise { + const { settings } = ctx; + const format = detectOutputFormat(settings.output); + const { startTime, endTime } = resolveRange(); + + const reqDTO: Record = { + ...(settings.workspaceId + ? { workspaceId: settings.workspaceId, filterWorkspaceId: settings.workspaceId } + : {}), + startTime, + endTime, + telemetryType: LOG_GROUP_TYPES[kind], + models: flags.model ? parseCommaList(flags.model) : undefined, + apikeyIds: flags.apiKeyId ? parseCommaList(flags.apiKeyId) : undefined, + }; + + if (settings.dryRun) { + emitResult( + { + api: COUNT_LOGS_API, + data: { reqDTO }, + ...effectiveConsoleGatewayConfig(settings), + }, + format, + ); + return; + } + + ensureTelemetryRegionSupported(settings); + + const raw = await ctx.client.console(COUNT_LOGS_API, { reqDTO }); + const count = unwrapConsolePrimitive(raw); + + if (format === "json") { + emitResult( + { + kind, + period: { start: formatDateTime(startTime), end: formatDateTime(endTime) }, + count, + }, + format, + ); + return; + } + + process.stdout.write( + `${kind === "audit" ? "Audit" : "Inference"} logs: ${formatNumber(count)} (${formatDateTime(startTime)} ~ ${formatDateTime(endTime)})\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..e67a42350 --- /dev/null +++ b/packages/commands/src/commands/log/status.ts @@ -0,0 +1,112 @@ +import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { ansi, emitResult, displayWidth, padEnd } from "bailian-cli-runtime"; +import { + ensureTelemetryRegionSupported, + getTelemetryServiceStatus, + unwrapConsolePrimitive, + type TelemetryServiceStatus, +} from "../shared/telemetry.ts"; +import { getLogSwitch } from "./shared.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; + /** Delivery switch per kind: true = on, false = off, null = never configured. */ + auditSwitch: boolean | null; + inferenceSwitch: boolean | null; +} + +export async function fetchLogServiceStatus( + client: Parameters[0], + workspaceId?: string, +): Promise { + // All five lookups are independent reads; fan them out in parallel. + const [slrRaw, audit, inference, auditSwitch, inferenceSwitch] = await Promise.all([ + client.console(SLR_STATUS_API, { + reqDTO: { ...(workspaceId ? { workspaceId } : {}), slrType: "Log" }, + }), + getTelemetryServiceStatus(client, LOG_SERVICE_TYPES.audit, workspaceId), + getTelemetryServiceStatus(client, LOG_SERVICE_TYPES.inference, workspaceId), + getLogSwitch(client, "audit", workspaceId), + getLogSwitch(client, "inference", workspaceId), + ]); + const slrAuthorized = unwrapConsolePrimitive(slrRaw) === true; + + return { slrAuthorized, audit, inference, auditSwitch, inferenceSwitch }; +} + +function statusText(status: TelemetryServiceStatus): string { + if (!status.openStatus) return "Not activated"; + return status.instanceStatus ?? "-"; +} + +function switchText(enabled: boolean | null): string { + if (enabled === null) return "Never configured"; + return enabled ? "On" : "Off"; +} + +export default defineCommand({ + description: { + "en-US": + "Show model log delivery status (SLS authorization, audit / inference service and switches)", + "zh-CN": "查看模型日志投递状态(SLS 授权、审计 / 推理日志的服务与开关状态)", + }, + auth: "console", + usageArgs: "[flags]", + exampleArgs: ["", "--output json"], + notes: [ + { + "en-US": + "Both log kinds have independent switches; the inference log additionally requires the audit log to stay on.", + "zh-CN": "审计与推理日志各有独立开关;推理日志还要求审计日志保持开启。", + }, + ], + async run(ctx) { + const { settings, identity } = ctx; + const format = detectOutputFormat(settings.output); + + ensureTelemetryRegionSupported(settings); + 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 (service)", statusText(status.audit)], + ["Audit Log (switch)", switchText(status.auditSwitch)], + ["Inference Log (service)", statusText(status.inference)], + ["Inference Log (switch)", switchText(status.inferenceSwitch)], + ]; + 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.auditSwitch !== true) { + process.stdout.write( + color.dim(`\nRun \`${identity.binName} log audit enable\` to enable audit log delivery.\n`), + ); + } else if (status.inferenceSwitch !== true) { + process.stdout.write( + color.dim( + `\nRun \`${identity.binName} log inference 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..cd4252e39 --- /dev/null +++ b/packages/commands/src/commands/log/trace-get.ts @@ -0,0 +1,120 @@ +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, + ensureTelemetryRegionSupported, + 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; + } + + ensureTelemetryRegionSupported(settings); + 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..900ea21ac --- /dev/null +++ b/packages/commands/src/commands/log/trace-list.ts @@ -0,0 +1,165 @@ +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, + ensureTelemetryRegionSupported, + 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; + } + + ensureTelemetryRegionSupported(settings); + 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..93910e47e --- /dev/null +++ b/packages/commands/src/commands/log/trace-stats.ts @@ -0,0 +1,117 @@ +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, + ensureTelemetryRegionSupported, + 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; + } + + ensureTelemetryRegionSupported(settings); + 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