mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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
This commit is contained in:
@@ -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 使用的只读快照
|
||||
|
||||
@@ -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": "<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 <name>`:展示指定 Config,不改变激活状态。
|
||||
- 输出中包含 `config`、`active` 和 `config_file`。
|
||||
|
||||
`config ui` 应展示当前激活项,并提供激活操作。
|
||||
|
||||
## Config 激活状态设计
|
||||
|
||||
### 存储形状
|
||||
|
||||
激活状态保存在 `~/.bailian/config.json` 顶层元数据中:
|
||||
|
||||
```json
|
||||
{
|
||||
"active_config": "token-plan",
|
||||
"api_key": "<DEFAULT_API_KEY>",
|
||||
"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 <name>
|
||||
> active_config
|
||||
> default
|
||||
```
|
||||
|
||||
需要保留“参数是否出现”的信息:
|
||||
|
||||
- 未传 `--config`:读取 `active_config`。
|
||||
- `--config default`:明确选择顶层配置,不能被 `active_config` 替换。
|
||||
- `--config <name>`:明确选择该命名 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: <base_url>/compatible-mode/v1/chat/completions
|
||||
image: <base_url>/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 <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 模式,也不复制现有模型调用实现。
|
||||
@@ -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<void> {
|
||||
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<unknown>(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,
|
||||
});
|
||||
}
|
||||
@@ -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<number> {
|
||||
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<void> {
|
||||
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<unknown>(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;
|
||||
|
||||
@@ -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 <key> | --console | --open-api --access-key-id <id> --access-key-secret <secret>",
|
||||
flags: {
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "DashScope API key to store" },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "Model API key to store" },
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startValidationServer(statusCode = 200): Promise<ValidationServer> {
|
||||
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<string, unknown>) : {},
|
||||
});
|
||||
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<void>((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<void>((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<string, unknown>).base_url).not.toBe(
|
||||
validationServer.baseUrl,
|
||||
);
|
||||
expect((config["token-plan"] as Record<string, unknown>).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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void>;
|
||||
/** 清凭证: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<string, unknown>;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
interface ModelProfilePreset {
|
||||
baseUrl: string;
|
||||
defaultTextModel: string;
|
||||
defaultImageModel: string;
|
||||
}
|
||||
|
||||
const MODEL_PROFILE_PRESETS: Readonly<Record<string, ModelProfilePreset>> = {
|
||||
"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;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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<typeof src>[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" };
|
||||
|
||||
@@ -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 <id> --access-key-secret <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 <id> --access-key-secret <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:
|
||||
|
||||
@@ -50,8 +50,8 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------- |
|
||||
| `--api-key <key>` | string | no | DashScope API key to store |
|
||||
| `--base-url <url>` | string | no | DashScope API base URL (used with --api-key for validation) |
|
||||
| `--api-key <key>` | string | no | Model API key to store |
|
||||
| `--base-url <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 <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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user