fix(core): normalize model base URLs across all sources

- preserve custom gateway path prefixes
- strip query, fragment, trailing slash, and known SDK suffixes
- normalize flag, environment, config, and fallback sources
- normalize auth and config writes before persistence
- add resolver, login, config, and UI coverage
This commit is contained in:
若麒
2026-07-16 14:43:26 +08:00
parent 196b2a1f51
commit 84805f287c
19 changed files with 286 additions and 35 deletions
+1 -1
View File
@@ -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_*`,新写入只写短字段
+4 -4
View File
@@ -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 归一化(已实现)
建议提交信息:
@@ -3,6 +3,7 @@ import {
ExitCode,
chatPath,
requestJson,
normalizeModelBaseUrl,
type AuthPersistPatch,
type AuthStore,
type Identity,
@@ -49,8 +50,12 @@ export async function validateAndPersistApiKey(
): Promise<void> {
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,
});
+7 -2
View File
@@ -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) {
+1 -1
View File
@@ -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,
},
@@ -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;
}
+17 -3
View File
@@ -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}`, {
+57 -8
View File
@@ -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");
@@ -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",
+4 -1
View File
@@ -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,
);
}
/**
+4 -1
View File
@@ -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<string, unknown>;
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);
},
+1
View File
@@ -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";
@@ -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.",
);
}
+9 -5
View File
@@ -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<string>(["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)
+2 -1
View File
@@ -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<string, unknown>;
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);
},
+14 -5
View File
@@ -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",
+20
View File
@@ -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: {} });
+4
View File
@@ -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();
@@ -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/);
});