fix(auth): only apply profile presets on login

This commit is contained in:
若麒
2026-08-26 17:10:00 +08:00
parent 670c15532b
commit d343dc3265
7 changed files with 27 additions and 54 deletions
+2 -2
View File
@@ -46,8 +46,8 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin仅保留协议、host 和显式端口,去除 path、query、fragment
- `--config` 只选择 config 文件 block不提升该 block 的字段优先级。对 `auth: "apiKey"` 命令runtime 会先按叶子命令路径检查所选 Profile 的 `api_key_capabilities`:
- `--api-key` / `--base-url``DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式连接覆盖存在时,完全跳过自动降级,继续走统一的 flag > env > selected config file > 默认值
- 配置文件显式声明 `api_key_capabilities` 优先于内置预设;命中能力时保留所选 Profile未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile
- 字段缺失且 Profile 命中内置套餐预设(当前为 `token-plan`runtime 使用当前预设白名单兼容旧配置,但不自动写回磁盘;字段缺失且没有内置预设时不启用降级
- 配置文件显式声明 `api_key_capabilities` 后,命中能力时保留所选 Profile未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile
- 字段缺失时不启用降级,包括命中内置套餐预设的 Profilepreset 只在 API Key 登录验证成功后物化写入,升级 preset 需要重新登录
- fallback 反馈写 stderr:text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象;若后续鉴权失败warning 与多行 `error` 对象以空行分隔stdout 仍只保留命令结果
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile未传
`--config` 时继续写当前激活项,失败和 dry-run 不切换
+2 -2
View File
@@ -21,7 +21,7 @@
`auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。
- 激活状态只选择配置 block不改变字段优先级字段仍为 flag > env > selected config > 默认值。
- API Key capability fallback 是窄例外:命名 Profile 显式配置 `api_key_capabilities` 后,不在白名单中的 `auth: "apiKey"` 叶子命令只把 file 层 `api_key` / `base_url` 切到顶层 `default`;所选 Profile 的其他 settings 和 `active_config` 均不变。如果 `--api-key` / `--base-url``DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一提供了更高优先级的模型连接参数,则整个 capability fallback 跳过file 层也不切换;未显式提供的另一部分继续按 flag > env > 所选 Profile 解析。
- 自定义 Profile 是否启用 capability fallback 只看 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。内置 `token-plan` 是当前唯一的名称 preset 兼容例外旧配置缺少该字段时runtime 使用当前内置白名单,但不自动写回磁盘
- Profile 是否启用 capability fallback 只看持久化的 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。runtime 不注入内置 preset升级内置 Plan Profile 的 preset 需要重新登录
- 对命中内置 preset 的 ProfileAPI Key 登录验证成功后会把当前 preset 中缺少的 capability 追加落盘同时保留已有项且不做删除Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。
- Capability ID 直接使用产品实际叶子命令路径并以 `.` 连接(例如 `video task get``video.task.get`);不新增命令元数据。新增或改名后的 API Key 路由未进入白名单时自然 fail closed。
- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。
@@ -60,7 +60,7 @@
- 旧配置无 `active_config` -> `default`
- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。
- 任意名称 Profile 的叶子路由 capability 命中时使用自身 API Key未命中或空白名单时使用 `default` API Key`--api-key` / `--base-url``DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一覆盖时跳过 fallback。
- 旧内置 Plan Profile 缺 capability 字段时使用 preset显式配置(含 `[]`覆盖 preset且不自动迁移写盘
- 旧内置 Plan Profile 缺 capability 字段时不启用 fallback重新登录后使用并持久化当前 preset显式配置(含 `[]`按文件值生效
- 显式命名 `--config``--config default` 均覆盖激活项且不修改磁盘状态。
- 激活不存在的 Profile 失败且不写盘。
- 悬空 `active_config` 明确失败。
+4 -5
View File
@@ -202,14 +202,13 @@ export interface ApiKeyResolutionSourceSelection {
}
/**
* Select the file-backed API-key source for a command capability. An explicit
* flag or environment API connection override bypasses Profile capability
* fallback entirely.
* Select the file-backed API-key source for a command capability. Only a
* persisted Profile capability list enables fallback. An explicit flag or
* environment API connection override bypasses it entirely.
*/
export function selectApiKeyResolutionSources(
sources: ResolutionSources,
capability: string,
presetCapabilities?: readonly string[],
): ApiKeyResolutionSourceSelection {
if (
sources.flags.apiKey ||
@@ -221,7 +220,7 @@ export function selectApiKeyResolutionSources(
}
if (!sources.configName) return { sources };
const allowedCapabilities = sources.file.api_key_capabilities ?? presetCapabilities;
const allowedCapabilities = sources.file.api_key_capabilities;
if (allowedCapabilities === undefined) return { sources };
if (allowedCapabilities.includes(capability)) return { sources };
+6 -24
View File
@@ -18,7 +18,6 @@ import {
selectApiKeyResolutionSources,
} from "../src/config/loader.ts";
import { getConfigPath } from "../src/config/paths.ts";
import { getModelProfilePreset } from "../src/config/profile-presets.ts";
/** 在隔离的临时配置目录里执行,结束后恢复环境。 */
async function inTempConfigDir(fn: () => Promise<void>): Promise<void> {
@@ -446,42 +445,25 @@ test("显式 API Key 跳过 capability fallback其他字段仍按既有优先
});
});
test("旧 token-plan Profile 缺少持久化字段时使用内置 capability 预设", async () => {
test("旧 token-plan Profile 缺少 capability 字段时不启用 fallback", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default" });
await writeConfigFile({ api_key: "sk-token-plan" }, "token-plan");
const selectedSources = { ...buildSources({ config: "token-plan" }), env: {} };
const presetCapabilities = getModelProfilePreset("token-plan")?.apiKeyCapabilities;
const supported = selectApiKeyResolutionSources(
selectedSources,
"video.generate",
presetCapabilities,
);
expect(supported.sources.file.api_key).toBe("sk-token-plan");
const fallback = selectApiKeyResolutionSources(
selectedSources,
"search.web",
presetCapabilities,
);
expect(fallback.fallbackFrom).toBe("token-plan");
expect(fallback.sources.file.api_key).toBe("sk-default");
const selected = selectApiKeyResolutionSources(selectedSources, "search.web");
expect(selected.fallbackFrom).toBeUndefined();
expect(selected.sources.file.api_key).toBe("sk-token-plan");
});
});
test("token-plan 显式空 capability 白名单覆盖内置预设", async () => {
test("token-plan 显式空 capability 白名单启用全量 fallback", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default" });
await writeConfigFile({ api_key: "sk-token-plan", api_key_capabilities: [] }, "token-plan");
const selectedSources = { ...buildSources({ config: "token-plan" }), env: {} };
const presetCapabilities = getModelProfilePreset("token-plan")?.apiKeyCapabilities;
const fallback = selectApiKeyResolutionSources(
selectedSources,
"video.generate",
presetCapabilities,
);
const fallback = selectApiKeyResolutionSources(selectedSources, "video.generate");
expect(fallback.fallbackFrom).toBe("token-plan");
expect(fallback.sources.file.api_key).toBe("sk-default");
});
+1 -3
View File
@@ -15,7 +15,6 @@ import type {
import {
Client,
DEFAULT_LANGUAGE,
getModelProfilePreset,
resolveApiKey,
resolveConsole,
resolveOpenApi,
@@ -127,8 +126,7 @@ export const authStage: Middleware = async (ctx, next) => {
};
if (command.auth === "apiKey") {
const capability = ctx.path.join(".");
const presetCapabilities = getModelProfilePreset(sources.configName)?.apiKeyCapabilities;
const selection = selectApiKeyResolutionSources(sources, capability, presetCapabilities);
const selection = selectApiKeyResolutionSources(sources, capability);
const apiSources = selection.sources;
if (selection.fallbackFrom && !settings.quiet) {
writeApiKeyFallbackNotice(ctx, selection.fallbackFrom);
@@ -139,7 +139,7 @@ test("authStage 为支持的 capability 保留所选 Profile API Key", async ()
expect(context.client.exportApiCredential()?.token).toBe("sk-plan");
});
test("authStage 为缺少 capability 字段的旧 token-plan 应用内置 preset", async () => {
test("authStage 为缺少 capability 字段的旧 token-plan 注入内置 preset", async () => {
useTempConfigDir();
await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" });
await writeConfigFile(
@@ -147,19 +147,12 @@ test("authStage 为缺少 capability 字段的旧 token-plan 应用内置 preset
"token-plan",
);
const supportedContext = makeContext(["image", "generate"], { config: "token-plan" });
await runAuth(supportedContext);
expect(supportedContext.client.exportApiCredential()).toMatchObject({
const context = makeContext(["search", "web"], { config: "token-plan" });
await runAuth(context);
expect(context.client.exportApiCredential()).toMatchObject({
token: "sk-token-plan",
baseUrl: "https://token-plan.example.com",
});
const unsupportedContext = makeContext(["search", "web"], { config: "token-plan" });
await runAuth(unsupportedContext);
expect(unsupportedContext.client.exportApiCredential()).toMatchObject({
token: "sk-default",
baseUrl: "https://default.example.com",
});
});
test("authStage 在 --api-key 覆盖 Profile API Key 时跳过 capability fallback", async () => {
+8 -7
View File
@@ -98,12 +98,11 @@ A named Profile can limit the API Key commands it supports through `api_key_capa
- Unless `--quiet` is used, the CLI reports fallback on stderr. With `--output json`, it emits a
structured warning.
Custom Profiles opt in only by defining `api_key_capabilities`. A missing field disables fallback,
while `[]` makes every API Key command fall back to `default`.
The built-in `token-plan` Profile is the compatibility exception: older configs without the field
use the current built-in preset. A successful API Key login appends missing preset capabilities
without removing existing entries.
Profiles opt in only by defining `api_key_capabilities`. A missing field disables fallback, while
`[]` makes every API Key command fall back to `default`. This applies to the built-in `token-plan`
Profile too: the CLI never injects a newer preset at runtime. A successful Token Plan API Key login
appends missing preset capabilities without removing existing entries, so log in again to upgrade
the persisted preset.
```bash
bl config set --config company-plan --key api-key-capabilities \
@@ -128,7 +127,9 @@ still follow flag > environment > config.
Activation selects the entire Config for every credential domain, not only model consumption. The only exception is the API Key capability fallback described above. After activating `token-plan`, Token Plan management and Console commands still read their OpenAPI or Console credentials from that Profile. If those credentials remain in `default`, invoke the command with `--config default` or log the corresponding credential domain into `token-plan`.
The built-in `token-plan` profile defaults to:
The built-in `token-plan` preset contains the following values. The CLI materializes them only after
a successful `auth login --config token-plan`. It does not replace them at runtime; run the login
command again to persist a newer preset:
- Base URL: `https://token-plan.cn-beijing.maas.aliyuncs.com`
- Text model: `qwen3.8-max`