Skip to content

Commit 84805f2

Browse files
committed
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
1 parent 196b2a1 commit 84805f2

19 files changed

Lines changed: 286 additions & 35 deletions

docs/agents/auth-change.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
4343
解析分工:
4444

4545
- `resolveApiKey()``auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
46-
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`
46+
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一去除 query、fragment、尾斜杠和已知 SDK/API Base 后缀,同时保留自定义网关前缀
4747
- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
4848
- `resolveConsole()``auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
4949
- `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_*`,新写入只写短字段

docs/token-plan-profile-integration.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Token Plan Profile 与激活配置接入方案
22

3-
> 状态:Token Plan 模型消费 MVP 与 Config 激活状态已实现;通用 Base URL 归一化待实现
3+
> 状态:Token Plan 模型消费Config 激活状态与通用 Base URL 归一化均已实现
44
>
55
> 目标分支:`feat/cli-access-token`
66
@@ -96,7 +96,7 @@ bl auth login \
9696
https://proxy.example.com/bailian
9797
```
9898

99-
紧急交付阶段以“不传 `--base-url`”的推荐登录路径为准,直接使用 `token-plan` 预设中的 canonical 根地址。完整的 SDK Base URL、自定义代理前缀和其他输入来源归一化在独立的通用 Base URL commit 中完成。在该 commit 合入前,如需显式覆盖,用户必须传入已经规范化的根地址,不能传 `/compatible-mode/v1``/apps/anthropic` 后缀
99+
推荐路径仍是不传 `--base-url`,直接使用 `token-plan` 预设中的 canonical 根地址。显式覆盖时可以传服务根地址、自定义代理前缀,或带 `/compatible-mode/v1``/apps/anthropic` 的 SDK Base URL;CLI 会在验证和落盘前统一归一化
100100

101101
### 2. 单次选择 Config
102102

@@ -273,7 +273,7 @@ Selected Profile
273273

274274
## 通用模型 Base URL 归一化
275275

276-
Base URL 归一化是独立的通用能力,必须在 Token Plan 接入前完成,不能只针对 Token Plan hostname 实现
276+
Base URL 归一化是独立的通用能力,不针对 Token Plan hostname 做特判
277277

278278
### 语义
279279

@@ -480,7 +480,7 @@ feat(config): add active profile selection
480480

481481
激活项选择的是完整 Config,而不是只选择模型消费凭证。激活 `token-plan` 后,Token Plan 管控命令也会从该 Profile 解析 OpenAPI AK/SK,Console 命令也会从该 Profile 解析 Console 凭证。如果相应凭证仍保存在顶层 `default`,用户需要为单次命令显式传入 `--config default`,或将对应凭证域登录到 `token-plan`;CLI 不为不同鉴权域做隐式跨 Profile 回退。
482482

483-
### Commit 5:通用模型 Base URL 归一化(待实现
483+
### Commit 5:通用模型 Base URL 归一化(已实现
484484

485485
建议提交信息:
486486

packages/commands/src/commands/auth/login-api-key.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ExitCode,
44
chatPath,
55
requestJson,
6+
normalizeModelBaseUrl,
67
type AuthPersistPatch,
78
type AuthStore,
89
type Identity,
@@ -49,8 +50,12 @@ export async function validateAndPersistApiKey(
4950
): Promise<void> {
5051
process.stderr.write("Testing key... ");
5152
const httpDeps = { identity: deps.identity, settings: deps.settings };
53+
const baseUrl = normalizeModelBaseUrl(profile.baseUrl);
54+
const persistBaseUrl = profile.persistBaseUrl
55+
? normalizeModelBaseUrl(profile.persistBaseUrl)
56+
: undefined;
5257
const requestOpts = {
53-
url: profile.baseUrl + chatPath(),
58+
url: baseUrl + chatPath(),
5459
method: "POST",
5560
headers: { Authorization: `Bearer ${key}` },
5661
timeout: Math.min(deps.settings.timeout, 30),
@@ -81,7 +86,7 @@ export async function validateAndPersistApiKey(
8186
await deps.authStore.login({
8287
...profile.persistPatch,
8388
api_key: key,
84-
base_url: profile.persistBaseUrl,
89+
base_url: persistBaseUrl,
8590
default_text_model: profile.defaultTextModel,
8691
default_image_model: profile.defaultImageModel,
8792
});

packages/commands/src/commands/auth/login.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { defineCommand, generateCLIAccessToken, getModelProfilePreset } from "bailian-cli-core";
1+
import {
2+
defineCommand,
3+
generateCLIAccessToken,
4+
getModelProfilePreset,
5+
normalizeModelBaseUrl,
6+
} from "bailian-cli-core";
27
import { emitBare } from "bailian-cli-runtime";
38
import { validateAndPersistApiKey } from "./login-api-key.ts";
49
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
@@ -88,7 +93,7 @@ export default defineCommand({
8893
const store = ctx.authStore;
8994
const deps = { identity, settings, authStore: store };
9095
const key = flags.apiKey;
91-
const baseUrl = flags.baseUrl || undefined;
96+
const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined;
9297

9398
if (flags.console) {
9499
if (settings.dryRun) {

packages/commands/src/commands/config/set.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export default defineCommand({
3535
if (settings.dryRun) {
3636
emitResult(
3737
{
38-
would_set: { [resolvedKey]: value },
38+
would_set: { [resolvedKey]: coerced },
3939
config: settings.configName ?? "default",
4040
config_file: ctx.configStore.path,
4141
},

packages/commands/src/commands/config/shared.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BailianError, ExitCode } from "bailian-cli-core";
1+
import { BailianError, ExitCode, normalizeModelBaseUrl } from "bailian-cli-core";
22

33
/** Config keys that `config set` / `config ui` accept for read/write. */
44
export const VALID_KEYS = [
@@ -84,5 +84,7 @@ export function validateAndCoerce(key: string, value: string): string | number {
8484
return num;
8585
}
8686

87+
if (resolvedKey === "base_url") return normalizeModelBaseUrl(value);
88+
8789
return value;
8890
}

packages/commands/tests/config-ui.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import http from "node:http";
2-
import { mkdtempSync, rmSync } from "node:fs";
2+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { expect, test } from "vite-plus/test";
66
import {
77
activateConfigProfile,
8+
getConfigPath,
89
makeConfigStore,
910
writeConfigFile,
1011
readConfigFile,
@@ -98,10 +99,23 @@ test("鉴权:错误 token 401、非 loopback Host 403", async () => {
9899
test("POST /api/profile 写命名 profile(timeout 强制为 number),空串清除键", async () => {
99100
await withServer(async (port) => {
100101
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
101-
body: { name: "stage", data: { api_key: "sk-stage", timeout: "90" } },
102+
body: {
103+
name: "stage",
104+
data: {
105+
api_key: "sk-stage",
106+
timeout: "90",
107+
base_url: "https://proxy.example.com/team/compatible-mode/v1/?x=1#fragment",
108+
},
109+
},
102110
});
103111
expect(save.status).toBe(200);
104-
expect(readConfigFile("stage")).toMatchObject({ api_key: "sk-stage", timeout: 90 });
112+
expect(readConfigFile("stage")).toMatchObject({
113+
api_key: "sk-stage",
114+
timeout: 90,
115+
base_url: "https://proxy.example.com/team",
116+
});
117+
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
118+
expect(rawConfig.stage.base_url).toBe("https://proxy.example.com/team");
105119

106120
// 空串清除 api_key(整块替换)
107121
const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {

packages/commands/tests/e2e/auth.e2e.test.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,20 +175,28 @@ describe("e2e: auth", () => {
175175
expect(stdout).toContain("Would validate and save API key.");
176176
});
177177

178+
test("auth login --dry-run 仍校验显式 Base URL", async () => {
179+
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
180+
"auth",
181+
"login",
182+
"--dry-run",
183+
"--api-key",
184+
"sk-e2e-dry-run-placeholder",
185+
"--base-url",
186+
"ftp://example.com/models",
187+
]);
188+
expect(exitCode).toBe(2);
189+
expect(stderr).toMatch(/Invalid model base URL/);
190+
});
191+
178192
test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => {
179193
const validationServer = await startValidationServer();
180194
const configDir = makeE2eOutputDir("auth-api-key-login");
195+
const sdkBaseUrl = `${validationServer.baseUrl}/compatible-mode/v1/?source=login#fragment`;
181196
try {
182197
const login = await runCommandE2e(
183198
AUTH_ROUTES,
184-
[
185-
"auth",
186-
"login",
187-
"--api-key",
188-
"sk-e2e-placeholder",
189-
"--base-url",
190-
validationServer.baseUrl,
191-
],
199+
["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl],
192200
{
193201
BAILIAN_CONFIG_DIR: configDir,
194202
DASHSCOPE_API_KEY: "",
@@ -217,6 +225,47 @@ describe("e2e: auth", () => {
217225
}
218226
});
219227

228+
test("auth login --config token-plan 接受 Anthropic SDK Base URL", async () => {
229+
const validationServer = await startValidationServer();
230+
const configDir = makeE2eOutputDir("auth-token-plan-anthropic-base-url");
231+
try {
232+
const login = await runCommandE2e(
233+
AUTH_ROUTES,
234+
[
235+
"auth",
236+
"login",
237+
"--config",
238+
"token-plan",
239+
"--api-key",
240+
"sk-sp-e2e-placeholder",
241+
"--base-url",
242+
`${validationServer.baseUrl}/apps/anthropic?source=sdk#fragment`,
243+
],
244+
{
245+
BAILIAN_CONFIG_DIR: configDir,
246+
DASHSCOPE_API_KEY: "",
247+
DASHSCOPE_BASE_URL: "",
248+
},
249+
);
250+
expect(login.exitCode, login.stderr).toBe(0);
251+
expect(validationServer.requests).toHaveLength(1);
252+
expect(validationServer.requests[0].path).toBe("/compatible-mode/v1/chat/completions");
253+
254+
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
255+
string,
256+
unknown
257+
>;
258+
expect(config["token-plan"]).toMatchObject({
259+
api_key: "sk-sp-e2e-placeholder",
260+
base_url: validationServer.baseUrl,
261+
default_text_model: "qwen3.7-max",
262+
default_image_model: "qwen-image-2.0",
263+
});
264+
} finally {
265+
await validationServer.close();
266+
}
267+
});
268+
220269
test("auth login --config token-plan 物化并重置内置预设", async () => {
221270
const validationServer = await startValidationServer();
222271
const configDir = makeE2eOutputDir("auth-token-plan-preset-login");

packages/commands/tests/e2e/config.e2e.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,43 @@ describe("e2e: config", () => {
205205
expect(stderr).toMatch(/Invalid timeout|positive/i);
206206
});
207207

208+
test("config set 归一化 Base URL 并拒绝非法协议", async () => {
209+
const configDir = mkdtempSync(join(tmpdir(), "bl-config-base-url-"));
210+
try {
211+
const setResult = await runCommandE2e(
212+
CONFIG_ROUTES,
213+
[
214+
"config",
215+
"set",
216+
"--key",
217+
"base_url",
218+
"--value",
219+
"https://proxy.example.com/bailian/compatible-mode/v1/?x=1#fragment",
220+
"--output",
221+
"json",
222+
],
223+
{ BAILIAN_CONFIG_DIR: configDir },
224+
);
225+
expect(setResult.exitCode, setResult.stderr).toBe(0);
226+
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
227+
"https://proxy.example.com/bailian",
228+
);
229+
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
230+
"https://proxy.example.com/bailian",
231+
);
232+
233+
const invalidResult = await runCommandE2e(
234+
CONFIG_ROUTES,
235+
["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"],
236+
{ BAILIAN_CONFIG_DIR: configDir },
237+
);
238+
expect(invalidResult.exitCode).toBe(2);
239+
expect(invalidResult.stderr).toMatch(/Invalid model base URL/);
240+
} finally {
241+
rmSync(configDir, { recursive: true, force: true });
242+
}
243+
});
244+
208245
test("config set --dry-run 不落盘(仅输出 would_set)", async () => {
209246
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
210247
"config",
@@ -239,6 +276,23 @@ describe("e2e: config", () => {
239276
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
240277
});
241278

279+
test("config set --dry-run 展示归一化后的 Base URL", async () => {
280+
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
281+
"config",
282+
"set",
283+
"--dry-run",
284+
"--key",
285+
"base-url",
286+
"--value",
287+
"https://proxy.example.com/apps/anthropic/?x=1#fragment",
288+
"--output",
289+
"json",
290+
]);
291+
expect(exitCode, stderr).toBe(0);
292+
const data = parseStdoutJson<{ would_set?: { base_url?: string } }>(stdout);
293+
expect(data.would_set?.base_url).toBe("https://proxy.example.com");
294+
});
295+
242296
test("config set --dry-run 支持 AccessKey 短字段别名", async () => {
243297
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
244298
"config",

packages/core/src/auth/resolver.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { REGIONS } from "../config/schema.ts";
2+
import { normalizeModelBaseUrl } from "../config/model-base-url.ts";
23
import type { ResolutionSources } from "../config/loader.ts";
34
import type { ApiKeyCredential, ConsoleCredential, OpenApiCredential, AuthState } from "./types.ts";
45
import { BailianError } from "../errors/base.ts";
@@ -9,7 +10,9 @@ import { ExitCode } from "../errors/codes.ts";
910

1011
/** Model-domain baseUrl(flag > env > config file > fallback);无需 key 也可解析。 */
1112
export function resolveModelBaseUrl(s: ResolutionSources, fallback: string = REGIONS.cn): string {
12-
return s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback;
13+
return normalizeModelBaseUrl(
14+
s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback,
15+
);
1316
}
1417

1518
/**

0 commit comments

Comments
 (0)