magpie-gemini-web 0.5.6 → 0.7.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/lib/backend.mjs CHANGED
@@ -1,16 +1,20 @@
1
1
  import { citeStream, citeJSON } from './cite.mjs';
2
+ import { validateProxyURL } from './setup.mjs';
2
3
 
3
4
  export const DEFAULT_BASE = 'http://127.0.0.1:8083/v1';
5
+ export const PLUGIN_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyIiB5MT0iMyIgeDI9IjIwIiB5Mj0iMjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjMDZCNkQ0Ii8+PHN0b3Agb2Zmc2V0PSI1MCUiIHN0b3AtY29sb3I9IiMzQjgyRjYiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM4QjVDRjYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjE0IiB5MT0iMiIgeDI9IjIyIiB5Mj0iMTAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjOEI1Q0Y2Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRUM0ODk5Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEwIDMuMkMxMC4zNSAzLjIgMTAuNjIgMy40NSAxMC43MiAzLjhDMTEuNDUgNi44NSAxMy4xNSA4LjU1IDE2LjIgOS4yOEMxNi41NSA5LjM4IDE2LjggOS42NSAxNi44IDEwQzE2LjggMTAuMzUgMTYuNTUgMTAuNjIgMTYuMiAxMC43MkMxMy4xNSAxMS40NSAxMS40NSAxMy4xNSAxMC43MiAxNi4yQzEwLjYyIDE2LjU1IDEwLjM1IDE2LjggMTAgMTYuOEM5LjY1IDE2LjggOS4zOCAxNi41NSA5LjI4IDE2LjJDOC41NSAxMy4xNSA2Ljg1IDExLjQ1IDMuOCAxMC43MkMzLjQ1IDEwLjYyIDMuMiAxMC4zNSAzLjIgMTBDMy4yIDkuNjUgMy40NSA5LjM4IDMuOCA5LjI4QzYuODUgOC41NSA4LjU1IDYuODUgOS4yOCAzLjhDOS4zOCAzLjQ1IDkuNjUgMy4yIDEwIDMuMloiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0wLjUgMi41KSBzY2FsZSgxLjEyKSIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Ik0xOC4yIDEuOEMxOC40MiAxLjggMTguNiAxLjk1IDE4LjY2IDIuMTZDMTkuMDggMy44MiAyMC4wMiA0Ljc2IDIxLjY4IDUuMThDMjEuODkgNS4yNCAyMi4wNCA1LjQyIDIyLjA0IDUuNjRDMjIuMDQgNS44NiAyMS44OSA2LjA0IDIxLjY4IDYuMUMyMC4wMiA2LjUyIDE5LjA4IDcuNDYgMTguNjYgOS4xMkMxOC42IDkuMzMgMTguNDIgOS40OCAxOC4yIDkuNDhDMTcuOTggOS40OCAxNy44IDkuMzMgMTcuNzQgOS4xMkMxNy4zMiA3LjQ2IDE2LjM4IDYuNTIgMTQuNzIgNi4xQzE0LjUxIDYuMDQgMTQuMzYgNS44NiAxNC4zNiA1LjY0QzE0LjM2IDUuNDIgMTQuNTEgNS4yNCAxNC43MiA1LjE4QzE2LjM4IDQuNzYgMTcuMzIgMy44MiAxNy43NCAyLjE2QzE3LjggMS45NSAxNy45OCAxLjggMTguMiAxLjhaIiBmaWxsPSJ1cmwoI2IpIi8+PGNpcmNsZSBjeD0iMTcuOCIgY3k9IjE4LjIiIHI9IjEuMzUiIGZpbGw9IiM2MzY2RjEiLz48L3N2Zz4=';
4
6
 
5
7
  const MEDIA_SPECIAL = /(?:^|[-_])(image|music|canvas|video|research)(?:[-_]|$)/i;
6
8
  const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
7
9
  const NEWLINE_CHARS = /[\r\n]/;
8
10
 
9
- // The backend has no effort parameter: extended thinking is a separate "<id>-thinking" model.
10
- // A base model with such a twin offers low (itself) and high (the twin), and the twin isn't
11
- // listed on its own; a request that names it still goes through. Magpie maps any other level to
12
- // the nearest declared one before it reaches us; direct callers get the same rule.
11
+ // The backend has no effort parameter: extended thinking uses separate "<id>-thinking-medium"
12
+ // and "<id>-thinking" models. A base model with such twins offers low (itself), medium (when
13
+ // "<id>-thinking-medium" exists), and high ("<id>-thinking"), while the twins aren't listed on
14
+ // their own; a request that names one directly still goes through. Magpie maps any other level
15
+ // to the nearest declared one before it reaches us; direct callers get the same rule.
13
16
  const THINKING_SUFFIX = '-thinking';
17
+ const MEDIUM_THINKING_SUFFIX = '-thinking-medium';
14
18
  const LOW_EFFORTS = new Set(['none', 'minimal', 'low']);
15
19
  const MODEL_CACHE_MS = 10 * 60 * 1000;
16
20
  const knownModels = new Map();
@@ -21,6 +25,7 @@ export const UNREACHABLE_HEADER = 'X-Gemini-Web-Backend';
21
25
 
22
26
  const KNOWN_OPTIONS = new Set([
23
27
  'baseURL',
28
+ 'proxy',
24
29
  'allowTools',
25
30
  'allowImages',
26
31
  'contextLimit',
@@ -115,6 +120,13 @@ export function settings(options = {}) {
115
120
  baseURL = normalizeBaseURL(options.baseURL);
116
121
  }
117
122
 
123
+ let proxy = '';
124
+ if (options.proxy !== undefined) {
125
+ proxy = validateProxyURL(options.proxy);
126
+ } else if (process.env.GEMINI_WEB_PROXY) {
127
+ proxy = validateProxyURL(process.env.GEMINI_WEB_PROXY);
128
+ }
129
+
118
130
  // On by default: coding agents send tools with every request and don't read toolcall,
119
131
  // so turning this off only means every one of their requests gets a 400.
120
132
  let allowTools = true;
@@ -181,6 +193,7 @@ export function settings(options = {}) {
181
193
 
182
194
  return {
183
195
  baseURL,
196
+ proxy,
184
197
  allowTools,
185
198
  allowImages,
186
199
  contextLimit,
@@ -289,7 +302,16 @@ export async function listModels(c, opts) {
289
302
  const result = Object.create(null);
290
303
  for (const id of validIds) {
291
304
  if (id.endsWith(THINKING_SUFFIX) && ids.has(id.slice(0, -THINKING_SUFFIX.length))) continue;
292
- const hasTwin = !id.endsWith(THINKING_SUFFIX) && ids.has(id + THINKING_SUFFIX);
305
+ if (id.endsWith(MEDIUM_THINKING_SUFFIX) && ids.has(id.slice(0, -MEDIUM_THINKING_SUFFIX.length))) continue;
306
+ const isTwin = id.endsWith(THINKING_SUFFIX) || id.endsWith(MEDIUM_THINKING_SUFFIX);
307
+ const hasMedium = !isTwin && ids.has(id + MEDIUM_THINKING_SUFFIX);
308
+ const hasHigh = !isTwin && ids.has(id + THINKING_SUFFIX);
309
+ const variants = {};
310
+ if (hasMedium || hasHigh) {
311
+ variants.low = {};
312
+ if (hasMedium) variants.medium = {};
313
+ if (hasHigh) variants.high = {};
314
+ }
293
315
  result[id] = {
294
316
  id,
295
317
  providerID: 'gemini-web',
@@ -326,7 +348,7 @@ export async function listModels(c, opts) {
326
348
  },
327
349
  options: {},
328
350
  headers: {},
329
- variants: hasTwin ? { low: {}, high: {} } : {},
351
+ variants,
330
352
  status: 'active'
331
353
  };
332
354
  }
@@ -345,7 +367,7 @@ async function thinkingTwin(body, c, opts) {
345
367
  const effort = body.reasoning_effort;
346
368
  const model = body.model;
347
369
  if (typeof effort !== 'string' || LOW_EFFORTS.has(effort)) return null;
348
- if (typeof model !== 'string' || model.endsWith(THINKING_SUFFIX)) return null;
370
+ if (typeof model !== 'string' || model.endsWith(THINKING_SUFFIX) || model.endsWith(MEDIUM_THINKING_SUFFIX)) return null;
349
371
 
350
372
  let cached = knownModels.get(modelCacheKey(c));
351
373
  if (!cached || Date.now() - cached.at > MODEL_CACHE_MS) {
@@ -356,6 +378,9 @@ async function thinkingTwin(body, c, opts) {
356
378
  }
357
379
  cached = knownModels.get(modelCacheKey(c));
358
380
  }
381
+ if (effort === 'medium' && cached?.ids.has(model + MEDIUM_THINKING_SUFFIX)) {
382
+ return model + MEDIUM_THINKING_SUFFIX;
383
+ }
359
384
  const twin = model + THINKING_SUFFIX;
360
385
  return cached?.ids.has(twin) ? twin : null;
361
386
  }
@@ -535,12 +560,14 @@ export async function forward(input, init, getAuth, opts) {
535
560
  let upstreamRes;
536
561
  timer.start();
537
562
  try {
563
+ // Bun's fetch drops a connection idle for 300 s; the timer sets the deadline instead.
538
564
  upstreamRes = await fetch(incomingUrl, {
539
565
  method: req.method,
540
566
  headers,
541
567
  body: rawBody,
542
568
  signal,
543
- redirect: 'error'
569
+ redirect: 'error',
570
+ timeout: false
544
571
  });
545
572
  } catch (err) {
546
573
  if (req.signal.aborted) {
package/lib/native.mjs CHANGED
@@ -2,23 +2,24 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import crypto from 'node:crypto';
5
- import { spawn } from 'node:child_process';
5
+ import { spawn, execFile } from 'node:child_process';
6
+ import { validateProxyURL } from './setup.mjs';
6
7
  import release from './backend-release.json' with { type: 'json' };
7
8
 
8
9
  export const BACKEND_VERSION = `v${release.version}`;
9
10
  const RELEASE_BASE = `https://github.com/${release.repository}/releases/download/${release.tag}/`;
10
11
  export const PINNED_BINARIES = release.binaries;
11
12
 
12
- const UPSTREAM_VERSION = 'v4.22.0';
13
+ const UPSTREAM_VERSION = 'v4.26.0';
13
14
  const UPSTREAM_BASE = `https://github.com/zexadev/gemini-web2api-go/releases/download/${UPSTREAM_VERSION}/`;
14
15
  // Retained for an explicit, checksum-verified rollback to the upstream release.
15
16
  export const UPSTREAM_BINARIES = {
16
- 'darwin-arm64': { asset: 'gemini-web2api-go_v4.22.0_darwin_arm64', sha256: 'c69ca75cf43017cf2a62dbf5c12b07f6bde66f403f714c32209b1ad350ed24c9' },
17
- 'darwin-x64': { asset: 'gemini-web2api-go_v4.22.0_darwin_amd64', sha256: '384e617bcc30aacac6b7c4e4d0490e88d282b13b29f7a0a20d645b69514cd405' },
18
- 'linux-x64': { asset: 'gemini-web2api-go_v4.22.0_linux_amd64', sha256: '21fcc81e0ad8ce0921e6b6c2cafce1089852530142b37e0289355c6e72df5299' },
19
- 'linux-arm64': { asset: 'gemini-web2api-go_v4.22.0_linux_arm64', sha256: 'f2822fa0f1a7d2b37c2bc9cbb30a95c2e8028462453a110389511408bdfd4fd7' },
20
- 'win32-x64': { asset: 'gemini-web2api-go_v4.22.0_windows_amd64.exe', sha256: '17e37f7e5c813b2a8d90c9a0265e6cde3bcfa4e814822af999fe8c4178f02729' },
21
- 'win32-arm64': { asset: 'gemini-web2api-go_v4.22.0_windows_arm64.exe', sha256: 'b42e951bfc08d7fe8bdb436dca87fc6a1b7948bb8d10e110653bcf13a790756c' }
17
+ 'darwin-arm64': { asset: 'gemini-web2api-go_v4.26.0_darwin_arm64', sha256: 'cb1afabb5c5db57f99dc67b7cdf8c862a6e7aa33883cbe5355da6f696f02bc41' },
18
+ 'darwin-x64': { asset: 'gemini-web2api-go_v4.26.0_darwin_amd64', sha256: 'de4e519a65caa168b49d906c73e9012a0b8f09ec80978dc8d3efcb674bb28c8f' },
19
+ 'linux-x64': { asset: 'gemini-web2api-go_v4.26.0_linux_amd64', sha256: '2c0c4cb73989a34ca76a9e75c79e0e8684c6d7abc58231c65ef1eca4c411be81' },
20
+ 'linux-arm64': { asset: 'gemini-web2api-go_v4.26.0_linux_arm64', sha256: '5518a5f055e025b118d327b361f8c9f81df319132ed5e064eea191bbe129a50a' },
21
+ 'win32-x64': { asset: 'gemini-web2api-go_v4.26.0_windows_amd64.exe', sha256: '32717fed3ed02b5c82950a01ab3a49e6724de21be8b31c7bb83e8e2e8e454128' },
22
+ 'win32-arm64': { asset: 'gemini-web2api-go_v4.26.0_windows_arm64.exe', sha256: 'ed5a4f0b6f6b0078ff2173f9b3128ed0836748e42e35ea78ff1bc6e1f456e37d' }
22
23
  };
23
24
 
24
25
  export function pinnedBinary(platform = process.platform, arch = process.arch, channel = 'optimized') {
@@ -76,6 +77,7 @@ export async function installBackend(options = {}) {
76
77
  const expectedHash = options.expectedHash || pin.sha256;
77
78
  const downloadUrl = options.url || pin.url;
78
79
  const fetchFn = options.fetchFn || globalThis.fetch;
80
+ const proxy = validateProxyURL(options.proxy ?? process.env.GEMINI_WEB_PROXY ?? '');
79
81
 
80
82
  const dataDir = options.dataDir || getDataDir();
81
83
  fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
@@ -98,24 +100,57 @@ export async function installBackend(options = {}) {
98
100
 
99
101
  if (!binaryReady) {
100
102
  console.log(`Downloading backend binary from ${downloadUrl}...`);
101
- const res = await fetchFn(downloadUrl, {
102
- signal: AbortSignal.timeout(300000)
103
- });
104
- if (!res.ok) {
105
- throw new Error(`Failed to download binary: HTTP ${res.status}`);
106
- }
107
-
108
- const arrayBuffer = await res.arrayBuffer();
109
- const buffer = Buffer.from(arrayBuffer);
110
- const downloadedHash = crypto.createHash('sha256').update(buffer).digest('hex');
111
-
112
- if (downloadedHash !== expectedHash) {
113
- throw new Error(`SHA256 checksum mismatch: expected ${expectedHash}, got ${downloadedHash}`);
114
- }
115
-
116
103
  const tmpPath = path.join(binDir, `gemini-web2api-go.tmp.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`);
117
104
  try {
118
- fs.writeFileSync(tmpPath, buffer, { mode: 0o700 });
105
+ // A slow download goes on while data keeps coming; a minute without any gives it up.
106
+ if (proxy && (!options.fetchFn || options.fetchFn === globalThis.fetch)) {
107
+ const curlArgs = [
108
+ '--fail', '--location', '--silent', '--show-error',
109
+ '--connect-timeout', '15', '--speed-limit', '1', '--speed-time', '60',
110
+ '--proxy', proxy,
111
+ '-o', tmpPath, downloadUrl
112
+ ];
113
+ await new Promise((resolve, reject) => {
114
+ execFile('curl', curlArgs, (err, _stdout, stderr) => {
115
+ if (!err) return resolve();
116
+ const status = err.code || err.status || 'failed';
117
+ const reason = String(stderr || '').trim().slice(0, 500);
118
+ reject(new Error(`Failed to download binary via curl (status: ${status})${reason ? `: ${reason}` : ''}`));
119
+ });
120
+ });
121
+ fs.chmodSync(tmpPath, 0o700);
122
+ } else {
123
+ const stall = new AbortController();
124
+ let timer;
125
+ const rearm = () => {
126
+ clearTimeout(timer);
127
+ timer = setTimeout(() => stall.abort(new Error('Failed to download binary: no data for 60 s')), 60000);
128
+ };
129
+ const chunks = [];
130
+ try {
131
+ rearm();
132
+ const res = await fetchFn(downloadUrl, {
133
+ ...(proxy ? { proxy } : {}),
134
+ signal: stall.signal
135
+ });
136
+ if (!res.ok) {
137
+ throw new Error(`Failed to download binary: HTTP ${res.status}`);
138
+ }
139
+ for await (const chunk of res.body) {
140
+ chunks.push(chunk);
141
+ rearm();
142
+ }
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
146
+ fs.writeFileSync(tmpPath, Buffer.concat(chunks), { mode: 0o700 });
147
+ }
148
+
149
+ const downloadedHash = computeFileHash(tmpPath);
150
+ if (downloadedHash !== expectedHash) {
151
+ throw new Error(`SHA256 checksum mismatch: expected ${expectedHash}, got ${downloadedHash}`);
152
+ }
153
+
119
154
  fs.renameSync(tmpPath, binPath);
120
155
  fs.chmodSync(binPath, 0o700);
121
156
  console.log(`Backend binary installed: ${binPath}`);
@@ -341,9 +376,19 @@ async function withLock(dataDir, fn, timeoutMs) {
341
376
  await sleep(300);
342
377
  }
343
378
  }
379
+ // A download has no overall deadline, so the holder keeps the lock fresh while it works;
380
+ // only a lock left by a process that is gone goes stale.
381
+ const keepFresh = setInterval(() => {
382
+ try {
383
+ const now = new Date();
384
+ fs.utimesSync(lockPath, now, now);
385
+ } catch {}
386
+ }, Math.max(1000, Math.floor(timeoutMs / 4)));
387
+ keepFresh.unref?.();
344
388
  try {
345
389
  return await fn();
346
390
  } finally {
391
+ clearInterval(keepFresh);
347
392
  fs.rmSync(lockPath, { force: true });
348
393
  }
349
394
  }
@@ -376,7 +421,7 @@ export async function ensureBackend(options = {}) {
376
421
  const trusted = trustedHashes(dataDir, platform, arch);
377
422
  const binPath = binaryPath(dataDir, platform);
378
423
  if (!fs.existsSync(binPath) || !trusted.includes(computeFileHash(binPath))) {
379
- await installBackend({ dataDir, platform, arch, fetchFn, channel: backendChannel(dataDir) });
424
+ await installBackend({ dataDir, platform, arch, fetchFn, channel: backendChannel(dataDir), proxy: options.proxy });
380
425
  } else {
381
426
  ensureDataFiles(dataDir);
382
427
  }
@@ -389,8 +434,9 @@ export async function ensureBackend(options = {}) {
389
434
  const launch = prepareLaunch(dataDir, trusted, platform);
390
435
  const logPath = path.join(dataDir, 'backend.log');
391
436
  const log = fs.openSync(logPath, 'a', 0o600);
437
+ let child;
392
438
  try {
393
- const child = spawnFn(launch.binPath, launch.args, {
439
+ child = spawnFn(launch.binPath, launch.args, {
394
440
  env: launch.env,
395
441
  detached: true,
396
442
  stdio: ['ignore', log, log],
@@ -408,6 +454,11 @@ export async function ensureBackend(options = {}) {
408
454
  if (await backendState(setup, fetchFn) === 'up') return setup;
409
455
  await sleep(300);
410
456
  }
457
+ try { child?.kill?.('SIGTERM'); } catch {}
458
+ if (child?.pid && typeof child?.kill !== 'function') {
459
+ try { process.kill(child.pid, 'SIGTERM'); } catch {}
460
+ }
461
+ fs.rmSync(path.join(dataDir, 'backend.pid'), { force: true });
411
462
  throw new Error(`Gemini Web backend did not start within ${Math.round(startTimeoutMs / 1000)}s; see ${logPath}`);
412
463
  }, 600000);
413
464
  }
package/lib/setup.mjs CHANGED
@@ -1,5 +1,36 @@
1
1
  // Sign-in helpers for the one-click setup: turn what the user pasted into a Cookie header and
2
2
  // add it to the local backend's account pool through its admin API.
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ // Do not infer support from a version: upstream and local builds can have the same version
8
+ // while silently ignoring proxy_id. Recheck at submission too, in case the service restarted.
9
+ export async function requireLoginBackend(setup, options = {}) {
10
+ const fetchFn = options.fetchFn || globalThis.fetch;
11
+ let res, info;
12
+ try {
13
+ res = await fetchFn(`${setup.origin}/`, {
14
+ method: 'GET', redirect: 'error', signal: AbortSignal.timeout(5000)
15
+ });
16
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
17
+ info = await res.json();
18
+ } catch (err) {
19
+ throw new Error(`无法确认本地 Gemini Web 后端的登录能力(${setup.origin}):${err.message}`);
20
+ }
21
+ if (info?.capabilities?.account_proxy_binding === true) return;
22
+ const dataDir = options.dataDir || process.env.GEMINI_WEB_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'magpie-gemini-web');
23
+ const platform = options.platform || process.platform;
24
+ const script = fileURLToPath(new URL('../scripts/backend.mjs', import.meta.url));
25
+ // PowerShell and POSIX shells both accept single-quoted literal arguments, but escape
26
+ // apostrophes differently. Never interpolate credentials or proxy URLs into the hint.
27
+ const quote = value => `'${value.replaceAll("'", platform === 'win32' ? "''" : "'\\''")}'`;
28
+ const command = `node ${quote(script)} install --data-dir=${quote(path.resolve(dataDir))}`;
29
+ const stop = platform === 'win32'
30
+ ? '在任务管理器的「详细信息」中结束本数据目录对应的 gemini-web2api-go.exe'
31
+ : '在活动监视器或进程管理器中停止本数据目录对应的 gemini-web2api-go';
32
+ throw new Error(`正在运行的 Gemini Web 后端不支持登录时绑定代理,请先升级。先退出 Magpie,${stop},再执行:\n${command}\n数据目录:${path.resolve(dataDir)}。如下载需要代理,请先设置 GEMINI_WEB_PROXY 环境变量。升级后重新打开 Magpie 并登录;账号和配置会保留。`);
33
+ }
3
34
 
4
35
  // The sign-in cookies a gemini.google.com request carries; without these the backend serves
5
36
  // the request anonymously (or the account check fails).
@@ -79,6 +110,31 @@ export function parseCookieInput(input) {
79
110
  }
80
111
 
81
112
  const ADMIN_TIMEOUT_MS = 60000;
113
+ const PROXY_PROTOCOLS = new Set(['http:', 'https:', 'socks5:', 'socks5h:']);
114
+
115
+ // validateProxyURL checks a proxy URL meant for the backend's proxy pool (http, https, socks5,
116
+ // or socks5h). An empty or omitted value returns '', meaning no proxy is set here.
117
+ export function validateProxyURL(proxy) {
118
+ if (proxy === undefined || proxy === null || proxy === '') return '';
119
+ if (typeof proxy !== 'string') {
120
+ throw new Error('Invalid option proxy: must be a string');
121
+ }
122
+ const trimmed = proxy.trim();
123
+ if (!trimmed) return '';
124
+ if (/[\r\n]/.test(trimmed)) {
125
+ throw new Error('Invalid proxy URL: must not contain newlines');
126
+ }
127
+ let parsed;
128
+ try {
129
+ parsed = new URL(trimmed);
130
+ } catch {
131
+ throw new Error('代理地址格式无效:请填写完整的 URL,例如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080');
132
+ }
133
+ if (!PROXY_PROTOCOLS.has(parsed.protocol)) {
134
+ throw new Error('代理地址必须以 http://、https://、socks5:// 或 socks5h:// 开头');
135
+ }
136
+ return trimmed;
137
+ }
82
138
 
83
139
  // adminCall is one request to the backend's admin API. A request that gets no answer within
84
140
  // ADMIN_TIMEOUT_MS fails with timeoutMessage when one is given: the backend was reached, but what
@@ -108,10 +164,41 @@ async function adminCall(setup, fetchFn, method, path, body, timeoutMessage) {
108
164
  return data;
109
165
  }
110
166
 
167
+ // ensureProxy makes sure the given proxy URL is present, enabled, and not circuit-broken in the
168
+ // backend's proxy pool before the backend reaches gemini.google.com to check a cookie.
169
+ export async function ensureProxy(setup, proxy, options = {}) {
170
+ const url = validateProxyURL(proxy);
171
+ if (!url) return null;
172
+ const fetchFn = options.fetchFn || globalThis.fetch;
173
+ const name = options.name || 'Magpie';
174
+ const listed = await adminCall(setup, fetchFn, 'GET', 'proxies');
175
+ const items = Array.isArray(listed?.items) ? listed.items : [];
176
+ const exact = items.find(p => p && p.url === url && Number.isInteger(p.id));
177
+ if (exact) {
178
+ if (!exact.enabled) {
179
+ await adminCall(setup, fetchFn, 'PATCH', `proxies/${exact.id}`, { enabled: true });
180
+ }
181
+ if ((exact.fail_count ?? 0) > 0) {
182
+ await adminCall(setup, fetchFn, 'POST', `proxies/${exact.id}/reset`);
183
+ }
184
+ return { id: exact.id, url };
185
+ }
186
+ const managed = items.find(p => p && p.name === name && Number.isInteger(p.id));
187
+ if (managed) {
188
+ await adminCall(setup, fetchFn, 'PATCH', `proxies/${managed.id}`, { name, url, enabled: true });
189
+ if ((managed.fail_count ?? 0) > 0) {
190
+ await adminCall(setup, fetchFn, 'POST', `proxies/${managed.id}/reset`);
191
+ }
192
+ return { id: managed.id, url };
193
+ }
194
+ const created = await adminCall(setup, fetchFn, 'POST', 'proxies', { name, url, weight: 1 });
195
+ return { id: created.id, url };
196
+ }
197
+
111
198
  // The check is the backend fetching gemini.google.com with the cookie, directly or through the
112
199
  // backend's own proxy pool: neither Magpie's proxy nor HTTP_PROXY reach it.
113
200
  function proxyHint(setup) {
114
- return `后端不走 Magpie 的代理,也不读系统代理(HTTP_PROXY / HTTPS_PROXY):需要代理才能访问 Google 的话,请在管理面板 ${setup.origin}/admin 的代理池里添加(登录口令是数据目录 secrets.json 里的 adminToken),然后重试。`;
201
+ return `后端不走 Magpie 的代理,也不读系统代理(HTTP_PROXY / HTTPS_PROXY):需要代理才能访问 Google 的话,请在登录时填写代理地址(或设置插件选项 proxy / 环境变量 GEMINI_WEB_PROXY,也可以在管理面板 ${setup.origin}/admin 的代理池里添加,登录口令是数据目录 secrets.json 里的 adminToken),然后重试。`;
115
202
  }
116
203
 
117
204
  // checkFailure is the error for a check that came back not ok. The backend words the cookie's
@@ -128,11 +215,18 @@ function checkFailure(setup, detail) {
128
215
  // account that fails the check is removed again, and the error says why.
129
216
  export async function importCookie(setup, cookie, options = {}) {
130
217
  const fetchFn = options.fetchFn || globalThis.fetch;
218
+ await requireLoginBackend(setup, options);
219
+ let proxyId = Number.isInteger(options.proxyId) ? options.proxyId : undefined;
220
+ if (options.proxy) {
221
+ const ensured = await ensureProxy(setup, options.proxy, { fetchFn });
222
+ if (ensured && Number.isInteger(ensured.id)) proxyId = ensured.id;
223
+ }
131
224
  const label = options.label || `Magpie ${new Date().toISOString().slice(0, 10)}`;
132
225
  const added = await adminCall(setup, fetchFn, 'POST', 'cookies', {
133
226
  label,
134
227
  cookie,
135
- note: 'Added by Magpie one-click setup'
228
+ note: 'Added by Magpie one-click setup',
229
+ ...(proxyId !== undefined ? { proxy_id: proxyId } : {})
136
230
  });
137
231
  if (!Number.isInteger(added.id)) throw new Error('本地后端没有返回新账号的 id');
138
232
 
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import crypto from 'node:crypto';
5
5
  import net from 'node:net';
6
+ import http from 'node:http';
7
+ import { Readable } from 'node:stream';
6
8
  import { fileURLToPath } from 'node:url';
7
9
  import { spawn, execFile, execFileSync } from 'node:child_process';
8
10
  import { promisify } from 'node:util';
@@ -16,6 +18,7 @@ export const STUDIO_VERSION = `v${release.version}`;
16
18
  export const PINNED_ARCHIVES = release.binaries;
17
19
  // The port a new setup tries first: AIStudio2API's own default.
18
20
  export const DEFAULT_STUDIO_PORT = 2048;
21
+ const DEFAULT_PREVIEW_PORT = 2050;
19
22
  // backend.log is set aside as backend.log.1 when a start finds it past this size.
20
23
  const LOG_ROTATE_BYTES = 10 * 1024 * 1024;
21
24
 
@@ -34,7 +37,7 @@ export function setupStep(dataDir) {
34
37
  }
35
38
 
36
39
  export function studioDataDir(env = process.env) {
37
- return env.AI_STUDIO_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'magpie-ai-studio');
40
+ return path.resolve(env.AI_STUDIO_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'magpie-ai-studio'));
38
41
  }
39
42
 
40
43
  export function computeFileHash(filePath) {
@@ -59,7 +62,7 @@ export function studioCommand(action, { dataDir } = {}) {
59
62
  // has free. A user who already runs AIStudio2API keeps it on its default port, which a new setup
60
63
  // would otherwise find taken on every start.
61
64
  export async function pickStudioPort(preferred = DEFAULT_STUDIO_PORT) {
62
- if (!(await portAnswers(preferred)) && await canListen(preferred)) return preferred;
65
+ if (preferred > 0 && !(await portAnswers(preferred)) && await canListen(preferred)) return preferred;
63
66
  return new Promise((resolve, reject) => {
64
67
  const server = net.createServer();
65
68
  server.once('error', reject);
@@ -189,11 +192,12 @@ export function validateConfig(config) {
189
192
 
190
193
  // ENV_KEYS are the settings upstream reads from .env, in the order it writes them (config.configKeys).
191
194
  const ENV_KEYS = [
192
- 'AISTUDIO_AUTH_STATES', 'LISTEN_ADDR', 'PROXY_API_KEY', 'PROXY', 'INIT_TIMEOUT', 'REQUEST_TIMEOUT',
195
+ 'AISTUDIO_AUTH_STATES', 'LISTEN_ADDR', 'PREVIEW_LISTEN_ADDR', 'PROXY_API_KEY', 'PROXY', 'INIT_TIMEOUT', 'REQUEST_TIMEOUT',
193
196
  'WARM_WORKER_LIMIT', 'MAX_ACTIVE_WORKERS', 'WARM_STARTUP_CONCURRENCY', 'PER_ACCOUNT_CONCURRENCY',
194
- 'ROUTING_STRATEGY', 'UPSTREAM_CHANNELS', 'MODEL_MAPPING', 'TEMPORARY_CHAT', 'WAA_BACKEND', 'AUTO_START',
195
- 'REQUEST_BODY_LOG', 'LOG_FILE', 'LOG_MAX_TOTAL_MB', 'ADMIN_AUTH_ENABLED', 'ADMIN_USERNAME', 'ADMIN_PASSWORD',
196
- 'BUILD_NATIVE_NONSTREAM', 'MODEL_CHANNELS', 'MEDIA_OUTPUT', 'MEDIA_BASE_URL'
197
+ 'ROUTING_STRATEGY', 'UPSTREAM_CHANNELS', 'MODEL_MAPPING', 'MODEL_CHANNELS', 'TEMPORARY_CHAT', 'WAA_BACKEND',
198
+ 'MEDIA_OUTPUT', 'MEDIA_BASE_URL', 'AUTO_START', 'REQUEST_BODY_LOG', 'LOG_FILE', 'LOG_MAX_TOTAL_MB',
199
+ 'ADMIN_AUTH_ENABLED', 'ADMIN_USERNAME', 'ADMIN_PASSWORD', 'BUILD_NATIVE_NONSTREAM',
200
+ 'NONSTREAM_KEEPALIVE', 'FAKE_STREAM', 'YOUTUBE_LINKS', 'FIRST_EVENT_HEDGE'
197
201
  ];
198
202
 
199
203
  // PROXY_ENV are the proxy variables the service would fall back to with no PROXY of its own.
@@ -211,7 +215,8 @@ const SETTING_DEFAULTS = {
211
215
  WARM_STARTUP_CONCURRENCY: '1',
212
216
  ROUTING_STRATEGY: 'fill-first',
213
217
  REQUEST_TIMEOUT: '30m',
214
- INIT_TIMEOUT: '2m'
218
+ INIT_TIMEOUT: '2m',
219
+ NONSTREAM_KEEPALIVE: 'false'
215
220
  };
216
221
 
217
222
  // pluginEnv is the settings this plugin depends on to reach, trust and drive the service. The
@@ -305,6 +310,17 @@ function writeStudioEnv(dataDir, values) {
305
310
  fs.chmodSync(envPath, 0o600);
306
311
  }
307
312
 
313
+ function previewPort(addr) {
314
+ const match = /^.+:(\d+)$/.exec(String(addr ?? '').trim());
315
+ if (!match) return 0;
316
+ const port = Number(match[1]);
317
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 0;
318
+ }
319
+
320
+ function defaultPreviewPort(studioPort) {
321
+ return studioPort === DEFAULT_PREVIEW_PORT ? DEFAULT_PREVIEW_PORT + 1 : DEFAULT_PREVIEW_PORT;
322
+ }
323
+
308
324
  // syncStudioEnv brings .env up to date with config.json, keeping what the admin page saved: this
309
325
  // plugin's settings are written over, the others only when missing. A missing PROXY is the one
310
326
  // given, else the one an older config.json kept (which nothing reads after that).
@@ -312,10 +328,26 @@ function syncStudioEnv(dataDir, config, seedProxy) {
312
328
  const values = readStudioEnv(dataDir);
313
329
  Object.assign(values, pluginEnv(dataDir, config));
314
330
  for (const [key, value] of Object.entries(SETTING_DEFAULTS)) values[key] ??= value;
331
+ const preview = previewPort(values.PREVIEW_LISTEN_ADDR);
332
+ if (!preview || preview === config.port) {
333
+ values.PREVIEW_LISTEN_ADDR = `127.0.0.1:${defaultPreviewPort(config.port)}`;
334
+ }
315
335
  values.PROXY ??= validateProxy(seedProxy) || config.proxy;
316
336
  writeStudioEnv(dataDir, values);
317
337
  }
318
338
 
339
+ // ensurePreviewPort makes sure PREVIEW_LISTEN_ADDR in .env is on a free port other than
340
+ // studioPort before the service starts: upstream listens on both and exits when either is taken.
341
+ async function ensurePreviewPort(dataDir, studioPort) {
342
+ const values = readStudioEnv(dataDir);
343
+ const current = previewPort(values.PREVIEW_LISTEN_ADDR);
344
+ if (current && current !== studioPort && !(await portAnswers(current)) && await canListen(current)) return;
345
+ let port = await pickStudioPort(defaultPreviewPort(studioPort));
346
+ while (port === studioPort) port = await pickStudioPort(0);
347
+ values.PREVIEW_LISTEN_ADDR = `127.0.0.1:${port}`;
348
+ writeStudioEnv(dataDir, values);
349
+ }
350
+
319
351
  // saveStudioProxy saves the service's proxy in its .env, as the admin page does. The service
320
352
  // takes it the next time its generation service starts.
321
353
  export function saveStudioProxy(dataDir, proxy) {
@@ -398,6 +430,20 @@ export function localStudio(options = {}) {
398
430
  };
399
431
  }
400
432
 
433
+ // nodeFetch is fetch over node:http, bounded only by its signal.
434
+ export function nodeFetch(url, init = {}) {
435
+ return new Promise((resolve, reject) => {
436
+ const options = { method: init.method, headers: Object.fromEntries(new Headers(init.headers)), signal: init.signal };
437
+ const req = http.request(url, options, res => {
438
+ const headers = new Headers();
439
+ for (let i = 0; i < res.rawHeaders.length; i += 2) headers.append(res.rawHeaders[i], res.rawHeaders[i + 1]);
440
+ resolve(new Response(res.statusCode === 204 ? null : Readable.toWeb(res), { status: res.statusCode, headers }));
441
+ });
442
+ req.on('error', reject);
443
+ req.end(init.body ?? undefined);
444
+ });
445
+ }
446
+
401
447
  export async function studioAdmin(local, method, pathname, body = null, options = {}) {
402
448
  if (!local || typeof local !== 'object') {
403
449
  throw new Error('localStudio configuration required for studioAdmin');
@@ -484,11 +530,13 @@ export async function studioAdmin(local, method, pathname, body = null, options
484
530
  payload = String(body);
485
531
  }
486
532
  }
533
+ // Bun's fetch drops a connection idle for 300 s; the signal sets the deadline instead.
487
534
  return fetchFn(url, {
488
535
  method,
489
536
  headers,
490
537
  body: payload,
491
538
  redirect: 'error',
539
+ timeout: false,
492
540
  signal: AbortSignal.timeout(timeoutMs)
493
541
  });
494
542
  }
@@ -893,7 +941,7 @@ export function verifyProcessIdentity(pid, expectedBinaryPath, platform = proces
893
941
  '-NoProfile',
894
942
  '-NonInteractive',
895
943
  '-Command',
896
- `Get-CimInstance Win32_Process -Filter "ProcessId = $env:TARGET_PID" | Select-Object -ExpandProperty ExecutablePath`
944
+ `[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); Get-CimInstance Win32_Process -Filter "ProcessId = $env:TARGET_PID" | Select-Object -ExpandProperty ExecutablePath`
897
945
  ], {
898
946
  env: { ...process.env, TARGET_PID: String(pid) },
899
947
  encoding: 'utf8',
@@ -1188,7 +1236,7 @@ export async function installStudio(options = {}) {
1188
1236
  fs.writeFileSync(archivePath, buffer, { mode: 0o600 });
1189
1237
  } else {
1190
1238
  // Without a progress meter curl's stderr holds just the reason a download failed.
1191
- const curlArgs = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '15', '--max-time', '300'];
1239
+ const curlArgs = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '15', '--speed-limit', '1', '--speed-time', '60'];
1192
1240
  if (proxy) {
1193
1241
  curlArgs.push('--proxy', proxy);
1194
1242
  }
@@ -1513,6 +1561,7 @@ async function launchStudio(options) {
1513
1561
  if (health.state === 'foreign') throw portTaken(local);
1514
1562
  if (health.state !== 'down') throw notResponding(local, health);
1515
1563
  assertStudioExited(dataDir, platform);
1564
+ await ensurePreviewPort(dataDir, config.port);
1516
1565
  protectPackage(dataDir, platform);
1517
1566
 
1518
1567
  // Clean environment: no host AISTUDIO_* or WAA_* vars, nor any of upstream's settings, which