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/README.en.md +43 -9
- package/README.md +43 -9
- package/docs/logo.svg +16 -0
- package/docs/studio-release.md +99 -20
- package/index.mjs +2 -1
- package/lib/backend.mjs +121 -3
- package/lib/studio-native.mjs +7 -3
- package/lib/studio-release.json +73 -19
- package/lib/studio.mjs +82 -12
- package/package.json +7 -2
- package/patches/studio-0.2.17-preserve-request-bodies.patch +160 -0
- package/scripts/studio-managed-smoke.mjs +25 -1
- package/scripts/studio-pins.mjs +49 -0
- package/scripts/studio-release.mjs +193 -18
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
|
-
|
|
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);
|
package/lib/studio-native.mjs
CHANGED
|
@@ -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: '
|
|
242
|
+
WAA_BACKEND: 'spidermonkey',
|
|
239
243
|
UPSTREAM_CHANNELS: 'playground',
|
|
240
244
|
BUILD_NATIVE_NONSTREAM: 'false'
|
|
241
245
|
};
|
package/lib/studio-release.json
CHANGED
|
@@ -1,39 +1,93 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.18-magpie.1",
|
|
3
3
|
"repository": "BUKOWSKIREAL/magpie-gemini-web",
|
|
4
|
-
"tag": "studio-v0.2.
|
|
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.
|
|
58
|
+
"version": "0.2.18",
|
|
8
59
|
"repository": "https://github.com/Mag1cFall/AIStudio2API.git",
|
|
9
|
-
"commit": "
|
|
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.
|
|
15
|
-
"sha256": "
|
|
16
|
-
"binarySha256": "
|
|
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.
|
|
20
|
-
"sha256": "
|
|
21
|
-
"binarySha256": "
|
|
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.
|
|
25
|
-
"sha256": "
|
|
26
|
-
"binarySha256": "
|
|
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.
|
|
30
|
-
"sha256": "
|
|
31
|
-
"binarySha256": "
|
|
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.
|
|
35
|
-
"sha256": "
|
|
36
|
-
"binarySha256": "
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
270
|
-
const
|
|
271
|
-
|
|
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,
|
|
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,
|
|
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 下载(约
|
|
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.
|
|
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
|
}
|