diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7bf390..191b7884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md) +## [2.7.3] - 2026-11-03 + +### Added + +- **Custom model import**: Add `bl cmodel base-models/import/get/list/watch/delete` to import Bailian base models as custom models. + - `cmodel base-models` lists base models eligible for import (console API; import is currently available in the Beijing region only). + - `cmodel import` submits an import job with weight type, display name, and optional bucket/object-key, validating the base model against the console-supported import list before submission. + - `cmodel watch --job-id ` tracks import job progress with `--follow`, polling interval, and timeout options. + - `cmodel get/list/delete` query and remove imported custom models. + +### Changed + +- **Unified output contract**: Route every command family through one invocation output contract (result / progress / info / warning / debug). Progress and informational messages go to stderr and are hidden in quiet mode; warnings and errors are always preserved. In JSON mode, warnings and errors are emitted as single-line JSON on stderr. Quiet mode no longer trims result fields and gains a `DASHSCOPE_QUIET=1` environment variable. +- **Advisor thinking disabled**: `bl advisor recommend` LLM calls no longer use thinking mode; intent analysis was the only thinking stage. `--no-thinking` remains accepted as a deprecated no-op for existing scripts. + +### Fixed + +- **Root help configuration**: Reuse the loaded configuration when rendering root help, preserving parsed flags, Profile selection, and credential precedence. +- **Duplicate error messages**: Stop repeating the redundant normalization cause in text errors while preserving explicit causes, error codes, JSON details, and verbose stacks. +- **SDK warnings and empty ASR results**: Preserve SDK warnings and empty ASR task results in managed-agent workflows and `bl speech recognize`. +- **Finetune fee diagnostics**: `bl finetune fee` now emits gateway diagnostics for fee queries in verbose mode. + +### Internal + +- Instrument advisor LLM requests for telemetry. +- Align all command tests and guidance with the output contract; optimize CLI test suites and shard CI test jobs. +- Clarify quiet output behavior in the CLI and Knowledge Studio READMEs. + ## [2.7.2] - 2026-10-30 ### Added diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index bd6abd2e..724d8240 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -6,6 +6,34 @@ [English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md) +## [2.7.3] - 2026-11-03 + +### 新增 + +- **自定义模型导入**:新增 `bl cmodel base-models/import/get/list/watch/delete`,支持将百炼基础模型导入为自定义模型。 + - `cmodel base-models` 列出可导入的基础模型(控制台接口动态返回;导入当前仅北京地域开放)。 + - `cmodel import` 提交导入任务,支持权重类型、展示名称与可选 bucket/object-key,提交前按控制台可导入清单校验基础模型。 + - `cmodel watch --job-id ` 跟踪导入任务进度,支持 `--follow`、轮询间隔与超时配置。 + - `cmodel get/list/delete` 查询与删除已导入的自定义模型。 + +### 变更 + +- **统一输出契约**:全部命令统一走本次调用的输出契约(result / progress / info / warning / debug)。进度与过程提示写 stderr(quiet 下隐藏),警告与错误始终保留;JSON 模式下警告/错误以 stderr 单行 JSON 输出。quiet 不再裁剪结果字段,并新增 `DASHSCOPE_QUIET=1` 环境变量。 +- **Advisor 关闭思考模式**:`bl advisor recommend` 的 LLM 调用始终关闭思考模式(意图分析此前为唯一开启思考的阶段);`--no-thinking` 仍被接受但已无效果,为兼容旧脚本保留。 + +### 修复 + +- **根帮助配置复用**:渲染根帮助时复用已加载的配置,保留 flag 解析、Profile 选择与凭证优先级。 +- **重复错误信息**:文本错误不再重复输出归一化 cause,同时保留显式 cause、错误码、JSON 详情与 verbose 堆栈。 +- **SDK 警告与空 ASR 结果**:managed-agent 工作流与 `bl speech recognize` 保留 SDK 警告与空的 ASR 任务结果。 +- **微调费用诊断**:`bl finetune fee` 在 verbose 模式下输出费用查询的 gateway 诊断信息。 + +### 内部 + +- Advisor LLM 调用接入请求埋点。 +- 全部命令测试与指引对齐输出契约;优化 CLI 测试套件并对 CI 测试任务分片。 +- 澄清 CLI 与 Knowledge Studio README 中 quiet 行为说明。 + ## [2.7.2] - 2026-10-30 ### 新增 diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index b4462926..bceb6707 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -29,6 +29,10 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `openapi` — 阿里云 OpenAPI 签名域,用 AccessKey ID/Secret 调用 Token Plan 等 OpenAPI - `none` — 本地命令、登录/配置类命令、无需 credential 的命令 +### 可选 console 副凭证 + +`auth: "apiKey"` 的命令可用 `optionalConsole: true` 声明 best-effort console 副凭证(例:`cmodel import` 提交前用 console 目录预检基础模型)。runtime 在注入 apiCred 后尽力 `resolveConsole`;解析失败静默跳过,**不得**让 apiKey 主流程失败。此类命令的 help 会额外展示 console 域 flag(`--console-region` / `--console-site` / `--console-switch-agent`),命令捕获 `client.console` 的 AUTH 错误后降级并 warning。纯 console 请求仍必须声明 `auth: "console"`。 + ### 多凭证并存 `~/.bailian/config.json` 可同时保存 `api_key`、`access_token` 与 `access_key_*`。登录任一种方式不得删除另一种: @@ -130,6 +134,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `globalFlags` 与 `ownFlags` 分流正确 - [ ] `packages/runtime/src/middleware.ts:authStage`: - 根据 `command.auth` 解析 credential 并注入 `ctx.client` + - `optionalConsole` 只做 best-effort 副注入;console 解析失败不得影响 apiKey 主流程 - `settings.dryRun` 下是否允许缺 credential 的策略明确 - [ ] `packages/runtime/src/error-handler.ts`: - AUTH hint 增强使用 `binName`,不要硬编码 `bl` diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index 3cc475a7..c10aa633 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -67,6 +67,7 @@ packages/commands/src/index.ts - [ ] 新建/删除/移动对应的 `packages/commands/src/commands/<...>.ts` - [ ] `defineCommand` 字段使用当前 schema: - `auth: "apiKey" | "console" | "openapi" | "none"` + - `optionalConsole: true` 仅用于 `auth: "apiKey"` 但需要 best-effort console 预检的命令:runtime 会尽力注入 console 凭证并展示 console 域 flag;console 会话缺失/过期不得阻断主流程,命令必须自行捕获 `client.console` 的 AUTH 错误 - `flags`(camelCase key,由 runtime 渲染为 kebab-case) - `usageArgs`(不含 bin/path 前缀) - `exampleArgs`(不含 bin/path 前缀) diff --git a/docs/research/agent-skill-versioning.md b/docs/research/agent-skill-versioning.md new file mode 100644 index 00000000..b2395515 --- /dev/null +++ b/docs/research/agent-skill-versioning.md @@ -0,0 +1,320 @@ +# Agent 扩展能力单元的版本感知与更新提示 + +- 调研日期:2026-10-30 +- 调研问题:主流 AI coding agent 怎样给扩展能力单元(skill / plugin / extension / MCP server / rules)声明版本、感知版本和提示更新,以及 agent 能否在会话内知道自己正在用的能力包已经过期。 +- 参照版本:Claude Code CHANGELOG 最新条目为 `2.1.314`;Agent Skills spec 已迁移至 `agentskills.io`;Agent Plugins 标准为 `1.0.0`;MCP 最新协议版本串为 `2026-07-28`。 + +## TL;DR + +各家的做法基本一致:版本声明写在 plugin 或 package 清单里,skill 层没有;更新检查在 CLI 或 app 层做,结果只显示给人,不进模型上下文。Agent Skills 规范只定义 6 个 frontmatter 字段,其中没有 `version`。规范的示例把 `version` 放进自由字典 `metadata`,和本仓库 `tools/sync-skill-metadata.ts` 写 `metadata.version` 的做法相同。这种写法符合规范,但没有任何运行时会读它。 + +让模型自己感知版本也有可用的路径。Claude Code 官方文档为 SKILL.md 提供了 ``!``` `` 动态注入语法,可以把实时版本号送进模型上下文;这个语法是 Claude Code 专有的,并且可以用 `disableSkillShellExecution` 关掉。Claude Code 自己也改成通过 tool result 把 artifact 的版本变化告诉模型。反面的情况有两条:官方对 `` 做了转义防护;伪造「critical update」通知已经是有记录的 skill 投毒手法。 + +## 横向对比表 + +| Agent / 生态 | 扩展单元机制 | 有无 `version` 字段 | 运行时是否读取 | 更新提示方式 | 来源 | +| ------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Agent Skills spec(跨厂商规范) | `SKILL.md` frontmatter | 无。只有 `name`/`description`(必填)和 `license`/`compatibility`/`metadata`/`allowed-tools` | 否。`metadata` 是自由的字符串键值字典,规范不赋予语义 | 规范不涉及,分发和安装明确不在范围内 | [agentskills.io/specification](https://agentskills.io/specification) | +| Claude Code Skills | `SKILL.md` | 无 `version`;有 `metadata`("Free-form YAML map","Claude Code doesn't act on its contents") | 否(`license`/`compatibility` 也是 "accepted but not acted on") | 没有 skill 级更新检查;`/skill-doctor` 只报告未使用的 skill 和 context 成本 | [skills.md](https://docs.claude.com/en/docs/claude-code/skills.md)、[CHANGELOG:3392](https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md) | +| Claude Code Plugins | `plugin.json` 和 `.claude-plugin/marketplace.json` | 有,均为可选。marketplace 有 entry 级 `version`;`plugin.json` 优先 | 是,也是唯一驱动更新的版本 | 后台 auto-update(按 marketplace 开关),以及 `/plugin marketplace update`、`claude plugin update` | [marketplace-reference](https://code.claude.com/docs/en/plugins/marketplace-reference)、[host-marketplace](https://code.claude.com/docs/en/plugins/host-marketplace) | +| Agent Plugins 1.0.0(开放标准) | `plugin.json` | 有,非必填(`required` 只有 `$schema` 和 `name`),无格式约束(只是 `"type": "string"`) | 标准不规定,分发和安装不在范围内 | 标准不涉及 | [plugin.schema.json](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json) | +| OpenAI Codex CLI | `SKILL.md` frontmatter 和 sidecar `agents/openai.yaml` | 无。frontmatter 只反序列化 `name`/`description`/`metadata`,`metadata` 里只取 `short-description`;sidecar 有 `interface`/`dependencies`/`policy`,也没有 `version` | 否(`metadata` 里除 `short-description` 外的键被 serde 静默丢弃) | 没有扩展单元的更新检查(`config.md` 无相关配置项) | [parser.rs](https://github.com/openai/codex/blob/main/codex-rs/skills/src/parser.rs)、[metadata.rs](https://github.com/openai/codex/blob/main/codex-rs/skills/src/metadata.rs) | +| Cursor Rules | `.cursor/rules/*.mdc` | 无(只有 `description`/`globs`/`alwaysApply`) | 否 | 无,靠 git 版本控制和手工修改 | [cursor.com/docs/context/rules](https://cursor.com/docs/context/rules) | +| Cursor Plugins | `plugin.json` / `.cursor-plugin/plugin.json` | 有 `version`(示例里有),但清单 "only require a `name`" | 在清单层读取 | 从 GitHub 导入的可开 "Enable Auto Refresh"(push 触发,最多每 10 分钟一次),也可手动 Refresh;Cloud Agent 运行期间锁定版本 | [cursor.com/docs/plugins](https://cursor.com/docs/plugins) | +| OpenCode | Skills 和 plugins(`opencode.json`) | 无。skill frontmatter 没有 `version`;plugin 数组只收 npm 包名 | 只用来去重(同名同版本只加载一次) | 只针对 CLI 本身:`Updater` 服务有 `disable`/`notify`/`auto` 三种策略(默认 `notify`),另有 `opencode upgrade` | [docs/skills](https://opencode.ai/docs/skills/)、[updater.ts](https://github.com/sst/opencode/blob/dev/packages/cli/src/services/updater.ts) | +| Cline | Agent Skills | 无。frontmatter 白名单写死为 spec 的 6 个字段 | 否 | 用 SHA-256 内容哈希代替版本:`skills-lock.json` 的 `computedHash` | [agent-skill.ts](https://github.com/cline/cline/blob/main/sdk/packages/core/src/extensions/agent-plugin/agent-skill.ts) | +| Continue | Hub blocks 和 `config.yaml` | 有且必填:`config.yaml` 顶层 `version: z.string()`;block 引用支持 `owner/pkg@version` | 是,由 `decodeFullSlug` 解析,未写版本时取 `latest` | 没有 block 更新通知,靠锁定版本或 `latest` 控制分发 | [config-yaml/schemas/index.ts](https://github.com/continuedev/continue/blob/main/packages/config-yaml/src/schemas/index.ts)、[slugs.ts](https://github.com/continuedev/continue/blob/main/packages/config-yaml/src/interfaces/slugs.ts) | +| Aider | 没有插件体系 | 不适用 | 不适用 | 只针对 CLI 本身:每 24h 查一次 PyPI,经 `io.tool_output` 输出给人看 | [versioncheck.py](https://github.com/Aider-AI/aider/blob/main/aider/versioncheck.py) | +| MCP | server 实现 | 有 `serverInfo.version`,另有独立的 `protocolVersion` | 是,但只在 client 侧的 `initialize` 握手中使用 | 协议级版本协商(不匹配时返回 `-32602` `Unsupported protocol version`) | [lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle) | + +## 各 Agent 机制细节 + +### 1. Agent Skills 规范:没有 `version` 字段 + +置信度:文档明确。规范现托管于 `agentskills.io`(Anthropic 仓库的 `spec/agent-skills-spec.md` 已改为一行指向该地址),定义了 6 个 frontmatter 字段: + +| 字段 | 必填 | 约束 | +| --------------- | ---- | --------------------------------------------------- | +| `name` | 是 | ≤64 字符,小写字母/数字/连字符,须与父目录同名 | +| `description` | 是 | ≤1024 字符,须写明做什么和何时用 | +| `license` | 否 | 许可名或随包文件指针 | +| `compatibility` | 否 | ≤500 字符,环境需求 | +| `metadata` | 否 | 自由映射,"a map from string keys to string values" | +| `allowed-tools` | 否 | 空格分隔,标注 "(Experimental)" | + +`version` 不在其中。规范只在 `metadata` 的示例里用到 `version`(`version: "1.0"`),并说明这些键 "carries no spec-defined meaning"。`metadata` 的用途是 "Clients can use this to store additional properties not defined by the Agent Skills spec"。 + +所以我们写的 `metadata.version` 符合规范,也和规范示例一致,但它只是附注数据,没有宿主运行时会读取或比对它。 + +交叉验证(源码):`anthropics/skills` 仓库的 20 个 `SKILL.md` 里没有一个含 `version:`(`gh search code 'repo:anthropics/skills filename:SKILL.md "version:"'` 返回 `total_count: 0`)。[template/SKILL.md](https://raw.githubusercontent.com/anthropics/skills/main/template/SKILL.md) 只有 `name` 和 `description`,官方 README 也说 frontmatter "requires only two fields"。 + +### 2. Claude Code:skill 层没有版本,plugin 层有 + +置信度:文档明确,并用源码交叉验证。 + +Skill 侧:[skills.md](https://docs.claude.com/en/docs/claude-code/skills.md) 的 frontmatter 参考表共 21 个字段(`name`/`description`/`when_to_use`/`allowed-tools`/`context`/`agent`/`model`/`hooks`/`paths` 等),没有 `version`。`metadata` 的描述是 "Free-form YAML map for your own key-value data",文档说 "Claude Code doesn't act on its contents",非 map 的值会被丢弃。`license` 和 `compatibility` 标为 spec 字段,"accepted but not acted on"。注入模型的 skill 列表也不带版本,description 在列表里按 1,536 字符截断(`/skills` 列表另有 250 字符上限,见 CHANGELOG:7141)。 + +Plugin 侧的版本有实际作用。[host-marketplace](https://code.claude.com/docs/en/plugins/host-marketplace) 的说明如下: + +- 更新的触发条件是 "a user gets a new copy of a plugin only when its computed version changes"。 +- 版本来源顺序是 "That version comes from `plugin.json` first, then from the marketplace entry"。两处都写时静默采用 `plugin.json`,并给出校验告警。 +- 作者可以每次发版都 bump `version`,不 bump 的话 "users stay on their cached copy until the string changes";也可以不写 `version`,这时 "users track your commits instead"。 +- 例外:本地 marketplace 的 in-place 插件 "isn't controlled by `version`",每次会话启动都读取当前文件。 +- `command` 源的 copy 模式用内容哈希得出版本:"derives the [version] from a hash of the copied files","A re-run that produces identical files counts as up to date"。 + +更新通道([install](https://code.claude.com/docs/en/plugins/install)):后台 auto-update 按 marketplace 开关,官方 marketplace 默认开,第三方默认关。`marketplace.json` 里没有字段能打开它,只能由用户在 `/plugin` 里选 "Enable auto-update",或由管理员在 managed settings 的 `extraKnownMarketplaces` 里设 `"autoUpdate": true`。手动更新用 `/plugin marketplace update ` 和 `claude plugin update @`,并且 "There's no command that updates every plugin at once"。会话中已加载的版本不会热替换,只会提示 `Plugin updated: · Run /reload-plugins to apply`。 + +Anthropic 自己的做法(源码):[anthropics/skills 的 marketplace.json](https://raw.githubusercontent.com/anthropics/skills/main/.claude-plugin/marketplace.json) 把 `version: "1.0.0"` 放在顶层 `metadata` 里,两个 plugin entry(`document-skills`、`example-skills`)都没有 `version`,也就是不写版本、跟随 commit。 + +附注:marketplace.json 中 `$schema` 指向的 `https://anthropic.com/claude-code/marketplace.schema.json` 曾返回 404([issue #9686](https://github.com/anthropics/claude-code/issues/9686),2025-10-16 提交,已关闭)。 + +### 3. Agent Plugins 1.0.0:有 `version`,非必填,无格式约束 + +置信度:从 JSON Schema 源码读出。[plugin.schema.json](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json) 的相关部分: + +```json +"version": { "type": "string" }, +... +"required": ["$schema", "name"], +"additionalProperties": false +``` + +`version` 是普通字符串,没有 semver pattern,也不在 `required` 里(必填的只有 `$schema` 和 `name`)。相比之下 `name` 有 `maxLength: 64` 和严格的 pattern,标准没有给 `version` 规定语义。标准把 "distribution, installation, permissions, user experience" 留给各客户端。Cursor 和 Claude Code 都采用这份 plugin 清单标准。 + +### 4. OpenAI Codex CLI:没有 `version`,有 `dependencies` 声明 + +置信度:源码读出。`developers.openai.com/codex/skills` 返回 403,仓库内的 `docs/skills.md` 只有一行外链。Codex 的解析逻辑在开源的 Rust 代码里,以下结论来自结构体定义。 + +[`codex-rs/skills/src/parser.rs`](https://github.com/openai/codex/blob/main/codex-rs/skills/src/parser.rs) 的 frontmatter 结构体: + +```rust +struct SkillFrontmatter { + name: Option, + description: Option, + metadata: SkillFrontmatterMetadata, +} +struct SkillFrontmatterMetadata { + #[serde(default, rename = "short-description")] + short_description: Option, +} +``` + +校验后的 `ParsedSkillFrontmatter` 只有 `name`、`description`、`short_description` 三个成员。 + +Codex 的 SKILL.md frontmatter 没有 `version`。它会解析 `metadata`,但只取 `short-description`。结构体没有 `deny_unknown_fields`,所以我们写的 `metadata.version` 和 `metadata.requires` 会被 serde 静默丢弃,不报错也不警告。 + +Codex 还有一个 sidecar 元数据文件。[`codex-rs/skills/src/metadata.rs`](https://github.com/openai/codex/blob/main/codex-rs/skills/src/metadata.rs) 的 doc comment 说明它解析 `agents/openai.yaml`,顶层有三个键: + +- `interface`:skill 的接口资产 +- `dependencies`:其中 `dependencies.tools[]` 可以声明所需的工具或 MCP,字段为 `type`/`value`/`description`/`transport`/`command`/`url`/`oauth` +- `policy`:`allow_implicit_invocation` 和 `products` + +`dependencies` 里也没有版本或版本区间字段,只有 `MAX_DEPENDENCY_*_LEN` 这类长度限制。 + +`dependencies.tools` 让 skill 用结构化字段声明自己需要的外部工具,用意和我们的 `metadata.requires.bins: ["bl"]` 相同。它只声明需要哪个工具,没有版本要求。要表达「需要 ≥2.7.1 的 `bl`」,已调研的宿主都没有原生字段,最接近的是 VS Code 的 `engines`(见下节)。 + +### 5. Cursor:rules 没有版本,靠 git 管理;plugins 有版本,靠 push 刷新 + +置信度:文档明确。[rules 文档](https://cursor.com/docs/context/rules) 的 `.mdc` frontmatter 只有 `description`、`globs`、`alwaysApply` 三个字段,全文没有 version、schema-version 或 revision 字段,也没有自动更新。文档给的办法是:project rules "version-controlled"(交给 git),发现 agent 出错后手工修改;"Rules aren't imported on their own",跨仓库分发要打包成 plugin。 + +[plugins 文档](https://cursor.com/docs/plugins) 里的两种清单格式(Agent Plugins 开放标准的 `plugin.json` 和 Cursor 原生的 `.cursor-plugin/plugin.json`)示例都含 `"version": "1.0.0"`,但 Cursor 格式 "only require a `name`"。更新由仓库事件触发,不比对版本串:从 GitHub 导入的插件可以开 "Enable Auto Refresh"(需要 Cursor GitHub App,推送后更新,最多每 10 分钟一次),也可以手动点 Refresh。运行中的 Cloud Agent "keeps the plugin versions it started with",要用新版本得新起一个 agent。 + +### 6. OpenCode / Cline / Continue / Aider + +置信度:源码读出(部分文档站返回 403)。 + +- OpenCode:skill frontmatter 与 spec 的六个字段一致,没有 `version`,且 "Unknown frontmatter fields are ignored"。plugin 数组只接受 npm 包名(`["opencode-wakatime", "@my-org/custom-plugin"]`),不支持 `@version` 锁定,版本只用来去重("Duplicate npm packages with the same name and version are loaded once")。更新机制只管 CLI 本身:[updater.ts](https://github.com/sst/opencode/blob/dev/packages/cli/src/services/updater.ts) 实现了 `disable`/`notify`/`auto` 三种策略,默认 `notify`,日志记录 `{current, latest, action}`。 +- Cline:[agent-skill.ts](https://github.com/cline/cline/blob/main/sdk/packages/core/src/extensions/agent-plugin/agent-skill.ts) 把 frontmatter 白名单写死为 spec 的 6 个字段,`AgentSkillMetadata` 没有 `version` 成员。Cline 用内容哈希代替版本:`skills-lock.json` 记录 `computedHash`(SHA-256) 以及 `source`/`sourceType`/`skillPath`,其中的 `version: 1` 是 lockfile 的格式版本。这套机制检测漂移和完整性,不负责通知更新。 +- Continue:四者中只有它有扩展单元的版本体系。`config.yaml` 顶层的 `version: z.string()` 必填;block 引用经 `FullSlug = {ownerSlug, packageSlug, versionSlug}` 解析,`encodeFullSlug` 生成 `owner/pkg@version`,`decodeFullSlug` 在未写版本时取 `VirtualTags.Latest`。版本由配置加载器(CLI 或扩展层)使用,不注入模型 prompt。 +- Aider:没有插件体系,[versioncheck.py](https://github.com/Aider-AI/aider/blob/main/aider/versioncheck.py) 是一个完整的 CLI 层实现。它用 `~/.aider/caches/versioncheck` 的 mtime 记录状态(不存内容),每 24h 检查一次,延迟导入 `requests`("To keep startup fast"),查 PyPI JSON 后用 `packaging.version.parse` 比较;`finally` 块无论成败都更新时间戳,避免失败后反复重试。输出走 `io.tool_output` / `io.tool_warning`,给人看,不进模型上下文。 + +## 其他生态的类似机制 + +### npm update-notifier + +置信度:文档明确。[update-notifier readme](https://raw.githubusercontent.com/yeoman/update-notifier/main/readme.md) 的设计大多可以直接用在我们这里: + +- 异步加缓存,不拖慢启动:超过间隔才在后台查 npm 并写盘,下次运行从缓存读出结果填进 `.update`,"so startup speed isn't affected"。检查跑在 unref'ed 子进程里,主进程 `process.exit` 后也能完成。 +- 状态文件在 `~/.config/configstore/update-notifier-[module].json`,路径由 `notifier.config.path` 暴露。 +- `updateCheckInterval` 默认 1 天;`distTag` 默认 `latest`。 +- `notify()` 只在有更新且进程是 TTY 时打印,默认推迟到进程退出时显示(boxen 黄框)。 +- 关闭方式有三种:配置里的 `optOut: true`、`NO_UPDATE_NOTIFIER` 环境变量、`--no-update-notifier` flag。CI 和 `NODE_ENV=test` 下直接跳过。 +- 第一次运行不提示(间隔还没到),写 e2e 断言时要考虑这一点。 + +### VS Code extension:`version` 和 `engines` + +置信度:文档明确。[extension-manifest](https://code.visualstudio.com/api/references/extension-manifest) 里 `version` 必填("SemVer compatible version");`engines` 也必填,至少包含 `vscode` 键,"Cannot be `*`",例如 `^0.10.5` 表示最低兼容版本。前者是扩展自己的版本,后者声明它兼容的宿主版本。我们的 skill 需要声明「需要多新的 `bl`」,和 `engines` 是同一类需求。Agent Skills 的 `compatibility` 是自由文本,而且 "not acted on",起不到同样的作用。 + +### MCP:版本协商在 client 层完成,不经过模型 + +置信度:规范原文和 schema 源码。[lifecycle 规范](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle) 区分两种版本: + +- `protocolVersion`(如 `"2025-06-18"`):client 发送自己支持的最新版本;server 支持则 MUST 回同一版本,否则 MUST 回一个自己支持的版本;client 无法处理时 SHOULD 断开。HTTP 下后续请求要带 `MCP-Protocol-Version` 头。版本不匹配的错误示例是 `-32602` 加 `"Unsupported protocol version"`,`data` 里列出 `supported` 和 `requested`。 +- `serverInfo.version`(如 `"1.0.0"`):实现本身的版本,与 `protocolVersion` 无关。`clientInfo` 和 `serverInfo` 结构相同,都是 `name`/`title`/`version`。 + +tools 规范的消息流程图([tools.mdx](https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-06-18/server/tools.mdx),源码读出)列出了 `LLM` 参与者,但 Updates 阶段只有 `Server--)Client` 和随后的 `Client->>Server: tools/list`,LLM 不参与版本和变更的传递。规范里唯一面向模型的字段是 `InitializeResult.instructions`,[schema.ts](https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts) 的描述是 "like a 'hint' to the model. For example, this information MAY be added to the system prompt",是否加入由 client 决定。server 可以把版本号写进 `instructions`,规范既没有建议也没有禁止。 + +### Homebrew 与 gh CLI + +置信度:源码读出。[cli/cli 的 cmd.go](https://raw.githubusercontent.com/cli/cli/trunk/internal/ghcmd/cmd.go) 用 `fmt.Fprintf(stderr, ...)` 加 ANSI 颜色把更新提示写到 stderr,给人看,不进入模型上下文。[update.go](https://raw.githubusercontent.com/cli/cli/trunk/internal/update/update.go) 只在有人坐在终端前时提示,条件是 `!ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr)`,另外可以用 `GH_NO_EXTENSION_UPDATE_NOTIFIER`、`CODESPACES` 关闭。Homebrew 用 `HOMEBREW_NO_AUTO_UPDATE` 控制自动更新,用 `brew outdated` 单独查询。 + +## 会话内版本感知的先例 + +### (a) 把版本注入模型上下文:Claude Code 有官方语法 + +置信度:文档明确。Claude Code [skills.md 第 705 行 "Inject dynamic context"](https://docs.claude.com/en/docs/claude-code/skills.md) 原文: + +> The `` !`` `` syntax runs shell commands before the skill content is sent to Claude. The command output replaces the placeholder, so Claude receives actual data, not the command itself. + +官方示例在 SKILL.md 正文里写 ``- PR diff: !`gh pr diff` ``。内联写法有位置要求:"The inline form is only recognized when `!` appears at the start of a line or immediately after whitespace";多行命令用 ` ```! ` 开头的围栏代码块。 + +文档没有给这个语法本身设版本下限。`v2.1.228+` 只针对另一条安全限制:从 claude.ai 账号同步来的 skill 不在本机执行这些命令,原文 "This restriction requires Claude Code v2.1.228 or later"。 + +settings 里的 `disableSkillShellExecution` 可以关掉这个功能,开启后每条命令被替换成 policy-disabled 占位符(bundled 和 managed skill 不受影响)。依赖这个语法的方案要处理占位符原样留在上下文里的情况。 + +在 SKILL.md 正文写 ``!`bl --version`` `` 可以把真实版本号送进模型上下文,可以放在 `bailian-protocol` 现有的「版本预检」环节。 + +### (b) Claude Code 通过 tool result 把版本变化告诉模型 + +置信度:官方 CHANGELOG 原文。CHANGELOG 第 2850 行: + +> Changed artifact watching in local sessions: a new version published elsewhere no longer starts a turn; Claude learns of it from a later Artifact tool result + +这次改动让版本变化的信号随 tool result 进入模型上下文,和我们想做的事属于同一类,只是对象是 artifact。信号附在后续的 tool result 里,不再单独发起一个 turn 打断用户。 + +### (c) skill 写入自身版本号、让模型自查是否过期:没有找到公开先例 + +置信度:基于代码检索,查询语句如下。`gh search code '"your version is outdated" filename:SKILL.md'` 只有 4 条结果,其中两条是提示注入攻击样本(`antgroup/Agent3Sigma-Canary/assets/skill_poison/trusted-agent-deception/critical-update/SKILL.md`),说明伪造「critical update」通知已被收录为一种 skill 投毒手法。 + +唯一的正面样本是 [ykdojo/claude-code-tips 的 skills/version-check/SKILL.md](https://raw.githubusercontent.com/ykdojo/claude-code-tips/main/skills/version-check/SKILL.md)(源码读出)。它让 agent 执行 `claude --version` 和 `npm view @anthropic-ai/claude-code dist-tags --json`,对照 changelog 后给出 update、pin 或 stay 的建议。它检查的是宿主 CLI 的版本,由用户主动触发,不检查 skill 自身的版本。 + +更宽的查询(`"check for updates"`、`"--version" "outdated"`)有数千条结果,基本是讲依赖管理的 skill,和 skill 自身的版本感知无关。 + +公开生态里没有可以照搬的做法,安全上的风险见 D2。 + +## 对我们的启示 + +先确认几条事实: + +1. 我们的 `metadata.version` 符合规范,但没有宿主读取 `version`,各宿主忽略的方式不同。Claude Code 文档说 `metadata` "doesn't act on its contents";Codex 的 `SkillFrontmatterMetadata` 会解析 `metadata`,但只取 `short-description`,`version` 和 `requires` 被 serde 静默丢弃,不报错也不警告;Cline 和 OpenCode 把 frontmatter 白名单写死。目前这个字段只有人和我们自己的工具会看。 +2. 宿主装载 skill 都是复制文件或 clone,版本感知放在 plugin/marketplace 层。我们用 `bl skill init` 自己做安装,所以能在安装器里做宿主做不到的检查。 +3. 本仓库已有两处可以复用:`bailian-protocol` 负责的「版本预检」,以及 `packages/runtime/src/middleware.ts` 里的 update middleware。 +4. 按 `skill-change.md`,纳入 Git 的 `reference/` 不写包版本号(channel 发布会临时 bump 成带时间戳的 beta 版本,否则每次发布都会产生生成物 diff)。把版本写进生成物的方案都要避开这条。 + +下面 4 个方向可以组合使用。 + +### D1(推荐,风险低):`bl` 在 CLI 层检查版本,提示走 agent 读得到的通道 + +做法:在 runtime 的 update middleware 旁加一个 skill 版本比对,比较已安装 skill 的 `metadata.version` 和当前 `bl` 的版本(两者都来自 `packages/cli/package.json`),不一致时输出一行提示,建议执行 `bl skill update`。 + +先例:这是最常见的做法。update-notifier(异步、缓存、TTY 判断、多种关闭方式),Aider `versioncheck.py`(mtime 记录状态、24h 间隔、`finally` 防止重试风暴),gh CLI(写 stderr、判断 CI 和 TTY),OpenCode `Updater`(三种策略,默认 `notify`)。 + +优点:不往 prompt 里加内容,没有投毒入口,有成熟实现可参考,结构和现有 update middleware 相同。 + +和先例的差别:上面的先例都只在 TTY 下给人看。我们的用户主要是 agent,agent 通常不在 TTY 下运行,照搬 TTY 判断的话,agent 场景下永远看不到提示。建议 TTY 下输出给人看的横幅,非 TTY 下输出一行格式固定、机器可读的前缀(比如写到 stderr 的单行 `notice:`),agent 能读到,stdout 的结构化输出也不受影响。 + +风险:stderr 多出一行可能影响现有 e2e 断言(可参考 update-notifier 首次运行不提示的处理);需要提供 env var 关闭开关,并在 CI 下跳过。 + +### D2:在 SKILL.md 正文用 ``!`bl --version`` `` 做运行时版本预检 + +做法:在 `bailian-protocol` 的版本预检段落写 ``!`bl --version`` ``(或专用的 `bl skill doctor --format json`),让模型在执行前拿到实际的宿主版本,和 skill 正文或 `metadata` 里的期望版本比对。 + +先例:Claude Code 官方文档的 "Inject dynamic context";Claude Code 通过 tool result 把 artifact 版本变化告诉模型(CHANGELOG:2850)。 + +优点:只有这个方向能让模型在会话中、执行命令前知道版本;版本信息来自我们自己的命令输出;和 `bailian-protocol` 现有的职责一致。 + +风险: + +- 可移植性差。这是 Claude Code 专有语法。OpenCode 说明了 "Unknown frontmatter fields are ignored",但不保证正文语法;Cursor 和 Codex 没有这个语法,``!`...`` `` 会作为普通文本进入上下文,模型可能把它当成要执行的指令。需要兜底的措辞。 +- 可以被关掉。即使在 Claude Code 里,开启 `disableSkillShellExecution` 后命令也会变成 policy-disabled 占位符。版本预检不能是硬依赖,看到占位符时应跳过版本检查,不报错,也不猜测。 +- 安全。伪造「critical update」是有记录的 skill 投毒手法。版本结论只能来自我们自己的命令输出,不能来自正文里的文字断言。提示语气要平和,免得用户对更新警告习以为常。 +- 从 claude.ai 同步的 skill 不执行该语法(v2.1.228+ 起生效)。 + +### D3:提供 `.claude-plugin/marketplace.json` 分发通道,接入 plugin 层 + +做法:在 `bl skill init` 之外,再以 Claude Code plugin marketplace(或 Agent Plugins 1.0.0 `plugin.json`)的形式分发 `bailian-*` skills,使用宿主自带的版本和更新机制。 + +先例:这是 Claude Code 唯一根据版本触发更新的路径("a user gets a new copy only when its computed version changes");Cursor 也支持 Agent Plugins `plugin.json`;Anthropic 的 `anthropics/skills` 就是这样分发的。 + +优点:直接用宿主的后台 auto-update 和 `claude plugin update`;这一层的 `version` 会被运行时读取;同一份 `plugin.json` 也能给 Cursor 用。 + +风险与注意事项: + +- 第三方 marketplace 的 auto-update 默认关闭,`marketplace.json` 里也没有字段能打开,只能用户手动开,或管理员通过 managed settings 下发。发布之后不会自动到达用户机器。 +- 官方自己不写 entry `version`,跟随 commit。如果我们写了 `version`,每次发版都必须 bump,否则用户会一直停在缓存副本上。这和我们七包锁步版本的习惯一致,但要加进发布清单。 +- 只覆盖 Claude Code 和 Cursor 这类宿主,覆盖不了全部安装场景。 +- 和现有的 `bl skill init/update` 并存会形成两条安装通道,需要定好优先级,否则会出现以哪边版本为准的冲突。 + +### D4:用内容哈希检测漂移,发现用户改过的本地 skill + +做法:参考 Cline 的 `skills-lock.json`,在 `bl skill` 安装时写一份 lock(`computedHash`、source、path),`bl skill update` 和 `doctor` 用哈希判断本地文件是否与发布版一致、用户是否改过。 + +先例:Cline `skills-lock.json` 的 `computedHash`(SHA-256)(源码读出);Claude Code 对 `command` 源插件 "derives the version from a hash of the copied files",且 "identical files counts as up to date"。 + +优点:比版本串可靠,能发现用户本地改动、安装中途失败、漏装 `bailian-protocol` 这些我们遇到过的问题;可以和 `metadata.version` 配合使用;不依赖宿主功能。 + +风险:需要额外的状态文件,也要保证生成结果稳定。`reference/` 生成物在 channel 发布时会临时带上 beta 版本号,哈希计算必须排除含版本号的内容,否则每次发布所有文件都会被判为漂移。 + +### 建议 + +D1 和 D4 作为基础,它们不依赖宿主,也不往 prompt 里加内容。D2 作为 `bailian-protocol` 的增强,它是唯一能在会话内感知版本的方向。D3 等看过 Claude Code 的安装占比再决定。D1 要针对「用户是 agent、常在非 TTY 下运行」这一点设计,不能沿用业界只在 TTY 下提示的默认做法。 + +## 来源清单 + +### 一手来源(官方文档 / 官方仓库源码 / 协议规范 / 官方 CHANGELOG) + +#### 规范 + +- [Agent Skills 规范](https://agentskills.io/specification):6 个 frontmatter 字段,无 `version` +- [anthropics/skills spec/agent-skills-spec.md](https://raw.githubusercontent.com/anthropics/skills/main/spec/agent-skills-spec.md):只指向上述地址 +- [anthropics/skills template/SKILL.md](https://raw.githubusercontent.com/anthropics/skills/main/template/SKILL.md) +- [anthropics/skills .claude-plugin/marketplace.json](https://raw.githubusercontent.com/anthropics/skills/main/.claude-plugin/marketplace.json):顶层 `metadata.version`,entry 无 version +- [Agent Plugins 1.0.0 plugin.schema.json](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json):`required: ["$schema","name"]` +- [agent-plugins.org 总览](https://agent-plugins.org/) + +#### Claude Code + +- [Skills 文档(frontmatter 表和 "Inject dynamic context" L705)](https://docs.claude.com/en/docs/claude-code/skills.md) +- [Marketplace reference](https://code.claude.com/docs/en/plugins/marketplace-reference) +- [Create a marketplace](https://code.claude.com/docs/en/plugins/create-marketplace) +- [Host a marketplace: Keep users up to date](https://code.claude.com/docs/en/plugins/host-marketplace) +- [Install and manage plugins](https://code.claude.com/docs/en/plugins/install) +- [CHANGELOG(v2.1.314;L2850 artifact 版本变化、L1312 system-reminder 转义、L3392 /skill-doctor、L4207 后台通知)](https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md) +- [issue #9686:marketplace schema 404](https://github.com/anthropics/claude-code/issues/9686) + +#### MCP + +- [Lifecycle 规范(protocolVersion 协商和 serverInfo.version)](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle) +- [schema.ts:`instructions` 的 "MAY be added to the system prompt"](https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts) +- [tools.mdx:Updates 流程图不经过 LLM](https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-06-18/server/tools.mdx) + +#### 其他 agent + +- [Cursor Rules](https://cursor.com/docs/context/rules) +- [Cursor Plugins](https://cursor.com/docs/plugins) +- [OpenAI Codex codex-rs/skills/src/parser.rs(frontmatter 结构体,无 version)](https://github.com/openai/codex/blob/main/codex-rs/skills/src/parser.rs) +- [OpenAI Codex codex-rs/skills/src/metadata.rs(sidecar `agents/openai.yaml`:interface/dependencies/policy)](https://github.com/openai/codex/blob/main/codex-rs/skills/src/metadata.rs) +- [OpenAI Codex docs/config.md](https://raw.githubusercontent.com/openai/codex/main/docs/config.md) +- [OpenAI Codex docs/skills.md(只有一行外链,正文在返回 403 的开发者站)](https://raw.githubusercontent.com/openai/codex/main/docs/skills.md) +- [OpenCode Skills](https://opencode.ai/docs/skills/) / [Plugins](https://opencode.ai/docs/plugins/) / [updater.ts](https://github.com/sst/opencode/blob/dev/packages/cli/src/services/updater.ts) / [upgrade.ts](https://github.com/sst/opencode/blob/dev/packages/cli/src/commands/handlers/upgrade.ts) / [version.ts](https://github.com/sst/opencode/blob/dev/packages/cli/src/version.ts) +- [Cline agent-skill.ts(frontmatter 白名单)](https://github.com/cline/cline/blob/main/sdk/packages/core/src/extensions/agent-plugin/agent-skill.ts) / [skills-lock.json](https://github.com/cline/cline/blob/main/apps/vscode/skills-lock.json) +- [Continue config-yaml schemas/index.ts](https://github.com/continuedev/continue/blob/main/packages/config-yaml/src/schemas/index.ts) / [slugs.ts](https://github.com/continuedev/continue/blob/main/packages/config-yaml/src/interfaces/slugs.ts) / [reference 文档](https://docs.continue.dev/reference) +- [Aider versioncheck.py](https://github.com/Aider-AI/aider/blob/main/aider/versioncheck.py) + +#### 类似机制 + +- [update-notifier readme](https://raw.githubusercontent.com/yeoman/update-notifier/main/readme.md) +- [VS Code extension manifest(version 和 engines.vscode)](https://code.visualstudio.com/api/references/extension-manifest) +- [gh CLI internal/ghcmd/cmd.go](https://raw.githubusercontent.com/cli/cli/trunk/internal/ghcmd/cmd.go) / [internal/update/update.go](https://raw.githubusercontent.com/cli/cli/trunk/internal/update/update.go) + +### 二手来源(只作模式举证,非权威) + +- [ykdojo/claude-code-tips skills/version-check/SKILL.md](https://raw.githubusercontent.com/ykdojo/claude-code-tips/main/skills/version-check/SKILL.md):社区个人仓库,是「skill 指导 agent 自查宿主版本」的唯一正面样本 +- [antgroup/Agent3Sigma-Canary skill_poison/.../critical-update/SKILL.md](https://github.com/antgroup/Agent3Sigma-Canary):第三方安全测试集,用来说明伪造更新通知是已知的投毒手法 + +### 未能获取或未找到的证据 + +- `developers.openai.com/codex/skills` 返回 403。Codex 的结论来自 `codex-rs/skills/src/{parser,metadata}.rs` 源码,不依赖该文档站(见第 4 节)。 +- `docs.claude.com/en/docs/claude-code/{skills,plugin-marketplaces,cli-reference}` 的 HTML 页返回 403,改用内容相同的 `.md` 原文和 `code.claude.com`;`skills.md` 已于 2026-10-30 复核。 +- `agentskills.io/schemas/**` 返回 404,没有机器可读的 skill JSON Schema。「规范有 6 个字段」只有文档文字支撑,没有 schema;Claude Code 文档、Cline 和 OpenCode 的字段白名单源码、`anthropics/skills` 检索 0 条结果,三者结论一致。 +- Goose:`block.github.io/goose/docs/guides/recipes/` 返回 404,没有取得一手证据,所以 Goose 没有列入对比表。 diff --git a/packages/bailian-kb-dsh/package.json b/packages/bailian-kb-dsh/package.json index 234b1654..6d3a08a8 100644 --- a/packages/bailian-kb-dsh/package.json +++ b/packages/bailian-kb-dsh/package.json @@ -1,6 +1,6 @@ { "name": "bailian-kb-dsh", - "version": "2.7.2", + "version": "2.7.3", "description": "Bailian knowledge-base tools for DeepSeek Harness: kb_search and kb_chat over the DashScope RAG API, plus the bl CLI management skill.", "keywords": [ "alibaba-cloud", diff --git a/packages/bailian-memo-dsh/package.json b/packages/bailian-memo-dsh/package.json index b7c30f21..aae9017d 100644 --- a/packages/bailian-memo-dsh/package.json +++ b/packages/bailian-memo-dsh/package.json @@ -1,6 +1,6 @@ { "name": "bailian-memo-dsh", - "version": "2.7.2", + "version": "2.7.3", "description": "Bailian personal memory for DeepSeek Harness: automatic recall, silent curation, and user-controlled remember/forget.", "keywords": [ "alibaba-cloud", diff --git a/packages/cli/package.json b/packages/cli/package.json index 8646e251..eba18a8a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "2.7.2", + "version": "2.7.3", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 39183430..79d07710 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -112,6 +112,12 @@ import { modelList, modelSearch, modelCode, + cmodelImport, + cmodelGet, + cmodelList, + cmodelWatch, + cmodelDelete, + cmodelBaseModels, workspaceList, quotaList, quotaUpdate, @@ -446,6 +452,12 @@ export const commands: Record = { "model list": modelList, "model search": modelSearch, "model code": modelCode, + "cmodel import": cmodelImport, + "cmodel get": cmodelGet, + "cmodel list": cmodelList, + "cmodel watch": cmodelWatch, + "cmodel delete": cmodelDelete, + "cmodel base-models": cmodelBaseModels, "workspace list": workspaceList, "quota list": quotaList, "quota update": quotaUpdate, diff --git a/packages/commands/package.json b/packages/commands/package.json index 335f8a3f..06137c08 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-commands", - "version": "2.7.2", + "version": "2.7.3", "description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/commands/src/commands/cmodel/base-models.ts b/packages/commands/src/commands/cmodel/base-models.ts new file mode 100644 index 00000000..109ea28d --- /dev/null +++ b/packages/commands/src/commands/cmodel/base-models.ts @@ -0,0 +1,53 @@ +import { + defineCommand, + effectiveConsoleGatewayConfig, + listSupportImportModels, + type FlagsDef, +} from "bailian-cli-core"; + +export default defineCommand({ + defaultOutput: "json", + description: { + "en-US": "List base models that can be imported as custom models", + "zh-CN": "列出可导入为自定义模型的基础模型", + }, + auth: "console", + usageArgs: "", + flags: {} satisfies FlagsDef, + exampleArgs: ["", "--console-region cn-beijing", "--output json"], + notes: [ + { + "en-US": + "The list is returned dynamically by the console API and may change between releases. Import is currently available only in the Beijing region.", + "zh-CN": "清单由控制台接口动态返回,可能随版本更新调整。导入当前仅北京地域开放。", + }, + ], + async run(ctx) { + const { settings } = ctx; + + if (settings.dryRun) { + const resultData = { + action: "cmodel.baseModels", + ...effectiveConsoleGatewayConfig(settings), + }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + return; + } + + const supported = await listSupportImportModels(ctx.client); + const items = supported.list.map((entry) => ({ + model: entry.model ?? "", + name: entry.name ?? "", + support_train_types: entry.supportTrainTypes ?? [], + post_process_capabilities: entry.postProcessCapabilities ?? null, + })); + const resultData = { items, total: supported.total ?? items.length }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + }, +}); diff --git a/packages/commands/src/commands/cmodel/delete.ts b/packages/commands/src/commands/cmodel/delete.ts new file mode 100644 index 00000000..d43da872 --- /dev/null +++ b/packages/commands/src/commands/cmodel/delete.ts @@ -0,0 +1,60 @@ +import { defineCommand, deleteCustomModelImport } from "bailian-cli-core"; + +import { IMPORT_JOB_ID_FLAGS, PUBLIC_DASHSCOPE_NOTE, buildImportJobOutput } from "./shared.ts"; + +export default defineCommand({ + defaultOutput: "json", + description: { + "en-US": "Delete a custom model and its Bailian-side import record", + "zh-CN": "删除自定义模型及其百炼侧导入记录", + }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "This permanently deletes the Bailian-side import record and imported model copy; it cannot be undone. OSS source files are not modified.", + "zh-CN": "该操作会永久删除百炼侧导入记录及平台侧导入副本,且无法撤销;不会修改 OSS 源文件。", + }, + }, + usageArgs: "--job-id ", + flags: IMPORT_JOB_ID_FLAGS, + exampleArgs: ["--job-id 937b-xxxx", "--job-id 937b-xxxx --dry-run", "--job-id 937b-xxxx --yes"], + notes: [ + PUBLIC_DASHSCOPE_NOTE, + { + "en-US": + "Irreversible: the import record and imported model are removed from Bailian and must be imported again to restore. OSS source files are untouched.", + "zh-CN": "不可撤销:导入记录与导入模型会从百炼删除,需重新导入才能恢复;OSS 源文件不受影响。", + }, + { + "en-US": + "Only SUCCESSED or FAILED jobs can be deleted; deleting a RUNNING job is rejected by the service (OperationDenied) and the error is surfaced verbatim.", + "zh-CN": + "仅 SUCCESSED 或 FAILED 状态的任务可删除;删除 RUNNING 任务会被服务端拒绝(OperationDenied),错误原样透出。", + }, + ], + async run(ctx) { + const { settings, flags } = ctx; + const jobId = flags.jobId; + + if (settings.dryRun) { + const resultData = { action: "cmodel.delete", job_id: jobId }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + return; + } + + const response = await deleteCustomModelImport(ctx.client, jobId); + const job = response.output ?? response.data; + const resultData = job + ? { ...buildImportJobOutput(job, jobId), request_id: response.request_id } + : { ...response, job_id: jobId }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + }, +}); diff --git a/packages/commands/src/commands/cmodel/get.ts b/packages/commands/src/commands/cmodel/get.ts new file mode 100644 index 00000000..9e8944ff --- /dev/null +++ b/packages/commands/src/commands/cmodel/get.ts @@ -0,0 +1,52 @@ +import { defineCommand, getCustomModelImport } from "bailian-cli-core"; + +import { IMPORT_JOB_ID_FLAGS, PUBLIC_DASHSCOPE_NOTE, buildImportJobOutput } from "./shared.ts"; + +export default defineCommand({ + defaultOutput: "json", + description: { + "en-US": "Get details of a custom model (import job)", + "zh-CN": "获取自定义模型(导入任务)的详情", + }, + auth: "apiKey", + usageArgs: "--job-id ", + flags: IMPORT_JOB_ID_FLAGS, + exampleArgs: ["--job-id 937b-xxxx", "--job-id 937b-xxxx --output json"], + notes: [ + PUBLIC_DASHSCOPE_NOTE, + { + "en-US": + "FAILED jobs include the server-provided error_code verbatim; the CLI does not translate it.", + "zh-CN": "FAILED 任务会原样返回服务端提供的 error_code,CLI 不做翻译。", + }, + ], + async run(ctx) { + const { settings, flags } = ctx; + const jobId = flags.jobId; + + if (settings.dryRun) { + const resultData = { action: "cmodel.get", job_id: jobId }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + return; + } + + const response = await getCustomModelImport(ctx.client, jobId); + const job = response.output ?? response.data; + if (!job) { + const resultData = { job_id: jobId, error: "No data returned" }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + return; + } + const resultData = { ...buildImportJobOutput(job, jobId), request_id: response.request_id }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + }, +}); diff --git a/packages/commands/src/commands/cmodel/import.ts b/packages/commands/src/commands/cmodel/import.ts new file mode 100644 index 00000000..f9a7b19b --- /dev/null +++ b/packages/commands/src/commands/cmodel/import.ts @@ -0,0 +1,215 @@ +import { + defineCommand, + createCustomModelImport, + findSupportImportModel, + listSupportImportModels, + requiredTrainTypeForWeightType, + UsageError, + type CreateCustomModelImportRequest, + type FlagsDef, +} from "bailian-cli-core"; + +import { PUBLIC_DASHSCOPE_NOTE } from "./shared.ts"; + +const CREATE_FLAGS = { + baseModel: { + type: "string", + valueHint: "", + description: { + "en-US": "Base model ID used for tuning (required, e.g. qwen3-32b)", + "zh-CN": "训练使用的基础模型 ID(必填,例如 qwen3-32b)", + }, + required: true, + }, + weightType: { + type: "string", + valueHint: "", + choices: ["lora", "full"] as const, + description: { + "en-US": "Tuning weight type (required): lora or full", + "zh-CN": "调优类型(必填):lora 或 full", + }, + required: true, + }, + bucket: { + type: "string", + valueHint: "", + description: { + "en-US": "OSS bucket containing the model files (required)", + "zh-CN": "存放模型文件的 OSS Bucket(必填)", + }, + required: true, + }, + objectKey: { + type: "string", + valueHint: "", + description: { + "en-US": + "OSS directory prefix containing the model files (required, must end with / and not be the bucket root)", + "zh-CN": "模型文件所在 OSS 目录前缀(必填,必须以 / 结尾且不能是 Bucket 根目录)", + }, + required: true, + }, + displayName: { + type: "string", + valueHint: "", + description: { + "en-US": "Display name (optional, max 50 characters)", + "zh-CN": "显示名称(可选,最多 50 字符)", + }, + }, +} satisfies FlagsDef; + +const OSS_BUCKET_PATTERN = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; + +export default defineCommand({ + defaultOutput: "json", + description: { + "en-US": "Import a custom model from OSS (creates an import job)", + "zh-CN": "从 OSS 导入自定义模型(创建导入任务)", + }, + auth: "apiKey", + optionalConsole: true, + usageArgs: + "--base-model --weight-type --bucket --object-key [--display-name ]", + flags: CREATE_FLAGS, + exampleArgs: [ + "--base-model qwen3-32b --weight-type lora --bucket my-model-bucket --object-key models/qwen3-32b-lora/", + "--base-model qwen3-32b --weight-type lora --bucket my-model-bucket --object-key models/qwen3-32b-lora/ --display-name my-lora", + "--base-model qwen3-32b --weight-type lora --bucket my-model-bucket --object-key models/qwen3-32b-lora/ --dry-run", + ], + notes: [ + PUBLIC_DASHSCOPE_NOTE, + { + "en-US": + "After submission, follow the job with `cmodel watch --job-id` and inspect it with `cmodel get` / `cmodel list`.", + "zh-CN": + "提交后可用 `cmodel watch --job-id` 跟踪进度,并用 `cmodel get` / `cmodel list` 查看任务。", + }, + { + "en-US": + "The import API is currently available only in the Beijing region; use the console in other regions.", + "zh-CN": "导入 API 当前仅北京地域开放;其他地域请使用控制台操作。", + }, + { + "en-US": + "The source is fixed to OSS in this release. The bucket must be tagged bailian-datahub-access=read, must not use archive/cold-archive/deep-archive storage or SSE-KMS, and the directory must contain the model files required by the platform (e.g. adapter_model.safetensors, adapter_config.json, config.json).", + "zh-CN": + "本期导入来源固定为 OSS。Bucket 需添加 bailian-datahub-access=read 标签,不支持归档/冷归档/深度冷归档存储和 SSE-KMS;目录需包含平台要求的模型文件(如 adapter_model.safetensors、adapter_config.json、config.json)。", + }, + { + "en-US": + "When a valid console session is available, the base model is checked against the console-supported import list before submission: the ID is normalized to the catalog `model` value, and weight-type is checked against the model's supportTrainTypes (lora requires efficient_sft, full requires sft). Unsupported combinations fail immediately with a usage error. If the console session is missing or expired, the check is skipped with a warning and the import API's final result is authoritative.", + "zh-CN": + "控制台会话可用时,提交前会用控制台支持导入的基础模型清单校验 --base-model:ID 会规范化为目录返回的 model 值,并按模型的 supportTrainTypes 校验 weight-type(lora 需要 efficient_sft,full 需要 sft),不支持的组合会立即报用法错误。控制台登录缺失或过期时跳过校验并输出警告,以导入接口的最终结果为准。", + }, + { + "en-US": + "The current product version supports importing LoRA models; submitting weight-type=full may be rejected by the service. The API contract keeps both values, so the CLI does not hide `full`.", + "zh-CN": + "当前产品版本支持导入 LoRA 模型;提交 weight-type=full 可能被服务端拒绝。API 协议保留两个取值,因此 CLI 不隐藏 full。", + }, + ], + validate(flags) { + if (flags.bucket && !OSS_BUCKET_PATTERN.test(flags.bucket)) { + return "--bucket must be a valid OSS bucket name (3-63 lowercase letters, digits or hyphens, starting and ending with a letter or digit). / --bucket 必须是合法的 OSS Bucket 名称(3-63 位小写字母、数字或连字符,且以字母或数字开头和结尾)。"; + } + if (flags.objectKey && (!flags.objectKey.endsWith("/") || flags.objectKey === "/")) { + return "--object-key must be a non-root directory prefix ending with /. / --object-key 必须是以 / 结尾的非根目录前缀。"; + } + if (flags.displayName !== undefined && flags.displayName.length > 50) { + return "--display-name must be at most 50 characters. / --display-name 最多 50 个字符。"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const buildRequest = (baseModel: string): CreateCustomModelImportRequest => ({ + model_name: baseModel, + ...(flags.displayName ? { display_name: flags.displayName } : {}), + source: "oss", + weight_type: flags.weightType, + storage_info: { + bucket_name: flags.bucket, + object_key: flags.objectKey, + }, + }); + + if (settings.dryRun) { + const resultData = { + action: "cmodel.import", + request: buildRequest(flags.baseModel), + precheck: { performed: false, reason: "dry-run" }, + }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + return; + } + + let baseModel = flags.baseModel; + let precheck: Record; + try { + const supported = await listSupportImportModels(ctx.client); + const matched = findSupportImportModel(supported.list, flags.baseModel); + if (!matched) { + const supportedModels = supported.list + .map((entry) => entry.model) + .filter((model): model is string => Boolean(model)) + .sort(); + throw new UsageError( + `Base model "${flags.baseModel}" is not in the current import-supported list. Supported models: ${supportedModels.join( + ", ", + )}. Run cmodel base-models for the latest list. / 基础模型 "${flags.baseModel}" 不在当前支持导入的模型清单中。支持模型:${supportedModels.join( + "、", + )}。可运行 cmodel base-models 查看最新清单。`, + ); + } + if (matched.model && matched.model !== flags.baseModel) { + baseModel = matched.model; + ctx.output.info({ + "en-US": `Base model normalized to ${matched.model} (catalog model ID).`, + "zh-CN": `基础模型已规范化为 ${matched.model}(目录返回的模型 ID)。`, + }); + } + const supportTrainTypes = matched.supportTrainTypes ?? []; + const requiredTrainType = requiredTrainTypeForWeightType(flags.weightType); + if (supportTrainTypes.length > 0 && !supportTrainTypes.includes(requiredTrainType)) { + const alternativeWeightType = flags.weightType === "lora" ? "full" : "lora"; + throw new UsageError( + `Base model ${baseModel} supports train types: ${supportTrainTypes.join( + ", ", + )}. weight-type=${flags.weightType} requires ${requiredTrainType}; use --weight-type ${alternativeWeightType}. / 基础模型 ${baseModel} 支持的训练方式:${supportTrainTypes.join( + "、", + )}。weight-type=${flags.weightType} 需要 ${requiredTrainType};请改用 --weight-type ${alternativeWeightType}。`, + ); + } + precheck = { + performed: true, + matched: true, + ...(baseModel !== flags.baseModel ? { normalized_base_model: baseModel } : {}), + support_train_types: supportTrainTypes, + }; + } catch (error) { + if (error instanceof UsageError) throw error; + precheck = { performed: false, reason: "console-unavailable" }; + ctx.output.warning({ + code: "model_import.precheck_skipped", + message: { + "en-US": + "Base-model precheck skipped (console session unavailable or expired). The import API's final result is authoritative.", + "zh-CN": "已跳过基础模型预检(控制台会话不可用或已过期)。以导入接口的最终结果为准。", + }, + details: { message: error instanceof Error ? error.message : String(error) }, + }); + } + + const response = await createCustomModelImport(ctx.client, buildRequest(baseModel)); + const resultData = { ...response, precheck }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + }, +}); diff --git a/packages/commands/src/commands/cmodel/list.ts b/packages/commands/src/commands/cmodel/list.ts new file mode 100644 index 00000000..d4d5f881 --- /dev/null +++ b/packages/commands/src/commands/cmodel/list.ts @@ -0,0 +1,122 @@ +import { + defineCommand, + listCustomModelImports, + type CustomModelImportJob, + type FlagsDef, +} from "bailian-cli-core"; + +import { PUBLIC_DASHSCOPE_NOTE } from "./shared.ts"; + +const LIST_FLAGS = { + page: { + type: "number", + valueHint: "", + description: { "en-US": "Page number (default: 1)", "zh-CN": "页码(默认:1)" }, + }, + pageSize: { + type: "number", + valueHint: "", + description: { + "en-US": "Results per page (default: 10, max 100)", + "zh-CN": "每页结果数(默认:10,最多:100)", + }, + }, + status: { + type: "string", + valueHint: "", + choices: ["PENDING", "RUNNING", "SUCCESSED", "FAILED"] as const, + description: { + "en-US": "Filter by status (server-side)", + "zh-CN": "按任务状态筛选(服务端筛选)", + }, + }, + modelName: { + type: "string", + valueHint: "", + description: { + "en-US": "Filter by the server-generated model identifier (exact match, server-side)", + "zh-CN": "按服务端生成的模型标识精确筛选(服务端筛选)", + }, + }, +} satisfies FlagsDef; + +export default defineCommand({ + defaultOutput: "json", + description: { + "en-US": "List custom models (import jobs)", + "zh-CN": "列出自定义模型(导入任务)", + }, + auth: "apiKey", + usageArgs: "[--page ] [--page-size ] [--status ] [--model-name ]", + flags: LIST_FLAGS, + exampleArgs: [ + "", + "--status RUNNING", + "--model-name qwen3-32b-offline-20240101-abc1", + "--page-size 20", + ], + notes: [ + PUBLIC_DASHSCOPE_NOTE, + { + "en-US": + "status and model-name are server-side filters over the whole result set (not local page filters). FAILED entries include the server-provided error_code.", + "zh-CN": + "status 与 model-name 由服务端在整个结果集上筛选(不是本页本地筛选)。FAILED 条目会包含服务端提供的 error_code。", + }, + ], + validate(flags) { + if (flags.page !== undefined && (!Number.isFinite(flags.page) || flags.page < 1)) { + return "--page must be at least 1. / --page 必须至少为 1。"; + } + if ( + flags.pageSize !== undefined && + (!Number.isFinite(flags.pageSize) || flags.pageSize < 1 || flags.pageSize > 100) + ) { + return "--page-size must be between 1 and 100. / --page-size 必须在 1 到 100 之间。"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const params = { + pageNo: flags.page, + pageSize: flags.pageSize, + status: flags.status || undefined, + modelName: flags.modelName || undefined, + }; + + if (settings.dryRun) { + const resultData = { action: "cmodel.list", ...params }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + return; + } + + const response = await listCustomModelImports(ctx.client, params); + const payload = response.output ?? response.data; + const jobs: CustomModelImportJob[] = payload?.list ?? []; + const items = jobs.map((job) => ({ + job_id: job.job_id ?? "", + model_name: job.model_name ?? "", + display_name: job.display_name ?? "", + source: job.source ?? "", + storage_info: job.storage_info ?? null, + status: job.status ?? "", + gmt_create: job.gmt_create ?? "", + ...(job.error_code !== undefined ? { error_code: job.error_code } : {}), + })); + const resultData = { + items, + total: payload?.total ?? items.length, + page_no: payload?.page_no ?? params.pageNo ?? 1, + page_size: payload?.page_size ?? params.pageSize ?? 10, + request_id: response.request_id, + }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + }, +}); diff --git a/packages/commands/src/commands/cmodel/shared.ts b/packages/commands/src/commands/cmodel/shared.ts new file mode 100644 index 00000000..df9f5517 --- /dev/null +++ b/packages/commands/src/commands/cmodel/shared.ts @@ -0,0 +1,75 @@ +import { BailianError, ExitCode, type CustomModelImportJob, type FlagsDef } from "bailian-cli-core"; + +export const IMPORT_JOB_ID_FLAGS = { + jobId: { + type: "string", + valueHint: "", + description: { + "en-US": "Import job ID returned by `cmodel import` (required)", + "zh-CN": "由 `cmodel import` 返回的导入任务 ID(必填)", + }, + required: true, + }, +} satisfies FlagsDef; + +/** Server-documented terminal statuses (including the `SUCCESSED` spelling). */ +export const IMPORT_TERMINAL_STATUSES = new Set(["SUCCESSED", "FAILED"]); + +/** cmodel APIs live on the public DashScope host, not workspace hosts. */ +export const PUBLIC_DASHSCOPE_NOTE = { + "en-US": + "This command always calls the public DashScope endpoint (https://dashscope.aliyuncs.com) in the Beijing region. The API is not deployed on workspace-specific base URLs (https://.cn-beijing.maas.aliyuncs.com, HTTP 404), so the Profile base URL is not used for this request; workspace API keys are accepted.", + "zh-CN": + "该命令固定调用公共 DashScope 站点(https://dashscope.aliyuncs.com,北京地域)。此 API 未部署在 Workspace 专属 base URL(https://.cn-beijing.maas.aliyuncs.com,返回 HTTP 404)上,因此本次请求不使用 Profile 的 base URL;Workspace API Key 可正常调用。", +}; + +export function buildImportJobOutput( + job: CustomModelImportJob, + fallbackJobId: string, +): Record { + return { + job_id: job.job_id ?? fallbackJobId, + model_name: job.model_name ?? "", + display_name: job.display_name ?? "", + source: job.source ?? "", + storage_info: job.storage_info ?? null, + status: job.status ?? "", + gmt_create: job.gmt_create ?? "", + ...(job.error_code !== undefined ? { error_code: job.error_code } : {}), + }; +} + +export function formatImportFailure( + jobId: string, + status: string, + errorCode?: string, +): BailianError { + const detail = errorCode ? ` Server error_code: ${errorCode}` : ""; + return new BailianError( + `Import job ${jobId} ended in status ${status}.${detail} / 导入任务 ${jobId} 已以 ${status} 状态结束。${ + errorCode ? ` 服务端错误:${errorCode}` : "" + }`, + ExitCode.GENERAL, + ); +} + +/** + * Resolve after `milliseconds`, rejecting early if `signal` aborts (Ctrl-C). + */ +export function sleepWithSignal(milliseconds: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error("aborted")); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(new Error("aborted")); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/commands/src/commands/cmodel/watch.ts b/packages/commands/src/commands/cmodel/watch.ts new file mode 100644 index 00000000..3729b035 --- /dev/null +++ b/packages/commands/src/commands/cmodel/watch.ts @@ -0,0 +1,209 @@ +import { + BailianError, + ExitCode, + defineCommand, + getCustomModelImport, + type FlagsDef, +} from "bailian-cli-core"; + +import { + IMPORT_JOB_ID_FLAGS, + IMPORT_TERMINAL_STATUSES, + buildImportJobOutput, + formatImportFailure, + PUBLIC_DASHSCOPE_NOTE, + sleepWithSignal, +} from "./shared.ts"; + +const DEFAULT_INTERVAL_SEC = 10; +const MIN_INTERVAL_SEC = 1; + +const WATCH_FLAGS = { + ...IMPORT_JOB_ID_FLAGS, + follow: { + type: "switch", + description: { + "en-US": + "Block and poll until a terminal state. Without it, a single status probe is performed and the command returns immediately.", + "zh-CN": "阻塞并轮询至终态。不使用时仅查询一次状态并立即返回。", + }, + }, + interval: { + type: "number", + valueHint: "", + description: { + "en-US": `Seconds between polls with --follow (default: ${DEFAULT_INTERVAL_SEC}, min: ${MIN_INTERVAL_SEC}). Ignored without --follow.`, + "zh-CN": `使用 --follow 时的轮询间隔秒数(默认:${DEFAULT_INTERVAL_SEC},最小:${MIN_INTERVAL_SEC})。未使用 --follow 时忽略。`, + }, + }, + pollTimeout: { + type: "number", + valueHint: "", + description: { + "en-US": + "With --follow, stop polling after this many seconds (default: no limit). Ignored without --follow.", + "zh-CN": "使用 --follow 时,在指定秒数后停止轮询(默认:无限制)。未使用 --follow 时忽略。", + }, + }, +} satisfies FlagsDef; + +function formatElapsed(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes === 0) return `${seconds}s`; + return `${minutes}m ${seconds}s`; +} + +export default defineCommand({ + defaultOutput: "json", + description: { + "en-US": + "Probe a custom model's import status (default: single non-blocking fetch). Pass --follow to poll until terminal.", + "zh-CN": "查询自定义模型的导入状态(默认:单次非阻塞获取)。使用 --follow 持续轮询至终态。", + }, + auth: "apiKey", + usageArgs: "--job-id [--follow] [--interval ] [--poll-timeout ]", + flags: WATCH_FLAGS, + exampleArgs: [ + "--job-id 937b-xxxx", + "--job-id 937b-xxxx --output json", + "--job-id 937b-xxxx --follow", + "--job-id 937b-xxxx --follow --interval 5 --poll-timeout 3600", + ], + notes: [ + PUBLIC_DASHSCOPE_NOTE, + { + "en-US": + "Default (no --follow) is a NON-BLOCKING single status probe for agents/scripts. Terminal statuses are SUCCESSED and FAILED; an unknown server status is surfaced verbatim and treated as non-terminal.", + "zh-CN": + "默认不使用 --follow,执行非阻塞的单次状态查询,适合 Agent/脚本。终态为 SUCCESSED 和 FAILED;未知服务端状态原样透出并按非终态处理。", + }, + { + "en-US": + "A terminal FAILED status raises a normal CLI error (non-zero exit) with the server error_code preserved; SUCCEEDED and still-running statuses return 0. With --follow, exceeding --poll-timeout raises a timeout error.", + "zh-CN": + "终态 FAILED 会触发普通 CLI 错误(非零退出码)并保留服务端 error_code;SUCCEEDED 或仍在运行时返回 0。使用 --follow 时,超过 --poll-timeout 会触发超时错误。", + }, + ], + validate(flags) { + if (flags.interval !== undefined && (!Number.isFinite(flags.interval) || flags.interval < 1)) { + return "--interval must be at least 1 second. / --interval 必须至少为 1 秒。"; + } + if ( + flags.pollTimeout !== undefined && + (!Number.isFinite(flags.pollTimeout) || flags.pollTimeout < 1) + ) { + return "--poll-timeout must be at least 1 second. / --poll-timeout 必须至少为 1 秒。"; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const jobId = flags.jobId; + const follow = flags.follow; + const intervalSec = Math.max(MIN_INTERVAL_SEC, flags.interval ?? DEFAULT_INTERVAL_SEC); + const pollTimeoutSec = flags.pollTimeout; + + if (settings.dryRun) { + const resultData = { + action: "cmodel.watch", + job_id: jobId, + follow, + interval: intervalSec, + timeout: pollTimeoutSec, + }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + return; + } + + if (!follow) { + const response = await getCustomModelImport(ctx.client, jobId); + const job = response.output ?? response.data; + const status = String(job?.status ?? "").toUpperCase(); + const terminal = IMPORT_TERMINAL_STATUSES.has(status); + const resultData = job + ? { ...buildImportJobOutput(job, jobId), terminal, request_id: response.request_id } + : { + job_id: jobId, + status: "UNKNOWN", + terminal, + request_id: response.request_id, + }; + ctx.output.result( + resultData, + ctx.settings.output === "text" ? { text: JSON.stringify(resultData, null, 2) } : undefined, + ); + if (terminal && status !== "SUCCESSED") { + throw formatImportFailure(jobId, job?.status ?? status, job?.error_code); + } + return; + } + + const controller = new AbortController(); + const onSigint = () => controller.abort(); + process.on("SIGINT", onSigint); + + try { + let lastStatus = ""; + const startedAt = Date.now(); + + // eslint-disable-next-line no-constant-condition + while (true) { + const response = await getCustomModelImport(ctx.client, jobId, controller.signal); + const job = response.output ?? response.data; + const rawStatus = String(job?.status ?? ""); + const status = rawStatus.toUpperCase(); + + if (status !== lastStatus) { + ctx.output.progress({ + "en-US": `${jobId} ${rawStatus || "UNKNOWN"}`, + "zh-CN": `${jobId} ${rawStatus || "UNKNOWN"}`, + }); + lastStatus = status; + } + + if (IMPORT_TERMINAL_STATUSES.has(status)) { + const resultData = { + ...(job ? buildImportJobOutput(job, jobId) : { job_id: jobId }), + request_id: response.request_id, + }; + ctx.output.result( + resultData, + ctx.settings.output === "text" + ? { text: JSON.stringify(resultData, null, 2) } + : undefined, + ); + if (status !== "SUCCESSED") { + throw formatImportFailure(jobId, rawStatus, job?.error_code); + } + return; + } + + if (pollTimeoutSec !== undefined && (Date.now() - startedAt) / 1000 >= pollTimeoutSec) { + throw new BailianError( + `Watching import job ${jobId} timed out after ${formatElapsed( + Date.now() - startedAt, + )} (last status: ${rawStatus || "UNKNOWN"}). / 跟踪导入任务 ${jobId} 超时(耗时 ${formatElapsed( + Date.now() - startedAt, + )},最后状态:${rawStatus || "UNKNOWN"})。`, + ExitCode.TIMEOUT, + ); + } + + await sleepWithSignal(intervalSec * 1000, controller.signal); + } + } catch (error) { + if (controller.signal.aborted) { + ctx.output.info({ "en-US": "Interrupted.", "zh-CN": "已中断。" }); + return; + } + throw error; + } finally { + process.off("SIGINT", onSigint); + } + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index d9377e67..b06422ab 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -113,6 +113,12 @@ export { default as advisorRecommend } from "./commands/advisor/recommend.ts"; export { default as modelList } from "./commands/model/list.ts"; export { default as modelSearch } from "./commands/model/search.ts"; export { default as modelCode } from "./commands/model/code.ts"; +export { default as cmodelImport } from "./commands/cmodel/import.ts"; +export { default as cmodelGet } from "./commands/cmodel/get.ts"; +export { default as cmodelList } from "./commands/cmodel/list.ts"; +export { default as cmodelWatch } from "./commands/cmodel/watch.ts"; +export { default as cmodelDelete } from "./commands/cmodel/delete.ts"; +export { default as cmodelBaseModels } from "./commands/cmodel/base-models.ts"; export { default as workspaceList } from "./commands/workspace/list.ts"; export { default as quotaList } from "./commands/quota/list.ts"; export { default as quotaUpdate } from "./commands/quota/update.ts"; diff --git a/packages/commands/tests/e2e/cmodel.e2e.test.ts b/packages/commands/tests/e2e/cmodel.e2e.test.ts new file mode 100644 index 00000000..1cbdeab8 --- /dev/null +++ b/packages/commands/tests/e2e/cmodel.e2e.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCommandHelp, runCommandInProcess } from "./helpers.ts"; +import { CMODEL_ROUTES } from "./topic-routes.ts"; + +describe("e2e: cmodel", () => { + test("cmodel --help 列出子命令", async () => { + const { stderr, exitCode } = await runCommandHelp(CMODEL_ROUTES, ["cmodel", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Usage: bl cmodel [flags]"); + expect(stderr).toMatch(/import|get|list|watch|delete|base-models/); + }); + + test("cmodel import --help 展示导入参数与可选 console 预检 flag", async () => { + const { stderr, exitCode } = await runCommandHelp(CMODEL_ROUTES, [ + "cmodel", + "import", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("--base-model"); + expect(stderr).toContain("--weight-type"); + expect(stderr).toContain("--bucket"); + expect(stderr).toContain("--object-key"); + // optionalConsole: apiKey 命令额外可见 console 域 flag + expect(stderr).toContain("--console-region"); + expect(stderr).toContain("API Key"); + }); + + test("cmodel import 缺少 --weight-type 报错", async () => { + const { stderr, exitCode } = await runCommandInProcess(CMODEL_ROUTES, [ + "cmodel", + "import", + "--base-model", + "qwen3-32b", + "--bucket", + "my-model-bucket", + "--object-key", + "models/qwen3-32b-lora/", + ]); + expect(exitCode).toBe(2); + expect(stderr).toContain("--weight-type"); + }); + + test("cmodel import --weight-type 非法取值报错", async () => { + const { stderr, exitCode } = await runCommandInProcess(CMODEL_ROUTES, [ + "cmodel", + "import", + "--base-model", + "qwen3-32b", + "--weight-type", + "qlora", + "--bucket", + "my-model-bucket", + "--object-key", + "models/qwen3-32b-lora/", + ]); + expect(exitCode).toBe(2); + expect(stderr).toContain("--weight-type"); + }); + + test.each([ + { + name: "object-key 不以 / 结尾", + objectKey: "models/qwen3-32b-lora", + expected: "--object-key", + }, + { name: "object-key 是 Bucket 根目录", objectKey: "/", expected: "--object-key" }, + { + name: "display-name 超过 50 字符", + displayName: "x".repeat(51), + expected: "--display-name", + }, + { name: "bucket 命名非法", bucket: "My_Model_Bucket", expected: "--bucket" }, + ])("cmodel 校验失败:$name", async ({ objectKey, displayName, bucket, expected }) => { + const args = [ + "cmodel", + "import", + "--base-model", + "qwen3-32b", + "--weight-type", + "lora", + "--bucket", + bucket ?? "my-model-bucket", + "--object-key", + objectKey ?? "models/qwen3-32b-lora/", + ]; + if (displayName) args.push("--display-name", displayName); + const { stderr, exitCode } = await runCommandInProcess(CMODEL_ROUTES, args); + expect(exitCode).toBe(2); + expect(stderr).toContain(expected); + }); + + test("cmodel import --dry-run 构造 OSS 导入请求体且跳过预检", async () => { + const { stdout, stderr, exitCode } = await runCommandInProcess(CMODEL_ROUTES, [ + "cmodel", + "import", + "--base-model", + "qwen3-32b", + "--weight-type", + "lora", + "--bucket", + "my-model-bucket", + "--object-key", + "models/qwen3-32b-lora/", + "--display-name", + "my-lora", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + action?: string; + request?: { + model_name?: string; + display_name?: string; + source?: string; + weight_type?: string; + storage_info?: { bucket_name?: string; object_key?: string }; + }; + precheck?: { performed?: boolean; reason?: string }; + }>(stdout); + expect(data.action).toBe("cmodel.import"); + expect(data.request?.model_name).toBe("qwen3-32b"); + expect(data.request?.display_name).toBe("my-lora"); + expect(data.request?.source).toBe("oss"); + expect(data.request?.weight_type).toBe("lora"); + expect(data.request?.storage_info).toEqual({ + bucket_name: "my-model-bucket", + object_key: "models/qwen3-32b-lora/", + }); + expect(data.precheck).toEqual({ performed: false, reason: "dry-run" }); + }); + + test.each([ + { + name: "get", + args: ["get", "--job-id", "937b-xxxx"], + action: "cmodel.get", + }, + { + name: "list", + args: ["list", "--status", "RUNNING", "--page", "2", "--page-size", "20"], + action: "cmodel.list", + }, + { + name: "watch", + args: ["watch", "--job-id", "937b-xxxx", "--follow", "--interval", "5"], + action: "cmodel.watch", + }, + { name: "delete", args: ["delete", "--job-id", "937b-xxxx"], action: "cmodel.delete" }, + ])("cmodel $name --dry-run 输出计划", async ({ args, action }) => { + const { stdout, stderr, exitCode } = await runCommandInProcess(CMODEL_ROUTES, [ + "cmodel", + ...args, + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + action?: string; + job_id?: string; + status?: string; + follow?: boolean; + }>(stdout); + expect(data.action).toBe(action); + if (data.job_id) expect(data.job_id).toBe("937b-xxxx"); + if (action === "cmodel.list") expect(data.status).toBe("RUNNING"); + if (action === "cmodel.watch") expect(data.follow).toBe(true); + }); + + test.each([ + { + name: "list --page-size 0", + args: ["list", "--page-size", "0"], + expected: "--page-size", + }, + { + name: "watch --interval 0", + args: ["watch", "--job-id", "937b-xxxx", "--interval", "0"], + expected: "--interval", + }, + ])("cmodel 校验失败:$name", async ({ args, expected }) => { + const { stderr, exitCode } = await runCommandInProcess(CMODEL_ROUTES, ["cmodel", ...args]); + expect(exitCode).toBe(2); + expect(stderr).toContain(expected); + }); + + test("cmodel import delete --help 展示高风险提示", async () => { + const { stderr, exitCode } = await runCommandHelp(CMODEL_ROUTES, [ + "cmodel", + "delete", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Risk"); + expect(stderr).toContain("cannot be undone"); + }); + + test("cmodel import base-models 使用 Console 鉴权并暴露地域 flag", async () => { + const { stderr, exitCode } = await runCommandHelp(CMODEL_ROUTES, [ + "cmodel", + "base-models", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Console"); + expect(stderr).toContain("--console-region"); + }); + + test("cmodel import base-models --dry-run 输出 console 目标", async () => { + const { stdout, stderr, exitCode } = await runCommandInProcess(CMODEL_ROUTES, [ + "cmodel", + "base-models", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + action?: string; + consoleRegion?: string; + consoleSite?: string; + }>(stdout); + expect(data.action).toBe("cmodel.baseModels"); + expect(data.consoleRegion).toBe("cn-beijing"); + expect(data.consoleSite).toBe("domestic"); + }); +}); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index f72e4b6a..34a6dbfc 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -182,6 +182,15 @@ export const MODEL_ROUTES: E2eRouteExports = { "model code": "modelCode", }; +export const CMODEL_ROUTES: E2eRouteExports = { + "cmodel import": "cmodelImport", + "cmodel get": "cmodelGet", + "cmodel list": "cmodelList", + "cmodel watch": "cmodelWatch", + "cmodel delete": "cmodelDelete", + "cmodel base-models": "cmodelBaseModels", +}; + export const QUOTA_ROUTES: E2eRouteExports = { "quota list": "quotaList", "quota update": "quotaUpdate", diff --git a/packages/commands/tests/output/managed-command-output.test.ts b/packages/commands/tests/output/managed-command-output.test.ts index 95a9519d..bf9f403f 100644 --- a/packages/commands/tests/output/managed-command-output.test.ts +++ b/packages/commands/tests/output/managed-command-output.test.ts @@ -526,10 +526,20 @@ for (const entry of mapping) { else if (nativeStream) { expect(result.strayOut).toBe(baseline.strayOut); expect(result.stdout).toBe("\n"); - } else - expect(canonical(result.stdout, result.fixtureVersionId)).toBe( - canonical(baseline.stdout + baseline.strayOut, baseline.fixtureVersionId), + } else { + // Version IDs embed a random nonce; when the first 12 characters + // happen to match a YAML number pattern (e.g. `0x`), the text + // serializer quotes the scalar. Normalize both quote forms. + const normalizeScalarQuotes = (value: string) => + value + .replaceAll('""', "") + .replaceAll('""', ""); + expect(normalizeScalarQuotes(canonical(result.stdout, result.fixtureVersionId))).toBe( + normalizeScalarQuotes( + canonical(baseline.stdout + baseline.strayOut, baseline.fixtureVersionId), + ), ); + } expect(result.calls).toEqual(baseline.calls); expect(result.stderr).not.toContain("fixture-api-key"); expect(result.stderr).not.toContain("fixture-secret-value"); diff --git a/packages/core/package.json b/packages/core/package.json index 92c65172..eb7f0705 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "2.7.2", + "version": "2.7.3", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index a9acbdeb..c6f80f17 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -314,6 +314,28 @@ export function finetuneCheckpointsPath(jobId: string): string { return `/api/v1/fine-tunes/${encodeURIComponent(jobId)}/checkpoints`; } +// ---- Custom Model Import (public DashScope host only) ---- + +/** + * Absolute base endpoint for OSS custom-model import jobs. + * + * This API family is deployed only on the public DashScope host — workspace + * hosts (https://.cn-beijing.maas.aliyuncs.com) return HTTP 404 for + * it, even though they serve other /api/v1 routes. Workspace API keys are + * accepted by the public host, so callers keep using the resolved credential. + */ +const CUSTOM_MODEL_IMPORT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/custom_models/import"; + +/** Create (POST) and list (GET) endpoint. */ +export function customModelImportEndpoint(): string { + return CUSTOM_MODEL_IMPORT_ENDPOINT; +} + +/** Single-import-job get (GET) / delete (DELETE) endpoint. */ +export function customModelImportJobEndpoint(jobId: string): string { + return `${CUSTOM_MODEL_IMPORT_ENDPOINT}/${encodeURIComponent(jobId)}`; +} + /** GET /api/v1/fine-tunes/{job_id}/export/{checkpoint} */ export function finetuneExportPath(jobId: string, checkpoint: string): string { return `/api/v1/fine-tunes/${encodeURIComponent(jobId)}/export/${encodeURIComponent(checkpoint)}`; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df16203d..737eb6ef 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ export * from "./decision/index.ts"; export * from "./parsex/index.ts"; export * from "./finetune/index.ts"; export * from "./deploy/index.ts"; +export * from "./model-import/index.ts"; export * from "./speech/index.ts"; export * from "./types/index.ts"; export * from "./utils/index.ts"; diff --git a/packages/core/src/model-import/api.ts b/packages/core/src/model-import/api.ts new file mode 100644 index 00000000..612406bb --- /dev/null +++ b/packages/core/src/model-import/api.ts @@ -0,0 +1,84 @@ +/** + * Custom model import HTTP API wrappers. + * + * Thin functions over `requestJson` for DashScope + * `/api/v1/custom_models/import`. Server errors are surfaced verbatim by the + * shared HTTP error layer — these wrappers never translate them. + */ +import { customModelImportEndpoint, customModelImportJobEndpoint } from "../client/endpoints.ts"; +import type { Client } from "../client/client.ts"; +import type { + CreateCustomModelImportRequest, + CreateCustomModelImportResponse, + DeleteCustomModelImportResponse, + GetCustomModelImportResponse, + ListCustomModelImportsResponse, +} from "./types.ts"; + +/** POST /api/v1/custom_models/import */ +export async function createCustomModelImport( + client: Client, + body: CreateCustomModelImportRequest, + signal?: AbortSignal, +): Promise { + return client.requestJson({ + path: customModelImportEndpoint(), + method: "POST", + body, + signal, + }); +} + +export interface ListCustomModelImportsParams { + pageNo?: number; + pageSize?: number; + status?: string; + /** Exact match against the server-generated `model_name`. */ + modelName?: string; + signal?: AbortSignal; +} + +/** GET /api/v1/custom_models/import */ +export async function listCustomModelImports( + client: Client, + params: ListCustomModelImportsParams = {}, +): Promise { + const query = new URLSearchParams(); + if (params.pageNo !== undefined) query.set("page_no", String(params.pageNo)); + if (params.pageSize !== undefined) query.set("page_size", String(params.pageSize)); + if (params.status) query.set("status", params.status); + if (params.modelName) query.set("model_name", params.modelName); + const basePath = customModelImportEndpoint(); + const path = query.toString() ? `${basePath}?${query.toString()}` : basePath; + return client.requestJson({ + path, + method: "GET", + signal: params.signal, + }); +} + +/** GET /api/v1/custom_models/import/{job_id} */ +export async function getCustomModelImport( + client: Client, + jobId: string, + signal?: AbortSignal, +): Promise { + return client.requestJson({ + path: customModelImportJobEndpoint(jobId), + method: "GET", + signal, + }); +} + +/** DELETE /api/v1/custom_models/import/{job_id} */ +export async function deleteCustomModelImport( + client: Client, + jobId: string, + signal?: AbortSignal, +): Promise { + return client.requestJson({ + path: customModelImportJobEndpoint(jobId), + method: "DELETE", + signal, + }); +} diff --git a/packages/core/src/model-import/console.ts b/packages/core/src/model-import/console.ts new file mode 100644 index 00000000..0c89baad --- /dev/null +++ b/packages/core/src/model-import/console.ts @@ -0,0 +1,58 @@ +/** + * Console-domain import catalog: base models that support OSS import. + * + * The frontend page calls the same zelda API for its "base model" picker. + * The gateway layer injects `cornerstoneParam` (protocol/console/productCode/ + * switchUserType/consoleSite) and the console access token; the business data + * payload is empty. + */ +import type { Client } from "../client/client.ts"; +import { unwrapResponse } from "../console/models.ts"; +import type { SupportImportModel, SupportImportModelsResult } from "./types.ts"; + +export const LIST_SUPPORT_IMPORT_MODELS_API = + "zeldaEasy.broadscope-platform.customModel.listSupportImportModels"; + +/** + * List base models that currently support custom-model import. + * + * Requires a console credential (`client.console` throws AUTH without one); + * callers that treat this as an optional precheck must catch that error. + */ +export async function listSupportImportModels(client: Client): Promise { + const raw = await client.console>(LIST_SUPPORT_IMPORT_MODELS_API, {}); + const response = unwrapResponse(raw) as { + total?: number; + pageNo?: number; + pageSize?: number; + list?: SupportImportModel[]; + }; + return { + total: response.total, + pageNo: response.pageNo, + pageSize: response.pageSize, + list: response.list ?? [], + }; +} + +/** Exact (case-insensitive) match on the catalog `model` ID. */ +export function findSupportImportModel( + models: readonly SupportImportModel[], + baseModel: string, +): SupportImportModel | undefined { + const target = baseModel.trim().toLowerCase(); + return models.find((entry) => String(entry.model ?? "").toLowerCase() === target); +} + +/** + * Console train type required by an import weight type. + * + * The catalog's `supportTrainTypes` uses the fine-tune vocabulary: `sft` + * (full-parameter) and `efficient_sft` (LoRA). Mapping the import + * `weight_type` onto that vocabulary lets callers reject unsupported + * combinations before the service answers with its opaque + * "No import access for base model" error. + */ +export function requiredTrainTypeForWeightType(weightType: string): "sft" | "efficient_sft" { + return weightType === "lora" ? "efficient_sft" : "sft"; +} diff --git a/packages/core/src/model-import/index.ts b/packages/core/src/model-import/index.ts new file mode 100644 index 00000000..1ffa9d38 --- /dev/null +++ b/packages/core/src/model-import/index.ts @@ -0,0 +1,3 @@ +export * from "./types.ts"; +export * from "./api.ts"; +export * from "./console.ts"; diff --git a/packages/core/src/model-import/types.ts b/packages/core/src/model-import/types.ts new file mode 100644 index 00000000..514a4f2c --- /dev/null +++ b/packages/core/src/model-import/types.ts @@ -0,0 +1,106 @@ +/** + * Custom model import API types. + * + * Maps DashScope `/api/v1/custom_models/import` request/response shapes + * (snake_case preserved verbatim — callers decide how to surface fields). + */ + +/** OSS storage location of the model files to import. */ +export interface CustomModelImportStorageInfo { + bucket_name: string; + /** Directory prefix; must end with `/` and cannot be the bucket root. */ + object_key: string; +} + +/** POST /api/v1/custom_models/import request body. */ +export interface CreateCustomModelImportRequest { + /** Base model ID (e.g. `qwen3-32b`). */ + model_name: string; + /** Display name; server defaults to the base model name when omitted. */ + display_name?: string; + /** Import source. Only `oss` is currently supported. */ + source: string; + /** `lora` (LoRA-tuned) or `full` (fully-tuned). */ + weight_type: string; + storage_info: CustomModelImportStorageInfo; +} + +/** A custom model import job as returned by the platform. */ +export interface CustomModelImportJob { + job_id?: string; + /** Server-generated model identifier (base model name + timestamp suffix). */ + model_name?: string; + display_name?: string; + /** Uppercase `OSS` in responses. */ + source?: string; + /** Present on create; omitted by get/list. */ + weight_type?: string; + storage_info?: CustomModelImportStorageInfo; + /** PENDING | RUNNING | SUCCESSED | FAILED (server values verbatim). */ + status?: string; + /** Present when status is FAILED; server-provided reason text. */ + error_code?: string; + gmt_create?: string; + [key: string]: unknown; +} + +export interface CreateCustomModelImportResponse { + request_id?: string; + output?: CustomModelImportJob; + data?: CustomModelImportJob; +} + +export interface GetCustomModelImportResponse { + request_id?: string; + output?: CustomModelImportJob; + data?: CustomModelImportJob; +} + +export interface DeleteCustomModelImportResponse { + request_id?: string; + output?: CustomModelImportJob; + data?: CustomModelImportJob; +} + +export interface ListCustomModelImportsResponse { + request_id?: string; + output?: { + total?: number; + page_no?: number; + page_size?: number; + list?: CustomModelImportJob[]; + [key: string]: unknown; + }; + data?: { + total?: number; + page_no?: number; + page_size?: number; + list?: CustomModelImportJob[]; + [key: string]: unknown; + }; +} + +/** Entry returned by the console `listSupportImportModels` catalog API. */ +export interface SupportImportModel { + /** Display name (e.g. `Qwen3-32B`); may be absent. */ + name?: string; + /** Model ID used to match `--base-model` (e.g. `qwen3-32b`). */ + model?: string; + /** Training methods supported for import (e.g. `sft`, `efficient_sft`). */ + supportTrainTypes?: string[]; + postProcessCapabilities?: { + quantizationSupported?: boolean; + mtpSupported?: boolean; + quantizationStrategyCodes?: string[]; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** Parsed `listSupportImportModels` catalog result. */ +export interface SupportImportModelsResult { + total?: number; + pageNo?: number; + pageSize?: number; + list: SupportImportModel[]; +} diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 3d7e6822..e5e6c131 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -230,8 +230,13 @@ export type SourceFlags = ParsedFlags< >; /** 该命令可见的凭证域 flag 定义。 */ -export function credentialFlagDefs(cmd: { auth: AuthRequirement }): FlagsDef { - if (cmd.auth === "apiKey") return MODEL_AUTH_FLAGS; +export function credentialFlagDefs(cmd: { + auth: AuthRequirement; + optionalConsole?: boolean; +}): FlagsDef { + if (cmd.auth === "apiKey") { + return cmd.optionalConsole ? { ...MODEL_AUTH_FLAGS, ...CONSOLE_AUTH_FLAGS } : MODEL_AUTH_FLAGS; + } if (cmd.auth === "console") return CONSOLE_AUTH_FLAGS; if (cmd.auth === "openapi") return OPENAPI_AUTH_FLAGS; return {}; @@ -313,6 +318,14 @@ export interface Command { description: LocalizedText; /** Credential this command requires. See {@link AuthRequirement}. */ auth: AuthRequirement; + /** + * Best-effort secondary console credential for `auth: "apiKey"` commands. + * When true, the runtime additionally resolves the console credential and + * injects it when available; a missing/expired console session never blocks + * the command. Console-domain flags become visible so the precheck target can + * be selected explicitly. + */ + optionalConsole?: boolean; /** * Runtime-classified operation risk and its user-facing consequence message. * Omit for normal commands. diff --git a/packages/core/tests/model-import-api.test.ts b/packages/core/tests/model-import-api.test.ts new file mode 100644 index 00000000..dfbc4269 --- /dev/null +++ b/packages/core/tests/model-import-api.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test, vi } from "vite-plus/test"; +import { + createCustomModelImport, + deleteCustomModelImport, + findSupportImportModel, + getCustomModelImport, + listCustomModelImports, + listSupportImportModels, + LIST_SUPPORT_IMPORT_MODELS_API, + requiredTrainTypeForWeightType, + type Client, +} from "../src/index.ts"; + +function mockClient(response: unknown = {}) { + const requestJson = vi.fn().mockResolvedValue(response); + return { client: { requestJson } as unknown as Client, requestJson }; +} + +describe("custom model import API", () => { + test("maps create, get, list, and delete endpoints", async () => { + const { client, requestJson } = mockClient(); + await createCustomModelImport(client, { + model_name: "qwen3-32b", + source: "oss", + weight_type: "lora", + storage_info: { bucket_name: "my-model-bucket", object_key: "models/qwen3-32b-lora/" }, + }); + await getCustomModelImport(client, "job /?#"); + await listCustomModelImports(client, { + pageNo: 2, + pageSize: 20, + status: "RUNNING", + modelName: "qwen3-32b-offline-20240101-abc1", + }); + await deleteCustomModelImport(client, "job /?#"); + + expect(requestJson.mock.calls.map(([options]) => [options.method, options.path])).toEqual([ + ["POST", "https://dashscope.aliyuncs.com/api/v1/custom_models/import"], + ["GET", "https://dashscope.aliyuncs.com/api/v1/custom_models/import/job%20%2F%3F%23"], + [ + "GET", + "https://dashscope.aliyuncs.com/api/v1/custom_models/import?page_no=2&page_size=20&status=RUNNING&model_name=qwen3-32b-offline-20240101-abc1", + ], + ["DELETE", "https://dashscope.aliyuncs.com/api/v1/custom_models/import/job%20%2F%3F%23"], + ]); + expect(requestJson.mock.calls[0]?.[0].body).toEqual({ + model_name: "qwen3-32b", + source: "oss", + weight_type: "lora", + storage_info: { bucket_name: "my-model-bucket", object_key: "models/qwen3-32b-lora/" }, + }); + }); + + test("keeps list query empty when no filters are provided", async () => { + const { client, requestJson } = mockClient(); + await listCustomModelImports(client); + expect(requestJson).toHaveBeenCalledWith({ + path: "https://dashscope.aliyuncs.com/api/v1/custom_models/import", + method: "GET", + signal: undefined, + }); + }); +}); + +describe("console-supported import model catalog", () => { + test("calls listSupportImportModels with an empty business payload and unwraps DataV2", async () => { + const consoleCall = vi + .fn() + .mockResolvedValue(gatewayResponse([{ model: "qwen3-32b", supportTrainTypes: ["sft"] }])); + const client = { console: consoleCall } as unknown as Client; + + const result = await listSupportImportModels(client); + + expect(consoleCall).toHaveBeenCalledWith(LIST_SUPPORT_IMPORT_MODELS_API, {}); + expect(result.total).toBe(1); + expect(result.list).toEqual([{ model: "qwen3-32b", supportTrainTypes: ["sft"] }]); + }); + + test("matches the catalog model ID case-insensitively", () => { + const models = [{ model: "qwen3-32b" }, { model: "deepseek-v4-flash-0731" }]; + expect(findSupportImportModel(models, "Qwen3-32B")?.model).toBe("qwen3-32b"); + expect(findSupportImportModel(models, "qwen3-8b")).toBeUndefined(); + }); + + test("maps import weight types onto console train types", () => { + expect(requiredTrainTypeForWeightType("lora")).toBe("efficient_sft"); + expect(requiredTrainTypeForWeightType("full")).toBe("sft"); + }); +}); + +/** Mirrors the real console gateway envelope (DataV2 double wrap). */ +function gatewayResponse(list: unknown[]) { + return { + code: "200", + data: { + DataV2: { + ret: ["SUCCESS::接口调用成功"], + data: { + code: "200", + data: { total: list.length, pageNo: 1, pageSize: list.length, list }, + success: true, + appName: "broadscope", + }, + }, + success: true, + }, + }; +} diff --git a/packages/kscli/package.json b/packages/kscli/package.json index ccc9c27a..4adc00d4 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-studio-cli", - "version": "2.7.2", + "version": "2.7.3", "description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.", "keywords": [ "alibaba-cloud", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index dfbe02ec..57f08c9e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-runtime", - "version": "2.7.2", + "version": "2.7.3", "description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 0e34e15d..61027fce 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -216,6 +216,23 @@ export const authStage: Middleware = async (ctx, next) => { ...resolveModelBaseUrlState(apiSources), apiCred: cred, }); + // Optional console precheck surface: inject the console credential when it + // resolves, but never fail an apiKey command because the console session is + // missing or expired. Commands must treat `client.console` calls as + // best-effort and catch AUTH errors themselves. + if (command.optionalConsole) { + try { + const consoleCred = resolveConsole(sources); + ctx.client = new Client({ + ...base, + ...resolveModelBaseUrlState(apiSources), + apiCred: cred, + consoleCred, + }); + } catch { + // Best-effort only — the command's apiKey flow continues. + } + } if (cred && !command.prepare) maybeShowStatusBar(settings, cred.token, cred, ctx.output); } else if (command.auth === "console") { let cred: ConsoleCredential | undefined; diff --git a/packages/runtime/tests/console-flags.e2e.test.ts b/packages/runtime/tests/console-flags.e2e.test.ts index dd022f5b..cccdfcd8 100644 --- a/packages/runtime/tests/console-flags.e2e.test.ts +++ b/packages/runtime/tests/console-flags.e2e.test.ts @@ -50,5 +50,15 @@ describe.each([ expect(stdout).toBe(""); expect(stderr).toMatch(new RegExp(`${unknownFlag}.*--api-key`)); }); + + test("optionalConsole 命令接受 --console-region 且 dry-run 正常退出", async () => { + const { stdout, stderr, exitCode } = await runRuntimeHarness( + ["cmodel", "import", "--console-region", "cn-beijing", "--dry-run"], + language, + ); + expect(exitCode, stderr).toBe(0); + expect(stderr).toBe(""); + expect(stdout).toBe(""); + }); }, ); diff --git a/packages/runtime/tests/harness/cross-domain-main.ts b/packages/runtime/tests/harness/cross-domain-main.ts index c585486d..b8f710b2 100644 --- a/packages/runtime/tests/harness/cross-domain-main.ts +++ b/packages/runtime/tests/harness/cross-domain-main.ts @@ -22,8 +22,17 @@ const mcpList = defineCommand({ run: noop, }); +/** apiKey + optionalConsole stub:用于可选 console 预检 flag 可见性测试 */ +const cmodelImport = defineCommand({ + description: "runtime e2e stub", + auth: "apiKey", + optionalConsole: true, + flags: {}, + run: noop, +}); + void createCli( - { "text chat": textChat, "mcp list": mcpList }, + { "text chat": textChat, "mcp list": mcpList, "cmodel import": cmodelImport }, { binName: "bl", version: pkg.version, diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 8af88875..1c0266ed 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-cli metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-cli/reference/cmodel.md b/skills/bailian-cli/reference/cmodel.md new file mode 100644 index 00000000..63ab162e --- /dev/null +++ b/skills/bailian-cli/reference/cmodel.md @@ -0,0 +1,265 @@ +# `bl cmodel` 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 cmodel base-models` | Console | List base models that can be imported as custom models | +| `bl cmodel delete` | API Key | Delete a custom model and its Bailian-side import record | +| `bl cmodel get` | API Key | Get details of a custom model (import job) | +| `bl cmodel import` | API Key | Import a custom model from OSS (creates an import job) | +| `bl cmodel list` | API Key | List custom models (import jobs) | +| `bl cmodel watch` | API Key | Probe a custom model's import status (default: single non-blocking fetch). Pass --follow to poll until terminal. | + +## Command details + +### `bl cmodel base-models` + +| Field | Value | +| ------------------ | ------------------------------------------------------ | +| **Name** | `cmodel base-models` | +| **Description** | List base models that can be imported as custom models | +| **Authentication** | Console | +| **Usage** | `bl cmodel base-models` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------- | +| `--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) | + +#### Notes + +- The list is returned dynamically by the console API and may change between releases. Import is currently available only in the Beijing region. + +#### Examples + +```bash +bl cmodel base-models +``` + +```bash +bl cmodel base-models --console-region cn-beijing +``` + +```bash +bl cmodel base-models --output json +``` + +### `bl cmodel delete` + +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `cmodel delete` | +| **Description** | Delete a custom model and its Bailian-side import record | +| **Authentication** | API Key | +| **Usage** | `bl cmodel delete --job-id ` | +| **Risk** | `high` | +| **Risk message** | This permanently deletes the Bailian-side import record and imported model copy; it cannot be undone. OSS source files are not modified. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | ---------------------------------------------------- | +| `--job-id ` | string | yes | Import job ID returned by `cmodel import` (required) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- This command always calls the public DashScope endpoint (https://dashscope.aliyuncs.com) in the Beijing region. The API is not deployed on workspace-specific base URLs (https://.cn-beijing.maas.aliyuncs.com, HTTP 404), so the Profile base URL is not used for this request; workspace API keys are accepted. +- Irreversible: the import record and imported model are removed from Bailian and must be imported again to restore. OSS source files are untouched. +- Only SUCCESSED or FAILED jobs can be deleted; deleting a RUNNING job is rejected by the service (OperationDenied) and the error is surfaced verbatim. + +#### Examples + +```bash +bl cmodel delete --job-id 937b-xxxx +``` + +```bash +bl cmodel delete --job-id 937b-xxxx --dry-run +``` + +```bash +# Only after explicit user confirmation: +bl cmodel delete --job-id 937b-xxxx --yes +``` + +### `bl cmodel get` + +| Field | Value | +| ------------------ | ------------------------------------------ | +| **Name** | `cmodel get` | +| **Description** | Get details of a custom model (import job) | +| **Authentication** | API Key | +| **Usage** | `bl cmodel get --job-id ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | ---------------------------------------------------- | +| `--job-id ` | string | yes | Import job ID returned by `cmodel import` (required) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- This command always calls the public DashScope endpoint (https://dashscope.aliyuncs.com) in the Beijing region. The API is not deployed on workspace-specific base URLs (https://.cn-beijing.maas.aliyuncs.com, HTTP 404), so the Profile base URL is not used for this request; workspace API keys are accepted. +- FAILED jobs include the server-provided error_code verbatim; the CLI does not translate it. + +#### Examples + +```bash +bl cmodel get --job-id 937b-xxxx +``` + +```bash +bl cmodel get --job-id 937b-xxxx --output json +``` + +### `bl cmodel import` + +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `cmodel import` | +| **Description** | Import a custom model from OSS (creates an import job) | +| **Authentication** | API Key | +| **Usage** | `bl cmodel import --base-model --weight-type --bucket --object-key [--display-name ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------------------------ | +| `--base-model ` | string | yes | Base model ID used for tuning (required, e.g. qwen3-32b) | +| `--weight-type ` | string | yes | Tuning weight type (required): lora or full | +| `--bucket ` | string | yes | OSS bucket containing the model files (required) | +| `--object-key ` | string | yes | OSS directory prefix containing the model files (required, must end with / and not be the bucket root) | +| `--display-name ` | string | no | Display name (optional, max 50 characters) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | +| `--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) | + +#### Notes + +- This command always calls the public DashScope endpoint (https://dashscope.aliyuncs.com) in the Beijing region. The API is not deployed on workspace-specific base URLs (https://.cn-beijing.maas.aliyuncs.com, HTTP 404), so the Profile base URL is not used for this request; workspace API keys are accepted. +- After submission, follow the job with `cmodel watch --job-id` and inspect it with `cmodel get` / `cmodel list`. +- The import API is currently available only in the Beijing region; use the console in other regions. +- The source is fixed to OSS in this release. The bucket must be tagged bailian-datahub-access=read, must not use archive/cold-archive/deep-archive storage or SSE-KMS, and the directory must contain the model files required by the platform (e.g. adapter_model.safetensors, adapter_config.json, config.json). +- When a valid console session is available, the base model is checked against the console-supported import list before submission: the ID is normalized to the catalog `model` value, and weight-type is checked against the model's supportTrainTypes (lora requires efficient_sft, full requires sft). Unsupported combinations fail immediately with a usage error. If the console session is missing or expired, the check is skipped with a warning and the import API's final result is authoritative. +- The current product version supports importing LoRA models; submitting weight-type=full may be rejected by the service. The API contract keeps both values, so the CLI does not hide `full`. + +#### Examples + +```bash +bl cmodel import --base-model qwen3-32b --weight-type lora --bucket my-model-bucket --object-key models/qwen3-32b-lora/ +``` + +```bash +bl cmodel import --base-model qwen3-32b --weight-type lora --bucket my-model-bucket --object-key models/qwen3-32b-lora/ --display-name my-lora +``` + +```bash +bl cmodel import --base-model qwen3-32b --weight-type lora --bucket my-model-bucket --object-key models/qwen3-32b-lora/ --dry-run +``` + +### `bl cmodel list` + +| Field | Value | +| ------------------ | ----------------------------------------------------------------------------------------- | +| **Name** | `cmodel list` | +| **Description** | List custom models (import jobs) | +| **Authentication** | API Key | +| **Usage** | `bl cmodel list [--page ] [--page-size ] [--status ] [--model-name ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------------------------ | ------ | -------- | -------------------------------------------------------------------------- | +| `--page ` | number | no | Page number (default: 1) | +| `--page-size ` | number | no | Results per page (default: 10, max 100) | +| `--status ` | string | no | Filter by status (server-side) | +| `--model-name ` | string | no | Filter by the server-generated model identifier (exact match, server-side) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- This command always calls the public DashScope endpoint (https://dashscope.aliyuncs.com) in the Beijing region. The API is not deployed on workspace-specific base URLs (https://.cn-beijing.maas.aliyuncs.com, HTTP 404), so the Profile base URL is not used for this request; workspace API keys are accepted. +- status and model-name are server-side filters over the whole result set (not local page filters). FAILED entries include the server-provided error_code. + +#### Examples + +```bash +bl cmodel list +``` + +```bash +bl cmodel list --status RUNNING +``` + +```bash +bl cmodel list --model-name qwen3-32b-offline-20240101-abc1 +``` + +```bash +bl cmodel list --page-size 20 +``` + +### `bl cmodel watch` + +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------- | +| **Name** | `cmodel watch` | +| **Description** | Probe a custom model's import status (default: single non-blocking fetch). Pass --follow to poll until terminal. | +| **Authentication** | API Key | +| **Usage** | `bl cmodel watch --job-id [--follow] [--interval ] [--poll-timeout ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ---------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------- | +| `--job-id ` | string | yes | Import job ID returned by `cmodel import` (required) | +| `--follow` | switch | no | Block and poll until a terminal state. Without it, a single status probe is performed and the command returns immediately. | +| `--interval ` | number | no | Seconds between polls with --follow (default: 10, min: 1). Ignored without --follow. | +| `--poll-timeout ` | number | no | With --follow, stop polling after this many seconds (default: no limit). Ignored without --follow. | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- This command always calls the public DashScope endpoint (https://dashscope.aliyuncs.com) in the Beijing region. The API is not deployed on workspace-specific base URLs (https://.cn-beijing.maas.aliyuncs.com, HTTP 404), so the Profile base URL is not used for this request; workspace API keys are accepted. +- Default (no --follow) is a NON-BLOCKING single status probe for agents/scripts. Terminal statuses are SUCCESSED and FAILED; an unknown server status is surfaced verbatim and treated as non-terminal. +- A terminal FAILED status raises a normal CLI error (non-zero exit) with the server error_code preserved; SUCCEEDED and still-running statuses return 0. With --follow, exceeding --poll-timeout raises a timeout error. + +#### Examples + +```bash +bl cmodel watch --job-id 937b-xxxx +``` + +```bash +bl cmodel watch --job-id 937b-xxxx --output json +``` + +```bash +bl cmodel watch --job-id 937b-xxxx --follow +``` + +```bash +bl cmodel watch --job-id 937b-xxxx --follow --interval 5 --poll-timeout 3600 +``` diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index b808afcc..1a215a16 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -38,6 +38,12 @@ Use this index for the skill-scoped quick index and global flags. | `bl batch download` | API Key | Download original Batch results/errors, optionally export CSV | [batch.md](batch.md) | | `bl batch get` | API Key | Get Batch task details, optionally following progress | [batch.md](batch.md) | | `bl batch list` | API Key | List one page of Batch tasks | [batch.md](batch.md) | +| `bl cmodel base-models` | Console | List base models that can be imported as custom models | [cmodel.md](cmodel.md) | +| `bl cmodel delete` | API Key | Delete a custom model and its Bailian-side import record | [cmodel.md](cmodel.md) | +| `bl cmodel get` | API Key | Get details of a custom model (import job) | [cmodel.md](cmodel.md) | +| `bl cmodel import` | API Key | Import a custom model from OSS (creates an import job) | [cmodel.md](cmodel.md) | +| `bl cmodel list` | API Key | List custom models (import jobs) | [cmodel.md](cmodel.md) | +| `bl cmodel watch` | API Key | Probe a custom model's import status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [cmodel.md](cmodel.md) | | `bl config agent` | No Auth | Configure a coding agent to use DashScope API | [config.md](config.md) | | `bl config list` | No Auth | List config profiles and show the active profile | [config.md](config.md) | | `bl config set` | No Auth | Set a config value | [config.md](config.md) | @@ -126,6 +132,7 @@ Use this index for the skill-scoped quick index and global flags. | `app` | `call`, `list` | [app.md](app.md) | | `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) | | `batch` | `cancel`, `convert`, `create`, `download`, `get`, `list` | [batch.md](batch.md) | +| `cmodel` | `base-models`, `delete`, `get`, `import`, `list`, `watch` | [cmodel.md](cmodel.md) | | `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) | | `console` | `call` | [console.md](console.md) | | `decision` | `(root)` | [decision.md](decision.md) | diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 455f3286..a958b2bc 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-finetune metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >- @@ -46,24 +46,25 @@ BAILIAN_CALLER_TYPE=agent BAILIAN_CALLER_NAME=codex BAILIAN_SKILL_NAME=bailian-f ## When to use which command -| Intent | Command | -| -------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Validate / upload training data | `bl dataset validate` / `upload` (`.jsonl` or `.zip`) | -| Dataset list / detail / delete | `bl dataset list` / `get` / `delete` | -| Create a fine-tuning job | `bl finetune text\|audio\|image create` | -| Job list / detail / follow | `bl finetune list` / `get` / `watch` / `logs` | -| Artifacts and export | `bl finetune checkpoints` / `export` | -| Quantize / compress a full model | `bl finetune compress templates` / `create` / `watch` / `get` / `logs` / `list` | -| Cancel / delete a compression job | `bl finetune compress cancel` / `delete` | -| Cancel / delete a fine-tuning job | `bl finetune cancel` / `delete` | -| Trainable capability lookup | `bl finetune capability` | -| Deploy / lifecycle | `bl deploy text\|audio\|image create`, `list` / `get` / `update` / `scale` / `delete` / `models` | -| Query throughput reservations | `bl deploy list --plan ptu` / `bl deploy get` | -| Query capacity instances | `bl deploy capacity list` / `get` | -| Query / wait for a capacity operation | `bl deploy operation get` / `wait` | -| Buy / scale / renew / release capacity | `bl deploy capacity create` / `scale` / `renew` / `delete` | -| Unsubscribe a prepaid instance | `bl deploy capacity unsubscribe` (builds the billing console refund link) | -| Configure ModelCode overflow strategy | `bl deploy overflow` | +| Intent | Command | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Validate / upload training data | `bl dataset validate` / `upload` (`.jsonl` or `.zip`) | +| Dataset list / detail / delete | `bl dataset list` / `get` / `delete` | +| Create a fine-tuning job | `bl finetune text\|audio\|image create` | +| Job list / detail / follow | `bl finetune list` / `get` / `watch` / `logs` | +| Artifacts and export | `bl finetune checkpoints` / `export` | +| Quantize / compress a full model | `bl finetune compress templates` / `create` / `watch` / `get` / `logs` / `list` | +| Cancel / delete a compression job | `bl finetune compress cancel` / `delete` | +| Cancel / delete a fine-tuning job | `bl finetune cancel` / `delete` | +| Import an external OSS LoRA model | Hand-off: `bl cmodel import *` — hub-owned commands; see `bailian-cli` reference or `bl cmodel --help` | +| Trainable capability lookup | `bl finetune capability` | +| Deploy / lifecycle | `bl deploy text\|audio\|image create`, `list` / `get` / `update` / `scale` / `delete` / `models` | +| Query throughput reservations | `bl deploy list --plan ptu` / `bl deploy get` | +| Query capacity instances | `bl deploy capacity list` / `get` | +| Query / wait for a capacity operation | `bl deploy operation get` / `wait` | +| Buy / scale / renew / release capacity | `bl deploy capacity create` / `scale` / `renew` / `delete` | +| Unsubscribe a prepaid instance | `bl deploy capacity unsubscribe` (builds the billing console refund link) | +| Configure ModelCode overflow strategy | `bl deploy overflow` | The `capacity list` / `get`, `operation get` / `wait` and `deploy list` / `get` queries are read-only. Capacity values are kTPM; effective, configured and target capacities are distinct. `deploy list --status` filters only the fetched page locally, with the server total left unfiltered. `operation get` reports status as data; `operation wait` exits non-zero on failure or timeout and refreshes capacity after success. Use IDs returned by the API; waiting never retries a write. diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 039df61d..e69664f2 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-gen metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-knowledge/SKILL.md b/skills/bailian-knowledge/SKILL.md index 06e7c667..91141e76 100644 --- a/skills/bailian-knowledge/SKILL.md +++ b/skills/bailian-knowledge/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-knowledge metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 1060e7a4..fffd13d6 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-managed-agent metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-memory/SKILL.md b/skills/bailian-memory/SKILL.md index 4db43c77..b4832dad 100644 --- a/skills/bailian-memory/SKILL.md +++ b/skills/bailian-memory/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-memory metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 9e80dab3..31ce4cf0 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-protocol metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index 7ac90955..6cd73985 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-sandbox metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-web-search/SKILL.md b/skills/bailian-web-search/SKILL.md index e1869b90..d9d77410 100644 --- a/skills/bailian-web-search/SKILL.md +++ b/skills/bailian-web-search/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-web-search metadata: - version: "2.7.2" + version: "2.7.3" requires: bins: ["bl"] description: >-