Skip to content

Commit 4443bc0

Browse files
committed
Merge remote-tracking branch 'origin/main' into release/2.7.0
2 parents e88768c + 694c296 commit 4443bc0

16 files changed

Lines changed: 905 additions & 60 deletions

File tree

‎docs/agents/auth-change.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
3333

3434
`~/.bailian/config.json` 可同时保存 `api_key`、`access_token` 与 `access_key_*`。登录任一种方式不得删除另一种:
3535

36-
- `bl auth login --api-key ...` 先通过只读 `GET /models` 校验 Key,校验成功后才原子写入 `api_key` 与实际匹配的 `base_url`。显式 `--base-url` 只校验该站点,不自动改写;未显式指定时,普通 `sk-*` / `sk-ws-*` 按“当前 Profile 已保存的同类站点 → 基于 `workspace_id` 构造的各地域 Workspace 专属站点 → 公共地域”顺序选择首个成功结果,`sk-sp-*` 并行探测国内站与新加坡 Token Plan,其他格式同时尝试两类候选。所有候选明确拒绝时返回 `AUTH`;网络、超时、非 JSON 或 5xx 视为校验无法确定并透传原错误。任一失败都不落盘、不激活 Profile
36+
- `bl auth login --api-key ...` 先通过只读 `GET /models` 校验 Key,校验成功后才原子写入 `api_key` 与实际匹配的 `base_url`。公共 DashScope 站点使用 `/api/v1/models?page_no=1&page_size=1`,`*.maas.aliyuncs.com`(Workspace 与 Token Plan)使用 `/compatible-mode/v1/models`。显式 `--base-url` 只校验该站点,不自动改写;未显式指定时,普通 `sk-*` / `sk-ws-*` 按“当前 Profile 已保存的同类站点 → 基于 `workspace_id` 构造的各地域 Workspace 专属站点 → 公共地域”顺序选择首个成功结果,`sk-sp-*` 并行探测国内站与新加坡 Token Plan,其他格式同时尝试两类候选。所有候选失败时选择最有诊断价值的原始错误透传:无 HTTP status 的网络、超时或响应解析错误优先,其次为 HTTP 429/5xx,最后为其他 HTTP 错误;同级保持候选顺序。HTTP status、API code、request ID 与服务端 message 保持不变,不重新分类为 `AUTH`。任一失败都不落盘、不激活 Profile
3737
- 未显式传 `--config` 时,`sk-sp-*` 自动写入并激活 `token-plan`;普通 `sk-*` / `sk-ws-*` 仅在当前为 `token-plan` 时改写并激活 `default`,否则保留当前 Profile;其他格式不自动切换。显式 `--config` 始终优先。`sk-sp-*` 无论写入哪个 Profile 都会携带 Token Plan 的预设默认模型,并在保留已有项的基础上补齐 `api_key_capabilities`,不自动删除任何已有能力。登录仍不得删除其他鉴权域的凭证
3838
- `bl auth login --console` 更新 `access_token` 以及回调携带的 console 作用域字段;若所选 Profile 没有模型 API Key,会要求控制台页面创建并回传一个普通 API Key 与 Base URL 一并保存。该回调结果不重复执行 `/models` 校验,且不会创建或配置 Token Plan
3939
- `bl auth login --open-api ...` 调用 OpenAPI 生成 CLI `access_token`,通过目标 Console 网关的只读 `loginInfo` 校验后,才一并写入 `access_key_id` / `access_key_secret` / `access_token` 和实际 `console_region` / `console_site`;失败不覆盖原凭证、不激活 Profile。一次成功登录同时产生 `openapi` 与 `console` 域凭证

‎docs/agents/config-profile-change.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
- 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`。
5050
- `config list` 标识所有 Profile 与当前激活项。
5151
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
52+
- `config show` 额外展示 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL`(有值才输出,key 脱敏);JSON 字段值保持干净,另用 `active: { api_key?, base_url? }` 标生效源;text 模式才在生效层值后追加 `(active)` / `(active)`。`base_url` 展示文件层或内置默认,不再被 client 生效值覆盖来源语义。
53+
- 模型域 `api_key` 与显式 `base_url` 来自不同优先级层级时,仅 `auth: "apiKey"` 命令在 `authStage` 向 stderr 告警一次(`--quiet` 抑制;`--output json` 为 `API_KEY_BASE_URL_SOURCE_MISMATCH`);同源或仅一端显式配置不告。排查建议指向 `{binName} config show`。
5254
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
5355
- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。
5456
- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。
@@ -72,7 +74,8 @@
7274
- Console/OpenAPI/none 命令不参与 API Key capability fallback;fallback 后的 Client 特殊端点行为必须跟随最终解析的 `base_url`,不能根据原 `settings.configName` 推断端点类型。
7375
- Fallback 反馈只描述 CLI 能权威确认的本地行为:当前 Profile 不支持空格分隔的用户可见叶子命令,本次将从 `default` 读取 API Key 配置;不得声称整个 Profile 已切换,也不得展示 capability ID 或 `<undeclared>` 等内部值。存在 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式覆盖时必须跳过 fallback 且不输出反馈。
7476
- Fallback 反馈写 stderr,`--quiet` 抑制;text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象。dry-run 和后续鉴权失败仍保留反馈;JSON 模式下多个 diagnostics 以空行分隔,任何模式都不得输出凭证值。
75-
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
77+
- 跨源告警覆盖:env key + file base_url、flag/env 与另一层组合;同源与「仅 key + 默认 base_url」不告;quiet / json 形态与 fallback 一致。
78+
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E;`config show` 覆盖 env 脱敏与 active 标记。
7679
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
7780
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
7881

‎packages/cli/tests/e2e/config-profile.e2e.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ describe("e2e: named config", () => {
153153
});
154154
} else {
155155
expect(leadingData.base_url).toBe("https://work.example.test");
156+
expect(leadingData.api_key).toBeDefined();
157+
expect(String(leadingData.api_key)).not.toMatch(/active/);
158+
expect(leadingData.active).toEqual({ api_key: "config", base_url: "config" });
156159
expect(leadingData.workspace_id).toBe("work-space-review");
157160
}
158161
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))).toEqual(
@@ -227,13 +230,20 @@ describe("e2e: named config", () => {
227230
});
228231
const { stdout, stderr, exitCode } = await runCli(
229232
["config", "show", "--config", "default", "--output", "json"],
230-
{ BAILIAN_CONFIG_DIR: dir },
233+
{
234+
BAILIAN_CONFIG_DIR: dir,
235+
DASHSCOPE_API_KEY: "",
236+
DASHSCOPE_BASE_URL: "",
237+
},
231238
);
232239
expect(exitCode, stderr).toBe(0);
233240
const data = parseStdoutJson<Record<string, unknown>>(stdout);
234241
expect(data.config).toBe("default");
235-
expect(data.active).toBeUndefined();
242+
// `active` is credential-source provenance, not profile activation metadata.
243+
expect(data.active).toEqual({ api_key: "config" });
244+
expect(data).not.toHaveProperty("active_config");
236245
expect(data.api_key).toBeDefined();
246+
expect(String(data.api_key)).not.toMatch(/active/);
237247
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
238248
string,
239249
unknown

‎packages/commands/src/commands/auth/login-api-key.ts‎

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
BailianError,
3-
ExitCode,
43
getApiKeyLoginKind,
54
normalizeModelBaseUrl,
65
requestJson,
@@ -53,7 +52,6 @@ export interface ApiKeyValidationResult {
5352
}
5453

5554
const API_KEY_VALIDATION_TIMEOUT_SECONDS = 5;
56-
const DEFINITIVE_VALIDATION_FAILURE_STATUSES = new Set([200, 400, 401, 403, 404, 405]);
5755
const ORDINARY_WORKSPACE_REGIONS = [
5856
"cn-beijing",
5957
"ap-southeast-1",
@@ -76,12 +74,15 @@ const KNOWN_BASE_URLS: Readonly<Record<ApiKeyEndpointKind, readonly string[]>> =
7674
],
7775
};
7876

77+
function usesCompatibleModelsApi(baseUrl: string, kind: ApiKeyEndpointKind): boolean {
78+
const hostname = new URL(normalizeModelBaseUrl(baseUrl)).hostname;
79+
return kind === "token-plan" || hostname.endsWith(".maas.aliyuncs.com");
80+
}
81+
7982
function modelsUrl(baseUrl: string, kind: ApiKeyEndpointKind): string {
80-
const url = new URL(
81-
kind === "token-plan" ? "/compatible-mode/v1/models" : "/api/v1/models",
82-
`${baseUrl}/`,
83-
);
84-
if (kind === "ordinary") {
83+
const compatible = usesCompatibleModelsApi(baseUrl, kind);
84+
const url = new URL(compatible ? "/compatible-mode/v1/models" : "/api/v1/models", `${baseUrl}/`);
85+
if (!compatible) {
8586
url.searchParams.set("page_no", "1");
8687
url.searchParams.set("page_size", "1");
8788
}
@@ -97,6 +98,14 @@ function isTokenPlanBaseUrl(baseUrl: string): boolean {
9798
return new URL(normalizeModelBaseUrl(baseUrl)).hostname.startsWith("token-plan.");
9899
}
99100

101+
function kindsForBaseUrl(
102+
kinds: readonly ApiKeyEndpointKind[],
103+
baseUrl: string,
104+
): ApiKeyEndpointKind[] {
105+
if (!isTokenPlanBaseUrl(baseUrl) || !kinds.includes("token-plan")) return [...kinds];
106+
return ["token-plan", ...kinds.filter((kind) => kind !== "token-plan")];
107+
}
108+
100109
function addCandidate(
101110
candidates: ApiKeyValidationCandidate[],
102111
seenUrls: Set<string>,
@@ -120,13 +129,15 @@ export function apiKeyValidationCandidates(
120129
const seenUrls = new Set<string>();
121130

122131
if (options.explicitBaseUrl) {
123-
for (const kind of kinds) addCandidate(candidates, seenUrls, options.explicitBaseUrl, kind);
132+
for (const kind of kindsForBaseUrl(kinds, options.explicitBaseUrl)) {
133+
addCandidate(candidates, seenUrls, options.explicitBaseUrl, kind);
134+
}
124135
return candidates;
125136
}
126137

127138
if (options.storedBaseUrl) {
128139
const storedIsTokenPlan = isTokenPlanBaseUrl(options.storedBaseUrl);
129-
for (const kind of kinds) {
140+
for (const kind of kindsForBaseUrl(kinds, options.storedBaseUrl)) {
130141
if (apiKeyKind === "unknown" || storedIsTokenPlan === (kind === "token-plan")) {
131142
addCandidate(candidates, seenUrls, options.storedBaseUrl, kind);
132143
}
@@ -152,10 +163,25 @@ export function apiKeyValidationCandidates(
152163
return candidates;
153164
}
154165

155-
function isDefinitiveCandidateFailure(error: unknown): boolean {
156-
if (!(error instanceof BailianError)) return false;
157-
const status = error.api?.httpStatus;
158-
return status !== undefined && DEFINITIVE_VALIDATION_FAILURE_STATUSES.has(status);
166+
function validationFailurePriority(error: unknown): number {
167+
if (!(error instanceof BailianError) || error.api?.httpStatus === undefined) return 0;
168+
const status = error.api.httpStatus;
169+
if (status === 429 || status >= 500) return 1;
170+
return 2;
171+
}
172+
173+
function selectValidationFailure(results: readonly { error?: unknown }[]): unknown {
174+
let selectedError: unknown;
175+
let selectedPriority = Number.POSITIVE_INFINITY;
176+
for (const result of results) {
177+
if (result.error === undefined) continue;
178+
const priority = validationFailurePriority(result.error);
179+
if (priority < selectedPriority) {
180+
selectedError = result.error;
181+
selectedPriority = priority;
182+
}
183+
}
184+
return selectedError;
159185
}
160186

161187
/** Validate a key without consuming model quota and return the first matching endpoint. */
@@ -183,17 +209,10 @@ export async function validateApiKey(
183209
const success = results.find((result) => result.error === undefined);
184210
if (success) return { baseUrl: success.candidate.baseUrl, kind: success.candidate.kind };
185211

186-
const inconclusive = results.find((result) => !isDefinitiveCandidateFailure(result.error));
187-
if (inconclusive?.error !== undefined) throw inconclusive.error;
212+
const failure = selectValidationFailure(results);
213+
if (failure !== undefined) throw failure;
188214

189-
const explicitHint = options.explicitBaseUrl
190-
? "Check that the API key belongs to this Base URL."
191-
: "For a workspace-specific or custom endpoint, retry with --base-url <url>.";
192-
throw new BailianError(
193-
"API key validation failed: the key was not accepted by any supported model endpoint.",
194-
ExitCode.AUTH,
195-
explicitHint,
196-
);
215+
throw new TypeError("API key validation completed without a success or failure result.");
197216
}
198217

199218
/**
Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,127 @@
1-
import { DEFAULT_LANGUAGE, defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core";
2-
import { emitResult } from "bailian-cli-runtime";
1+
import {
2+
DEFAULT_LANGUAGE,
3+
defineCommand,
4+
detectOutputFormat,
5+
formatOutput,
6+
maskToken,
7+
normalizeModelBaseUrl,
8+
REGIONS,
9+
resolveModelConnectionProvenance,
10+
type CredentialSource,
11+
type ResolutionSources,
12+
} from "bailian-cli-core";
13+
import { ansi } from "bailian-cli-runtime";
314
import { SECRET_KEYS } from "./shared.ts";
415

16+
function markActive(value: string, isActive: boolean, suffix: string): string {
17+
return isActive ? `${value}${suffix}` : value;
18+
}
19+
520
export default defineCommand({
621
description: { "en-US": "Display current configuration", "zh-CN": "显示当前配置" },
722
auth: "none",
823
exampleArgs: ["", "--output json"],
924
async run(ctx) {
10-
const { settings, client } = ctx;
25+
const { settings, localize } = ctx;
1126
const store = ctx.configStore;
1227
const file = store.read();
1328
const format = detectOutputFormat(settings.output);
29+
const activeSuffix = localize({
30+
"en-US": " (active)",
31+
"zh-CN": "(active)",
32+
});
33+
34+
// auth:none — no credential flags; file + env match the dispatch resolution chain.
35+
const sources: ResolutionSources = {
36+
flags: {},
37+
file,
38+
env: process.env,
39+
configName: settings.configName,
40+
configPath: store.path,
41+
};
42+
const provenance = resolveModelConnectionProvenance(sources);
43+
44+
const envApiKey = process.env.DASHSCOPE_API_KEY?.trim();
45+
const envBaseUrl = process.env.DASHSCOPE_BASE_URL?.trim();
1446

1547
const result: Record<string, unknown> = {
1648
...file,
1749
language: file.language ?? DEFAULT_LANGUAGE,
18-
base_url: client.baseUrl,
1950
output: settings.output,
2051
timeout: settings.timeout,
2152
watermark: settings.watermark,
2253
config: settings.configName ?? "default",
2354
config_file: store.path,
2455
};
2556

57+
// Keep field values machine-clean; decorate only for text output below.
58+
if (typeof file.api_key === "string") {
59+
result.api_key = maskToken(file.api_key);
60+
}
61+
62+
if (envApiKey) {
63+
result.DASHSCOPE_API_KEY = maskToken(envApiKey);
64+
}
65+
66+
if (typeof file.base_url === "string") {
67+
result.base_url = file.base_url;
68+
} else {
69+
// Always show the built-in default when the file omits base_url.
70+
result.base_url = REGIONS.cn;
71+
}
72+
73+
if (envBaseUrl) {
74+
// Match the request path: origin only, so userinfo/query/fragment are not printed.
75+
result.DASHSCOPE_BASE_URL = normalizeModelBaseUrl(envBaseUrl);
76+
}
77+
2678
for (const key of SECRET_KEYS) {
27-
if (typeof result[key] === "string") result[key] = maskToken(result[key]);
79+
if (key === "api_key") continue;
80+
if (typeof result[key] === "string") result[key] = maskToken(result[key] as string);
81+
}
82+
83+
const active: { api_key?: CredentialSource; base_url?: CredentialSource } = {};
84+
if (provenance.apiKeySource) active.api_key = provenance.apiKeySource;
85+
if (provenance.baseUrlSource) active.base_url = provenance.baseUrlSource;
86+
87+
if (format === "json") {
88+
result.active = active;
89+
process.stdout.write(formatOutput(result, format) + "\n");
90+
return;
91+
}
92+
93+
// Text: append (active) to the winning layer's displayed value.
94+
if (typeof result.api_key === "string") {
95+
result.api_key = markActive(
96+
result.api_key,
97+
provenance.apiKeySource === "config",
98+
activeSuffix,
99+
);
100+
}
101+
if (typeof result.DASHSCOPE_API_KEY === "string") {
102+
result.DASHSCOPE_API_KEY = markActive(
103+
result.DASHSCOPE_API_KEY,
104+
provenance.apiKeySource === "env",
105+
activeSuffix,
106+
);
107+
}
108+
if (typeof result.base_url === "string") {
109+
const baseUrlActive =
110+
provenance.baseUrlSource === "config" || provenance.baseUrlSource === undefined;
111+
result.base_url = markActive(result.base_url, baseUrlActive, activeSuffix);
112+
}
113+
if (typeof result.DASHSCOPE_BASE_URL === "string") {
114+
result.DASHSCOPE_BASE_URL = markActive(
115+
result.DASHSCOPE_BASE_URL,
116+
provenance.baseUrlSource === "env",
117+
activeSuffix,
118+
);
28119
}
29120

30-
emitResult(result, format);
121+
// Color after render so YAML does not escape ANSI into values.
122+
const rendered = formatOutput(result, format);
123+
process.stdout.write(
124+
rendered.replaceAll(activeSuffix, ansi(process.stdout).cyan(activeSuffix)) + "\n",
125+
);
31126
},
32127
});

0 commit comments

Comments
 (0)