diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index 9f641a6..301e009 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -43,7 +43,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx 解析分工: - `resolveApiKey()` — `auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key` -- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn` +- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一去除 query、fragment、尾斜杠和已知 SDK/API Base 后缀,同时保留自定义网关前缀 - `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值 - `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/token-plan-profile-integration.md b/docs/token-plan-profile-integration.md index 5bccab0..9949120 100644 --- a/docs/token-plan-profile-integration.md +++ b/docs/token-plan-profile-integration.md @@ -1,6 +1,6 @@ # Token Plan Profile 与激活配置接入方案 -> 状态:Token Plan 模型消费 MVP 与 Config 激活状态已实现;通用 Base URL 归一化待实现。 +> 状态:Token Plan 模型消费、Config 激活状态与通用 Base URL 归一化均已实现。 > > 目标分支:`feat/cli-access-token`。 @@ -96,7 +96,7 @@ bl auth login \ https://proxy.example.com/bailian ``` -紧急交付阶段以“不传 `--base-url`”的推荐登录路径为准,直接使用 `token-plan` 预设中的 canonical 根地址。完整的 SDK Base URL、自定义代理前缀和其他输入来源归一化在独立的通用 Base URL commit 中完成。在该 commit 合入前,如需显式覆盖,用户必须传入已经规范化的根地址,不能传 `/compatible-mode/v1` 或 `/apps/anthropic` 后缀。 +推荐路径仍是不传 `--base-url`,直接使用 `token-plan` 预设中的 canonical 根地址。显式覆盖时可以传服务根地址、自定义代理前缀,或带 `/compatible-mode/v1`、`/apps/anthropic` 的 SDK Base URL;CLI 会在验证和落盘前统一归一化。 ### 2. 单次选择 Config @@ -273,7 +273,7 @@ Selected Profile ## 通用模型 Base URL 归一化 -Base URL 归一化是独立的通用能力,必须在 Token Plan 接入前完成,不能只针对 Token Plan hostname 实现。 +Base URL 归一化是独立的通用能力,不针对 Token Plan hostname 做特判。 ### 语义 @@ -480,7 +480,7 @@ feat(config): add active profile selection 激活项选择的是完整 Config,而不是只选择模型消费凭证。激活 `token-plan` 后,Token Plan 管控命令也会从该 Profile 解析 OpenAPI AK/SK,Console 命令也会从该 Profile 解析 Console 凭证。如果相应凭证仍保存在顶层 `default`,用户需要为单次命令显式传入 `--config default`,或将对应凭证域登录到 `token-plan`;CLI 不为不同鉴权域做隐式跨 Profile 回退。 -### Commit 5:通用模型 Base URL 归一化(待实现) +### Commit 5:通用模型 Base URL 归一化(已实现) 建议提交信息: diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index f3dce6a..8a9991f 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -3,6 +3,7 @@ import { ExitCode, chatPath, requestJson, + normalizeModelBaseUrl, type AuthPersistPatch, type AuthStore, type Identity, @@ -49,8 +50,12 @@ export async function validateAndPersistApiKey( ): 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 requestOpts = { - url: profile.baseUrl + chatPath(), + url: baseUrl + chatPath(), method: "POST", headers: { Authorization: `Bearer ${key}` }, timeout: Math.min(deps.settings.timeout, 30), @@ -81,7 +86,7 @@ export async function validateAndPersistApiKey( await deps.authStore.login({ ...profile.persistPatch, api_key: key, - base_url: profile.persistBaseUrl, + base_url: persistBaseUrl, default_text_model: profile.defaultTextModel, default_image_model: profile.defaultImageModel, }); diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index ea2a071..aa7c0a6 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -1,4 +1,9 @@ -import { defineCommand, generateCLIAccessToken, getModelProfilePreset } from "bailian-cli-core"; +import { + defineCommand, + generateCLIAccessToken, + getModelProfilePreset, + normalizeModelBaseUrl, +} from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; import { validateAndPersistApiKey } from "./login-api-key.ts"; import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; @@ -88,7 +93,7 @@ export default defineCommand({ const store = ctx.authStore; const deps = { identity, settings, authStore: store }; const key = flags.apiKey; - const baseUrl = flags.baseUrl || undefined; + const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined; if (flags.console) { if (settings.dryRun) { diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index 065b5cd..3e9f615 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -35,7 +35,7 @@ export default defineCommand({ if (settings.dryRun) { emitResult( { - would_set: { [resolvedKey]: value }, + would_set: { [resolvedKey]: coerced }, config: settings.configName ?? "default", config_file: ctx.configStore.path, }, diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 2ca0e08..7ff612d 100644 --- a/packages/commands/src/commands/config/shared.ts +++ b/packages/commands/src/commands/config/shared.ts @@ -1,4 +1,4 @@ -import { BailianError, ExitCode } from "bailian-cli-core"; +import { BailianError, ExitCode, normalizeModelBaseUrl } from "bailian-cli-core"; /** Config keys that `config set` / `config ui` accept for read/write. */ export const VALID_KEYS = [ @@ -84,5 +84,7 @@ export function validateAndCoerce(key: string, value: string): string | number { return num; } + if (resolvedKey === "base_url") return normalizeModelBaseUrl(value); + return value; } diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index 5aeed18..28bcf6e 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -1,10 +1,11 @@ import http from "node:http"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vite-plus/test"; import { activateConfigProfile, + getConfigPath, makeConfigStore, writeConfigFile, readConfigFile, @@ -98,10 +99,23 @@ test("鉴权:错误 token 401、非 loopback Host 403", async () => { test("POST /api/profile 写命名 profile(timeout 强制为 number),空串清除键", async () => { await withServer(async (port) => { const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { - body: { name: "stage", data: { api_key: "sk-stage", timeout: "90" } }, + body: { + name: "stage", + data: { + api_key: "sk-stage", + timeout: "90", + base_url: "https://proxy.example.com/team/compatible-mode/v1/?x=1#fragment", + }, + }, }); expect(save.status).toBe(200); - expect(readConfigFile("stage")).toMatchObject({ api_key: "sk-stage", timeout: 90 }); + expect(readConfigFile("stage")).toMatchObject({ + api_key: "sk-stage", + timeout: 90, + base_url: "https://proxy.example.com/team", + }); + const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8")); + expect(rawConfig.stage.base_url).toBe("https://proxy.example.com/team"); // 空串清除 api_key(整块替换) const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 2ee4da7..7630f53 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -175,20 +175,28 @@ describe("e2e: auth", () => { expect(stdout).toContain("Would validate and save API key."); }); + test("auth login --dry-run 仍校验显式 Base URL", async () => { + const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [ + "auth", + "login", + "--dry-run", + "--api-key", + "sk-e2e-dry-run-placeholder", + "--base-url", + "ftp://example.com/models", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Invalid model base URL/); + }); + test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => { const validationServer = await startValidationServer(); 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", - validationServer.baseUrl, - ], + ["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl], { BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", @@ -217,6 +225,47 @@ describe("e2e: auth", () => { } }); + 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.7-max", + default_image_model: "qwen-image-2.0", + }); + } finally { + await validationServer.close(); + } + }); + test("auth login --config token-plan 物化并重置内置预设", async () => { const validationServer = await startValidationServer(); const configDir = makeE2eOutputDir("auth-token-plan-preset-login"); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index bcaca0f..3273c67 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -205,6 +205,43 @@ describe("e2e: config", () => { expect(stderr).toMatch(/Invalid timeout|positive/i); }); + test("config set 归一化 Base URL 并拒绝非法协议", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-base-url-")); + try { + const setResult = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "set", + "--key", + "base_url", + "--value", + "https://proxy.example.com/bailian/compatible-mode/v1/?x=1#fragment", + "--output", + "json", + ], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(setResult.exitCode, setResult.stderr).toBe(0); + expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe( + "https://proxy.example.com/bailian", + ); + expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe( + "https://proxy.example.com/bailian", + ); + + const invalidResult = await runCommandE2e( + CONFIG_ROUTES, + ["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(invalidResult.exitCode).toBe(2); + expect(invalidResult.stderr).toMatch(/Invalid model base URL/); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + test("config set --dry-run 不落盘(仅输出 would_set)", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", @@ -239,6 +276,23 @@ describe("e2e: config", () => { expect(data.would_set?.default_text_model).toBe("qwen3.7-max"); }); + test("config set --dry-run 展示归一化后的 Base URL", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "set", + "--dry-run", + "--key", + "base-url", + "--value", + "https://proxy.example.com/apps/anthropic/?x=1#fragment", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ would_set?: { base_url?: string } }>(stdout); + expect(data.would_set?.base_url).toBe("https://proxy.example.com"); + }); + test("config set --dry-run 支持 AccessKey 短字段别名", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", diff --git a/packages/core/src/auth/resolver.ts b/packages/core/src/auth/resolver.ts index 4508a5b..08c9602 100644 --- a/packages/core/src/auth/resolver.ts +++ b/packages/core/src/auth/resolver.ts @@ -1,4 +1,5 @@ import { REGIONS } from "../config/schema.ts"; +import { normalizeModelBaseUrl } from "../config/model-base-url.ts"; import type { ResolutionSources } from "../config/loader.ts"; import type { ApiKeyCredential, ConsoleCredential, OpenApiCredential, AuthState } from "./types.ts"; import { BailianError } from "../errors/base.ts"; @@ -9,7 +10,9 @@ import { ExitCode } from "../errors/codes.ts"; /** Model-domain baseUrl(flag > env > config file > fallback);无需 key 也可解析。 */ export function resolveModelBaseUrl(s: ResolutionSources, fallback: string = REGIONS.cn): string { - return s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback; + return normalizeModelBaseUrl( + s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback, + ); } /** diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 47f3402..45200e5 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -2,6 +2,7 @@ import type { ConfigFile } from "../config/schema.ts"; 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 type { AuthState } from "./types.ts"; import { describeAuthState, resolveModelBaseUrl } from "./resolver.ts"; @@ -64,7 +65,9 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore { async login(patch) { const existing = readConfigFile(configName) as Record; for (const [key, value] of Object.entries(patch)) { - if (value !== undefined) existing[key] = value; + if (value !== undefined) { + existing[key] = key === "base_url" ? normalizeModelBaseUrl(String(value)) : value; + } } await writeConfigFile(existing, configName); }, diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 823454b..48c89a5 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -12,3 +12,4 @@ export { buildSources, buildSettings, type ResolutionSources } 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 { normalizeModelBaseUrl } from "./model-base-url.ts"; diff --git a/packages/core/src/config/model-base-url.ts b/packages/core/src/config/model-base-url.ts new file mode 100644 index 0000000..56101b7 --- /dev/null +++ b/packages/core/src/config/model-base-url.ts @@ -0,0 +1,45 @@ +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + +const KNOWN_API_BASE_SUFFIXES = ["/compatible-mode/v1", "/apps/anthropic"] as const; + +/** + * Normalize a model-service base URL while preserving custom gateway prefixes. + * CLI endpoints append their own API paths, so known SDK/API base suffixes must + * not remain in the stored or resolved base URL. + */ +export function normalizeModelBaseUrl(input: string): string { + const trimmed = input.trim(); + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw invalidModelBaseUrl(input); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw invalidModelBaseUrl(input); + } + + parsed.search = ""; + parsed.hash = ""; + + let pathname = parsed.pathname.replace(/\/+$/, ""); + const knownSuffix = KNOWN_API_BASE_SUFFIXES.find( + (suffix) => pathname === suffix || pathname.endsWith(suffix), + ); + if (knownSuffix) { + pathname = pathname.slice(0, -knownSuffix.length).replace(/\/+$/, ""); + } + parsed.pathname = pathname || "/"; + + return parsed.toString().replace(/\/$/, ""); +} + +function invalidModelBaseUrl(input: string): BailianError { + return new BailianError( + `Invalid model base URL "${input}".`, + ExitCode.USAGE, + "Use an absolute http(s) URL.", + ); +} diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index d9c62b1..71c12e9 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,3 +1,5 @@ +import { normalizeModelBaseUrl } from "./model-base-url.ts"; + export const REGIONS = { cn: "https://dashscope.aliyuncs.com", us: "https://dashscope-us.aliyuncs.com", @@ -71,12 +73,11 @@ const VALID_CONSOLE_SITES = new Set(["domestic", "international"]); * sends the Bearer token to these origins, so a bare `startsWith("http")` check * (which also accepts e.g. "httpfoo://…") is too loose. */ -function isHttpUrl(value: string): boolean { +function parseModelBaseUrl(value: string): string | undefined { try { - const u = new URL(value); - return u.protocol === "http:" || u.protocol === "https:"; + return normalizeModelBaseUrl(value); } catch { - return false; + return undefined; } } @@ -103,7 +104,10 @@ export function parseConfigFile(raw: unknown): ConfigFile { out.access_key_secret = obj.openapi_access_key_secret; if (typeof obj.security_token === "string" && obj.security_token.length > 0) out.security_token = obj.security_token; - if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url; + if (typeof obj.base_url === "string") { + const baseUrl = parseModelBaseUrl(obj.base_url); + if (baseUrl) out.base_url = baseUrl; + } if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output)) out.output = obj.output as ConfigFile["output"]; if (typeof obj.output_dir === "string" && obj.output_dir.length > 0) diff --git a/packages/core/src/config/store.ts b/packages/core/src/config/store.ts index 0e00fea..998d865 100644 --- a/packages/core/src/config/store.ts +++ b/packages/core/src/config/store.ts @@ -8,6 +8,7 @@ import { type ConfigProfiles, } from "./loader.ts"; import { getConfigPath } from "./paths.ts"; +import { normalizeModelBaseUrl } from "./model-base-url.ts"; /** * config 命令族的持久化能力面(lint 限定 commands/config/** 使用)。 @@ -35,7 +36,7 @@ export function makeConfigStore(configName?: string): ConfigStore { const existing = readConfigFile(configName) as Record; for (const [key, value] of Object.entries(patch)) { if (value === undefined) delete existing[key]; - else existing[key] = value; + else existing[key] = key === "base_url" ? normalizeModelBaseUrl(String(value)) : value; } await writeConfigFile(existing, configName); }, diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index c6f22ba..acf7b02 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -37,16 +37,25 @@ test("token-plan Profile 预设保持固定", () => { }); }); -test("baseUrl:flag > env > file > 默认(原为 flag>file>env,已归一)", () => { - const flags = { baseUrl: "https://flag.example.com" }; - const env = { DASHSCOPE_BASE_URL: "https://env.example.com" }; - const file: ConfigFile = { base_url: "https://file.example.com" }; +test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => { + const flags = { baseUrl: "https://flag.example.com/compatible-mode/v1?source=flag" }; + const env = { DASHSCOPE_BASE_URL: "https://env.example.com/apps/anthropic#env" }; + const file: ConfigFile = { base_url: "https://file.example.com/gateway/" }; expect(resolveModelBaseUrl(src({ flags, env, file }))).toBe("https://flag.example.com"); expect(resolveModelBaseUrl(src({ env, file }))).toBe("https://env.example.com"); - expect(resolveModelBaseUrl(src({ file }))).toBe("https://file.example.com"); + expect(resolveModelBaseUrl(src({ file }))).toBe("https://file.example.com/gateway"); expect(resolveModelBaseUrl(src({}))).toBe("https://dashscope.aliyuncs.com"); }); +test("baseUrl:非法 flag/env 在 resolver 边界报 usage error", () => { + expect(() => resolveModelBaseUrl(src({ flags: { baseUrl: "not-a-url" } }))).toThrow( + /Invalid model base URL/, + ); + expect(() => + resolveModelBaseUrl(src({ env: { DASHSCOPE_BASE_URL: "file:///tmp/model" } })), + ).toThrow(/Invalid model base URL/); +}); + test("命名 config 仍保持 flag > env > selected file", () => { const env = { DASHSCOPE_BASE_URL: "https://env.example.com", diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index e4fde5d..438f12e 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -48,6 +48,26 @@ test("ConfigStore:write 合并写入,undefined 键删除,unset 删键", async () }); }); +test("ConfigStore/AuthStore 写入前归一化 model Base URL", async () => { + await inTempConfigDir(async () => { + const configStore = makeConfigStore(); + await configStore.write({ + base_url: "https://proxy.example.com/bailian/compatible-mode/v1/?query=one#fragment", + }); + expect(readConfigFile().base_url).toBe("https://proxy.example.com/bailian"); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).base_url).toBe( + "https://proxy.example.com/bailian", + ); + + const authStore = makeAuthStore(buildSources({})); + await authStore.login({ base_url: "https://token.example.com/apps/anthropic/" }); + expect(readConfigFile().base_url).toBe("https://token.example.com"); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).base_url).toBe( + "https://token.example.com", + ); + }); +}); + test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () => { await inTempConfigDir(async () => { const store = makeAuthStore({ flags: {}, file: {}, env: {} }); diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 1e4e021..7c8dc3b 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -318,6 +318,10 @@ test("parseConfigFile accepts only well-formed http(s) base_url", () => { expect(parseConfigFile({ base_url: "http://localhost:8080" }).base_url).toBe( "http://localhost:8080", ); + expect( + parseConfigFile({ base_url: "https://proxy.example.com/team/compatible-mode/v1?x=1#y" }) + .base_url, + ).toBe("https://proxy.example.com/team"); // Previously accepted because the value merely "starts with http". expect(parseConfigFile({ base_url: "httpfoo://evil" }).base_url).toBeUndefined(); expect(parseConfigFile({ base_url: "not a url" }).base_url).toBeUndefined(); diff --git a/packages/core/tests/model-base-url.test.ts b/packages/core/tests/model-base-url.test.ts new file mode 100644 index 0000000..cfcc613 --- /dev/null +++ b/packages/core/tests/model-base-url.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "vite-plus/test"; +import { BailianError } from "../src/errors/base.ts"; +import { normalizeModelBaseUrl } from "../src/config/model-base-url.ts"; + +test("normalizeModelBaseUrl removes URL noise and known API base suffixes", () => { + expect(normalizeModelBaseUrl(" https://dashscope.aliyuncs.com/?region=cn#docs ")).toBe( + "https://dashscope.aliyuncs.com", + ); + expect( + normalizeModelBaseUrl("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/"), + ).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com"); + expect( + normalizeModelBaseUrl("https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic"), + ).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com"); +}); + +test("normalizeModelBaseUrl preserves ports and custom gateway prefixes", () => { + expect(normalizeModelBaseUrl("http://localhost:8080/bailian/")).toBe( + "http://localhost:8080/bailian", + ); + expect( + normalizeModelBaseUrl("https://proxy.example.com/bailian/compatible-mode/v1?tenant=one"), + ).toBe("https://proxy.example.com/bailian"); + expect(normalizeModelBaseUrl("https://proxy.example.com/custom/apps/anthropic#section")).toBe( + "https://proxy.example.com/custom", + ); +}); + +test("normalizeModelBaseUrl rejects non-http and malformed URLs", () => { + expect(() => normalizeModelBaseUrl("not a url")).toThrow(BailianError); + expect(() => normalizeModelBaseUrl("ftp://example.com/path")).toThrow(/Invalid model base URL/); +});