Skip to content

Commit f0711a8

Browse files
authored
Merge pull request #285 from modelstudioai/fix/fix-workspace-auth
Fix workspace MaaS API key login validation
2 parents 33d4717 + d2b7448 commit f0711a8

4 files changed

Lines changed: 341 additions & 44 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` 域凭证

‎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
/**

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

Lines changed: 176 additions & 16 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

@@ -73,25 +73,82 @@ test("普通 Key 优先使用已保存地址,再尝试 Workspace 专属地址
7373
}),
7474
).toEqual([
7575
"https://stored.example.com/api/v1/models?page_no=1&page_size=1",
76-
"https://llm-test-workspace.cn-beijing.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
77-
"https://llm-test-workspace.ap-southeast-1.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
78-
"https://llm-test-workspace.cn-hongkong.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
79-
"https://llm-test-workspace.ap-northeast-1.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
80-
"https://llm-test-workspace.eu-central-1.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
81-
"https://llm-test-workspace.us-east-1.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
76+
"https://llm-test-workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/models",
77+
"https://llm-test-workspace.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models",
78+
"https://llm-test-workspace.cn-hongkong.maas.aliyuncs.com/compatible-mode/v1/models",
79+
"https://llm-test-workspace.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1/models",
80+
"https://llm-test-workspace.eu-central-1.maas.aliyuncs.com/compatible-mode/v1/models",
81+
"https://llm-test-workspace.us-east-1.maas.aliyuncs.com/compatible-mode/v1/models",
8282
"https://dashscope.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
8383
"https://dashscope-intl.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
8484
"https://dashscope-us.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
8585
"https://cn-hongkong.dashscope.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
8686
]);
8787
});
8888

89+
test("已保存的 Workspace MaaS 地址使用 Compatible /models", () => {
90+
expect(
91+
candidateUrls("sk-ws-placeholder", {
92+
storedBaseUrl: "https://ws-stored.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
93+
}),
94+
).toEqual([
95+
"https://ws-stored.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models",
96+
"https://dashscope.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
97+
"https://dashscope-intl.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
98+
"https://dashscope-us.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
99+
"https://cn-hongkong.dashscope.aliyuncs.com/api/v1/models?page_no=1&page_size=1",
100+
]);
101+
});
102+
103+
test("Workspace 地域候选先于公共站点选择首个成功地域", async () => {
104+
const originalFetch = httpTransport.fetch;
105+
const workspaceOrigin = "https://llm-test-workspace.ap-southeast-1.maas.aliyuncs.com";
106+
httpTransport.fetch = async (input) => {
107+
const url = requestUrl(input);
108+
if (url === `${workspaceOrigin}/compatible-mode/v1/models`) {
109+
return jsonResponse({ object: "list", data: [{ id: "workspace-model" }] });
110+
}
111+
if (url.startsWith("https://dashscope-intl.aliyuncs.com/")) {
112+
return jsonResponse({ data: [{ id: "public-model" }] });
113+
}
114+
const status = url.includes(".maas.aliyuncs.com/") ? 404 : 401;
115+
return jsonResponse({ code: "Rejected", message: "candidate rejected" }, status);
116+
};
117+
118+
try {
119+
await expect(
120+
validateApiKey({ identity, settings }, "sk-ws-placeholder", {
121+
workspaceId: "llm-test-workspace",
122+
}),
123+
).resolves.toEqual({
124+
baseUrl: workspaceOrigin,
125+
kind: "ordinary",
126+
});
127+
} finally {
128+
httpTransport.fetch = originalFetch;
129+
}
130+
});
131+
89132
test("其他格式 Key 不限制端点类型", () => {
90133
expect(
91134
apiKeyValidationCandidates("custom-key-placeholder", {}).map((candidate) => candidate.kind),
92135
).toEqual(["ordinary", "ordinary", "ordinary", "ordinary", "token-plan", "token-plan"]);
93136
});
94137

138+
test("未知格式 Key 指向 Token Plan host 时保留 Token Plan kind", () => {
139+
expect(
140+
apiKeyValidationCandidates("custom-key-placeholder", {
141+
explicitBaseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com",
142+
}),
143+
).toEqual([
144+
{
145+
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com",
146+
kind: "token-plan",
147+
modelsUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models",
148+
},
149+
]);
150+
});
151+
95152
test("显式 Base URL 只校验指定站点,并根据 Key 类型选择 /models 路径", () => {
96153
expect(
97154
candidateUrls("sk-sp-placeholder", {
@@ -103,6 +160,11 @@ test("显式 Base URL 只校验指定站点,并根据 Key 类型选择 /models
103160
explicitBaseUrl: "https://workspace.example.com/compatible-mode/v1",
104161
}),
105162
).toEqual(["https://workspace.example.com/api/v1/models?page_no=1&page_size=1"]);
163+
expect(
164+
candidateUrls("sk-ws-placeholder", {
165+
explicitBaseUrl: "https://ws-explicit.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
166+
}),
167+
).toEqual(["https://ws-explicit.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models"]);
106168
});
107169

108170
test("Token Plan 多地区并行校验选择实际接受 Key 的国际站点", async () => {
@@ -140,15 +202,53 @@ test("Token Plan 多地区并行校验选择实际接受 Key 的国际站点", a
140202
}
141203
});
142204

143-
test("所有候选站点明确拒绝 Key 时返回 AUTH 校验失败", async () => {
205+
test("所有候选站点拒绝 Key 时透传首个服务端错误", async () => {
144206
const originalFetch = httpTransport.fetch;
145-
httpTransport.fetch = async () =>
146-
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+
};
147216

148217
try {
149218
const error = await captureValidationError("sk-sp-placeholder");
150-
expect(error.exitCode).toBe(ExitCode.AUTH);
151-
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+
});
152252
} finally {
153253
httpTransport.fetch = originalFetch;
154254
}
@@ -157,11 +257,15 @@ test("所有候选站点明确拒绝 Key 时返回 AUTH 校验失败", async ()
157257
test.each([
158258
{ name: "5xx", status: 503, message: "temporary outage" },
159259
{ name: "429", status: 429, message: "too many requests" },
160-
])("存在 $name 时校验结论不确定,不误报 Key 无效", async ({ status, message }) => {
260+
])("后续候选出现 $name 时优先透传该错误", async ({ status, message }) => {
161261
const originalFetch = httpTransport.fetch;
162262
httpTransport.fetch = async (input) => {
163-
if (requestUrl(input).startsWith("https://dashscope.aliyuncs.com/")) {
164-
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+
);
165269
}
166270
return jsonResponse({ code: "InvalidApiKey", message: "invalid key" }, 401);
167271
};
@@ -170,6 +274,62 @@ test.each([
170274
const error = await captureValidationError("sk-placeholder");
171275
expect(error.exitCode).toBe(ExitCode.GENERAL);
172276
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+
});
173333
} finally {
174334
httpTransport.fetch = originalFetch;
175335
}

0 commit comments

Comments
 (0)