@nvae/llmswitch 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,439 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { HttpsProxyAgent } from "https-proxy-agent";
5
+ import { SocksProxyAgent } from "socks-proxy-agent";
6
+ const SUPPORTED_TARGET_PROTOCOLS = new Set(["http:", "https:"]);
7
+ const SUPPORTED_PROXY_PROTOCOLS = new Set([
8
+ "http:",
9
+ "https:",
10
+ "socks:",
11
+ "socks4:",
12
+ "socks4a:",
13
+ "socks5:",
14
+ "socks5h:",
15
+ ]);
16
+ /** Hop-by-hop and inbound-credential headers never forwarded upstream. */
17
+ const STRIPPED_REQUEST_HEADERS = new Set([
18
+ "connection",
19
+ "keep-alive",
20
+ "proxy-authenticate",
21
+ "proxy-authorization",
22
+ "te",
23
+ "trailer",
24
+ "transfer-encoding",
25
+ "upgrade",
26
+ "host",
27
+ "content-length",
28
+ ]);
29
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
30
+ const DEFAULT_IDLE_TIMEOUT_MS = 90_000;
31
+ const DEFAULT_TOTAL_TIMEOUT_MS = 600_000;
32
+ const DEFAULT_MAX_REDIRECTS = 5;
33
+ export class TransportTimeoutError extends Error {
34
+ constructor(message = "上游请求超时") {
35
+ super(message);
36
+ this.name = "TransportTimeoutError";
37
+ }
38
+ }
39
+ export class ResponseLimitError extends Error {
40
+ constructor(message = "上游响应超出大小限制") {
41
+ super(message);
42
+ this.name = "ResponseLimitError";
43
+ }
44
+ }
45
+ export class CrossOriginRedirectError extends Error {
46
+ constructor(message = "拒绝跨源 redirect") {
47
+ super(message);
48
+ this.name = "CrossOriginRedirectError";
49
+ }
50
+ }
51
+ export class TransportProtocolError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "TransportProtocolError";
55
+ }
56
+ }
57
+ /**
58
+ * Resolve the proxy URL for a target. A profile has a single proxy applied to
59
+ * all traffic; the URL scheme (http/https/socks*) selects the agent.
60
+ */
61
+ export function selectProxyUrl(_target, proxy) {
62
+ const chosen = proxy?.trim();
63
+ return chosen || null;
64
+ }
65
+ /**
66
+ * Build a fresh per-request Agent for the given proxy URL. Callers own its
67
+ * lifecycle and must call `.destroy()` once the response is drained.
68
+ */
69
+ export function createTransportAgent(_target, proxyUrl) {
70
+ let proxy;
71
+ try {
72
+ proxy = new URL(proxyUrl);
73
+ }
74
+ catch {
75
+ throw new TransportProtocolError(`无效的 proxy URL: ${proxyUrl}`);
76
+ }
77
+ if (!SUPPORTED_PROXY_PROTOCOLS.has(proxy.protocol)) {
78
+ throw new TransportProtocolError(`不支持的 proxy protocol: ${proxy.protocol}`);
79
+ }
80
+ if (proxy.protocol === "http:" || proxy.protocol === "https:") {
81
+ return new HttpsProxyAgent(proxy);
82
+ }
83
+ // socks5 resolves DNS locally; socks5h/socks4a defer to the proxy.
84
+ return new SocksProxyAgent(proxy);
85
+ }
86
+ function sanitizeHeaders(headers) {
87
+ const out = {};
88
+ if (!headers)
89
+ return out;
90
+ for (const [key, value] of Object.entries(headers)) {
91
+ if (value === undefined)
92
+ continue;
93
+ if (STRIPPED_REQUEST_HEADERS.has(key.toLowerCase()))
94
+ continue;
95
+ out[key] = value;
96
+ }
97
+ return out;
98
+ }
99
+ function isSameOrigin(a, b) {
100
+ return (a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port);
101
+ }
102
+ /**
103
+ * A Web-`Response`-like view. Consume the body exactly once: either as a
104
+ * stream (`body`) for SSE pass-through, or buffered (`text`/`json`/
105
+ * `arrayBuffer`) with the response size limit enforced. Both paths re-arm the
106
+ * idle timer, and finalize timers/abort listener/proxy Agent on completion.
107
+ */
108
+ class NodeTransportResponse {
109
+ status;
110
+ statusText;
111
+ headers;
112
+ ctx;
113
+ consumed = false;
114
+ pendingFatal = null;
115
+ failSink = null;
116
+ constructor(status, statusText, headers, ctx) {
117
+ this.status = status;
118
+ this.statusText = statusText;
119
+ this.headers = headers;
120
+ this.ctx = ctx;
121
+ }
122
+ get ok() {
123
+ return this.status >= 200 && this.status < 300;
124
+ }
125
+ /** Routes a fatal condition (idle/total timeout, abort) to the active consumer. */
126
+ fail(err) {
127
+ if (this.failSink)
128
+ this.failSink(err);
129
+ else
130
+ this.pendingFatal = err;
131
+ }
132
+ get body() {
133
+ if (this.consumed)
134
+ return null;
135
+ this.consumed = true;
136
+ const ctx = this.ctx;
137
+ const { res } = ctx;
138
+ return new ReadableStream({
139
+ start: (controller) => {
140
+ let closed = false;
141
+ const stop = (err) => {
142
+ if (closed)
143
+ return;
144
+ closed = true;
145
+ ctx.finalize();
146
+ if (err) {
147
+ res.destroy();
148
+ try {
149
+ controller.error(err);
150
+ }
151
+ catch {
152
+ // already errored
153
+ }
154
+ }
155
+ else {
156
+ try {
157
+ controller.close();
158
+ }
159
+ catch {
160
+ // already closed
161
+ }
162
+ }
163
+ };
164
+ this.failSink = (err) => stop(err);
165
+ if (this.pendingFatal) {
166
+ stop(this.pendingFatal);
167
+ return;
168
+ }
169
+ res.on("data", (chunk) => {
170
+ ctx.armIdle();
171
+ controller.enqueue(new Uint8Array(chunk));
172
+ });
173
+ res.on("end", () => stop(null));
174
+ res.on("error", (err) => stop(err));
175
+ res.on("aborted", () => stop(new TransportTimeoutError("上游连接中断")));
176
+ res.resume();
177
+ },
178
+ cancel: () => {
179
+ ctx.finalize();
180
+ res.destroy();
181
+ },
182
+ });
183
+ }
184
+ buffered() {
185
+ if (this.consumed) {
186
+ return Promise.reject(new Error("响应 body 已被消费"));
187
+ }
188
+ this.consumed = true;
189
+ const ctx = this.ctx;
190
+ const { res } = ctx;
191
+ const max = ctx.maxResponseBytes;
192
+ return new Promise((resolve, reject) => {
193
+ const chunks = [];
194
+ let total = 0;
195
+ let done = false;
196
+ const finish = (err, buf) => {
197
+ if (done)
198
+ return;
199
+ done = true;
200
+ ctx.finalize();
201
+ if (err) {
202
+ res.destroy();
203
+ reject(err);
204
+ }
205
+ else {
206
+ resolve(buf ?? Buffer.concat(chunks));
207
+ }
208
+ };
209
+ this.failSink = (err) => finish(err);
210
+ if (this.pendingFatal) {
211
+ finish(this.pendingFatal);
212
+ return;
213
+ }
214
+ res.on("data", (chunk) => {
215
+ ctx.armIdle();
216
+ total += chunk.byteLength;
217
+ if (max !== undefined && total > max) {
218
+ finish(new ResponseLimitError());
219
+ return;
220
+ }
221
+ chunks.push(chunk);
222
+ });
223
+ res.on("end", () => finish(null, Buffer.concat(chunks)));
224
+ res.on("error", (err) => finish(err));
225
+ res.on("aborted", () => finish(new TransportTimeoutError("上游连接中断")));
226
+ res.resume();
227
+ });
228
+ }
229
+ async arrayBuffer() {
230
+ const buf = await this.buffered();
231
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
232
+ }
233
+ async text() {
234
+ return (await this.buffered()).toString("utf8");
235
+ }
236
+ async json() {
237
+ return JSON.parse(await this.text());
238
+ }
239
+ }
240
+ export async function requestWithNodeTransport(options) {
241
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
242
+ const origin = parseTarget(options.url);
243
+ let current = origin;
244
+ for (let redirects = 0;; redirects += 1) {
245
+ const result = await performRequest(current, options);
246
+ if (result.type === "redirect" &&
247
+ redirects < maxRedirects &&
248
+ result.location) {
249
+ const next = parseTarget(new URL(result.location, current).toString());
250
+ if (!isSameOrigin(next, origin)) {
251
+ throw new CrossOriginRedirectError(`拒绝跨源 redirect: ${current.origin} → ${next.origin}`);
252
+ }
253
+ current = next;
254
+ continue;
255
+ }
256
+ if (result.type === "redirect" && redirects >= maxRedirects) {
257
+ throw new TransportProtocolError("redirect 次数超过上限");
258
+ }
259
+ return result.response;
260
+ }
261
+ }
262
+ function parseTarget(url) {
263
+ let target;
264
+ try {
265
+ target = new URL(url);
266
+ }
267
+ catch {
268
+ throw new TransportProtocolError(`无效的目标 URL: ${url}`);
269
+ }
270
+ if (!SUPPORTED_TARGET_PROTOCOLS.has(target.protocol)) {
271
+ throw new TransportProtocolError(`不支持的 target protocol: ${target.protocol}`);
272
+ }
273
+ return target;
274
+ }
275
+ function performRequest(target, options) {
276
+ const proxyUrl = selectProxyUrl(target, options.proxy);
277
+ const agent = proxyUrl ? createTransportAgent(target, proxyUrl) : undefined;
278
+ const isHttps = target.protocol === "https:";
279
+ const requestImpl = isHttps ? httpsRequest : httpRequest;
280
+ const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
281
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
282
+ const totalTimeoutMs = options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
283
+ const maxResponseBytes = options.maxResponseBytes;
284
+ const headers = sanitizeHeaders(options.headers);
285
+ const bodyBuffer = normalizeBody(options.body);
286
+ if (bodyBuffer) {
287
+ headers["Content-Length"] = String(bodyBuffer.byteLength);
288
+ }
289
+ return new Promise((resolve, reject) => {
290
+ let settled = false;
291
+ let req = null;
292
+ const timers = new Set();
293
+ // Set once headers arrive; routes body-phase failures to the body reader.
294
+ let bodyController = null;
295
+ const timerBag = {
296
+ clearAll() {
297
+ for (const timer of timers)
298
+ clearTimeout(timer);
299
+ timers.clear();
300
+ },
301
+ };
302
+ const cleanupAgent = () => {
303
+ if (agent && typeof agent.destroy === "function") {
304
+ agent.destroy();
305
+ }
306
+ };
307
+ const fail = (err) => {
308
+ if (settled)
309
+ return;
310
+ settled = true;
311
+ timerBag.clearAll();
312
+ if (options.signal)
313
+ options.signal.removeEventListener("abort", onAbort);
314
+ req?.destroy();
315
+ cleanupAgent();
316
+ reject(err);
317
+ };
318
+ // Fatal condition (timeout/abort): before headers reject the request
319
+ // promise; during the body phase reject the body reader instead.
320
+ const onFatal = (err) => {
321
+ if (bodyController)
322
+ bodyController.fail(err);
323
+ else
324
+ fail(err);
325
+ };
326
+ const onAbort = () => {
327
+ const reason = options.signal?.reason instanceof Error
328
+ ? options.signal.reason
329
+ : abortError();
330
+ onFatal(reason);
331
+ };
332
+ if (options.signal) {
333
+ if (options.signal.aborted) {
334
+ cleanupAgent();
335
+ reject(abortError());
336
+ return;
337
+ }
338
+ options.signal.addEventListener("abort", onAbort, { once: true });
339
+ }
340
+ const totalTimer = setTimeout(() => onFatal(new TransportTimeoutError("上游总超时")), totalTimeoutMs);
341
+ timers.add(totalTimer);
342
+ req = requestImpl({
343
+ protocol: target.protocol,
344
+ hostname: target.hostname,
345
+ port: target.port || (isHttps ? 443 : 80),
346
+ path: `${target.pathname}${target.search}`,
347
+ method: options.method ?? "GET",
348
+ headers,
349
+ agent,
350
+ }, (res) => {
351
+ const location = res.headers.location;
352
+ const status = res.statusCode ?? 0;
353
+ const isRedirect = status >= 300 && status < 400 && typeof location === "string";
354
+ if (isRedirect) {
355
+ // Drain and discard the redirect body, keep the connection tidy.
356
+ res.resume();
357
+ if (!settled) {
358
+ settled = true;
359
+ timerBag.clearAll();
360
+ if (options.signal)
361
+ options.signal.removeEventListener("abort", onAbort);
362
+ cleanupAgent();
363
+ const redirectResponse = new NodeTransportResponse(status, res.statusMessage ?? "", toHeaders(res.headers), { res, armIdle: () => undefined, finalize: () => undefined });
364
+ resolve({
365
+ type: "redirect",
366
+ location: location,
367
+ response: redirectResponse,
368
+ });
369
+ }
370
+ return;
371
+ }
372
+ // Pause until a consumer (stream or buffered) attaches, so no chunk is
373
+ // lost between headers and consumption.
374
+ res.pause();
375
+ let idleTimer = null;
376
+ let finalized = false;
377
+ const armIdle = () => {
378
+ if (idleTimer) {
379
+ clearTimeout(idleTimer);
380
+ timers.delete(idleTimer);
381
+ }
382
+ idleTimer = setTimeout(() => bodyController?.fail(new TransportTimeoutError("上游空闲超时")), idleTimeoutMs);
383
+ timers.add(idleTimer);
384
+ };
385
+ const finalize = () => {
386
+ if (finalized)
387
+ return;
388
+ finalized = true;
389
+ timerBag.clearAll();
390
+ if (options.signal)
391
+ options.signal.removeEventListener("abort", onAbort);
392
+ cleanupAgent();
393
+ };
394
+ const response = new NodeTransportResponse(status, res.statusMessage ?? "", toHeaders(res.headers), { res, maxResponseBytes, armIdle, finalize });
395
+ bodyController = { fail: (err) => response.fail(err) };
396
+ armIdle();
397
+ if (!settled) {
398
+ settled = true;
399
+ resolve({ type: "response", response });
400
+ }
401
+ });
402
+ req.setTimeout(connectTimeoutMs, () => {
403
+ fail(new TransportTimeoutError("上游连接超时"));
404
+ });
405
+ req.on("error", (err) => fail(err));
406
+ if (bodyBuffer)
407
+ req.write(bodyBuffer);
408
+ req.end();
409
+ });
410
+ }
411
+ function normalizeBody(body) {
412
+ if (body == null)
413
+ return null;
414
+ if (Buffer.isBuffer(body))
415
+ return body;
416
+ if (typeof body === "string")
417
+ return Buffer.from(body, "utf8");
418
+ return Buffer.from(body);
419
+ }
420
+ function toHeaders(raw) {
421
+ const headers = new Headers();
422
+ for (const [key, value] of Object.entries(raw)) {
423
+ if (value === undefined)
424
+ continue;
425
+ if (Array.isArray(value)) {
426
+ for (const item of value)
427
+ headers.append(key, item);
428
+ }
429
+ else {
430
+ headers.set(key, value);
431
+ }
432
+ }
433
+ return headers;
434
+ }
435
+ function abortError() {
436
+ const err = new Error("请求已取消");
437
+ err.name = "AbortError";
438
+ return err;
439
+ }
@@ -1,5 +1,6 @@
1
- import { ensureBridgeForProfile, isBridgeAlive, isPidRunning, readPid, runBridgeForeground, startBridgeDaemon, stopBridge, upstreamFromProfile, } from "../bridge/manager.js";
2
- import { bridgeBaseUrl, bridgeRootUrl, readBridgeState, writeBridgeUpstream, } from "../bridge/state.js";
1
+ import { ensureBridgeForProfile, isBridgeAlive, isPidRunning, readPid, runBridgeForeground, startBridgeDaemon, stopBridge, } from "../bridge/manager.js";
2
+ import { bridgeBaseUrl, bridgeRootUrl, readBridgeState, } from "../bridge/state.js";
3
+ import { parseBridgePort } from "../bridge/runtime.js";
3
4
  import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT } from "../bridge/types.js";
4
5
  import { getActiveProfile, requireProfile } from "../store/profiles.js";
5
6
  import { isTool } from "../types.js";
@@ -12,25 +13,27 @@ export function registerBridgeCommand(program) {
12
13
  .description("前台运行 bridge(守护进程由 use/launch 自动拉起)")
13
14
  .option("--host <host>", "监听地址", DEFAULT_BRIDGE_HOST)
14
15
  .option("--port <port>", "监听端口", String(DEFAULT_BRIDGE_PORT))
16
+ .option("--allow-remote", "允许非回环监听(仍强制认证和限制)")
15
17
  .action(async (opts) => {
16
- const port = Number(opts.port) || DEFAULT_BRIDGE_PORT;
17
- await runBridgeForeground(opts.host || DEFAULT_BRIDGE_HOST, port);
18
+ const port = parseBridgePort(opts.port);
19
+ await runBridgeForeground(opts.host || DEFAULT_BRIDGE_HOST, port, Boolean(opts.allowRemote));
18
20
  });
19
21
  bridge
20
22
  .command("start")
21
23
  .description("后台启动 bridge")
22
24
  .option("--host <host>", "监听地址", DEFAULT_BRIDGE_HOST)
23
25
  .option("--port <port>", "监听端口", String(DEFAULT_BRIDGE_PORT))
26
+ .option("--allow-remote", "允许非回环监听(仍强制认证和限制)")
24
27
  .action(async (opts) => {
25
28
  const host = opts.host || DEFAULT_BRIDGE_HOST;
26
- const port = Number(opts.port) || DEFAULT_BRIDGE_PORT;
27
- const pid = await startBridgeDaemon(host, port);
29
+ const port = parseBridgePort(opts.port);
30
+ const pid = await startBridgeDaemon(host, port, Boolean(opts.allowRemote));
28
31
  for (let i = 0; i < 30; i++) {
29
- if (await isBridgeAlive(host, port))
32
+ if (await isBridgeAlive())
30
33
  break;
31
34
  await new Promise((r) => setTimeout(r, 100));
32
35
  }
33
- if (!(await isBridgeAlive(host, port))) {
36
+ if (!(await isBridgeAlive())) {
34
37
  throw new Error("bridge 启动失败,请尝试:llms bridge serve");
35
38
  }
36
39
  console.log(`bridge 已启动 pid=${pid} ${bridgeRootUrl()}`);
@@ -106,8 +109,7 @@ export function registerBridgeCommand(program) {
106
109
  if (!profile) {
107
110
  throw new Error(`没有可用的 ${tool} profile`);
108
111
  }
109
- writeBridgeUpstream(tool, upstreamFromProfile(profile, tool));
110
- const base = await ensureBridgeForProfile(profile, tool);
111
- console.log(`已刷新 ${tool} 上游 ${profile.name} → bridge ${base}`);
112
+ const connection = await ensureBridgeForProfile(profile, tool);
113
+ console.log(`已刷新 ${tool} 上游 ${profile.name} → bridge ${connection.baseUrl}`);
112
114
  });
113
115
  }
@@ -1,4 +1,5 @@
1
1
  import * as p from "@clack/prompts";
2
+ import { normalizeProxyValue } from "../types.js";
2
3
  import { isApiFormat } from "../types.js";
3
4
  import { formatLabel, supportedFormats } from "../formats/compatibility.js";
4
5
  import { getPreset, presetsForTool } from "../presets/index.js";
@@ -294,14 +295,10 @@ export async function promptProfileDraft(tool, partial = {}) {
294
295
  apiKey = v || "";
295
296
  }
296
297
  // Proxy before model fetch so listing can go through the same upstream proxy.
297
- let proxyHttp = partial.proxyHttp;
298
- let proxyHttps = partial.proxyHttps;
299
- let proxyAll = partial.proxyAll;
300
- if (proxyHttp === undefined &&
301
- proxyHttps === undefined &&
302
- proxyAll === undefined) {
298
+ let proxyUrl = partial.proxy;
299
+ if (proxyUrl === undefined) {
303
300
  const wantProxy = await p.confirm({
304
- message: "是否配置上游代理?(拉取模型与后续工具请求均可走 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY)",
301
+ message: "是否配置上游代理?(拉取模型与后续该 provider 的请求都会走此代理)",
305
302
  initialValue: false,
306
303
  });
307
304
  if (p.isCancel(wantProxy)) {
@@ -309,28 +306,15 @@ export async function promptProfileDraft(tool, partial = {}) {
309
306
  process.exit(0);
310
307
  }
311
308
  if (wantProxy) {
312
- proxyAll =
309
+ proxyUrl =
313
310
  (await promptText({
314
- message: "ALL_PROXY(如 socks5h://127.0.0.1:1080,可留空)",
315
- placeholder: "socks5h://127.0.0.1:1080",
316
- })) || undefined;
317
- proxyHttp =
318
- (await promptText({
319
- message: "HTTP_PROXY(可留空)",
320
- placeholder: "http://127.0.0.1:5112",
321
- })) || undefined;
322
- proxyHttps =
323
- (await promptText({
324
- message: "HTTPS_PROXY(可留空)",
325
- placeholder: "http://127.0.0.1:5112",
311
+ message: "代理地址(支持 http/https/socks5,可留空)",
312
+ placeholder: "socks5://127.0.0.1:1080",
313
+ validate: validateProxyUrl,
326
314
  })) || undefined;
327
315
  }
328
316
  }
329
- const proxy = buildProxyConfig({
330
- http: proxyHttp,
331
- https: proxyHttps,
332
- all: proxyAll,
333
- });
317
+ const proxy = buildProxyConfig({ url: proxyUrl });
334
318
  if (tool === "codex" && apiFormat === "openai-chat" && !bridgeMode) {
335
319
  bridgeMode = "chat";
336
320
  }
@@ -417,23 +401,13 @@ export async function promptEditProfile(tool, current) {
417
401
  }
418
402
  apiKey = v || "";
419
403
  }
420
- const http = await promptText({
421
- message: "HTTP_PROXY(留空清除)",
422
- initialValue: current.proxy?.http || "",
423
- });
424
- const https = await promptText({
425
- message: "HTTPS_PROXY(留空清除)",
426
- initialValue: current.proxy?.https || "",
427
- });
428
- const all = await promptText({
429
- message: "ALL_PROXY(留空清除)",
430
- initialValue: current.proxy?.all || "",
431
- });
432
- const proxy = buildProxyConfig({
433
- http: http || undefined,
434
- https: https || undefined,
435
- all: all || undefined,
404
+ const proxyInput = await promptText({
405
+ message: "代理地址(支持 http/https/socks5,留空清除)",
406
+ placeholder: "socks5://127.0.0.1:1080",
407
+ initialValue: current.proxy || "",
408
+ validate: validateProxyUrl,
436
409
  });
410
+ const proxy = buildProxyConfig({ url: proxyInput || undefined });
437
411
  const next = {
438
412
  ...current,
439
413
  displayName,
@@ -581,15 +555,37 @@ export async function tryFetchModels(input) {
581
555
  return null;
582
556
  }
583
557
  }
584
- export function buildProxyConfig(input) {
585
- const proxy = {};
586
- if (input.http?.trim())
587
- proxy.http = input.http.trim();
588
- if (input.https?.trim())
589
- proxy.https = input.https.trim();
590
- if (input.all?.trim())
591
- proxy.all = input.all.trim();
592
- if (!proxy.http && !proxy.https && !proxy.all)
558
+ const SUPPORTED_PROXY_SCHEMES = [
559
+ "http",
560
+ "https",
561
+ "socks",
562
+ "socks4",
563
+ "socks4a",
564
+ "socks5",
565
+ "socks5h",
566
+ ];
567
+ /**
568
+ * Validate a single proxy URL for the interactive prompts. Empty is allowed
569
+ * (clears the proxy). Returns an error message string when invalid.
570
+ */
571
+ export function validateProxyUrl(value) {
572
+ const trimmed = value?.trim();
573
+ if (!trimmed)
593
574
  return undefined;
594
- return proxy;
575
+ let url;
576
+ try {
577
+ url = new URL(trimmed);
578
+ }
579
+ catch {
580
+ return "代理地址格式无效,示例:http://127.0.0.1:7890 或 socks5://127.0.0.1:1080";
581
+ }
582
+ const scheme = url.protocol.replace(/:$/, "").toLowerCase();
583
+ if (!SUPPORTED_PROXY_SCHEMES.includes(scheme)) {
584
+ return `不支持的代理协议「${scheme}」。支持:http、https、socks5(含 socks/socks4/socks4a/socks5h)`;
585
+ }
586
+ return undefined;
587
+ }
588
+ /** Normalize a single proxy URL input into the stored value. */
589
+ export function buildProxyConfig(input) {
590
+ return normalizeProxyValue(input.url);
595
591
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { readFileSync } from "node:fs";
3
- import { isApiFormat } from "../types.js";
3
+ import { isApiFormat, normalizeProxyValue } from "../types.js";
4
4
  import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
5
5
  import { atomicWriteFile, ensureDir, maskSecret } from "../utils/fs.js";
6
6
  import { getProfilePath, getProfilesDir, getStatePath, getToolStoreDir, } from "../utils/paths.js";
@@ -191,7 +191,7 @@ function normalizeProfile(raw, fallbackName) {
191
191
  fast: raw.models?.fast || undefined,
192
192
  list: list.length ? list : raw.models?.default ? [raw.models.default] : [],
193
193
  },
194
- proxy: raw.proxy,
194
+ proxy: normalizeProxyValue(raw.proxy),
195
195
  bridgeMode: raw.bridgeMode,
196
196
  headers: raw.headers || {},
197
197
  updatedAt: raw.updatedAt || new Date(0).toISOString(),
package/dist/types.js CHANGED
@@ -11,7 +11,24 @@ export function isApiFormat(value) {
11
11
  return API_FORMATS.includes(value);
12
12
  }
13
13
  export function emptyProxy(proxy) {
14
- if (!proxy)
15
- return true;
16
- return !proxy.http && !proxy.https && !proxy.all;
14
+ return !proxy || !proxy.trim();
15
+ }
16
+ /**
17
+ * Coerce a stored proxy value into a single URL string. Accepts the current
18
+ * string form, or the legacy `{ http, https, all }` object (preferring `all`,
19
+ * then `https`, then `http`). Returns undefined when no proxy is set.
20
+ */
21
+ export function normalizeProxyValue(raw) {
22
+ if (typeof raw === "string") {
23
+ const trimmed = raw.trim();
24
+ return trimmed ? trimmed : undefined;
25
+ }
26
+ if (raw && typeof raw === "object") {
27
+ const row = raw;
28
+ for (const value of [row.all, row.https, row.http]) {
29
+ if (typeof value === "string" && value.trim())
30
+ return value.trim();
31
+ }
32
+ }
33
+ return undefined;
17
34
  }