From c0d30fee3d88205ace406bd1fff647f601d80de1 Mon Sep 17 00:00:00 2001 From: wb-liuxuehuan Date: Tue, 23 Jun 2026 14:05:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(tokenplan):=20=E6=B7=BB=E5=8A=A0=20tokenpl?= =?UTF-8?q?an=20seats=20=E5=91=BD=E4=BB=A4=E4=BB=A5=E5=88=97=E5=87=BA?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=BA=A7=E4=BD=8D=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 tokenplan seats 命令,支持分页和状态过滤,提供详细的座位信息查询功能。相关文档已更新。 --- packages/cli/src/commands/catalog.ts | 2 + packages/cli/src/commands/tokenplan/seats.ts | 201 +++++++++++++++++++ packages/cli/src/main.ts | 1 + packages/cli/tests/e2e/helpers.ts | 9 + packages/cli/tests/e2e/tokenplan.e2e.test.ts | 96 +++++++++ packages/core/src/client/ak-sign.ts | 31 ++- packages/core/src/client/endpoints.ts | 13 ++ packages/core/src/client/index.ts | 3 +- packages/core/src/types/api.ts | 38 ++++ skills/bailian-cli/reference/index.md | 2 + skills/bailian-cli/reference/tokenplan.md | 52 +++++ 11 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/tokenplan/seats.ts create mode 100644 packages/cli/tests/e2e/tokenplan.e2e.test.ts create mode 100644 skills/bailian-cli/reference/tokenplan.md diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index ae48fcc..948d695 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -46,6 +46,7 @@ import quotaList from "./quota/list.ts"; import quotaRequest from "./quota/request.ts"; import quotaHistory from "./quota/history.ts"; import quotaCheck from "./quota/check.ts"; +import tokenplanSeats from "./tokenplan/seats.ts"; /** Command registry map (no dependency on registry.ts — safe for build-time import). */ export const commands: Record = { @@ -94,5 +95,6 @@ export const commands: Record = { "quota request": quotaRequest, "quota history": quotaHistory, "quota check": quotaCheck, + "tokenplan seats": tokenplanSeats, update: update, }; diff --git a/packages/cli/src/commands/tokenplan/seats.ts b/packages/cli/src/commands/tokenplan/seats.ts new file mode 100644 index 0000000..16ff409 --- /dev/null +++ b/packages/cli/src/commands/tokenplan/seats.ts @@ -0,0 +1,201 @@ +import { + defineCommand, + buildCanonicalQuery, + signRequest, + modelStudioHost, + detectOutputFormat, + maskToken, + trackingHeaders, + type Config, + type GlobalFlags, + type GetSubscriptionSeatDetailsResponse, + type TokenPlanSeatDetail, + BailianError, + ExitCode, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "../../output/output.ts"; +import { padEnd } from "../../output/cjk-width.ts"; + +const API_VERSION = "2026-02-10"; +const API_ACTION = "GetSubscriptionSeatDetails"; +const API_PATH = "/tokenplan/subscription/seat-detail"; + +export default defineCommand({ + name: "tokenplan seats", + description: "List Token Plan subscription seat details", + usage: "bl tokenplan seats [flags]", + options: [ + { flag: "--page-no ", description: "Page number (default: 1)", type: "number" }, + { flag: "--page-size ", description: "Page size (default: 10)", type: "number" }, + { + flag: "--caller-uac-account-id ", + description: "Caller UAC account ID", + }, + { + flag: "--namespace-id ", + description: "Product namespace ID (Token Plan default: namespace-1)", + }, + { + flag: "--status ", + description: + "Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED", + type: "array", + }, + { + flag: "--status-list-str ", + description: "StatusList as JSON string, e.g. '[\"NORMAL\"]'", + }, + { flag: "--seat-id ", description: "Filter by seat ID" }, + { + flag: "--seat-type ", + description: "Seat tier: standard, pro, or max", + }, + { + flag: "--query-assigned ", + description: "Filter by assignment: true=assigned, false=unassigned", + }, + { flag: "--access-key-id ", description: "Alibaba Cloud Access Key ID (deprecated)" }, + { + flag: "--access-key-secret ", + description: "Alibaba Cloud Access Key Secret (deprecated)", + }, + ], + examples: [ + "bl tokenplan seats", + "bl tokenplan seats --page-size 20 --status NORMAL", + "bl tokenplan seats --query-assigned true --seat-type standard", + ], + async run(config: Config, flags: GlobalFlags) { + const format = detectOutputFormat(config.output); + const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId; + const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret; + + if (!accessKeyId || !accessKeySecret) { + throw new BailianError( + "No credentials found.\n" + + "Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.", + ExitCode.AUTH, + ); + } + + const queryParams = buildQueryParams(flags); + const queryString = buildCanonicalQuery(queryParams); + const host = modelStudioHost(config.region); + const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`; + + if (config.dryRun) { + emitResult({ endpoint, query: queryParams }, format); + return; + } + + const headers = signRequest({ + accessKeyId, + accessKeySecret, + action: API_ACTION, + version: API_VERSION, + body: "", + host, + pathname: API_PATH, + method: "GET", + queryString, + }); + + if (config.verbose) { + process.stderr.write(`> GET ${endpoint}\n`); + process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`); + } + + const timeoutMs = config.timeout * 1000; + const res = await fetch(endpoint, { + method: "GET", + headers: { ...headers, ...trackingHeaders() }, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (config.verbose) { + process.stderr.write(`< ${res.status} ${res.statusText}\n`); + } + + const data = (await res.json()) as GetSubscriptionSeatDetailsResponse; + + if (!res.ok || data.Success === false) { + throw new BailianError( + `${data.Code || res.status} - ${data.Message || res.statusText}`, + ExitCode.GENERAL, + ); + } + + const items = data.Data?.Items ?? []; + if (config.quiet || format === "text") { + emitTextSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize); + } else { + emitResult(data, format); + } + }, +}); + +function buildQueryParams(flags: GlobalFlags): Record { + const params: Record = {}; + + if (flags.pageNo !== undefined) params.PageNo = String(flags.pageNo as number); + if (flags.pageSize !== undefined) params.PageSize = String(flags.pageSize as number); + if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string; + if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string; + if (flags.statusListStr) params.StatusListStr = flags.statusListStr as string; + + const status = flags.status; + if (Array.isArray(status) && status.length > 0) { + params.StatusList = status as string[]; + } else if (typeof status === "string" && status.length > 0) { + params.StatusList = [status]; + } + + if (flags.seatId) params.SeatId = flags.seatId as string; + if (flags.seatType) params.SeatType = flags.seatType as string; + + if (typeof flags.queryAssigned === "string" && flags.queryAssigned.length > 0) { + params.QueryAssigned = flags.queryAssigned; + } + + return params; +} + +function emitTextSeats( + items: TokenPlanSeatDetail[], + total?: number, + pageNo?: number, + pageSize?: number, +): void { + if (items.length === 0) { + emitBare("No seats found."); + return; + } + + const header = [ + padEnd("SeatId", 18), + padEnd("Type", 10), + padEnd("Status", 10), + padEnd("Assigned", 12), + padEnd("Account", 20), + ].join(" "); + emitBare(header); + emitBare("-".repeat(header.length)); + + for (const item of items) { + const row = [ + padEnd(item.SeatId ?? "-", 18), + padEnd(item.SpecType ?? "-", 10), + padEnd(item.Status ?? "-", 10), + padEnd(item.AssignedStatus ?? "-", 12), + padEnd(item.AccountName ?? item.AccountId ?? "-", 20), + ].join(" "); + emitBare(row); + } + + if (total !== undefined) { + emitBare(""); + emitBare( + `Total: ${total}${pageNo !== undefined ? ` | Page: ${pageNo}` : ""}${pageSize !== undefined ? ` | PageSize: ${pageSize}` : ""}`, + ); + } +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 4448aed..e879377 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -70,6 +70,7 @@ const NO_AUTH_SETUP = [ ["quota", "request"], ["quota", "history"], ["quota", "check"], + ["tokenplan", "seats"], ]; async function main() { diff --git a/packages/cli/tests/e2e/helpers.ts b/packages/cli/tests/e2e/helpers.ts index e35b8a3..63d5b97 100644 --- a/packages/cli/tests/e2e/helpers.ts +++ b/packages/cli/tests/e2e/helpers.ts @@ -136,6 +136,15 @@ export function isKnowledgeAkSkReady(): boolean { ); } +/** Token Plan POP commands (AK/SK only). */ +export function isTokenPlanAkSkReady(): boolean { + return ( + isBailianE2EEnabled() && + !!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID && + !!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET + ); +} + export interface RunCliResult { stdout: string; stderr: string; diff --git a/packages/cli/tests/e2e/tokenplan.e2e.test.ts b/packages/cli/tests/e2e/tokenplan.e2e.test.ts new file mode 100644 index 0000000..ff23b13 --- /dev/null +++ b/packages/cli/tests/e2e/tokenplan.e2e.test.ts @@ -0,0 +1,96 @@ +import { tmpdir } from "os"; +import { describe, expect, test } from "vite-plus/test"; +import { isTokenPlanAkSkReady, parseStdoutJson, runCli } from "./helpers.ts"; + +interface DryRunBody { + endpoint?: string; + query?: Record; +} + +describe("e2e: tokenplan seats", () => { + test("tokenplan 分组展示子命令帮助且成功退出", async () => { + const { stdout, stderr, exitCode } = await runCli(["tokenplan"]); + expect(exitCode, stderr).toBe(0); + const out = `${stdout}\n${stderr}`; + expect(out).toMatch(/tokenplan|seats/i); + }); + + test("tokenplan seats --help 正常退出", async () => { + const { stderr, exitCode } = await runCli(["tokenplan", "seats", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--page-no/i); + expect(stderr).toMatch(/--page-size/i); + expect(stderr).toMatch(/--seat-id/i); + expect(stderr).toMatch(/--status/i); + expect(stderr).toMatch(/--query-assigned/i); + }); +}); + +describe("e2e: tokenplan seats errors", () => { + test("无任何凭证时提示 No credentials found 并非零退出", async () => { + const { stderr, exitCode } = await runCli( + ["tokenplan", "seats", "--non-interactive", "--output", "json"], + { + DASHSCOPE_API_KEY: undefined, + DASHSCOPE_ACCESS_TOKEN: undefined, + ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, + ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, + BAILIAN_CONFIG_DIR: tmpdir(), + }, + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/no credentials found/i); + }); +}); + +describe("e2e: tokenplan seats dry-run", () => { + test("--dry-run 输出 endpoint 和 query 参数", async () => { + const { stdout, stderr, exitCode } = await runCli( + [ + "tokenplan", + "seats", + "--dry-run", + "--page-no", + "1", + "--page-size", + "10", + "--status", + "NORMAL", + "--query-assigned", + "true", + "--non-interactive", + "--output", + "json", + ], + { + ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-fake", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "fake-secret", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson(stdout); + expect(data.endpoint).toMatch(/\/tokenplan\/subscription\/seat-detail/); + expect(data.query?.PageNo).toBe("1"); + expect(data.query?.PageSize).toBe("10"); + expect(data.query?.QueryAssigned).toBe("true"); + expect(data.query?.StatusList).toEqual(["NORMAL"]); + }); +}); + +describe.skipIf(!isTokenPlanAkSkReady())("e2e: tokenplan seats(AK/SK)", () => { + test("GetSubscriptionSeatDetails 真实调用", async () => { + const { stdout, stderr, exitCode } = await runCli([ + "tokenplan", + "seats", + "--page-size", + "5", + "--non-interactive", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ Success?: boolean; Data?: { Items?: unknown[] } }>(stdout); + expect(data.Success).toBe(true); + expect(Array.isArray(data.Data?.Items)).toBe(true); + }); +}); diff --git a/packages/core/src/client/ak-sign.ts b/packages/core/src/client/ak-sign.ts index e9ed7be..d807ad3 100644 --- a/packages/core/src/client/ak-sign.ts +++ b/packages/core/src/client/ak-sign.ts @@ -18,6 +18,33 @@ export interface AkSignConfig { host: string; pathname: string; method?: string; + /** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */ + queryString?: string; +} + +/** Build ACS3 canonical query string from POP query parameters. */ +export function buildCanonicalQuery(params: Record): string { + const pairs: Array<[string, string]> = []; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === "") continue; + if (Array.isArray(value)) { + const sorted = [...value].sort(); + for (const v of sorted) { + if (v !== "") pairs.push([key, v]); + } + } else { + pairs.push([key, value]); + } + } + pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&"); +} + +function encodeRFC3986(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); } export function signRequest(cfg: AkSignConfig): Record { @@ -47,11 +74,13 @@ export function signRequest(cfg: AkSignConfig): Record { const signedHeadersStr = signedHeaderKeys.join(";"); + const queryString = cfg.queryString ?? ""; + // Build canonical request const canonicalRequest = [ method, cfg.pathname, - "", // query string (empty for POST) + queryString, canonicalHeaders, signedHeadersStr, hashedBody, diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 7cb4ab2..cbcfa20 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -1,3 +1,16 @@ +import type { Region } from "../config/schema.ts"; + +const MODEL_STUDIO_HOSTS: Record = { + cn: "modelstudio.cn-beijing.aliyuncs.com", + us: "modelstudio.cn-beijing.aliyuncs.com", + intl: "modelstudio.ap-southeast-1.aliyuncs.com", +}; + +/** ModelStudio POP OpenAPI host for the given DashScope region preset. */ +export function modelStudioHost(region: Region): string { + return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn; +} + // ---- Chat (OpenAI Compatible) ---- export function chatEndpoint(baseUrl: string): string { diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 22be23e..5c334c7 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -1,5 +1,5 @@ export type { AkSignConfig } from "./ak-sign.ts"; -export { signRequest } from "./ak-sign.ts"; +export { buildCanonicalQuery, signRequest } from "./ak-sign.ts"; export { appCompletionEndpoint, chatEndpoint, @@ -10,6 +10,7 @@ export { memoryListEndpoint, memoryNodeEndpoint, memorySearchEndpoint, + modelStudioHost, mcpWebSearchEndpoint, profileSchemaEndpoint, speechRecognizeEndpoint, diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 87f0782..8c1ab9c 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -417,6 +417,44 @@ export interface DashScopeKnowledgeRetrieveResponse { }; } +// ---- Token Plan / ModelStudio POP (2026-02-10) ---- + +export interface TokenPlanSeatEquity { + EquityType?: string; + CycleInstanceId?: string; + CycleStartTime?: number; + CycleEndTime?: number; + CycleTotalValue?: number; + CycleSurplusValue?: number; + CycleVersion?: number; +} + +export interface TokenPlanSeatDetail { + InstanceCode?: string; + EquityList?: TokenPlanSeatEquity[]; + EndTime?: number; + SeatId?: string; + SpecType?: string; + StartTime?: number; + AssignedStatus?: string; + AccountId?: string; + AccountName?: string; + AccountEmail?: string; + Status?: string; +} + +export interface GetSubscriptionSeatDetailsResponse { + Success?: boolean; + Code?: string; + Message?: string; + Data?: { + Items?: TokenPlanSeatDetail[]; + Total?: number; + PageNo?: number; + PageSize?: number; + }; +} + // ---- Speech Synthesis / TTS (DashScope) ---- export interface DashScopeTTSRequest { diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 0da2213..f78ae52 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -45,6 +45,7 @@ Use this index for the full quick index and global flags. | `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) | | `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | | `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | +| `bl tokenplan seats` | List Token Plan subscription seat details | [tokenplan.md](tokenplan.md) | | `bl update` | Update bl to the latest version | [update.md](update.md) | | `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) | | `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | @@ -77,6 +78,7 @@ Use this index for the full quick index and global flags. | `search` | `web` | [search.md](search.md) | | `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | | `text` | `chat` | [text.md](text.md) | +| `tokenplan` | `seats` | [tokenplan.md](tokenplan.md) | | `update` | `(root)` | [update.md](update.md) | | `usage` | `free`, `freetier`, `stats` | [usage.md](usage.md) | | `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) | diff --git a/skills/bailian-cli/reference/tokenplan.md b/skills/bailian-cli/reference/tokenplan.md new file mode 100644 index 0000000..91253c6 --- /dev/null +++ b/skills/bailian-cli/reference/tokenplan.md @@ -0,0 +1,52 @@ +# `bl tokenplan` commands + +> Auto-generated from `packages/cli/src/commands/catalog.ts`. Do not edit by hand. +> Regenerate: `pnpm --filter bailian-cli run generate:reference`. + +Index: [index.md](index.md) + +## Commands in this group + +| Command | Description | +| -------------------- | ----------------------------------------- | +| `bl tokenplan seats` | List Token Plan subscription seat details | + +## Command details + +### `bl tokenplan seats` + +| Field | Value | +| --------------- | ----------------------------------------- | +| **Name** | `tokenplan seats` | +| **Description** | List Token Plan subscription seat details | +| **Usage** | `bl tokenplan seats [flags]` | + +#### Options + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | --------------------------------------------------------------------------------- | +| `--page-no ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Page size (default: 10) | +| `--caller-uac-account-id ` | string | no | Caller UAC account ID | +| `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | +| `--status ` | array | no | Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED | +| `--status-list-str ` | string | no | StatusList as JSON string, e.g. '["NORMAL"]' | +| `--seat-id ` | string | no | Filter by seat ID | +| `--seat-type ` | string | no | Seat tier: standard, pro, or max | +| `--query-assigned ` | string | no | Filter by assignment: true=assigned, false=unassigned | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (deprecated) | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (deprecated) | + +#### Examples + +```bash +bl tokenplan seats +``` + +```bash +bl tokenplan seats --page-size 20 --status NORMAL +``` + +```bash +bl tokenplan seats --query-assigned true --seat-type standard +```