Skip to content

Commit 88e5b90

Browse files
authored
Merge pull request #74 from budiga/token-plan-openapi
Token plan openapi
2 parents 60c49ec + ba16613 commit 88e5b90

11 files changed

Lines changed: 1049 additions & 50 deletions

File tree

packages/cli/README.zh.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ bl quota check # 查看当前用量 vs
122122
bl quota check --model qwen3.6-plus --period 5 # 查看最近 5 分钟用量
123123
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
124124
bl quota history # 查看提额历史记录
125+
126+
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
127+
bl tokenplan seats # 查看订阅席位明细
128+
bl tokenplan add-member --account-name dev --org-id org_xxx
129+
bl tokenplan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
130+
bl tokenplan create-key --account-id acc_xxx --workspace-id ws_xxx
125131
```
126132

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

154-
### 阿里云 AK/SK(仅知识库检索
160+
### 阿里云 AK/SK(知识库检索与 Token Plan
155161

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

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

packages/cli/src/commands/catalog.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ import quotaList from "./quota/list.ts";
4646
import quotaRequest from "./quota/request.ts";
4747
import quotaHistory from "./quota/history.ts";
4848
import quotaCheck from "./quota/check.ts";
49+
import tokenplanSeats from "./tokenplan/seats.ts";
50+
import tokenplanCreateKey from "./tokenplan/create-key.ts";
51+
import tokenplanAssignSeats from "./tokenplan/assign-seats.ts";
52+
import tokenplanAddMember from "./tokenplan/add-member.ts";
4953

5054
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
5155
export const commands: Record<string, Command> = {
@@ -94,5 +98,9 @@ export const commands: Record<string, Command> = {
9498
"quota request": quotaRequest,
9599
"quota history": quotaHistory,
96100
"quota check": quotaCheck,
101+
"tokenplan seats": tokenplanSeats,
102+
"tokenplan create-key": tokenplanCreateKey,
103+
"tokenplan assign-seats": tokenplanAssignSeats,
104+
"tokenplan add-member": tokenplanAddMember,
97105
update: update,
98106
};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
defineCommand,
3+
detectOutputFormat,
4+
type Config,
5+
type GlobalFlags,
6+
BailianError,
7+
ExitCode,
8+
} from "bailian-cli-core";
9+
import { emitResult, emitBare } from "../../output/output.ts";
10+
import { padEnd } from "../../output/cjk-width.ts";
11+
import type { AddOrganizationMemberResponse } from "./types.ts";
12+
import {
13+
TOKEN_PLAN_AK_OPTIONS,
14+
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
15+
appendCommonQueryParams,
16+
callTokenPlanApi,
17+
prepareTokenPlanRequest,
18+
resolveTokenPlanCredentials,
19+
type TokenPlanQueryParams,
20+
} from "./utils.ts";
21+
22+
const API_ACTION = "AddOrganizationMember";
23+
const API_PATH = "/tokenplan/organization/member-additions";
24+
25+
const DEFAULT_ORG_ROLE = "ORG_MEMBER";
26+
27+
export default defineCommand({
28+
name: "tokenplan add-member",
29+
description: "Add a member to a Token Plan organization",
30+
usage: "bl tokenplan add-member --account-name <name> --org-id <id> [flags]",
31+
options: [
32+
{ flag: "--account-name <name>", description: "Member display name", required: true },
33+
{ flag: "--org-id <id>", description: "Organization ID", required: true },
34+
{
35+
flag: "--org-role-code <code>",
36+
description: "Organization role: ORG_ADMIN or ORG_MEMBER (default: ORG_MEMBER)",
37+
},
38+
{
39+
flag: "--spec-type <type>",
40+
description: "Seat tier to assign on creation: standard, pro, or max",
41+
},
42+
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
43+
...TOKEN_PLAN_AK_OPTIONS,
44+
],
45+
examples: [
46+
"bl tokenplan add-member --account-name dev_user --org-id org_123",
47+
"bl tokenplan add-member --account-name admin_user --org-id org_123 --org-role-code ORG_ADMIN",
48+
"bl tokenplan add-member --account-name member1 --org-id org_123 --spec-type standard",
49+
],
50+
async run(config: Config, flags: GlobalFlags) {
51+
const format = detectOutputFormat(config.output);
52+
const credentials = resolveTokenPlanCredentials(config, flags);
53+
54+
const accountName = flags.accountName as string | undefined;
55+
const orgId = flags.orgId as string | undefined;
56+
if (!accountName) {
57+
throw new BailianError("Missing required argument --account-name.", ExitCode.USAGE);
58+
}
59+
if (!orgId) {
60+
throw new BailianError("Missing required argument --org-id.", ExitCode.USAGE);
61+
}
62+
63+
const queryParams = buildQueryParams(flags);
64+
65+
if (config.dryRun) {
66+
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
67+
config,
68+
API_PATH,
69+
queryParams,
70+
);
71+
emitResult({ endpoint, query }, format);
72+
return;
73+
}
74+
75+
const data = await callTokenPlanApi<AddOrganizationMemberResponse>({
76+
config,
77+
credentials,
78+
action: API_ACTION,
79+
path: API_PATH,
80+
method: "POST",
81+
queryParams,
82+
});
83+
84+
if (config.quiet || format === "text") {
85+
emitTextMember(data);
86+
} else {
87+
emitResult(data, format);
88+
}
89+
},
90+
});
91+
92+
function buildQueryParams(flags: GlobalFlags): TokenPlanQueryParams {
93+
const params: TokenPlanQueryParams = {};
94+
95+
if (flags.accountName) params.AccountName = flags.accountName as string;
96+
if (flags.orgId) params.OrgId = flags.orgId as string;
97+
params.OrgRoleCode =
98+
typeof flags.orgRoleCode === "string" && flags.orgRoleCode.length > 0
99+
? flags.orgRoleCode
100+
: DEFAULT_ORG_ROLE;
101+
if (flags.specType) params.SpecType = flags.specType as string;
102+
appendCommonQueryParams(params, flags);
103+
104+
return params;
105+
}
106+
107+
function emitTextMember(data: AddOrganizationMemberResponse): void {
108+
const item = data.Data;
109+
if (!item) {
110+
emitBare("Member added.");
111+
return;
112+
}
113+
114+
emitBare(`${padEnd("AccountId", 14)} ${item.AccountId ?? "-"}`);
115+
emitBare(`${padEnd("SeatAssigned", 14)} ${String(item.SeatAssigned ?? "-")}`);
116+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* ACS3-HMAC-SHA256 signing for ModelStudio Token Plan POP APIs (query-string style).
3+
*
4+
* Extends the core ROA signer with canonical query string support required by
5+
* Token Plan endpoints that pass parameters in the URL query.
6+
*/
7+
8+
import { createHmac, createHash, randomUUID } from "crypto";
9+
10+
export interface TokenPlanAkSignConfig {
11+
accessKeyId: string;
12+
accessKeySecret: string;
13+
action: string;
14+
version: string;
15+
body: string;
16+
host: string;
17+
pathname: string;
18+
method?: string;
19+
/** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */
20+
queryString?: string;
21+
}
22+
23+
/** Build ACS3 canonical query string from POP query parameters. */
24+
export function buildCanonicalQuery(params: Record<string, string | string[] | undefined>): string {
25+
const pairs: Array<[string, string]> = [];
26+
for (const [key, value] of Object.entries(params)) {
27+
if (value === undefined || value === "") continue;
28+
if (Array.isArray(value)) {
29+
for (let i = 0; i < value.length; i++) {
30+
const v = value[i];
31+
if (v !== "") pairs.push([`${key}.${i + 1}`, v]);
32+
}
33+
} else {
34+
pairs.push([key, value]);
35+
}
36+
}
37+
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
38+
return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&");
39+
}
40+
41+
function encodeRFC3986(str: string): string {
42+
return encodeURIComponent(str).replace(
43+
/[!'()*]/g,
44+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
45+
);
46+
}
47+
48+
export function signTokenPlanRequest(cfg: TokenPlanAkSignConfig): Record<string, string> {
49+
const method = cfg.method ?? "POST";
50+
const now = new Date();
51+
const dateISO = now.toISOString().replace(/\.\d{3}Z$/, "Z");
52+
const nonce = randomUUID();
53+
54+
const hashedBody = sha256Hex(cfg.body);
55+
56+
const headers: Record<string, string> = {
57+
host: cfg.host,
58+
"x-acs-action": cfg.action,
59+
"x-acs-version": cfg.version,
60+
"x-acs-date": dateISO,
61+
"x-acs-signature-nonce": nonce,
62+
"x-acs-content-sha256": hashedBody,
63+
"content-type": "application/json",
64+
};
65+
66+
const signedHeaderKeys = Object.keys(headers)
67+
.filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-"))
68+
.sort();
69+
70+
const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${headers[k]}`).join("\n") + "\n";
71+
72+
const signedHeadersStr = signedHeaderKeys.join(";");
73+
74+
const queryString = cfg.queryString ?? "";
75+
76+
const canonicalRequest = [
77+
method,
78+
cfg.pathname,
79+
queryString,
80+
canonicalHeaders,
81+
signedHeadersStr,
82+
hashedBody,
83+
].join("\n");
84+
85+
const algorithm = "ACS3-HMAC-SHA256";
86+
const hashedCanonical = sha256Hex(canonicalRequest);
87+
const stringToSign = `${algorithm}\n${hashedCanonical}`;
88+
89+
const signature = hmacSHA256Hex(cfg.accessKeySecret, stringToSign);
90+
91+
headers["authorization"] =
92+
`${algorithm} Credential=${cfg.accessKeyId},SignedHeaders=${signedHeadersStr},Signature=${signature}`;
93+
94+
return headers;
95+
}
96+
97+
function sha256Hex(data: string): string {
98+
return createHash("sha256").update(data, "utf8").digest("hex");
99+
}
100+
101+
function hmacSHA256Hex(key: string, data: string): string {
102+
return createHmac("sha256", key).update(data, "utf8").digest("hex");
103+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
defineCommand,
3+
detectOutputFormat,
4+
type Config,
5+
type GlobalFlags,
6+
BailianError,
7+
ExitCode,
8+
} from "bailian-cli-core";
9+
import { emitResult, emitBare } from "../../output/output.ts";
10+
import type { BatchAssignSeatsResponse } from "./types.ts";
11+
import {
12+
TOKEN_PLAN_AK_OPTIONS,
13+
TOKEN_PLAN_COMMON_QUERY_OPTIONS,
14+
TOKEN_PLAN_WORKSPACE_OPTION,
15+
appendCommonQueryParams,
16+
callTokenPlanApi,
17+
prepareTokenPlanRequest,
18+
requireWorkspaceId,
19+
resolveTokenPlanCredentials,
20+
type TokenPlanQueryParams,
21+
} from "./utils.ts";
22+
23+
const API_ACTION = "BatchAssignSeats";
24+
const API_PATH = "/tokenplan/subscription/seat-assignments";
25+
26+
export default defineCommand({
27+
name: "tokenplan assign-seats",
28+
description: "Batch assign Token Plan seats to members",
29+
usage:
30+
"bl tokenplan assign-seats --workspace-id <id> --seat-type <type> --account-id <id> [flags]",
31+
options: [
32+
TOKEN_PLAN_WORKSPACE_OPTION,
33+
{
34+
flag: "--seat-type <type>",
35+
description: "Seat tier: standard, pro, or max",
36+
required: true,
37+
},
38+
{
39+
flag: "--account-id <id>",
40+
description: "Target member account ID (repeatable)",
41+
type: "array",
42+
},
43+
...TOKEN_PLAN_COMMON_QUERY_OPTIONS,
44+
{
45+
flag: "--locale <locale>",
46+
description: "Language: zh-CN or en-US",
47+
},
48+
...TOKEN_PLAN_AK_OPTIONS,
49+
],
50+
examples: [
51+
"bl tokenplan assign-seats --workspace-id ws_456 --seat-type standard --account-id acc_123",
52+
"bl tokenplan assign-seats --workspace-id ws_456 --seat-type pro --account-id acc_1 --account-id acc_2",
53+
],
54+
async run(config: Config, flags: GlobalFlags) {
55+
const format = detectOutputFormat(config.output);
56+
const credentials = resolveTokenPlanCredentials(config, flags);
57+
58+
const workspaceId = requireWorkspaceId(config, flags);
59+
const seatType = flags.seatType as string | undefined;
60+
if (!seatType) {
61+
throw new BailianError("Missing required argument --seat-type.", ExitCode.USAGE);
62+
}
63+
64+
const accountIds = flags.accountId as string[] | undefined;
65+
if (!accountIds || accountIds.length === 0) {
66+
throw new BailianError("Missing required argument --account-id.", ExitCode.USAGE);
67+
}
68+
69+
const queryParams = buildQueryParams(flags, workspaceId);
70+
71+
if (config.dryRun) {
72+
const { endpoint, queryParams: query } = prepareTokenPlanRequest(
73+
config,
74+
API_PATH,
75+
queryParams,
76+
);
77+
emitResult({ endpoint, query }, format);
78+
return;
79+
}
80+
81+
const data = await callTokenPlanApi<BatchAssignSeatsResponse>({
82+
config,
83+
credentials,
84+
action: API_ACTION,
85+
path: API_PATH,
86+
method: "POST",
87+
queryParams,
88+
});
89+
90+
if (config.quiet || format === "text") {
91+
emitBare("Seats assigned successfully.");
92+
} else {
93+
emitResult(data, format);
94+
}
95+
},
96+
});
97+
98+
function buildQueryParams(flags: GlobalFlags, workspaceId: string): TokenPlanQueryParams {
99+
const params: TokenPlanQueryParams = {};
100+
101+
params.WorkspaceId = workspaceId;
102+
if (flags.seatType) params.SeatType = flags.seatType as string;
103+
appendCommonQueryParams(params, flags);
104+
if (flags.locale) params.Locale = flags.locale as string;
105+
106+
const accountIds = flags.accountId as string[] | undefined;
107+
if (accountIds && accountIds.length > 0) {
108+
params.AccountIds = accountIds;
109+
}
110+
111+
return params;
112+
}

0 commit comments

Comments
 (0)