Skip to content

Commit d2b7448

Browse files
committed
refactor(auth): enhance error handling in API key validation to prioritize meaningful error propagation
1 parent ab52373 commit d2b7448

3 files changed

Lines changed: 88 additions & 6 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`。公共 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、API code、request ID 与服务端 message 保持不变,不重新分类为 `AUTH`。任一失败都不落盘、不激活 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` 域凭证

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
BailianError,
23
getApiKeyLoginKind,
34
normalizeModelBaseUrl,
45
requestJson,
@@ -162,6 +163,27 @@ export function apiKeyValidationCandidates(
162163
return candidates;
163164
}
164165

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;
185+
}
186+
165187
/** Validate a key without consuming model quota and return the first matching endpoint. */
166188
export async function validateApiKey(
167189
deps: ApiKeyValidationDeps,
@@ -187,8 +209,8 @@ export async function validateApiKey(
187209
const success = results.find((result) => result.error === undefined);
188210
if (success) return { baseUrl: success.candidate.baseUrl, kind: success.candidate.kind };
189211

190-
const failure = results.find((result) => result.error !== undefined);
191-
if (failure?.error !== undefined) throw failure.error;
212+
const failure = selectValidationFailure(results);
213+
if (failure !== undefined) throw failure;
192214

193215
throw new TypeError("API key validation completed without a success or failure result.");
194216
}

‎packages/commands/tests/auth-api-key.test.ts‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,15 @@ test("显式 Base URL 返回 404 时保留状态和 request ID", async () => {
257257
test.each([
258258
{ name: "5xx", status: 503, message: "temporary outage" },
259259
{ name: "429", status: 429, message: "too many requests" },
260-
])("存在 $name 时校验结论不确定,不误报 Key 无效", async ({ status, message }) => {
260+
])("后续候选出现 $name 时优先透传该错误", async ({ status, message }) => {
261261
const originalFetch = httpTransport.fetch;
262262
httpTransport.fetch = async (input) => {
263-
if (requestUrl(input).startsWith("https://dashscope.aliyuncs.com/")) {
264-
return jsonResponse({ code: "TemporaryFailure", message }, status);
263+
const url = requestUrl(input);
264+
if (url.startsWith("https://dashscope-intl.aliyuncs.com/")) {
265+
return jsonResponse(
266+
{ code: "TemporaryFailure", message, request_id: `req-${status}` },
267+
status,
268+
);
265269
}
266270
return jsonResponse({ code: "InvalidApiKey", message: "invalid key" }, 401);
267271
};
@@ -270,6 +274,62 @@ test.each([
270274
const error = await captureValidationError("sk-placeholder");
271275
expect(error.exitCode).toBe(ExitCode.GENERAL);
272276
expect(error.message).toBe(message);
277+
expect(error.api).toEqual({
278+
httpStatus: status,
279+
apiCode: "TemporaryFailure",
280+
requestId: `req-${status}`,
281+
});
282+
} finally {
283+
httpTransport.fetch = originalFetch;
284+
}
285+
});
286+
287+
test("后续候选出现网络错误时优先透传该错误", async () => {
288+
const originalFetch = httpTransport.fetch;
289+
const networkError = new TypeError("fetch failed", {
290+
cause: Object.assign(new Error("connection reset"), { code: "ECONNRESET" }),
291+
});
292+
httpTransport.fetch = async (input) => {
293+
if (requestUrl(input).startsWith("https://dashscope-intl.aliyuncs.com/")) {
294+
throw networkError;
295+
}
296+
return jsonResponse({ code: "InvalidApiKey", message: "invalid key" }, 401);
297+
};
298+
299+
try {
300+
const error = await validateApiKey({ identity, settings }, "sk-placeholder", {}).catch(
301+
(caught) => caught,
302+
);
303+
expect(error).toBe(networkError);
304+
} finally {
305+
httpTransport.fetch = originalFetch;
306+
}
307+
});
308+
309+
test("同一优先级的 HTTP 错误保持候选顺序", async () => {
310+
const originalFetch = httpTransport.fetch;
311+
httpTransport.fetch = async (input) => {
312+
const url = requestUrl(input);
313+
if (url.startsWith("https://dashscope.aliyuncs.com/")) {
314+
return jsonResponse(
315+
{ code: "FirstRejected", message: "first candidate rejected", request_id: "req-first" },
316+
401,
317+
);
318+
}
319+
return jsonResponse(
320+
{ code: "LaterRejected", message: "later candidate rejected", request_id: "req-later" },
321+
404,
322+
);
323+
};
324+
325+
try {
326+
const error = await captureValidationError("sk-placeholder");
327+
expect(error.message).toBe("first candidate rejected");
328+
expect(error.api).toEqual({
329+
httpStatus: 401,
330+
apiCode: "FirstRejected",
331+
requestId: "req-first",
332+
});
273333
} finally {
274334
httpTransport.fetch = originalFetch;
275335
}

0 commit comments

Comments
 (0)