magpie-gemini-web 0.5.2 → 0.5.3

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 CHANGED
@@ -82,12 +82,17 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
82
82
  | `stripCitations` | `true` | `gemini-web` only | Strip `[cite: 1]` and `[cite_start]` markers from replies and tool arguments (not supported on AI Studio) |
83
83
  | `baseURL` | `http://127.0.0.1:8083/v1` | `gemini-web` only | Default Gemini Web backend URL |
84
84
  | `contextLimit` / `outputLimit` | `1000000` / `8192` | `gemini-web` only | Context and output limits reported to clients |
85
- | `studio.proxy` | none | `ai-studio` only | Proxy for reaching Google (`http` / `https` / `socks5`), or set `AI_STUDIO_PROXY` env var |
85
+ | `studio.proxy` | none | `ai-studio` only | Proxy for reaching Google (`http` / `https` / `socks5`), or set `AI_STUDIO_PROXY` env var; when set, it takes precedence over the proxy saved in the admin page. An empty value counts as unset |
86
86
  | `studio.baseURL` | `http://127.0.0.1:2048/v1` | `ai-studio` only | Default URL when connecting an existing AIStudio2API instance |
87
87
  | `studio.contextLimit` / `studio.outputLimit` | Model catalog defaults | `ai-studio` only | Limits reported to clients (can only lower the catalog defaults) |
88
88
 
89
89
  **Using a proxy to reach Google:**
90
- - **AI Studio:** Set `studio.proxy` in plugin options or the `AI_STUDIO_PROXY` environment variable.
90
+ - **AI Studio:** Preferably set it in the local admin page. Open the address on the usage card's "管理页" (admin page) line (default `http://127.0.0.1:2048`) and sign in as `magpie` with `adminPassword` from `config.json` in the data directory. Save the proxy, then restart the service from the admin page. The plugin keeps the saved value when it restarts the backend.
91
+ - Alternatively, `studio.proxy` or `AI_STUDIO_PROXY` take precedence over the saved proxy. A change applies after the local service restarts (`npm run studio:stop`; the next request starts it again).
92
+ - With neither set, the backend falls back to the `HTTPS_PROXY` environment variable or the system proxy (Windows proxy settings).
93
+ - **The provider proxy set for `ai-studio` in Magpie is not passed to the local backend**; use one of the options above.
94
+ - The usage card's "代理出口" (proxy) line, the Google sign-in instructions and failure messages, and `npm run studio:status` show the proxy the backend actually uses, where it comes from, and whether a new setting still needs a restart.
95
+ - The listen address, API key and admin credentials are managed by the plugin; changing them in the admin page has no effect.
91
96
  - **Gemini Web:** The backend ignores `HTTP_PROXY`. Add your proxy to the proxy pool in the local admin panel at `http://127.0.0.1:8083/admin` (password is `adminToken` in `secrets.json` inside the data directory).
92
97
  - *Note:* When using a proxy pool, send one request with a newly added account so the backend binds it to a proxy, then re-add it via "Add an existing Google account from the local backend" to load its email and usage (SOCKS proxies are not supported for usage reads).
93
98
 
package/README.md CHANGED
@@ -82,12 +82,17 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
82
82
  | `stripCitations` | `true` | 仅 `gemini-web` | 剥离回复与工具参数中的 `[cite: 1]`、`[cite_start]` 标记(AI Studio 不支持剥离) |
83
83
  | `baseURL` | `http://127.0.0.1:8083/v1` | 仅 `gemini-web` | Gemini Web 默认后端地址 |
84
84
  | `contextLimit` / `outputLimit` | `1000000` / `8192` | 仅 `gemini-web` | 向客户端声明的上下文 / 输出上限 |
85
- | `studio.proxy` | 无 | 仅 `ai-studio` | 访问 Google 的代理(`http` / `https` / `socks5`),也可用环境变量 `AI_STUDIO_PROXY` |
85
+ | `studio.proxy` | 无 | 仅 `ai-studio` | 访问 Google 的代理(`http` / `https` / `socks5`),也可用环境变量 `AI_STUDIO_PROXY`;设置后优先于管理页里保存的代理,留空等于不设置 |
86
86
  | `studio.baseURL` | `http://127.0.0.1:2048/v1` | 仅 `ai-studio` | 连接已有 AIStudio2API 时的默认地址 |
87
87
  | `studio.contextLimit` / `studio.outputLimit` | 模型目录默认值 | 仅 `ai-studio` | 向客户端声明的上限(仅支持在目录值基础上调低) |
88
88
 
89
89
  **配置代理访问 Google:**
90
- - **AI Studio**:配置上表中的 `studio.proxy` 或环境变量 `AI_STUDIO_PROXY`。
90
+ - **AI Studio**:推荐在本地管理页设置。打开额度卡片「管理页」一行里的地址(默认 `http://127.0.0.1:2048`),用户名 `magpie`,密码是数据目录 `config.json` 里的 `adminPassword`;保存代理后在管理页重启服务即可生效,之后插件重启后端也不会覆盖它。
91
+ - 也可以用上表的 `studio.proxy` 或环境变量 `AI_STUDIO_PROXY`,它们优先于管理页里保存的代理。改了要重启本地服务才生效(`npm run studio:stop`,下一次请求会自动启动)。
92
+ - 都没设置时,后端自动使用 `HTTPS_PROXY` 环境变量或系统代理(Windows 的「代理」设置)。
93
+ - **Magpie 里给 `ai-studio` 设置的供应商代理不会传给本地后端**,请用上面两种方式之一设置。
94
+ - 额度卡片的「代理出口」一行会显示后端当前实际使用的代理、它来自哪里,以及新设置是否还要重启才生效;登录 Google 时的说明、登录失败的提示和 `npm run studio:status` 也会显示。
95
+ - 监听地址、API Key、管理员账号等由插件管理,在管理页修改不会生效。
91
96
  - **Gemini Web**:后端不读取 `HTTP_PROXY`,请打开本地管理面板 `http://127.0.0.1:8083/admin` 在代理池中添加(登录口令为数据目录 `secrets.json` 中的 `adminToken`)。
92
97
  - *注意*:配置代理池后,新添加的账号需先发送一次请求以绑定代理,再通过「添加本地后端里已有的 Google 账号」重新添加,才能正常读取邮箱和用量(不支持通过 SOCKS 代理读取用量)。
93
98
 
@@ -109,12 +109,36 @@ export function validateProxy(proxy) {
109
109
  return trimmed;
110
110
  }
111
111
 
112
- // effectiveProxy is the proxy the service and its downloads go through: the one given, else
113
- // AI_STUDIO_PROXY, else the one saved in config.json.
114
- function effectiveProxy(explicit, saved) {
112
+ // pluginProxy is the proxy this plugin sets for the service over the one saved in its .env: the
113
+ // one given (studio.proxy, or a CLI --proxy), else AI_STUDIO_PROXY, with where it comes from. An
114
+ // empty one names none, so the saved one applies; null when neither names one.
115
+ export function pluginProxy(explicit) {
116
+ const given = validateProxy(explicit);
117
+ if (given) return { proxy: given, source: 'option' };
118
+ const env = validateProxy(process.env.AI_STUDIO_PROXY);
119
+ if (env) return { proxy: env, source: 'env' };
120
+ return null;
121
+ }
122
+
123
+ // downloadProxy is the proxy an install downloads through: the one given (an empty one turns it
124
+ // off), else AI_STUDIO_PROXY, else the service's saved one, else the one an older config.json kept.
125
+ function downloadProxy(dataDir, explicit, savedConfig) {
115
126
  if (explicit !== undefined) return validateProxy(explicit);
116
127
  if (process.env.AI_STUDIO_PROXY !== undefined) return validateProxy(process.env.AI_STUDIO_PROXY);
117
- return validateProxy(saved);
128
+ let saved;
129
+ try {
130
+ saved = readStudioEnv(dataDir).PROXY;
131
+ } catch {}
132
+ return saved !== undefined ? firstProxy(saved) : validateProxy(savedConfig?.proxy);
133
+ }
134
+
135
+ // firstProxy is the first of a PROXY list (upstream takes several, comma-separated, as a pool).
136
+ function firstProxy(value) {
137
+ return proxyList(value)[0] ?? '';
138
+ }
139
+
140
+ function proxyList(value) {
141
+ return [...new Set(String(value ?? '').split(',').map(p => p.trim()).filter(Boolean))];
118
142
  }
119
143
 
120
144
  export function validateConfig(config) {
@@ -148,9 +172,34 @@ export function validateConfig(config) {
148
172
  };
149
173
  }
150
174
 
151
- // serviceEnv is the fixed settings the service runs with. The service is started with them and
152
- // they are written to its .env, which it reads again when its generation service restarts.
153
- function serviceEnv(dataDir, config, options = {}) {
175
+ // ENV_KEYS are the settings upstream reads from .env, in the order it writes them (config.configKeys).
176
+ const ENV_KEYS = [
177
+ 'AISTUDIO_AUTH_STATES', 'LISTEN_ADDR', 'PROXY_API_KEY', 'PROXY', 'INIT_TIMEOUT', 'REQUEST_TIMEOUT',
178
+ 'WARM_WORKER_LIMIT', 'MAX_ACTIVE_WORKERS', 'WARM_STARTUP_CONCURRENCY', 'PER_ACCOUNT_CONCURRENCY',
179
+ 'ROUTING_STRATEGY', 'UPSTREAM_CHANNELS', 'MODEL_MAPPING', 'TEMPORARY_CHAT', 'WAA_BACKEND', 'AUTO_START',
180
+ 'REQUEST_BODY_LOG', 'LOG_FILE', 'LOG_MAX_TOTAL_MB', 'ADMIN_AUTH_ENABLED', 'ADMIN_USERNAME', 'ADMIN_PASSWORD',
181
+ 'BUILD_NATIVE_NONSTREAM'
182
+ ];
183
+
184
+ // SETTING_DEFAULTS are the settings the admin page owns, as a new setup has them. They are written
185
+ // to .env when it lacks them and left as the admin page saves them after that.
186
+ const SETTING_DEFAULTS = {
187
+ REQUEST_BODY_LOG: 'false',
188
+ LOG_FILE: 'false',
189
+ TEMPORARY_CHAT: 'true',
190
+ PER_ACCOUNT_CONCURRENCY: '1',
191
+ WARM_WORKER_LIMIT: '1',
192
+ MAX_ACTIVE_WORKERS: '1',
193
+ WARM_STARTUP_CONCURRENCY: '1',
194
+ ROUTING_STRATEGY: 'fill-first',
195
+ REQUEST_TIMEOUT: '30m',
196
+ INIT_TIMEOUT: '2m'
197
+ };
198
+
199
+ // pluginEnv is the settings this plugin depends on to reach, trust and drive the service. The
200
+ // service is started with them in its environment, which upstream reads over .env, so saving
201
+ // others in the admin page can't break them.
202
+ function pluginEnv(dataDir, config) {
154
203
  const authDir = path.resolve(dataDir, 'auth');
155
204
  if (/[\r\n]/.test(authDir) || /[\r\n]/.test(config.apiKey) || /[\r\n]/.test(config.adminUsername) || /[\r\n]/.test(config.adminPassword)) {
156
205
  throw new Error('Configuration paths or credentials must not contain newlines');
@@ -163,29 +212,103 @@ function serviceEnv(dataDir, config, options = {}) {
163
212
  ADMIN_USERNAME: config.adminUsername,
164
213
  ADMIN_PASSWORD: config.adminPassword,
165
214
  AUTO_START: 'true',
166
- REQUEST_BODY_LOG: 'false',
167
- LOG_FILE: 'false',
168
215
  WAA_BACKEND: 'go',
169
216
  UPSTREAM_CHANNELS: 'playground',
170
- BUILD_NATIVE_NONSTREAM: 'false',
171
- TEMPORARY_CHAT: 'true',
172
- PER_ACCOUNT_CONCURRENCY: '1',
173
- WARM_WORKER_LIMIT: '1',
174
- MAX_ACTIVE_WORKERS: '1',
175
- WARM_STARTUP_CONCURRENCY: '1',
176
- ROUTING_STRATEGY: 'fill-first',
177
- REQUEST_TIMEOUT: '30m',
178
- INIT_TIMEOUT: '2m',
179
- PROXY: effectiveProxy(options.proxy, config.proxy)
217
+ BUILD_NATIVE_NONSTREAM: 'false'
180
218
  };
181
219
  }
182
220
 
183
- export function generateEnv(dataDir, config, options = {}) {
184
- return Object.entries(serviceEnv(dataDir, config, options)).map(([k, v]) => `${k}=${v}\n`).join('');
221
+ // serviceEnv is the environment the service is started with: this plugin's settings, and PROXY
222
+ // only when the plugin sets one (see pluginProxy). Without it the service takes the PROXY saved
223
+ // in .env, which the admin page changes.
224
+ export function serviceEnv(dataDir, config, options = {}) {
225
+ const env = pluginEnv(dataDir, config);
226
+ const owned = pluginProxy(options.proxy);
227
+ if (owned) env.PROXY = owned.proxy;
228
+ return env;
229
+ }
230
+
231
+ // parseEnvValue and formatEnvValue read and write a .env value as upstream does.
232
+ function parseEnvValue(raw, where) {
233
+ if (!raw) return '';
234
+ if (raw[0] === "'") {
235
+ if (raw.length < 2 || !raw.endsWith("'")) throw new Error(`${where}: unterminated single quote`);
236
+ return raw.slice(1, -1);
237
+ }
238
+ if (raw[0] === '"') {
239
+ try {
240
+ return JSON.parse(raw);
241
+ } catch {
242
+ throw new Error(`${where}: invalid double-quoted value`);
243
+ }
244
+ }
245
+ const comment = raw.indexOf(' #');
246
+ return comment >= 0 ? raw.slice(0, comment).trim() : raw;
247
+ }
248
+
249
+ function formatEnvValue(value) {
250
+ return /[ \t\r\n#"']/.test(value) ? JSON.stringify(value) : value;
251
+ }
252
+
253
+ // readStudioEnv is the settings saved in the service's .env (none when it has no .env).
254
+ export function readStudioEnv(dataDir) {
255
+ const envPath = path.join(dataDir, '.env');
256
+ let text;
257
+ try {
258
+ text = fs.readFileSync(envPath, 'utf8');
259
+ } catch (err) {
260
+ if (err.code === 'ENOENT') return {};
261
+ throw err;
262
+ }
263
+ const values = {};
264
+ text.split(/\r?\n/).forEach((line, i) => {
265
+ line = line.trim();
266
+ if (!line || line.startsWith('#')) return;
267
+ const eq = line.indexOf('=');
268
+ if (eq < 0) throw new Error(`${envPath}:${i + 1}: missing "="`);
269
+ const key = line.slice(0, eq).trim();
270
+ if (ENV_KEYS.includes(key)) values[key] = parseEnvValue(line.slice(eq + 1).trim(), `${envPath}:${i + 1}`);
271
+ });
272
+ return values;
273
+ }
274
+
275
+ function writeStudioEnv(dataDir, values) {
276
+ const envPath = path.join(dataDir, '.env');
277
+ const content = ENV_KEYS.filter(k => values[k] !== undefined).map(k => `${k}=${formatEnvValue(String(values[k]))}\n`).join('');
278
+ let current;
279
+ try {
280
+ current = fs.readFileSync(envPath, 'utf8');
281
+ } catch {}
282
+ if (current !== content) {
283
+ const tmpEnv = `${envPath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
284
+ fs.writeFileSync(tmpEnv, content, { mode: 0o600 });
285
+ fs.renameSync(tmpEnv, envPath);
286
+ }
287
+ fs.chmodSync(envPath, 0o600);
288
+ }
289
+
290
+ // syncStudioEnv brings .env up to date with config.json, keeping what the admin page saved: this
291
+ // plugin's settings are written over, the others only when missing. A missing PROXY is the one
292
+ // given, else the one an older config.json kept (which nothing reads after that).
293
+ function syncStudioEnv(dataDir, config, seedProxy) {
294
+ const values = readStudioEnv(dataDir);
295
+ Object.assign(values, pluginEnv(dataDir, config));
296
+ for (const [key, value] of Object.entries(SETTING_DEFAULTS)) values[key] ??= value;
297
+ values.PROXY ??= validateProxy(seedProxy) || config.proxy;
298
+ writeStudioEnv(dataDir, values);
299
+ }
300
+
301
+ // saveStudioProxy saves the service's proxy in its .env, as the admin page does. The service
302
+ // takes it the next time its generation service starts.
303
+ export function saveStudioProxy(dataDir, proxy) {
304
+ const values = readStudioEnv(dataDir);
305
+ values.PROXY = validateProxy(proxy);
306
+ writeStudioEnv(dataDir, values);
185
307
  }
186
308
 
187
309
  // ensureStudioConfig reads config.json, writing a new one if there is none: on options.port when
188
- // given (see pickStudioPort), else on DEFAULT_STUDIO_PORT.
310
+ // given (see pickStudioPort), else on DEFAULT_STUDIO_PORT. It then brings .env up to date (see
311
+ // syncStudioEnv), with explicitProxy as the proxy to save if .env has none yet.
189
312
  export function ensureStudioConfig(dataDir, explicitProxy = undefined, options = {}) {
190
313
  fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
191
314
  fs.chmodSync(dataDir, 0o700);
@@ -204,29 +327,24 @@ export function ensureStudioConfig(dataDir, explicitProxy = undefined, options =
204
327
  fs.chmodSync(configPath, 0o600);
205
328
  } catch (err) {
206
329
  if (err.code === 'ENOENT') {
207
- config = {
330
+ const created = {
208
331
  version: 1,
209
332
  port: options.port ?? DEFAULT_STUDIO_PORT,
210
333
  apiKey: 'as_' + crypto.randomBytes(32).toString('hex'),
211
334
  adminUsername: 'magpie',
212
- adminPassword: crypto.randomBytes(32).toString('hex'),
213
- proxy: validateProxy(explicitProxy ?? '')
335
+ adminPassword: crypto.randomBytes(32).toString('hex')
214
336
  };
215
337
  const tmp = `${configPath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
216
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
338
+ fs.writeFileSync(tmp, JSON.stringify(created, null, 2) + '\n', { mode: 0o600 });
217
339
  fs.renameSync(tmp, configPath);
218
340
  fs.chmodSync(configPath, 0o600);
341
+ config = validateConfig(created);
219
342
  } else {
220
343
  throw err;
221
344
  }
222
345
  }
223
346
 
224
- const envPath = path.join(dataDir, '.env');
225
- const envContent = generateEnv(dataDir, config, { proxy: explicitProxy });
226
- const tmpEnv = `${envPath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
227
- fs.writeFileSync(tmpEnv, envContent, { mode: 0o600 });
228
- fs.renameSync(tmpEnv, envPath);
229
- fs.chmodSync(envPath, 0o600);
347
+ syncStudioEnv(dataDir, config, explicitProxy);
230
348
 
231
349
  const authDir = path.join(dataDir, 'auth');
232
350
  fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
@@ -425,6 +543,114 @@ export async function studioAccounts(options = {}) {
425
543
  return accounts.filter(a => a.enabled !== false);
426
544
  }
427
545
 
546
+ // PROXY_SOURCES names where the proxy the service goes out through comes from.
547
+ export const PROXY_SOURCES = {
548
+ option: '插件选项 studio.proxy',
549
+ env: '环境变量 AI_STUDIO_PROXY',
550
+ saved: '管理页',
551
+ startup: '服务启动时的设置',
552
+ unknown: '来源待确认',
553
+ system: '自动检测:HTTPS_PROXY 环境变量或系统代理',
554
+ direct: '直连'
555
+ };
556
+
557
+ // redactProxy hides the passwords in a PROXY list.
558
+ export function redactProxy(value) {
559
+ return proxyList(value).map(p => {
560
+ try {
561
+ const u = new URL(p);
562
+ if (u.password) u.password = '***';
563
+ return u.toString().replace(/\/$/, '');
564
+ } catch {
565
+ return p;
566
+ }
567
+ }).join(',');
568
+ }
569
+
570
+ // LAUNCH_RECORD says, for the service process this plugin last started, whether it was started
571
+ // with a PROXY of the plugin's over the saved one, and whose: 'option' or 'env' (see pluginProxy),
572
+ // null for none. A caller that doesn't know the plugin's options (the CLI) reads it from there.
573
+ const LAUNCH_RECORD = 'backend.launch.json';
574
+
575
+ function writeLaunchRecord(dataDir, pid, proxy) {
576
+ const record = { pid, proxy: pluginProxy(proxy)?.source ?? null };
577
+ try {
578
+ fs.writeFileSync(path.join(dataDir, LAUNCH_RECORD), JSON.stringify(record) + '\n', { mode: 0o600 });
579
+ } catch {}
580
+ }
581
+
582
+ // launchRecord is the record of the running service's start, or null when the running service
583
+ // isn't the one it was written for (or an older plugin started it, which wrote none).
584
+ function launchRecord(dataDir) {
585
+ try {
586
+ const record = JSON.parse(fs.readFileSync(path.join(dataDir, LAUNCH_RECORD), 'utf8'));
587
+ if (record?.pid !== recordedPid(dataDir) || !(record.proxy === null || record.proxy in PROXY_SOURCES)) return null;
588
+ return record;
589
+ } catch {
590
+ return null;
591
+ }
592
+ }
593
+
594
+ // studioProxy is how the running service reaches Google:
595
+ // active the proxy it goes out through now ('' for none), and source where that comes from (a
596
+ // PROXY_SOURCES key; 'unknown' when the service can't tell it from a change not yet applied);
597
+ // saved the proxy saved in .env, as the admin page shows it;
598
+ // overridden, when another one is used over a saved one: whose it is ('option' or 'env'), or
599
+ // 'unknown' when the service was started with another one in its environment and
600
+ // nothing tells whose;
601
+ // pending what it takes for the one set (wanted, from wantedSource) to apply: 'restart', the
602
+ // service must start again; 'generation', its generation service must; null, nothing.
603
+ // A standalone caller (the CLI) doesn't know the plugin's options, so it can't tell what the
604
+ // service will be started with next: it reads what it was started with from the launch record.
605
+ // Passwords are hidden.
606
+ export async function studioProxy(local, options = {}) {
607
+ const saved = proxyList(readStudioEnv(local.dataDir).PROXY).join(',');
608
+ const override = options.standalone ? null : pluginProxy(options.proxy);
609
+ const cfg = await studioAdmin(local, 'GET', '/api/config', null, { timeoutMs: 3000, fetchFn: options.fetchFn });
610
+ // What the service reads as its PROXY now: .env, or the environment it was started with over it
611
+ const loaded = proxyList(cfg?.proxy).join(',');
612
+ const active = proxyList(cfg?.active_proxy).join(',');
613
+
614
+ let want = override ? override.proxy : saved;
615
+ let wantedSource = override ? override.source : 'saved';
616
+ let overridden = override && saved && saved !== override.proxy ? override.source : null;
617
+ if (options.standalone && loaded !== saved) {
618
+ // Started with another proxy in its environment: one of the plugin's, which applies as long as
619
+ // it is set, or, with no record of whose, one a restart may or may not set again
620
+ const launched = launchRecord(local.dataDir)?.proxy;
621
+ overridden = launched || 'unknown';
622
+ if (launched) {
623
+ want = loaded;
624
+ wantedSource = launched;
625
+ }
626
+ }
627
+
628
+ let pending = null;
629
+ if (loaded !== want) pending = 'restart';
630
+ else if (want && active !== want) pending = 'generation';
631
+ // Saved none, going out through one: the system's, or one saved before that the generation
632
+ // service still has. A change not yet applied leaves which it is open.
633
+ const unsure = !want && active && cfg?.service_restart_required === true;
634
+ if (unsure) pending = 'generation';
635
+
636
+ let source;
637
+ if (!active) source = 'direct';
638
+ else if (unsure) source = 'unknown';
639
+ else if (pending) source = 'startup';
640
+ else if (!loaded) source = 'system';
641
+ else source = wantedSource;
642
+
643
+ return {
644
+ active: redactProxy(active),
645
+ source,
646
+ saved: redactProxy(saved),
647
+ overridden,
648
+ wanted: redactProxy(want),
649
+ wantedSource,
650
+ pending
651
+ };
652
+ }
653
+
428
654
  export function isConnectRefused(err) {
429
655
  if (!err) return false;
430
656
  const visited = new Set();
@@ -799,7 +1025,7 @@ export async function installStudio(options = {}) {
799
1025
  } else {
800
1026
  const downloadUrl = `https://github.com/${release.repository}/releases/download/${release.tag}/${pin.asset}`;
801
1027
  archivePath = path.join(stageDir, pin.asset);
802
- const proxy = effectiveProxy(options.proxy, savedConfig?.proxy);
1028
+ const proxy = downloadProxy(dataDir, options.proxy, savedConfig);
803
1029
 
804
1030
  if (options.fetchFn && options.fetchFn !== globalThis.fetch) {
805
1031
  const res = await options.fetchFn(downloadUrl, { signal: AbortSignal.timeout(300000) });
@@ -1036,7 +1262,9 @@ function shared(map, key, fn) {
1036
1262
  }
1037
1263
 
1038
1264
  // ensureStudio makes sure the service is installed and running, and, unless managementOnly,
1039
- // that it takes generation requests. Callers share one start of the service and one wait for
1265
+ // that it takes generation requests. options.proxy is a proxy it is started with over the saved
1266
+ // one (studio.proxy); options.savedProxy is one saved as the service's own before it starts, as
1267
+ // the admin page saves one. Callers share one start of the service and one wait for
1040
1268
  // it to take requests; a managementOnly caller doesn't wait for the latter.
1041
1269
  export async function ensureStudio(options = {}) {
1042
1270
  const dataDir = options.dataDir || studioDataDir();
@@ -1113,10 +1341,11 @@ async function launchStudio(options) {
1113
1341
  }
1114
1342
 
1115
1343
  if (!manifestValid) {
1116
- await installStudio({ dataDir, platform, arch, fetchFn, archivePath: options.archivePath, proxy: options.proxy, preferredPort: options.preferredPort });
1344
+ await installStudio({ dataDir, platform, arch, fetchFn, archivePath: options.archivePath, proxy: options.proxy ?? options.savedProxy, preferredPort: options.preferredPort });
1117
1345
  }
1118
1346
 
1119
1347
  const config = ensureStudioConfig(dataDir, options.proxy, { port: await newConfigPort(dataDir, options) });
1348
+ if (options.savedProxy !== undefined) saveStudioProxy(dataDir, options.savedProxy);
1120
1349
  local = localStudio({ dataDir, platform });
1121
1350
  if (!local) throw new Error(`Invalid AI Studio configuration in ${dataDir}`);
1122
1351
 
@@ -1128,10 +1357,11 @@ async function launchStudio(options) {
1128
1357
  assertStudioExited(dataDir, platform);
1129
1358
  protectPackage(dataDir, platform);
1130
1359
 
1131
- // Clean environment: no arbitrary host AISTUDIO_* or WAA_* vars
1360
+ // Clean environment: no host AISTUDIO_* or WAA_* vars, nor any of upstream's settings, which
1361
+ // would hide the ones saved in .env
1132
1362
  const cleanEnv = {};
1133
1363
  for (const [k, v] of Object.entries(process.env)) {
1134
- if (!k.startsWith('AISTUDIO_') && !k.startsWith('WAA_')) {
1364
+ if (!k.startsWith('AISTUDIO_') && !k.startsWith('WAA_') && !ENV_KEYS.includes(k.toUpperCase())) {
1135
1365
  cleanEnv[k] = v;
1136
1366
  }
1137
1367
  }
@@ -1161,6 +1391,7 @@ async function launchStudio(options) {
1161
1391
  });
1162
1392
  if (child.pid) {
1163
1393
  fs.writeFileSync(path.join(dataDir, 'backend.pid'), `${child.pid}\n`, { mode: 0o600 });
1394
+ writeLaunchRecord(dataDir, child.pid, options.proxy);
1164
1395
  }
1165
1396
  child.unref?.();
1166
1397
  } finally {
package/lib/studio.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER } from './backend.mjs';
2
- import { localStudio, ensureStudio, studioAdmin, studioAccounts, validateProxy, isConnectRefused } from './studio-native.mjs';
2
+ import path from 'node:path';
3
+ import { localStudio, ensureStudio, studioAdmin, studioAccounts, studioProxy, studioCommand, validateProxy, isConnectRefused, PROXY_SOURCES } from './studio-native.mjs';
3
4
 
4
5
  export const STUDIO_ID = 'ai-studio';
5
6
  export const STUDIO_BASE = 'http://127.0.0.1:2048/v1';
@@ -454,6 +455,43 @@ export function poolUsage(accounts, cooldowns, now = Date.now()) {
454
455
  return { user, windows: cooldownWindows(cooldowns, accounts, now) };
455
456
  }
456
457
 
458
+ // proxyLine says which proxy the local service goes out through, where that comes from, a saved
459
+ // one set aside for another, and what it takes for the one set to apply (see studioProxy).
460
+ export function proxyLine(proxy, local) {
461
+ const named = p => p || '不走代理';
462
+ const restart = `重启本地服务(${studioCommand('stop', { dataDir: local.dataDir })},下一次请求会自动启动)`;
463
+ let line = `${named(proxy.active)}(${PROXY_SOURCES[proxy.source]})`;
464
+ if (proxy.overridden === 'unknown') {
465
+ line += `;管理页保存的是 ${named(proxy.saved)},但服务启动时的环境变量盖住了它(旧版插件启动的服务会这样)。${restart}后应按管理页设置生效;如果由 Magpie 再次启动后仍被盖住,检查插件选项 studio.proxy 和环境变量 AI_STUDIO_PROXY`;
466
+ return line;
467
+ }
468
+ if (proxy.pending === 'restart') {
469
+ line += `;${PROXY_SOURCES[proxy.wantedSource]}设置的是 ${named(proxy.wanted)},${restart}后生效`;
470
+ } else if (proxy.pending === 'generation') {
471
+ line += proxy.source === 'unknown'
472
+ ? ';管理页有改动还没生效,在管理页重启服务后生效'
473
+ : `;管理页保存的 ${named(proxy.wanted)} 要在管理页重启服务后生效`;
474
+ }
475
+ if (proxy.overridden) {
476
+ const by = PROXY_SOURCES[proxy.overridden];
477
+ line += `;管理页保存的 ${proxy.saved} 被 ${by} 覆盖,不会生效。要改用它,先去掉 ${by},再${restart}`;
478
+ }
479
+ return line;
480
+ }
481
+
482
+ // serviceWindows are the usage card's lines about the local service itself: the proxy it goes out
483
+ // through (when it could be read), and where its admin page is.
484
+ function serviceWindows(local, proxy) {
485
+ const windows = [];
486
+ if (proxy) windows.push({ name: '代理出口', used: 0, display: proxyLine(proxy, local) });
487
+ windows.push({
488
+ name: '管理页',
489
+ used: 0,
490
+ display: `${local.origin}(用户名 ${local.adminUsername},密码是 ${path.join(local.dataDir, 'config.json')} 里的 adminPassword)`
491
+ });
492
+ return windows;
493
+ }
494
+
457
495
  export const AIStudioPlugin = async (_input, options = {}) => {
458
496
  const opts = studioSettings(options);
459
497
 
@@ -473,14 +511,26 @@ export const AIStudioPlugin = async (_input, options = {}) => {
473
511
  return accountSignIn(local, account);
474
512
  }
475
513
 
514
+ // proxyNote is a line on the proxy the local service goes out through, for a sign-in to show
515
+ // before its login window opens and when it fails: a wrong proxy is the usual reason the window
516
+ // can't reach Google, and nothing else shows it before an account is signed in.
517
+ async function proxyNote(local) {
518
+ if (!local) return '';
519
+ try {
520
+ return `\n本地服务的代理出口:${proxyLine(await studioProxy(local, opts), local)}`;
521
+ } catch {
522
+ return '';
523
+ }
524
+ }
525
+
476
526
  // addAccount signs a new Google account in through the service's own login window, setting the
477
527
  // service up first if this is the first.
478
528
  async function addAccount() {
479
- await ensureStudio({ proxy: opts.proxy, managementOnly: true });
529
+ const ready = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
480
530
  return {
481
531
  url: '',
482
532
  method: 'auto',
483
- instructions: LOGIN_STEPS,
533
+ instructions: LOGIN_STEPS + await proxyNote(ready),
484
534
  async callback() {
485
535
  let local, account;
486
536
  try {
@@ -491,7 +541,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
491
541
  throw new Error('AIStudio2API did not return an enabled account');
492
542
  }
493
543
  } catch (err) {
494
- return { type: 'failed', error: `Google 登录没有完成:${err.message}` };
544
+ return { type: 'failed', error: `Google 登录没有完成:${err.message}${await proxyNote(local)}` };
495
545
  }
496
546
  return afterLogin(local, account);
497
547
  }
@@ -499,11 +549,11 @@ export const AIStudioPlugin = async (_input, options = {}) => {
499
549
  }
500
550
 
501
551
  // signInAgain signs an account whose Google sign-in expired in again, in a login window.
502
- function signInAgain(chosen) {
552
+ async function signInAgain(ready, chosen) {
503
553
  return {
504
554
  url: '',
505
555
  method: 'auto',
506
- instructions: RELOGIN_STEPS,
556
+ instructions: RELOGIN_STEPS + await proxyNote(ready),
507
557
  async callback() {
508
558
  let local;
509
559
  try {
@@ -511,7 +561,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
511
561
  const res = await studioAdmin(local, 'POST', `/api/accounts/${encodeURIComponent(chosen.id)}/login`, {}, { timeoutMs: LOGIN_TIMEOUT_MS });
512
562
  if (res?.account?.state === 'auth_required') throw new Error(res.account.message || '账号仍然需要重新登录');
513
563
  } catch (err) {
514
- return { type: 'failed', error: `${labelOf(chosen)} 没有重新登录:${err.message}` };
564
+ return { type: 'failed', error: `${labelOf(chosen)} 没有重新登录:${err.message}${await proxyNote(local)}` };
515
565
  }
516
566
  return afterLogin(local, chosen);
517
567
  }
@@ -564,7 +614,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
564
614
  const local = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
565
615
  const chosen = (await studioAccounts(opts)).find(a => a.id === id);
566
616
  if (!chosen) throw new Error(`本地 Google AI Studio 里没有启用的账号 ${id}`);
567
- if (chosen.state === 'auth_required') return signInAgain(chosen);
617
+ if (chosen.state === 'auth_required') return signInAgain(local, chosen);
568
618
  return { url: '', method: 'auto', instructions: '', callback: async () => accountSignIn(local, chosen) };
569
619
  }
570
620
  },
@@ -631,12 +681,13 @@ export const AIStudioPlugin = async (_input, options = {}) => {
631
681
  return { error: '本地 AI Studio 配置不匹配,请重新登录', windows: [], signIn: 'expired' };
632
682
  }
633
683
 
634
- let accounts, cooldowns;
684
+ let accounts, cooldowns, proxy;
635
685
  try {
636
- [accounts, cooldowns] = await Promise.all([
686
+ [accounts, cooldowns, proxy] = await Promise.all([
637
687
  studioAdmin(local, 'GET', '/api/accounts', null, { timeoutMs: 3000 }),
638
- // Without its cooldowns the card still says whose accounts these are.
639
- studioAdmin(local, 'GET', '/api/cooldowns', null, { timeoutMs: 3000 }).catch(() => null)
688
+ // Without its cooldowns or proxy the card still says whose accounts these are.
689
+ studioAdmin(local, 'GET', '/api/cooldowns', null, { timeoutMs: 3000 }).catch(() => null),
690
+ studioProxy(local, opts).catch(() => null)
640
691
  ]);
641
692
  } catch (err) {
642
693
  if (isConnectRefused(err)) {
@@ -647,15 +698,18 @@ export const AIStudioPlugin = async (_input, options = {}) => {
647
698
  const enabled = (Array.isArray(accounts?.accounts) ? accounts.accounts : []).filter(a => a && a.enabled !== false);
648
699
  const cooling = Array.isArray(cooldowns?.cooldowns) ? cooldowns.cooldowns : [];
649
700
  const id = auth.metadata.account;
701
+ const service = serviceWindows(local, proxy);
702
+ let card;
650
703
  if (typeof id !== 'string' || !id) {
651
704
  // A sign-in for the whole pool, as 0.5.0 made them
652
- return { ...poolUsage(enabled, cooling), signIn: 'kept' };
653
- }
654
- const account = enabled.find(a => a.id === id);
655
- if (!account) {
656
- return { error: `本地 AI Studio 里已经没有这个账号(${auth.metadata.email || id}),可能被停用或删除了`, windows: [], signIn: 'kept' };
705
+ card = poolUsage(enabled, cooling);
706
+ } else {
707
+ const account = enabled.find(a => a.id === id);
708
+ card = account
709
+ ? accountUsage(account, cooling)
710
+ : { error: `本地 AI Studio 里已经没有这个账号(${auth.metadata.email || id}),可能被停用或删除了`, windows: [] };
657
711
  }
658
- return { ...accountUsage(account, cooling), signIn: 'kept' };
712
+ return { ...card, windows: [...card.windows, ...service], signIn: 'kept' };
659
713
  }
660
714
  },
661
715
  provider: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Gemini Web and Google AI Studio provider for Magpie, backed by gemini-web2api-go and AIStudio2API",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -11,11 +11,11 @@ import {
11
11
  studioAccounts,
12
12
  stopStudio,
13
13
  studioStatus,
14
- validateConfig,
15
- validateProxy,
16
- generateEnv,
14
+ studioProxy,
15
+ saveStudioProxy,
17
16
  studioCommand
18
17
  } from '../lib/studio-native.mjs';
18
+ import { proxyLine } from '../lib/studio.mjs';
19
19
 
20
20
  const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
21
21
 
@@ -63,22 +63,10 @@ function parseArgs(args) {
63
63
  return flags;
64
64
  }
65
65
 
66
- async function updateProxyConfig(dataDir, proxy) {
67
- const configPath = path.join(dataDir, 'config.json');
68
- if (!fs.existsSync(configPath)) return;
69
- const config = validateConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
70
- config.proxy = validateProxy(proxy);
71
- const tmp = `${configPath}.tmp.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
72
- fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
73
- fs.renameSync(tmp, configPath);
74
- fs.chmodSync(configPath, 0o600);
75
-
76
- const envPath = path.join(dataDir, '.env');
77
- const envContent = generateEnv(dataDir, config, { proxy: config.proxy });
78
- const tmpEnv = `${envPath}.tmp.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
79
- fs.writeFileSync(tmpEnv, envContent, { mode: 0o600 });
80
- fs.renameSync(tmpEnv, envPath);
81
- fs.chmodSync(envPath, 0o600);
66
+ // saveProxy saves the proxy given with --proxy as the service's own, as its admin page does,
67
+ // once the data directory is set up.
68
+ function saveProxy(dataDir, proxy) {
69
+ if (proxy !== undefined && fs.existsSync(path.join(dataDir, 'config.json'))) saveStudioProxy(dataDir, proxy);
82
70
  }
83
71
 
84
72
  function validateStorageState(filePath) {
@@ -128,15 +116,13 @@ async function main() {
128
116
  if (status.status === 'running' || status.status === 'outdated' || status.status === 'unresponsive') {
129
117
  throw new Error('Cannot install or update AI Studio while service is running. Stop the service first.');
130
118
  }
131
- if (flags.proxy !== undefined) {
132
- await updateProxyConfig(dataDir, flags.proxy);
133
- }
134
119
  console.log(`Installing AIStudio2API into ${dataDir}...`);
135
120
  const local = await installStudio({
136
121
  dataDir,
137
122
  archivePath: flags.archive,
138
123
  proxy: flags.proxy
139
124
  });
125
+ saveProxy(dataDir, flags.proxy);
140
126
  console.log(`AIStudio2API installed successfully.`);
141
127
  console.log(`Listen origin: ${local.origin}`);
142
128
  console.log(`Data directory: ${local.dataDir}`);
@@ -155,14 +141,12 @@ async function main() {
155
141
  console.log(`Management URL: ${local.origin}`);
156
142
  return;
157
143
  }
158
- if (flags.proxy !== undefined) {
159
- await updateProxyConfig(dataDir, flags.proxy);
160
- }
161
144
  if (status.status === 'outdated') {
162
145
  console.log(`Replacing AIStudio2API ${status.version} from an earlier install with ${status.expected}...`);
163
146
  }
164
147
  console.log(`Starting AIStudio2API service in ${dataDir}...`);
165
- const local = await ensureStudio({ dataDir, proxy: flags.proxy });
148
+ // --proxy is saved as the service's own, as its admin page saves one, not set over it
149
+ const local = await ensureStudio({ dataDir, savedProxy: flags.proxy });
166
150
  console.log(`AIStudio2API service is running.`);
167
151
  console.log(`API Base URL: ${local.baseURL}`);
168
152
  console.log(`Management URL: ${local.origin}`);
@@ -175,6 +159,13 @@ async function main() {
175
159
  console.log(`State: ${status.state || 'RUNNING'}`);
176
160
  console.log(`Listen port: ${status.port}`);
177
161
  console.log(`Accounts: ready=${status.accounts?.ready ?? 0}, total=${status.accounts?.total ?? 0}`);
162
+ const local = localStudio({ dataDir });
163
+ console.log(`Management URL: ${local.origin} (user ${local.adminUsername}, password: adminPassword in ${path.join(dataDir, 'config.json')})`);
164
+ try {
165
+ console.log(`Proxy: ${proxyLine(await studioProxy(local, { standalone: true }), local)}`);
166
+ } catch (err) {
167
+ console.log(`Proxy: could not be read (${err.message})`);
168
+ }
178
169
  const accounts = await studioAccounts({ dataDir });
179
170
  if (accounts.length > 0) {
180
171
  console.log(`Enabled accounts:`);