mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -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_*`,新写入只写短字段
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user