feat(tokenplan): 添加 tokenplan seats 命令以列出订阅座位详情

新增 tokenplan seats 命令,支持分页和状态过滤,提供详细的座位信息查询功能。相关文档已更新。
This commit is contained in:
wb-liuxuehuan
2026-06-23 14:05:11 +08:00
parent 173e5a7e45
commit c0d30fee3d
11 changed files with 446 additions and 2 deletions
+2
View File
@@ -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<string, Command> = {
@@ -94,5 +95,6 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"tokenplan seats": tokenplanSeats,
update: update,
};
@@ -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 <n>", description: "Page number (default: 1)", type: "number" },
{ flag: "--page-size <n>", description: "Page size (default: 10)", type: "number" },
{
flag: "--caller-uac-account-id <id>",
description: "Caller UAC account ID",
},
{
flag: "--namespace-id <id>",
description: "Product namespace ID (Token Plan default: namespace-1)",
},
{
flag: "--status <status>",
description:
"Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED",
type: "array",
},
{
flag: "--status-list-str <json>",
description: "StatusList as JSON string, e.g. '[\"NORMAL\"]'",
},
{ flag: "--seat-id <id>", description: "Filter by seat ID" },
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
},
{
flag: "--query-assigned <bool>",
description: "Filter by assignment: true=assigned, false=unassigned",
},
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
{
flag: "--access-key-secret <key>",
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<string, string | string[] | undefined> {
const params: Record<string, string | string[] | undefined> = {};
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}` : ""}`,
);
}
}
+1
View File
@@ -70,6 +70,7 @@ const NO_AUTH_SETUP = [
["quota", "request"],
["quota", "history"],
["quota", "check"],
["tokenplan", "seats"],
];
async function main() {
+9
View File
@@ -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;
@@ -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<string, unknown>;
}
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<DryRunBody>(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);
});
});
+30 -1
View File
@@ -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, string | string[] | undefined>): 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<string, string> {
@@ -47,11 +74,13 @@ export function signRequest(cfg: AkSignConfig): Record<string, string> {
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,
+13
View File
@@ -1,3 +1,16 @@
import type { Region } from "../config/schema.ts";
const MODEL_STUDIO_HOSTS: Record<Region, string> = {
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 {
+2 -1
View File
@@ -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,
+38
View File
@@ -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 {
+2
View File
@@ -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) |
+52
View File
@@ -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 <n>` | number | no | Page number (default: 1) |
| `--page-size <n>` | number | no | Page size (default: 10) |
| `--caller-uac-account-id <id>` | string | no | Caller UAC account ID |
| `--namespace-id <id>` | string | no | Product namespace ID (Token Plan default: namespace-1) |
| `--status <status>` | array | no | Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED |
| `--status-list-str <json>` | string | no | StatusList as JSON string, e.g. '["NORMAL"]' |
| `--seat-id <id>` | string | no | Filter by seat ID |
| `--seat-type <type>` | string | no | Seat tier: standard, pro, or max |
| `--query-assigned <bool>` | string | no | Filter by assignment: true=assigned, false=unassigned |
| `--access-key-id <key>` | string | no | Alibaba Cloud Access Key ID (deprecated) |
| `--access-key-secret <key>` | 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
```