diff --git a/INSTALL.md b/INSTALL.md index 6a8f6c8df..61e128f6c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -79,13 +79,9 @@ which bl # Windows: where.exe bl ### 推荐:浏览器登录(控制台会话) 1. 执行 `bl auth status --output json`,判断是否已配置。 -2. 若未配置,在**用户本机终端**执行 `bl auth login --console`。 -3. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整凭据。 - -### 备选:API Key / Token Plan - -- 普通 Key:`bl auth login --api-key ` -- Token Plan:`bl auth login --config token-plan --api-key ` +2. 若未配置,在**用户本机终端**执行 `bl auth login --console`(国际站执行 `bl auth login --console --console-site international`);需要时会自动创建普通 API Key。 +3. 如果使用 Token Plan 等订阅计划,不能使用 `--console`,请执行 `bl auth login --api-key `。 +4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整凭据。 ### Agent 安全约束 diff --git a/README.md b/README.md index aacba39fe..a041ca8f9 100644 --- a/README.md +++ b/README.md @@ -124,28 +124,26 @@ Once installed, just describe your task to your AI Agent — no need to assemble ## Authentication -### API Key +### Console Login (OAuth, Recommended) -Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key). +Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). It can create an ordinary API key when needed. Token Plan and other subscription plans cannot use `--console`; use the API Key login below. ```bash -bl auth login --api-key sk-xxxxx +bl auth login --console ``` -Get or copy your Token Plan API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview). - -```bash -bl auth login --config token-plan --api-key sk-sp-xxxxx -``` +Use `--console-site international` for the international console. -### Console Login (OAuth) +### API Key -Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in. +To use an existing ordinary API key or a Token Plan subscription key, run the same command: ```bash -bl auth login --console +bl auth login --api-key ``` +Get an ordinary key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key), or a Token Plan key from the [subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview). + ### Alibaba Cloud OpenAPI AK/SK Token Plan seat and member management requires an Alibaba Cloud AccessKey. Get yours from the [RAM Console](https://ram.console.aliyun.com/manage/ak). diff --git a/README.zh.md b/README.zh.md index 446da2cf8..a82226756 100644 --- a/README.zh.md +++ b/README.zh.md @@ -123,28 +123,26 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex ## 认证方式 -### API Key +### 控制台登录(OAuth,推荐) -大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。 +控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式,并可在需要时创建普通 API Key。Token Plan 等订阅计划不能使用 `--console`,请使用下面的 API Key 登录方式。 ```bash -bl auth login --api-key sk-xxxxx +bl auth login --console ``` -Token Plan 的 API Key 前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制。 - -```bash -bl auth login --config token-plan --api-key sk-sp-xxxxx -``` +国际站请使用 `--console-site international`。 -### 控制台登录(OAuth) +### API Key -控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。 +使用已有的普通 API Key 或 Token Plan 订阅 Key,都执行同一条命令: ```bash -bl auth login --console +bl auth login --api-key ``` +普通 Key 前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取,Token Plan Key 前往 [订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取。 + ### 阿里云 OpenAPI AK/SK Token Plan 的席位与成员管理需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index 696279a87..8be0b17ae 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -33,8 +33,9 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx `~/.bailian/config.json` 可同时保存 `api_key`、`access_token` 与 `access_key_*`。登录任一种方式不得删除另一种: -- `bl auth login --api-key ...` 更新 `api_key`;显式 `base_url` 会一并写入,所选命名 Profile 若命中内置套餐预设(当前为 `token-plan`),则在尚未保存 `base_url` 时补写预设地址,并把该预设的默认模型物化写入。API Key 落盘成功后,`api_key_capabilities` 保留已有项并追加当前 preset 中缺少的项,不自动删除任何已有能力;无 preset 的自定义 Profile 不做合并。登录仍不得删除其他鉴权域的凭证 -- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段 +- `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 +- 未显式传 `--config` 时,`sk-sp-*` 自动写入并激活 `token-plan`;普通 `sk-*` / `sk-ws-*` 仅在当前为 `token-plan` 时改写并激活 `default`,否则保留当前 Profile;其他格式不自动切换。显式 `--config` 始终优先。`sk-sp-*` 无论写入哪个 Profile 都会携带 Token Plan 的预设默认模型,并在保留已有项的基础上补齐 `api_key_capabilities`,不自动删除任何已有能力。登录仍不得删除其他鉴权域的凭证 +- `bl auth login --console` 更新 `access_token` 以及回调携带的 console 作用域字段;若所选 Profile 没有模型 API Key,会要求控制台页面创建并回传一个普通 API Key 与 Base URL 一并保存。该回调结果不重复执行 `/models` 校验,且不会创建或配置 Token Plan - `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi` 与 `console` 域凭证 - `bl auth logout --console` 只清 `access_token` - `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token` diff --git a/packages/cli/README.md b/packages/cli/README.md index aacba39fe..a041ca8f9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -124,28 +124,26 @@ Once installed, just describe your task to your AI Agent — no need to assemble ## Authentication -### API Key +### Console Login (OAuth, Recommended) -Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key). +Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). It can create an ordinary API key when needed. Token Plan and other subscription plans cannot use `--console`; use the API Key login below. ```bash -bl auth login --api-key sk-xxxxx +bl auth login --console ``` -Get or copy your Token Plan API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview). - -```bash -bl auth login --config token-plan --api-key sk-sp-xxxxx -``` +Use `--console-site international` for the international console. -### Console Login (OAuth) +### API Key -Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in. +To use an existing ordinary API key or a Token Plan subscription key, run the same command: ```bash -bl auth login --console +bl auth login --api-key ``` +Get an ordinary key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key), or a Token Plan key from the [subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview). + ### Alibaba Cloud OpenAPI AK/SK Token Plan seat and member management requires an Alibaba Cloud AccessKey. Get yours from the [RAM Console](https://ram.console.aliyun.com/manage/ak). diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 446da2cf8..a82226756 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -123,28 +123,26 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex ## 认证方式 -### API Key +### 控制台登录(OAuth,推荐) -大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。 +控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式,并可在需要时创建普通 API Key。Token Plan 等订阅计划不能使用 `--console`,请使用下面的 API Key 登录方式。 ```bash -bl auth login --api-key sk-xxxxx +bl auth login --console ``` -Token Plan 的 API Key 前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制。 - -```bash -bl auth login --config token-plan --api-key sk-sp-xxxxx -``` +国际站请使用 `--console-site international`。 -### 控制台登录(OAuth) +### API Key -控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。 +使用已有的普通 API Key 或 Token Plan 订阅 Key,都执行同一条命令: ```bash -bl auth login --console +bl auth login --api-key ``` +普通 Key 前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取,Token Plan Key 前往 [订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取。 + ### 阿里云 OpenAPI AK/SK Token Plan 的席位与成员管理需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。 diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index f31e9936a..5a3f8a629 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -1,6 +1,22 @@ -import { normalizeModelBaseUrl, type AuthPersistPatch, type AuthStore } from "bailian-cli-core"; +import { + BailianError, + ExitCode, + getApiKeyLoginKind, + normalizeModelBaseUrl, + requestJson, + type ApiKeyLoginKind, + type AuthPersistPatch, + type AuthStore, + type Identity, + type Settings, +} from "bailian-cli-core"; -interface ApiKeyLoginDeps { +interface ApiKeyValidationDeps { + identity: Identity; + settings: Settings; +} + +interface ApiKeyLoginDeps extends ApiKeyValidationDeps { authStore: AuthStore; } @@ -17,10 +33,173 @@ interface ApiKeyLoginProfile { persistPatch?: AuthPersistPatch; } +type ApiKeyEndpointKind = Exclude; + +interface ApiKeyValidationCandidate { + baseUrl: string; + kind: ApiKeyEndpointKind; + modelsUrl: string; +} + +export interface ApiKeyValidationOptions { + explicitBaseUrl?: string; + storedBaseUrl?: string; + workspaceId?: string; +} + +export interface ApiKeyValidationResult { + baseUrl: string; + kind: ApiKeyEndpointKind; +} + +const API_KEY_VALIDATION_TIMEOUT_SECONDS = 5; +const DEFINITIVE_VALIDATION_FAILURE_STATUSES = new Set([200, 400, 401, 403, 404, 405]); +const ORDINARY_WORKSPACE_REGIONS = [ + "cn-beijing", + "ap-southeast-1", + "cn-hongkong", + "ap-northeast-1", + "eu-central-1", + "us-east-1", +] as const; + +const KNOWN_BASE_URLS: Readonly> = { + ordinary: [ + "https://dashscope.aliyuncs.com", + "https://dashscope-intl.aliyuncs.com", + "https://dashscope-us.aliyuncs.com", + "https://cn-hongkong.dashscope.aliyuncs.com", + ], + "token-plan": [ + "https://token-plan.cn-beijing.maas.aliyuncs.com", + "https://token-plan.ap-southeast-1.maas.aliyuncs.com", + ], +}; + +function modelsUrl(baseUrl: string, kind: ApiKeyEndpointKind): string { + const url = new URL( + kind === "token-plan" ? "/compatible-mode/v1/models" : "/api/v1/models", + `${baseUrl}/`, + ); + if (kind === "ordinary") { + url.searchParams.set("page_no", "1"); + url.searchParams.set("page_size", "1"); + } + return url.toString(); +} + +function validationKinds(apiKeyKind: ApiKeyLoginKind): ApiKeyEndpointKind[] { + if (apiKeyKind === "unknown") return ["ordinary", "token-plan"]; + return [apiKeyKind]; +} + +function isTokenPlanBaseUrl(baseUrl: string): boolean { + return new URL(normalizeModelBaseUrl(baseUrl)).hostname.startsWith("token-plan."); +} + +function addCandidate( + candidates: ApiKeyValidationCandidate[], + seenUrls: Set, + baseUrl: string, + kind: ApiKeyEndpointKind, +): void { + const normalizedBaseUrl = normalizeModelBaseUrl(baseUrl); + const url = modelsUrl(normalizedBaseUrl, kind); + if (seenUrls.has(url)) return; + seenUrls.add(url); + candidates.push({ baseUrl: normalizedBaseUrl, kind, modelsUrl: url }); +} + +export function apiKeyValidationCandidates( + key: string, + options: ApiKeyValidationOptions, +): ApiKeyValidationCandidate[] { + const apiKeyKind = getApiKeyLoginKind(key); + const kinds = validationKinds(apiKeyKind); + const candidates: ApiKeyValidationCandidate[] = []; + const seenUrls = new Set(); + + if (options.explicitBaseUrl) { + for (const kind of kinds) addCandidate(candidates, seenUrls, options.explicitBaseUrl, kind); + return candidates; + } + + if (options.storedBaseUrl) { + const storedIsTokenPlan = isTokenPlanBaseUrl(options.storedBaseUrl); + for (const kind of kinds) { + if (apiKeyKind === "unknown" || storedIsTokenPlan === (kind === "token-plan")) { + addCandidate(candidates, seenUrls, options.storedBaseUrl, kind); + } + } + } + + if (options.workspaceId && kinds.includes("ordinary")) { + for (const region of ORDINARY_WORKSPACE_REGIONS) { + addCandidate( + candidates, + seenUrls, + `https://${options.workspaceId}.${region}.maas.aliyuncs.com`, + "ordinary", + ); + } + } + + for (const kind of kinds) { + for (const baseUrl of KNOWN_BASE_URLS[kind]) { + addCandidate(candidates, seenUrls, baseUrl, kind); + } + } + return candidates; +} + +function isDefinitiveCandidateFailure(error: unknown): boolean { + if (!(error instanceof BailianError)) return false; + const status = error.api?.httpStatus; + return status !== undefined && DEFINITIVE_VALIDATION_FAILURE_STATUSES.has(status); +} + +/** Validate a key without consuming model quota and return the first matching endpoint. */ +export async function validateApiKey( + deps: ApiKeyValidationDeps, + key: string, + options: ApiKeyValidationOptions, +): Promise { + const candidates = apiKeyValidationCandidates(key, options); + const results = await Promise.all( + candidates.map(async (candidate) => { + try { + await requestJson(deps, { + url: candidate.modelsUrl, + headers: { Authorization: `Bearer ${key}` }, + timeout: Math.min(deps.settings.timeout, API_KEY_VALIDATION_TIMEOUT_SECONDS), + }); + return { candidate }; + } catch (error) { + return { candidate, error }; + } + }), + ); + + const success = results.find((result) => result.error === undefined); + if (success) return { baseUrl: success.candidate.baseUrl, kind: success.candidate.kind }; + + const inconclusive = results.find((result) => !isDefinitiveCandidateFailure(result.error)); + if (inconclusive?.error !== undefined) throw inconclusive.error; + + const explicitHint = options.explicitBaseUrl + ? "Check that the API key belongs to this Base URL." + : "For a workspace-specific or custom endpoint, retry with --base-url ."; + throw new BailianError( + "API key validation failed: the key was not accepted by any supported model endpoint.", + ExitCode.AUTH, + explicitHint, + ); +} + /** - * Persist an API key (and optional profile defaults) without a live model probe. - * Login is credential storage; connectivity is verified on the first API command. - * A former chat/completions smoke test conflated quota/model-access 403s with bad keys. + * Persist an already-trusted API key and optional profile defaults. + * Console login uses this directly because its callback already supplies the key and endpoint; + * direct API-key login must call validateApiKey first. */ export async function persistApiKey( deps: ApiKeyLoginDeps, diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index a565fde73..b4c9554c7 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -5,7 +5,7 @@ import { normalizeModelBaseUrl, } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; -import { persistApiKey } from "./login-api-key.ts"; +import { persistApiKey, validateApiKey } from "./login-api-key.ts"; import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; const LOGIN_MODE_HINT = "Choose exactly one login mode: --api-key, --console, or --open-api"; @@ -27,22 +27,26 @@ export default defineCommand({ apiKey: { type: "string", valueHint: "", - description: { "en-US": "Model API key to store", "zh-CN": "要保存的模型 API Key" }, + description: { + "en-US": "Model API key to validate and store", + "zh-CN": "要校验并保存的模型 API Key", + }, }, baseUrl: { type: "string", valueHint: "", description: { - "en-US": "Model API base URL to store with --api-key", - "zh-CN": "与 --api-key 一并保存的模型 API Base URL", + "en-US": "Model API base URL to validate and store with --api-key", + "zh-CN": "与 --api-key 一并校验并保存的模型 API Base URL", }, }, console: { type: "switch", description: { "en-US": - "Sign in via browser; use --console-site to choose domestic (default) or international", - "zh-CN": "通过浏览器登录;使用 --console-site 选择国内站(默认)或国际站", + "Sign in via browser (China by default; use --console-site international for the international site). Creates an ordinary API key if needed; subscription plans require --api-key", + "zh-CN": + "通过浏览器登录(默认中国站;国际站使用 --console-site international)。需要时会创建普通 API Key;订阅计划必须使用 --api-key 登录", }, }, consoleSite: { @@ -78,9 +82,8 @@ export default defineCommand({ }, }, exampleArgs: [ - "--api-key sk-xxxxx", - "--config token-plan --api-key sk-sp-xxxxx", "--console", + "--api-key sk-xxxxx", "--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx", ], validate: (f) => { @@ -164,18 +167,20 @@ export default defineCommand({ if (!key) return; if (settings.dryRun) { - emitBare("Would save API key."); + emitBare("Would validate and save API key."); return; } - const profilePreset = getModelProfilePreset(settings.configName); const stored = store.stored(); - const storedBaseUrl = stored.baseUrl; - const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined); - const apiKeyCapabilities = profilePreset - ? [...new Set([...(stored.apiKeyCapabilities ?? []), ...profilePreset.apiKeyCapabilities])] - : stored.apiKeyCapabilities; + const validation = await validateApiKey(deps, key, { + explicitBaseUrl: baseUrl, + storedBaseUrl: stored.baseUrl, + workspaceId: settings.workspaceId, + }); + const profilePreset = getModelProfilePreset( + validation.kind === "token-plan" ? "token-plan" : undefined, + ); await persistApiKey(deps, key, { - persistBaseUrl, + persistBaseUrl: validation.baseUrl, defaultTextModel: profilePreset?.defaultTextModel, defaultVideoModel: profilePreset?.defaultVideoModel, defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel, @@ -183,8 +188,8 @@ export default defineCommand({ defaultImageModel: profilePreset?.defaultImageModel, defaultSpeechModel: profilePreset?.defaultSpeechModel, defaultSpeechRecognitionModel: profilePreset?.defaultSpeechRecognitionModel, - apiKeyCapabilities, + apiKeyCapabilities: profilePreset?.apiKeyCapabilities, }); - process.stderr.write(`API key saved to ${store.path}\n`); + process.stderr.write(`API key validated and saved to ${store.path}\n`); }, }); diff --git a/packages/commands/tests/auth-api-key.test.ts b/packages/commands/tests/auth-api-key.test.ts new file mode 100644 index 000000000..84f9fd47b --- /dev/null +++ b/packages/commands/tests/auth-api-key.test.ts @@ -0,0 +1,175 @@ +import { BailianError, ExitCode, type Identity, type Settings } from "bailian-cli-core"; +import { expect, test } from "vite-plus/test"; +import { + apiKeyValidationCandidates, + validateApiKey, + type ApiKeyValidationOptions, +} from "../src/commands/auth/login-api-key.ts"; + +const identity: Identity = { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", +}; + +const settings: Settings = { + output: "text", + outputExplicit: false, + timeout: 30, + verbose: false, + quiet: false, + dryRun: false, + watermark: true, + telemetry: false, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function requestUrl(input: string | URL | Request): string { + if (typeof input === "string") return input; + return input instanceof URL ? input.href : input.url; +} + +function candidateUrls(key: string, options: ApiKeyValidationOptions = {}): string[] { + return apiKeyValidationCandidates(key, options).map((candidate) => candidate.modelsUrl); +} + +async function captureValidationError(key: string): Promise { + const error = await validateApiKey({ identity, settings }, key, {}).catch((caught) => caught); + expect(error).toBeInstanceOf(BailianError); + return error as BailianError; +} + +test("sk-sp Key 只探测国内与国际 Token Plan /models", () => { + expect(candidateUrls("sk-sp-placeholder")).toEqual([ + "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/models", + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models", + ]); +}); + +test("普通 sk / sk-ws Key 探测公共地域,且不会探测 Token Plan", () => { + const expectedUrls = [ + "https://dashscope.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://dashscope-intl.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://dashscope-us.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://cn-hongkong.dashscope.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + ]; + expect(candidateUrls("sk-placeholder")).toEqual(expectedUrls); + expect(candidateUrls("sk-ws-placeholder")).toEqual(expectedUrls); +}); + +test("普通 Key 优先使用已保存地址,再尝试 Workspace 专属地址和公共地址", () => { + expect( + candidateUrls("sk-placeholder", { + storedBaseUrl: "https://stored.example.com/api/v1", + workspaceId: "llm-test-workspace", + }), + ).toEqual([ + "https://stored.example.com/api/v1/models?page_no=1&page_size=1", + "https://llm-test-workspace.cn-beijing.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://llm-test-workspace.ap-southeast-1.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://llm-test-workspace.cn-hongkong.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://llm-test-workspace.ap-northeast-1.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://llm-test-workspace.eu-central-1.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://llm-test-workspace.us-east-1.maas.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://dashscope.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://dashscope-intl.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://dashscope-us.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + "https://cn-hongkong.dashscope.aliyuncs.com/api/v1/models?page_no=1&page_size=1", + ]); +}); + +test("其他格式 Key 不限制端点类型", () => { + expect( + apiKeyValidationCandidates("custom-key-placeholder", {}).map((candidate) => candidate.kind), + ).toEqual(["ordinary", "ordinary", "ordinary", "ordinary", "token-plan", "token-plan"]); +}); + +test("显式 Base URL 只校验指定站点,并根据 Key 类型选择 /models 路径", () => { + expect( + candidateUrls("sk-sp-placeholder", { + explicitBaseUrl: "https://custom.example.com/apps/anthropic?source=test", + }), + ).toEqual(["https://custom.example.com/compatible-mode/v1/models"]); + expect( + candidateUrls("sk-placeholder", { + explicitBaseUrl: "https://workspace.example.com/compatible-mode/v1", + }), + ).toEqual(["https://workspace.example.com/api/v1/models?page_no=1&page_size=1"]); +}); + +test("Token Plan 多地区并行校验选择实际接受 Key 的国际站点", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ authorization: string; url: string }> = []; + globalThis.fetch = async (input, init) => { + const url = requestUrl(input); + requests.push({ + authorization: new Headers(init?.headers).get("authorization") ?? "", + url, + }); + if (url.startsWith("https://token-plan.ap-southeast-1.maas.aliyuncs.com/")) { + return jsonResponse({ data: [{ id: "qwen3.8-plus" }] }); + } + return jsonResponse({ code: "InvalidApiKey", message: "invalid key" }, 401); + }; + + try { + await expect(validateApiKey({ identity, settings }, "sk-sp-placeholder", {})).resolves.toEqual({ + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com", + kind: "token-plan", + }); + expect(requests).toEqual([ + { + authorization: "Bearer sk-sp-placeholder", + url: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/models", + }, + { + authorization: "Bearer sk-sp-placeholder", + url: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models", + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("所有候选站点明确拒绝 Key 时返回 AUTH 校验失败", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + jsonResponse({ code: "InvalidApiKey", message: "invalid key" }, 401); + + try { + const error = await captureValidationError("sk-sp-placeholder"); + expect(error.exitCode).toBe(ExitCode.AUTH); + expect(error.message).toMatch(/API key validation failed/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test.each([ + { name: "5xx", status: 503, message: "temporary outage" }, + { name: "429", status: 429, message: "too many requests" }, +])("存在 $name 时校验结论不确定,不误报 Key 无效", async ({ status, message }) => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + if (requestUrl(input).startsWith("https://dashscope.aliyuncs.com/")) { + return jsonResponse({ code: "TemporaryFailure", message }, status); + } + return jsonResponse({ code: "InvalidApiKey", message: "invalid key" }, 401); + }; + + try { + const error = await captureValidationError("sk-placeholder"); + expect(error.exitCode).toBe(ExitCode.GENERAL); + expect(error.message).toBe(message); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index fcaee69ce..2c0cd22ee 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,7 +1,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { createServer, type Server } from "http"; import { tmpdir } from "os"; import { join } from "path"; -import { describe, expect, test } from "vite-plus/test"; +import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; import { isDashScopeE2EReady, isOpenApiE2EReady, @@ -14,13 +15,44 @@ import { AUTH_ROUTES } from "./topic-routes.ts"; /** Auth E2E:本地参数/持久化契约默认执行;真实鉴权请求按对应 readiness gate 执行。 */ +let modelsServer: Server; +let modelsOrigin: string; + +beforeAll(async () => { + modelsServer = createServer((request, response) => { + if (request.method !== "GET" || !request.url?.includes("/models")) { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ code: "NotFound", message: "models route required" })); + return; + } + if (request.headers.authorization === "Bearer sk-rejected-placeholder") { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ code: "InvalidApiKey", message: "invalid key" })); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ data: [{ model: "qwen-plus" }] })); + }); + await new Promise((resolveListen) => modelsServer.listen(0, "127.0.0.1", resolveListen)); + const address = modelsServer.address(); + if (!address || typeof address === "string") throw new Error("models server did not start"); + modelsOrigin = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolveClose) => modelsServer.close(() => resolveClose())); +}); + describe("e2e: auth", () => { test("auth login --help 正常退出", async () => { const { stderr, exitCode } = await runCommandHelp(AUTH_ROUTES, ["auth", "login", "--help"]); expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/login|api-key/i); - expect(stderr).toMatch(/--console-site/); + expect(stderr).toMatch(/--console-site.*ordinary API key/i); + expect(stderr).toMatch(/subscription plans.*--api-key/i); expect(stderr).toMatch(/--open-api/); + expect(stderr).toContain("bl auth login --console\n"); + expect(stderr).not.toContain("bl auth login --console --console-site domestic"); }); test("auth login 一次只能选择一种登录模式", async () => { @@ -135,7 +167,7 @@ describe("e2e: auth", () => { "sk-e2e-dry-run-placeholder", ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("Would save API key."); + expect(stdout).toContain("Would validate and save API key."); }); test("auth login --dry-run 仍校验显式 Base URL", async () => { @@ -152,13 +184,18 @@ describe("e2e: auth", () => { expect(stderr).toMatch(/Invalid model base URL/); }); - test("auth login --api-key 原子保存凭证和 Base URL(无联网探测)", async () => { + test("auth login --api-key 通过 /models 校验后原子保存凭证和 Base URL", async () => { const configDir = makeE2eOutputDir("auth-api-key-login"); - const origin = "https://dashscope.example.test"; - const sdkBaseUrl = `${origin}/compatible-mode/v1/?source=login#fragment`; const login = await runCommandE2e( AUTH_ROUTES, - ["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl], + [ + "auth", + "login", + "--api-key", + "sk-e2e-placeholder", + "--base-url", + `${modelsOrigin}/compatible-mode/v1/?source=login#fragment`, + ], { BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", @@ -166,19 +203,18 @@ describe("e2e: auth", () => { }, ); expect(login.exitCode, login.stderr).toBe(0); - expect(login.stderr).toMatch(/API key saved to/); + expect(login.stderr).toMatch(/API key validated and saved to/); const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< string, unknown >; expect(config.api_key).toBe("sk-e2e-placeholder"); - expect(config.base_url).toBe(origin); + expect(config.base_url).toBe(modelsOrigin); }); test("auth login --config token-plan 接受 Anthropic SDK Base URL", async () => { const configDir = makeE2eOutputDir("auth-token-plan-anthropic-base-url"); - const origin = "https://token-plan.example.test"; const login = await runCommandE2e( AUTH_ROUTES, [ @@ -189,7 +225,7 @@ describe("e2e: auth", () => { "--api-key", "sk-sp-e2e-placeholder", "--base-url", - `${origin}/apps/anthropic?source=sdk#fragment`, + `${modelsOrigin}/apps/anthropic?source=sdk#fragment`, ], { BAILIAN_CONFIG_DIR: configDir, @@ -205,7 +241,7 @@ describe("e2e: auth", () => { >; expect(config["token-plan"]).toMatchObject({ api_key: "sk-sp-e2e-placeholder", - base_url: origin, + base_url: modelsOrigin, default_text_model: "qwen3.8-max", default_video_model: "happyhorse-1.1-t2v", default_image_to_video_model: "happyhorse-1.1-i2v", @@ -250,7 +286,16 @@ describe("e2e: auth", () => { const login = await runCommandE2e( AUTH_ROUTES, - ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], + [ + "auth", + "login", + "--config", + "token-plan", + "--api-key", + "sk-sp-e2e-placeholder", + "--base-url", + modelsOrigin, + ], { BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted", @@ -267,7 +312,7 @@ describe("e2e: auth", () => { expect(config.active_config).toBe("token-plan"); expect(config["token-plan"]).toMatchObject({ api_key: "sk-sp-e2e-placeholder", - base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + base_url: modelsOrigin, default_text_model: "qwen3.8-max", default_video_model: "happyhorse-1.1-t2v", default_image_to_video_model: "happyhorse-1.1-i2v", @@ -297,6 +342,104 @@ describe("e2e: auth", () => { ); }); + test("auth login 根据 sk-sp 前缀自动写入并激活 Token Plan Profile", async () => { + const configDir = makeE2eOutputDir("auth-token-plan-key-inference"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify( + { + active_config: "dev", + dev: { base_url: "https://dev-profile.example.test" }, + }, + null, + 2, + ) + "\n", + ); + + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--api-key", "sk-sp-e2e-inferred", "--base-url", modelsOrigin], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.active_config).toBe("token-plan"); + expect(config.api_key).toBeUndefined(); + expect(config.dev).toEqual({ base_url: "https://dev-profile.example.test" }); + expect(config["token-plan"]).toMatchObject({ + api_key: "sk-sp-e2e-inferred", + base_url: modelsOrigin, + default_text_model: "qwen3.8-max", + api_key_capabilities: [ + "text.chat", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], + }); + }); + + test("auth login 显式 Profile 和 Base URL 优先且保留 sk-sp 的 Token Plan 预设", async () => { + const configDir = makeE2eOutputDir("auth-explicit-profile-precedence"); + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--config", + "custom-plan", + "--api-key", + "sk-sp-e2e-explicit", + "--base-url", + `${modelsOrigin}/compatible-mode/v1`, + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.active_config).toBe("custom-plan"); + expect(config["custom-plan"]).toMatchObject({ + api_key: "sk-sp-e2e-explicit", + base_url: modelsOrigin, + default_text_model: "qwen3.8-max", + api_key_capabilities: [ + "text.chat", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], + }); + expect(config["token-plan"]).toBeUndefined(); + }); + test("auth login --config token-plan 为显式空白名单追加 capability preset", async () => { const configDir = makeE2eOutputDir("auth-token-plan-empty-capabilities-login"); writeFileSync( @@ -314,7 +457,16 @@ describe("e2e: auth", () => { const login = await runCommandE2e( AUTH_ROUTES, - ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], + [ + "auth", + "login", + "--config", + "token-plan", + "--api-key", + "sk-sp-e2e-placeholder", + "--base-url", + modelsOrigin, + ], { BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", @@ -343,9 +495,9 @@ describe("e2e: auth", () => { }); }); - test("auth login 未传 --config 时写当前激活 Config", async () => { + test("auth login 普通 Key 保留当前激活的非 Token Plan Profile", async () => { const configDir = makeE2eOutputDir("auth-active-profile-login"); - const storedBaseUrl = "https://dev-profile.example.test"; + const storedBaseUrl = modelsOrigin; writeFileSync( join(configDir, "config.json"), JSON.stringify( @@ -376,12 +528,70 @@ describe("e2e: auth", () => { >; expect(config.api_key).toBeUndefined(); expect(config.active_config).toBe("dev"); - expect(config.dev).toMatchObject({ + expect(config.dev).toEqual({ api_key: "sk-active-placeholder", base_url: storedBaseUrl, }); }); + test("auth login 普通 Key 校验成功后为新 Profile 保存匹配的 Base URL", async () => { + const configDir = makeE2eOutputDir("auth-standard-key-without-base-url"); + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--config", + "standard", + "--api-key", + "sk-standard-placeholder", + "--base-url", + modelsOrigin, + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.active_config).toBe("standard"); + expect(config["standard"]).toEqual({ + api_key: "sk-standard-placeholder", + base_url: modelsOrigin, + }); + }); + + test("auth login /models 拒绝 Key 时不写配置、不激活 Profile", async () => { + const configDir = makeE2eOutputDir("auth-api-key-validation-rejected"); + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--config", + "rejected", + "--api-key", + "sk-rejected-placeholder", + "--base-url", + modelsOrigin, + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode).toBe(3); + expect(login.stderr).toMatch(/API key validation failed/); + expect(existsSync(join(configDir, "config.json"))).toBe(false); + }); + test("auth login --api-key 非法 Base URL 失败时不留下半配置", async () => { const configDir = makeE2eOutputDir("auth-api-key-login-failure"); const login = await runCommandE2e( @@ -420,7 +630,7 @@ describe("e2e: auth", () => { "120", ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("Would save API key."); + expect(stdout).toContain("Would validate and save API key."); }); test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => { diff --git a/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts b/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts index 5e1b58d53..88cc0366c 100644 --- a/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts +++ b/packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts @@ -127,6 +127,25 @@ describe("e2e: Sandbox shared base URL resolution", () => { }); test("auth login persists a base URL that Sandbox uses without a workspace flag", async () => { + const requests: Array<{ authorization?: string; method?: string; path?: string }> = []; + const server = createServer((request, response) => { + request.resume(); + requests.push({ + authorization: request.headers.authorization, + method: request.method, + path: request.url, + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ data: [{ id: "qwen-plus" }] })); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a local TCP server."); + const origin = `http://127.0.0.1:${address.port}`; const env = makeConfigEnv(); const login = await runCommandE2e( ROUTES, @@ -138,14 +157,21 @@ describe("e2e: Sandbox shared base URL resolution", () => { "--api-key", "sk-login-test", "--base-url", - "https://login.example.test/api/v1/agentstudio/sandbox/", + `${origin}/api/v1/agentstudio/sandbox/`, ], env, ); expect(login.exitCode, login.stderr).toBe(0); + expect(requests).toEqual([ + { + authorization: "Bearer sk-login-test", + method: "GET", + path: "/api/v1/models?page_no=1&page_size=1", + }, + ]); const stored = JSON.parse(readFileSync(join(env.BAILIAN_CONFIG_DIR!, "config.json"), "utf8")); expect(stored.active_config).toBe("sandbox-test"); - expect(stored["sandbox-test"].base_url).toBe("https://login.example.test"); + expect(stored["sandbox-test"].base_url).toBe(origin); const result = await runCommandE2e( ROUTES, @@ -154,7 +180,7 @@ describe("e2e: Sandbox shared base URL resolution", () => { ); expect(result.exitCode, result.stderr).toBe(0); expect(parseStdoutJson(result.stdout)).toMatchObject({ - endpoint: `https://login.example.test${API_PATH}/sandboxes/sbx-test/pause`, + endpoint: `${origin}${API_PATH}/sandboxes/sbx-test/pause`, }); }); }); diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 9ac4d8617..77ba42f70 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -3,6 +3,7 @@ import type { ResolutionSources } from "../config/loader.ts"; import { readConfigFile, writeConfigFile } from "../config/loader.ts"; import { getConfigPath } from "../config/paths.ts"; import { normalizeModelBaseUrl } from "../config/model-base-url.ts"; +import { resolveApiKeyLoginConfigName } from "../config/profile-presets.ts"; import type { AuthState } from "./types.ts"; import { describeAuthState, resolveModelBaseUrl } from "./resolver.ts"; @@ -88,13 +89,23 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore { }, resolveBaseUrl: (fallback) => resolveModelBaseUrl(sources, fallback), async login(patch) { - const existing = readConfigFile(configName) as Record; + const targetConfigName = patch.api_key + ? resolveApiKeyLoginConfigName(patch.api_key, configName, activateAfterLogin) + : configName; + const shouldActivate = activateAfterLogin || targetConfigName !== configName; + const existing = readConfigFile(targetConfigName) as Record; + for (const [key, value] of Object.entries(patch)) { if (value !== undefined) { - existing[key] = key === "base_url" ? normalizeModelBaseUrl(String(value)) : value; + if (key === "api_key_capabilities" && Array.isArray(value)) { + const storedCapabilities = Array.isArray(existing[key]) ? existing[key] : []; + existing[key] = [...new Set([...storedCapabilities, ...value])]; + } else { + existing[key] = key === "base_url" ? normalizeModelBaseUrl(String(value)) : value; + } } } - await writeConfigFile(existing, configName, { activate: activateAfterLogin }); + await writeConfigFile(existing, targetConfigName, { activate: shouldActivate }); }, async logout(scope) { const existing = readConfigFile(configName) as Record; diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 422b7bbf7..54bcd0c48 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -28,5 +28,9 @@ export { } from "./loader.ts"; export { makeConfigStore, type ConfigStore } from "./store.ts"; export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts"; -export { getModelProfilePreset } from "./profile-presets.ts"; +export { + getApiKeyLoginKind, + getModelProfilePreset, + type ApiKeyLoginKind, +} from "./profile-presets.ts"; export { normalizeModelBaseUrl } from "./model-base-url.ts"; diff --git a/packages/core/src/config/profile-presets.ts b/packages/core/src/config/profile-presets.ts index adfa11349..2f6f05c5e 100644 --- a/packages/core/src/config/profile-presets.ts +++ b/packages/core/src/config/profile-presets.ts @@ -10,6 +10,8 @@ interface ModelProfilePreset { apiKeyCapabilities: readonly string[]; } +export type ApiKeyLoginKind = "token-plan" | "ordinary" | "unknown"; + const MODEL_PROFILE_PRESETS: Readonly> = { "token-plan": { baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", @@ -39,3 +41,24 @@ const MODEL_PROFILE_PRESETS: Readonly> = { export function getModelProfilePreset(configName?: string): ModelProfilePreset | undefined { return configName ? MODEL_PROFILE_PRESETS[configName] : undefined; } + +/** Classify only the key formats that affect automatic Profile routing. */ +export function getApiKeyLoginKind(apiKey: string): ApiKeyLoginKind { + const normalizedApiKey = apiKey.trim(); + if (normalizedApiKey.startsWith("sk-sp-")) return "token-plan"; + if (normalizedApiKey.startsWith("sk-")) return "ordinary"; + return "unknown"; +} + +/** Resolve only the Profile target; API Key defaults remain owned by the login flow. */ +export function resolveApiKeyLoginConfigName( + apiKey: string, + selectedConfigName: string | undefined, + configExplicit: boolean, +): string | undefined { + if (configExplicit) return selectedConfigName; + const apiKeyKind = getApiKeyLoginKind(apiKey); + if (apiKeyKind === "token-plan") return "token-plan"; + if (apiKeyKind === "ordinary" && selectedConfigName === "token-plan") return undefined; + return selectedConfigName; +} diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index a49ae24ad..9d95c9aff 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -144,6 +144,58 @@ test("AuthStore:未传 --config 时写当前激活项,显式配置在登录成 }); }); +test.each([ + { + name: "普通 sk Key 避开 token-plan", + activeConfig: "token-plan", + apiKey: "sk-standard", + targetConfig: undefined, + }, + { + name: "普通 sk-ws Key 避开 token-plan", + activeConfig: "token-plan", + apiKey: "sk-ws-workspace", + targetConfig: undefined, + }, + { + name: "普通 Key 保留非 token-plan Profile", + activeConfig: "dev", + apiKey: "sk-standard", + targetConfig: "dev", + }, + { + name: "其他格式 Key 不限制 Profile", + activeConfig: "token-plan", + apiKey: "custom-key", + targetConfig: "token-plan", + }, +])("AuthStore:API Key 路由规则:$name", async (testCase) => { + await inTempConfigDir(async () => { + await writeConfigFile( + { + api_key: `existing-${testCase.activeConfig}`, + base_url: + testCase.activeConfig === "token-plan" + ? "https://token-plan.cn-beijing.maas.aliyuncs.com" + : "https://dev.example.test", + }, + testCase.activeConfig, + ); + await activateConfigProfile(testCase.activeConfig); + + const store = makeAuthStore(buildSources({})); + await store.login({ api_key: testCase.apiKey }); + + expect(readConfigFile(testCase.targetConfig).api_key).toBe(testCase.apiKey); + expect(readConfigProfiles().active).toBe(testCase.targetConfig ?? "default"); + if (testCase.targetConfig !== "token-plan") { + expect(readConfigFile("token-plan").api_key).toBe( + testCase.activeConfig === "token-plan" ? "existing-token-plan" : undefined, + ); + } + }); +}); + test("Console access token 自动刷新只读取当前选中 Config 的 AK/SK", async () => { await inTempConfigDir(async () => { await writeConfigFile({ diff --git a/packages/runtime/src/output/banner.ts b/packages/runtime/src/output/banner.ts index a5adf0455..368dae521 100644 --- a/packages/runtime/src/output/banner.ts +++ b/packages/runtime/src/output/banner.ts @@ -1,14 +1,16 @@ -import { API_KEY_PAGE, TOKEN_PLAN_PAGE } from "../urls.ts"; import { DEFAULT_LANGUAGE, type Language, type LocalizedText } from "bailian-cli-core"; import type { Translator } from "../i18n.ts"; import { ansi } from "./color.ts"; const WELCOME_TEXT = { title: { "en-US": "Welcome to Bailian CLI!", "zh-CN": "欢迎使用 Bailian CLI!" }, - getStarted: { "en-US": "Get started in 2 steps:", "zh-CN": "只需两步即可开始使用:" }, - getApiKey: { "en-US": "Get your API Key:", "zh-CN": "获取 API Key:" }, - login: { "en-US": "Login:", "zh-CN": "登录:" }, - tokenPlan: { "en-US": "Token Plan:", "zh-CN": "Token Plan:" }, + consoleLogin: { "en-US": "Recommended: console login", "zh-CN": "推荐:控制台登录" }, + domestic: { "en-US": "China site:", "zh-CN": "中国站:" }, + international: { "en-US": "International:", "zh-CN": "国际站:" }, + apiKeyLogin: { + "en-US": "Existing API Key or subscription plan:", + "zh-CN": "已有 API Key 或订阅计划:", + }, } satisfies Record; function localize(translator: Translator | undefined, text: LocalizedText): string { @@ -22,18 +24,15 @@ export function printWelcomeBanner(cliName: string, translator?: Translator): vo color.purple("Bailian"), ); process.stderr.write(`\n ${title}\n\n`); - process.stderr.write(` ${localize(translator, WELCOME_TEXT.getStarted)}\n`); - process.stderr.write(` 1. ${localize(translator, WELCOME_TEXT.getApiKey)} ${API_KEY_PAGE}\n`); + process.stderr.write(` ${localize(translator, WELCOME_TEXT.consoleLogin)}\n`); process.stderr.write( - ` 2. ${localize(translator, WELCOME_TEXT.login)} ${cliName} auth login --api-key \n\n`, + ` ${localize(translator, WELCOME_TEXT.domestic)} ${cliName} auth login --console\n`, ); - process.stderr.write(` ${localize(translator, WELCOME_TEXT.tokenPlan)}\n`); process.stderr.write( - ` 1. ${localize(translator, WELCOME_TEXT.getApiKey)} ${TOKEN_PLAN_PAGE}\n`, - ); - process.stderr.write( - ` 2. ${localize(translator, WELCOME_TEXT.login)} ${cliName} auth login --config token-plan --api-key \n\n`, + ` ${localize(translator, WELCOME_TEXT.international)} ${cliName} auth login --console --console-site international\n\n`, ); + process.stderr.write(` ${localize(translator, WELCOME_TEXT.apiKeyLogin)}\n`); + process.stderr.write(` ${cliName} auth login --api-key \n\n`); } export function printQuickStart( diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index eb6eab4fd..cef3f8187 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -130,4 +130,4 @@ schema-export commands. - For `risk: high` commands or `requires_confirmation`, follow the shared protocol; never add `--yes` automatically. - `bl managed-agent apply` / `destroy` have an additional domain rule: run `plan` first and show the diff before asking for confirmation. - When a matched `bl` command accepts a file URL, pass local paths directly; never require the user to host the file first. -- Console login → always `--console-site domestic|international`; see [`../bailian-protocol/assets/setup.md`](../bailian-protocol/assets/setup.md#console-site-selection). +- Console login → use `bl auth login --console` for China; add `--console-site international` for the international site. It may create an ordinary API Key. Token Plan and other subscription plans must use `bl auth login --api-key `. See [`../bailian-protocol/assets/setup.md`](../bailian-protocol/assets/setup.md#authentication). diff --git a/skills/bailian-cli/reference/auth.md b/skills/bailian-cli/reference/auth.md index a98d85682..c111f93de 100644 --- a/skills/bailian-cli/reference/auth.md +++ b/skills/bailian-cli/reference/auth.md @@ -50,28 +50,24 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx #### Flags -| Flag | Type | Required | Description | -| ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------- | -| `--api-key ` | string | no | Model API key to store | -| `--base-url ` | string | no | Model API base URL to store with --api-key | -| `--console` | switch | no | Sign in via browser; use --console-site to choose domestic (default) or international | -| `--console-site ` | string | no | Console site: domestic, international | -| `--open-api` | switch | no | Store Alibaba Cloud OpenAPI AK/SK credentials | -| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID to store | -| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret to store | +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--api-key ` | string | no | Model API key to validate and store | +| `--base-url ` | string | no | Model API base URL to validate and store with --api-key | +| `--console` | switch | no | Sign in via browser (China by default; use --console-site international for the international site). Creates an ordinary API key if needed; subscription plans require --api-key | +| `--console-site ` | string | no | Console site: domestic, international | +| `--open-api` | switch | no | Store Alibaba Cloud OpenAPI AK/SK credentials | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID to store | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret to store | #### Examples ```bash -bl auth login --api-key sk-xxxxx -``` - -```bash -bl auth login --config token-plan --api-key sk-sp-xxxxx +bl auth login --console ``` ```bash -bl auth login --console +bl auth login --api-key sk-xxxxx ``` ```bash diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 14c0cf819..b5e7e4d1d 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -78,9 +78,9 @@ After approval, treat Bailian as selected for the current task. Do not ask again Install, API key / console login, endpoint override, and config keys: [`assets/setup.md`](assets/setup.md). -**Token Plan:** Get the API key from the [subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview), then run `bl auth login --config token-plan --api-key `. The built-in Profile supplies the Base URL and its supported leaf API Key capabilities, and login saves the key without a live model probe. While that Profile is active, unsupported API Key commands automatically use `default` credentials. A per-command `--api-key` / `--base-url` or `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` bypasses this fallback entirely. +**Console login (recommended):** The browser flow may create an ordinary API Key. For the China site, run `bl auth login --console`; for the international site, add `--console-site international`. -**Console login:** never run bare `bl auth login --console` — always pass `--console-site domestic` or `--console-site international`. Before login, run `bl config show --output json` and follow the site-selection rules in [`assets/setup.md` → Console site selection](assets/setup.md#console-site-selection). +**API Key login:** Token Plan and other subscription plans cannot use `--console`. For them, or when the user already has an ordinary API Key, run `bl auth login --api-key `. ```bash bl auth status # check current auth diff --git a/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index 8c97458e5..3a75c3a94 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -52,12 +52,11 @@ Verify: `bl --version` (prints `bl X.Y.Z`). For the exact command-to-auth mapping, read the owning skill's `reference/index.md` or run `bl --help`. -| Auth | How | Used by | -| ------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------- | -| API key | `export DASHSCOPE_API_KEY=sk-...` or `bl auth login --api-key sk-...` | Most DashScope API commands | -| Token Plan API key | `bl auth login --config token-plan --api-key sk-sp-...` | Token Plan text, image, and video consumption | -| Console | `bl auth login --console --console-site domestic` or `... international` | `app list`, `usage free`, `console call` | -| OpenAPI AK/SK | `bl auth login --open-api --access-key-id --access-key-secret ` or Alibaba env vars | Token Plan management commands (`token-plan`) | +| Auth | How | Used by | +| ------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------- | +| Console | `bl auth login --console` or `... --console-site international` | Recommended; may create an ordinary API Key | +| API key | `bl auth login --api-key ` | Existing keys, including Token Plan consumption | +| OpenAPI AK/SK | `bl auth login --open-api --access-key-id --access-key-secret ` or Alibaba env vars | Token Plan management commands (`token-plan`) | ```bash bl auth status # check current auth @@ -75,16 +74,20 @@ bl auth logout --open-api # clear OpenAPI AK/SK only Get or copy the Token Plan API key from the [subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview). A `PlainApiKey` returned by `bl token-plan create-key` is the same credential type. It is separate from the OpenAPI AK/SK used by Token Plan management commands. +Token Plan and other subscription plans cannot use `--console`; log in directly with the subscription API Key: + ```bash -bl auth login --config token-plan --api-key sk-sp-xxx +bl auth login --api-key sk-sp-xxx bl text chat --message "Hello" bl image generate --prompt "A cat" bl video generate --prompt "A horse running through a field" ``` -The built-in Profile supplies the Token Plan Base URL. `auth login` saves the key and activates -the Profile without a live model probe; do not ask the user to configure the Base URL or run a -duplicate smoke test. +Ordinary API Keys use the same command: + +```bash +bl auth login --api-key +``` ### API Key capability fallback @@ -119,19 +122,18 @@ bl config list bl config use --name default ``` -`auth login --config token-plan` creates or updates that Profile and activates it only after the -credential is saved. Failed login and `--dry-run` do not switch Profiles. Use +API Key login selects and activates the matching built-in Profile only after the credential is saved. Failed login and `--dry-run` do not switch Profiles. Use `--config default` for a one-command override. Config selection follows explicit `--config` > persisted `active_config` > `default`; credential and endpoint fields inside the selected Profile 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` 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 +The built-in `token-plan` preset contains the following defaults. The CLI materializes them only after +a successful Token Plan API Key login. 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` +- Base URL: the matching domestic or international Token Plan site - Text model: `qwen3.8-max` - Image model: `wan2.7-image` - Text-to-video model (`default_video_model`): `happyhorse-1.1-t2v` @@ -147,14 +149,14 @@ The usual priority applies to this profile too: per-command `--api-key` / `--bas Console login and console-gateway commands (`app list`, `usage *`, `quota *`, `workspace list`, `console call`) target one of two Bailian consoles: +Console login may create an **ordinary** API Key. Token Plan and other subscription plans cannot use `--console`; run `bl auth login --api-key ` instead. + | Site | Value | Login URL | | ----------------- | --------------- | ---------------------------------------------- | | Domestic (中国站) | `domestic` | `https://bailian.console.aliyun.com` | | International | `international` | `https://modelstudio.console.alibabacloud.com` | -**Do not run bare `bl auth login --console`** — the CLI defaults to `domestic`. Always pass `--console-site` explicitly (or rely on a saved `console_site` in config). - -**Before console login**, run `bl config show --output json` and check `console_site`. +Use `bl auth login --console` for the China site. Add `--console-site international` for the international site. When the flag is omitted, a saved `console_site` continues to apply; otherwise the CLI defaults to `domestic`. **How to choose the site** (first match wins): @@ -168,7 +170,7 @@ Console login and console-gateway commands (`app list`, `usage *`, `quota *`, `w ```bash # Domestic -bl auth login --console --console-site domestic +bl auth login --console # International bl auth login --console --console-site international