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 +29 -5
- package/README.md +33 -5
- package/index.mjs +23 -6
- package/lib/backend.mjs +9 -5
- package/lib/task.mjs +87 -0
- package/lib/usage.mjs +118 -16
- package/native-task/LICENSE +21 -0
- package/native-task/README.md +87 -0
- package/native-task/bridge/gemini_native_bridge/__init__.py +3 -0
- package/native-task/bridge/gemini_native_bridge/__main__.py +235 -0
- package/native-task/bridge/gemini_native_bridge/auth.py +143 -0
- package/native-task/bridge/gemini_native_bridge/config.py +222 -0
- package/native-task/bridge/gemini_native_bridge/contract.py +283 -0
- package/native-task/bridge/gemini_native_bridge/native_auth_keychain.swift +111 -0
- package/native-task/bridge/gemini_native_bridge/oauth.py +154 -0
- package/native-task/bridge/gemini_native_bridge/protocol.py +363 -0
- package/native-task/bridge/gemini_native_bridge/server.py +441 -0
- package/native-task/bridge/gemini_native_bridge/state.py +806 -0
- package/native-task/bridge/gemini_native_bridge/transport.py +531 -0
- package/native-task/index.mjs +76 -0
- package/native-task/lib/runtime.mjs +189 -0
- package/native-task/pyproject.toml +18 -0
- package/native-task/requirements.txt +2 -0
- package/native-task/scripts/bridge.mjs +97 -0
- package/package.json +11 -3
- package/patches/argon.patch +231 -51
- package/scripts/backend-argon.mjs +1 -1
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
|
|
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 '{"
|
|
32
|
+
magpie plugin options magpie-gemini-web '{"allowImages":true}'
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
| Option | Default | Description |
|
|
36
36
|
| :--- | :--- | :--- |
|
|
37
|
-
| `allowTools` | `
|
|
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
|
-
|
|
29
|
+
工具调用默认开启(Coding Agent 每个请求都带工具),图片输入默认关闭。需要图片输入时:
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
magpie plugin options magpie-gemini-web '{"
|
|
32
|
+
magpie plugin options magpie-gemini-web '{"allowImages":true}'
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
| 选项 | 默认值 | 说明 |
|
|
36
36
|
| :--- | :--- | :--- |
|
|
37
|
-
| `allowTools` | `
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
11
|
-
//
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
75
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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.
|