From 64ff057fe5ddcbcb9f211d29a3163c3eb1c520a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 15 Jul 2026 16:26:43 +0800 Subject: [PATCH] feat(auth): support token-plan model profile login - add the built-in Token Plan profile preset - validate and persist model API keys atomically - materialize the default Base URL and models on login - preserve flag > env > config precedence --- docs/agents/auth-change.md | 1 + docs/token-plan-profile-integration.md | 549 ++++++++++++++++++ .../src/commands/auth/login-api-key.ts | 88 +++ .../src/commands/auth/login-console.ts | 93 +-- packages/commands/src/commands/auth/login.ts | 29 +- packages/commands/tests/e2e/auth.e2e.test.ts | 164 +++++- packages/core/src/auth/resolver.ts | 6 +- packages/core/src/auth/store.ts | 13 +- packages/core/src/config/index.ts | 1 + packages/core/src/config/profile-presets.ts | 18 + packages/core/tests/config-priority.test.ts | 64 +- skills/bailian-cli/assets/setup.md | 29 +- skills/bailian-cli/reference/auth.md | 8 +- 13 files changed, 958 insertions(+), 105 deletions(-) create mode 100644 docs/token-plan-profile-integration.md create mode 100644 packages/commands/src/commands/auth/login-api-key.ts create mode 100644 packages/core/src/config/profile-presets.ts diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index fde6101..72ca85f 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -44,6 +44,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `resolveApiKey()` — `auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key` - `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn` +- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值 - `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认 - `resolveOpenApi()` — `auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段 - `describeAuthState()` — `auth status` / banner / telemetry 使用的只读快照 diff --git a/docs/token-plan-profile-integration.md b/docs/token-plan-profile-integration.md new file mode 100644 index 0000000..a504383 --- /dev/null +++ b/docs/token-plan-profile-integration.md @@ -0,0 +1,549 @@ +# Token Plan Profile 与激活配置接入方案 + +> 状态:Token Plan 模型消费 MVP 已实现;Config 激活状态和通用 Base URL 归一化待实现。 +> +> 目标分支:`feat/cli-access-token`。 + +## 结论摘要 + +Token Plan 的模型消费能力继续使用现有 `apiKey` 鉴权域和模型 Client,不新增 Token Plan 鉴权模式或专用 Client。 + +本次接入拆为三类相互独立的能力,并按业务紧急度而不是最终调用链顺序交付: + +1. 优先完成 `token-plan` 内置 Profile 预设、登录和文本/图片消费。 +2. 然后完成 Config 激活状态,允许用户选择未传 `--config` 时默认使用的命名配置。 +3. 最后以独立 commit 完成通用模型 Base URL 归一化,覆盖所有输入来源,不只服务 Token Plan。 + +`token-plan` 是有默认值的内置 Profile 名,不是 `active_auth_mode`,也不是新的 `AuthRequirement`。 + +## 背景与边界 + +当前分支已经包含以下 Token Plan 管控命令: + +```text +token-plan list-seats +token-plan create-key +token-plan assign-seats +token-plan add-member +``` + +这些命令属于管理面,继续使用 OpenAPI AK/SK。本方案增加的是模型消费面:用户把 `create-key` 获得的 `PlainApiKey` 保存到 Profile,然后通过现有文本和图片命令调用模型。 + +```text +OpenAPI AK/SK + -> token-plan create-key + -> PlainApiKey + -> auth login --config token-plan + -> text/image model command +``` + +### 目标 + +- 将 Token Plan 模型 API Key 作为普通 `apiKey` credential 使用。 +- 将 `token-plan` 作为内置命名 Profile 管理。 +- 支持 Config 激活状态和默认切换。 +- 复用现有文本、图片命令与 Client。 +- 对所有来源的模型 Base URL 做统一归一化。 +- 登录验证成功后原子保存 API Key 和 Base URL。 +- 服务端错误保持原消息,不在 CLI 内翻译。 + +### 非目标 + +- 不重写现有 Token Plan 管控命令。 +- 不把模型消费 API Key 合并到 OpenAPI AK/SK 鉴权域。 +- 不新增 Token Plan 专用 Client。 +- 基础阶段不承诺视频、语音和音频模型消费。 +- 暂不维护会阻断请求的本地模型白名单。 +- 暂不把服务端错误翻译成 CLI 自定义错误。 + +## 用户交互 + +### 1. 配置 Token Plan + +`token-plan` 提供默认 Base URL,因此推荐登录命令不要求用户输入地址: + +```sh +bl auth login \ + --config token-plan \ + --api-key sk-sp-xxx +``` + +CLI 应解析并保存以下配置: + +```json +{ + "token-plan": { + "api_key": "", + "base_url": "https://token-plan.cn-beijing.maas.aliyuncs.com", + "default_text_model": "qwen3.7-max", + "default_image_model": "qwen-image-2.0" + } +} +``` + +用户仍可显式覆盖 Base URL,用于代理、测试或未来新增地域: + +```sh +bl auth login \ + --config token-plan \ + --api-key sk-sp-xxx \ + --base-url https://proxy.example.com/bailian/compatible-mode/v1 +``` + +显式地址归一化后应保存为: + +```text +https://proxy.example.com/bailian +``` + +紧急交付阶段以“不传 `--base-url`”的推荐登录路径为准,直接使用 `token-plan` 预设中的 canonical 根地址。完整的 SDK Base URL、自定义代理前缀和其他输入来源归一化在独立的通用 Base URL commit 中完成。在该 commit 合入前,如需显式覆盖,用户必须传入已经规范化的根地址,不能传 `/compatible-mode/v1` 或 `/apps/anthropic` 后缀。 + +### 2. 单次选择 Config + +`--config` 只影响当前命令,不修改激活状态: + +```sh +bl text chat --config token-plan --message "你好" +bl image generate --config token-plan --prompt "一只猫" +``` + +### 3. 激活 Config + +新增命令: + +```sh +bl config use --name token-plan +``` + +激活后,未传 `--config` 的命令默认使用 `token-plan`: + +```sh +bl text chat --message "你好" +bl image generate --prompt "一只猫" +``` + +切回顶层默认配置: + +```sh +bl config use --name default +``` + +单次绕过当前激活项、临时使用其他 Profile: + +```sh +bl text chat --config staging --message "你好" +``` + +单次显式使用顶层默认配置: + +```sh +bl text chat --config default --message "你好" +``` + +上述两种单次覆盖都不得改变持久化的激活状态。 + +### 4. 查看 Config + +新增列表能力,用于展示所有 Profile 和当前激活项: + +```sh +bl config list +``` + +示例输出: + +```text +NAME ACTIVE +default +staging +token-plan * +``` + +`config show` 和 `auth status` 的行为: + +- 未传 `--config`:展示当前激活的 Config。 +- 传 `--config `:展示指定 Config,不改变激活状态。 +- 输出中包含 `config`、`active` 和 `config_file`。 + +`config ui` 应展示当前激活项,并提供激活操作。 + +## Config 激活状态设计 + +### 存储形状 + +激活状态保存在 `~/.bailian/config.json` 顶层元数据中: + +```json +{ + "active_config": "token-plan", + "api_key": "", + "token-plan": { + "api_key": "", + "base_url": "https://token-plan.cn-beijing.maas.aliyuncs.com" + } +} +``` + +`active_config` 只允许出现在顶层,不属于单个 Profile 的业务字段。允许值为: + +- `default`:顶层默认配置。 +- 一个实际存在的命名 Profile。 + +旧配置没有 `active_config` 时等价于: + +```json +{ + "active_config": "default" +} +``` + +因此该能力对现有用户向后兼容。 + +### 选择优先级 + +Config block 的选择顺序为: + +```text +显式 --config + > active_config + > default +``` + +需要保留“参数是否出现”的信息: + +- 未传 `--config`:读取 `active_config`。 +- `--config default`:明确选择顶层配置,不能被 `active_config` 替换。 +- `--config `:明确选择该命名 Profile。 + +当前 `normalizeConfigName("default")` 会返回 `undefined`,实现时不能只根据归一化结果判断参数是否出现。 + +Config 激活只改变配置文件 block 的选择,`--config` 本身不提升所选 block 的字段优先级。运行时和 Base URL 登录验证保持“具体字段 flag > 环境变量 > selected config file > Profile 预设或系统默认值”。环境变量只影响本次有效值,不复制进 Profile;登录成功时,如果 Token Plan Profile 尚未保存 `base_url`,仍物化写入官方预设地址。Token Plan 默认模型是例外:每次登录都重置为内置版本。`config show` / `auth status` 应展示最终生效来源,避免用户误判套餐流量去向。 + +### 异常状态 + +- 激活不存在的 Profile:`config use` 返回 usage error,不写入状态。 +- 配置文件中的 `active_config` 指向不存在的 Profile:命令失败并提示切回 `default`,不得静默使用其他凭证。 +- 删除当前激活的 Profile:删除操作同时切回 `default`,或者要求用户先切换;不能保留悬空引用。 +- `config use --name token-plan` 只切换状态,不创建 Profile,也不执行登录。 +- `auth login --config token-plan` 只写入指定 Profile,不自动激活,避免登录命令产生隐藏的全局状态变化。 + +## `token-plan` 内置 Profile 预设 + +`token-plan` 是允许用户选择的内置 Profile 名,不应加入非法名称列表。它提供以下默认值: + +```text +base_url: https://token-plan.cn-beijing.maas.aliyuncs.com +default_text_model: qwen3.7-max +default_image_model: qwen-image-2.0 +``` + +Token Plan Base URL 预设只在登录写入阶段提供最低优先级的缺省值: + +```text +显式命令参数 + > 环境变量 + > 已保存的 Profile 字段 + > token-plan 预设值 +``` + +登录成功时应把显式 Base URL 或缺失的预设 Base URL,以及默认模型写入 Profile,使 `config show --config token-plan` 能看到完整配置。环境变量不复制进 Profile。运行时不再合并预设;如果手工删除字段,则按统一的环境变量、配置文件和系统默认值链继续解析。 + +默认模型采用更简单的固定策略:每次执行 `auth login --config token-plan`,都将 `default_text_model` 重置为 `qwen3.7-max`,将 `default_image_model` 重置为 `qwen-image-2.0`。登录不保留用户之前写入的其他 Profile 默认模型;用户需要临时调用其他 Token Plan 模型时,通过具体模型命令的 `--model` 覆盖,不修改这两个内置默认值。 + +预设建议通过集中 registry 表达,不在 resolver、命令和 Client 中散落名称判断: + +```ts +const MODEL_PROFILE_PRESETS = { + "token-plan": { + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", + defaultTextModel: "qwen3.7-max", + defaultImageModel: "qwen-image-2.0", + }, +}; +``` + +Profile 预设不改变命令协议: + +```text +Selected Profile + -> API Key Credential + -> Client + -> Command Endpoint +``` + +## 通用模型 Base URL 归一化 + +Base URL 归一化是独立的通用能力,必须在 Token Plan 接入前完成,不能只针对 Token Plan hostname 实现。 + +### 语义 + +CLI 中 `base_url` 表示模型服务根地址或自定义网关前缀,不包含 CLI 已知的 SDK/API Base 后缀。 + +建议新增统一函数: + +```text +normalizeModelBaseUrl(input) -> canonical base URL +``` + +通用规则: + +1. 去除首尾空白。 +2. 使用 `URL` 解析,只接受 `http:` 和 `https:`。 +3. 去除 query 和 fragment。 +4. 去除末尾 `/`。 +5. 保留协议、hostname、端口和自定义代理路径。 +6. 去除末尾已知 SDK/API Base 后缀,例如: + - `/compatible-mode/v1` + - `/apps/anthropic` +7. 不无条件返回 `url.origin`,避免破坏自定义代理路径。 + +示例: + +| 用户输入 | 归一化结果 | +| -------------------------------------------------------------------- | ------------------------------------------------- | +| `https://dashscope.aliyuncs.com/` | `https://dashscope.aliyuncs.com` | +| `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com` | +| `https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic` | `https://token-plan.cn-beijing.maas.aliyuncs.com` | +| `https://proxy.example.com/bailian/` | `https://proxy.example.com/bailian` | +| `https://proxy.example.com/bailian/compatible-mode/v1` | `https://proxy.example.com/bailian` | + +### 覆盖入口 + +所有模型 Base URL 来源都必须经过同一个函数: + +- 模型命令的 `--base-url`。 +- `DASHSCOPE_BASE_URL`。 +- `config.json` 中的 `base_url`。 +- `config set --key base_url`。 +- `config ui`。 +- `auth login --base-url`。 +- Console 登录回调返回的 `base_url`。 +- 手工修改的旧配置。 +- 内置默认地址和 Profile 预设地址。 + +归一化采用双层防线: + +- 写入前归一化,保证磁盘配置整洁。 +- `resolveModelBaseUrl()` 返回前防御性归一化,兼容旧配置和手工修改。 + +### URL 拼接 + +归一化后,命令继续拼接已有 endpoint: + +```text +text: /compatible-mode/v1/chat/completions +image: /api/v1/services/aigc/.../generation +``` + +最终 URL 中不得重复出现 `/compatible-mode/v1`。 + +## API Key 登录与原子保存 + +当前登录流程可能先写入 `base_url`,再验证 API Key。该顺序需要独立修复: + +```text +解析 Profile 和预设 + -> 归一化 Base URL + -> 使用最终 Base URL 验证 API Key + -> 验证成功后一次写入 api_key + base_url + 默认模型 +``` + +验证失败时,不得产生以下半配置状态: + +```json +{ + "token-plan": { + "base_url": "https://token-plan.cn-beijing.maas.aliyuncs.com" + } +} +``` + +登录验证使用的模型必须在目标 Profile 中可用。基础阶段 Token Plan 预设使用 `qwen3.7-max`;后续如不同订阅计划的模型集合分化,应将验证模型纳入 Profile 预设,而不是继续在登录函数里硬编码唯一模型。 + +## 模型消费范围 + +基础阶段承诺: + +| 能力 | 默认模型 | 调用方式 | +| -------------- | ---------------- | ---------------------------------- | +| 文本生成和推理 | `qwen3.7-max` | OpenAI Compatible Chat Completions | +| 图片生成和编辑 | `qwen-image-2.0` | DashScope 原生图片接口 | + +Token Plan 当前模型快照中还包含其他文本、视觉理解和图片模型,但该列表可能由后端调整。基础接入不维护阻断请求的本地白名单;用户可通过具体模型命令的 `--model` 临时覆盖本次请求,但再次登录时 Profile 默认模型仍重置为内置版本。 + +视频、语音和音频不作为本阶段支持承诺。现有命令仍保持通用实现,但 Token Plan Profile 的验收不包含这些模态。 + +## 错误处理 + +CLI 继续遵循“服务端错误消息原样透传”的规则。 + +例如服务端返回: + +```json +{ + "code": "InvalidParameter", + "message": "Model not exist." +} +``` + +CLI 保留 `Model not exist.`,不改写成“Token Plan 不支持该模态”,因为本地没有权威、实时的模型开放列表。 + +## Commit 拆分 + +以下 commit 按紧急度和必要依赖提交,每个 commit 都应能独立通过对应测试和静态检查。前三个 commit 组成可优先交付的 Token Plan 模型消费 MVP,后两个 commit 再补齐默认激活体验和通用 URL 输入兼容。 + +### Commit 1:Token Plan 内置 Profile 预设(已实现) + +建议提交信息: + +```text +feat(core): add token-plan model profile preset +``` + +完成内容: + +- 将 `token-plan` 注册为内置、可选择的 Profile 名。 +- 提供 canonical 默认 Base URL、文本模型和图片模型。 +- Base URL 登录验证遵循 flag > 环境变量 > 已保存 Profile > 预设;环境变量不复制进 Profile。 +- Profile 缺少 Base URL 时物化预设地址;每次 Token Plan 登录都重置并写入内置默认文本和图片模型。 +- 运行时 loader/resolver 不再合并预设。 +- 不新增 AuthRequirement,不修改 Token Plan 管控命令。 +- 补充预设值单元测试;不重复增加 Token Plan 专属消费 E2E。 +- 不依赖通用 Base URL 归一化;预设直接使用规范化后的根地址。 + +### Commit 2:Token Plan API Key 登录(已实现) + +建议提交信息: + +```text +feat(auth): support token-plan API key login +``` + +完成内容: + +- 支持 `bl auth login --config token-plan --api-key ...`。 +- 未传 `--base-url` 且没有更高优先级的环境变量或已保存地址时,使用 Token Plan Profile 预设地址。 +- 使用 Token Plan 预设文本模型验证 API Key。 +- 登录验证前不写配置。 +- 验证成功后一次写入 API Key、canonical Base URL 和默认模型。 +- 每次登录都将默认模型重置为 `qwen3.7-max` 和 `qwen-image-2.0`。 +- 验证失败不留下半配置。 +- 补充一个最小 Token Plan 登录 E2E,覆盖命名 Profile 落盘、环境变量不复制、预设 Base URL 物化和默认模型重置;通用 API Key 登录 E2E 继续覆盖成功原子保存和失败不写半配置。 +- 该 commit 暂不承诺自动归一化用户显式输入的 SDK Base URL。 + +### Commit 3:Token Plan 文本与图片消费验收(已实现) + +建议提交信息: + +```text +feat(cli): enable token-plan text and image consumption +``` + +完成内容: + +- Token Plan 消费复用现有 API Key、文本和图片调用链,不重复增加专属 E2E。 +- 发布前按需人工验证 `auth login --config token-plan --api-key ...`、文本和图片调用。 +- 更新 Token Plan 消费方案文档和 Skill reference。 +- 到该 commit 为止即可先交付显式 `--config token-plan` 的紧急消费能力。 + +### 运营文档 TODO + +- [ ] 由运营同事补充 `README.md` 和 `README.zh.md` 的 Token Plan 模型消费说明。 +- [ ] 区分 `sk-sp-...` 模型消费 API Key 与管控命令使用的 OpenAPI AK/SK。 +- [ ] 增加 `auth login --config token-plan --api-key ...`、文本消费和图片消费示例。 +- [ ] 与届时实际上线范围核对模型名称、服务地域、限制条件和用户措辞。 + +### Commit 4:Config 激活状态与切换命令(待实现) + +建议提交信息: + +```text +feat(config): add active profile selection +``` + +完成内容: + +- 增加顶层 `active_config` 元数据。 +- 实现 `--config > active_config > default` 的选择顺序。 +- 保证 `--config default` 能显式覆盖激活项。 +- 新增 `bl config list`。 +- 新增 `bl config use --name `。 +- `config show`、`auth status` 和 `config ui` 展示激活状态。 +- 删除激活 Profile 时处理状态一致性。 +- 验证激活 `token-plan` 后不传 `--config` 的文本和图片请求。 +- 验证临时 `--config default` 不改变激活状态。 +- 更新命令导出、`packages/cli/src/commands.ts`、E2E 和生成 reference。 + +### Commit 5:通用模型 Base URL 归一化(待实现) + +建议提交信息: + +```text +fix(core): normalize model base URLs across all sources +``` + +完成内容: + +- 新增 `normalizeModelBaseUrl()`。 +- 保留自定义网关路径,去除尾斜杠、query、fragment 和已知 API Base 后缀。 +- `resolveModelBaseUrl()` 对 flag、env、配置文件和默认值统一归一化。 +- `auth login`、Console callback、`config set`、`config ui` 写入前归一化。 +- 验证 Token Plan 显式输入 `/compatible-mode/v1` 和 `/apps/anthropic` 的兼容行为。 +- 补充通用 URL 单元测试和各来源解析测试。 +- 更新 README、中文 README、Skill reference 和本方案状态。 + +## 验证清单 + +### Base URL + +- 根地址和自定义路径正确保留。 +- 尾部 `/` 被移除。 +- `/compatible-mode/v1` 和 `/apps/anthropic` 后缀被移除。 +- query 和 fragment 不进入最终请求地址。 +- flag、env、配置文件和所有写入入口结果一致。 +- 最终文本 URL 只包含一次 `/compatible-mode/v1`。 + +### Config 激活 + +- 旧配置缺少 `active_config` 时继续使用 `default`。 +- `config use` 只能激活存在的 Profile。 +- 未传 `--config` 时使用激活项。 +- 显式 `--config` 优先且不修改激活项。 +- `--config default` 能绕过命名激活项。 +- 悬空激活项不会静默回退到其他凭证。 +- 删除激活项后状态保持一致。 +- `config list/show/ui` 正确标识激活项。 + +### Token Plan + +- `token-plan` 登录初始化时缺省写入官方根地址。 +- 显式 Base URL 覆盖预设并经过通用归一化。 +- 登录验证失败不写入任何 Token Plan 半配置。 +- 文本默认使用 `qwen3.7-max`。 +- 图片默认使用 `qwen-image-2.0`。 +- 文本和图片均复用现有 `apiKey` Client。 +- 管控命令继续使用 OpenAPI AK/SK,不受模型 Profile 影响。 + +## 完成后检查 + +```sh +pnpm run sync:skill-assets +vp check +vp test +``` + +完成改动后,应评估“Profile 预设与激活状态”是否需要沉淀为新的 `docs/agents/config-profile-change.md` 场景清单。 + +## 最终结论 + +Token Plan 模型消费最终表现为一个可激活的内置 Profile: + +```text +通用 Base URL 归一化 + -> Config 选择与激活 + -> 登录时物化 token-plan 预设 + -> 普通 apiKey Client + -> 文本/图片 endpoint +``` + +用户既可以通过 `--config token-plan` 单次使用,也可以通过 `bl config use --name token-plan` 将其设为默认激活配置。整个过程不引入 Token Plan 模式,也不复制现有模型调用实现。 diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts new file mode 100644 index 0000000..f3dce6a --- /dev/null +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -0,0 +1,88 @@ +import { + BailianError, + ExitCode, + chatPath, + requestJson, + type AuthPersistPatch, + type AuthStore, + type Identity, + type Settings, +} from "bailian-cli-core"; + +interface ApiKeyLoginDeps { + identity: Identity; + settings: Settings; + authStore: AuthStore; +} + +interface ApiKeyLoginProfile { + baseUrl: string; + persistBaseUrl?: string; + defaultTextModel?: string; + defaultImageModel?: string; + persistPatch?: AuthPersistPatch; +} + +const RETRY_DELAY_BASE_MS = 500; + +function canRetry(error: unknown): boolean { + if (error instanceof BailianError) { + if (error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT) return true; + const status = error.api?.httpStatus; + return status === 401 || (status !== undefined && status >= 500); + } + if (error instanceof Error) { + return ( + error.name === "AbortError" || + error.name === "TimeoutError" || + error.message.includes("timed out") || + error.message === "fetch failed" + ); + } + return false; +} + +export async function validateAndPersistApiKey( + deps: ApiKeyLoginDeps, + key: string, + profile: ApiKeyLoginProfile, +): Promise { + process.stderr.write("Testing key... "); + const httpDeps = { identity: deps.identity, settings: deps.settings }; + const requestOpts = { + url: profile.baseUrl + chatPath(), + method: "POST", + headers: { Authorization: `Bearer ${key}` }, + timeout: Math.min(deps.settings.timeout, 30), + body: { + model: profile.defaultTextModel || "qwen3.7-max", + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + stream: false, + enable_thinking: false, + }, + }; + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await requestJson(httpDeps, requestOpts); + break; + } catch (error) { + if (attempt >= 3 || !canRetry(error)) { + process.stderr.write("Failed\n"); + throw error; + } + const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + process.stderr.write("Valid\n"); + await deps.authStore.login({ + ...profile.persistPatch, + api_key: key, + base_url: profile.persistBaseUrl, + default_text_model: profile.defaultTextModel, + default_image_model: profile.defaultImageModel, + }); +} diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index 43954c7..5b1a0f7 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -4,14 +4,14 @@ import http from "node:http"; import { BailianError, ExitCode, - chatPath, - requestJson, + type AuthPersistPatch, type AuthStore, type ConfigFile, type Identity, type Settings, } from "bailian-cli-core"; import { listenLocalServer, openInBrowser } from "../shared/local-server.ts"; +import { validateAndPersistApiKey } from "./login-api-key.ts"; /** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */ export interface LoginDeps { @@ -364,64 +364,6 @@ function listenServerOnFreeLocalPort(server: http.Server): Promise { return listenLocalServer(server); } -const RETRY_DELAY_BASE_MS = 500; - -function canRetry(err: unknown): boolean { - if (err instanceof BailianError) { - if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) return true; - const status = err.api?.httpStatus; - return status === 401 || (status !== undefined && status >= 500); - } - if (err instanceof Error) { - return ( - err.name === "AbortError" || - err.name === "TimeoutError" || - err.message.includes("timed out") || - err.message === "fetch failed" - ); - } - return false; -} - -export async function validateAndPersistApiKey( - deps: LoginDeps, - key: string, - baseUrl: string, -): Promise { - process.stderr.write("Testing key... "); - const httpDeps = { identity: deps.identity, settings: deps.settings }; - const requestOpts = { - url: baseUrl + chatPath(), - method: "POST", - headers: { Authorization: `Bearer ${key}` }, - timeout: Math.min(deps.settings.timeout, 30), - body: { - model: "qwen3.7-max", - messages: [{ role: "user", content: "hi" }], - max_tokens: 1, - }, - }; - - for (let attempt = 1; attempt <= 3; attempt++) { - try { - await requestJson(httpDeps, requestOpts); - break; - } catch (err) { - if (attempt >= 3 || !canRetry(err)) { - process.stderr.write("Failed\n"); - throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", { - cause: err, - }); - } - const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - - process.stderr.write("Valid\n"); - await deps.authStore.login({ api_key: key }); -} - export async function runConsoleLogin( consoleOrigin: string, deps: LoginDeps, @@ -463,20 +405,27 @@ export async function runConsoleLogin( if (hasConfig || apiKey) { try { - if (hasConfig) { - await deps.authStore.login({ - access_token: accessToken || undefined, - base_url: baseUrl || undefined, - console_site: (consoleSite || undefined) as ConfigFile["console_site"], - console_region: consoleRegion || undefined, - console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined, - workspace_id: workspaceId || undefined, - }); - process.stderr.write(`Config saved to ${deps.authStore.path}\n`); - } + const callbackPatch: AuthPersistPatch = { + access_token: accessToken || undefined, + console_site: (consoleSite || undefined) as ConfigFile["console_site"], + console_region: consoleRegion || undefined, + console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined, + workspace_id: workspaceId || undefined, + }; if (apiKey) { const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl(); - await validateAndPersistApiKey(deps, apiKey, testBaseUrl); + await validateAndPersistApiKey(deps, apiKey, { + baseUrl: testBaseUrl, + persistBaseUrl: baseUrl || undefined, + persistPatch: callbackPatch, + }); + process.stderr.write(`Config saved to ${deps.authStore.path}\n`); + } else if (hasConfig) { + await deps.authStore.login({ + ...callbackPatch, + base_url: baseUrl || undefined, + }); + process.stderr.write(`Config saved to ${deps.authStore.path}\n`); } } catch (err: unknown) { callbackError = err; diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index 16a44d5..ea2a071 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -1,11 +1,7 @@ -import { defineCommand } from "bailian-cli-core"; +import { defineCommand, generateCLIAccessToken, getModelProfilePreset } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; -import { - resolveConsoleOrigin, - runConsoleLogin, - validateAndPersistApiKey, -} from "./login-console.ts"; -import { generateCLIAccessToken } from "bailian-cli-core"; +import { validateAndPersistApiKey } from "./login-api-key.ts"; +import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; const LOGIN_MODE_HINT = "Choose exactly one login mode: --api-key, --console, or --open-api"; @@ -20,11 +16,11 @@ export default defineCommand({ usageArgs: "--api-key | --console | --open-api --access-key-id --access-key-secret ", flags: { - apiKey: { type: "string", valueHint: "", description: "DashScope API key to store" }, + apiKey: { type: "string", valueHint: "", description: "Model API key to store" }, baseUrl: { type: "string", valueHint: "", - description: "DashScope API base URL (used with --api-key for validation)", + description: "Model API base URL (used with --api-key for validation)", }, console: { type: "switch", @@ -53,6 +49,7 @@ export default defineCommand({ }, exampleArgs: [ "--api-key sk-xxxxx", + "--config token-plan --api-key sk-sp-xxxxx", "--console", "--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx", ], @@ -140,9 +137,15 @@ export default defineCommand({ emitBare("Would validate and save API key."); return; } - if (baseUrl) { - await store.login({ base_url: baseUrl }); - } - await validateAndPersistApiKey(deps, key, baseUrl || store.resolveBaseUrl()); + const profilePreset = getModelProfilePreset(settings.configName); + const storedBaseUrl = store.stored().baseUrl; + const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl); + const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined); + await validateAndPersistApiKey(deps, key, { + baseUrl: resolvedBaseUrl, + persistBaseUrl, + defaultTextModel: profilePreset?.defaultTextModel, + defaultImageModel: profilePreset?.defaultImageModel, + }); }, }); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 6e8a2a4..1682f77 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,4 +1,6 @@ -import { readFileSync } from "fs"; +import { existsSync, readFileSync, writeFileSync } from "fs"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; import { @@ -9,6 +11,42 @@ import { } from "./helpers.ts"; import { AUTH_ROUTES } from "./topic-routes.ts"; +interface ValidationServer { + baseUrl: string; + requests: Array<{ path: string; body: Record }>; + close(): Promise; +} + +async function startValidationServer(statusCode = 200): Promise { + const requests: ValidationServer["requests"] = []; + const server = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const rawBody = Buffer.concat(chunks).toString("utf8"); + requests.push({ + path: request.url ?? "", + body: rawBody ? (JSON.parse(rawBody) as Record) : {}, + }); + response.writeHead(statusCode, { "Content-Type": "application/json" }); + if (statusCode >= 400) { + response.end(JSON.stringify({ code: "InvalidApiKey", message: "invalid key" })); + return; + } + response.end( + JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }), + ); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + /** * Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。 */ @@ -108,6 +146,130 @@ describe("e2e: auth", () => { expect(stdout).toContain("Would validate and save API key."); }); + test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => { + const validationServer = await startValidationServer(); + const configDir = makeE2eOutputDir("auth-api-key-login"); + try { + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--api-key", + "sk-e2e-placeholder", + "--base-url", + validationServer.baseUrl, + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + expect(validationServer.requests).toHaveLength(1); + expect(validationServer.requests[0]).toMatchObject({ + path: "/compatible-mode/v1/chat/completions", + body: { + model: "qwen3.7-max", + stream: false, + enable_thinking: false, + }, + }); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.api_key).toBe("sk-e2e-placeholder"); + expect(config.base_url).toBe(validationServer.baseUrl); + } finally { + await validationServer.close(); + } + }); + + test("auth login --config token-plan 物化并重置内置预设", async () => { + const validationServer = await startValidationServer(); + const configDir = makeE2eOutputDir("auth-token-plan-preset-login"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify( + { + "token-plan": { + default_text_model: "custom-text-model", + default_image_model: "custom-image-model", + }, + }, + null, + 2, + ) + "\n", + ); + + try { + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted", + DASHSCOPE_BASE_URL: validationServer.baseUrl, + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + expect(validationServer.requests).toHaveLength(1); + expect(validationServer.requests[0]).toMatchObject({ + path: "/compatible-mode/v1/chat/completions", + body: { + model: "qwen3.7-max", + stream: false, + enable_thinking: false, + }, + }); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.api_key).toBeUndefined(); + expect(config["token-plan"]).toMatchObject({ + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_text_model: "qwen3.7-max", + default_image_model: "qwen-image-2.0", + }); + expect((config["token-plan"] as Record).base_url).not.toBe( + validationServer.baseUrl, + ); + expect((config["token-plan"] as Record).api_key).not.toBe( + "sk-env-must-not-be-persisted", + ); + } finally { + await validationServer.close(); + } + }); + + test("auth login --api-key 验证失败不留下半配置", async () => { + const validationServer = await startValidationServer(400); + const configDir = makeE2eOutputDir("auth-api-key-login-failure"); + try { + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--api-key", "sk-invalid", "--base-url", validationServer.baseUrl], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode).not.toBe(0); + expect(login.stderr).toMatch(/invalid key/); + expect(login.stderr).not.toMatch(/API key validation failed|Invalid API key/); + expect(existsSync(join(configDir, "config.json"))).toBe(false); + } finally { + await validationServer.close(); + } + }); + test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", diff --git a/packages/core/src/auth/resolver.ts b/packages/core/src/auth/resolver.ts index 2746443..4508a5b 100644 --- a/packages/core/src/auth/resolver.ts +++ b/packages/core/src/auth/resolver.ts @@ -7,9 +7,9 @@ import { ExitCode } from "../errors/codes.ts"; // Resolve the credential for a command's declared domain (model = api-key, // console = access-token), by priority, or throw. Read only from sources. -/** Model-domain baseUrl(flag > env > file > cn)——无需 key 也可解析;login 验证等用。 */ -export function resolveModelBaseUrl(s: ResolutionSources): string { - return s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || REGIONS.cn; +/** Model-domain baseUrl(flag > env > config file > fallback);无需 key 也可解析。 */ +export function resolveModelBaseUrl(s: ResolutionSources, fallback: string = REGIONS.cn): string { + return s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback; } /** diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 32315ea..f08f7b7 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -24,6 +24,8 @@ export type AuthPersistPatch = Pick< | "console_region" | "console_switch_agent" | "workspace_id" + | "default_text_model" + | "default_image_model" >; /** @@ -33,10 +35,10 @@ export type AuthPersistPatch = Pick< export interface AuthStore { /** 各域"将会解析出"的凭证快照(auth status 用)。 */ describe(): AuthState; - /** 磁盘上当前是否存有各域凭证(区别于 describe:只看 file,不含 flag/env 源)。 */ - stored(): { apiKey: boolean; console: boolean; openapi: boolean }; - /** model 域 baseUrl 链(flag > env > file > 默认);验证 API key 等无凭证场景用。 */ - resolveBaseUrl(): string; + /** 磁盘上当前是否存有各域凭证及 model baseUrl(区别于 describe:只看 file,不含 flag/env 源)。 */ + stored(): { apiKey: boolean; console: boolean; openapi: boolean; baseUrl?: string }; + /** model 域 baseUrl 链(flag > env > config file > fallback)。 */ + resolveBaseUrl(fallback?: string): string; /** 登录落盘:合并写入,undefined 键忽略。 */ login(patch: AuthPersistPatch): Promise; /** 清凭证:console/openapi 只删对应域;all 清全部登录凭证。返回是否有变更。 */ @@ -57,9 +59,10 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore { apiKey: !!file.api_key, console: !!file.access_token, openapi: !!(file.access_key_id || file.access_key_secret), + baseUrl: file.base_url, }; }, - resolveBaseUrl: () => resolveModelBaseUrl(sources), + resolveBaseUrl: (fallback) => resolveModelBaseUrl(sources, fallback), async login(patch) { const existing = readConfigFile(configName) as Record; for (const [key, value] of Object.entries(patch)) { diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 9c40203..679c680 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -5,3 +5,4 @@ export { readConfigProfiles, deleteConfigProfile, type ConfigProfiles } from "./ export { buildSources, buildSettings, type ResolutionSources } from "./loader.ts"; export { makeConfigStore, type ConfigStore } from "./store.ts"; export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts"; +export { getModelProfilePreset } from "./profile-presets.ts"; diff --git a/packages/core/src/config/profile-presets.ts b/packages/core/src/config/profile-presets.ts new file mode 100644 index 0000000..197839e --- /dev/null +++ b/packages/core/src/config/profile-presets.ts @@ -0,0 +1,18 @@ +interface ModelProfilePreset { + baseUrl: string; + defaultTextModel: string; + defaultImageModel: string; +} + +const MODEL_PROFILE_PRESETS: Readonly> = { + "token-plan": { + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", + defaultTextModel: "qwen3.7-max", + defaultImageModel: "qwen-image-2.0", + }, +}; + +/** Defaults materialized when logging into a well-known model profile. */ +export function getModelProfilePreset(configName?: string): ModelProfilePreset | undefined { + return configName ? MODEL_PROFILE_PRESETS[configName] : undefined; +} diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index f51c6f2..c6f22ba 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -7,21 +7,36 @@ import { resolveModelBaseUrl, resolveOpenApi, } from "../src/auth/resolver.ts"; +import { getModelProfilePreset } from "../src/config/profile-presets.ts"; -// 行为锁定:锁住各字段的 flag/env/file 优先级链,统一为 flag>env>file>默认 -// (baseUrl 原为 flag>file>env,2026-07 前置 commit 翻转)。buildSettings 与 -// resolver 都是纯函数,sources 直接构造,无需环境隔离。 +// 行为锁定:所有配置字段统一保持 flag>env>selected file>默认。`--config` 只选择 +// file block,不提升该 block 的字段优先级。Profile 预设只在登录写入阶段使用。 +// buildSettings 与 resolver 都是纯函数,sources 直接构造,无需环境隔离。 function src(s: { flags?: ResolutionSources["flags"]; env?: Record; file?: ConfigFile; + configName?: string; }): ResolutionSources { - return { flags: s.flags ?? {}, file: s.file ?? {}, env: s.env ?? {} }; + return { + flags: s.flags ?? {}, + file: s.file ?? {}, + env: s.env ?? {}, + configName: s.configName, + }; } const resolve = (s: Parameters[0]): Settings => buildSettings(src(s)); +test("token-plan Profile 预设保持固定", () => { + expect(getModelProfilePreset("token-plan")).toEqual({ + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", + defaultTextModel: "qwen3.7-max", + defaultImageModel: "qwen-image-2.0", + }); +}); + test("baseUrl:flag > env > file > 默认(原为 flag>file>env,已归一)", () => { const flags = { baseUrl: "https://flag.example.com" }; const env = { DASHSCOPE_BASE_URL: "https://env.example.com" }; @@ -32,6 +47,47 @@ test("baseUrl:flag > env > file > 默认(原为 flag>file>env,已归一)", () => expect(resolveModelBaseUrl(src({}))).toBe("https://dashscope.aliyuncs.com"); }); +test("命名 config 仍保持 flag > env > selected file", () => { + const env = { + DASHSCOPE_BASE_URL: "https://env.example.com", + DASHSCOPE_API_KEY: "sk-env", + }; + const sources = src({ + configName: "token-plan", + env, + file: { + api_key: "sk-token-plan", + base_url: "https://profile.example.com", + default_text_model: "custom-text", + default_image_model: "custom-image", + }, + }); + expect(resolveModelBaseUrl(sources)).toBe("https://env.example.com"); + expect(resolveApiKey(sources)).toMatchObject({ + token: "sk-env", + baseUrl: "https://env.example.com", + source: "env", + }); + expect(buildSettings(sources)).toMatchObject({ + defaultTextModel: "custom-text", + defaultImageModel: "custom-image", + }); + expect( + resolveApiKey( + src({ + configName: "token-plan", + flags: { apiKey: "sk-flag", baseUrl: "https://flag.example.com" }, + env, + file: sources.file, + }), + ), + ).toMatchObject({ + token: "sk-flag", + baseUrl: "https://flag.example.com", + source: "flag", + }); +}); + test("output:flag > env > file > text", () => { const env = { DASHSCOPE_OUTPUT: "json" }; const file: ConfigFile = { output: "json" }; diff --git a/skills/bailian-cli/assets/setup.md b/skills/bailian-cli/assets/setup.md index 48d3556..311c4d7 100644 --- a/skills/bailian-cli/assets/setup.md +++ b/skills/bailian-cli/assets/setup.md @@ -21,11 +21,12 @@ Verify: `bl --version` (prints `bl X.Y.Z`). ## Authentication -| Auth | How | Used by | -| ---------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------- | -| API key | `export DASHSCOPE_API_KEY=sk-...` or `bl auth login --api-key sk-...` | Most DashScope API commands | -| Console | `bl auth login --console --console-site domestic` or `... international` | `app list`, `usage free`, `console call` | -| OpenAPI AK | `bl auth login --open-api --access-key-id --access-key-secret ` or Alibaba env vars | `token-plan *` | +| Auth | How | Used by | +| ------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------- | +| API key | `export DASHSCOPE_API_KEY=sk-...` or `bl auth login --api-key sk-...` | Most DashScope API commands | +| Token Plan API key | `bl auth login --config token-plan --api-key sk-sp-...` | Token Plan text and image model consumption | +| Console | `bl auth login --console --console-site domestic` or `... international` | `app list`, `usage free`, `console call` | +| OpenAPI AK | `bl auth login --open-api --access-key-id --access-key-secret ` or Alibaba env vars | Token Plan management commands (`token-plan`) | ```bash bl auth status # check current auth @@ -36,6 +37,24 @@ bl auth logout --open-api # clear OpenAPI AK/SK only Get an API key: https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key +### Token Plan model consumption + +Use the `PlainApiKey` returned by `bl token-plan create-key` as a model API key. It is separate from the OpenAPI AK/SK used by Token Plan management commands. + +```bash +bl auth login --config token-plan --api-key sk-sp-xxx +bl text chat --config token-plan --message "Hello" +bl image generate --config token-plan --prompt "A cat" +``` + +The built-in `token-plan` profile defaults to: + +- Base URL: `https://token-plan.cn-beijing.maas.aliyuncs.com` +- Text model: `qwen3.7-max` +- Image model: `qwen-image-2.0` + +The usual priority applies to this profile too: per-command `--api-key` / `--base-url`, then `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL`, then the selected profile. Unset environment overrides when you want to use the credentials saved in `token-plan`. + ### Console site selection Console login and console-gateway commands (`app list`, `usage *`, `quota *`, `workspace list`, `console call`) target one of two Bailian consoles: diff --git a/skills/bailian-cli/reference/auth.md b/skills/bailian-cli/reference/auth.md index b5c0819..c6b492e 100644 --- a/skills/bailian-cli/reference/auth.md +++ b/skills/bailian-cli/reference/auth.md @@ -50,8 +50,8 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------- | -| `--api-key ` | string | no | DashScope API key to store | -| `--base-url ` | string | no | DashScope API base URL (used with --api-key for validation) | +| `--api-key ` | string | no | Model API key to store | +| `--base-url ` | string | no | Model API base URL (used with --api-key for validation) | | `--console` | switch | no | Sign in via browser; use --console-site to choose domestic (default) or international | | `--console-site ` | string | no | Console site: domestic, international | | `--open-api` | switch | no | Store Alibaba Cloud OpenAPI AK/SK credentials | @@ -64,6 +64,10 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx bl auth login --api-key sk-xxxxx ``` +```bash +bl auth login --config token-plan --api-key sk-sp-xxxxx +``` + ```bash bl auth login --console ```