Skip to content

Commit ab52373

Browse files
committed
refactor(auth): improve API key validation error handling and response propagation
1 parent 3d39548 commit ab52373

4 files changed

Lines changed: 57 additions & 30 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,其他格式同时尝试两类候选。所有候选明确拒绝时返回 `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、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: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import {
2-
BailianError,
3-
ExitCode,
42
getApiKeyLoginKind,
53
normalizeModelBaseUrl,
64
requestJson,
@@ -53,7 +51,6 @@ export interface ApiKeyValidationResult {
5351
}
5452

5553
const API_KEY_VALIDATION_TIMEOUT_SECONDS = 5;
56-
const DEFINITIVE_VALIDATION_FAILURE_STATUSES = new Set([200, 400, 401, 403, 404, 405]);
5754
const ORDINARY_WORKSPACE_REGIONS = [
5855
"cn-beijing",
5956
"ap-southeast-1",
@@ -165,12 +162,6 @@ export function apiKeyValidationCandidates(
165162
return candidates;
166163
}
167164

168-
function isDefinitiveCandidateFailure(error: unknown): boolean {
169-
if (!(error instanceof BailianError)) return false;
170-
const status = error.api?.httpStatus;
171-
return status !== undefined && DEFINITIVE_VALIDATION_FAILURE_STATUSES.has(status);
172-
}
173-
174165
/** Validate a key without consuming model quota and return the first matching endpoint. */
175166
export async function validateApiKey(
176167
deps: ApiKeyValidationDeps,
@@ -196,17 +187,10 @@ export async function validateApiKey(
196187
const success = results.find((result) => result.error === undefined);
197188
if (success) return { baseUrl: success.candidate.baseUrl, kind: success.candidate.kind };
198189

199-
const inconclusive = results.find((result) => !isDefinitiveCandidateFailure(result.error));
200-
if (inconclusive?.error !== undefined) throw inconclusive.error;
190+
const failure = results.find((result) => result.error !== undefined);
191+
if (failure?.error !== undefined) throw failure.error;
201192

202-
const explicitHint = options.explicitBaseUrl
203-
? "Check that the API key belongs to this Base URL."
204-
: "For a workspace-specific or custom endpoint, retry with --base-url <url>.";
205-
throw new BailianError(
206-
"API key validation failed: the key was not accepted by any supported model endpoint.",
207-
ExitCode.AUTH,
208-
explicitHint,
209-
);
193+
throw new TypeError("API key validation completed without a success or failure result.");
210194
}
211195

212196
/**

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

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ const settings: Settings = {
2525
telemetry: false,
2626
};
2727

28-
function jsonResponse(body: unknown, status = 200): Response {
28+
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
2929
return new Response(JSON.stringify(body), {
3030
status,
31-
headers: { "content-type": "application/json" },
31+
headers: { "content-type": "application/json", ...headers },
3232
});
3333
}
3434

@@ -202,15 +202,53 @@ test("Token Plan 多地区并行校验选择实际接受 Key 的国际站点", a
202202
}
203203
});
204204

205-
test("所有候选站点明确拒绝 Key 时返回 AUTH 校验失败", async () => {
205+
test("所有候选站点拒绝 Key 时透传首个服务端错误", async () => {
206206
const originalFetch = httpTransport.fetch;
207-
httpTransport.fetch = async () =>
208-
jsonResponse({ code: "InvalidApiKey", message: "invalid key" }, 401);
207+
httpTransport.fetch = async (input) => {
208+
const requestId = requestUrl(input).includes("cn-beijing")
209+
? "req-cn-rejected"
210+
: "req-sg-rejected";
211+
return jsonResponse(
212+
{ code: "InvalidApiKey", message: "invalid key", request_id: requestId },
213+
401,
214+
);
215+
};
209216

210217
try {
211218
const error = await captureValidationError("sk-sp-placeholder");
212-
expect(error.exitCode).toBe(ExitCode.AUTH);
213-
expect(error.message).toMatch(/API key validation failed/);
219+
expect(error.exitCode).toBe(ExitCode.GENERAL);
220+
expect(error.message).toBe("invalid key");
221+
expect(error.api).toEqual({
222+
httpStatus: 401,
223+
apiCode: "InvalidApiKey",
224+
requestId: "req-cn-rejected",
225+
});
226+
} finally {
227+
httpTransport.fetch = originalFetch;
228+
}
229+
});
230+
231+
test("显式 Base URL 返回 404 时保留状态和 request ID", async () => {
232+
const originalFetch = httpTransport.fetch;
233+
httpTransport.fetch = async () =>
234+
jsonResponse({ code: "NotFound", message: "models route not found" }, 404, {
235+
"x-request-id": "req-route-not-found",
236+
});
237+
238+
try {
239+
const error = await validateApiKey({ identity, settings }, "sk-ws-placeholder", {
240+
explicitBaseUrl: "https://ws-explicit.ap-southeast-1.maas.aliyuncs.com",
241+
}).catch((caught) => caught);
242+
expect(error).toBeInstanceOf(BailianError);
243+
expect(error).toMatchObject({
244+
exitCode: ExitCode.GENERAL,
245+
message: "models route not found",
246+
api: {
247+
httpStatus: 404,
248+
apiCode: "NotFound",
249+
requestId: "req-route-not-found",
250+
},
251+
});
214252
} finally {
215253
httpTransport.fetch = originalFetch;
216254
}

‎packages/commands/tests/e2e/auth.e2e.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ beforeAll(async () => {
4040
return;
4141
}
4242
if (request.headers.authorization === "Bearer sk-rejected-placeholder") {
43-
response.writeHead(401, { "content-type": "application/json" });
43+
response.writeHead(401, {
44+
"content-type": "application/json",
45+
"x-request-id": "req-auth-rejected",
46+
});
4447
response.end(JSON.stringify({ code: "InvalidApiKey", message: "invalid key" }));
4548
return;
4649
}
@@ -991,8 +994,10 @@ describe("e2e: auth", () => {
991994
DASHSCOPE_BASE_URL: "",
992995
},
993996
);
994-
expect(login.exitCode).toBe(3);
995-
expect(login.stderr).toMatch(/API key validation failed/);
997+
expect(login.exitCode).toBe(1);
998+
expect(login.stderr).toMatch(/invalid key/);
999+
expect(login.stderr).toMatch(/Status:\s+HTTP 401 \(InvalidApiKey\)/);
1000+
expect(login.stderr).toMatch(/Request ID:\s+req-auth-rejected/);
9961001
expect(existsSync(join(configDir, "config.json"))).toBe(false);
9971002
});
9981003

0 commit comments

Comments
 (0)