magpie-gemini-web 0.4.0 → 0.5.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.
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": "0.2.12",
3
+ "repository": "Mag1cFall/AIStudio2API",
4
+ "tag": "v0.2.12",
5
+ "binaries": {
6
+ "darwin-x64": {
7
+ "asset": "aistudio2api-v0.2.12-darwin-amd64.tar.gz",
8
+ "sha256": "d7700be498293539c551671c582c4112997873593dcea6dee5cf9f23562f0331"
9
+ },
10
+ "darwin-arm64": {
11
+ "asset": "aistudio2api-v0.2.12-darwin-arm64.tar.gz",
12
+ "sha256": "a24bdc093e024e6c5d737ebdaed719c9cf5d6418c4f2edbf907f3c2979a9bb63"
13
+ },
14
+ "linux-x64": {
15
+ "asset": "aistudio2api-v0.2.12-linux-amd64.tar.gz",
16
+ "sha256": "fa2de6053b2bf8c8188cef39751a31bd0f88a07c67f34ad59a8bce29b0069b6a"
17
+ },
18
+ "linux-arm64": {
19
+ "asset": "aistudio2api-v0.2.12-linux-arm64.tar.gz",
20
+ "sha256": "32c9cbe63fa0b864f201e917aaa2c9b3b5a2be6c3f7734ad4f2328df7ed5e19c"
21
+ },
22
+ "win32-x64": {
23
+ "asset": "aistudio2api-v0.2.12-windows-amd64.zip",
24
+ "sha256": "e03235bc5aecc6e81c2c609ca4cc010afb6b88ce98ac87934603a59fe625b493"
25
+ }
26
+ }
27
+ }
package/lib/studio.mjs ADDED
@@ -0,0 +1,641 @@
1
+ import { normalizeBaseURL, credentials, forward, makeOpenAIError, UNREACHABLE_HEADER } from './backend.mjs';
2
+ import { localStudio, ensureStudio, studioAdmin, studioAccounts, validateProxy, isConnectRefused } from './studio-native.mjs';
3
+
4
+ export const STUDIO_ID = 'ai-studio';
5
+ export const STUDIO_BASE = 'http://127.0.0.1:2048/v1';
6
+
7
+ const STUDIO_EXCLUDE = /(?:image|nano-banana|tts|audio|live|transcribe|robotics|research|video|music|canvas)/i;
8
+ // Catalog capabilities of models made for images, speech or live sessions. Such a model can be
9
+ // marked chat_model and named like a text model too (gemini-omni-*), so the name can't tell.
10
+ const NON_TEXT_CAPS = ['image_route', 'speech_route', 'live_route', 'aspect_ratio', 'output_resolution', 'interactions_api', 'transcription_output'];
11
+ const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
12
+
13
+ // The service opens its own login window for a new account, and waits this long for it.
14
+ const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
15
+ // Upstream logs in with Camoufox even when generation runs in Go, and downloads it on the first
16
+ // login, so that one can take minutes before a window shows.
17
+ const LOGIN_WINDOW = [
18
+ '即将打开独立的 Google 登录窗口(不影响你平时用的浏览器)。',
19
+ '第一次登录要先下载登录用的浏览器 Camoufox,可能要等几分钟窗口才出现。',
20
+ '请在 15 分钟内完成登录;登录完成后可以关掉窗口,日常生成不需要浏览器。'
21
+ ];
22
+ const LOGIN_STEPS = [
23
+ ...LOGIN_WINDOW,
24
+ '已经配置过也可以再来一次,添加更多 Google 账号:所有账号共用这一个登录,由本地服务自动轮换。'
25
+ ].join('\n');
26
+ const RELOGIN_STEPS = LOGIN_WINDOW.join('\n');
27
+ const SETUP_METHOD = '一键配置本地 Google AI Studio(推荐)';
28
+ const RELOGIN_METHOD = '重新登录失效的 AI Studio 账号';
29
+ const NO_LOCAL_STUDIO = `还没有本地 Google AI Studio 服务:请先用「${SETUP_METHOD}」`;
30
+
31
+ const KNOWN_STUDIO_NESTED = new Set([
32
+ 'baseURL',
33
+ 'allowTools',
34
+ 'allowImages',
35
+ 'contextLimit',
36
+ 'outputLimit',
37
+ 'requestTimeoutMs',
38
+ 'modelTimeoutMs',
39
+ 'proxy',
40
+ 'stripCitations'
41
+ ]);
42
+
43
+ export function studioSettings(options = {}) {
44
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
45
+ throw new Error('Plugin options must be an object');
46
+ }
47
+
48
+ let allowTools = true;
49
+ if (options.allowTools !== undefined) {
50
+ if (typeof options.allowTools !== 'boolean') {
51
+ throw new Error('Invalid option allowTools: must be a boolean');
52
+ }
53
+ allowTools = options.allowTools;
54
+ }
55
+
56
+ let allowImages = false;
57
+ if (options.allowImages !== undefined) {
58
+ if (typeof options.allowImages !== 'boolean') {
59
+ throw new Error('Invalid option allowImages: must be a boolean');
60
+ }
61
+ allowImages = options.allowImages;
62
+ }
63
+
64
+ let requestTimeoutMs = 240000;
65
+ if (options.requestTimeoutMs !== undefined) {
66
+ if (!Number.isInteger(options.requestTimeoutMs) || options.requestTimeoutMs <= 0) {
67
+ throw new Error('Invalid option requestTimeoutMs: must be a positive integer');
68
+ }
69
+ requestTimeoutMs = options.requestTimeoutMs;
70
+ }
71
+
72
+ let modelTimeoutMs = 10000;
73
+ if (options.modelTimeoutMs !== undefined) {
74
+ if (!Number.isInteger(options.modelTimeoutMs) || options.modelTimeoutMs <= 0) {
75
+ throw new Error('Invalid option modelTimeoutMs: must be a positive integer');
76
+ }
77
+ modelTimeoutMs = options.modelTimeoutMs;
78
+ }
79
+
80
+ let baseURL = STUDIO_BASE;
81
+ let proxy;
82
+ let explicitContextLimit = null;
83
+ let explicitOutputLimit = null;
84
+
85
+ if (options.studio !== undefined) {
86
+ if (!options.studio || typeof options.studio !== 'object' || Array.isArray(options.studio)) {
87
+ throw new Error('Invalid option studio: must be an object');
88
+ }
89
+ for (const key of Object.keys(options.studio)) {
90
+ if (!KNOWN_STUDIO_NESTED.has(key)) {
91
+ throw new Error(`Unknown studio option: ${key}`);
92
+ }
93
+ }
94
+
95
+ const s = options.studio;
96
+ if (s.stripCitations !== undefined && s.stripCitations !== false) {
97
+ throw new Error('Invalid option stripCitations: AI Studio citations cannot be stripped (must be false)');
98
+ }
99
+
100
+ if (s.baseURL !== undefined) {
101
+ baseURL = normalizeBaseURL(s.baseURL);
102
+ }
103
+ if (s.proxy !== undefined) {
104
+ proxy = validateProxy(s.proxy);
105
+ }
106
+ if (s.allowTools !== undefined) {
107
+ if (typeof s.allowTools !== 'boolean') {
108
+ throw new Error('Invalid option studio.allowTools: must be a boolean');
109
+ }
110
+ allowTools = s.allowTools;
111
+ }
112
+ if (s.allowImages !== undefined) {
113
+ if (typeof s.allowImages !== 'boolean') {
114
+ throw new Error('Invalid option studio.allowImages: must be a boolean');
115
+ }
116
+ allowImages = s.allowImages;
117
+ }
118
+ if (s.requestTimeoutMs !== undefined) {
119
+ if (!Number.isInteger(s.requestTimeoutMs) || s.requestTimeoutMs <= 0) {
120
+ throw new Error('Invalid option studio.requestTimeoutMs: must be a positive integer');
121
+ }
122
+ requestTimeoutMs = s.requestTimeoutMs;
123
+ }
124
+ if (s.modelTimeoutMs !== undefined) {
125
+ if (!Number.isInteger(s.modelTimeoutMs) || s.modelTimeoutMs <= 0) {
126
+ throw new Error('Invalid option studio.modelTimeoutMs: must be a positive integer');
127
+ }
128
+ modelTimeoutMs = s.modelTimeoutMs;
129
+ }
130
+ if (s.contextLimit !== undefined) {
131
+ if (!Number.isInteger(s.contextLimit) || s.contextLimit <= 0) {
132
+ throw new Error('Invalid option studio.contextLimit: must be a positive integer');
133
+ }
134
+ explicitContextLimit = s.contextLimit;
135
+ }
136
+ if (s.outputLimit !== undefined) {
137
+ if (!Number.isInteger(s.outputLimit) || s.outputLimit <= 0) {
138
+ throw new Error('Invalid option studio.outputLimit: must be a positive integer');
139
+ }
140
+ explicitOutputLimit = s.outputLimit;
141
+ }
142
+ if (explicitContextLimit !== null && explicitOutputLimit !== null && explicitOutputLimit > explicitContextLimit) {
143
+ throw new Error('Invalid option studio.outputLimit: cannot exceed contextLimit');
144
+ }
145
+ }
146
+
147
+ return {
148
+ baseURL,
149
+ allowTools,
150
+ allowImages,
151
+ requestTimeoutMs,
152
+ modelTimeoutMs,
153
+ proxy,
154
+ stripCitations: false,
155
+ nativeThinking: true,
156
+ explicitContextLimit,
157
+ explicitOutputLimit
158
+ };
159
+ }
160
+
161
+ export async function listStudioModels(c, opts) {
162
+ const url = `${c.baseURL}/models`;
163
+ let res;
164
+ try {
165
+ res = await fetch(url, {
166
+ method: 'GET',
167
+ headers: {
168
+ 'Authorization': `Bearer ${c.key}`,
169
+ 'Accept': 'application/json'
170
+ },
171
+ redirect: 'error',
172
+ signal: AbortSignal.timeout(opts.modelTimeoutMs)
173
+ });
174
+ } catch (err) {
175
+ const error = new Error('Failed to reach AI Studio backend models endpoint: ' + (err?.message || err));
176
+ error.status = 502;
177
+ error.unreachable = true;
178
+ throw error;
179
+ }
180
+
181
+ if (!res.ok) {
182
+ const error = new Error(`Failed to list AI Studio models: HTTP ${res.status}`);
183
+ error.status = res.status;
184
+ if (res.status === 401) {
185
+ error.signIn = 'expired';
186
+ }
187
+ throw error;
188
+ }
189
+
190
+ let body;
191
+ try {
192
+ body = await res.json();
193
+ } catch {
194
+ throw new Error('Invalid models response: failed to parse JSON');
195
+ }
196
+
197
+ if (!body || typeof body !== 'object' || !Array.isArray(body.data)) {
198
+ throw new Error('Invalid models response: expected data array');
199
+ }
200
+
201
+ const result = Object.create(null);
202
+ const seen = new Set();
203
+ // A model no enabled account's subscription covers is listed without channels, and calls to it
204
+ // get a 403. A backend that lists no channels at all is older than that rule: keep its models.
205
+ const listsChannels = body.data.some(item => Array.isArray(item?.channels));
206
+
207
+ for (const item of body.data) {
208
+ if (!item || typeof item !== 'object' || typeof item.id !== 'string') {
209
+ throw new Error('Invalid model entry in response: expected object with string id');
210
+ }
211
+ const id = item.id;
212
+ if (!id || CONTROL_CHARS.test(id)) {
213
+ throw new Error('Invalid model entry in response: invalid id format');
214
+ }
215
+ if (!id.startsWith('gemini-')) continue;
216
+ if (STUDIO_EXCLUDE.test(id)) continue;
217
+
218
+ const methods = Array.isArray(item.supported_generation_methods) ? item.supported_generation_methods : [];
219
+ if (!methods.includes('generateContent')) continue;
220
+
221
+ const caps = item.capabilities && typeof item.capabilities === 'object' ? item.capabilities : {};
222
+ if (caps.chat_model !== true) continue;
223
+ if (NON_TEXT_CAPS.some(cap => caps[cap] === true)) continue;
224
+ if (listsChannels && !(Array.isArray(item.channels) && item.channels.length > 0)) continue;
225
+
226
+ if (seen.has(id)) continue;
227
+ seen.add(id);
228
+
229
+ const upstreamInput = Number.isInteger(item.input_token_limit) && item.input_token_limit > 0
230
+ ? item.input_token_limit
231
+ : 1000000;
232
+ const upstreamOutput = Number.isInteger(item.output_token_limit) && item.output_token_limit > 0
233
+ ? item.output_token_limit
234
+ : 8192;
235
+
236
+ let context = opts.explicitContextLimit ? Math.min(upstreamInput, opts.explicitContextLimit) : upstreamInput;
237
+ let output = opts.explicitOutputLimit ? Math.min(upstreamOutput, opts.explicitOutputLimit) : upstreamOutput;
238
+ output = Math.min(output, context);
239
+
240
+ const toolcall = Boolean(opts.allowTools && caps.function_declarations === true);
241
+ const reasoning = Boolean(caps.thinking === true || caps.thinking_level === true);
242
+ // Of the levels the backend takes, none is the lowest one again and xhigh/max are high.
243
+ const variants = caps.thinking_level === true ? { minimal: {}, low: {}, medium: {}, high: {} } : {};
244
+ const image = Boolean(opts.allowImages && caps.media_resolution === true);
245
+
246
+ result[id] = {
247
+ id,
248
+ providerID: STUDIO_ID,
249
+ name: item.name || id,
250
+ api: {
251
+ id,
252
+ url: c.baseURL,
253
+ npm: '@ai-sdk/openai-compatible'
254
+ },
255
+ limit: {
256
+ context,
257
+ output
258
+ },
259
+ capabilities: {
260
+ temperature: true,
261
+ reasoning,
262
+ attachment: image,
263
+ toolcall,
264
+ input: {
265
+ text: true,
266
+ image
267
+ },
268
+ output: {
269
+ text: true
270
+ }
271
+ },
272
+ cost: {
273
+ input: 0,
274
+ output: 0,
275
+ cache: {
276
+ read: 0,
277
+ write: 0
278
+ }
279
+ },
280
+ options: {},
281
+ headers: {},
282
+ variants,
283
+ status: 'active'
284
+ };
285
+ }
286
+
287
+ if (Object.keys(result).length === 0) {
288
+ throw new Error('No usable Gemini models found in AI Studio response');
289
+ }
290
+
291
+ return result;
292
+ }
293
+
294
+ function sameBackend(c, local) {
295
+ return c.key === local.apiKey && c.baseURL === local.baseURL;
296
+ }
297
+
298
+ function managedMismatch() {
299
+ const err = new Error('Managed AI Studio key or baseURL mismatch; please re-authenticate');
300
+ err.signIn = 'expired';
301
+ return err;
302
+ }
303
+
304
+ // staleManagedSignIn says whether a managed sign-in names a key or URL the local service no
305
+ // longer has. Without a local setup there is nothing to tell by: the request finds out when it
306
+ // reaches the service, which is installed again if it is down. Unusable credentials are left to
307
+ // forward(), which answers them as an expired sign-in.
308
+ function staleManagedSignIn(auth, opts) {
309
+ const local = localStudio(opts);
310
+ if (!local) return false;
311
+ let c;
312
+ try {
313
+ c = credentials(auth, opts);
314
+ } catch {
315
+ return false;
316
+ }
317
+ return !sameBackend(c, local);
318
+ }
319
+
320
+ async function forwardManagedStudio(input, init, getAuth, opts) {
321
+ const retryInput = input instanceof Request ? input.clone() : input;
322
+
323
+ const auth = await getAuth();
324
+ if (auth?.metadata?.managed === true && staleManagedSignIn(auth, opts)) {
325
+ return makeOpenAIError(401, 'Managed AI Studio key or baseURL changed; sign in to AI Studio again');
326
+ }
327
+
328
+ const callerSignal = input instanceof Request ? input.signal : init?.signal;
329
+ const res = await forward(input, init, getAuth, { ...opts, nativeThinking: true, stripCitations: false });
330
+ if (res.headers.get(UNREACHABLE_HEADER) !== 'unreachable') return res;
331
+
332
+ if (callerSignal?.aborted) return res;
333
+ if (!auth?.metadata?.managed) return res;
334
+
335
+ try {
336
+ await ensureStudio({ ...opts, proxy: opts.proxy });
337
+ } catch {
338
+ return res;
339
+ }
340
+
341
+ if (callerSignal?.aborted) return res;
342
+ return forward(retryInput, init, getAuth, { ...opts, nativeThinking: true, stripCitations: false });
343
+ }
344
+
345
+ // poolSignIn is the Magpie sign-in for the local service: its key, which serves every account
346
+ // signed in to it. The service picks the account for each request itself (fill-first), so a
347
+ // sign-in can't pin one, and none is named: Magpie takes two sign-ins with no email and the same
348
+ // key for one, so signing in again, as adding another account does, keeps the one card.
349
+ function poolSignIn(local) {
350
+ return {
351
+ type: 'success',
352
+ key: local.apiKey,
353
+ metadata: { mode: 'studio', managed: true, baseURL: local.baseURL }
354
+ };
355
+ }
356
+
357
+ const SERVING_STATES = new Set(['ready', 'busy', 'cooldown']);
358
+ const labelOf = a => String(a.label || a.id);
359
+
360
+ // cooldownWindows is the usage card's windows for the pool's cooldowns (AdminCooldown, one for each
361
+ // account and model): one window a model, as full as the share of its accounts cooling down on it,
362
+ // so a full one is a model no account can serve now, and starting again when the first of them
363
+ // does. A cooldown with no model stands for all of an account's models.
364
+ export function cooldownWindows(cooldowns, accounts, now = Date.now()) {
365
+ const serving = accounts.filter(a => SERVING_STATES.has(a.state));
366
+ const byModel = new Map();
367
+ for (const c of cooldowns) {
368
+ const until = Date.parse(c?.until ?? '');
369
+ if (!c || !Number.isFinite(until) || until <= now) continue;
370
+ const model = typeof c.model_id === 'string' ? c.model_id : '';
371
+ const m = byModel.get(model) ?? { accounts: new Set(), until: Infinity, reasons: new Set() };
372
+ m.accounts.add(c.account_id);
373
+ m.until = Math.min(m.until, until);
374
+ if (typeof c.reason === 'string' && c.reason.trim()) m.reasons.add(c.reason.trim());
375
+ byModel.set(model, m);
376
+ }
377
+ const out = [];
378
+ for (const [model, m] of byModel) {
379
+ const serves = serving.filter(a => !model || !Array.isArray(a.models) || !a.models.length || a.models.includes(model)).length;
380
+ const total = Math.max(serves, m.accounts.size);
381
+ const [reason] = m.reasons;
382
+ const why = m.reasons.size === 1 && reason.length <= 40 ? `:${reason}` : '';
383
+ out.push({
384
+ name: model || '所有模型',
385
+ used: Math.round((m.accounts.size / total) * 100),
386
+ resetsAt: new Date(m.until).toISOString(),
387
+ display: `${m.accounts.size}/${total} 个账号冷却中${why}`
388
+ });
389
+ }
390
+ return out.sort((a, b) => b.used - a.used || Date.parse(a.resetsAt) - Date.parse(b.resetsAt));
391
+ }
392
+
393
+ // poolUsage is the usage card of the local service's accounts: they go by their labels (Google
394
+ // emails), with the ones that can't serve counted beside them. The service gives no allowance to
395
+ // show, only the models its accounts cool down on (see cooldownWindows); a pool none of whose
396
+ // accounts can serve until one signs in again is an error, since every request fails.
397
+ export function poolUsage(accounts, cooldowns, now = Date.now()) {
398
+ if (!accounts.length) {
399
+ return { error: `本地 AI Studio 里还没有 Google 账号:在 Magpie 里登录 AI Studio,选「${SETUP_METHOD}」`, windows: [] };
400
+ }
401
+ const expired = accounts.filter(a => a.state === 'auth_required').length;
402
+ const unavailable = accounts.filter(a => a.state === 'unavailable').length;
403
+ const named = accounts.length > 1 ? `${labelOf(accounts[0])} +${accounts.length - 1}` : labelOf(accounts[0]);
404
+ const notes = [];
405
+ if (expired) notes.push(`${expired} 个需要重新登录`);
406
+ if (unavailable) notes.push(`${unavailable} 个暂时不可用`);
407
+ const user = notes.length ? `${named}(${notes.join(',')})` : named;
408
+ if (!accounts.some(a => SERVING_STATES.has(a.state))) {
409
+ const error = expired
410
+ ? `${expired} 个 Google 账号都需要重新登录:在 Magpie 里登录 AI Studio,选「${RELOGIN_METHOD}」`
411
+ : '本地 AI Studio 的 Google 账号暂时都不可用';
412
+ return { user, error, windows: [] };
413
+ }
414
+ return { user, windows: cooldownWindows(cooldowns, accounts, now) };
415
+ }
416
+
417
+ export const AIStudioPlugin = async (_input, options = {}) => {
418
+ const opts = studioSettings(options);
419
+
420
+ if (localStudio(opts)) {
421
+ ensureStudio({ ...opts, proxy: opts.proxy }).catch(err => {
422
+ console.error(`[ai-studio] could not start the local AI Studio service: ${err.message}`);
423
+ });
424
+ }
425
+
426
+ // afterLogin is the sign-in once an account is signed in. Generation may still be starting;
427
+ // the first request waits for it, so a slow start doesn't fail a sign-in that worked (and a
428
+ // retry doesn't open another login window for the same account).
429
+ function afterLogin(local) {
430
+ ensureStudio({ proxy: opts.proxy }).catch(err => {
431
+ console.error(`[ai-studio] generation did not start after sign-in: ${err.message}`);
432
+ });
433
+ return poolSignIn(local);
434
+ }
435
+
436
+ // expiredAccounts is the enabled accounts that need to sign in again, of a setup already here.
437
+ async function expiredAccounts() {
438
+ if (!localStudio(opts)) throw new Error(NO_LOCAL_STUDIO);
439
+ await ensureStudio({ proxy: opts.proxy, managementOnly: true });
440
+ const accounts = (await studioAccounts(opts)).filter(a => a.state === 'auth_required');
441
+ if (!accounts.length) throw new Error('本地 Google AI Studio 里没有需要重新登录的账号');
442
+ return accounts;
443
+ }
444
+
445
+ return {
446
+ config: async cfg => {
447
+ cfg.provider ??= {};
448
+ cfg.provider[STUDIO_ID] ??= {
449
+ name: 'Google AI Studio',
450
+ npm: '@ai-sdk/openai-compatible',
451
+ api: opts.baseURL,
452
+ models: {}
453
+ };
454
+ },
455
+ auth: {
456
+ provider: STUDIO_ID,
457
+ maxConcurrency: 1,
458
+ methods: [
459
+ {
460
+ type: 'oauth',
461
+ label: SETUP_METHOD,
462
+ async authorize() {
463
+ await ensureStudio({ proxy: opts.proxy, managementOnly: true });
464
+ return {
465
+ url: '',
466
+ method: 'auto',
467
+ instructions: LOGIN_STEPS,
468
+ async callback() {
469
+ let local;
470
+ try {
471
+ local = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
472
+ const res = await studioAdmin(local, 'POST', '/api/accounts', {}, { timeoutMs: LOGIN_TIMEOUT_MS });
473
+ const account = res?.account;
474
+ if (!account || typeof account.id !== 'string' || !account.id || account.enabled === false) {
475
+ throw new Error('AIStudio2API did not return an enabled account');
476
+ }
477
+ } catch (err) {
478
+ return { type: 'failed', error: `Google 登录没有完成:${err.message}` };
479
+ }
480
+ return afterLogin(local);
481
+ }
482
+ };
483
+ }
484
+ },
485
+ {
486
+ type: 'oauth',
487
+ label: '使用本机已有的 AI Studio 服务',
488
+ async authorize() {
489
+ // Only a setup that is already here has accounts to use: don't install one.
490
+ if (!localStudio(opts)) throw new Error(NO_LOCAL_STUDIO);
491
+ const local = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
492
+ const accounts = await studioAccounts(opts);
493
+ if (!accounts.length) throw new Error(`本地 Google AI Studio 里没有启用的 Google 账号:请用「${SETUP_METHOD}」添加`);
494
+ return {
495
+ url: '',
496
+ method: 'auto',
497
+ instructions: '',
498
+ callback: async () => poolSignIn(local)
499
+ };
500
+ }
501
+ },
502
+ {
503
+ type: 'oauth',
504
+ label: RELOGIN_METHOD,
505
+ prompts: [{ type: 'select', key: 'account', message: '选择要重新登录的 Google 账号', options: [] }],
506
+ async ask(inputs) {
507
+ if (inputs?.account) return null;
508
+ const accounts = await expiredAccounts();
509
+ return {
510
+ type: 'select',
511
+ key: 'account',
512
+ message: '选择要重新登录的 Google 账号',
513
+ placeholder: '',
514
+ options: accounts.map(a => ({ label: labelOf(a), value: a.id, hint: a.message || '需要重新登录' }))
515
+ };
516
+ },
517
+ async authorize(inputs) {
518
+ const accounts = await expiredAccounts();
519
+ const chosen = accounts.find(a => a.id === inputs?.account);
520
+ if (!chosen) throw new Error(`账号 ${inputs?.account ?? ''} 不需要重新登录`);
521
+ return {
522
+ url: '',
523
+ method: 'auto',
524
+ instructions: RELOGIN_STEPS,
525
+ async callback() {
526
+ let local;
527
+ try {
528
+ local = await ensureStudio({ proxy: opts.proxy, managementOnly: true });
529
+ const res = await studioAdmin(local, 'POST', `/api/accounts/${encodeURIComponent(chosen.id)}/login`, {}, { timeoutMs: LOGIN_TIMEOUT_MS });
530
+ if (res?.account?.state === 'auth_required') throw new Error(res.account.message || '账号仍然需要重新登录');
531
+ } catch (err) {
532
+ return { type: 'failed', error: `${labelOf(chosen)} 没有重新登录:${err.message}` };
533
+ }
534
+ return afterLogin(local);
535
+ }
536
+ };
537
+ }
538
+ },
539
+ {
540
+ type: 'api',
541
+ label: '连接已有 AIStudio2API(地址 + API key)',
542
+ placeholder: 'AIStudio2API PROXY_API_KEY',
543
+ prompts: [{
544
+ type: 'text',
545
+ key: 'baseURL',
546
+ message: 'AIStudio2API base URL (blank: default http://127.0.0.1:2048/v1)',
547
+ placeholder: opts.baseURL,
548
+ validate: value => {
549
+ try {
550
+ normalizeBaseURL(value || opts.baseURL);
551
+ } catch (e) {
552
+ return e.message;
553
+ }
554
+ }
555
+ }],
556
+ authorize: async inputs => ({
557
+ type: 'success',
558
+ metadata: {
559
+ mode: 'studio',
560
+ managed: false,
561
+ baseURL: normalizeBaseURL(inputs?.baseURL || opts.baseURL)
562
+ }
563
+ })
564
+ }
565
+ ],
566
+ async loader(getAuth) {
567
+ const auth = await getAuth();
568
+ if (auth?.type !== 'api') return {};
569
+ const c = credentials(auth, opts);
570
+ if (auth.metadata?.managed === true) {
571
+ // Bring the service up (installing it again if it went missing) before comparing:
572
+ // only a setup that is there can tell that the sign-in's key is no longer its own.
573
+ const local = await ensureStudio({ proxy: opts.proxy });
574
+ if (!sameBackend(c, local)) throw managedMismatch();
575
+ }
576
+ return {
577
+ apiKey: c.key,
578
+ baseURL: c.baseURL,
579
+ fetch: (input, init) => forwardManagedStudio(input, init, getAuth, opts)
580
+ };
581
+ },
582
+ async usage(getAuth) {
583
+ const auth = await getAuth();
584
+ if (auth?.type !== 'api') return { error: 'AI Studio 还没有登录', windows: [], signIn: 'kept' };
585
+ if (!auth.metadata?.managed) {
586
+ return { error: '连接已有 AIStudio2API 时读不到本地账户状态(外部服务不提供管理接口)', windows: [], signIn: 'kept' };
587
+ }
588
+ let c;
589
+ try {
590
+ c = credentials(auth, opts);
591
+ } catch (err) {
592
+ return { error: err.message, windows: [], signIn: 'kept' };
593
+ }
594
+ const local = localStudio(opts);
595
+ if (!local) {
596
+ return { error: '本地 AI Studio 服务还没有装好,下一次请求时会自动安装', windows: [], signIn: 'kept' };
597
+ }
598
+ if (!sameBackend(c, local)) {
599
+ return { error: '本地 AI Studio 配置不匹配,请重新登录', windows: [], signIn: 'expired' };
600
+ }
601
+
602
+ let accounts, cooldowns;
603
+ try {
604
+ [accounts, cooldowns] = await Promise.all([
605
+ studioAdmin(local, 'GET', '/api/accounts', null, { timeoutMs: 3000 }),
606
+ // Without its cooldowns the card still says whose accounts these are.
607
+ studioAdmin(local, 'GET', '/api/cooldowns', null, { timeoutMs: 3000 }).catch(() => null)
608
+ ]);
609
+ } catch (err) {
610
+ if (isConnectRefused(err)) {
611
+ return { error: '本地 AI Studio 服务没有在运行,下一次请求时会自动启动', windows: [], signIn: 'kept' };
612
+ }
613
+ return { error: `无法读取本地 AI Studio 状态: ${err.message}`, windows: [], signIn: 'kept' };
614
+ }
615
+ const enabled = (Array.isArray(accounts?.accounts) ? accounts.accounts : []).filter(a => a && a.enabled !== false);
616
+ const cooling = Array.isArray(cooldowns?.cooldowns) ? cooldowns.cooldowns : [];
617
+ return { ...poolUsage(enabled, cooling), signIn: 'kept' };
618
+ }
619
+ },
620
+ provider: {
621
+ id: STUDIO_ID,
622
+ async models(provider, { auth } = {}) {
623
+ if (auth?.type !== 'api') return provider.models;
624
+ const c = credentials(auth, opts);
625
+ if (auth.metadata?.managed === true) {
626
+ const local = await ensureStudio({ proxy: opts.proxy });
627
+ if (!sameBackend(c, local)) throw managedMismatch();
628
+ }
629
+ try {
630
+ return await listStudioModels(c, opts);
631
+ } catch (err) {
632
+ if (!err.unreachable || !auth.metadata?.managed) throw err;
633
+ await ensureStudio({ ...opts, proxy: opts.proxy });
634
+ return listStudioModels(c, opts);
635
+ }
636
+ }
637
+ }
638
+ };
639
+ };
640
+
641
+ export default AIStudioPlugin;
@@ -29,7 +29,9 @@
29
29
 
30
30
  2026-10-15:已执行通过 41 项 Python 测试和 12 项 Node 测试;真实 Magpie `0.1.1244` 的隔离环境完成了插件安装、本地连接、模型发现及 8 个 HTTP 请求的工具往返测试。该网关测试使用模拟原生上游,包含两次串行工具、最终答案重试、错误会话/调用 ID 拒绝、旧工具阶段拒绝和下一轮用户消息。
31
31
 
32
- 当前实现已使用现有登录完成 OAuth 续期及保存轮次读取,但真实 Task 请求被 Google 返回的 `Remy quota exhausted`(gRPC 8)拒绝,尚未完成本版真实 Argon 工具端到端验收。服务将该错误映射为 `native_quota_exhausted`,不会自动重发 Task。
32
+ 2026-10-15:通过 `magpie-gemini-web` 0.4.0 的 Task 登录方式(直接加载插件,未经 Magpie 网关)完成了一次真实 Argon 工具往返:模型返回 `Read` 工具调用(19.2 秒),回传工具结果后得到正确的最终答案(13.7 秒)。此前真实 Task 请求曾被 Google 以 `Remy quota exhausted`(gRPC 8)拒绝,服务将该错误映射为 `native_quota_exhausted`,不会自动重发 Task。
33
+
34
+ 已知问题:客户端收到工具调用后若不回传结果(例如 agent 中途被打断),该 Task 会一直占用唯一的名额,直到工具等待和任务超时到期(最长约 4 分钟),期间其他请求返回 409 `concurrency_limit`。另外曾出现过一次首个健康检查超时(2 秒),未能复现。
33
35
 
34
36
  ## Magpie 插件安装与登录
35
37