diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 8a0dea46..b772ba34 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -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 = { "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 = { "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 = { + // Pre-migration name of "quota update". + "quota request": quotaUpdate, +}; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index f4b6e83f..5df9cdf8 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -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(); diff --git a/packages/commands/src/commands/finetune/capability.ts b/packages/commands/src/commands/finetune/capability.ts index 1bf72034..a92e6ee0 100644 --- a/packages/commands/src/commands/finetune/capability.ts +++ b/packages/commands/src/commands/finetune/capability.ts @@ -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 { - const eff = effectiveConsoleGatewayConfig(settings); - const call = (api: string, data: Record) => - 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[]; } diff --git a/packages/commands/src/commands/model/list.ts b/packages/commands/src/commands/model/list.ts index b76f0582..daff9461 100644 --- a/packages/commands/src/commands/model/list.ts +++ b/packages/commands/src/commands/model/list.ts @@ -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 ] [--page ] [--page-size ] [--provider

] [--capability ] [--feature ] [--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); diff --git a/packages/commands/src/commands/permission/grant.ts b/packages/commands/src/commands/permission/grant.ts new file mode 100644 index 00000000..0af6a53a --- /dev/null +++ b/packages/commands/src/commands/permission/grant.ts @@ -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 [--action ] | --all", + flags: { + model: { + type: "string", + valueHint: "", + description: "Model ID(s), comma-separated (max 20)", + }, + action: { + type: "string", + valueHint: "", + 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); + }, +}); diff --git a/packages/commands/src/commands/permission/list.ts b/packages/commands/src/commands/permission/list.ts new file mode 100644 index 00000000..258bf1f0 --- /dev/null +++ b/packages/commands/src/commands/permission/list.ts @@ -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 ] [--model ] [--name ] [--page ] [--page-size ]", + flags: { + scope: { + type: "string", + valueHint: "", + choices: ["authorized", "authorizable"] as const, + description: "Authorization scope: authorizable (default, full catalog), authorized", + }, + model: { + type: "string", + valueHint: "", + description: "Model ID (exact match)", + }, + name: { + type: "string", + valueHint: "", + description: "Fuzzy search by model name or ID", + }, + page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, + pageSize: { type: "number", valueHint: "", 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({ + 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 \`.` + : `Adjust --name/--model filters, or check pagination with --page/--page-size.`; + + printTable(permissions, total, emptyHint); + }, +}); diff --git a/packages/commands/src/commands/permission/revoke.ts b/packages/commands/src/commands/permission/revoke.ts new file mode 100644 index 00000000..253aef86 --- /dev/null +++ b/packages/commands/src/commands/permission/revoke.ts @@ -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 [--action ] | --all --yes", + flags: { + model: { + type: "string", + valueHint: "", + description: "Model ID(s), comma-separated (max 20)", + }, + action: { + type: "string", + valueHint: "", + 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); + }, +}); diff --git a/packages/commands/src/commands/permission/shared.ts b/packages/commands/src/commands/permission/shared.ts new file mode 100644 index 00000000..77c99c06 --- /dev/null +++ b/packages/commands/src/commands/permission/shared.ts @@ -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 { + 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 = flags.all + ? { access_all_entities: grant ? "OPEN" : "CLOSE" } + : { + models: models.map((model) => { + const entry: Record = { 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 = 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`); +} diff --git a/packages/commands/src/commands/quota/history.ts b/packages/commands/src/commands/quota/history.ts index a12db999..42f03b6d 100644 --- a/packages/commands/src/commands/quota/history.ts +++ b/packages/commands/src/commands/quota/history.ts @@ -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); diff --git a/packages/commands/src/commands/quota/list.ts b/packages/commands/src/commands/quota/list.ts index a6aefd6a..47ec35e7 100644 --- a/packages/commands/src/commands/quota/list.ts +++ b/packages/commands/src/commands/quota/list.ts @@ -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"; - -const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData"; - -interface QpmInfoItem { - count_limit: number; - count_limit_period: number; - usage_limit: number; - usage_limit_period: number; - usage_limit_field: string; - type: string; +import { formatNumber } from "../shared/format.ts"; +import { buildQuery, parseCommaList } from "../shared/params.ts"; + +// --------------------------------------------------------------------------- +// Types — mirror GET /api/v1/models/limits +// --------------------------------------------------------------------------- + +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; -} - -interface MonitorPoint { - value: number; - timestamp: number; + workspace_id?: string; + model_limit?: LimitSpec | null; + workspace_limit?: LimitSpec | null; } -interface MonitorMetric { - aggMethod: string; - metricName: string; - points: MonitorPoint[]; +interface LimitsResponse { + output?: { + total?: number; + page_no?: number; + page_size?: number; + quotas?: ModelQuota[]; + }; + request_id?: string; } -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); +// --------------------------------------------------------------------------- +// 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 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 formatRequestLimit(spec: LimitSpec | null | undefined): string { + if (!spec) return "-"; + return formatLimit(spec.request_limit, spec.request_limit_period); } -function formatNumber(num: number): string { - return num.toLocaleString("en-US"); +function formatUsageLimit(spec: LimitSpec | null | undefined): string { + if (!spec) return "-"; + return formatLimit(spec.usage_limit, spec.usage_limit_period); } -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); - const metrics = (resp.data ?? resp) as MonitorMetric[] | Record; - 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 { - const allModels: ModelWithQpm[] = []; - let pageNo = 1; - - while (true) { - const input: Record = { - 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); - 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 ] [flags]", + description: "View model rate limits (QPM/TPM, account and workspace level)", + auth: "apiKey", + usageArgs: "[--model ] [--name ] [--page ] [--page-size ]", flags: { model: { type: "string", valueHint: "", - description: "Model name(s), comma-separated", + description: "Model name(s), comma-separated (exact match)", }, + name: { + type: "string", + valueHint: "", + description: "Fuzzy search by model name", + }, + page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, + pageSize: { type: "number", valueHint: "", 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 = { - 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({ + 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}".`); - } - } - - 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, - }; + quotas = responses.flatMap((resp) => resp.output?.quotas ?? []); + total = quotas.length; + } else { + const resp = await ctx.client.requestJson({ + path: + modelsLimitsPath() + + buildQuery({ + name: nameFlag, + page_no: flags.page || 1, + page_size: flags.pageSize || 20, + }), }); - emitResult(items, format); - return; + quotas = resp.output?.quotas ?? []; + total = resp.output?.total ?? quotas.length; } - // 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"); + if (format === "json") { + emitResult({ items: quotas, total }, format); return; } - printTable(rows); + printTable(quotas, total); }, }); diff --git a/packages/commands/src/commands/quota/request.ts b/packages/commands/src/commands/quota/request.ts deleted file mode 100644 index 5f25e27a..00000000 --- a/packages/commands/src/commands/quota/request.ts +++ /dev/null @@ -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, - key: string, -): Record | undefined { - const val = obj[key]; - if (val && typeof val === "object" && !Array.isArray(val)) return val as Record; - return undefined; -} - -function extractResponseData(result: Record): Record { - 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 } | 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); - const list = (resp.list as Array<{ model: string; qpmInfo?: Record }>) ?? []; - return list.find((m) => m.model === modelName && m.qpmInfo) as - | { model: string; qpmInfo: Record } - | undefined; -} - -export default defineCommand({ - description: "Request a temporary quota increase", - auth: "console", - usageArgs: "--model --tpm [flags]", - flags: { - model: { - type: "string", - valueHint: "", - description: "Model name (required)", - required: true, - }, - tpm: { - type: "string", - valueHint: "", - 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, - }; - - const submitRequest = async (confirmedDowngrade?: boolean): Promise => { - 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); - - 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`, - ); - }, -}); diff --git a/packages/commands/src/commands/quota/update.ts b/packages/commands/src/commands/quota/update.ts new file mode 100644 index 00000000..33ac6171 --- /dev/null +++ b/packages/commands/src/commands/quota/update.ts @@ -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 [--rpm ] [--tpm ] [--delete]", + flags: { + model: { + type: "string", + valueHint: "", + description: "Model name (required)", + required: true, + }, + rpm: { + type: "number", + valueHint: "", + description: "Max requests per minute (QPM)", + }, + tpm: { + type: "number", + valueHint: "", + 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 = { 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`); + }, +}); diff --git a/packages/commands/src/commands/shared/format.ts b/packages/commands/src/commands/shared/format.ts new file mode 100644 index 00000000..41035815 --- /dev/null +++ b/packages/commands/src/commands/shared/format.ts @@ -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"); +} diff --git a/packages/commands/src/commands/shared/params.ts b/packages/commands/src/commands/shared/params.ts new file mode 100644 index 00000000..b6ebb2e8 --- /dev/null +++ b/packages/commands/src/commands/shared/params.ts @@ -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 { + 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}` : ""; +} diff --git a/packages/commands/src/commands/usage/coding-plan.ts b/packages/commands/src/commands/usage/coding-plan.ts index 62e77c03..2655c98a 100644 --- a/packages/commands/src/commands/usage/coding-plan.ts +++ b/packages/commands/src/commands/usage/coding-plan.ts @@ -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"; diff --git a/packages/commands/src/commands/usage/free.ts b/packages/commands/src/commands/usage/free.ts index 6748a887..98154da0 100644 --- a/packages/commands/src/commands/usage/free.ts +++ b/packages/commands/src/commands/usage/free.ts @@ -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[]) || [])); } diff --git a/packages/commands/src/commands/usage/shared.ts b/packages/commands/src/commands/usage/shared.ts index 04e41495..5b4fa934 100644 --- a/packages/commands/src/commands/usage/shared.ts +++ b/packages/commands/src/commands/usage/shared.ts @@ -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 { - const allModels: Record[] = []; - 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) => ({ diff --git a/packages/commands/src/commands/usage/stats.ts b/packages/commands/src/commands/usage/stats.ts index 13cd0423..a21eefd0 100644 --- a/packages/commands/src/commands/usage/stats.ts +++ b/packages/commands/src/commands/usage/stats.ts @@ -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; diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index d0d37de4..19aae774 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -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"; diff --git a/packages/commands/tests/e2e/permission.e2e.test.ts b/packages/commands/tests/e2e/permission.e2e.test.ts new file mode 100644 index 00000000..f2d03e6b --- /dev/null +++ b/packages/commands/tests/e2e/permission.e2e.test.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),无需凭证即可断言请求形状。 + // 不传 --output:permission 命令组默认 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: permission(DashScope)", () => { + 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 }[]; + 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); + }); +}); diff --git a/packages/commands/tests/e2e/quota.e2e.test.ts b/packages/commands/tests/e2e/quota.e2e.test.ts index 1ee3cb05..337e8643 100644 --- a/packages/commands/tests/e2e/quota.e2e.test.ts +++ b/packages/commands/tests/e2e/quota.e2e.test.ts @@ -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,118 +65,140 @@ describe("e2e: quota", () => { expect(exitCode).toBe(2); expect(stderr).toContain("at least 1 minute"); }); -}); -describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => { - test("quota list --dry-run 输出请求参数", async () => { - const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ + test("quota update 缺少 --rpm/--tpm/--delete 报用法错误", async () => { + const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", - "list", - "--dry-run", - "--output", - "json", + "update", + "--model", + "qwen-plus", ]); - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ - apis?: (string | { api: string; note?: string })[]; - modelListInput?: { - input?: { queryQpmInfo?: boolean; supports?: { selfServiceLimitIncrease?: boolean } }; - }; - }>(stdout); - expect(data.apis?.[0]).toContain("listFoundationModels"); - expect(data.modelListInput?.input?.queryQpmInfo).toBe(true); - expect(data.modelListInput?.input?.supports?.selfServiceLimitIncrease).toBe(true); + expect(exitCode).toBe(2); + expect(stderr).toContain("one of --rpm / --tpm / --delete"); }); - 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 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 list --model 指定模型返回结果", async () => { - const result = await runCommandE2e(QUOTA_ROUTES, [ + test("quota update --rpm 负数报错", async () => { + const { stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", - "list", + "update", "--model", - "qwen3.6-plus", - "--output", - "text", + "qwen-plus", + "--rpm", + "-1", ]); - if (isConsoleAuthFailure(result)) return; - expect(result.exitCode, result.stderr).toBe(0); + expect(exitCode).toBe(2); + expect(stderr).toContain("non-negative"); }); - test("quota list --model 不存在的模型报错", async () => { - const result = await runCommandE2e(QUOTA_ROUTES, [ + // --dry-run 跳过 auth stage(见 runtime middleware),无需凭证即可断言请求形状。 + test("quota list --dry-run 输出 GET 请求", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", "list", - "--model", - "nonexistent-model-xyz-99999", + "--dry-run", "--output", - "text", + "json", ]); - 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); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + endpoint?: string; + method?: string; + query?: { page_no?: number; page_size?: number }; + }>(stdout); + 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 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", + "qwen-plus", + "--rpm", + "60", "--tpm", - "999", + "100000", + "--dry-run", + "--output", + "json", ]); - 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"); + 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 request 不支持提额的模型报错", async () => { - const result = await runCommandE2e(QUOTA_ROUTES, [ + test("quota update --delete --dry-run 输出 DELETE 操作", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(QUOTA_ROUTES, [ "quota", - "request", + "update", "--model", - "nonexistent-model-xyz-99999", - "--tpm", - "100000", + "qwen-plus", + "--delete", + "--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<{ + 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: quota(Console)", () => { 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: quota(DashScope)", () => { + 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: quota(Console)", () => { 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: quota(Console)", () => { 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); - }); }); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index c4732ea0..6df26efa 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -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", diff --git a/packages/core/src/advisor/sources/api.ts b/packages/core/src/advisor/sources/api.ts index 17e7c698..17f451fa 100644 --- a/packages/core/src/advisor/sources/api.ts +++ b/packages/core/src/advisor/sources/api.ts @@ -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): ModelProfile | null { if (!item.model) return null; const meta = item.inferenceMetadata as Record | undefined; @@ -41,22 +39,7 @@ export class ApiSource implements ModelSource { async load(): Promise { // Public model catalog — no console token (advisor runs unauthenticated). - const eff = effectiveConsoleGatewayConfig(this.settings); - const call = (api: string, data: Record) => - 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) diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 4991dfdb..8ec1d307 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -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`; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index b85e7001..5164e477 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -13,6 +13,8 @@ export { memoryNodePath, memorySearchPath, mcpWebSearchPath, + modelsLimitsPath, + modelsPermissionsPath, profileSchemaPath, responsesPath, speechRecognizePath, diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index 7881a7b7..3004c50c 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -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; } +/** Console-call signature shared by catalog helpers (`client.console` or an anonymous call). */ +export type ConsoleCall = (api: string, data: Record) => Promise; + +/** + * Build an anonymous (token-less) gateway caller for public catalog APIs such + * as `listFoundationModels` — no console login required. + */ +export function anonymousConsoleCall( + config: Pick, +): 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, diff --git a/packages/core/src/console/index.ts b/packages/core/src/console/index.ts index f1b2d1ce..d4f27279 100644 --- a/packages/core/src/console/index.ts +++ b/packages/core/src/console/index.ts @@ -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, diff --git a/packages/core/src/console/models.ts b/packages/core/src/console/models.ts index f9674789..31447816 100644 --- a/packages/core/src/console/models.ts +++ b/packages/core/src/console/models.ts @@ -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) => Promise; - /** Unwrap the DataV2 double-envelope that console gateway returns. */ export function unwrapResponse(result: Record): Record { const data = result.data as Record | 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 = {}, +): Promise[]> { + 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 | 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` // --------------------------------------------------------------------------- diff --git a/packages/core/src/finetune/capability.ts b/packages/core/src/finetune/capability.ts index 314bcd13..28e5d30a 100644 --- a/packages/core/src/finetune/capability.ts +++ b/packages/core/src/finetune/capability.ts @@ -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 { // Public model catalog — anonymous gateway call, no console token needed. - const eff = effectiveConsoleGatewayConfig(settings); - const call = (api: string, data: Record) => - 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; } diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 88a142dd..39916369 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -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) | diff --git a/skills/bailian-cli/reference/model.md b/skills/bailian-cli/reference/model.md index 89837292..128cdb0c 100644 --- a/skills/bailian-cli/reference/model.md +++ b/skills/bailian-cli/reference/model.md @@ -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 ] [--page ] [--page-size ] [--provider

] [--capability ] [--feature ] [--enrich]` | #### Flags -| Flag | Type | Required | Description | -| ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------- | -| `--model ` | string | no | Show full details of a specific model family (switches to detail mode) | -| `--page ` | number | no | Page number (default: 1) | -| `--page-size ` | number | no | Results per page (default: 10) | -| `--provider

` | array | no | Filter by provider (repeatable, e.g. --provider alibaba --provider deepseek) | -| `--capability ` | array | no | Filter by capability code (TG, Reasoning, VU, IG, VG, TTS, ASR, …) | -| `--feature ` | array | no | Filter by feature (function-calling, web-search, structured-outputs, …) | -| `--context-window ` | 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 ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | -| `--console-site ` | string | no | Console site: domestic, international | -| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | -| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | +| Flag | Type | Required | Description | +| ---------------------- | ------ | -------- | ------------------------------------------------------------------------------------- | +| `--model ` | string | no | Show full details of a specific model family (switches to detail mode) | +| `--page ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Results per page (default: 10) | +| `--provider

` | array | no | Filter by provider (repeatable, e.g. --provider alibaba --provider deepseek) | +| `--capability ` | array | no | Filter by capability code (TG, Reasoning, VU, IG, VG, TTS, ASR, …) | +| `--feature ` | array | no | Filter by feature (function-calling, web-search, structured-outputs, …) | +| `--context-window ` | 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 diff --git a/skills/bailian-cli/reference/permission.md b/skills/bailian-cli/reference/permission.md new file mode 100644 index 00000000..9666cfea --- /dev/null +++ b/skills/bailian-cli/reference/permission.md @@ -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 [--action ] \| --all` | + +#### Flags + +| Flag | Type | Required | Description | +| -------------------- | ------ | -------- | --------------------------------------------------------------------------------------- | +| `--model ` | string | no | Model ID(s), comma-separated (max 20) | +| `--action ` | 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 ` | string | no | API key | +| `--base-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 ] [--model ] [--name ] [--page ] [--page-size ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------------ | ------ | -------- | --------------------------------------------------------------------- | +| `--scope ` | string | no | Authorization scope: authorizable (default, full catalog), authorized | +| `--model ` | string | no | Model ID (exact match) | +| `--name ` | string | no | Fuzzy search by model name or ID | +| `--page ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Results per page (default: 20) | +| `--api-key ` | string | no | API key | +| `--base-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 [--action ] \| --all --yes` | + +#### Flags + +| Flag | Type | Required | Description | +| -------------------- | ------ | -------- | --------------------------------------------------------------------------------------- | +| `--model ` | string | no | Model ID(s), comma-separated (max 20) | +| `--action ` | 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 ` | string | no | API key | +| `--base-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 +``` diff --git a/skills/bailian-cli/reference/quota.md b/skills/bailian-cli/reference/quota.md index df47cd52..755b98ac 100644 --- a/skills/bailian-cli/reference/quota.md +++ b/skills/bailian-cli/reference/quota.md @@ -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 ] [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 ] [--name ] [--page ] [--page-size ]` | #### Flags -| Flag | Type | Required | Description | -| ------------------------------ | ------ | -------- | -------------------------------------------------------- | -| `--model ` | string | no | Model name(s), comma-separated | -| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | -| `--console-site ` | string | no | Console site: domestic, international | -| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | -| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | -------------------------------------------- | +| `--model ` | string | no | Model name(s), comma-separated (exact match) | +| `--name ` | string | no | Fuzzy search by model name | +| `--page ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Results per page (default: 20) | +| `--api-key ` | string | no | API key | +| `--base-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-max,qwen-plus ``` ```bash -bl quota list --model qwen3.6-plus,qwen-turbo +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 --tpm [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 [--rpm ] [--tpm ] [--delete]` | #### Flags -| Flag | Type | Required | Description | -| ------------------------------ | ------ | -------- | -------------------------------------------------------- | -| `--model ` | string | yes | Model name (required) | -| `--tpm ` | string | yes | Target TPM value (required) | -| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | -| `--console-site ` | string | no | Console site: domestic, international | -| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | -| `--workspace-id ` | string | no | Workspace ID (env: BAILIAN_WORKSPACE_ID) | +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | ------------------------------------------ | +| `--model ` | string | yes | Model name (required) | +| `--rpm ` | number | no | Max requests per minute (QPM) | +| `--tpm ` | number | no | Max tokens per minute (TPM) | +| `--delete` | switch | no | Clear all custom rate limits for the model | +| `--api-key ` | string | no | API key | +| `--base-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 update --model qwen3-max --tpm 500000 ``` ```bash -bl quota request --model qwen3.6-plus --tpm 8000000 +bl quota update --model qwen-plus --delete ``` ```bash -bl quota request --model qwen-turbo --tpm 100000 --output json +bl quota update --model qwen-plus --rpm 60 --output json ```