@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.
- package/README.md +1 -1
- package/dist/adapters/claude.js +8 -5
- package/dist/adapters/codex.js +10 -4
- package/dist/bridge/manager.js +241 -146
- package/dist/bridge/runtime.js +199 -0
- package/dist/bridge/server.js +207 -80
- package/dist/bridge/state.js +345 -69
- package/dist/bridge/translate-response.js +205 -80
- package/dist/bridge/transport.js +439 -0
- package/dist/commands/bridge-cmd.js +13 -11
- package/dist/commands/prompts.js +47 -51
- package/dist/store/profiles.js +2 -2
- package/dist/types.js +20 -3
- package/dist/utils/fetch-models.js +45 -56
- package/dist/utils/proxy.js +14 -37
- package/package.json +5 -3
|
@@ -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,
|
|
2
|
-
import { bridgeBaseUrl, bridgeRootUrl, readBridgeState,
|
|
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 =
|
|
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 =
|
|
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(
|
|
32
|
+
if (await isBridgeAlive())
|
|
30
33
|
break;
|
|
31
34
|
await new Promise((r) => setTimeout(r, 100));
|
|
32
35
|
}
|
|
33
|
-
if (!(await isBridgeAlive(
|
|
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
|
-
|
|
110
|
-
|
|
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
|
}
|
package/dist/commands/prompts.js
CHANGED
|
@@ -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
|
|
298
|
-
|
|
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: "
|
|
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
|
-
|
|
309
|
+
proxyUrl =
|
|
313
310
|
(await promptText({
|
|
314
|
-
message: "
|
|
315
|
-
placeholder: "
|
|
316
|
-
|
|
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
|
|
421
|
-
message: "
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/store/profiles.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
}
|