Compare commits

..

2 Commits

33 changed files with 1465 additions and 700 deletions
+18 -2
View File
@@ -53,9 +53,12 @@ import {
modelList,
workspaceList,
quotaList,
quotaRequest,
quotaUpdate,
quotaHistory,
quotaCheck,
permissionList,
permissionGrant,
permissionRevoke,
datasetUpload,
datasetList,
datasetGet,
@@ -174,9 +177,12 @@ export const commands: Record<string, AnyCommand> = {
"model list": modelList,
"workspace list": workspaceList,
"quota list": quotaList,
"quota request": quotaRequest,
"quota update": quotaUpdate,
"quota history": quotaHistory,
"quota check": quotaCheck,
"permission list": permissionList,
"permission grant": permissionGrant,
"permission revoke": permissionRevoke,
"dataset upload": datasetUpload,
"dataset list": datasetList,
"dataset get": datasetGet,
@@ -235,3 +241,13 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent session events": managedAgentSessionEvents,
"managed-agent skill-list": managedAgentSkillList,
};
/**
* Runtime-only aliases for renamed commands: dispatched by the CLI (merged in
* main.ts) but kept out of the canonical map so generate-reference.ts only
* documents the canonical path.
*/
export const commandAliases: Record<string, AnyCommand> = {
// Pre-migration name of "quota update".
"quota request": quotaUpdate,
};
+12 -9
View File
@@ -1,5 +1,5 @@
import { createCli } from "bailian-cli-runtime";
import { commands } from "./commands.ts";
import { commandAliases, commands } from "./commands.ts";
import { commandPackPolicy } from "./command-pack-policy.ts";
import pkg from "../package.json" with { type: "json" };
@@ -10,11 +10,14 @@ const quickStartTasks = [
"Help me analyze this video and write a Xiaohongshu-style post",
] as const;
void createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
}).run();
void createCli(
{ ...commands, ...commandAliases },
{
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
},
).run();
@@ -1,15 +1,14 @@
import {
defineCommand,
detectOutputFormat,
fetchModelList,
fetchModelListAll,
fetchModelCapability,
listSupportedTrainingTypes,
modelSupportsTrainingType,
isTrainingTypeCli,
trainingTypeMethodVariant,
TRAINING_TYPES_CLI,
callConsoleGateway,
effectiveConsoleGatewayConfig,
anonymousConsoleCall,
UsageError,
type Settings,
type ModelCapability,
@@ -17,8 +16,6 @@ import {
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const PAGE_SIZE = 50;
/**
* Page through every foundation-model page (listFoundationModels, public — no
* console login needed, so the gateway is called anonymously). Returns raw
@@ -26,20 +23,7 @@ const PAGE_SIZE = 50;
* for filtering.
*/
async function fetchAllFoundationModels(settings: Settings): Promise<ModelCapability[]> {
const eff = effectiveConsoleGatewayConfig(settings);
const call = (api: string, data: Record<string, unknown>) =>
callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
settings.timeout,
{ api, data },
);
const first = await fetchModelList(call, { pageNo: 1, pageSize: PAGE_SIZE });
const all = [...first.models];
const totalPages = Math.ceil(first.total / PAGE_SIZE);
for (let pageNo = 2; pageNo <= totalPages; pageNo++) {
const result = await fetchModelList(call, { pageNo, pageSize: PAGE_SIZE });
all.push(...result.models);
}
const all = await fetchModelListAll(anonymousConsoleCall(settings));
return all as ModelCapability[];
}
+9 -7
View File
@@ -1,4 +1,5 @@
import {
anonymousConsoleCall,
defineCommand,
detectOutputFormat,
fetchModelDetail,
@@ -290,7 +291,7 @@ function printPredictConfigTable(entries: PredictConfigEntry[]): void {
export default defineCommand({
description: "Browse model families or show detailed model info in the Bailian model marketplace",
auth: "console",
auth: "none",
usageArgs:
"[--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]",
flags: LIST_FLAGS,
@@ -302,10 +303,14 @@ export default defineCommand({
"--model qwen-max --enrich --output json",
"--feature function-calling --output json",
],
notes: [
"Both the catalog and --enrich parameter-schema endpoints are public — no console login needed.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
const modelKey = flags.model;
const call = anonymousConsoleCall(settings);
// ── Detail mode ──
if (modelKey) {
@@ -316,7 +321,7 @@ export default defineCommand({
return;
}
const detail = await fetchModelDetail(ctx.client.console.bind(ctx.client), modelKey);
const detail = await fetchModelDetail(call, modelKey);
if (!detail) {
emitBare(`Model "${modelKey}" not found.`);
@@ -328,10 +333,7 @@ export default defineCommand({
await Promise.all(
trunkItems.map(async (item) => {
if (!item.model) return;
const config = await fetchPredictConfig(
ctx.client.console.bind(ctx.client),
item.model,
);
const config = await fetchPredictConfig(call, item.model);
if (config) item.predictConfig = config;
}),
);
@@ -361,7 +363,7 @@ export default defineCommand({
return;
}
const { total, groups } = await fetchModelGroups(ctx.client.console.bind(ctx.client), params);
const { total, groups } = await fetchModelGroups(call, params);
if (format === "json") {
emitResult(formatBrowseJson(groups, total), format);
@@ -0,0 +1,41 @@
import { defineCommand } from "bailian-cli-core";
import { runPermissionChange, validatePermissionChange } from "./shared.ts";
export default defineCommand({
description: "Grant model permissions (inference / finetune / deploy)",
auth: "apiKey",
usageArgs: "--model <models> [--action <actions>] | --all",
flags: {
model: {
type: "string",
valueHint: "<models>",
description: "Model ID(s), comma-separated (max 20)",
},
action: {
type: "string",
valueHint: "<actions>",
description:
"Permission action(s), comma-separated: inference, finetune, deploy (default: inference)",
},
all: {
type: "switch",
description:
"One-key grant inference for all models in the workspace (including future ones)",
},
},
exampleArgs: [
"--model qwen-plus",
"--model qwen-plus,qwen3-max --action inference,finetune",
"--all",
"--model qwen-plus --dry-run --output json",
],
notes: [
"Grants apply to the business workspace your API key belongs to.",
"--all maps to the server one-key switch (access_all_entities: OPEN) and only covers inference.",
"Actions you omit keep their current grants (server-side tri-state patch).",
],
validate: (flags) => validatePermissionChange(flags),
async run(ctx) {
await runPermissionChange(ctx, ctx.flags, true);
},
});
@@ -0,0 +1,145 @@
import { defineCommand, detectOutputFormat, modelsPermissionsPath } from "bailian-cli-core";
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
import { buildQuery } from "../shared/params.ts";
// ---------------------------------------------------------------------------
// Types — mirror GET /api/v1/models/permissions
// ---------------------------------------------------------------------------
interface PermissionDetail {
inference?: boolean | null;
fine_tune?: boolean | null;
deploy?: boolean | null;
}
interface ModelPermission {
model: string;
name?: string;
permissions?: PermissionDetail;
}
interface PermissionsResponse {
output?: {
total?: number;
page_no?: number;
page_size?: number;
permissions?: ModelPermission[];
};
request_id?: string;
}
// ---------------------------------------------------------------------------
// Formatters
// ---------------------------------------------------------------------------
/** Tri-state permission cell: true → yes, false → no, null/undefined → "-". */
function formatGrant(granted: boolean | null | undefined): string {
if (granted == null) return "-";
return granted ? "yes" : "no";
}
function printTable(permissions: ModelPermission[], total: number, emptyHint: string): void {
if (permissions.length === 0) {
process.stdout.write(`No model permissions found.\n${emptyHint}\n`);
return;
}
const headers = ["Model", "Name", "Inference", "Fine-tune", "Deploy"];
const rows = permissions.map((entry) => [
entry.model,
entry.name ?? "-",
formatGrant(entry.permissions?.inference),
formatGrant(entry.permissions?.fine_tune),
formatGrant(entry.permissions?.deploy),
]);
const lines = renderBoxTable({
headers,
rows,
align: ["left", "left", "right", "right", "right"],
});
for (const line of lines) process.stdout.write(line + "\n");
process.stdout.write(`\nTotal: ${total}\n`);
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
export default defineCommand({
description: "List model permissions (inference / fine-tune / deploy) in the workspace",
auth: "apiKey",
usageArgs: "[--scope <scope>] [--model <model>] [--name <name>] [--page <n>] [--page-size <n>]",
flags: {
scope: {
type: "string",
valueHint: "<scope>",
choices: ["authorized", "authorizable"] as const,
description: "Authorization scope: authorizable (default, full catalog), authorized",
},
model: {
type: "string",
valueHint: "<model>",
description: "Model ID (exact match)",
},
name: {
type: "string",
valueHint: "<name>",
description: "Fuzzy search by model name or ID",
},
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 20)" },
},
exampleArgs: [
"",
"--model qwen-plus",
"--scope authorized",
"--name qwen --page-size 50",
"--output text",
],
notes: [
"Default scope is `authorizable` (the full grantable catalog); use `--scope authorized` to see only models already granted.",
"Output defaults to JSON; pass `--output text` for a table. Permission values are tri-state: true / false / null (never set).",
"Values mirror the server's grant records as-is for the workspace bound to your API key. A model reporting false/null can still be callable (access may come from other channels); see the Model Studio authorization docs for the exact semantics.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
const scope = flags.scope ?? "authorizable";
const query = {
authorization_scope: scope.toUpperCase(),
model: flags.model || undefined,
name: flags.name || undefined,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
};
if (settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsPermissionsPath()), method: "GET", query },
format,
);
return;
}
const resp = await ctx.client.requestJson<PermissionsResponse>({
path: modelsPermissionsPath() + buildQuery(query),
});
const permissions = resp.output?.permissions ?? [];
const total = resp.output?.total ?? permissions.length;
if (format === "json") {
emitResult({ items: permissions, total }, format);
return;
}
// The default authorized view is empty until something is granted — point
// at the authorizable catalog instead of ending with a bare "nothing".
const binName = ctx.identity.binName;
const emptyHint =
scope === "authorized"
? `Nothing granted yet in this workspace. Browse grantable models with \`${binName} permission list --scope authorizable\`, then grant with \`${binName} permission grant --model <model>\`.`
: `Adjust --name/--model filters, or check pagination with --page/--page-size.`;
printTable(permissions, total, emptyHint);
},
});
@@ -0,0 +1,52 @@
import { defineCommand, BailianError, ExitCode } from "bailian-cli-core";
import { runPermissionChange, validatePermissionChange } from "./shared.ts";
export default defineCommand({
description: "Revoke model permissions (inference / finetune / deploy)",
auth: "apiKey",
usageArgs: "--model <models> [--action <actions>] | --all --yes",
flags: {
model: {
type: "string",
valueHint: "<models>",
description: "Model ID(s), comma-separated (max 20)",
},
action: {
type: "string",
valueHint: "<actions>",
description:
"Permission action(s), comma-separated: inference, finetune, deploy (default: inference)",
},
all: {
type: "switch",
description: "Close one-key authorization and clear ALL historical inference grants",
},
yes: {
type: "switch",
description: "Confirm --all without an interactive prompt (required)",
},
},
exampleArgs: [
"--model qwen-plus",
"--model qwen-plus,qwen3-max --action inference,finetune",
"--all --yes",
"--model qwen-plus --dry-run --output json",
],
notes: [
"Grants apply to the business workspace your API key belongs to.",
"--all maps to the server one-key switch (access_all_entities: CLOSE): it clears every historical inference grant and cannot be undone, so it requires --yes.",
"Actions you omit keep their current grants (server-side tri-state patch).",
],
validate: (flags) => validatePermissionChange(flags),
async run(ctx) {
const { flags, settings } = ctx;
if (flags.all && !flags.yes && !settings.dryRun) {
throw new BailianError(
"Refusing to clear all historical inference grants without confirmation.",
ExitCode.USAGE,
"Re-run with --yes to close one-key authorization (or preview with --dry-run).",
);
}
await runPermissionChange(ctx, flags, false);
},
});
@@ -0,0 +1,109 @@
import {
detectOutputFormat,
modelsPermissionsPath,
type Client,
type Settings,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { parseCommaList } from "../shared/params.ts";
// POST /api/v1/models/permissions accepts at most 20 models per call.
export const MAX_MODELS_PER_REQUEST = 20;
// POST body field names (server ignores unknown keys silently — the docs' curl
// example spells `fine_tune`, but only `finetune` actually takes effect).
export const PERMISSION_ACTIONS = ["inference", "finetune", "deploy"] as const;
export type PermissionAction = (typeof PERMISSION_ACTIONS)[number];
/** Parse --action into deduped actions (default: inference); returns an error message on bad values. */
export function parsePermissionActions(
actionFlag: string | undefined,
): PermissionAction[] | { error: string } {
if (!actionFlag) return ["inference"];
const actions = parseCommaList(actionFlag);
if (actions.length === 0) return { error: "--action must not be empty." };
for (const action of actions) {
if (!(PERMISSION_ACTIONS as readonly string[]).includes(action)) {
return { error: `--action "${action}" is invalid; use ${PERMISSION_ACTIONS.join(", ")}.` };
}
}
return actions as PermissionAction[];
}
/** Cross-flag validation shared by grant and revoke. */
export function validatePermissionChange(flags: {
model?: string;
action?: string;
all: boolean;
}): string | undefined {
if (flags.all && flags.model) return "--all cannot be combined with --model.";
if (!flags.all && !flags.model) return "one of --model / --all is required.";
const actions = parsePermissionActions(flags.action);
if ("error" in actions) return actions.error;
if (flags.all && (actions.length !== 1 || actions[0] !== "inference"))
return "--all only supports the inference action.";
if (flags.model) {
const models = parseCommaList(flags.model);
if (models.length === 0) return "--model must not be empty.";
if (models.length > MAX_MODELS_PER_REQUEST)
return `--model accepts at most ${MAX_MODELS_PER_REQUEST} models per call.`;
}
}
/**
* Shared grant/revoke execution: build the POST body (per-model tri-state
* patch, or the access_all_entities one-key switch) and send it. Validation
* (mutual exclusion, action values, model count) has already run.
*/
export async function runPermissionChange(
ctx: { settings: Settings; client: Client },
flags: { model?: string; action?: string; all: boolean },
grant: boolean,
): Promise<void> {
const format = ctx.settings.outputExplicit ? detectOutputFormat(ctx.settings.output) : "json";
const actions = parsePermissionActions(flags.action) as PermissionAction[];
const models = flags.model ? parseCommaList(flags.model) : [];
const body: Record<string, unknown> = flags.all
? { access_all_entities: grant ? "OPEN" : "CLOSE" }
: {
models: models.map((model) => {
const entry: Record<string, unknown> = { model };
for (const action of actions) entry[action] = grant;
return entry;
}),
};
if (ctx.settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsPermissionsPath()), method: "POST", request: body },
format,
);
return;
}
const result = await ctx.client.requestJson<{ request_id?: string }>({
path: modelsPermissionsPath(),
method: "POST",
body,
});
const verb = grant ? "granted" : "revoked";
if (format === "json") {
const summary: Record<string, unknown> = flags.all
? { all: true, action: "inference" }
: { models, actions };
emitResult({ ...summary, [verb]: true, request_id: result.request_id }, format);
return;
}
if (flags.all) {
process.stdout.write(
grant
? "Inference permission granted for all models in the workspace (including future ones).\n"
: "One-key authorization closed; historical inference grants cleared.\n",
);
return;
}
process.stdout.write(`Permissions ${verb} (${actions.join(", ")}): ${models.join(", ")}\n`);
}
@@ -1,6 +1,7 @@
import { defineCommand, detectOutputFormat, BailianError, ExitCode } from "bailian-cli-core";
import { ansi, emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
const HISTORY_API = "zeldaEasy.broadscope-platform.modelInstance.listModelLimitApplications";
@@ -49,10 +50,6 @@ function formatDateTime(ts: string | undefined): string {
}
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
function printTable(records: LimitApplicationItem[], total: number): void {
const color = ansi(process.stdout);
+142 -244
View File
@@ -1,297 +1,195 @@
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
unwrapResponse,
MODEL_LIST_API,
type Client,
} from "bailian-cli-core";
import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core";
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
import { buildQuery, parseCommaList } from "../shared/params.ts";
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
// ---------------------------------------------------------------------------
// Types — mirror GET /api/v1/models/limits
// ---------------------------------------------------------------------------
interface QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
interface LimitSpec {
request_limit: number | null;
request_limit_period: number | null;
usage_limit: number | null;
usage_limit_field: string | null;
usage_limit_period: number | null;
async_user_queue_limit: number | null;
async_user_concurrency_limit: number | null;
}
interface ModelWithQpm {
interface ModelQuota {
model: string;
qpmInfo?: Record<string, QpmInfoItem>;
workspace_id?: string;
model_limit?: LimitSpec | null;
workspace_limit?: LimitSpec | null;
}
interface MonitorPoint {
value: number;
timestamp: number;
interface LimitsResponse {
output?: {
total?: number;
page_no?: number;
page_size?: number;
quotas?: ModelQuota[];
};
request_id?: string;
}
interface MonitorMetric {
aggMethod: string;
metricName: string;
points: MonitorPoint[];
// ---------------------------------------------------------------------------
// Formatters
// ---------------------------------------------------------------------------
/** Compact rate display: `500/s`, `60/min`, `83,333/6s`; "-" when unlimited. */
function formatLimit(limit: number | null | undefined, period: number | null | undefined): string {
if (limit == null) return "-";
const seconds = period ?? 60;
if (seconds === 1) return `${formatNumber(limit)}/s`;
if (seconds === 60) return `${formatNumber(limit)}/min`;
return `${formatNumber(limit)}/${seconds}s`;
}
function calculateRPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.count_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.count_limit * 60) / period);
function formatRequestLimit(spec: LimitSpec | null | undefined): string {
if (!spec) return "-";
return formatLimit(spec.request_limit, spec.request_limit_period);
}
function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.usage_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.usage_limit * 60) / period);
function formatUsageLimit(spec: LimitSpec | null | undefined): string {
if (!spec) return "-";
return formatLimit(spec.usage_limit, spec.usage_limit_period);
}
function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
async function fetchMonitorData(
client: Client,
modelName: string,
windowMinutes: number,
): Promise<{ rpm: number; tpm: number }> {
const now = Date.now();
const startTime = now - windowMinutes * 60 * 1000;
try {
const raw = await client.console(MONITOR_API, {
reqDTO: {
monitorType: "Advanced",
metricFilters: [
{ aggMethod: "sum_pm", metricName: "model_total_amount" },
{ aggMethod: "sum_pm", metricName: "model_call_count" },
],
labelFilters: {
resourceId: modelName,
resourceType: "model",
},
startTime,
endTime: now,
},
});
const resp = unwrapResponse(raw as Record<string, unknown>);
const metrics = (resp.data ?? resp) as MonitorMetric[] | Record<string, unknown>;
if (!Array.isArray(metrics)) {
return { rpm: 0, tpm: 0 };
}
let rpm = 0;
let tpm = 0;
for (const metric of metrics) {
if (metric.aggMethod !== "sum_pm" || !metric.points?.length) continue;
const lastValue = metric.points[metric.points.length - 1].value ?? 0;
if (metric.metricName === "model_call_count") rpm = Math.round(lastValue);
if (metric.metricName === "model_total_amount") tpm = Math.round(lastValue);
}
return { rpm, tpm };
} catch (error) {
// Re-throw authentication errors (BailianError with ExitCode.AUTH);
// other errors are treated as "no data" and show "-" in the table.
if (error instanceof BailianError && error.exitCode === ExitCode.AUTH) {
throw error;
}
return { rpm: -1, tpm: -1 };
/** Async task headroom as `queue/concurrency`; "-" when the model has no async limits. */
function formatAsync(spec: LimitSpec | null | undefined): string {
if (!spec || (spec.async_user_queue_limit == null && spec.async_user_concurrency_limit == null)) {
return "-";
}
const queue =
spec.async_user_queue_limit != null ? formatNumber(spec.async_user_queue_limit) : "-";
const concurrency =
spec.async_user_concurrency_limit != null
? formatNumber(spec.async_user_concurrency_limit)
: "-";
return `${queue}/${concurrency}`;
}
async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
const allModels: ModelWithQpm[] = [];
let pageNo = 1;
while (true) {
const input: Record<string, unknown> = {
pageNo,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
};
const raw = await client.console(MODEL_LIST_API, { input });
const resp = unwrapResponse(raw as Record<string, unknown>);
const list = (resp.list as ModelWithQpm[]) ?? [];
const total = (resp.total as number) ?? 0;
allModels.push(...list);
if (allModels.length >= total || list.length === 0) break;
pageNo++;
function printTable(quotas: ModelQuota[], total: number): void {
if (quotas.length === 0) {
process.stdout.write("No rate limits found.\n");
return;
}
return allModels;
}
interface ListRow {
model: string;
rpm: string;
tpm: string;
rpmQuotaLeft: number | null;
tpmQuotaLeft: number | null;
rpmQuotaLabel: string | null;
tpmQuotaLabel: string | null;
}
function printTable(rows: ListRow[]): void {
const headers = ["Model", "Req/min", "Token/min", "RPM Left", "TPM Left"];
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
const tableRows = rows.map((r) => [r.model, r.rpm, r.tpm, "", ""]);
const headers = ["Model", "Req Limit", "Usage Limit", "WS Req", "WS Usage", "Async Q/C"];
const rows = quotas.map((quota) => [
quota.model,
formatRequestLimit(quota.model_limit),
formatUsageLimit(quota.model_limit),
formatRequestLimit(quota.workspace_limit),
formatUsageLimit(quota.workspace_limit),
formatAsync(quota.model_limit),
]);
const lines = renderBoxTable({
headers,
rows: tableRows,
align: ["left", "right", "right", "left", "left"],
barColumns: [
{ index: 3, percents: rpmPercents, labels: rpmLabels, width: 15 },
{ index: 4, percents: tpmPercents, labels: tpmLabels, width: 15 },
],
rows,
align: ["left", "right", "right", "right", "right", "right"],
});
for (const line of lines) process.stdout.write(line + "\n");
process.stdout.write(`\nTotal: ${total}\n`);
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
export default defineCommand({
description: "View model RPM/TPM rate limits",
auth: "console",
usageArgs: "[--model <model>] [flags]",
description: "View model rate limits (QPM/TPM, account and workspace level)",
auth: "apiKey",
usageArgs: "[--model <model>] [--name <name>] [--page <n>] [--page-size <n>]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name(s), comma-separated",
description: "Model name(s), comma-separated (exact match)",
},
name: {
type: "string",
valueHint: "<name>",
description: "Fuzzy search by model name",
},
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Results per page (default: 20)" },
},
exampleArgs: ["", "--model qwen3.6-plus", "--model qwen3.6-plus,qwen-turbo", "--output json"],
exampleArgs: [
"",
"--model qwen3-max",
"--model qwen3-max,qwen-plus",
"--name qwen --page-size 50",
"--output json",
],
notes: ["Usage-vs-limit pressure checks live in `quota check` (console auth)."],
async run(ctx) {
const { settings, flags } = ctx;
const modelFlag = flags.model || undefined;
const nameFlag = flags.name || undefined;
const format = detectOutputFormat(settings.output);
const endpoint = ctx.client.url(modelsLimitsPath());
if (settings.dryRun) {
const input: Record<string, unknown> = {
pageNo: 1,
pageSize: 50,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
};
emitResult(
{
apis: [
MODEL_LIST_API,
{ api: MONITOR_API, note: "called per-model for text output with gauges" },
],
modelListInput: { input },
},
format,
);
if (modelFlag) {
// One exact-match GET per model; dry-run lists them all.
const requests = parseCommaList(modelFlag).map((model) => ({
endpoint,
method: "GET",
query: { model, page_size: 100 },
}));
emitResult({ requests }, format);
} else {
emitResult(
{
endpoint,
method: "GET",
query: {
name: nameFlag,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
},
},
format,
);
}
return;
}
let models = await fetchAllModelsWithQpm(ctx.client);
let quotas: ModelQuota[];
let total: number;
if (modelFlag) {
const names = new Set(
modelFlag
.split(",")
.map((n) => n.trim())
.filter(Boolean),
// Exact lookup per model, then merge.
const responses = await Promise.all(
parseCommaList(modelFlag).map((model) =>
ctx.client.requestJson<LimitsResponse>({
path: modelsLimitsPath() + buildQuery({ model, page_size: 100 }),
}),
),
);
models = models.filter((m) => names.has(m.model));
if (models.length === 0) {
throw new BailianError(`no matching models found for "${modelFlag}".`);
}
quotas = responses.flatMap((resp) => resp.output?.quotas ?? []);
total = quotas.length;
} else {
const resp = await ctx.client.requestJson<LimitsResponse>({
path:
modelsLimitsPath() +
buildQuery({
name: nameFlag,
page_no: flags.page || 1,
page_size: flags.pageSize || 20,
}),
});
quotas = resp.output?.quotas ?? [];
total = resp.output?.total ?? quotas.length;
}
if (format === "json") {
const items = models.map((m) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
return {
model: m.model,
rpm: currentRPM > 0 ? currentRPM : null,
tpm: currentTPM > 0 ? currentTPM : null,
};
});
emitResult(items, format);
emitResult({ items: quotas, total }, format);
return;
}
// For text output with gauges, we need monitor data
const monitorResults = await Promise.all(
models.map((m) => fetchMonitorData(ctx.client, m.model, 2)),
);
const rows: ListRow[] = models.map((m, idx) => {
const qpm = m.qpmInfo;
const modelDefault = qpm?.["model-default"];
const userSpec = qpm?.["user-spec"];
const defaultRPM = calculateRPM(modelDefault);
const defaultTPM = calculateTPM(modelDefault);
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
const rpmUsage = monitorResults[idx].rpm;
const tpmUsage = monitorResults[idx].tpm;
// RPM Quota Left = 1 - (rpmUsage / currentRPM) in percentage
let rpmQuotaPercent: number | null = null;
let rpmQuotaLabel: string | null = null;
if (rpmUsage >= 0 && currentRPM > 0) {
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / currentRPM) * 100);
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
}
// TPM Quota Left = 1 - (tpmUsage / currentTPM) in percentage
let tpmQuotaPercent: number | null = null;
let tpmQuotaLabel: string | null = null;
if (tpmUsage >= 0 && currentTPM > 0) {
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / currentTPM) * 100);
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
}
return {
model: m.model,
rpm: currentRPM > 0 ? formatNumber(currentRPM) : "-",
tpm: currentTPM > 0 ? formatNumber(currentTPM) : "-",
rpmQuotaLeft: rpmQuotaPercent,
tpmQuotaLeft: tpmQuotaPercent,
rpmQuotaLabel,
tpmQuotaLabel,
};
});
if (rows.length === 0) {
process.stdout.write("No models found.\n");
return;
}
printTable(rows);
printTable(quotas, total);
},
});
@@ -1,188 +0,0 @@
import {
defineCommand,
UsageError,
BailianError,
ExitCode,
detectOutputFormat,
type Client,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
const UPDATE_LIMITS_API = "zeldaEasy.broadscope-platform.modelInstance.updateFoundationModelLimits";
interface QpmInfoItem {
count_limit: number;
count_limit_period: number;
usage_limit: number;
usage_limit_period: number;
usage_limit_field: string;
type: string;
}
function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
if (!item) return 0;
const period = item.usage_limit_period || fallbackPeriod;
if (!period) return 0;
return Math.floor((item.usage_limit * 60) / period);
}
function getNestedRecord(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
return undefined;
}
function extractResponseData(result: Record<string, unknown>): Record<string, unknown> {
const data = getNestedRecord(result, "data");
if (!data) return result;
const dataV2 = getNestedRecord(data, "DataV2");
if (dataV2) {
const inner = getNestedRecord(dataV2, "data");
const innerData = inner ? getNestedRecord(inner, "data") : undefined;
return innerData ?? inner ?? dataV2;
}
const direct = getNestedRecord(data, "data");
return direct ?? data;
}
async function fetchModelQpmInfo(
client: Client,
modelName: string,
): Promise<{ model: string; qpmInfo: Record<string, QpmInfoItem> } | undefined> {
const raw = await client.console(MODEL_LIST_API, {
input: {
pageNo: 1,
pageSize: 50,
name: modelName,
group: false,
queryQpmInfo: true,
ignoreWorkspaceServiceSite: true,
supports: { selfServiceLimitIncrease: true },
},
});
const resp = extractResponseData(raw as Record<string, unknown>);
const list = (resp.list as Array<{ model: string; qpmInfo?: Record<string, QpmInfoItem> }>) ?? [];
return list.find((m) => m.model === modelName && m.qpmInfo) as
| { model: string; qpmInfo: Record<string, QpmInfoItem> }
| undefined;
}
export default defineCommand({
description: "Request a temporary quota increase",
auth: "console",
usageArgs: "--model <model> --tpm <value> [flags]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name (required)",
required: true,
},
tpm: {
type: "string",
valueHint: "<value>",
description: "Target TPM value (required)",
required: true,
},
},
exampleArgs: [
"--model qwen-turbo --tpm 100000",
"--model qwen3.6-plus --tpm 8000000",
"--model qwen-turbo --tpm 100000 --output json",
],
validate: (f) => (Number(f.tpm) > 0 ? undefined : "--tpm must be a positive number."),
async run(ctx) {
const { identity, settings, flags } = ctx;
const modelName = flags.model;
const tpmValue = Number(flags.tpm);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
const requestData = {
input: {
model: modelName,
limit: { usage_limit: tpmValue },
},
};
emitResult({ api: UPDATE_LIMITS_API, data: requestData }, format);
return;
}
const modelInfo = await fetchModelQpmInfo(ctx.client, modelName);
if (!modelInfo) {
throw new BailianError(
`model "${modelName}" not found or does not support self-service quota increase.`,
ExitCode.GENERAL,
`Run \`${identity.binName} quota list\` to view available models.`,
);
}
const modelDefault = modelInfo.qpmInfo["model-default"];
const userSpec = modelInfo.qpmInfo["user-spec"];
const minLimit = calculateTPM(modelDefault);
const currentLimit = calculateTPM(userSpec, modelDefault?.usage_limit_period) || minLimit;
const maxLimit = minLimit * 2;
if (tpmValue < minLimit || tpmValue > maxLimit) {
throw new UsageError(
`TPM value ${tpmValue.toLocaleString()} is out of range. ` +
`Current: ${currentLimit.toLocaleString()}, Range: ${minLimit.toLocaleString()} ~ ${maxLimit.toLocaleString()}.`,
);
}
const requestData = {
input: {
model: modelName,
limit: { usage_limit: tpmValue },
originalQpmInfo: modelInfo.qpmInfo,
} as Record<string, unknown>,
};
const submitRequest = async (confirmedDowngrade?: boolean): Promise<unknown> => {
if (confirmedDowngrade) {
requestData.input.confirmedDowngrade = true;
}
try {
return await ctx.client.console(UPDATE_LIMITS_API, requestData);
} catch (err) {
if (err instanceof BailianError && err.message.includes("NotLogined")) {
throw new BailianError(
"session expired.",
ExitCode.AUTH,
`Run \`${identity.binName} auth login --console\` to re-authenticate.`,
);
}
throw err;
}
};
let result = await submitRequest();
const resp = extractResponseData(result as Record<string, unknown>);
if (resp.needConfirm) {
const confirmCode = resp.confirmCode as string;
if (confirmCode === "Refresh_Required") {
throw new BailianError("rate limit has been updated externally. Please retry.");
}
if (confirmCode === "Downgrade") {
result = await submitRequest(true);
}
}
if (format === "json") {
emitResult(result, format);
return;
}
process.stdout.write(
`Quota updated for "${modelName}": TPM ${currentLimit.toLocaleString()}${tpmValue.toLocaleString()}\n`,
);
},
});
@@ -0,0 +1,100 @@
import { defineCommand, detectOutputFormat, modelsLimitsPath } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
const MINUTE_SECONDS = 60;
export default defineCommand({
description: "Update model rate limits (QPM/TPM), or clear them with --delete",
auth: "apiKey",
usageArgs: "--model <model> [--rpm <n>] [--tpm <n>] [--delete]",
flags: {
model: {
type: "string",
valueHint: "<model>",
description: "Model name (required)",
required: true,
},
rpm: {
type: "number",
valueHint: "<n>",
description: "Max requests per minute (QPM)",
},
tpm: {
type: "number",
valueHint: "<n>",
description: "Max tokens per minute (TPM)",
},
delete: {
type: "switch",
description: "Clear all custom rate limits for the model",
},
},
exampleArgs: [
"--model qwen-plus --rpm 60 --tpm 100000",
"--model qwen3-max --tpm 500000",
"--model qwen-plus --delete",
"--model qwen-plus --rpm 60 --output json",
],
notes: [
"Fields you omit keep their current values (server-side OVERLAY merge); --delete clears all custom limits.",
"Setting TPM without an existing QPM limit is rejected server-side — pass --rpm first or together.",
],
validate: (flags) => {
if (flags.delete && (flags.rpm !== undefined || flags.tpm !== undefined))
return "--delete cannot be combined with --rpm/--tpm.";
if (!flags.delete && flags.rpm === undefined && flags.tpm === undefined)
return "one of --rpm / --tpm / --delete is required.";
if (flags.rpm !== undefined && flags.rpm < 0) return "--rpm must be a non-negative number.";
if (flags.tpm !== undefined && flags.tpm < 0) return "--tpm must be a non-negative number.";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const modelName = flags.model;
const format = detectOutputFormat(settings.output);
const entry: Record<string, unknown> = { model: modelName };
if (flags.delete) {
entry.operation_type = "DELETE";
} else {
if (flags.rpm !== undefined) {
entry.request_limit = flags.rpm;
entry.request_limit_period = MINUTE_SECONDS;
}
if (flags.tpm !== undefined) {
entry.usage_limit = flags.tpm;
entry.usage_limit_period = MINUTE_SECONDS;
}
}
const body = { models: [entry] };
if (settings.dryRun) {
emitResult(
{ endpoint: ctx.client.url(modelsLimitsPath()), method: "POST", request: body },
format,
);
return;
}
const result = await ctx.client.requestJson<{ request_id?: string }>({
path: modelsLimitsPath(),
method: "POST",
body,
});
if (format === "json") {
emitResult({ model: modelName, ...result }, format);
return;
}
if (flags.delete) {
process.stdout.write(`Rate limits cleared for "${modelName}".\n`);
return;
}
const parts: string[] = [];
if (flags.rpm !== undefined) parts.push(`QPM ${formatNumber(flags.rpm)}`);
if (flags.tpm !== undefined) parts.push(`TPM ${formatNumber(flags.tpm)}`);
process.stdout.write(`Rate limits updated for "${modelName}": ${parts.join(", ")}\n`);
},
});
@@ -0,0 +1,4 @@
/** Format an integer with en-US thousands separators for table / text output. */
export function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
@@ -0,0 +1,21 @@
/** Split a comma-separated flag value into trimmed, deduped, non-empty entries. */
export function parseCommaList(value: string): string[] {
return [
...new Set(
value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
/** Serialize defined, non-empty params into a `?key=value` query string ("" when empty). */
export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") search.set(key, String(value));
}
const queryString = search.toString();
return queryString ? `?${queryString}` : "";
}
@@ -1,7 +1,7 @@
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts";
import { formatNumber } from "./shared.ts";
import { formatNumber } from "../shared/format.ts";
const CODING_PLAN_USAGE_API =
"zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2";
+4 -6
View File
@@ -1,4 +1,4 @@
import { defineCommand, detectOutputFormat, fetchModelList } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, findModelByName } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import {
FREE_TIER_API,
@@ -93,13 +93,11 @@ export default defineCommand({
}
requestData.queryFreeTierQuotaRequest.models = models;
} else {
const searchResults = await Promise.all(
models.map((name) =>
fetchModelList((api, data) => ctx.client.console(api, data), { name, pageSize: 50 }),
),
const matches = await Promise.all(
models.map((name) => findModelByName((api, data) => ctx.client.console(api, data), name)),
);
for (let idx = 0; idx < models.length; idx++) {
const matched = searchResults[idx].models.find((item) => item.model === models[idx]);
const matched = matches[idx];
if (matched) {
typeMap.set(models[idx], resolveModelType((matched.capabilities as string[]) || []));
}
+3 -16
View File
@@ -1,5 +1,5 @@
import {
fetchModelList,
fetchModelListAll,
BailianError,
ExitCode,
unwrapResponse,
@@ -7,15 +7,12 @@ import {
type Settings,
} from "bailian-cli-core";
import { ansi, renderBoxTable, displayWidth, padEnd } from "bailian-cli-runtime";
import { formatNumber } from "../shared/format.ts";
// ---------------------------------------------------------------------------
// Common formatters
// ---------------------------------------------------------------------------
export function formatNumber(num: number): string {
return num.toLocaleString("en-US");
}
export function formatDate(ts: number): string {
const date = new Date(ts);
const year = date.getFullYear();
@@ -87,17 +84,7 @@ export interface ModelInfo {
}
export async function fetchAllModels(client: Client): Promise<ModelInfo[]> {
const allModels: Record<string, unknown>[] = [];
let page = 1;
while (true) {
const result = await fetchModelList((api, data) => client.console(api, data), {
pageNo: page,
pageSize: 50,
});
allModels.push(...result.models);
if (allModels.length >= result.total) break;
page++;
}
const allModels = await fetchModelListAll((api, data) => client.console(api, data));
return allModels
.filter((item) => typeof item.model === "string" && item.model)
.map((item) => ({
@@ -8,13 +8,13 @@ import {
extractListData,
extractOverviewData,
formatDate,
formatNumber,
pollTelemetryApi,
requireWorkspaceId,
resolveUsageMap,
type ModelStatisticItem,
type OverviewStatistic,
} from "./shared.ts";
import { formatNumber } from "../shared/format.ts";
interface UsageLabel {
en: string;
+4 -1
View File
@@ -56,9 +56,12 @@ export { default as advisorRecommend } from "./commands/advisor/recommend.ts";
export { default as modelList } from "./commands/model/list.ts";
export { default as workspaceList } from "./commands/workspace/list.ts";
export { default as quotaList } from "./commands/quota/list.ts";
export { default as quotaRequest } from "./commands/quota/request.ts";
export { default as quotaUpdate } from "./commands/quota/update.ts";
export { default as quotaHistory } from "./commands/quota/history.ts";
export { default as quotaCheck } from "./commands/quota/check.ts";
export { default as permissionList } from "./commands/permission/list.ts";
export { default as permissionGrant } from "./commands/permission/grant.ts";
export { default as permissionRevoke } from "./commands/permission/revoke.ts";
export { default as datasetUpload } from "./commands/dataset/upload.ts";
export { default as datasetList } from "./commands/dataset/list.ts";
export { default as datasetGet } from "./commands/dataset/get.ts";
@@ -0,0 +1,267 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { PERMISSION_ROUTES } from "./topic-routes.ts";
describe("e2e: permission", () => {
test("permission list --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--scope");
expect(stderr).toContain("--model");
expect(stderr).toContain("--name");
expect(stderr).toContain("--page-size");
});
test("permission grant --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--action");
expect(stderr).toContain("--all");
});
test("permission revoke --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"revoke",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--yes");
});
test("permission grant 缺少 --model/--all 报用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("one of --model / --all");
});
test("permission grant --all 与 --model 互斥", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--all",
"--model",
"qwen-plus",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("cannot be combined");
});
test("permission grant --action 非法值报错", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--model",
"qwen-plus",
"--action",
"training",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("invalid");
});
test("permission grant --all 仅支持 inference", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--all",
"--action",
"finetune",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("only supports the inference action");
});
test("permission grant --model 超过 20 个报错", async () => {
const tooMany = Array.from({ length: 21 }, (_, index) => `model-${index}`).join(",");
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--model",
tooMany,
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("at most 20");
});
test("permission revoke --all 缺 --yes 拒绝执行", async () => {
// --yes 护栏在 run() 开头、任何网络调用之前抛出;带 dummy key 让用例不依赖环境凭证(否则 auth stage 先报 AUTH(3))。
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"revoke",
"--all",
"--api-key",
"e2e-dummy-key",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("Refusing");
expect(stderr).toContain("--yes");
});
// --dry-run 跳过 auth stage见 runtime middleware无需凭证即可断言请求形状。
// 不传 --outputpermission 命令组默认 JSON 输出。
test("permission list --dry-run 输出 GET 请求(默认 AUTHORIZABLE + JSON", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
endpoint?: string;
method?: string;
query?: { authorization_scope?: string; page_no?: number; page_size?: number };
}>(stdout);
expect(data.endpoint).toContain("/api/v1/models/permissions");
expect(data.method).toBe("GET");
expect(data.query?.authorization_scope).toBe("AUTHORIZABLE");
expect(data.query?.page_no).toBe(1);
expect(data.query?.page_size).toBe(20);
});
test("permission list --scope authorized --dry-run 透传 scope", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--scope",
"authorized",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ query?: { authorization_scope?: string } }>(stdout);
expect(data.query?.authorization_scope).toBe("AUTHORIZED");
});
test("permission grant --dry-run 输出逐模型 POST 请求体(默认 JSON", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--model",
"qwen-plus,qwen3-max",
"--action",
"inference,finetune",
"--dry-run",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
endpoint?: string;
method?: string;
request?: { models?: { model?: string; inference?: boolean; finetune?: boolean }[] };
}>(stdout);
expect(data.endpoint).toContain("/api/v1/models/permissions");
expect(data.method).toBe("POST");
expect(data.request?.models?.length).toBe(2);
expect(data.request?.models?.[0]).toEqual({
model: "qwen-plus",
inference: true,
finetune: true,
});
});
test("permission revoke --dry-run 输出取消授权请求体", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"revoke",
"--model",
"qwen-plus",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { models?: { model?: string; inference?: boolean }[] };
}>(stdout);
expect(data.request?.models?.[0]).toEqual({ model: "qwen-plus", inference: false });
});
test("permission grant --all --dry-run 输出一键授权 OPEN", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"grant",
"--all",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { access_all_entities?: string } }>(stdout);
expect(data.request?.access_all_entities).toBe("OPEN");
});
test("permission revoke --all --dry-run 免 --yes 输出 CLOSE", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"revoke",
"--all",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { access_all_entities?: string } }>(stdout);
expect(data.request?.access_all_entities).toBe("CLOSE");
});
});
// 真实调用 GET /api/v1/models/permissions。grant/revoke 只测 --dry-run——live POST
// 会真实改写业务空间的模型授权,不做 e2e。
describe.skipIf(!isDashScopeE2EReady())("e2e: permissionDashScope", () => {
test("permission list JSON 输出返回授权列表(默认 JSON", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ items?: unknown[]; total?: number }>(stdout);
expect(Array.isArray(data.items)).toBe(true);
expect(typeof data.total).toBe("number");
});
test("permission list --scope authorizable 分页生效", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--scope",
"authorizable",
"--page-size",
"5",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
items?: { model?: string; permissions?: Record<string, unknown> }[];
total?: number;
}>(stdout);
expect(data.items?.length).toBeLessThanOrEqual(5);
expect(data.total).toBeGreaterThan(0);
expect(data.items?.[0]).toHaveProperty("permissions");
});
test("permission list 文本输出正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(PERMISSION_ROUTES, [
"permission",
"list",
"--scope",
"authorizable",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
});
});
+202 -105
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vite-plus/test";
import {
isConsoleE2EReady,
isConsoleAuthFailure,
isDashScopeE2EReady,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
@@ -12,20 +13,31 @@ describe("e2e: quota", () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--name");
});
test("quota list --help 包含所有示例", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl quota list");
expect(stderr).toContain("bl quota list --model qwen3.6-plus");
expect(stderr).toContain("bl quota list --model qwen3-max");
expect(stderr).toContain("bl quota list --name qwen --page-size 50");
});
test("quota request --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "request", "--help"]);
test("quota update --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--model");
expect(stderr).toContain("--rpm");
expect(stderr).toContain("--tpm");
expect(stderr).toContain("--delete");
});
test("quota request 作为 quota update 的兼容别名可用", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, ["quota", "request", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--rpm");
expect(stderr).toContain("--delete");
});
test("quota history --help 正常退出", async () => {
@@ -53,10 +65,47 @@ describe("e2e: quota", () => {
expect(exitCode).toBe(2);
expect(stderr).toContain("at least 1 minute");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
test("quota list --dry-run 输出请求参数", async () => {
test("quota update 缺少 --rpm/--tpm/--delete 报用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("one of --rpm / --tpm / --delete");
});
test("quota update --delete 与 --rpm 互斥", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
"--delete",
"--rpm",
"60",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("cannot be combined");
});
test("quota update --rpm 负数报错", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
"--rpm",
"-1",
]);
expect(exitCode).toBe(2);
expect(stderr).toContain("non-negative");
});
// --dry-run 跳过 auth stage见 runtime middleware无需凭证即可断言请求形状。
test("quota list --dry-run 输出 GET 请求", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
@@ -66,105 +115,90 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
apis?: (string | { api: string; note?: string })[];
modelListInput?: {
input?: { queryQpmInfo?: boolean; supports?: { selfServiceLimitIncrease?: boolean } };
};
endpoint?: string;
method?: string;
query?: { page_no?: number; page_size?: number };
}>(stdout);
expect(data.apis?.[0]).toContain("listFoundationModels");
expect(data.modelListInput?.input?.queryQpmInfo).toBe(true);
expect(data.modelListInput?.input?.supports?.selfServiceLimitIncrease).toBe(true);
expect(data.endpoint).toContain("/api/v1/models/limits");
expect(data.method).toBe("GET");
expect(data.query?.page_no).toBe(1);
expect(data.query?.page_size).toBe(20);
});
test("quota list 文本输出包含英文表头", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--output", "text"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 指定模型返回结果", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"qwen3.6-plus",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota list --model 不存在的模型报错", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"nonexistent-model-xyz-99999",
"--output",
"text",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("no matching models found");
});
test("quota list JSON 输出包含 model/rpm/tpm/maxTPM", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "list", "--output", "json"]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota request --dry-run 输出请求参数", async () => {
test("quota list --model 多模型 --dry-run 逐模型一个请求", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"list",
"--model",
"qwen3.6-plus",
"--tpm",
"6000000",
"qwen3-max,qwen-plus",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { input?: { model?: string; limit?: { usage_limit?: number } } };
requests?: { endpoint?: string; method?: string; query?: { model?: string } }[];
}>(stdout);
expect(data.api).toContain("updateFoundationModelLimits");
expect(data.data?.input?.model).toBe("qwen3.6-plus");
expect(data.data?.input?.limit?.usage_limit).toBeTypeOf("number");
expect(data.requests?.length).toBe(2);
expect(data.requests?.[0]?.endpoint).toContain("/api/v1/models/limits");
expect(data.requests?.[0]?.query?.model).toBe("qwen3-max");
expect(data.requests?.[1]?.query?.model).toBe("qwen-plus");
});
test("quota request TPM 超范围报错", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
test("quota update --dry-run 输出 OVERLAY 请求体", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"update",
"--model",
"qwen3.6-plus",
"--tpm",
"999",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain("out of range");
expect(result.stderr).toContain("Current");
expect(result.stderr).toContain("Range");
});
test("quota request 不支持提额的模型报错", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"request",
"--model",
"nonexistent-model-xyz-99999",
"qwen-plus",
"--rpm",
"60",
"--tpm",
"100000",
"--dry-run",
"--output",
"json",
]);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("not found");
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
endpoint?: string;
method?: string;
request?: {
models?: {
model?: string;
request_limit?: number;
request_limit_period?: number;
usage_limit?: number;
usage_limit_period?: number;
}[];
};
}>(stdout);
expect(data.endpoint).toContain("/api/v1/models/limits");
expect(data.method).toBe("POST");
const entry = data.request?.models?.[0];
expect(entry?.model).toBe("qwen-plus");
expect(entry?.request_limit).toBe(60);
expect(entry?.request_limit_period).toBe(60);
expect(entry?.usage_limit).toBe(100000);
expect(entry?.usage_limit_period).toBe(60);
});
test("quota update --delete --dry-run 输出 DELETE 操作", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"update",
"--model",
"qwen-plus",
"--delete",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { models?: { model?: string; operation_type?: string }[] };
}>(stdout);
expect(data.request?.models?.[0]?.operation_type).toBe("DELETE");
});
test("quota history --dry-run 输出请求参数", async () => {
@@ -217,6 +251,89 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
expect(data.consoleRegion).toBe("cn-hangzhou");
});
test("quota history --dry-run --page 2 --page-size 20", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"history",
"--page",
"2",
"--page-size",
"20",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { input?: { pageNo?: number; pageSize?: number } };
}>(stdout);
expect(data.data?.input?.pageNo).toBe(2);
expect(data.data?.input?.pageSize).toBe(20);
});
});
// 真实调用 GET /api/v1/models/limits。quota update 只测 --dry-run——live POST
// 会真实改写账号限流,不做 e2e。
describe.skipIf(!isDashScopeE2EReady())("e2e: quotaDashScope", () => {
test("quota list 文本输出正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
});
test("quota list --model 精确查询返回模型限流", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"qwen3-max",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
items?: { model?: string; model_limit?: { request_limit?: number | null } | null }[];
}>(stdout);
expect(data.items?.[0]?.model).toBe("qwen3-max");
expect(data.items?.[0]).toHaveProperty("model_limit");
});
test("quota list --name 模糊搜索分页生效", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--name",
"qwen",
"--page-size",
"5",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ items?: unknown[]; total?: number }>(stdout);
expect(data.items?.length).toBeLessThanOrEqual(5);
expect(data.total).toBeGreaterThan(0);
});
test("quota list --model 不存在的模型返回空列表", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"list",
"--model",
"nonexistent-model-xyz-99999",
"--output",
"text",
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("No rate limits found");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
test("quota check 文本输出包含英文表头", async () => {
const result = await runCommandE2e(QUOTA_ROUTES, ["quota", "check", "--output", "text"]);
if (isConsoleAuthFailure(result)) return;
@@ -261,24 +378,4 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quotaConsole", () => {
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
test("quota history --dry-run --page 2 --page-size 20", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [
"quota",
"history",
"--page",
"2",
"--page-size",
"20",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
data?: { input?: { pageNo?: number; pageSize?: number } };
}>(stdout);
expect(data.data?.input?.pageNo).toBe(2);
expect(data.data?.input?.pageSize).toBe(20);
});
});
+9 -1
View File
@@ -100,11 +100,19 @@ export const ADVISOR_ROUTES: E2eRouteExports = {
export const QUOTA_ROUTES: E2eRouteExports = {
"quota list": "quotaList",
"quota request": "quotaRequest",
"quota update": "quotaUpdate",
// Backward-compatible alias of "quota update".
"quota request": "quotaUpdate",
"quota history": "quotaHistory",
"quota check": "quotaCheck",
};
export const PERMISSION_ROUTES: E2eRouteExports = {
"permission list": "permissionList",
"permission grant": "permissionGrant",
"permission revoke": "permissionRevoke",
};
export const USAGE_ROUTES: E2eRouteExports = {
"usage free": "usageFree",
"usage freetier": "usageFreetier",
+3 -20
View File
@@ -1,11 +1,9 @@
import type { Settings } from "../../config/schema.ts";
import { callConsoleGateway, effectiveConsoleGatewayConfig } from "../../console/gateway.ts";
import { fetchModelList } from "../../console/models.ts";
import { anonymousConsoleCall } from "../../console/gateway.ts";
import { fetchModelListAll } from "../../console/models.ts";
import type { ModelProfile } from "../types.ts";
import type { ModelSource } from "./types.ts";
const PAGE_SIZE = 50;
function toModelProfile(item: Record<string, unknown>): ModelProfile | null {
if (!item.model) return null;
const meta = item.inferenceMetadata as Record<string, unknown> | undefined;
@@ -41,22 +39,7 @@ export class ApiSource implements ModelSource {
async load(): Promise<ModelProfile[]> {
// Public model catalog — no console token (advisor runs unauthenticated).
const eff = effectiveConsoleGatewayConfig(this.settings);
const call = (api: string, data: Record<string, unknown>) =>
callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
this.settings.timeout,
{ api, data },
);
const first = await fetchModelList(call, { pageNo: 1, pageSize: PAGE_SIZE });
const allRaw = [...first.models];
const totalPages = Math.ceil(first.total / PAGE_SIZE);
for (let page = 2; page <= totalPages; page++) {
const result = await fetchModelList(call, { pageNo: page, pageSize: PAGE_SIZE });
allRaw.push(...result.models);
}
const allRaw = await fetchModelListAll(anonymousConsoleCall(this.settings));
return allRaw
.map(toModelProfile)
+10
View File
@@ -42,6 +42,16 @@ export function taskPath(taskId: string): string {
return `/api/v1/tasks/${encodeURIComponent(taskId)}`;
}
// ---- Model Rate Limits (DashScope) ----
export function modelsLimitsPath(): string {
return "/api/v1/models/limits";
}
// ---- Model Permissions (DashScope) ----
export function modelsPermissionsPath(): string {
return "/api/v1/models/permissions";
}
// ---- Application (Agent / Workflow) ----
export function appCompletionPath(appId: string): string {
return `/api/v1/apps/${encodeURIComponent(appId)}/completion`;
+2
View File
@@ -13,6 +13,8 @@ export {
memoryNodePath,
memorySearchPath,
mcpWebSearchPath,
modelsLimitsPath,
modelsPermissionsPath,
profileSchemaPath,
responsesPath,
speechRecognizePath,
+20 -1
View File
@@ -58,11 +58,30 @@ export function effectiveConsoleGatewayConfig(
}
export interface ConsoleGatewayRequest {
/** Console API name, e.g. zeldaEasy.bailian-commerce.freeTrial.queryFreeTierQuota */
/** Console API name, e.g. zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota */
api: string;
data: Record<string, unknown>;
}
/** Console-call signature shared by catalog helpers (`client.console` or an anonymous call). */
export type ConsoleCall = (api: string, data: Record<string, unknown>) => Promise<unknown>;
/**
* Build an anonymous (token-less) gateway caller for public catalog APIs such
* as `listFoundationModels` no console login required.
*/
export function anonymousConsoleCall(
config: Pick<Settings, "consoleRegion" | "consoleSite" | "consoleSwitchAgent" | "timeout">,
): ConsoleCall {
const eff = effectiveConsoleGatewayConfig(config);
return (api, data) =>
callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
config.timeout,
{ api, data },
);
}
function buildGatewayParams(
api: string,
data: Record<string, unknown>,
+13 -2
View File
@@ -1,5 +1,14 @@
export type { ConsoleGatewayRequest, ConsoleGatewayTarget, ConsoleSite } from "./gateway.ts";
export { callConsoleGateway, effectiveConsoleGatewayConfig } from "./gateway.ts";
export type {
ConsoleCall,
ConsoleGatewayRequest,
ConsoleGatewayTarget,
ConsoleSite,
} from "./gateway.ts";
export {
anonymousConsoleCall,
callConsoleGateway,
effectiveConsoleGatewayConfig,
} from "./gateway.ts";
export type {
ModelListParams,
ModelListResult,
@@ -12,6 +21,8 @@ export type {
} from "./models.ts";
export {
fetchModelList,
fetchModelListAll,
findModelByName,
fetchModelGroups,
fetchModelDetail,
fetchPredictConfig,
+32 -2
View File
@@ -1,3 +1,5 @@
import type { ConsoleCall } from "./gateway.ts";
export const MODEL_LIST_API =
"zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
export const PREDICT_CONFIG_API = "zeldaEasy.bmp.modelPredictRpcService.getPredictParamConfig";
@@ -6,8 +8,6 @@ export const PREDICT_CONFIG_API = "zeldaEasy.bmp.modelPredictRpcService.getPredi
// Shared helpers
// ---------------------------------------------------------------------------
type ConsoleCall = (api: string, data: Record<string, unknown>) => Promise<unknown>;
/** Unwrap the DataV2 double-envelope that console gateway returns. */
export function unwrapResponse(result: Record<string, unknown>): Record<string, unknown> {
const data = result.data as Record<string, unknown> | undefined;
@@ -77,6 +77,36 @@ export async function fetchModelList(
return { total, models };
}
/** Page through every model-list page and return all raw model items. */
export async function fetchModelListAll(
call: ConsoleCall,
params: Omit<ModelListParams, "pageNo"> = {},
): Promise<Record<string, unknown>[]> {
const pageSize = params.pageSize ?? 50;
const first = await fetchModelList(call, { ...params, pageNo: 1, pageSize });
const allModels = [...first.models];
const totalPages = Math.ceil(first.total / pageSize);
for (let pageNo = 2; pageNo <= totalPages; pageNo++) {
const result = await fetchModelList(call, { ...params, pageNo, pageSize });
if (result.models.length === 0) break;
allModels.push(...result.models);
}
return allModels;
}
/**
* Look up a single model by exact id. The server's `name` filter is a
* substring match, so an exact `model` equality check narrows the result
* (e.g. avoids `qwen3-8b` matching `qwen3-8b-v2`).
*/
export async function findModelByName(
call: ConsoleCall,
modelName: string,
): Promise<Record<string, unknown> | null> {
const result = await fetchModelList(call, { name: modelName, pageSize: 50 });
return result.models.find((item) => item.model === modelName) ?? null;
}
// ---------------------------------------------------------------------------
// Model group types — family-level structure returned by `group: true`
// ---------------------------------------------------------------------------
+4 -12
View File
@@ -1,6 +1,6 @@
import type { Settings } from "../config/schema.ts";
import { callConsoleGateway, effectiveConsoleGatewayConfig } from "../console/gateway.ts";
import { fetchModelList } from "../console/models.ts";
import { anonymousConsoleCall } from "../console/gateway.ts";
import { findModelByName } from "../console/models.ts";
/**
* Training-type vocabulary exposed to users.
@@ -111,14 +111,6 @@ export async function fetchModelCapability(
modelName: string,
): Promise<ModelCapability | null> {
// Public model catalog — anonymous gateway call, no console token needed.
const eff = effectiveConsoleGatewayConfig(settings);
const call = (api: string, data: Record<string, unknown>) =>
callConsoleGateway(
{ region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent },
settings.timeout,
{ api, data },
);
const result = await fetchModelList(call, { name: modelName, pageSize: 20 });
const match = result.models.find((item) => (item.model as string | undefined) === modelName);
return (match as ModelCapability | undefined) ?? null;
const match = await findModelByName(anonymousConsoleCall(settings), modelName);
return match as ModelCapability | null;
}
+8 -4
View File
@@ -39,7 +39,10 @@ Use this index for the skill-scoped quick index and global flags.
| `bl memory profile get` | API Key | Get user profile by schema ID and user ID | [memory.md](memory.md) |
| `bl memory search` | API Key | Search memory nodes by query or messages | [memory.md](memory.md) |
| `bl memory update` | API Key | Update a memory node content | [memory.md](memory.md) |
| `bl model list` | Console | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
| `bl model list` | No Auth | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
| `bl permission grant` | API Key | Grant model permissions (inference / finetune / deploy) | [permission.md](permission.md) |
| `bl permission list` | API Key | List model permissions (inference / fine-tune / deploy) in the workspace | [permission.md](permission.md) |
| `bl permission revoke` | API Key | Revoke model permissions (inference / finetune / deploy) | [permission.md](permission.md) |
| `bl pipeline run` | No Auth | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
| `bl pipeline validate` | No Auth | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
| `bl plugin install` | No Auth | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
@@ -48,8 +51,8 @@ Use this index for the skill-scoped quick index and global flags.
| `bl plugin remove` | No Auth | Remove an installed Command Pack | [plugin.md](plugin.md) |
| `bl quota check` | Console | Check current usage against rate limits | [quota.md](quota.md) |
| `bl quota history` | Console | View quota change history | [quota.md](quota.md) |
| `bl quota list` | Console | View model RPM/TPM rate limits | [quota.md](quota.md) |
| `bl quota request` | Console | Request a temporary quota increase | [quota.md](quota.md) |
| `bl quota list` | API Key | View model rate limits (QPM/TPM, account and workspace level) | [quota.md](quota.md) |
| `bl quota update` | API Key | Update model rate limits (QPM/TPM), or clear them with --delete | [quota.md](quota.md) |
| `bl search web` | API Key | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
| `bl skill add` | No Auth | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
| `bl skill init` | No Auth | Install all bailian-\* skills (one-shot bootstrap for new environments) | [skill.md](skill.md) |
@@ -85,9 +88,10 @@ Use this index for the skill-scoped quick index and global flags.
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
| `model` | `list` | [model.md](model.md) |
| `permission` | `grant`, `list`, `revoke` | [permission.md](permission.md) |
| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) |
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
| `quota` | `check`, `history`, `list`, `update` | [quota.md](quota.md) |
| `search` | `web` | [search.md](search.md) |
| `skill` | `add`, `init`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `text` | `chat` | [text.md](text.md) |
+16 -16
View File
@@ -9,7 +9,7 @@ Index: [index.md](index.md)
| Command | Authentication | Description |
| --------------- | -------------- | ---------------------------------------------------------------------------------- |
| `bl model list` | Console | Browse model families or show detailed model info in the Bailian model marketplace |
| `bl model list` | No Auth | Browse model families or show detailed model info in the Bailian model marketplace |
## Command details
@@ -19,25 +19,25 @@ Index: [index.md](index.md)
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | `model list` |
| **Description** | Browse model families or show detailed model info in the Bailian model marketplace |
| **Authentication** | Console |
| **Authentication** | No Auth |
| **Usage** | `bl model list [--model <model>] [--page <n>] [--page-size <n>] [--provider <p>] [--capability <c>] [--feature <f>] [--enrich]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------- |
| `--model <model>` | string | no | Show full details of a specific model family (switches to detail mode) |
| `--page <n>` | number | no | Page number (default: 1) |
| `--page-size <n>` | number | no | Results per page (default: 10) |
| `--provider <p>` | array | no | Filter by provider (repeatable, e.g. --provider alibaba --provider deepseek) |
| `--capability <c>` | array | no | Filter by capability code (TG, Reasoning, VU, IG, VG, TTS, ASR, …) |
| `--feature <f>` | array | no | Filter by feature (function-calling, web-search, structured-outputs, …) |
| `--context-window <w>` | array | no | Filter by context window range bucket |
| `--enrich` | switch | no | Also fetch input parameter schema (predictConfig) for trunk models (detail mode only) |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
| Flag | Type | Required | Description |
| ---------------------- | ------ | -------- | ------------------------------------------------------------------------------------- |
| `--model <model>` | string | no | Show full details of a specific model family (switches to detail mode) |
| `--page <n>` | number | no | Page number (default: 1) |
| `--page-size <n>` | number | no | Results per page (default: 10) |
| `--provider <p>` | array | no | Filter by provider (repeatable, e.g. --provider alibaba --provider deepseek) |
| `--capability <c>` | array | no | Filter by capability code (TG, Reasoning, VU, IG, VG, TTS, ASR, …) |
| `--feature <f>` | array | no | Filter by feature (function-calling, web-search, structured-outputs, …) |
| `--context-window <w>` | array | no | Filter by context window range bucket |
| `--enrich` | switch | no | Also fetch input parameter schema (predictConfig) for trunk models (detail mode only) |
#### Notes
- Both the catalog and --enrich parameter-schema endpoints are public — no console login needed.
#### Examples
+152
View File
@@ -0,0 +1,152 @@
# `bl permission` commands
> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand.
> Regenerate: `pnpm --filter bailian-cli run generate:reference`.
Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ---------------------- | -------------- | ------------------------------------------------------------------------ |
| `bl permission grant` | API Key | Grant model permissions (inference / finetune / deploy) |
| `bl permission list` | API Key | List model permissions (inference / fine-tune / deploy) in the workspace |
| `bl permission revoke` | API Key | Revoke model permissions (inference / finetune / deploy) |
## Command details
### `bl permission grant`
| Field | Value |
| ------------------ | -------------------------------------------------------------------- |
| **Name** | `permission grant` |
| **Description** | Grant model permissions (inference / finetune / deploy) |
| **Authentication** | API Key |
| **Usage** | `bl permission grant --model <models> [--action <actions>] \| --all` |
#### Flags
| Flag | Type | Required | Description |
| -------------------- | ------ | -------- | --------------------------------------------------------------------------------------- |
| `--model <models>` | string | no | Model ID(s), comma-separated (max 20) |
| `--action <actions>` | string | no | Permission action(s), comma-separated: inference, finetune, deploy (default: inference) |
| `--all` | switch | no | One-key grant inference for all models in the workspace (including future ones) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- Grants apply to the business workspace your API key belongs to.
- --all maps to the server one-key switch (access_all_entities: OPEN) and only covers inference.
- Actions you omit keep their current grants (server-side tri-state patch).
#### Examples
```bash
bl permission grant --model qwen-plus
```
```bash
bl permission grant --model qwen-plus,qwen3-max --action inference,finetune
```
```bash
bl permission grant --all
```
```bash
bl permission grant --model qwen-plus --dry-run --output json
```
### `bl permission list`
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------------- |
| **Name** | `permission list` |
| **Description** | List model permissions (inference / fine-tune / deploy) in the workspace |
| **Authentication** | API Key |
| **Usage** | `bl permission list [--scope <scope>] [--model <model>] [--name <name>] [--page <n>] [--page-size <n>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------------ | ------ | -------- | --------------------------------------------------------------------- |
| `--scope <authorized\|authorizable>` | string | no | Authorization scope: authorizable (default, full catalog), authorized |
| `--model <model>` | string | no | Model ID (exact match) |
| `--name <name>` | string | no | Fuzzy search by model name or ID |
| `--page <n>` | number | no | Page number (default: 1) |
| `--page-size <n>` | number | no | Results per page (default: 20) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- Default scope is `authorizable` (the full grantable catalog); use `--scope authorized` to see only models already granted.
- Output defaults to JSON; pass `--output text` for a table. Permission values are tri-state: true / false / null (never set).
- Values mirror the server's grant records as-is for the workspace bound to your API key. A model reporting false/null can still be callable (access may come from other channels); see the Model Studio authorization docs for the exact semantics.
#### Examples
```bash
bl permission list
```
```bash
bl permission list --model qwen-plus
```
```bash
bl permission list --scope authorized
```
```bash
bl permission list --name qwen --page-size 50
```
```bash
bl permission list --output text
```
### `bl permission revoke`
| Field | Value |
| ------------------ | --------------------------------------------------------------------------- |
| **Name** | `permission revoke` |
| **Description** | Revoke model permissions (inference / finetune / deploy) |
| **Authentication** | API Key |
| **Usage** | `bl permission revoke --model <models> [--action <actions>] \| --all --yes` |
#### Flags
| Flag | Type | Required | Description |
| -------------------- | ------ | -------- | --------------------------------------------------------------------------------------- |
| `--model <models>` | string | no | Model ID(s), comma-separated (max 20) |
| `--action <actions>` | string | no | Permission action(s), comma-separated: inference, finetune, deploy (default: inference) |
| `--all` | switch | no | Close one-key authorization and clear ALL historical inference grants |
| `--yes` | switch | no | Confirm --all without an interactive prompt (required) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- Grants apply to the business workspace your API key belongs to.
- --all maps to the server one-key switch (access_all_entities: CLOSE): it clears every historical inference grant and cannot be undone, so it requires --yes.
- Actions you omit keep their current grants (server-side tri-state patch).
#### Examples
```bash
bl permission revoke --model qwen-plus
```
```bash
bl permission revoke --model qwen-plus,qwen3-max --action inference,finetune
```
```bash
bl permission revoke --all --yes
```
```bash
bl permission revoke --model qwen-plus --dry-run --output json
```
+57 -39
View File
@@ -7,12 +7,12 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ------------------ | -------------- | --------------------------------------- |
| `bl quota check` | Console | Check current usage against rate limits |
| `bl quota history` | Console | View quota change history |
| `bl quota list` | Console | View model RPM/TPM rate limits |
| `bl quota request` | Console | Request a temporary quota increase |
| Command | Authentication | Description |
| ------------------ | -------------- | --------------------------------------------------------------- |
| `bl quota check` | Console | Check current usage against rate limits |
| `bl quota history` | Console | View quota change history |
| `bl quota list` | API Key | View model rate limits (QPM/TPM, account and workspace level) |
| `bl quota update` | API Key | Update model rate limits (QPM/TPM), or clear them with --delete |
## Command details
@@ -103,22 +103,27 @@ bl quota history --output json
### `bl quota list`
| Field | Value |
| ------------------ | ----------------------------------------- |
| **Name** | `quota list` |
| **Description** | View model RPM/TPM rate limits |
| **Authentication** | Console |
| **Usage** | `bl quota list [--model <model>] [flags]` |
| Field | Value |
| ------------------ | -------------------------------------------------------------------------------- |
| **Name** | `quota list` |
| **Description** | View model rate limits (QPM/TPM, account and workspace level) |
| **Authentication** | API Key |
| **Usage** | `bl quota list [--model <model>] [--name <name>] [--page <n>] [--page-size <n>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--model <model>` | string | no | Model name(s), comma-separated |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | -------------------------------------------- |
| `--model <model>` | string | no | Model name(s), comma-separated (exact match) |
| `--name <name>` | string | no | Fuzzy search by model name |
| `--page <n>` | number | no | Page number (default: 1) |
| `--page-size <n>` | number | no | Results per page (default: 20) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- Usage-vs-limit pressure checks live in `quota check` (console auth).
#### Examples
@@ -127,47 +132,60 @@ bl quota list
```
```bash
bl quota list --model qwen3.6-plus
bl quota list --model qwen3-max
```
```bash
bl quota list --model qwen3.6-plus,qwen-turbo
bl quota list --model qwen3-max,qwen-plus
```
```bash
bl quota list --name qwen --page-size 50
```
```bash
bl quota list --output json
```
### `bl quota request`
### `bl quota update`
| Field | Value |
| ------------------ | -------------------------------------------------------- |
| **Name** | `quota request` |
| **Description** | Request a temporary quota increase |
| **Authentication** | Console |
| **Usage** | `bl quota request --model <model> --tpm <value> [flags]` |
| Field | Value |
| ------------------ | -------------------------------------------------------------------- |
| **Name** | `quota update` |
| **Description** | Update model rate limits (QPM/TPM), or clear them with --delete |
| **Authentication** | API Key |
| **Usage** | `bl quota update --model <model> [--rpm <n>] [--tpm <n>] [--delete]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--model <model>` | string | yes | Model name (required) |
| `--tpm <value>` | string | yes | Target TPM value (required) |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--workspace-id <id>` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) |
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | ------------------------------------------ |
| `--model <model>` | string | yes | Model name (required) |
| `--rpm <n>` | number | no | Max requests per minute (QPM) |
| `--tpm <n>` | number | no | Max tokens per minute (TPM) |
| `--delete` | switch | no | Clear all custom rate limits for the model |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- Fields you omit keep their current values (server-side OVERLAY merge); --delete clears all custom limits.
- Setting TPM without an existing QPM limit is rejected server-side — pass --rpm first or together.
#### Examples
```bash
bl quota request --model qwen-turbo --tpm 100000
bl quota update --model qwen-plus --rpm 60 --tpm 100000
```
```bash
bl quota request --model qwen3.6-plus --tpm 8000000
bl quota update --model qwen3-max --tpm 500000
```
```bash
bl quota request --model qwen-turbo --tpm 100000 --output json
bl quota update --model qwen-plus --delete
```
```bash
bl quota update --model qwen-plus --rpm 60 --output json
```