magpie-gemini-web 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/backend.mjs CHANGED
@@ -2,6 +2,7 @@ import { citeStream, citeJSON } from './cite.mjs';
2
2
  import { validateProxyURL } from './setup.mjs';
3
3
 
4
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=';
5
6
 
6
7
  const MEDIA_SPECIAL = /(?:^|[-_])(image|music|canvas|video|research)(?:[-_]|$)/i;
7
8
  const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
@@ -425,6 +426,68 @@ function timedBody(body, timer) {
425
426
  });
426
427
  }
427
428
 
429
+ // keyRefused says whether an error on Gemini's own API is the AI Studio service refusing the key.
430
+ // From 0.2.18 it says so as Gemini does: 400 API_KEY_INVALID for a wrong key, 403 for none. A 403
431
+ // from Google itself ("The caller does not have permission") is about the account, not the key.
432
+ function keyRefused(status, text) {
433
+ let error;
434
+ try {
435
+ const body = JSON.parse(text);
436
+ error = (Array.isArray(body) ? body[0] : body)?.error;
437
+ } catch {
438
+ return false;
439
+ }
440
+ if (status === 400) return Array.isArray(error?.details) && error.details.some(detail => detail?.reason === 'API_KEY_INVALID');
441
+ return error?.status === 'PERMISSION_DENIED' && /unregistered callers/.test(error.message);
442
+ }
443
+
444
+ const GEMINI_CALL = /^\/models\/([^/:]+):(generateContent|streamGenerateContent)$/;
445
+
446
+ // geminiTarget is where a call on Gemini's own API goes at the AI Studio service, with the model
447
+ // it names: the service's /v1beta, beside the /v1 that baseURL always ends in. Magpie draws with
448
+ // an image model there (the model's npm is @ai-sdk/google, see listStudioModels), asking
449
+ // <baseURL>/models/<id>:streamGenerateContent?alt=sse. null for any other request, and for one
450
+ // with any other query.
451
+ function geminiTarget(url, baseURL) {
452
+ const base = new URL(baseURL);
453
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname + '/')) return null;
454
+ const m = GEMINI_CALL.exec(url.pathname.slice(base.pathname.length));
455
+ if (!m || url.hash || (url.search && url.search !== '?alt=sse')) return null;
456
+ let model;
457
+ try {
458
+ model = decodeURIComponent(m[1]);
459
+ } catch {
460
+ return null;
461
+ }
462
+ return { url: `${baseURL}beta/models/${m[1]}:${m[2]}${url.search}`, model };
463
+ }
464
+
465
+ // The line Magpie ends a drawing's prompt with when a size is asked for (drawPrompt): a ratio
466
+ // given as one, or the nearest of its usual ones to a WIDTHxHEIGHT size.
467
+ const ASPECT_LINE = /\n\nAspect ratio: (\d+(?:\.\d+)?):(\d+(?:\.\d+)?)\./;
468
+
469
+ // aspectRatio moves the aspect ratio a generateContent request's prompt names to
470
+ // imageConfig.aspectRatio, as one of aspects, the model's: the one named when the model takes it,
471
+ // else the nearest. With the line in its prompt, gemini-3.1-flash-lite-image (seen on
472
+ // AIStudio2API 0.2.17) answered with no image at all, each of three times; with imageConfig it
473
+ // drew. A request that names a ratio in imageConfig already is left as it is.
474
+ function aspectRatio(body, aspects) {
475
+ const config = body.generationConfig && typeof body.generationConfig === 'object' ? body.generationConfig : {};
476
+ if (config.imageConfig?.aspectRatio) return;
477
+ const contents = Array.isArray(body.contents) ? body.contents : [];
478
+ const content = contents.findLast(c => c?.role === 'user' && Array.isArray(c.parts));
479
+ const part = content?.parts.find(p => typeof p?.text === 'string' && ASPECT_LINE.test(p.text));
480
+ if (!part) return;
481
+ const [, w, h] = ASPECT_LINE.exec(part.text);
482
+ const asked = `${w}:${h}`;
483
+ const log = r => Math.log(Number(r.split(':')[0]) / Number(r.split(':')[1]));
484
+ const nearest = aspects.includes(asked)
485
+ ? asked
486
+ : aspects.reduce((best, r) => Math.abs(log(r) - log(asked)) < Math.abs(log(best) - log(asked)) ? r : best);
487
+ part.text = part.text.replace(ASPECT_LINE, '');
488
+ body.generationConfig = { ...config, imageConfig: { ...config.imageConfig, aspectRatio: nearest } };
489
+ }
490
+
428
491
  export async function forward(input, init, getAuth, opts) {
429
492
  const req = new Request(input, init);
430
493
 
@@ -453,7 +516,11 @@ export async function forward(input, init, getAuth, opts) {
453
516
  return makeOpenAIError(400, 'Invalid request URL');
454
517
  }
455
518
 
456
- if (reqUrl.search || reqUrl.hash) {
519
+ // AI Studio's image models are asked on Gemini's own API (opts.imageModel, see imageModelOf)
520
+ const gemini = opts.imageModel ? geminiTarget(reqUrl, c.baseURL) : null;
521
+ const geminiUrl = gemini?.url;
522
+
523
+ if ((reqUrl.search || reqUrl.hash) && !geminiUrl) {
457
524
  return makeOpenAIError(400, 'Query parameters and fragments are not allowed');
458
525
  }
459
526
 
@@ -461,13 +528,16 @@ export async function forward(input, init, getAuth, opts) {
461
528
  const modelsUrl = `${c.baseURL}/models`;
462
529
  const incomingUrl = `${reqUrl.origin}${reqUrl.pathname}`;
463
530
 
464
- if (incomingUrl !== chatUrl && incomingUrl !== modelsUrl) {
531
+ if (incomingUrl !== chatUrl && incomingUrl !== modelsUrl && !geminiUrl) {
465
532
  return makeOpenAIError(404, 'Endpoint not found');
466
533
  }
467
534
 
468
535
  if (incomingUrl === chatUrl && req.method !== 'POST') {
469
536
  return makeOpenAIError(400, 'Method not allowed: /chat/completions requires POST');
470
537
  }
538
+ if (geminiUrl && req.method !== 'POST') {
539
+ return makeOpenAIError(400, 'Method not allowed: generateContent requires POST');
540
+ }
471
541
  if (incomingUrl === modelsUrl && req.method !== 'GET') {
472
542
  return makeOpenAIError(400, 'Method not allowed: /models requires GET');
473
543
  }
@@ -549,6 +619,47 @@ export async function forward(input, init, getAuth, opts) {
549
619
  body.model = twin;
550
620
  rawBody = JSON.stringify(body);
551
621
  }
622
+ } else if (geminiUrl) {
623
+ try {
624
+ rawBody = await req.text();
625
+ } catch {
626
+ return makeOpenAIError(400, 'Failed to read request body');
627
+ }
628
+
629
+ let body;
630
+ try {
631
+ body = JSON.parse(rawBody);
632
+ } catch {
633
+ return makeOpenAIError(400, 'Invalid JSON body');
634
+ }
635
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
636
+ return makeOpenAIError(400, 'Request body must be a JSON object');
637
+ }
638
+
639
+ if (!opts.allowTools && Array.isArray(body.tools) && body.tools.length > 0) {
640
+ return makeOpenAIError(400, `Tool calls are turned off for magpie-gemini-web. Turn them on with: magpie plugin options magpie-gemini-web '{"allowTools":true}'`);
641
+ }
642
+
643
+ // A text model is asked on chat completions, where allowImages holds. One the service's list
644
+ // can't tell of goes no further: an unreachable service is answered as one, for a managed one
645
+ // to be started again (forwardManagedStudio).
646
+ let aspects;
647
+ try {
648
+ aspects = await opts.imageModel(c, gemini.model);
649
+ } catch (err) {
650
+ if (err?.unreachable) {
651
+ return makeOpenAIError(502, 'Bad gateway: failed to reach backend service', { [UNREACHABLE_HEADER]: 'unreachable' });
652
+ }
653
+ if (err?.signIn === 'expired') {
654
+ return makeOpenAIError(401, 'Unauthorized: AI Studio refused the key');
655
+ }
656
+ return makeOpenAIError(503, `Can't tell whether ${gemini.model} is one of AI Studio's image models: ${err?.message || err}`);
657
+ }
658
+ if (!aspects) {
659
+ return makeOpenAIError(400, `${gemini.model} isn't one of AI Studio's image models: generateContent here is for those alone, and a text model is asked on /chat/completions`);
660
+ }
661
+ aspectRatio(body, aspects);
662
+ rawBody = JSON.stringify(body);
552
663
  }
553
664
 
554
665
  // requestTimeoutMs bounds each wait on the backend, for the response headers and then for each
@@ -560,7 +671,7 @@ export async function forward(input, init, getAuth, opts) {
560
671
  timer.start();
561
672
  try {
562
673
  // Bun's fetch drops a connection idle for 300 s; the timer sets the deadline instead.
563
- upstreamRes = await fetch(incomingUrl, {
674
+ upstreamRes = await fetch(geminiUrl || incomingUrl, {
564
675
  method: req.method,
565
676
  headers,
566
677
  body: rawBody,
@@ -592,6 +703,13 @@ export async function forward(input, init, getAuth, opts) {
592
703
 
593
704
  // Gemini's citation markers come out of a chat reply, streamed or whole (see cite.mjs).
594
705
  let respBody = upstreamRes.body && timedBody(upstreamRes.body, timer);
706
+ if (geminiUrl && (upstreamRes.status === 400 || upstreamRes.status === 403) && respBody) {
707
+ const text = await new Response(respBody).text();
708
+ if (keyRefused(upstreamRes.status, text)) {
709
+ return makeOpenAIError(401, 'Unauthorized: AI Studio refused the key');
710
+ }
711
+ respBody = text;
712
+ }
595
713
  if (opts.stripCitations && incomingUrl === chatUrl && upstreamRes.ok && respBody) {
596
714
  const type = upstreamRes.headers.get('content-type') || '';
597
715
  if (type.includes('text/event-stream')) respBody = citeStream(respBody);
@@ -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'
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,39 +1,93 @@
1
1
  {
2
- "version": "0.2.16-magpie.1",
2
+ "version": "0.2.18-magpie.1",
3
3
  "repository": "BUKOWSKIREAL/magpie-gemini-web",
4
- "tag": "studio-v0.2.16-magpie.1",
4
+ "tag": "studio-v0.2.18-magpie.1",
5
5
  "goVersion": "1.27.1",
6
+ "toolchains": {
7
+ "zig": "0.16.0",
8
+ "xcode": {
9
+ "version": "26.3",
10
+ "build": "17C529"
11
+ },
12
+ "llvmMingw": {
13
+ "version": "20261020",
14
+ "clang": "clang version 23.1.4 (https://github.com/llvm/llvm-project.git 7a941dfb2877a0b8cf30323fefa85ad019e0b0f5)",
15
+ "hosts": {
16
+ "darwin": {
17
+ "asset": "llvm-mingw-20261020-ucrt-macos-universal.tar.xz",
18
+ "sha256": "e9c13e4ede38e715003ffc674d4d00a1088df8235439d1f0c16188ff0704c17e"
19
+ },
20
+ "linux-x64": {
21
+ "asset": "llvm-mingw-20261020-ucrt-ubuntu-22.04-x86_64.tar.xz",
22
+ "sha256": "55064b60b991a1e1e8090b8c2f84e82b22cfe5903d12c159d65064cf5e8ae335"
23
+ },
24
+ "win32-x64": {
25
+ "asset": "llvm-mingw-20261020-ucrt-x86_64.zip",
26
+ "sha256": "fd5462b7f5d93b2ff981fcd3f9c7e427e47f3203c64ea91c27bcc444eebd74cd"
27
+ }
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
+ }
55
+ }
56
+ },
6
57
  "upstream": {
7
- "version": "0.2.16",
58
+ "version": "0.2.18",
8
59
  "repository": "https://github.com/Mag1cFall/AIStudio2API.git",
9
- "commit": "fd6aab611b52fbfe179fbfc1bf830c4343066e32",
60
+ "commit": "9b694a6b7c6439db1767a577994dfcc87a3cf588",
10
61
  "camoufox": "152.0.4-beta.29"
11
62
  },
63
+ "patches": [
64
+ "studio-0.2.17-preserve-request-bodies.patch"
65
+ ],
12
66
  "binaries": {
13
67
  "darwin-arm64": {
14
- "asset": "aistudio2api-v0.2.16-magpie.1-darwin-arm64.tar.gz",
15
- "sha256": "3dd83c7c7fef6bbf96dc7d8eb6dd7257955af6c274c0fc67ad2ecb24a7df9992",
16
- "binarySha256": "6f07122038587514bebe9f15ba0b00f7d9916d4db130028669380729b2e97c97"
68
+ "asset": "aistudio2api-v0.2.18-magpie.1-darwin-arm64.tar.gz",
69
+ "sha256": "9f70596a5b95f4bcf5347a52b88ad41e26106cd6e2a79304183a73d58d8a7a29",
70
+ "binarySha256": "d41e9d4c950efb479ad19727bdf496f8de05c01451110fd9eb7425a9054f5c66"
17
71
  },
18
72
  "darwin-x64": {
19
- "asset": "aistudio2api-v0.2.16-magpie.1-darwin-amd64.tar.gz",
20
- "sha256": "4152cbb37d83680c903d1bb3e72e7eaebd80e5e0df617c20e3ba7fbffd160868",
21
- "binarySha256": "a0ad46091dd4ec20f0d82ffa3f14673ac3b5c70dc4a8a0e4cc8aac658525d050"
73
+ "asset": "aistudio2api-v0.2.18-magpie.1-darwin-amd64.tar.gz",
74
+ "sha256": "b118733750a3f882fe13754b2079f9cc89b2645d3cd3bce4bab26aaee24b54e5",
75
+ "binarySha256": "6a1017195809a881614032be647faf25754ebfe060e3301424dcdacefba32469"
22
76
  },
23
77
  "linux-x64": {
24
- "asset": "aistudio2api-v0.2.16-magpie.1-linux-amd64.tar.gz",
25
- "sha256": "7f97398ea29c2413d3c3e12aca9525213bc80b97c6667a68429ccd3e8dc27ba1",
26
- "binarySha256": "deef4cc68cafff147ad24fcaed2d42389d2dbd54d081696c3d6ea95b7d0ea8fa"
78
+ "asset": "aistudio2api-v0.2.18-magpie.1-linux-amd64.tar.gz",
79
+ "sha256": "e6310fb89c34295d1a4c60e40b30d7a3073e9f5fc16343650f4021f396225922",
80
+ "binarySha256": "1803037342daada925486f5acd40bfc0bc06bda54b0f1f4459da79d1e917d283"
27
81
  },
28
82
  "linux-arm64": {
29
- "asset": "aistudio2api-v0.2.16-magpie.1-linux-arm64.tar.gz",
30
- "sha256": "2fccc892f00e17b8171ab2ed5d22b4a413b6e7d8230bb911bc5e846c5dc8f717",
31
- "binarySha256": "567c61bec4f7cf199eabd05ecb3420f409039064fe02a2e38ed22e8b35db3da9"
83
+ "asset": "aistudio2api-v0.2.18-magpie.1-linux-arm64.tar.gz",
84
+ "sha256": "965336192889bcd2d9f6db1e5f9c035251ff64db86ff0e590d9ed89662867629",
85
+ "binarySha256": "81553da83a7322d608b2498e6c6a01b08fe963341d8655262ed2dd17ca7a83df"
32
86
  },
33
87
  "win32-x64": {
34
- "asset": "aistudio2api-v0.2.16-magpie.1-windows-amd64.zip",
35
- "sha256": "e7d115aacd957ffda80cf2ecadc38af8404d780bc45af01b6b945accc50d8bc7",
36
- "binarySha256": "bf96178445dbb653b6876b3abe1768b880465974531d6fb12892857fbc666818"
88
+ "asset": "aistudio2api-v0.2.18-magpie.1-windows-amd64.zip",
89
+ "sha256": "537884412868849665b22d5d9066101a94d389ad0f048555da8c8ea55c7240ba",
90
+ "binarySha256": "0024d8709eb97950ddb3ad6016612521a76c7aca553235db2dddb9183188806e"
37
91
  }
38
92
  }
39
93
  }
package/lib/studio.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER } from './backend.mjs';
1
+ import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER, PLUGIN_ICON } from './backend.mjs';
2
2
  import path from 'node:path';
3
3
  import { localStudio, ensureStudio, studioAdmin, studioAccounts, studioProxy, studioCommand, validateProxy, isConnectRefused, PROXY_SOURCES, downloadProxy, redactProxy, studioDataDir, checkStudioHealth, setupStep, camoufoxState } from './studio-native.mjs';
4
4
  import { openProgressPage } from './studio-page.mjs';
@@ -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;
@@ -266,9 +295,19 @@ export async function listStudioModels(c, opts) {
266
295
 
267
296
  const toolcall = Boolean(opts.allowTools && caps.function_declarations === true);
268
297
  const reasoning = Boolean(caps.thinking === true || caps.thinking_level === true);
269
- // Of the levels the backend takes, none is the lowest one again and xhigh/max are high.
270
- const variants = caps.thinking_level === true ? { minimal: {}, low: {}, medium: {}, high: {} } : {};
271
- const image = Boolean(opts.allowImages && caps.media_resolution === true);
298
+ // Newer catalogs expose each model's supported levels; older external services do not.
299
+ const levels = Array.isArray(item.thinking_levels)
300
+ ? item.thinking_levels.filter(level => ['minimal', 'low', 'medium', 'high'].includes(level))
301
+ : ['minimal', 'low', 'medium', 'high'];
302
+ const variants = caps.thinking_level === true ? Object.fromEntries(levels.map(level => [level, {}])) : {};
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
+ }
272
311
 
273
312
  result[id] = {
274
313
  id,
@@ -277,7 +316,7 @@ export async function listStudioModels(c, opts) {
277
316
  api: {
278
317
  id,
279
318
  url: c.baseURL,
280
- npm: '@ai-sdk/openai-compatible'
319
+ npm: draws ? '@ai-sdk/google' : '@ai-sdk/openai-compatible'
281
320
  },
282
321
  limit: {
283
322
  context,
@@ -293,7 +332,8 @@ export async function listStudioModels(c, opts) {
293
332
  image
294
333
  },
295
334
  output: {
296
- text: true
335
+ text: true,
336
+ image: draws
297
337
  }
298
338
  },
299
339
  cost: {
@@ -315,9 +355,31 @@ export async function listStudioModels(c, opts) {
315
355
  throw new Error('No usable Gemini models found in AI Studio response');
316
356
  }
317
357
 
358
+ imageCatalogs.set(catalogKey(c), { images, at: Date.now() });
318
359
  return result;
319
360
  }
320
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
+
321
383
  function sameBackend(c, local) {
322
384
  return c.key === local.apiKey && c.baseURL === local.baseURL;
323
385
  }
@@ -346,6 +408,13 @@ function staleManagedSignIn(auth, opts) {
346
408
 
347
409
  async function forwardManagedStudio(input, init, getAuth, opts) {
348
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
+ };
349
418
 
350
419
  const auth = await getAuth();
351
420
  if (auth?.metadata?.managed === true && staleManagedSignIn(auth, opts)) {
@@ -353,7 +422,7 @@ async function forwardManagedStudio(input, init, getAuth, opts) {
353
422
  }
354
423
 
355
424
  const callerSignal = input instanceof Request ? input.signal : init?.signal;
356
- const res = await forward(input, init, getAuth, { ...opts, nativeThinking: true, stripCitations: false });
425
+ const res = await forward(input, init, getAuth, forwardOpts);
357
426
  if (res.headers.get(UNREACHABLE_HEADER) !== 'unreachable') return res;
358
427
 
359
428
  if (callerSignal?.aborted) return res;
@@ -366,7 +435,7 @@ async function forwardManagedStudio(input, init, getAuth, opts) {
366
435
  }
367
436
 
368
437
  if (callerSignal?.aborted) return res;
369
- return forward(retryInput, init, getAuth, { ...opts, nativeThinking: true, stripCitations: false });
438
+ return forward(retryInput, init, getAuth, forwardOpts);
370
439
  }
371
440
 
372
441
  // accountSignIn is the Magpie sign-in for one Google account of the local service: the service's
@@ -667,7 +736,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
667
736
  proxy = downloadProxy(studioDataDir(), opts.proxy);
668
737
  } catch {}
669
738
  const via = proxy ? `走代理 ${redactProxy(proxy)}` : '按 Magpie 的代理设置';
670
- return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约 25 MB,${via}),可能要等几分钟。${SETUP_PROXY_HINT}\n`;
739
+ return `本地 AI Studio 服务还没启动:要先启动它,第一次使用或插件更新后还要先从 GitHub 下载(约 40 MB,${via}),可能要等几分钟。${SETUP_PROXY_HINT}\n`;
671
740
  }
672
741
 
673
742
  // addAccount signs a new Google account in through the service's own login window, setting the
@@ -743,6 +812,7 @@ export const AIStudioPlugin = async (_input, options = {}) => {
743
812
  },
744
813
  auth: {
745
814
  provider: STUDIO_ID,
815
+ icon: PLUGIN_ICON,
746
816
  maxConcurrency: 1,
747
817
  methods: [
748
818
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
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",
@@ -26,6 +26,7 @@
26
26
  "scripts/studio-smoke.mjs",
27
27
  "scripts/studio.mjs",
28
28
  "scripts/studio-release.mjs",
29
+ "scripts/studio-pins.mjs",
29
30
  "scripts/studio-archive.go",
30
31
  "scripts/studio-regzip.go",
31
32
  "scripts/studio-managed-smoke.mjs",
@@ -39,6 +40,7 @@
39
40
  "native-task/LICENSE",
40
41
  "README.md",
41
42
  "README.en.md",
43
+ "docs/logo.svg",
42
44
  "docs/backend-release.md",
43
45
  "docs/studio-release.md",
44
46
  "LICENSE",
@@ -51,7 +53,9 @@
51
53
  "opencode",
52
54
  "provider",
53
55
  "gemini",
54
- "gemini-web"
56
+ "gemini-web",
57
+ "ai-studio",
58
+ "google-ai-studio"
55
59
  ],
56
60
  "engines": {
57
61
  "node": ">=22"
@@ -78,6 +82,7 @@
78
82
  "studio:smoke": "node scripts/studio-smoke.mjs"
79
83
  },
80
84
  "magpie": {
85
+ "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyIiB5MT0iMyIgeDI9IjIwIiB5Mj0iMjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjMDZCNkQ0Ii8+PHN0b3Agb2Zmc2V0PSI1MCUiIHN0b3AtY29sb3I9IiMzQjgyRjYiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM4QjVDRjYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjE0IiB5MT0iMiIgeDI9IjIyIiB5Mj0iMTAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjOEI1Q0Y2Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRUM0ODk5Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEwIDMuMkMxMC4zNSAzLjIgMTAuNjIgMy40NSAxMC43MiAzLjhDMTEuNDUgNi44NSAxMy4xNSA4LjU1IDE2LjIgOS4yOEMxNi41NSA5LjM4IDE2LjggOS42NSAxNi44IDEwQzE2LjggMTAuMzUgMTYuNTUgMTAuNjIgMTYuMiAxMC43MkMxMy4xNSAxMS40NSAxMS40NSAxMy4xNSAxMC43MiAxNi4yQzEwLjYyIDE2LjU1IDEwLjM1IDE2LjggMTAgMTYuOEM5LjY1IDE2LjggOS4zOCAxNi41NSA5LjI4IDE2LjJDOC41NSAxMy4xNSA2Ljg1IDExLjQ1IDMuOCAxMC43MkMzLjQ1IDEwLjYyIDMuMiAxMC4zNSAzLjIgMTBDMy4yIDkuNjUgMy40NSA5LjM4IDMuOCA5LjI4QzYuODUgOC41NSA4LjU1IDYuODUgOS4yOCAzLjhDOS4zOCAzLjQ1IDkuNjUgMy4yIDEwIDMuMloiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0wLjUgMi41KSBzY2FsZSgxLjEyKSIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Ik0xOC4yIDEuOEMxOC40MiAxLjggMTguNiAxLjk1IDE4LjY2IDIuMTZDMTkuMDggMy44MiAyMC4wMiA0Ljc2IDIxLjY4IDUuMThDMjEuODkgNS4yNCAyMi4wNCA1LjQyIDIyLjA0IDUuNjRDMjIuMDQgNS44NiAyMS44OSA2LjA0IDIxLjY4IDYuMUMyMC4wMiA2LjUyIDE5LjA4IDcuNDYgMTguNjYgOS4xMkMxOC42IDkuMzMgMTguNDIgOS40OCAxOC4yIDkuNDhDMTcuOTggOS40OCAxNy44IDkuMzMgMTcuNzQgOS4xMkMxNy4zMiA3LjQ2IDE2LjM4IDYuNTIgMTQuNzIgNi4xQzE0LjUxIDYuMDQgMTQuMzYgNS44NiAxNC4zNiA1LjY0QzE0LjM2IDUuNDIgMTQuNTEgNS4yNCAxNC43MiA1LjE4QzE2LjM4IDQuNzYgMTcuMzIgMy44MiAxNy43NCAyLjE2QzE3LjggMS45NSAxNy45OCAxLjggMTguMiAxLjhaIiBmaWxsPSJ1cmwoI2IpIi8+PGNpcmNsZSBjeD0iMTcuOCIgY3k9IjE4LjIiIHI9IjEuMzUiIGZpbGw9IiM2MzY2RjEiLz48L3N2Zz4=",
81
86
  "maxConcurrency": 1
82
87
  }
83
88
  }