Skip to content
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24.16.0
24
10 changes: 8 additions & 2 deletions packages/cli/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ bl quota check # 查看当前用量 vs
bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录

# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
bl token-plan seats # 查看订阅席位明细
bl token-plan add-member --account-name dev --org-id org_xxx
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
```

> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
Expand Down Expand Up @@ -151,9 +157,9 @@ bl text chat --api-key sk-xxxxx --message "你好"
bl auth login --console
```

### 阿里云 AK/SK(仅知识库检索
### 阿里云 AK/SK(知识库检索与 Token Plan

`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
`knowledge retrieve` 与 `token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。

> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ 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 "./token-plan/seats.ts";
import tokenplanCreateKey from "./token-plan/create-key.ts";
import tokenplanAssignSeats from "./token-plan/assign-seats.ts";
import tokenplanAddMember from "./token-plan/add-member.ts";

/** Command registry map (no dependency on registry.ts — safe for build-time import). */
export const commands: Record<string, Command> = {
Expand Down Expand Up @@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
"quota request": quotaRequest,
"quota history": quotaHistory,
"quota check": quotaCheck,
"token-plan seats": tokenplanSeats,
"token-plan create-key": tokenplanCreateKey,
"token-plan assign-seats": tokenplanAssignSeats,
"token-plan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/token-plan/add-member.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "AddOrganizationMember";
const API_PATH = "/tokenplan/organization/member-additions";

const DEFAULT_ORG_ROLE = "ORG_MEMBER";

export default defineCommand({
name: "token-plan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl token-plan add-member --account-name <name> --org-id <id> [flags]",
options: [
{ flag: "--account-name <name>", description: "Member display name", required: true },
{ flag: "--org-id <id>", description: "Organization ID", required: true },
{
flag: "--org-role-code <code>",
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
},
{
flag: "--spec-type <type>",
description: "Seat tier to assign on creation: standard, pro, or max",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"bl token-plan add-member --account-name dev_user --org-id org_123",
"bl token-plan add-member --account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN",
"bl token-plan add-member --account-name member1 --org-id org_123 --spec-type standard",
],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const credentials = resolveTokenPlanCredentials(config, flags);

const accountName = flags.accountName as string | undefined;
const orgId = flags.orgId as string | undefined;
if (!accountName) {
throw new BailianError("Missing required argument --account-name.", ExitCode.USAGE);
}
if (!orgId) {
throw new BailianError("Missing required argument --org-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitTextMember(data);
} else {
emitResult(data, format);
}
},
});

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;
params.OrgRoleCode =
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
? flags.orgRoleCode
: DEFAULT_ORG_ROLE;
if (flags.specType) params.SpecType = flags.specType as string;
appendCommonQueryParams(params, flags);

return params;
}

function emitTextMember(data: AddOrganizationMemberResponse): void {
const item = data.Data;
if (!item) {
emitBare("Member added.");
return;
}

emitBare(`${padEnd("AccountId", 14)} ${item.AccountId ?? "-"}`);
emitBare(`${padEnd("SeatAssigned", 14)} ${String(item.SeatAssigned ?? "-")}`);
}
103 changes: 103 additions & 0 deletions packages/cli/src/commands/token-plan/ak-sign.ts
Original file line number Diff line number Diff line change
@@ -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");
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/token-plan/assign-seats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
defineCommand,
detectOutputFormat,
type Config,
type GlobalFlags,
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_ACTION = "BatchAssignSeats";
const API_PATH = "/tokenplan/subscription/seat-assignments";

export default defineCommand({
name: "token-plan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl token-plan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
options: [
TOKEN_PLAN_WORKSPACE_OPTION,
{
flag: "--seat-type <type>",
description: "Seat tier: standard, pro, or max",
required: true,
},
{
flag: "--account-id <id>",
description: "Target member account ID (repeatable)",
type: "array",
},
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
{
flag: "--locale <locale>",
description: "Language: zh-CN or en-US",
},
...TOKEN_PLAN_AK_OPTIONS,
],
examples: [
"bl token-plan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123",
"bl token-plan assign-seats --workspace-id ws_456 --seat-type pro --account-id acc_1 --account-id acc_2",
],
async run(config: Config, flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const credentials = resolveTokenPlanCredentials(config, flags);

const workspaceId = requireWorkspaceId(config, flags);
const seatType = flags.seatType as string | undefined;
if (!seatType) {
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
}

const accountIds = flags.accountId as string[] | undefined;
if (!accountIds || accountIds.length === 0) {
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
}

const queryParams = buildQueryParams(flags, workspaceId);

if (config.dryRun) {
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
config,
API_PATH,
queryParams,
);
emitResult({ endpoint, query }, format);
return;
}

const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
config,
credentials,
action: API_ACTION,
path: API_PATH,
method: "POST",
queryParams,
});

if (config.quiet || format === "text") {
emitBare("Seats assigned successfully.");
} else {
emitResult(data, format);
}
},
});

function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
const params: TokenPlanQueryParams = {};

params.WorkspaceId = workspaceId;
if (flags.seatType) params.SeatType = flags.seatType as string;
appendCommonQueryParams(params, flags);
if (flags.locale) params.Locale = flags.locale as string;

const accountIds = flags.accountId as string[] | undefined;
if (accountIds && accountIds.length > 0) {
params.AccountIds = accountIds;
}

return params;
}
Loading