Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 tokenplan seats # 查看订阅席位明细
bl tokenplan add-member --account-name dev --org-id org_xxx
bl tokenplan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
bl tokenplan 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` 与 `tokenplan` 命令组需要阿里云 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 "./tokenplan/seats.ts";
import tokenplanCreateKey from "./tokenplan/create-key.ts";
import tokenplanAssignSeats from "./tokenplan/assign-seats.ts";
import tokenplanAddMember from "./tokenplan/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,
"tokenplan seats": tokenplanSeats,
"tokenplan create-key": tokenplanCreateKey,
"tokenplan assign-seats": tokenplanAssignSeats,
"tokenplan add-member": tokenplanAddMember,
update: update,
};
116 changes: 116 additions & 0 deletions packages/cli/src/commands/tokenplan/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: "tokenplan add-member",
description: "Add a member to a Token Plan organization",
usage: "bl tokenplan 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 tokenplan add-member --account-name dev_user --org-id org_123",
"bl tokenplan add-member --account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN",
"bl tokenplan 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/tokenplan/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/tokenplan/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: "tokenplan assign-seats",
description: "Batch assign Token Plan seats to members",
usage:
"bl tokenplan 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 tokenplan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123",
"bl tokenplan 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