magpie-gemini-web 0.3.2 → 0.4.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 CHANGED
@@ -26,15 +26,15 @@ From then on, Magpie starts the backend automatically every time it launches. To
26
26
 
27
27
  ## Plugin options
28
28
 
29
- Tool calls and image input are off by default. Turn them on if a coding agent needs tools:
29
+ Tool calls are on by default (coding agents send tools with every request); image input is off. To allow images:
30
30
 
31
31
  ```bash
32
- magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
32
+ magpie plugin options magpie-gemini-web '{"allowImages":true}'
33
33
  ```
34
34
 
35
35
  | Option | Default | Description |
36
36
  | :--- | :--- | :--- |
37
- | `allowTools` | `false` | Allows tool calls (experimental). When off, requests with tools get a 400 |
37
+ | `allowTools` | `true` | Allows tool calls (experimental; upstream emulates them with prompts). When `false`, requests with tools get a 400 |
38
38
  | `allowImages` | `false` | Allows image input |
39
39
  | `baseURL` | `http://127.0.0.1:8083/v1` | Default backend URL |
40
40
  | `contextLimit` / `outputLimit` | `1000000` / `8192` | Context and output limits reported to clients |
@@ -43,7 +43,7 @@ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
43
43
 
44
44
  ## Thinking effort
45
45
 
46
- You can switch a model between `low` and `high` effort in Magpie if it has a `-thinking` version. `low` uses the base model and `high` uses the `-thinking` model with extended thinking. After upgrading the plugin, run `magpie provider models gemini-web` to refresh the model list. Argon's medium tier will be added once upstream supports it ([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5)).
46
+ You can switch a model between `low` and `high` effort in Magpie if it has a `-thinking` version. `low` uses the base model and `high` uses the `-thinking` model with extended thinking, so the `-thinking` model isn't listed on its own. After upgrading the plugin, run `magpie provider models gemini-web` to refresh the model list. Argon's medium tier will be added once upstream supports it ([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5)).
47
47
 
48
48
  ## Security and data
49
49
 
@@ -61,14 +61,29 @@ Magpie's usage cards show each Google account's **5-hour** and **weekly** usage
61
61
 
62
62
  Note that the backend picks the Google account for each request from its pool in turn: how Magpie spreads requests over the cards doesn't decide which account serves them. Only the one-click local backend has usage; a backend you connect to yourself keeps its cookies out of the plugin's reach.
63
63
 
64
+ To send Google fewer requests, usage isn't read on every refresh. An account is read again once the backend has used it or one of its windows has reset, and never twice within 10 minutes. An idle account is read at most every 6 hours, which also picks up what you used in the Gemini web app.
65
+
66
+ Usage and emails are read the way the backend reaches Google for that account. When that way isn't certain, nothing is read and the card says why:
67
+
68
+ - the backend has a proxy pool, but the account isn't bound to a proxy yet (the backend binds it on its first request);
69
+ - the account's proxy is disabled, or is a SOCKS proxy.
70
+
71
+ So with a proxy pool, an account just added by one-click setup has no email yet. Send one request with it, sign in to it again with **「添加本地后端里已有的 Google 账号」**, then remove the sign-in without an email.
72
+
64
73
  ## Limitations
65
74
 
66
75
  - Upstream emulates tool calls with prompts, so they aren't always reliable. Flash models often don't call tools.
67
76
  - Only text models whose names start with `gemini-` are exposed. A `cost` of 0 doesn't reflect your actual Google quota.
68
77
 
78
+ ## Native Task on macOS (experimental)
79
+
80
+ On macOS, sign-in offers one more option, "macOS 原生 Task(实验性)": requests skip the Gemini Web backend and go to a local daemon that runs them in Gemini.app's own Task mode, with tool calls on its native Function Calling channel, as the model `gemini-task-argon`. Windows and Linux don't offer it; keep using one-click setup there.
81
+
82
+ This is an alpha: it supports only the verified Gemini.app build, needs a local setup first (Gemini.app signed in, plus session templates), hasn't passed an end-to-end check against real Argon yet, and shows no usage. See [native-task/README.md](native-task/README.md) for setup, session requirements and limitations.
83
+
69
84
  ## Argon patched build (optional)
70
85
 
71
- The official backend already supports `gemini-4-argon`, which needs a paid or Ultra account. This repo's patched build also verifies that responses really come from Argon, so a silent downgrade is caught, and it lowers latency. Building it requires Go 1.26+:
86
+ The official backend already supports `gemini-4-argon`, which needs a paid or Ultra account. This repo's patched build also verifies that responses really come from Argon, so a silent downgrade is caught, and it lowers latency. It also makes the backend's requests look more like a browser's: on a direct connection every request carries the full set of Chrome headers (the official build's direct chat requests say `Go-http-client/2.0`), and refreshing the cookies to keep a session alive no longer fetches the `/app` page right away. Building it requires Go 1.26+:
72
87
 
73
88
  ```bash
74
89
  git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git && cd magpie-gemini-web
@@ -81,6 +96,15 @@ npm run backend:install:argon
81
96
 
82
97
  Restart Magpie when the install finishes, and the plugin starts the Argon build in the background. If an older backend is still running, stop it first with `kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`. Run `npm run backend:install` to switch back to the official build.
83
98
 
99
+ When `patches/argon.patch` changes (after you pull a new version of this repo, say), apply it again to clean sources and rebuild. Once the old backend is stopped, the plugin starts the new one on the next request:
100
+
101
+ ```bash
102
+ git -C backend-source checkout -- . && git -C backend-source clean -fdq
103
+ git -C backend-source apply ../patches/argon.patch
104
+ npm run backend:build:argon && npm run backend:install:argon
105
+ kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)
106
+ ```
107
+
84
108
  ## License
85
109
 
86
110
  MIT. The upstream gemini-web2api-go project is also MIT (see `LICENSE-UPSTREAM`).
package/README.md CHANGED
@@ -26,15 +26,15 @@
26
26
 
27
27
  ## 插件选项
28
28
 
29
- 工具调用和图片输入默认关闭,Coding Agent 需要工具时请开启:
29
+ 工具调用默认开启(Coding Agent 每个请求都带工具),图片输入默认关闭。需要图片输入时:
30
30
 
31
31
  ```bash
32
- magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
32
+ magpie plugin options magpie-gemini-web '{"allowImages":true}'
33
33
  ```
34
34
 
35
35
  | 选项 | 默认值 | 说明 |
36
36
  | :--- | :--- | :--- |
37
- | `allowTools` | `false` | 允许工具调用(实验性)。关闭时带工具的请求会返回 400 |
37
+ | `allowTools` | `true` | 允许工具调用(实验性,由上游用提示词模拟)。设为 `false` 时带工具的请求会返回 400 |
38
38
  | `allowImages` | `false` | 允许图片输入 |
39
39
  | `baseURL` | `http://127.0.0.1:8083/v1` | 默认后端地址 |
40
40
  | `contextLimit` / `outputLimit` | `1000000` / `8192` | 向客户端声明的上下文 / 输出上限 |
@@ -43,7 +43,7 @@ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
43
43
 
44
44
  ## 思考档位
45
45
 
46
- 有 `-thinking` 版本的模型可以在 Magpie 里切换 `low` / `high`:`low` 用普通模型,`high` 用 `-thinking` 模型(扩展思考)。升级插件后执行 `magpie provider models gemini-web` 刷新模型列表。Argon 的「中」档等上游支持([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
46
+ 有 `-thinking` 版本的模型可以在 Magpie 里切换 `low` / `high`:`low` 用普通模型,`high` 用 `-thinking` 模型(扩展思考)。`-thinking` 模型因此不再单独列出。升级插件后执行 `magpie provider models gemini-web` 刷新模型列表。Argon 的「中」档等上游支持([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
47
47
 
48
48
  ## 安全与数据
49
49
 
@@ -61,14 +61,29 @@ Magpie 的额度卡片会显示 Google 账号的 **5 小时** 和 **每周** 用
61
61
 
62
62
  注意:请求最终由后端在账号池里轮流挑选 Google 账号,Magpie 在几张卡片之间怎么分配请求,不决定实际用的是哪个账号。只有一键配置的本地后端能显示用量;「连接已有后端」模式下读不到账号的 Cookie。
63
63
 
64
+ 为了少向 Google 发请求,用量不会每次刷新都去查:账号经后端用过、或者某个窗口到了重置时间,才重新读取,两次至少间隔 10 分钟;账号闲着时最多每 6 小时读一次(顺带反映你在 Gemini 网页里的用量)。
65
+
66
+ 读取用量和邮箱时,走的是后端访问 Google 的同一个出口。出口不确定时不读取,卡片上会写明原因:
67
+
68
+ - 配了代理池,但账号还没绑定代理(后端在它第一次请求时绑定);
69
+ - 账号绑定的代理已停用,或者是 SOCKS 代理。
70
+
71
+ 所以配了代理池时,一键配置刚添加的账号暂时没有邮箱。先用它发一次请求,再用「添加本地后端里已有的 Google 账号」重新登录它,然后删掉没有邮箱的那个登录。
72
+
64
73
  ## 局限
65
74
 
66
75
  - 工具调用由上游用提示词模拟,不一定可靠,Flash 系列经常不调用工具。
67
76
  - 只开放 `gemini-` 开头的文本模型;`cost` 显示为 0 不代表 Google 的实际配额。
68
77
 
78
+ ## macOS 原生 Task(实验性)
79
+
80
+ 在 macOS 上登录时会多出一个「macOS 原生 Task(实验性)」选项:请求不走 Gemini Web 后端,而是交给本机常驻服务,由 Gemini.app 自己的 Task 模式执行,工具调用走原生的 Function Calling 通道,模型为 `gemini-task-argon`。Windows 和 Linux 上不会出现这个选项,请继续使用上面的一键配置。
81
+
82
+ 当前为 Alpha:仅支持已验证的 Gemini.app 版本,选这个选项之前需要先在本机完成 setup(需要 Gemini.app 已登录,以及会话模板),尚未完成真实 Argon 的端到端验收,读不到额度。配置步骤、会话要求和限制见 [native-task/README.md](native-task/README.md)。
83
+
69
84
  ## Argon 增强版(可选)
70
85
 
71
- 官方后端已支持 `gemini-4-argon`(需付费 / Ultra 账号)。本仓库的补丁版额外校验返回的确实是 Argon(防止被静默降级),并降低延迟。需要 Go 1.26+:
86
+ 官方后端已支持 `gemini-4-argon`(需付费 / Ultra 账号)。本仓库的补丁版额外校验返回的确实是 Argon(防止被静默降级),并降低延迟。它也让后端的请求更像浏览器:直连时每个请求都带齐 Chrome 请求头(官方版直连时,对话请求的 UA 是 `Go-http-client/2.0`),保活刷新 Cookie 后也不再立即重新抓取 `/app` 页面。需要 Go 1.26+:
72
87
 
73
88
  ```bash
74
89
  git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git && cd magpie-gemini-web
@@ -81,6 +96,19 @@ npm run backend:install:argon
81
96
 
82
97
  安装后重启 Magpie 即可,插件会在后台启动 Argon 版(如有旧后端在运行,先 `kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`)。运行 `npm run backend:install` 可切回官方版。
83
98
 
99
+ `patches/argon.patch` 更新后(比如拉取了本仓库的新版本),需要在干净的源码上重新应用补丁并重新构建。停掉旧后端后,插件会在下一个请求时启动新版:
100
+
101
+ ```bash
102
+ git -C backend-source checkout -- . && git -C backend-source clean -fdq
103
+ git -C backend-source apply ../patches/argon.patch
104
+ npm run backend:build:argon && npm run backend:install:argon
105
+ kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)
106
+ ```
107
+
108
+ ## 友链
109
+
110
+ [LINUX DO](https://linux.do/) — 真诚、友善、团结、专业的技术社区。
111
+
84
112
  ## 许可证
85
113
 
86
114
  MIT。上游 gemini-web2api-go 同为 MIT(见 `LICENSE-UPSTREAM`)。
package/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { normalizeBaseURL, settings, credentials, listModels, forward, UNREACHABLE_HEADER } from './lib/backend.mjs';
2
2
  import { ensureBackend, localBackend } from './lib/native.mjs';
3
3
  import { parseCookieInput, importCookie } from './lib/setup.mjs';
4
- import { usage, poolChoices, accountEmail } from './lib/usage.mjs';
4
+ import { usage, poolChoices, poolAccounts, accountEmail } from './lib/usage.mjs';
5
+ import { taskSupported, taskMethod, taskLoader, taskModels, isTaskAuth } from './lib/task.mjs';
5
6
 
6
7
  const ID = 'gemini-web';
7
8
  const GEMINI_URL = 'https://gemini.google.com/app';
@@ -22,7 +23,9 @@ const COOKIE_STEPS = [
22
23
  // signInFor is the Magpie sign-in for one account of the local backend's pool: the backend's
23
24
  // key, with the account's id for its usage card and its Google email, which names the card and
24
25
  // keeps each Google account a sign-in of its own (Magpie takes two sign-ins with one email for
25
- // one account, and two with neither for one when their key is the same).
26
+ // one account, and two with neither for one when their key is the same). The account is the
27
+ // pool's own entry (see poolAccounts), which says the way out to read the email by; without
28
+ // one the email isn't read.
26
29
  async function signInFor(local, account) {
27
30
  const email = account.email || await accountEmail(account).catch(() => undefined);
28
31
  return {
@@ -96,7 +99,8 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
96
99
  } catch (err) {
97
100
  return { type: 'failed', error: err.message };
98
101
  }
99
- return signInFor(local, { id: added.id, cookie });
102
+ const account = (await poolAccounts().catch(() => [])).find(a => a.id === added.id);
103
+ return signInFor(local, account || { id: added.id });
100
104
  }
101
105
  };
102
106
  }
@@ -155,9 +159,10 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
155
159
  callback: async () => signInFor(local, chosen)
156
160
  };
157
161
  }
158
- }],
162
+ }, ...(taskSupported() ? [taskMethod] : [])],
159
163
  async loader(getAuth) {
160
164
  const auth = await getAuth();
165
+ if (isTaskAuth(auth)) return taskLoader(auth);
161
166
  if (auth?.type !== 'api') return {};
162
167
  const c = credentials(auth, opts);
163
168
  return {
@@ -173,6 +178,7 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
173
178
  async usage(getAuth) {
174
179
  const auth = await getAuth();
175
180
  if (auth?.type !== 'api') return { error: 'Gemini Web 还没有登录', windows: [], signIn: 'kept' };
181
+ if (isTaskAuth(auth)) return { error: '原生 Task 登录读不到额度', windows: [], signIn: 'kept' };
176
182
  let c;
177
183
  try {
178
184
  c = credentials(auth, opts);
@@ -182,14 +188,15 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
182
188
  if (!managedBackend(c.baseURL)) {
183
189
  return { error: '只能读取插件自己管理的本地后端的额度(连接已有后端时读不到)', windows: [], signIn: 'kept' };
184
190
  }
185
- const account = auth.metadata?.account;
186
- return { ...(await usage(Number.isInteger(account) ? { account } : {})), signIn: 'kept' };
191
+ const { account, email } = auth.metadata || {};
192
+ return { ...(await usage(Number.isInteger(account) ? { account, email } : {})), signIn: 'kept' };
187
193
  }
188
194
  },
189
195
  provider: {
190
196
  id: ID,
191
197
  async models(provider, { auth } = {}) {
192
198
  if (auth?.type !== 'api') return provider.models;
199
+ if (isTaskAuth(auth)) return taskModels(auth, ID);
193
200
  const c = credentials(auth, opts);
194
201
  try {
195
202
  return await listModels(c, opts);
@@ -199,6 +206,16 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
199
206
  return listModels(c, opts);
200
207
  }
201
208
  }
209
+ },
210
+ // The Task bridge keeps a conversation's state by its session, so each request carries the
211
+ // host's session id. The Web backend's forward() passes on only the headers it needs, so
212
+ // this doesn't reach it.
213
+ async 'chat.headers'(input, output) {
214
+ if (input?.model?.providerID !== ID && input?.provider?.info?.id !== ID) return;
215
+ if (typeof input.sessionID === 'string' && input.sessionID) {
216
+ output.headers ??= {};
217
+ output.headers['X-Gemini-Session'] = input.sessionID;
218
+ }
202
219
  }
203
220
  };
204
221
  };
package/lib/backend.mjs CHANGED
@@ -7,8 +7,9 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
7
7
  const NEWLINE_CHARS = /[\r\n]/;
8
8
 
9
9
  // The backend has no effort parameter: extended thinking is a separate "<id>-thinking" model.
10
- // A base model with such a twin offers low (itself) and high (the twin). Magpie maps any other
11
- // level to the nearest declared one before it reaches us; direct callers get the same rule.
10
+ // A base model with such a twin offers low (itself) and high (the twin), and the twin isn't
11
+ // listed on its own; a request that names it still goes through. Magpie maps any other level to
12
+ // the nearest declared one before it reaches us; direct callers get the same rule.
12
13
  const THINKING_SUFFIX = '-thinking';
13
14
  const LOW_EFFORTS = new Set(['none', 'minimal', 'low']);
14
15
  const MODEL_CACHE_MS = 10 * 60 * 1000;
@@ -114,7 +115,9 @@ export function settings(options = {}) {
114
115
  baseURL = normalizeBaseURL(options.baseURL);
115
116
  }
116
117
 
117
- let allowTools = false;
118
+ // On by default: coding agents send tools with every request and don't read toolcall,
119
+ // so turning this off only means every one of their requests gets a 400.
120
+ let allowTools = true;
118
121
  if (options.allowTools !== undefined) {
119
122
  if (typeof options.allowTools !== 'boolean') {
120
123
  throw new Error('Invalid option allowTools: must be a boolean');
@@ -285,6 +288,7 @@ export async function listModels(c, opts) {
285
288
 
286
289
  const result = Object.create(null);
287
290
  for (const id of validIds) {
291
+ if (id.endsWith(THINKING_SUFFIX) && ids.has(id.slice(0, -THINKING_SUFFIX.length))) continue;
288
292
  const hasTwin = !id.endsWith(THINKING_SUFFIX) && ids.has(id + THINKING_SUFFIX);
289
293
  result[id] = {
290
294
  id,
@@ -438,7 +442,7 @@ export async function forward(input, init, getAuth, opts) {
438
442
  const hasTools = (Array.isArray(body.tools) && body.tools.length > 0) ||
439
443
  (Array.isArray(body.functions) && body.functions.length > 0);
440
444
  if (!opts.allowTools && hasTools) {
441
- return makeOpenAIError(400, 'Experimental tools are disabled; set allowTools: true');
445
+ return makeOpenAIError(400, `Tool calls are turned off for magpie-gemini-web. Turn them on with: magpie plugin options magpie-gemini-web '{"allowTools":true}'`);
442
446
  }
443
447
 
444
448
  if (body.messages !== undefined) {
@@ -460,7 +464,7 @@ export async function forward(input, init, getAuth, opts) {
460
464
  }
461
465
  } else if (part.type === 'image_url') {
462
466
  if (!opts.allowImages) {
463
- return makeOpenAIError(400, 'Image input is disabled; set allowImages: true');
467
+ return makeOpenAIError(400, `Image input is turned off for magpie-gemini-web. Turn it on with: magpie plugin options magpie-gemini-web '{"allowImages":true}'`);
464
468
  }
465
469
  if (!part.image_url || typeof part.image_url.url !== 'string' || !part.image_url.url.trim()) {
466
470
  return makeOpenAIError(400, 'Image content must contain an image_url URL');
package/lib/task.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createForwarder, ensureService, getDefaultDataDir } from '../native-task/lib/runtime.mjs';
5
+
6
+ // The experimental Task route: on macOS, a sign-in can go to the native-task bridge (see
7
+ // native-task/), which runs Gemini.app's own Task mode, instead of the Gemini Web backend.
8
+ // The sign-in says which with metadata.mode.
9
+
10
+ export const TASK_MODE = 'task';
11
+ export const TASK_MODEL = 'gemini-task-argon';
12
+
13
+ const BRIDGE_CLI = fileURLToPath(new URL('../native-task/scripts/bridge.mjs', import.meta.url));
14
+
15
+ // taskSupported is whether this computer can run the bridge: it drives Gemini.app for macOS.
16
+ export function taskSupported(platform = process.platform) {
17
+ return platform === 'darwin';
18
+ }
19
+
20
+ export function isTaskAuth(auth) {
21
+ return auth?.type === 'api' && auth.metadata?.mode === TASK_MODE;
22
+ }
23
+
24
+ // startTask starts the bridge set up in dataDir. The bridge's setup needs files only a capture
25
+ // gives, so the plugin can't run it: without one, the error says how.
26
+ async function startTask(dataDir = getDefaultDataDir()) {
27
+ if (!fs.existsSync(path.join(dataDir, 'config.json'))) {
28
+ throw new Error(`还没有配置本机 Task 服务(${dataDir})。先在终端运行 node "${BRIDGE_CLI}" setup,需要的文件见 native-task/README.md`);
29
+ }
30
+ return ensureService({ dataDir });
31
+ }
32
+
33
+ // taskMethod is the Magpie sign-in method for the bridge.
34
+ export const taskMethod = {
35
+ type: 'oauth',
36
+ label: 'macOS 原生 Task(实验性,需先完成本机 setup)',
37
+ async authorize() {
38
+ const service = await startTask();
39
+ return {
40
+ url: '',
41
+ method: 'auto',
42
+ instructions: '',
43
+ callback: async () => ({
44
+ type: 'success',
45
+ key: service.apiKey,
46
+ accountId: `native:${service.instance_id}`,
47
+ metadata: { mode: TASK_MODE, baseURL: `${service.baseURL}/v1`, dataDir: service.dataDir }
48
+ })
49
+ };
50
+ }
51
+ };
52
+
53
+ // taskLoader is the auth loader for a Task sign-in: requests go to the bridge, starting it when
54
+ // it isn't running.
55
+ export async function taskLoader(auth) {
56
+ const service = await startTask(auth.metadata?.dataDir);
57
+ if (auth.key !== service.apiKey) {
58
+ throw Object.assign(new Error('本机 Task 服务的密钥变了,请重新登录'), { signIn: 'expired' });
59
+ }
60
+ return { apiKey: auth.key, baseURL: `${service.baseURL}/v1`, fetch: createForwarder(service) };
61
+ }
62
+
63
+ // taskModels is the one model the bridge serves, in the shape listModels gives.
64
+ export function taskModels(auth, providerID) {
65
+ return {
66
+ [TASK_MODEL]: {
67
+ id: TASK_MODEL,
68
+ providerID,
69
+ name: 'Argon Task',
70
+ api: { id: TASK_MODEL, url: auth.metadata?.baseURL, npm: '@ai-sdk/openai-compatible' },
71
+ limit: { context: 65536, output: 8192 },
72
+ capabilities: {
73
+ temperature: false,
74
+ reasoning: false,
75
+ attachment: false,
76
+ toolcall: true,
77
+ input: { text: true, image: false },
78
+ output: { text: true }
79
+ },
80
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
81
+ options: {},
82
+ headers: {},
83
+ variants: {},
84
+ status: 'active'
85
+ }
86
+ };
87
+ }
package/lib/usage.mjs CHANGED
@@ -23,7 +23,26 @@ const USAGE_VARIABLES = {
23
23
  }]
24
24
  }
25
25
  };
26
- const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
26
+ // The browser the backend says it is when it reaches Google (gemini-web2api-go v4.22.0:
27
+ // ChromeUA and applyChromeHeaders in client.go), so these reads come from the same client as
28
+ // its own requests on the account. The TLS handshake is still Bun's, as the backend's own is
29
+ // Go's when it goes through a proxy.
30
+ const CLIENT_HEADERS = {
31
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
32
+ 'Accept-Language': 'en-US,en;q=0.9',
33
+ 'Sec-CH-UA': '"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"',
34
+ 'Sec-CH-UA-Mobile': '?0',
35
+ 'Sec-CH-UA-Platform': '"Windows"'
36
+ };
37
+
38
+ // When an account's pools are read again. They move only while the account is used, which the
39
+ // backend records (usedAt, see poolAccounts), or when a window starts over; use from the Gemini
40
+ // app itself is caught by USAGE_MAX_AGE_MS. No two reads of an account are closer than
41
+ // USAGE_MIN_GAP_MS: Magpie set to read allowances automatically asks every few minutes, far
42
+ // more often than a person opens the Usage page. A failed read stands that long too, so a
43
+ // cookie Google turned away isn't asked about again on every refresh.
44
+ export const USAGE_MIN_GAP_MS = 10 * 60 * 1000;
45
+ export const USAGE_MAX_AGE_MS = 6 * 3600 * 1000;
27
46
 
28
47
  // The pools the Usage page shows, under the names Magpie gives the same windows elsewhere.
29
48
  const POOLS = [
@@ -45,12 +64,18 @@ async function openReadOnly(file) {
45
64
  }
46
65
 
47
66
  // poolAccounts lists the enabled accounts in the backend's pool, each with the proxy it is
48
- // bound to, if any.
67
+ // bound to, if any, and whether that proxy is enabled (proxyEnabled), whether the pool has
68
+ // proxies at all (pooled: disabled ones count, as they do for the backend), and when the
69
+ // backend last used it (usedAt, Unix seconds): the later of when it last took the account for
70
+ // a request and when it last finished one.
49
71
  export async function poolAccounts(dataDir = getDataDir()) {
50
72
  const db = await openReadOnly(path.join(dataDir, 'gemini.db'));
51
73
  try {
52
- return db.all(`SELECT a.id, a.label, a.cookie, p.url AS proxy
53
- FROM accounts a LEFT JOIN proxies p ON p.id = a.proxy_id AND p.enabled = 1
74
+ return db.all(`SELECT a.id, a.label, a.cookie, p.url AS proxy, p.enabled AS proxyEnabled,
75
+ EXISTS (SELECT 1 FROM proxies) AS pooled,
76
+ MAX(a.last_used_at, COALESCE((SELECT MAX(r.ts) FROM requests r
77
+ WHERE r.ts >= a.last_used_at AND r.account_id = a.id), 0)) AS usedAt
78
+ FROM accounts a LEFT JOIN proxies p ON p.id = a.proxy_id
54
79
  WHERE a.status = 'enabled' ORDER BY a.id`);
55
80
  } finally {
56
81
  db.close();
@@ -71,17 +96,49 @@ export function parseUsage(text) {
71
96
  throw new Error('Gemini 的回复里没有用量数据');
72
97
  }
73
98
 
74
- // geminiRequest is one signed-in request to gemini.google.com as the account, through the
75
- // proxy the backend bound it to. A redirect is Google sending a dead cookie to sign-in.
99
+ // The variables that send Bun's fetch, or Node's with NODE_USE_ENV_PROXY, through a proxy
100
+ // even when the request names none.
101
+ const PROXY_ENV = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
102
+
103
+ // egress is the proxy a read for the account goes through: the way the backend reaches Google
104
+ // for it, since a read from anywhere else would show Google the session at a second address.
105
+ // A read is made only when that way is certain, as poolAccounts tells it:
106
+ // - a pool without proxies (none at all, not just none enabled): the backend goes direct,
107
+ // and so does the read, unless proxy variables would take it elsewhere;
108
+ // - an account bound to an enabled proxy: that proxy, which its requests and keepalive both
109
+ // take. Only Bun's fetch takes a proxy (a fetchFn passed in stands for it), and only an
110
+ // HTTP(S) one;
111
+ // - anything else is unknown: an account not bound yet (the backend binds one on its first
112
+ // request, and keeps it alive direct until then), one bound to a disabled proxy (its
113
+ // requests take another, its keepalive still that one), or one poolAccounts didn't list.
114
+ function egress(account, options) {
115
+ if (account.pooled == null) throw new Error('不知道后端给这个账号走哪个出口,先不读用量');
116
+ if (!account.pooled) {
117
+ const env = options.env ?? process.env;
118
+ const set = PROXY_ENV.find(k => env[k]);
119
+ if (set) throw new Error(`后端直连 Google,但这里设置了 ${set},读取会改走代理,先不读用量`);
120
+ return undefined;
121
+ }
122
+ if (!account.proxy) throw new Error('这个账号还没绑定代理池里的出口(第一次请求后后端会自动绑定),先不读用量,免得从别的 IP 访问 Google');
123
+ if (!account.proxyEnabled) throw new Error('这个账号绑定的代理已停用,后端的请求和保活会走不同出口,先不读用量');
124
+ if (!/^https?:\/\//i.test(account.proxy) || !(options.fetchFn || globalThis.Bun)) {
125
+ throw new Error('这个账号绑定的代理插件用不了(只支持 HTTP / HTTPS 代理),不读用量,免得从别的 IP 访问 Google');
126
+ }
127
+ return account.proxy;
128
+ }
129
+
130
+ // geminiRequest is one signed-in request to gemini.google.com as the account, the way out the
131
+ // backend takes (egress). It always names its proxy, none included: Magpie's host gives a fetch
132
+ // that names none Magpie's own proxy, which the backend doesn't take. A redirect is Google
133
+ // sending a dead cookie to sign-in.
76
134
  async function geminiRequest(url, account, headers, options) {
77
135
  const fetchFn = options.fetchFn || globalThis.fetch;
78
136
  const init = {
79
- headers: { ...headers, Cookie: account.cookie, 'User-Agent': USER_AGENT },
137
+ headers: { ...CLIENT_HEADERS, ...headers, Cookie: account.cookie },
80
138
  redirect: 'manual',
139
+ proxy: egress(account, options),
81
140
  signal: AbortSignal.timeout(options.timeoutMs ?? 15000)
82
141
  };
83
- // Bun's fetch takes an HTTP proxy; an account bound to one reaches Google only through it.
84
- if (/^https?:\/\//i.test(account.proxy || '')) init.proxy = account.proxy;
85
142
  const res = await fetchFn(url, init);
86
143
  if (res.status >= 300 && res.status < 400 || res.status === 401 || res.status === 403) {
87
144
  throw new Error('Cookie 已失效,请重新走一遍一键配置');
@@ -96,21 +153,61 @@ export async function accountUsage(account, options = {}) {
96
153
  return parseUsage(await geminiRequest(url, account, {
97
154
  Accept: 'application/json',
98
155
  Origin: 'https://gemini.google.com',
99
- Referer: 'https://gemini.google.com/usage'
156
+ Referer: 'https://gemini.google.com/usage',
157
+ 'Sec-Fetch-Dest': 'empty',
158
+ 'Sec-Fetch-Mode': 'cors',
159
+ 'Sec-Fetch-Site': 'same-origin'
100
160
  }, options));
101
161
  }
102
162
 
163
+ // The last read of each account: id -> { at, read, pools }, pools once it has succeeded.
164
+ const reads = new Map();
165
+
166
+ // due says whether the account's last read is to be made again (see USAGE_MIN_GAP_MS).
167
+ function due(last, account, options) {
168
+ const now = Date.now();
169
+ const age = now - last.at;
170
+ if (age < (options.minGapMs ?? USAGE_MIN_GAP_MS)) return false;
171
+ if (!last.pools || age >= (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return true;
172
+ if ((account.usedAt ?? 0) >= Math.floor(last.at / 1000)) return true;
173
+ return last.pools.some(p => {
174
+ const at = Date.parse(p?.gxu?.windowRefreshesAt ?? '');
175
+ return at > last.at && at <= now;
176
+ });
177
+ }
178
+
179
+ function readUsage(account, options) {
180
+ const last = reads.get(account.id);
181
+ if (last && !due(last, account, options)) return last.read;
182
+ const entry = { at: Date.now() };
183
+ entry.read = accountUsage(account, options).then(u => ((entry.pools = u.pools), u));
184
+ reads.set(account.id, entry);
185
+ return entry.read;
186
+ }
187
+
103
188
  // The Google account's email, from the signed-in app page's bootstrap data (WIZ_global_data's
104
- // oPEP7c), kept per backend account: the page is large and the email doesn't change.
105
- const emails = new Map();
189
+ // oPEP7c), kept per backend account: the page is large and the email doesn't change. A page
190
+ // that didn't give it is asked for again no sooner than USAGE_MAX_AGE_MS: the email only names
191
+ // a card. An account with no certain way out (egress) isn't asked yet, and that isn't a try.
192
+ const emails = new Map(); // id -> { email }, or { at } since the last try that got none
106
193
  const EMAIL_FIELD = /"oPEP7c":"([^"@\s]+@[^"\s]+)"/;
107
194
 
108
195
  export async function accountEmail(account, options = {}) {
109
196
  const known = emails.get(account.id);
110
- if (known) return known;
111
- const page = await geminiRequest('https://gemini.google.com/app', account, { Accept: 'text/html' }, options);
197
+ if (known?.email) return known.email;
198
+ if (known && Date.now() - known.at < (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return undefined;
199
+ egress(account, options);
200
+ emails.set(account.id, { at: Date.now() });
201
+ const page = await geminiRequest('https://gemini.google.com/app', account, {
202
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
203
+ 'Sec-Fetch-Dest': 'document',
204
+ 'Sec-Fetch-Mode': 'navigate',
205
+ 'Sec-Fetch-Site': 'none',
206
+ 'Sec-Fetch-User': '?1',
207
+ 'Upgrade-Insecure-Requests': '1'
208
+ }, options);
112
209
  const email = EMAIL_FIELD.exec(page)?.[1];
113
- if (email) emails.set(account.id, email);
210
+ if (email) emails.set(account.id, { email });
114
211
  return email;
115
212
  }
116
213
 
@@ -153,6 +250,7 @@ export function poolWindows(read) {
153
250
  // usage is the Magpie usage card: one account's 5-hour and weekly windows, as
154
251
  // gemini.google.com/usage shows them, when the sign-in names its backend account; else the
155
252
  // whole pool's, as one card. Accounts go by their Google emails (#id until that is known).
253
+ // An account is read from Google again only when due (see USAGE_MIN_GAP_MS).
156
254
  // Magpie hides a card's windows behind an error, so a pool account that can't be read only
157
255
  // says so in the tooltip while the others still show.
158
256
  export async function usage(options = {}) {
@@ -165,11 +263,15 @@ export async function usage(options = {}) {
165
263
  if (options.account !== undefined) {
166
264
  accounts = accounts.filter(a => a.id === options.account);
167
265
  if (!accounts.length) return { error: `本地后端里已经没有这个账号(#${options.account}),可能被停用或删除了`, windows: [] };
266
+ // The sign-in already names its account's email: no app page to fetch for it.
267
+ if (typeof options.email === 'string' && options.email && !emails.get(options.account)?.email) {
268
+ emails.set(options.account, { email: options.email });
269
+ }
168
270
  }
169
271
  if (!accounts.length) return { error: '本地后端里没有启用的 Google 账号', windows: [] };
170
272
 
171
273
  const read = await Promise.all(accounts.map(async a => {
172
- const [u, email] = await Promise.allSettled([accountUsage(a, options), accountEmail(a, options)]);
274
+ const [u, email] = await Promise.allSettled([readUsage(a, options), accountEmail(a, options)]);
173
275
  const who = (email.status === 'fulfilled' && email.value) || `#${a.id}`;
174
276
  return u.status === 'fulfilled' ? { who, windows: windowsOf(u.value.pools) } : { who, error: u.reason?.message || String(u.reason) };
175
277
  }));
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 magpie-gemini-web contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.