From bd8b08b6171f757fe40a0e5163f47e35503734bd Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Fri, 28 Aug 2026 13:10:56 +0800 Subject: [PATCH] feat: cancel validate API key --- docs/agents/auth-change.md | 6 +- docs/agents/config-profile-change.md | 6 +- .../src/commands/auth/login-api-key.ts | 73 +-- .../src/commands/auth/login-console.ts | 6 +- packages/commands/src/commands/auth/login.ts | 13 +- packages/commands/tests/e2e/auth.e2e.test.ts | 479 +++++++----------- skills/bailian-cli/reference/auth.md | 2 +- skills/bailian-protocol/SKILL.md | 2 +- skills/bailian-protocol/assets/setup.md | 8 +- 9 files changed, 216 insertions(+), 379 deletions(-) diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index 50b524b7f..837a5e984 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -33,7 +33,7 @@ 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 --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 --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi` 与 `console` 域凭证 - `bl auth logout --console` 只清 `access_token` @@ -47,9 +47,9 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `--config` 只选择 config 文件 block,不提升该 block 的字段优先级。对 `auth: "apiKey"` 命令,runtime 会先按叶子命令路径检查所选 Profile 的 `api_key_capabilities`: - `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式连接覆盖存在时,完全跳过自动降级,继续走统一的 flag > env > selected config file > 默认值 - 配置文件显式声明 `api_key_capabilities` 后,命中能力时保留所选 Profile,未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile - - 字段缺失时不启用降级,包括命中内置套餐预设的 Profile;preset 只在 API Key 登录验证成功后物化写入,升级 preset 需要重新登录 + - 字段缺失时不启用降级,包括命中内置套餐预设的 Profile;preset 只在 API Key 登录落盘成功后物化写入,升级 preset 需要重新登录 - fallback 反馈写 stderr:text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象;若后续鉴权失败,warning 与多行 `error` 对象以空行分隔,stdout 仍只保留命令结果 -- 显式 `auth login --config ` 在凭证验证并落盘成功后自动激活目标 Profile;未传 +- 显式 `auth login --config ` 在凭证落盘成功后自动激活目标 Profile;未传 `--config` 时继续写当前激活项,失败和 dry-run 不切换 - `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认 - `resolveOpenApi()` — `auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段 diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index 3267f6da5..116e087ee 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -18,11 +18,11 @@ - 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`。 - `--config default` 必须显式选择顶层配置并绕过命名激活项。 - 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是 - `auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。 + `auth login --config ...`,凭证落盘成功后自动激活该 Profile。 - 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。 - API Key capability fallback 是窄例外:命名 Profile 显式配置 `api_key_capabilities` 后,不在白名单中的 `auth: "apiKey"` 叶子命令只把 file 层 `api_key` / `base_url` 切到顶层 `default`;所选 Profile 的其他 settings 和 `active_config` 均不变。如果 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一提供了更高优先级的模型连接参数,则整个 capability fallback 跳过,file 层也不切换;未显式提供的另一部分继续按 flag > env > 所选 Profile 解析。 - Profile 是否启用 capability fallback 只看持久化的 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。runtime 不注入内置 preset;升级内置 Plan Profile 的 preset 需要重新登录。 -- 对命中内置 preset 的 Profile,API Key 登录验证成功后会把当前 preset 中缺少的 capability 追加落盘,同时保留已有项且不做删除;Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。 +- 对命中内置 preset 的 Profile,API Key 登录落盘成功后会把当前 preset 中缺少的 capability 追加落盘,同时保留已有项且不做删除;Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。 - Capability ID 直接使用产品实际叶子命令路径并以 `.` 连接(例如 `video task get` → `video.task.get`);不新增命令元数据。新增或改名后的 API Key 路由未进入白名单时自然 fail closed。 - Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。 - Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。 @@ -30,7 +30,7 @@ ## 3. 保持读写命令交互一致 - `auth login`、`config set` 等写命令未传 `--config` 时修改当前激活项。 -- `auth login --config ` 显式指定不存在的 Profile 时,仅在凭证验证成功并实际落盘时 +- `auth login --config ` 显式指定不存在的 Profile 时,仅在凭证实际落盘时 创建和激活;`config set --config ` 可创建但不自动激活。 - `config show`、`auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。 - `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。 diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index bb3af2859..f31e9936a 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -1,23 +1,10 @@ -import { - BailianError, - ExitCode, - chatPath, - requestJson, - normalizeModelBaseUrl, - type AuthPersistPatch, - type AuthStore, - type Identity, - type Settings, -} from "bailian-cli-core"; +import { normalizeModelBaseUrl, type AuthPersistPatch, type AuthStore } from "bailian-cli-core"; interface ApiKeyLoginDeps { - identity: Identity; - settings: Settings; authStore: AuthStore; } interface ApiKeyLoginProfile { - baseUrl: string; persistBaseUrl?: string; defaultTextModel?: string; defaultVideoModel?: string; @@ -30,65 +17,19 @@ interface ApiKeyLoginProfile { persistPatch?: AuthPersistPatch; } -const RETRY_DELAY_BASE_MS = 500; - -function canRetry(error: unknown): boolean { - if (error instanceof BailianError) { - if (error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT) return true; - const status = error.api?.httpStatus; - return status === 401 || (status !== undefined && status >= 500); - } - if (error instanceof Error) { - return ( - error.name === "AbortError" || - error.name === "TimeoutError" || - error.message.includes("timed out") || - error.message === "fetch failed" - ); - } - return false; -} - -export async function validateAndPersistApiKey( +/** + * 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. + */ +export async function persistApiKey( deps: ApiKeyLoginDeps, key: string, profile: ApiKeyLoginProfile, ): Promise { - process.stderr.write("Testing key... "); - const httpDeps = { identity: deps.identity, settings: deps.settings }; - const baseUrl = normalizeModelBaseUrl(profile.baseUrl); const persistBaseUrl = profile.persistBaseUrl ? normalizeModelBaseUrl(profile.persistBaseUrl) : undefined; - const validationModel = "qwen3.8-max"; - const requestOpts = { - url: baseUrl + chatPath(), - method: "POST", - headers: { Authorization: `Bearer ${key}` }, - timeout: Math.min(deps.settings.timeout, 30), - body: { - model: validationModel, - messages: [{ role: "user", content: "hi" }], - max_tokens: 1, - stream: false, - }, - }; - - for (let attempt = 1; attempt <= 3; attempt++) { - try { - await requestJson(httpDeps, requestOpts); - break; - } catch (error) { - if (attempt >= 3 || !canRetry(error)) { - process.stderr.write("Failed\n"); - throw error; - } - const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - - process.stderr.write("Valid\n"); await deps.authStore.login({ ...profile.persistPatch, api_key: key, diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index 5b1a0f747..c37d3d8ab 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -11,7 +11,7 @@ import { type Settings, } from "bailian-cli-core"; import { listenLocalServer, openInBrowser } from "../shared/local-server.ts"; -import { validateAndPersistApiKey } from "./login-api-key.ts"; +import { persistApiKey } from "./login-api-key.ts"; /** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */ export interface LoginDeps { @@ -413,9 +413,7 @@ export async function runConsoleLogin( workspace_id: workspaceId || undefined, }; if (apiKey) { - const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl(); - await validateAndPersistApiKey(deps, apiKey, { - baseUrl: testBaseUrl, + await persistApiKey(deps, apiKey, { persistBaseUrl: baseUrl || undefined, persistPatch: callbackPatch, }); diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index 9000a8361..a565fde73 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 { validateAndPersistApiKey } from "./login-api-key.ts"; +import { persistApiKey } 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"; @@ -33,8 +33,8 @@ export default defineCommand({ type: "string", valueHint: "", description: { - "en-US": "Model API base URL (used with --api-key for validation)", - "zh-CN": "模型 API Base URL(用于配合 --api-key 进行验证)", + "en-US": "Model API base URL to store with --api-key", + "zh-CN": "与 --api-key 一并保存的模型 API Base URL", }, }, console: { @@ -164,19 +164,17 @@ export default defineCommand({ if (!key) return; if (settings.dryRun) { - emitBare("Would validate and save API key."); + emitBare("Would save API key."); return; } const profilePreset = getModelProfilePreset(settings.configName); const stored = store.stored(); const storedBaseUrl = stored.baseUrl; - const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl); const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined); const apiKeyCapabilities = profilePreset ? [...new Set([...(stored.apiKeyCapabilities ?? []), ...profilePreset.apiKeyCapabilities])] : stored.apiKeyCapabilities; - await validateAndPersistApiKey(deps, key, { - baseUrl: resolvedBaseUrl, + await persistApiKey(deps, key, { persistBaseUrl, defaultTextModel: profilePreset?.defaultTextModel, defaultVideoModel: profilePreset?.defaultVideoModel, @@ -187,5 +185,6 @@ export default defineCommand({ defaultSpeechRecognitionModel: profilePreset?.defaultSpeechRecognitionModel, apiKeyCapabilities, }); + process.stderr.write(`API key saved to ${store.path}\n`); }, }); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index b97346e20..0d3348297 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,6 +1,4 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; -import http from "node:http"; -import type { AddressInfo } from "node:net"; import { tmpdir } from "os"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; @@ -13,51 +11,6 @@ import { } from "./helpers.ts"; import { AUTH_ROUTES } from "./topic-routes.ts"; -interface ValidationServer { - baseUrl: string; - requests: Array<{ - path: string; - body: Record; - authorization?: string; - sourceConfig?: string; - openApiSource?: string; - }>; - close(): Promise; -} - -async function startValidationServer(statusCode = 200): Promise { - const requests: ValidationServer["requests"] = []; - const server = http.createServer((request, response) => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - const rawBody = Buffer.concat(chunks).toString("utf8"); - requests.push({ - path: request.url ?? "", - body: rawBody ? (JSON.parse(rawBody) as Record) : {}, - authorization: request.headers.authorization, - sourceConfig: request.headers["x-dashscope-source-config"] as string | undefined, - openApiSource: request.headers["x-dashscope-openapisource"] as string | undefined, - }); - response.writeHead(statusCode, { "Content-Type": "application/json" }); - if (statusCode >= 400) { - response.end(JSON.stringify({ code: "InvalidApiKey", message: "invalid key" })); - return; - } - response.end( - JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }), - ); - }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address() as AddressInfo; - return { - baseUrl: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise((resolve) => server.close(() => resolve())), - }; -} - /** Auth E2E:本地参数/持久化契约默认执行;真实鉴权请求按对应 readiness gate 执行。 */ describe("e2e: auth", () => { @@ -172,7 +125,7 @@ describe("e2e: auth", () => { expect(stderr).toMatch(/Choose exactly one login mode/); }); - test("auth login --dry-run --api-key 不发起校验与落盘", async () => { + test("auth login --dry-run --api-key 不发起落盘", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ "auth", "login", @@ -181,7 +134,7 @@ describe("e2e: auth", () => { "sk-e2e-dry-run-placeholder", ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("Would validate and save API key."); + expect(stdout).toContain("Would save API key."); }); test("auth login --dry-run 仍校验显式 Base URL", async () => { @@ -198,104 +151,83 @@ describe("e2e: auth", () => { expect(stderr).toMatch(/Invalid model base URL/); }); - test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => { - const validationServer = await startValidationServer(); + test("auth login --api-key 原子保存凭证和 Base URL(无联网探测)", async () => { const configDir = makeE2eOutputDir("auth-api-key-login"); - const sdkBaseUrl = `${validationServer.baseUrl}/compatible-mode/v1/?source=login#fragment`; - try { - const login = await runCommandE2e( - AUTH_ROUTES, - ["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); - expect(login.exitCode, login.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); - expect(validationServer.requests[0]).toMatchObject({ - path: "/compatible-mode/v1/chat/completions", - authorization: "Bearer sk-e2e-placeholder", - sourceConfig: expect.any(String), - openApiSource: "BailianCLI", - body: { - model: "qwen3.8-max", - stream: false, - }, - }); - - 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(validationServer.baseUrl); - } finally { - await validationServer.close(); - } + 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], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + expect(login.stderr).toMatch(/API key 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); }); test("auth login --config token-plan 接受 Anthropic SDK Base URL", async () => { - const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-token-plan-anthropic-base-url"); - try { - const login = await runCommandE2e( - AUTH_ROUTES, - [ - "auth", - "login", - "--config", - "token-plan", - "--api-key", - "sk-sp-e2e-placeholder", - "--base-url", - `${validationServer.baseUrl}/apps/anthropic?source=sdk#fragment`, - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); - expect(login.exitCode, login.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); - expect(validationServer.requests[0].path).toBe("/compatible-mode/v1/chat/completions"); - - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config["token-plan"]).toMatchObject({ - api_key: "sk-sp-e2e-placeholder", - base_url: validationServer.baseUrl, - default_text_model: "qwen3.8-max", - default_video_model: "happyhorse-1.1-t2v", - default_image_to_video_model: "happyhorse-1.1-i2v", - default_reference_to_video_model: "happyhorse-1.1-r2v", - default_image_model: "wan2.7-image", - default_speech_model: "qwen-audio-3.0-tts-plus", - default_speech_recognition_model: "qwen-audio-3.0-asr-flash", - api_key_capabilities: [ - "text.chat", - "vision.describe", - "image.generate", - "image.edit", - "speech.recognize", - "speech.synthesize", - "video.generate", - "video.ref", - "video.task.get", - "video.download", - ], - }); - } finally { - await validationServer.close(); - } + const origin = "https://token-plan.example.test"; + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--config", + "token-plan", + "--api-key", + "sk-sp-e2e-placeholder", + "--base-url", + `${origin}/apps/anthropic?source=sdk#fragment`, + ], + { + 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["token-plan"]).toMatchObject({ + api_key: "sk-sp-e2e-placeholder", + base_url: origin, + default_text_model: "qwen3.8-max", + default_video_model: "happyhorse-1.1-t2v", + default_image_to_video_model: "happyhorse-1.1-i2v", + default_reference_to_video_model: "happyhorse-1.1-r2v", + default_image_model: "wan2.7-image", + default_speech_model: "qwen-audio-3.0-tts-plus", + default_speech_recognition_model: "qwen-audio-3.0-asr-flash", + 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 --config token-plan 追加新版 capability preset 且不删除已有能力", async () => { - const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-token-plan-preset-login"); writeFileSync( join(configDir, "config.json"), @@ -315,72 +247,56 @@ describe("e2e: auth", () => { ) + "\n", ); - try { - const login = await runCommandE2e( - AUTH_ROUTES, - ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted", - DASHSCOPE_BASE_URL: validationServer.baseUrl, - }, - ); - expect(login.exitCode, login.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); - expect(validationServer.requests[0]).toMatchObject({ - path: "/compatible-mode/v1/chat/completions", - authorization: "Bearer sk-sp-e2e-placeholder", - sourceConfig: expect.any(String), - openApiSource: "BailianCLI", - body: { - model: "qwen3.8-max", - stream: false, - }, - }); - - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config.api_key).toBeUndefined(); - 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", - default_text_model: "qwen3.8-max", - default_video_model: "happyhorse-1.1-t2v", - default_image_to_video_model: "happyhorse-1.1-i2v", - default_reference_to_video_model: "happyhorse-1.1-r2v", - default_image_model: "wan2.7-image", - default_speech_model: "qwen-audio-3.0-tts-plus", - default_speech_recognition_model: "qwen-audio-3.0-asr-flash", - api_key_capabilities: [ - "text.chat", - "custom.command", - "vision.describe", - "image.generate", - "image.edit", - "speech.recognize", - "speech.synthesize", - "video.generate", - "video.ref", - "video.task.get", - "video.download", - ], - }); - expect((config["token-plan"] as Record).base_url).not.toBe( - validationServer.baseUrl, - ); - expect((config["token-plan"] as Record).api_key).not.toBe( - "sk-env-must-not-be-persisted", - ); - } finally { - await validationServer.close(); - } + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted", + DASHSCOPE_BASE_URL: "https://env-must-not-override-preset.example.test", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.api_key).toBeUndefined(); + 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", + default_text_model: "qwen3.8-max", + default_video_model: "happyhorse-1.1-t2v", + default_image_to_video_model: "happyhorse-1.1-i2v", + default_reference_to_video_model: "happyhorse-1.1-r2v", + default_image_model: "wan2.7-image", + default_speech_model: "qwen-audio-3.0-tts-plus", + default_speech_recognition_model: "qwen-audio-3.0-asr-flash", + api_key_capabilities: [ + "text.chat", + "custom.command", + "vision.describe", + "image.generate", + "image.edit", + "speech.recognize", + "speech.synthesize", + "video.generate", + "video.ref", + "video.task.get", + "video.download", + ], + }); + expect((config["token-plan"] as Record).base_url).not.toBe( + "https://env-must-not-override-preset.example.test", + ); + expect((config["token-plan"] as Record).api_key).not.toBe( + "sk-env-must-not-be-persisted", + ); }); test("auth login --config token-plan 为显式空白名单追加 capability preset", async () => { - const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-token-plan-empty-capabilities-login"); writeFileSync( join(configDir, "config.json"), @@ -395,51 +311,46 @@ describe("e2e: auth", () => { ) + "\n", ); - try { - const login = await runCommandE2e( - AUTH_ROUTES, - ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: validationServer.baseUrl, - }, - ); - expect(login.exitCode, login.stderr).toBe(0); - expect(validationServer.requests).toHaveLength(1); - - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config["token-plan"]).toMatchObject({ - api_key_capabilities: [ - "text.chat", - "vision.describe", - "image.generate", - "image.edit", - "speech.recognize", - "speech.synthesize", - "video.generate", - "video.ref", - "video.task.get", - "video.download", - ], - }); - } finally { - await validationServer.close(); - } + const login = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "https://env-ignored-for-persist.example.test", + }, + ); + expect(login.exitCode, login.stderr).toBe(0); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config["token-plan"]).toMatchObject({ + 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 未传 --config 时写当前激活 Config", async () => { - const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-active-profile-login"); + const storedBaseUrl = "https://dev-profile.example.test"; writeFileSync( join(configDir, "config.json"), JSON.stringify( { active_config: "dev", - dev: { base_url: validationServer.baseUrl }, + dev: { base_url: storedBaseUrl }, }, null, 2, @@ -451,60 +362,48 @@ describe("e2e: auth", () => { DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "", }; - try { - const activeLogin = await runCommandE2e( - AUTH_ROUTES, - ["auth", "login", "--api-key", "sk-active-placeholder"], - env, - ); - expect(activeLogin.exitCode, activeLogin.stderr).toBe(0); - - expect(validationServer.requests).toHaveLength(1); - - const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< - string, - unknown - >; - expect(config.api_key).toBeUndefined(); - expect(config.active_config).toBe("dev"); - expect(config.dev).toMatchObject({ - api_key: "sk-active-placeholder", - base_url: validationServer.baseUrl, - }); - } finally { - await validationServer.close(); - } + const activeLogin = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--api-key", "sk-active-placeholder"], + env, + ); + expect(activeLogin.exitCode, activeLogin.stderr).toBe(0); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.api_key).toBeUndefined(); + expect(config.active_config).toBe("dev"); + expect(config.dev).toMatchObject({ + api_key: "sk-active-placeholder", + base_url: storedBaseUrl, + }); }); - test("auth login --api-key 验证失败不留下半配置", async () => { - const validationServer = await startValidationServer(400); + test("auth login --api-key 非法 Base URL 失败时不留下半配置", async () => { const configDir = makeE2eOutputDir("auth-api-key-login-failure"); - try { - const login = await runCommandE2e( - AUTH_ROUTES, - [ - "auth", - "login", - "--config", - "failed-profile", - "--api-key", - "sk-invalid", - "--base-url", - validationServer.baseUrl, - ], - { - BAILIAN_CONFIG_DIR: configDir, - DASHSCOPE_API_KEY: "", - DASHSCOPE_BASE_URL: "", - }, - ); - expect(login.exitCode).not.toBe(0); - expect(login.stderr).toMatch(/invalid key/); - expect(login.stderr).not.toMatch(/API key validation failed|Invalid API key/); - expect(existsSync(join(configDir, "config.json"))).toBe(false); - } finally { - await validationServer.close(); - } + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--config", + "failed-profile", + "--api-key", + "sk-invalid", + "--base-url", + "ftp://example.com/models", + ], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + expect(login.exitCode).not.toBe(0); + expect(login.stderr).toMatch(/Invalid model base URL/); + expect(existsSync(join(configDir, "config.json"))).toBe(false); }); test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => { @@ -520,7 +419,7 @@ describe("e2e: auth", () => { "120", ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("Would validate and save API key."); + expect(stdout).toContain("Would save API key."); }); test("auth login 缺少密钥且 --output json 时报用法错误并退出 (2)", async () => { diff --git a/skills/bailian-cli/reference/auth.md b/skills/bailian-cli/reference/auth.md index 3b9222e34..a98d85682 100644 --- a/skills/bailian-cli/reference/auth.md +++ b/skills/bailian-cli/reference/auth.md @@ -53,7 +53,7 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------- | | `--api-key ` | string | no | Model API key to store | -| `--base-url ` | string | no | Model API base URL (used with --api-key for validation) | +| `--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 | diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index 48232d302..c0ebf91e2 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -68,7 +68,7 @@ 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 validates the key before saving it. 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. +**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:** 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). diff --git a/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index 79d35673f..8c97458e5 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -82,9 +82,9 @@ 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` tests the key first, then saves -and activates the Profile only when validation succeeds; do not ask the user to configure the Base -URL or run a duplicate smoke test. +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. ### API Key capability fallback @@ -120,7 +120,7 @@ bl config use --name default ``` `auth login --config token-plan` creates or updates that Profile and activates it only after the -credential is validated and saved. Failed login and `--dry-run` do not switch Profiles. Use +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.