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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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