magpie-gemini-web 0.7.0 → 0.8.1

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.
@@ -197,7 +197,8 @@ const ENV_KEYS = [
197
197
  'ROUTING_STRATEGY', 'UPSTREAM_CHANNELS', 'MODEL_MAPPING', 'MODEL_CHANNELS', 'TEMPORARY_CHAT', 'WAA_BACKEND',
198
198
  'MEDIA_OUTPUT', 'MEDIA_BASE_URL', 'AUTO_START', 'REQUEST_BODY_LOG', 'LOG_FILE', 'LOG_MAX_TOTAL_MB',
199
199
  'ADMIN_AUTH_ENABLED', 'ADMIN_USERNAME', 'ADMIN_PASSWORD', 'BUILD_NATIVE_NONSTREAM',
200
- 'NONSTREAM_KEEPALIVE', 'FAKE_STREAM', 'YOUTUBE_LINKS', 'FIRST_EVENT_HEDGE'
200
+ 'NONSTREAM_KEEPALIVE', 'FAKE_STREAM', 'YOUTUBE_LINKS', 'SEARCH_MODEL_ALIASES', 'GOOGLE_SEARCH',
201
+ 'FIRST_EVENT_HEDGE', 'CONTINUATION_REWRITE', 'CONTINUATION_PROMPT'
201
202
  ];
202
203
 
203
204
  // PROXY_ENV are the proxy variables the service would fall back to with no PROXY of its own.
@@ -216,7 +217,10 @@ const SETTING_DEFAULTS = {
216
217
  ROUTING_STRATEGY: 'fill-first',
217
218
  REQUEST_TIMEOUT: '30m',
218
219
  INIT_TIMEOUT: '2m',
219
- NONSTREAM_KEEPALIVE: 'false'
220
+ NONSTREAM_KEEPALIVE: 'false',
221
+ // Upstream lists each chat model that can search Google again as <id>-search (0.2.18), which
222
+ // would list most of Magpie's AI Studio models twice.
223
+ SEARCH_MODEL_ALIASES: 'false'
220
224
  };
221
225
 
222
226
  // pluginEnv is the settings this plugin depends on to reach, trust and drive the service. The
@@ -235,7 +239,7 @@ function pluginEnv(dataDir, config) {
235
239
  ADMIN_USERNAME: config.adminUsername,
236
240
  ADMIN_PASSWORD: config.adminPassword,
237
241
  AUTO_START: 'true',
238
- WAA_BACKEND: 'go',
242
+ WAA_BACKEND: 'spidermonkey',
239
243
  UPSTREAM_CHANNELS: 'playground',
240
244
  BUILD_NATIVE_NONSTREAM: 'false'
241
245
  };
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "0.2.17-magpie.1",
2
+ "version": "0.2.18-magpie.1",
3
3
  "repository": "BUKOWSKIREAL/magpie-gemini-web",
4
- "tag": "studio-v0.2.17-magpie.1",
4
+ "tag": "studio-v0.2.18-magpie.1",
5
5
  "goVersion": "1.27.1",
6
6
  "toolchains": {
7
7
  "zig": "0.16.0",
@@ -26,12 +26,38 @@
26
26
  "sha256": "fd5462b7f5d93b2ff981fcd3f9c7e427e47f3203c64ea91c27bcc444eebd74cd"
27
27
  }
28
28
  }
29
+ },
30
+ "spidermonkey": {
31
+ "release": "v152.0.4-aistudio2api.1",
32
+ "repository": "Mag1cFall/firefox",
33
+ "targets": {
34
+ "darwin-arm64": {
35
+ "asset": "spidermonkey-v152.0.4-aistudio2api.1-darwin-arm64.tar.gz",
36
+ "sha256": "19f690d52ce2cfe7dbaedcc9771976145fff0dbd81f24e89a2dd094a1424b673"
37
+ },
38
+ "darwin-x64": {
39
+ "asset": "spidermonkey-v152.0.4-aistudio2api.1-darwin-amd64.tar.gz",
40
+ "sha256": "8063d69f56711c72b1244c8415b6c5096aa64ae8b022f555722667531acfe659"
41
+ },
42
+ "linux-x64": {
43
+ "asset": "spidermonkey-v152.0.4-aistudio2api.1-linux-amd64.tar.gz",
44
+ "sha256": "d3ecebf1890355426ad261ab8723038849a524b348af53e1bb440ca53cc2cacb"
45
+ },
46
+ "linux-arm64": {
47
+ "asset": "spidermonkey-v152.0.4-aistudio2api.1-linux-arm64.tar.gz",
48
+ "sha256": "b3c9539b8d8378d99f86b267e0e3d007dd01ff80acebf4f52f3e1d6ac04d9d00"
49
+ },
50
+ "win32-x64": {
51
+ "asset": "spidermonkey-v152.0.4-aistudio2api.1-windows-amd64.tar.gz",
52
+ "sha256": "942a3b57b202f899d799cac678b7471d01d0b34714070f0810c18fca37ffd721"
53
+ }
54
+ }
29
55
  }
30
56
  },
31
57
  "upstream": {
32
- "version": "0.2.17",
58
+ "version": "0.2.18",
33
59
  "repository": "https://github.com/Mag1cFall/AIStudio2API.git",
34
- "commit": "bd6c40f5f8a3a680a19a1ebff67216298d8bae27",
60
+ "commit": "9b694a6b7c6439db1767a577994dfcc87a3cf588",
35
61
  "camoufox": "152.0.4-beta.29"
36
62
  },
37
63
  "patches": [
@@ -39,29 +65,29 @@
39
65
  ],
40
66
  "binaries": {
41
67
  "darwin-arm64": {
42
- "asset": "aistudio2api-v0.2.17-magpie.1-darwin-arm64.tar.gz",
43
- "sha256": "10a6f71fcd8313b4d3afc72797027454ff45ae545dfd2bbd96cc0b4b97004517",
44
- "binarySha256": "5978c56cb705eb1b449e59b772655aac2b3aa933da82e71ce28e7d6f7029e8ef"
68
+ "asset": "aistudio2api-v0.2.18-magpie.1-darwin-arm64.tar.gz",
69
+ "sha256": "9f70596a5b95f4bcf5347a52b88ad41e26106cd6e2a79304183a73d58d8a7a29",
70
+ "binarySha256": "d41e9d4c950efb479ad19727bdf496f8de05c01451110fd9eb7425a9054f5c66"
45
71
  },
46
72
  "darwin-x64": {
47
- "asset": "aistudio2api-v0.2.17-magpie.1-darwin-amd64.tar.gz",
48
- "sha256": "44527571d9294db1451ebb215552a76f3836be9e3a1a5e5fd79bfc57ad6b9878",
49
- "binarySha256": "f3b3d48094d1a7849c455b31487db5e4569de00124d092d1c639d63fb0162178"
73
+ "asset": "aistudio2api-v0.2.18-magpie.1-darwin-amd64.tar.gz",
74
+ "sha256": "b118733750a3f882fe13754b2079f9cc89b2645d3cd3bce4bab26aaee24b54e5",
75
+ "binarySha256": "6a1017195809a881614032be647faf25754ebfe060e3301424dcdacefba32469"
50
76
  },
51
77
  "linux-x64": {
52
- "asset": "aistudio2api-v0.2.17-magpie.1-linux-amd64.tar.gz",
53
- "sha256": "47bc3922c54031435fc35914cf382d20a892ca0a2f4432eb0904677a57b002c0",
54
- "binarySha256": "385b21e2d5905acb062a355ebf15c2db17492212a2ab93ef14da2f6acece96a1"
78
+ "asset": "aistudio2api-v0.2.18-magpie.1-linux-amd64.tar.gz",
79
+ "sha256": "e6310fb89c34295d1a4c60e40b30d7a3073e9f5fc16343650f4021f396225922",
80
+ "binarySha256": "1803037342daada925486f5acd40bfc0bc06bda54b0f1f4459da79d1e917d283"
55
81
  },
56
82
  "linux-arm64": {
57
- "asset": "aistudio2api-v0.2.17-magpie.1-linux-arm64.tar.gz",
58
- "sha256": "689e7644c766a9da8d8a462752e2495bbc659484e65d280fcf7e6554c6ec17b7",
59
- "binarySha256": "20971e5abe5eb27e7e91f1cc2b665c41cd07ad9e1e9a2aa2b8aa7f87a8f1ebe0"
83
+ "asset": "aistudio2api-v0.2.18-magpie.1-linux-arm64.tar.gz",
84
+ "sha256": "965336192889bcd2d9f6db1e5f9c035251ff64db86ff0e590d9ed89662867629",
85
+ "binarySha256": "81553da83a7322d608b2498e6c6a01b08fe963341d8655262ed2dd17ca7a83df"
60
86
  },
61
87
  "win32-x64": {
62
- "asset": "aistudio2api-v0.2.17-magpie.1-windows-amd64.zip",
63
- "sha256": "79f8372f81cbe93ad4e663e7a400989e9d55d57f25dd40ca47decfb838071980",
64
- "binarySha256": "e05b28fee8a5130074e3e0c23514e3b6451ba1c938458ae63576ffc6d81746f5"
88
+ "asset": "aistudio2api-v0.2.18-magpie.1-windows-amd64.zip",
89
+ "sha256": "537884412868849665b22d5d9066101a94d389ad0f048555da8c8ea55c7240ba",
90
+ "binarySha256": "0024d8709eb97950ddb3ad6016612521a76c7aca553235db2dddb9183188806e"
65
91
  }
66
92
  }
67
93
  }
package/lib/studio.mjs CHANGED
@@ -10,6 +10,19 @@ const STUDIO_EXCLUDE = /(?:image|nano-banana|tts|audio|live|transcribe|robotics|
10
10
  // Catalog capabilities of models made for images, speech or live sessions. Such a model can be
11
11
  // marked chat_model and named like a text model too (gemini-omni-*), so the name can't tell.
12
12
  const NON_TEXT_CAPS = ['image_route', 'speech_route', 'live_route', 'aspect_ratio', 'output_resolution', 'interactions_api', 'transcription_output'];
13
+ // Of those, the ones an image model (image_route) has. Such a model is listed for Magpie's image
14
+ // generation (one with any other is left out still): on Gemini's own API (@ai-sdk/google), as
15
+ // Magpie draws only with a plugin's Gemini image model there, asking generateContent for images
16
+ // (see forward). Magpie keeps it in its chat model lists too, if it is ticked there.
17
+ const IMAGE_CAPS = new Set(['image_route', 'aspect_ratio', 'output_resolution']);
18
+ // The aspect ratios of an image model whose catalog entry lists none (capability_options
19
+ // .image_aspect_ratios), as the service takes them then (openAIImageAspectRatios).
20
+ const DEFAULT_ASPECTS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'];
21
+ const ASPECT = /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/;
22
+ // The image models each backend's last list named (listStudioModels), with their aspect ratios,
23
+ // for the requests on Gemini's API (imageModelOf); asked again once this old.
24
+ const IMAGE_CATALOG_MS = 10 * 60 * 1000;
25
+ const imageCatalogs = new Map();
13
26
  const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
14
27
 
15
28
  // Magpie waits 10 minutes for a sign-in once authorize returns (signInTimeout), downloads and the
@@ -57,6 +70,7 @@ const KNOWN_STUDIO_NESTED = new Set([
57
70
  'baseURL',
58
71
  'allowTools',
59
72
  'allowImages',
73
+ 'imageModels',
60
74
  'contextLimit',
61
75
  'outputLimit',
62
76
  'requestTimeoutMs',
@@ -78,7 +92,10 @@ export function studioSettings(options = {}) {
78
92
  allowTools = options.allowTools;
79
93
  }
80
94
 
81
- let allowImages = false;
95
+ // On unless turned off: AI Studio's chat models read a chat's images (seen on AIStudio2API
96
+ // 0.2.17). Gemini Web keeps it off by default (settings), as gemini-3.6-flash there never
97
+ // answered a chat with an image, each of two times.
98
+ let allowImages = true;
82
99
  if (options.allowImages !== undefined) {
83
100
  if (typeof options.allowImages !== 'boolean') {
84
101
  throw new Error('Invalid option allowImages: must be a boolean');
@@ -102,6 +119,7 @@ export function studioSettings(options = {}) {
102
119
  modelTimeoutMs = options.modelTimeoutMs;
103
120
  }
104
121
 
122
+ let imageModels = true;
105
123
  let baseURL = STUDIO_BASE;
106
124
  let proxy;
107
125
  let explicitContextLimit = null;
@@ -140,6 +158,12 @@ export function studioSettings(options = {}) {
140
158
  }
141
159
  allowImages = s.allowImages;
142
160
  }
161
+ if (s.imageModels !== undefined) {
162
+ if (typeof s.imageModels !== 'boolean') {
163
+ throw new Error('Invalid option studio.imageModels: must be a boolean');
164
+ }
165
+ imageModels = s.imageModels;
166
+ }
143
167
  if (s.requestTimeoutMs !== undefined) {
144
168
  if (!Number.isInteger(s.requestTimeoutMs) || s.requestTimeoutMs <= 0) {
145
169
  throw new Error('Invalid option studio.requestTimeoutMs: must be a positive integer');
@@ -173,6 +197,7 @@ export function studioSettings(options = {}) {
173
197
  baseURL,
174
198
  allowTools,
175
199
  allowImages,
200
+ imageModels,
176
201
  requestTimeoutMs,
177
202
  modelTimeoutMs,
178
203
  proxy,
@@ -225,6 +250,7 @@ export async function listStudioModels(c, opts) {
225
250
 
226
251
  const result = Object.create(null);
227
252
  const seen = new Set();
253
+ const images = new Map();
228
254
  // A model no enabled account's subscription covers is listed without channels, and calls to it
229
255
  // get a 403. A backend that lists no channels at all is older than that rule: keep its models.
230
256
  const listsChannels = body.data.some(item => Array.isArray(item?.channels));
@@ -240,14 +266,17 @@ export async function listStudioModels(c, opts) {
240
266
  if (!id.startsWith('gemini-')) continue;
241
267
  // Build assistant models inject Google's coding-agent prompt and do not expose
242
268
  // ordinary function calling. Keep them out of this general-purpose provider.
243
- if (STUDIO_EXCLUDE.test(id) || /-build$/i.test(id)) continue;
269
+ if (/-build$/i.test(id)) continue;
244
270
 
245
271
  const methods = Array.isArray(item.supported_generation_methods) ? item.supported_generation_methods : [];
246
272
  if (!methods.includes('generateContent')) continue;
247
273
 
248
274
  const caps = item.capabilities && typeof item.capabilities === 'object' ? item.capabilities : {};
249
275
  if (caps.chat_model !== true || caps.assistant_route === true) continue;
250
- if (NON_TEXT_CAPS.some(cap => caps[cap] === true)) continue;
276
+ // An image model is named for images, so the name filter is for the others only.
277
+ const draws = caps.image_route === true;
278
+ if (draws ? !opts.imageModels : STUDIO_EXCLUDE.test(id)) continue;
279
+ if (NON_TEXT_CAPS.some(cap => caps[cap] === true && !(draws && IMAGE_CAPS.has(cap)))) continue;
251
280
  if (listsChannels && !(Array.isArray(item.channels) && item.channels.length > 0)) continue;
252
281
 
253
282
  if (seen.has(id)) continue;
@@ -271,7 +300,14 @@ export async function listStudioModels(c, opts) {
271
300
  ? item.thinking_levels.filter(level => ['minimal', 'low', 'medium', 'high'].includes(level))
272
301
  : ['minimal', 'low', 'medium', 'high'];
273
302
  const variants = caps.thinking_level === true ? Object.fromEntries(levels.map(level => [level, {}])) : {};
274
- const image = Boolean(opts.allowImages && caps.media_resolution === true);
303
+ // An image model takes images to edit or draw from, which allowImages (images in a chat on
304
+ // chat completions) doesn't cover.
305
+ const image = draws || Boolean(opts.allowImages && caps.media_resolution === true);
306
+ if (draws) {
307
+ const listed = item.capability_options?.image_aspect_ratios;
308
+ const aspects = Array.isArray(listed) ? listed.filter(r => typeof r === 'string' && ASPECT.test(r)) : [];
309
+ images.set(id, aspects.length > 0 ? aspects : DEFAULT_ASPECTS);
310
+ }
275
311
 
276
312
  result[id] = {
277
313
  id,
@@ -280,7 +316,7 @@ export async function listStudioModels(c, opts) {
280
316
  api: {
281
317
  id,
282
318
  url: c.baseURL,
283
- npm: '@ai-sdk/openai-compatible'
319
+ npm: draws ? '@ai-sdk/google' : '@ai-sdk/openai-compatible'
284
320
  },
285
321
  limit: {
286
322
  context,
@@ -296,7 +332,8 @@ export async function listStudioModels(c, opts) {
296
332
  image
297
333
  },
298
334
  output: {
299
- text: true
335
+ text: true,
336
+ image: draws
300
337
  }
301
338
  },
302
339
  cost: {
@@ -318,9 +355,31 @@ export async function listStudioModels(c, opts) {
318
355
  throw new Error('No usable Gemini models found in AI Studio response');
319
356
  }
320
357
 
358
+ imageCatalogs.set(catalogKey(c), { images, at: Date.now() });
321
359
  return result;
322
360
  }
323
361
 
362
+ function catalogKey(c) {
363
+ return `${c.baseURL}\n${c.key}`;
364
+ }
365
+
366
+ // imageModelOf is the aspect ratios of the image model id, which a request on Gemini's API names
367
+ // (see forward), as the backend's list says, asking it again once its last is old: null for a
368
+ // model that isn't one. When the list can't be had again, its last still holds; with none, what
369
+ // the model is isn't known, and the list's error is thrown for forward to answer with.
370
+ async function imageModelOf(c, opts, id) {
371
+ const key = catalogKey(c);
372
+ const cached = imageCatalogs.get(key);
373
+ if (!cached || Date.now() - cached.at > IMAGE_CATALOG_MS) {
374
+ try {
375
+ await listStudioModels(c, opts);
376
+ } catch (err) {
377
+ if (!cached) throw err;
378
+ }
379
+ }
380
+ return imageCatalogs.get(key).images.get(id) ?? null;
381
+ }
382
+
324
383
  function sameBackend(c, local) {
325
384
  return c.key === local.apiKey && c.baseURL === local.baseURL;
326
385
  }
@@ -349,6 +408,13 @@ function staleManagedSignIn(auth, opts) {
349
408
 
350
409
  async function forwardManagedStudio(input, init, getAuth, opts) {
351
410
  const retryInput = input instanceof Request ? input.clone() : input;
411
+ // Gemini's API, for the image models alone (see forward)
412
+ const forwardOpts = {
413
+ ...opts,
414
+ nativeThinking: true,
415
+ stripCitations: false,
416
+ imageModel: opts.imageModels ? (c, id) => imageModelOf(c, opts, id) : undefined
417
+ };
352
418
 
353
419
  const auth = await getAuth();
354
420
  if (auth?.metadata?.managed === true && staleManagedSignIn(auth, opts)) {
@@ -356,7 +422,7 @@ async function forwardManagedStudio(input, init, getAuth, opts) {
356
422
  }
357
423
 
358
424
  const callerSignal = input instanceof Request ? input.signal : init?.signal;
359
- const res = await forward(input, init, getAuth, { ...opts, nativeThinking: true, stripCitations: false });
425
+ const res = await forward(input, init, getAuth, forwardOpts);
360
426
  if (res.headers.get(UNREACHABLE_HEADER) !== 'unreachable') return res;
361
427
 
362
428
  if (callerSignal?.aborted) return res;
@@ -369,7 +435,7 @@ async function forwardManagedStudio(input, init, getAuth, opts) {
369
435
  }
370
436
 
371
437
  if (callerSignal?.aborted) return res;
372
- return forward(retryInput, init, getAuth, { ...opts, nativeThinking: true, stripCitations: false });
438
+ return forward(retryInput, init, getAuth, forwardOpts);
373
439
  }
374
440
 
375
441
  // accountSignIn is the Magpie sign-in for one Google account of the local service: the service's
@@ -670,7 +736,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
670
736
  proxy = downloadProxy(studioDataDir(), opts.proxy);
671
737
  } catch {}
672
738
  const via = proxy ? `走代理 ${redactProxy(proxy)}` : '按 Magpie 的代理设置';
673
- return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约 30 MB,${via}),可能要等几分钟。${SETUP_PROXY_HINT}\n`;
739
+ return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约 40 MB,${via}),可能要等几分钟。${SETUP_PROXY_HINT}\n`;
674
740
  }
675
741
 
676
742
  // addAccount signs a new Google account in through the service's own login window, setting the
package/lib/usage.mjs CHANGED
@@ -35,14 +35,26 @@ const CLIENT_HEADERS = {
35
35
  'Sec-CH-UA-Platform': '"Windows"'
36
36
  };
37
37
 
38
- // When an account's pools are read again. They move only while the account is used, which the
39
- // backend records (usedAt, see poolAccounts), or when a window starts over; use from the Gemini
40
- // app itself is caught by USAGE_MAX_AGE_MS. No two reads of an account are closer than
41
- // USAGE_MIN_GAP_MS: Magpie set to read allowances automatically asks every few minutes, far
42
- // more often than a person opens the Usage page. A failed read stands that long too, so a
43
- // cookie Google turned away isn't asked about again on every refresh.
44
- export const USAGE_MIN_GAP_MS = 10 * 60 * 1000;
45
- export const USAGE_MAX_AGE_MS = 6 * 3600 * 1000;
38
+ // When an account's pools are read again: whenever Magpie asks, once the last reading is
39
+ // USAGE_FRESH_MS old. Magpie keeps an account's reading a minute itself (loginUsageCache in its
40
+ // internal/provider/login_usage.go) and dates the card by when it got it, so a reading kept here
41
+ // any longer would show old figures as new, and a refresh would show them again (#34); use from
42
+ // the Gemini app, the website or another device leaves no trace here to read again on. On its
43
+ // own Magpie asks about once a minute while its Usage page or tray panel is open, whenever it
44
+ // brings the menu bar's allowances up to date (every 3 minutes unless set otherwise) and, a
45
+ // minute apart, while it routes requests among the accounts; it asks sooner only when it puts
46
+ // its minute aside. USAGE_FRESH_MS takes in asks that come together, a refresh pressed twice; a
47
+ // read under way is shared.
48
+ // A failed read stands USAGE_RETRY_MS, so a cookie Google turned away, or an account it is
49
+ // limiting, isn't asked about again every minute. Magpie asking again within
50
+ // USAGE_ASKED_AGAIN_MS of the last time tries it again at once: it does that only when it put
51
+ // its minute aside, for a card's refresh button, the Usage page or tray panel brought up or
52
+ // refreshed, `magpie quota` or a sign-in check. Its hook doesn't tell a refresh (auth.usage is
53
+ // given getAuth and the provider alone), so one pressed later than that tries again only when
54
+ // pressed once more.
55
+ export const USAGE_FRESH_MS = 30 * 1000;
56
+ export const USAGE_RETRY_MS = 10 * 60 * 1000;
57
+ export const USAGE_ASKED_AGAIN_MS = 60 * 1000;
46
58
 
47
59
  // The pools the Usage page shows, under the names Magpie gives the same windows elsewhere.
48
60
  const POOLS = [
@@ -64,17 +76,13 @@ async function openReadOnly(file) {
64
76
  }
65
77
 
66
78
  // poolAccounts lists the enabled accounts in the backend's pool, each with the proxy it is
67
- // bound to, if any, and whether that proxy is enabled (proxyEnabled), whether the pool has
68
- // proxies at all (pooled: disabled ones count, as they do for the backend), and when the
69
- // backend last used it (usedAt, Unix seconds): the later of when it last took the account for
70
- // a request and when it last finished one.
79
+ // bound to, if any, and whether that proxy is enabled (proxyEnabled), and whether the pool has
80
+ // proxies at all (pooled: disabled ones count, as they do for the backend).
71
81
  export async function poolAccounts(dataDir = getDataDir()) {
72
82
  const db = await openReadOnly(path.join(dataDir, 'gemini.db'));
73
83
  try {
74
84
  return db.all(`SELECT a.id, a.label, a.cookie, p.url AS proxy, p.enabled AS proxyEnabled,
75
- EXISTS (SELECT 1 FROM proxies) AS pooled,
76
- MAX(a.last_used_at, COALESCE((SELECT MAX(r.ts) FROM requests r
77
- WHERE r.ts >= a.last_used_at AND r.account_id = a.id), 0)) AS usedAt
85
+ EXISTS (SELECT 1 FROM proxies) AS pooled
78
86
  FROM accounts a LEFT JOIN proxies p ON p.id = a.proxy_id
79
87
  WHERE a.status = 'enabled' ORDER BY a.id`);
80
88
  } finally {
@@ -160,42 +168,48 @@ export async function accountUsage(account, options = {}) {
160
168
  }, options));
161
169
  }
162
170
 
163
- // The last read of each account: id -> { at, read, pools }, pools once it has succeeded.
171
+ // The last read of each account: id -> { at, read, pools, failed }, pools once it has
172
+ // succeeded, failed once it hasn't, neither while it is under way.
164
173
  const reads = new Map();
174
+ // When Magpie last asked for each account's usage: id -> ms.
175
+ const asked = new Map();
165
176
 
166
- // due says whether the account's last read is to be made again (see USAGE_MIN_GAP_MS).
167
- function due(last, account, options) {
168
- const now = Date.now();
169
- const age = now - last.at;
170
- if (age < (options.minGapMs ?? USAGE_MIN_GAP_MS)) return false;
171
- if (!last.pools || age >= (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return true;
172
- if ((account.usedAt ?? 0) >= Math.floor(last.at / 1000)) return true;
173
- return last.pools.some(p => {
174
- const at = Date.parse(p?.gxu?.windowRefreshesAt ?? '');
175
- return at > last.at && at <= now;
176
- });
177
+ // due says whether the account's last read is to be made again (see USAGE_FRESH_MS); one under
178
+ // way never is. again: Magpie asked within USAGE_ASKED_AGAIN_MS of the time before.
179
+ function due(last, again, options) {
180
+ const age = Date.now() - last.at;
181
+ const fresh = options.freshMs ?? USAGE_FRESH_MS;
182
+ if (last.pools) return age >= fresh;
183
+ return !!last.failed && age >= (again ? fresh : (options.retryMs ?? USAGE_RETRY_MS));
177
184
  }
178
185
 
179
186
  function readUsage(account, options) {
187
+ const now = Date.now();
188
+ const again = now - asked.get(account.id) < (options.askedAgainMs ?? USAGE_ASKED_AGAIN_MS);
189
+ asked.set(account.id, now);
180
190
  const last = reads.get(account.id);
181
- if (last && !due(last, account, options)) return last.read;
182
- const entry = { at: Date.now() };
183
- entry.read = accountUsage(account, options).then(u => ((entry.pools = u.pools), u));
191
+ if (last && !due(last, again, options)) return last.read;
192
+ const entry = { at: now };
193
+ entry.read = accountUsage(account, options).then(
194
+ u => ((entry.pools = u.pools), u),
195
+ err => { entry.failed = true; throw err; }
196
+ );
184
197
  reads.set(account.id, entry);
185
198
  return entry.read;
186
199
  }
187
200
 
188
201
  // The Google account's email, from the signed-in app page's bootstrap data (WIZ_global_data's
189
202
  // oPEP7c), kept per backend account: the page is large and the email doesn't change. A page
190
- // that didn't give it is asked for again no sooner than USAGE_MAX_AGE_MS: the email only names
203
+ // that didn't give it is asked for again no sooner than EMAIL_RETRY_MS: the email only names
191
204
  // a card. An account with no certain way out (egress) isn't asked yet, and that isn't a try.
192
205
  const emails = new Map(); // id -> { email }, or { at } since the last try that got none
193
206
  const EMAIL_FIELD = /"oPEP7c":"([^"@\s]+@[^"\s]+)"/;
207
+ const EMAIL_RETRY_MS = 6 * 3600 * 1000;
194
208
 
195
209
  export async function accountEmail(account, options = {}) {
196
210
  const known = emails.get(account.id);
197
211
  if (known?.email) return known.email;
198
- if (known && Date.now() - known.at < (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return undefined;
212
+ if (known && Date.now() - known.at < (options.emailRetryMs ?? EMAIL_RETRY_MS)) return undefined;
199
213
  egress(account, options);
200
214
  emails.set(account.id, { at: Date.now() });
201
215
  const page = await geminiRequest('https://gemini.google.com/app', account, {
@@ -250,7 +264,7 @@ export function poolWindows(read) {
250
264
  // usage is the Magpie usage card: one account's 5-hour and weekly windows, as
251
265
  // gemini.google.com/usage shows them, when the sign-in names its backend account; else the
252
266
  // whole pool's, as one card. Accounts go by their Google emails (#id until that is known).
253
- // An account is read from Google again only when due (see USAGE_MIN_GAP_MS).
267
+ // An account is read from Google again only when due (see USAGE_FRESH_MS).
254
268
  // Magpie hides a card's windows behind an error, so a pool account that can't be read only
255
269
  // says so in the tooltip while the others still show.
256
270
  export async function usage(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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",
@@ -83,6 +83,6 @@
83
83
  },
84
84
  "magpie": {
85
85
  "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyIiB5MT0iMyIgeDI9IjIwIiB5Mj0iMjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjMDZCNkQ0Ii8+PHN0b3Agb2Zmc2V0PSI1MCUiIHN0b3AtY29sb3I9IiMzQjgyRjYiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM4QjVDRjYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjE0IiB5MT0iMiIgeDI9IjIyIiB5Mj0iMTAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjOEI1Q0Y2Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRUM0ODk5Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEwIDMuMkMxMC4zNSAzLjIgMTAuNjIgMy40NSAxMC43MiAzLjhDMTEuNDUgNi44NSAxMy4xNSA4LjU1IDE2LjIgOS4yOEMxNi41NSA5LjM4IDE2LjggOS42NSAxNi44IDEwQzE2LjggMTAuMzUgMTYuNTUgMTAuNjIgMTYuMiAxMC43MkMxMy4xNSAxMS40NSAxMS40NSAxMy4xNSAxMC43MiAxNi4yQzEwLjYyIDE2LjU1IDEwLjM1IDE2LjggMTAgMTYuOEM5LjY1IDE2LjggOS4zOCAxNi41NSA5LjI4IDE2LjJDOC41NSAxMy4xNSA2Ljg1IDExLjQ1IDMuOCAxMC43MkMzLjQ1IDEwLjYyIDMuMiAxMC4zNSAzLjIgMTBDMy4yIDkuNjUgMy40NSA5LjM4IDMuOCA5LjI4QzYuODUgOC41NSA4LjU1IDYuODUgOS4yOCAzLjhDOS4zOCAzLjQ1IDkuNjUgMy4yIDEwIDMuMloiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0wLjUgMi41KSBzY2FsZSgxLjEyKSIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Ik0xOC4yIDEuOEMxOC40MiAxLjggMTguNiAxLjk1IDE4LjY2IDIuMTZDMTkuMDggMy44MiAyMC4wMiA0Ljc2IDIxLjY4IDUuMThDMjEuODkgNS4yNCAyMi4wNCA1LjQyIDIyLjA0IDUuNjRDMjIuMDQgNS44NiAyMS44OSA2LjA0IDIxLjY4IDYuMUMyMC4wMiA2LjUyIDE5LjA4IDcuNDYgMTguNjYgOS4xMkMxOC42IDkuMzMgMTguNDIgOS40OCAxOC4yIDkuNDhDMTcuOTggOS40OCAxNy44IDkuMzMgMTcuNzQgOS4xMkMxNy4zMiA3LjQ2IDE2LjM4IDYuNTIgMTQuNzIgNi4xQzE0LjUxIDYuMDQgMTQuMzYgNS44NiAxNC4zNiA1LjY0QzE0LjM2IDUuNDIgMTQuNTEgNS4yNCAxNC43MiA1LjE4QzE2LjM4IDQuNzYgMTcuMzIgMy44MiAxNy43NCAyLjE2QzE3LjggMS45NSAxNy45OCAxLjggMTguMiAxLjhaIiBmaWxsPSJ1cmwoI2IpIi8+PGNpcmNsZSBjeD0iMTcuOCIgY3k9IjE4LjIiIHI9IjEuMzUiIGZpbGw9IiM2MzY2RjEiLz48L3N2Zz4=",
86
- "maxConcurrency": 1
86
+ "maxConcurrency": 2
87
87
  }
88
88
  }