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/README.en.md +29 -10
- package/README.md +29 -10
- package/docs/backend-release.md +3 -1
- package/docs/logo.svg +16 -0
- package/docs/studio-release.md +79 -20
- package/index.mjs +39 -10
- package/lib/backend-release.json +15 -15
- package/lib/backend.mjs +35 -8
- package/lib/native.mjs +77 -26
- package/lib/setup.mjs +96 -2
- package/lib/studio-native.mjs +58 -9
- package/lib/studio-release.json +47 -19
- package/lib/studio.mjs +10 -8
- package/package.json +8 -2
- package/patches/argon.patch +781 -168
- package/patches/studio-0.2.17-preserve-request-bodies.patch +160 -0
- package/scripts/backend-argon.mjs +2 -2
- package/scripts/backend.mjs +17 -5
- package/scripts/studio-managed-smoke.mjs +30 -6
- package/scripts/studio-pins.mjs +49 -0
- package/scripts/studio-regzip.go +75 -0
- package/scripts/studio-release.mjs +137 -18
- package/scripts/studio.mjs +8 -6
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
|
|
10
|
-
// A base model with such
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
17
|
-
'darwin-x64': { asset: 'gemini-web2api-go_v4.
|
|
18
|
-
'linux-x64': { asset: 'gemini-web2api-go_v4.
|
|
19
|
-
'linux-arm64': { asset: 'gemini-web2api-go_v4.
|
|
20
|
-
'win32-x64': { asset: 'gemini-web2api-go_v4.
|
|
21
|
-
'win32-arm64': { asset: 'gemini-web2api-go_v4.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/lib/studio-native.mjs
CHANGED
|
@@ -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', '
|
|
195
|
-
'
|
|
196
|
-
'
|
|
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', '--
|
|
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
|