feat(tokenplan): 重构 Token Plan 命令以支持新功能

对 `tokenplan` 相关命令进行了重构,新增了 `ak-sign` 模块以支持 ACS3-HMAC-SHA256 签名,优化了参数处理逻辑,简化了对凭证的处理。更新了 `add-member`、`assign-seats`、`create-key` 和 `seats` 命令,增强了对参数的验证和处理,确保代码的可读性和健壮性。同时,新增了类型定义和工具函数以支持更好的代码结构。
This commit is contained in:
wb-liuxuehuan
2026-06-24 11:14:57 +08:00
parent dc5a535bf3
commit ba1661356f
14 changed files with 451 additions and 836 deletions
@@ -1,21 +1,24 @@
import {
defineCommand,
buildCanonicalQuery,
signRequest,
modelStudioHost,
detectOutputFormat,
maskToken,
trackingHeaders,
type Config,
type GlobalFlags,
type AddOrganizationMemberResponse,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { emitResult, emitBare } from "../../output/output.ts";
import { padEnd } from "../../output/cjk-width.ts";
import type { AddOrganizationMemberResponse } from "./types.ts";
import {
TOKEN_PLAN_AK_OPTIONS,
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
appendCommonQueryParams,
callTokenPlanApi,
prepareTokenPlanRequest,
resolveTokenPlanCredentials,
type TokenPlanQueryParams,
} from "./utils.ts";
const API_VERSION = "2026-02-10";
const API_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";
@@ -36,19 +39,8 @@ export default defineCommand({
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
{
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: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
{
flag: "--access-key-secret <key>",
description: "Alibaba Cloud Access Key Secret (deprecated)",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"bl tokenplan add-member --account-name dev_user --org-id org_123",
@@ -57,16 +49,7 @@ export default defineCommand({
],
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 credentials = resolveTokenPlanCredentials(config, flags);
const accountName = flags.accountName as string | undefined;
const orgId = flags.orgId as string | undefined;
@@ -78,52 +61,26 @@ export default defineCommand({
}
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);
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}
const headers = signRequest({
accessKeyId,
accessKeySecret,
const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
version: API_VERSION,
body: "",
host,
pathname: API_PATH,
path: API_PATH,
method: "POST",
queryString,
queryParams,
});
if (config.verbose) {
process.stderr.write(`> POST ${endpoint}\n`);
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(endpoint, {
method: "POST",
headers: { ...headers, ...trackingHeaders() },
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as AddOrganizationMemberResponse;
if (!res.ok || data.Success === false) {
throw new BailianError(
`${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
if (config.quiet || format === "text") {
emitTextMember(data);
} else {
@@ -132,8 +89,8 @@ export default defineCommand({
},
});
function buildQueryParams(flags: GlobalFlags): Record<string, string | string[] | undefined> {
const params: Record<string, string | string[] | undefined> = {};
function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};
if (flags.accountName) params.AccountName = flags.accountName as string;
if (flags.orgId) params.OrgId = flags.orgId as string;
@@ -142,8 +99,7 @@ function buildQueryParams(flags: GlobalFlags): Record<string, string | string[]
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
appendCommonQueryParams(params, flags);
return params;
}
@@ -0,0 +1,103 @@
/**
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
*
* Extends the core ROA signer with canonical query string support required by
* Token Plan endpoints that pass parameters in the URL query.
*/
import { createHmac, createHash, randomUUID } from "crypto";
export interface TokenPlanAkSignConfig {
accessKeyId: string;
accessKeySecret: string;
action: string;
version: string;
body: string;
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)) {
for (let i = 0; i < value.length; i++) {
const v = value[i];
if (v !== "") pairs.push([`${key}.${i + 1}`, 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 signTokenPlanRequest(cfg: TokenPlanAkSignConfig): Record<string, string> {
const method = cfg.method ?? "POST";
const now = new Date();
const dateISO = now.toISOString().replace(/\.\d{3}Z$/, "Z");
const nonce = randomUUID();
const hashedBody = sha256Hex(cfg.body);
const headers: Record<string, string> = {
host: cfg.host,
"x-acs-action": cfg.action,
"x-acs-version": cfg.version,
"x-acs-date": dateISO,
"x-acs-signature-nonce": nonce,
"x-acs-content-sha256": hashedBody,
"content-type": "application/json",
};
const signedHeaderKeys = Object.keys(headers)
.filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-"))
.sort();
const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n";
const signedHeadersStr = signedHeaderKeys.join(";");
const queryString = cfg.queryString ?? "";
const canonicalRequest = [
method,
cfg.pathname,
queryString,
canonicalHeaders,
signedHeadersStr,
hashedBody,
].join("\n");
const algorithm = "ACS3-HMAC-SHA256";
const hashedCanonical = sha256Hex(canonicalRequest);
const stringToSign = `${algorithm}\n${hashedCanonical}`;
const signature = hmacSHA256Hex(cfg.accessKeySecret, stringToSign);
headers["authorization"] =
`${algorithm} Credential=${cfg.accessKeyId},SignedHeaders=${signedHeadersStr},Signature=${signature}`;
return headers;
}
function sha256Hex(data: string): string {
return createHash("sha256").update(data, "utf8").digest("hex");
}
function hmacSHA256Hex(key: string, data: string): string {
return createHmac("sha256", key).update(data, "utf8").digest("hex");
}
@@ -1,20 +1,25 @@
import {
defineCommand,
buildCanonicalQuery,
signRequest,
modelStudioHost,
detectOutputFormat,
maskToken,
trackingHeaders,
type Config,
type GlobalFlags,
type BatchAssignSeatsResponse,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { emitResult, emitBare } from "../../output/output.ts";
import type { BatchAssignSeatsResponse } from "./types.ts";
import {
TOKEN_PLAN_AK_OPTIONS,
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
TOKEN_PLAN_WORKSPACE_OPTION,
appendCommonQueryParams,
callTokenPlanApi,
prepareTokenPlanRequest,
requireWorkspaceId,
resolveTokenPlanCredentials,
type TokenPlanQueryParams,
} from "./utils.ts";
const API_VERSION = "2026-02-10";
const API_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";
@@ -24,10 +29,7 @@ export default defineCommand({
usage:
"bl tokenplan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
{
flag: "--workspace-id <id>",
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)",
},
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
@@ -38,23 +40,12 @@ export default defineCommand({
description: "Target member account ID (repeatable)",
type: "array",
},
{
flag: "--caller-uac-account-id <id>",
description: "Caller UAC account ID",
},
{
flag: "--namespace-id <id>",
description: "Product namespace ID (Token Plan default: namespace-1)",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
{
flag: "--access-key-secret <key>",
description: "Alibaba Cloud Access Key Secret (deprecated)",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"bl tokenplan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123",
@@ -62,26 +53,10 @@ export default defineCommand({
],
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;
const credentials = resolveTokenPlanCredentials(config, flags);
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 workspaceId = (flags.workspaceId as string) || config.workspaceId;
const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!workspaceId) {
throw new BailianError(
"Missing workspace ID.\n" +
"Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
ExitCode.USAGE,
);
}
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}
@@ -92,52 +67,26 @@ export default defineCommand({
}
const queryParams = buildQueryParams(flags, workspaceId);
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);
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}
const headers = signRequest({
accessKeyId,
accessKeySecret,
const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
version: API_VERSION,
body: "",
host,
pathname: API_PATH,
path: API_PATH,
method: "POST",
queryString,
queryParams,
});
if (config.verbose) {
process.stderr.write(`> POST ${endpoint}\n`);
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(endpoint, {
method: "POST",
headers: { ...headers, ...trackingHeaders() },
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as BatchAssignSeatsResponse;
if (!res.ok || data.Success === false) {
throw new BailianError(
`${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
@@ -146,16 +95,12 @@ export default defineCommand({
},
});
function buildQueryParams(
flags: GlobalFlags,
workspaceId: string,
): Record<string, string | string[] | undefined> {
const params: Record<string, string | string[] | undefined> = {};
function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};
params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;
const accountIds = flags.accountId as string[] | undefined;
@@ -1,21 +1,26 @@
import {
defineCommand,
buildCanonicalQuery,
signRequest,
modelStudioHost,
detectOutputFormat,
maskToken,
trackingHeaders,
type Config,
type GlobalFlags,
type CreateTokenPlanKeyResponse,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { emitResult, emitBare } from "../../output/output.ts";
import { padEnd } from "../../output/cjk-width.ts";
import type { CreateTokenPlanKeyResponse } from "./types.ts";
import {
TOKEN_PLAN_AK_OPTIONS,
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
TOKEN_PLAN_WORKSPACE_OPTION,
appendCommonQueryParams,
callTokenPlanApi,
prepareTokenPlanRequest,
requireWorkspaceId,
resolveTokenPlanCredentials,
type TokenPlanQueryParams,
} from "./utils.ts";
const API_VERSION = "2026-02-10";
const API_ACTION = "CreateTokenPlanKey";
const API_PATH = "/tokenplan/api-keys";
@@ -25,24 +30,10 @@ export default defineCommand({
usage: "bl tokenplan create-key --account-id <id> --workspace-id <id> [flags]",
options: [
{ flag: "--account-id <id>", description: "Target member account ID", required: true },
{
flag: "--workspace-id <id>",
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)",
},
TOKEN_PLAN_WORKSPACE_OPTION,
{ flag: "--description <text>", description: "API key description" },
{
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: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
{
flag: "--access-key-secret <key>",
description: "Alibaba Cloud Access Key Secret (deprecated)",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"bl tokenplan create-key --account-id acc_123 --workspace-id ws_456",
@@ -50,77 +41,35 @@ export default defineCommand({
],
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 credentials = resolveTokenPlanCredentials(config, flags);
const accountId = flags.accountId as string | undefined;
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
const workspaceId = requireWorkspaceId(config, flags);
if (!accountId) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}
if (!workspaceId) {
throw new BailianError(
"Missing workspace ID.\n" +
"Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
ExitCode.USAGE,
);
}
const queryParams = buildQueryParams(flags, { accountId, workspaceId });
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);
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}
const headers = signRequest({
accessKeyId,
accessKeySecret,
const data = await callTokenPlanApi<CreateTokenPlanKeyResponse>({
config,
credentials,
action: API_ACTION,
version: API_VERSION,
body: "",
host,
pathname: API_PATH,
path: API_PATH,
method: "POST",
queryString,
queryParams,
});
if (config.verbose) {
process.stderr.write(`> POST ${endpoint}\n`);
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(endpoint, {
method: "POST",
headers: { ...headers, ...trackingHeaders() },
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as CreateTokenPlanKeyResponse;
if (!res.ok || data.Success === false) {
throw new BailianError(
`${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
if (config.quiet || format === "text") {
emitTextKey(data);
} else {
@@ -132,14 +81,13 @@ export default defineCommand({
function buildQueryParams(
flags: GlobalFlags,
resolved: { accountId: string; workspaceId: string },
): Record<string, string | string[] | undefined> {
const params: Record<string, string | string[] | undefined> = {};
): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};
params.AccountId = resolved.accountId;
params.WorkspaceId = resolved.workspaceId;
if (flags.description) params.Description = flags.description as string;
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
appendCommonQueryParams(params, flags);
return params;
}
+27 -73
View File
@@ -1,22 +1,24 @@
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";
import type { GetSubscriptionSeatDetailsResponse, TokenPlanSeatDetail } from "./types.ts";
import {
TOKEN_PLAN_AK_OPTIONS,
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
appendCommonQueryParams,
callTokenPlanApi,
prepareTokenPlanRequest,
resolveTokenPlanCredentials,
type TokenPlanQueryParams,
} from "./utils.ts";
const API_VERSION = "2026-02-10";
const API_ACTION = "GetSubscriptionSeatDetails";
const API_PATH = "/tokenplan/subscription/seat-detail";
@@ -27,14 +29,7 @@ export default defineCommand({
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)",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--status <status>",
description:
@@ -54,11 +49,7 @@ export default defineCommand({
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)",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"bl tokenplan seats",
@@ -67,64 +58,28 @@ export default defineCommand({
],
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 credentials = resolveTokenPlanCredentials(config, flags);
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);
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}
const headers = signRequest({
accessKeyId,
accessKeySecret,
const data = await callTokenPlanApi<GetSubscriptionSeatDetailsResponse>({
config,
credentials,
action: API_ACTION,
version: API_VERSION,
body: "",
host,
pathname: API_PATH,
path: API_PATH,
method: "GET",
queryString,
queryParams,
});
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);
@@ -134,13 +89,12 @@ export default defineCommand({
},
});
function buildQueryParams(flags: GlobalFlags): Record<string, string | string[] | undefined> {
const params: Record<string, string | string[] | undefined> = {};
function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};
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;
appendCommonQueryParams(params, flags);
if (flags.statusListStr) params.StatusListStr = flags.statusListStr as string;
const status = flags.status as string[] | undefined;
@@ -0,0 +1,69 @@
// ---- 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;
};
}
export interface CreateTokenPlanKeyResponse {
Success?: boolean;
Code?: string;
Message?: string;
Data?: {
ApiKeyId?: string;
PlainApiKey?: string;
MaskedApiKey?: string;
Description?: string;
CreatedAt?: string;
SourceId?: string;
};
}
export interface BatchAssignSeatsResponse {
Success?: boolean;
Code?: string;
Message?: string;
}
export interface AddOrganizationMemberResponse {
Success?: boolean;
Code?: string;
Message?: string;
RequestId?: string;
HttpStatusCode?: number;
Data?: {
AccountId?: string;
SeatAssigned?: boolean;
};
}
@@ -0,0 +1,161 @@
import {
REGIONS,
maskToken,
trackingHeaders,
type Config,
type GlobalFlags,
type OptionDef,
type Region,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { buildCanonicalQuery, signTokenPlanRequest } from "./ak-sign.ts";
export const TOKEN_PLAN_API_VERSION = "2026-02-10";
export const TOKEN_PLAN_AK_OPTIONS: OptionDef[] = [
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
{
flag: "--access-key-secret <key>",
description: "Alibaba Cloud Access Key Secret (deprecated)",
},
];
export const TOKEN_PLAN_COMMON_QUERY_OPTIONS: OptionDef[] = [
{
flag: "--caller-uac-account-id <id>",
description: "Caller UAC account ID",
},
{
flag: "--namespace-id <id>",
description: "Product namespace ID (Token Plan default: namespace-1)",
},
];
export const TOKEN_PLAN_WORKSPACE_OPTION: OptionDef = {
flag: "--workspace-id <id>",
description: "Workspace ID (env: BAILIAN_WORKSPACE_ID, config: workspace_id)",
};
const MODEL_STUDIO_HOSTS: Partial<Record<Region, string>> = {
cn: "modelstudio.cn-beijing.aliyuncs.com",
intl: "modelstudio.ap-southeast-1.aliyuncs.com",
};
function resolveRegion(baseUrl: string): Region {
for (const [region, url] of Object.entries(REGIONS) as Array<[Region, string]>) {
if (baseUrl === url || baseUrl.startsWith(`${url}/`)) return region;
}
return "cn";
}
/** ModelStudio POP OpenAPI host for the given DashScope base URL preset. */
function modelStudioHost(baseUrl: string): string {
const region = resolveRegion(baseUrl);
return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn!;
}
export interface TokenPlanApiResponse {
Success?: boolean;
Code?: string;
Message?: string;
}
export type TokenPlanQueryParams = Record<string, string | string[] | undefined>;
export function resolveTokenPlanCredentials(
config: Config,
flags: GlobalFlags,
): { accessKeyId: string; accessKeySecret: string } {
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,
);
}
return { accessKeyId, accessKeySecret };
}
export function requireWorkspaceId(config: Config, flags: GlobalFlags): string {
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Missing workspace ID.\n" +
"Set via: --workspace-id flag, env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
ExitCode.USAGE,
);
}
return workspaceId;
}
export function appendCommonQueryParams(params: TokenPlanQueryParams, flags: GlobalFlags): void {
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
}
export function prepareTokenPlanRequest(
config: Config,
path: string,
queryParams: TokenPlanQueryParams,
): { host: string; endpoint: string; queryString: string; queryParams: TokenPlanQueryParams } {
const queryString = buildCanonicalQuery(queryParams);
const host = modelStudioHost(config.baseUrl);
const endpoint = `https://${host}${path}${queryString ? `?${queryString}` : ""}`;
return { host, endpoint, queryString, queryParams };
}
export async function callTokenPlanApi<T extends TokenPlanApiResponse>(opts: {
config: Config;
credentials: { accessKeyId: string; accessKeySecret: string };
action: string;
path: string;
method: "GET" | "POST";
queryParams: TokenPlanQueryParams;
}): Promise<T> {
const { config, credentials, action, path, method, queryParams } = opts;
const { host, endpoint, queryString } = prepareTokenPlanRequest(config, path, queryParams);
const headers = signTokenPlanRequest({
accessKeyId: credentials.accessKeyId,
accessKeySecret: credentials.accessKeySecret,
action,
version: TOKEN_PLAN_API_VERSION,
body: "",
host,
pathname: path,
method,
queryString,
});
if (config.verbose) {
process.stderr.write(`> ${method} ${endpoint}\n`);
process.stderr.write(`> AK: ${maskToken(credentials.accessKeyId)}\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(endpoint, {
method,
headers: { ...headers, ...trackingHeaders() },
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as T;
if (!res.ok || data.Success === false) {
throw new BailianError(
`${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
return data;
}
-9
View File
@@ -136,15 +136,6 @@ 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;
@@ -1,379 +0,0 @@
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>;
}
const noCredsEnv = {
DASHSCOPE_API_KEY: undefined,
DASHSCOPE_ACCESS_TOKEN: undefined,
ALIBABA_CLOUD_ACCESS_KEY_ID: undefined,
ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined,
BAILIAN_CONFIG_DIR: tmpdir(),
};
const fakeAkEnv = {
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-fake",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "fake-secret",
};
describe("e2e: tokenplan", () => {
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);
expect(out).toMatch(/create-key/i);
expect(out).toMatch(/assign-seats/i);
expect(out).toMatch(/add-member/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);
});
test("tokenplan create-key --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["tokenplan", "create-key", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--account-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("tokenplan assign-seats --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["tokenplan", "assign-seats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--workspace-id/i);
expect(stderr).toMatch(/--seat-type/i);
expect(stderr).toMatch(/--account-id/i);
});
test("tokenplan add-member --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["tokenplan", "add-member", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--account-name/i);
expect(stderr).toMatch(/--org-id/i);
expect(stderr).toMatch(/--org-role-code/i);
});
});
describe("e2e: tokenplan errors", () => {
test("seats 无任何凭证时提示 No credentials found 并非零退出", async () => {
const { stderr, exitCode } = await runCli(
["tokenplan", "seats", "--non-interactive", "--output", "json"],
noCredsEnv,
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/no credentials found/i);
});
test("create-key 无任何凭证时提示 No credentials found 并非零退出", async () => {
const { stderr, exitCode } = await runCli(
[
"tokenplan",
"create-key",
"--account-id",
"acc_1",
"--workspace-id",
"ws_1",
"--non-interactive",
"--output",
"json",
],
noCredsEnv,
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/no credentials found/i);
});
test("assign-seats 无任何凭证时提示 No credentials found 并非零退出", async () => {
const { stderr, exitCode } = await runCli(
[
"tokenplan",
"assign-seats",
"--workspace-id",
"ws_1",
"--seat-type",
"standard",
"--account-id",
"acc_1",
"--non-interactive",
"--output",
"json",
],
noCredsEnv,
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/no credentials found/i);
});
test("add-member 无任何凭证时提示 No credentials found 并非零退出", async () => {
const { stderr, exitCode } = await runCli(
[
"tokenplan",
"add-member",
"--account-name",
"user1",
"--org-id",
"org_1",
"--non-interactive",
"--output",
"json",
],
noCredsEnv,
);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/no credentials found/i);
});
});
describe.skipIf(!isTokenPlanAkSkReady())("e2e: tokenplan missing args", () => {
test("create-key 缺少 --account-id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"tokenplan",
"create-key",
"--workspace-id",
"ws_1",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--account-id|Missing required argument/i);
});
test("create-key 缺少 --workspace-id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"tokenplan",
"create-key",
"--account-id",
"acc_1",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/workspace-id|Missing workspace ID/i);
});
test("assign-seats 缺少 --workspace-id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"tokenplan",
"assign-seats",
"--seat-type",
"standard",
"--account-id",
"acc_1",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/workspace-id|Missing workspace ID/i);
});
test("assign-seats 缺少 --seat-type 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"tokenplan",
"assign-seats",
"--workspace-id",
"ws_1",
"--account-id",
"acc_1",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--seat-type|Missing required argument/i);
});
test("assign-seats 缺少 account id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"tokenplan",
"assign-seats",
"--workspace-id",
"ws_1",
"--seat-type",
"standard",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--account-id|Missing required argument/i);
});
test("add-member 缺少 --account-name 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"tokenplan",
"add-member",
"--org-id",
"org_1",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--account-name|Missing required argument/i);
});
test("add-member 缺少 --org-id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli([
"tokenplan",
"add-member",
"--account-name",
"user1",
"--non-interactive",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--org-id|Missing required argument/i);
});
});
describe("e2e: tokenplan dry-run", () => {
test("seats --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",
],
fakeAkEnv,
);
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"]);
});
test("create-key --dry-run 从 BAILIAN_WORKSPACE_ID 读取 workspace", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"tokenplan",
"create-key",
"--dry-run",
"--account-id",
"acc_123",
"--non-interactive",
"--output",
"json",
],
{ ...fakeAkEnv, BAILIAN_WORKSPACE_ID: "ws-from-env" },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.query?.WorkspaceId).toBe("ws-from-env");
});
test("create-key --dry-run 输出 endpoint 和 query 参数", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"tokenplan",
"create-key",
"--dry-run",
"--account-id",
"acc_123",
"--workspace-id",
"ws_456",
"--description",
"test key",
"--non-interactive",
"--output",
"json",
],
fakeAkEnv,
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/\/tokenplan\/api-keys/);
expect(data.query?.AccountId).toBe("acc_123");
expect(data.query?.WorkspaceId).toBe("ws_456");
expect(data.query?.Description).toBe("test key");
});
test("assign-seats --dry-run 输出 endpoint 和 query 参数", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"tokenplan",
"assign-seats",
"--dry-run",
"--workspace-id",
"ws_456",
"--seat-type",
"standard",
"--account-id",
"acc_1",
"--account-id",
"acc_2",
"--non-interactive",
"--output",
"json",
],
fakeAkEnv,
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/\/tokenplan\/subscription\/seat-assignments/);
expect(data.query?.WorkspaceId).toBe("ws_456");
expect(data.query?.SeatType).toBe("standard");
expect(data.query?.AccountIds).toEqual(["acc_1", "acc_2"]);
expect(data.endpoint).toMatch(/AccountIds\.1=acc_1/);
expect(data.endpoint).toMatch(/AccountIds\.2=acc_2/);
});
test("add-member --dry-run 输出 endpoint 和 query 参数", async () => {
const { stdout, stderr, exitCode } = await runCli(
[
"tokenplan",
"add-member",
"--dry-run",
"--account-name",
"dev_user",
"--org-id",
"org_123",
"--spec-type",
"standard",
"--non-interactive",
"--output",
"json",
],
fakeAkEnv,
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/\/tokenplan\/organization\/member-additions/);
expect(data.query?.AccountName).toBe("dev_user");
expect(data.query?.OrgId).toBe("org_123");
expect(data.query?.OrgRoleCode).toBe("ORG_MEMBER");
expect(data.query?.SpecType).toBe("standard");
});
});
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);
});
});
+1 -30
View File
@@ -18,33 +18,6 @@ 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)) {
for (let i = 0; i < value.length; i++) {
const v = value[i];
if (v !== "") pairs.push([`${key}.${i + 1}`, 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> {
@@ -74,13 +47,11 @@ export function signRequest(cfg: AkSignConfig): Record<string, string> {
const signedHeadersStr = signedHeaderKeys.join(";");
const queryString = cfg.queryString ?? "";
// Build canonical request
const canonicalRequest = [
method,
cfg.pathname,
queryString,
"", // query string (empty for POST)
canonicalHeaders,
signedHeadersStr,
hashedBody,
-13
View File
@@ -1,16 +1,3 @@
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 {
+1 -2
View File
@@ -1,5 +1,5 @@
export type { AkSignConfig } from "./ak-sign.ts";
export { buildCanonicalQuery, signRequest } from "./ak-sign.ts";
export { signRequest } from "./ak-sign.ts";
export {
appCompletionEndpoint,
chatEndpoint,
@@ -10,7 +10,6 @@ export {
memoryListEndpoint,
memoryNodeEndpoint,
memorySearchEndpoint,
modelStudioHost,
mcpWebSearchEndpoint,
profileSchemaEndpoint,
speechRecognizeEndpoint,
-70
View File
@@ -417,76 +417,6 @@ 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;
};
}
export interface CreateTokenPlanKeyResponse {
Success?: boolean;
Code?: string;
Message?: string;
Data?: {
ApiKeyId?: string;
PlainApiKey?: string;
MaskedApiKey?: string;
Description?: string;
CreatedAt?: string;
SourceId?: string;
};
}
export interface BatchAssignSeatsResponse {
Success?: boolean;
Code?: string;
Message?: string;
}
export interface AddOrganizationMemberResponse {
Success?: boolean;
Code?: string;
Message?: string;
RequestId?: string;
HttpStatusCode?: number;
Data?: {
AccountId?: string;
SeatAssigned?: boolean;
};
}
// ---- Speech Synthesis / TTS (DashScope) ----
export interface DashScopeTTSRequest {
-20
View File
@@ -1,20 +0,0 @@
import { expect, test } from "vite-plus/test";
import { buildCanonicalQuery } from "../src/client/ak-sign.ts";
test("buildCanonicalQuery flattens arrays as indexed keys", () => {
expect(
buildCanonicalQuery({
WorkspaceId: "ws_1",
SeatType: "pro",
AccountIds: ["acc_1", "acc_2"],
}),
).toBe("AccountIds.1=acc_1&AccountIds.2=acc_2&SeatType=pro&WorkspaceId=ws_1");
});
test("buildCanonicalQuery uses single indexed key for one-element arrays", () => {
expect(
buildCanonicalQuery({
AccountIds: ["acc_2bd88814c31743d9aa5833dc16b3b8e0"],
}),
).toBe("AccountIds.1=acc_2bd88814c31743d9aa5833dc16b3b8e0");
});