magpie-gemini-web 0.1.3 → 0.3.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 ADDED
@@ -0,0 +1,85 @@
1
+ # magpie-gemini-web
2
+
3
+ [中文](README.md) | English
4
+
5
+ Use Gemini on the web through your Google account in [Magpie](https://usemagpie.ai/). The plugin runs the [gemini-web2api-go](https://github.com/zexadev/gemini-web2api-go) backend on your machine. The backend turns the Gemini web app into an OpenAI-compatible API.
6
+
7
+ ## Getting started
8
+
9
+ 1. Install **magpie-gemini-web** from the Magpie plugin market.
10
+ 2. Sign in to Gemini Web and choose **一键配置本地 Gemini Web (one-click local setup, recommended)**. The plugin downloads the backend, verifies it and starts it in the background.
11
+ 3. Copy your cookie when prompted. **Use a private / incognito window** (Firefox recommended):
12
+ 1. In the private window, open <https://gemini.google.com/app> and sign in to the Google account you want to use.
13
+ 2. Press F12 (⌥⌘I on macOS), switch to **Network** and reload the page.
14
+ 3. Click any `gemini.google.com` request and go to **Headers** → **Request Headers**.
15
+ 4. Copy the value of the `cookie` line and paste it into Magpie.
16
+ 5. Close the private window. **Don't sign out**, because signing out invalidates the cookie.
17
+ 4. The plugin checks that the account works, then finishes signing in.
18
+
19
+ > **Why a private window?** The backend refreshes this sign-in periodically to keep it alive. If it shares a session with the browser you normally use, Google revokes the session and your browser gets signed out of every Google service. A private window gives the backend a session of its own. Using a separate Google account for the backend is safer still.
20
+
21
+ From then on, Magpie starts the backend automatically every time it launches. To add another account, run the one-click setup again.
22
+
23
+ > Need a proxy to reach Google? The backend ignores `HTTP_PROXY`. Add the proxy in the proxy pool of the admin panel at `http://127.0.0.1:8083/admin`. The admin password is `adminToken` in `secrets.json` in the data directory.
24
+
25
+ **Connecting an existing backend:** if you already run gemini-web2api-go yourself, choose 连接已有后端 (connect an existing backend with a custom URL and API key). Enter its URL (ending in `/v1`) and its `API_KEY`.
26
+
27
+ ## Plugin options
28
+
29
+ Tool calls and image input are off by default. Turn them on if a coding agent needs tools:
30
+
31
+ ```bash
32
+ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
33
+ ```
34
+
35
+ | Option | Default | Description |
36
+ | :--- | :--- | :--- |
37
+ | `allowTools` | `false` | Allows tool calls (experimental). When off, requests with tools get a 400 |
38
+ | `allowImages` | `false` | Allows image input |
39
+ | `baseURL` | `http://127.0.0.1:8083/v1` | Default backend URL |
40
+ | `contextLimit` / `outputLimit` | `32768` / `8192` | Context and output limits reported to clients |
41
+ | `requestTimeoutMs` / `modelTimeoutMs` | `240000` / `10000` | Timeouts in ms for requests and for fetching the model list |
42
+
43
+ ## Thinking effort
44
+
45
+ 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
+
47
+ ## Security and data
48
+
49
+ - The backend listens on `127.0.0.1` only. Your cookie goes only to that local backend, and Magpie stores a locally generated API key instead.
50
+ - The plugin pins the official v4.22.0 backend binary and checks its SHA-256 on download and on every start. Builds are available for macOS, Linux and Windows on x64 and arm64.
51
+ - The data directory is `~/.local/share/magpie-gemini-web` (set `GEMINI_WEB_DATA_DIR` to change it). The backend writes its log to `backend.log` there.
52
+ - The plugin sends one request at a time, but this can't guarantee that Google won't flag your account. Use it at your own risk.
53
+
54
+ ## Usage
55
+
56
+ Magpie's usage cards show each Google account's **5-hour** and **weekly** usage and reset times, as <https://gemini.google.com/usage> does. Each Google account gets a card of its own, named by its email:
57
+
58
+ - An account added by one-click setup becomes its own card.
59
+ - For an account already in the backend (one added by an older version, say), add another Gemini Web account in Magpie and pick **「添加本地后端里已有的 Google 账号」** (add an account already in the local backend), then choose it from the list. The old `API key …` sign-in can then go: it shows the whole pool as one card (each window at the account that has used the most).
60
+
61
+ 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.
62
+
63
+ ## Limitations
64
+
65
+ - Upstream emulates tool calls with prompts, so they aren't always reliable. Flash models often don't call tools.
66
+ - Only text models whose names start with `gemini-` are exposed. A `cost` of 0 doesn't reflect your actual Google quota.
67
+
68
+ ## Argon patched build (optional)
69
+
70
+ 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+:
71
+
72
+ ```bash
73
+ git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git && cd magpie-gemini-web
74
+ git clone https://github.com/zexadev/gemini-web2api-go.git backend-source
75
+ git -C backend-source checkout a65b1d1a5ac614c1b455857334b4f1c73ab8fcfb
76
+ git -C backend-source apply ../patches/argon.patch
77
+ npm run backend:build:argon
78
+ npm run backend:install:argon
79
+ ```
80
+
81
+ 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.
82
+
83
+ ## License
84
+
85
+ MIT. The upstream gemini-web2api-go project is also MIT (see `LICENSE-UPSTREAM`).
package/README.md CHANGED
@@ -1,227 +1,85 @@
1
1
  # magpie-gemini-web
2
2
 
3
- Gemini Web provider plugin for [Magpie](https://usemagpie.ai/)(遵循 OpenCode v1 插件接口规范),基于 [zexadev/gemini-web2api-go](https://github.com/zexadev/gemini-web2api-go) (v4.22.0) 独立原生后端构建。
3
+ 中文 | [English](README.en.md)
4
4
 
5
- ---
5
+ 让 [Magpie](https://usemagpie.ai/) 通过你的 Google 账号使用 Gemini 网页版。插件在本机运行 [gemini-web2api-go](https://github.com/zexadev/gemini-web2api-go) 后端,把 Gemini 网页转成 OpenAI 兼容接口。
6
6
 
7
- ## 1. 为什么选择此方案(选型理由与架构说明)
7
+ ## 开始使用
8
8
 
9
- 将 Gemini 网页版接入 AI Agent 工具链通常面临复杂的网页抓包协议、Cookie 会话维持及接口兼容问题。本方案采用独立原生后端 + Magpie 接入插件的解耦架构:
9
+ 1. 在 Magpie 插件市场安装 **magpie-gemini-web**。
10
+ 2. 登录 Gemini Web,选 **「一键配置本地 Gemini Web(推荐)」**。插件会自动下载并校验后端,在后台启动它。
11
+ 3. 按提示复制 Cookie,**一定要用无痕 / 隐私窗口**(推荐 Firefox):
12
+ 1. 在无痕窗口打开 <https://gemini.google.com/app>,登录要使用的 Google 账号;
13
+ 2. 按 F12(macOS 按 ⌥⌘I),切到 **Network(网络)**,刷新页面;
14
+ 3. 点任意一个 `gemini.google.com` 请求 → **Headers(标头)** → **Request Headers(请求标头)**;
15
+ 4. 复制 `cookie` 那一行的值,粘贴回 Magpie;
16
+ 5. 直接关掉无痕窗口,**不要点退出登录**(退出会让这份 Cookie 失效)。
17
+ 4. 插件检测账号可用后自动完成登录。
10
18
 
11
- 1. **架构解耦与职责划分**:
12
- - **Magpie 插件层**(本目录):纯 JavaScript (ESM),无第三方依赖。负责实现 Magpie / OpenCode v1 规范的认证生命周期 (`auth`)、动态模型目录发现 (`models`)、请求流式转发与输入过滤 (`forward`)。
13
- - **原生后端层**(`gemini-web2api-go`):由上游独立 Go 进程运行,负责维护 Google 账户 Cookie 会话池与 OpenAI 兼容格式转换。
14
- - **凭证安全隔离**:插件与 Magpie **绝不直接接触 Google 账号 Cookie 或用户浏览器会话**,仅通过本地生成的随机 `apiKey` 与本地后端通信。
15
- 2. **本地回环安全保护**:
16
- - 上游原生后端默认配置监听 `0.0.0.0`。本插件安装助手生成的默认配置将监听地址**严格锁定为本地回环 `127.0.0.1:8083`**,避免服务与管理端端口暴露至局域网或公网。
17
- - 转发层在请求到达后端前剥离调用方所有的 `Cookie`、`Authorization`、`x-api-key`、`anthropic-*` 等敏感头部。
18
- 3. **并发抑制**:
19
- - 插件声明了 `maxConcurrency: 1`。该配置旨在降低短时间内并发请求频率,但**不能保证账号绝不被 Google 风控或封禁**;用户应知悉使用网页逆向服务的潜在风险。
20
- 4. **开源协议**:
21
- - 上游项目与本插件均基于宽松的 MIT 许可证。
19
+ > **为什么要用无痕窗口?** 后端会定时刷新这份登录来保活。如果它和你平时用的浏览器是同一个登录会话,Google 会把会话作废,你的浏览器就会退出所有 Google 服务。无痕窗口能给后端一份独立的会话;更稳妥的做法是给后端单独用一个 Google 小号。
22
20
 
23
- ---
21
+ 之后 Magpie 每次启动都会自动拉起后端,不用再管。想加更多账号,再走一遍一键配置即可。
24
22
 
25
- ## 2. 运行环境要求
23
+ > 需要代理才能访问 Google?后端不读 `HTTP_PROXY`,请在管理面板 `http://127.0.0.1:8083/admin` 的代理池里添加(登录口令是数据目录 `secrets.json` 里的 `adminToken`)。
26
24
 
27
- - **Node.js**:版本 `>= 22`(用于执行安装与管理脚本 `npm run backend:*` 及单元测试)。
28
- - **Magpie / Bun**:Magpie 客户端内置管理其插件运行环境(基于 Bun 执行插件),无需全局安装 Bun。
29
- - **操作系统支持**:
30
- - 本项目的自动安装与启动助手(`npm run backend:install` / `backend:start`)**仅针对 macOS (arm64 Apple Silicon)** 进行了二进制锁定与 SHA-256 完整性强校验。
31
- - **其他操作系统(Linux / Windows / Intel Mac)**:请**不要**将其他平台二进制放入助手目录。请直接参考上游 [gemini-web2api-go 文档](https://github.com/zexadev/gemini-web2api-go) 手动运行上游服务,并在 Magpie 登录插件时将 `baseURL` 指向上游监听地址即可。
25
+ **连接已有后端**:如果你自己运行了 gemini-web2api-go,登录时选「连接已有后端(自定义地址 + API key)」,填后端地址(以 `/v1` 结尾)和它的 `API_KEY`。
32
26
 
33
- ---
27
+ ## 插件选项
34
28
 
35
- ## 3. 快速上手
29
+ 工具调用和图片输入默认关闭,Coding Agent 需要工具时请开启:
36
30
 
37
- 插件与后端需要分别安装。Magpie 安装插件时不会执行安装脚本,也不会自动启动 Go 后端。先获取管理脚本:
38
31
  ```bash
39
- git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git
40
- cd magpie-gemini-web
32
+ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
41
33
  ```
42
- 官方后端自 v4.22.0 起已包含 `gemini-4-argon` / `gemini-4-argon-thinking`,但不核对上游实际返回的模型:无权限、Cookie 失效或配额耗尽时会被静默降级成 3.5 Flash-Lite。需要身份校验与延迟优化时,请直接按第 5 节构建补丁版后端,再完成 Cookie 配置和插件登录;不要先启动官方后端占用同一端口。
43
34
 
44
- ### 步骤 1:安装后端二进制(macOS arm64)
45
-
46
- 运行内置下载安装脚本(锁定版本 v4.22.0,SHA-256 为 `c69ca75cf43017cf2a62dbf5c12b07f6bde66f403f714c32209b1ad350ed24c9`):
47
-
48
- ```bash
49
- npm run backend:install
50
- ```
51
-
52
- 该命令将在本地数据目录(默认 `~/.local/share/magpie-gemini-web`,或通过环境变量 `GEMINI_WEB_DATA_DIR` 自定义)完成:
53
- 1. 校验哈希并原子化安装二进制至 `bin/gemini-web2api-go`(权限 `0700`)。
54
- 2. 初始化 `config.json`(将 host 显式绑定为 `127.0.0.1`,权限 `0600`)。
55
- 3. 生成随机的 `apiKey` 和 `adminToken` 并写入 `secrets.json`(权限 `0600`)。
56
-
57
- `secrets.json` 位于上述数据目录下(默认 `~/.local/share/magpie-gemini-web/secrets.json`),包含两个本地随机凭证,均与 Google 账号无关:
58
-
59
- | 字段 | 用途 |
60
- | :--- | :--- |
61
- | `apiKey` | 插件访问后端的密钥(`gw_` 开头),在 Magpie 登录插件时填写 |
62
- | `adminToken` | 后端管理面板 `http://127.0.0.1:8083/admin` 的登录口令 |
63
-
64
- 只要不删除 `secrets.json`,重复执行 `backend:install` 不会重新生成这两个值;若重新生成了 `apiKey`,需要在 Magpie 中重新登录插件。
65
-
66
- ### 步骤 2:启动后端服务
67
-
68
- ```bash
69
- npm run backend:start
70
- ```
35
+ | 选项 | 默认值 | 说明 |
36
+ | :--- | :--- | :--- |
37
+ | `allowTools` | `false` | 允许工具调用(实验性)。关闭时带工具的请求会返回 400 |
38
+ | `allowImages` | `false` | 允许图片输入 |
39
+ | `baseURL` | `http://127.0.0.1:8083/v1` | 默认后端地址 |
40
+ | `contextLimit` / `outputLimit` | `32768` / `8192` | 向客户端声明的上下文 / 输出上限 |
41
+ | `requestTimeoutMs` / `modelTimeoutMs` | `240000` / `10000` | 请求 / 拉取模型列表的超时(毫秒) |
71
42
 
72
- - 该服务在前台运行,会将日志输出至终端。
73
- - 可在另一终端执行以下命令确认后端已监听并响应:
74
- ```bash
75
- npm run backend:status
76
- ```
43
+ ## 思考档位
77
44
 
78
- > 后端成功启动并列出基础模型目录,只代表本地服务通路正常,不代表全部能力可用。2026-10-14 已通过真实 Magpie 网关验证匿名 Flash Lite 普通对话;工具调用在非流式、流式和直连后端对照中均未成功,`tool_choice` 未得到满足。上游未返回缓存 token 统计,Magpie 显示的 0 不代表 Google 的实际缓存命中情况。登录态模型需要有效 Cookie。后续 Argon 补丁已验证真实 Magpie 普通对话、SSE 和首轮工具调用;工具结果回传含正确值但未严格遵循仅回显要求,图片和 Argon 缓存未验证。本地验收记录不随公开仓库或 npm 包分发。
45
+ 有 `-thinking` 版本的模型可以在 Magpie 里切换 `low` / `high`:`low` 用普通模型,`high` 用 `-thinking` 模型(扩展思考)。升级插件后执行 `magpie provider models gemini-web` 刷新模型列表。Argon 的「中」档等上游支持([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
79
46
 
80
- ### 步骤 3:在后端管理后台配置 Google Cookie
47
+ ## 安全与数据
81
48
 
82
- 在本地浏览器中打开:
83
- ```
84
- http://127.0.0.1:8083/admin
85
- ```
86
- - 输入 `secrets.json` 中的 `adminToken` 登录管理面板。可用以下命令把它复制到剪贴板(macOS,不在终端显示):
87
- ```bash
88
- node -p "require(require('path').join(process.env.GEMINI_WEB_DATA_DIR || require('os').homedir() + '/.local/share/magpie-gemini-web', 'secrets.json')).adminToken" | tr -d '\n' | pbcopy
89
- ```
90
- - 在 Cookie 管理页面导入 Gemini 网页请求的完整 Cookie header;上游文档列出的关键项包括 `SID`、`HSID`、`SSID`、`APISID`、`SAPISID`、`__Secure-1PSID`、`__Secure-1PSIDTS`。仅有两个 `__Secure-*` 值未必足够。
91
- - **注意**:Cookie 仅保存在后端的本地 SQLite 数据库中,插件绝不存储或转发 Cookie。
92
-
93
- ### 步骤 4:在 Magpie 中加载插件与登录
94
-
95
- 1. **添加插件**(npm 安装):
96
- ```bash
97
- magpie plugin add magpie-gemini-web
98
- ```
99
- > 尚未发布到 npm 或从源码调试时可用 `magpie plugin add github:BUKOWSKIREAL/magpie-gemini-web` 或 `magpie plugin add /absolute/path/to/magpie-gemini-web`。
100
-
101
- 2. **登录提供商**:
102
- ```bash
103
- magpie plugin login gemini-web
104
- ```
105
- - **Backend OpenAI base URL**:留空使用默认的 `http://127.0.0.1:8083/v1`,或填入其他自定义本地/代理地址。
106
- - **Gemini Web backend API key**:填入 `secrets.json` 中的 `apiKey`(不是 Google Cookie)。可先用以下命令把它复制到剪贴板(macOS,不在终端显示),登录时直接粘贴:
107
- ```bash
108
- node -p "require(require('path').join(process.env.GEMINI_WEB_DATA_DIR || require('os').homedir() + '/.local/share/magpie-gemini-web', 'secrets.json')).apiKey" | tr -d '\n' | pbcopy
109
- ```
110
- 其他系统去掉末尾的 `| tr -d '\n' | pbcopy` 即可直接输出。
111
-
112
- > 重新登录(例如更换 API key 或 base URL)后,正在运行的 Magpie 不会重新读取 provider 地址,需要执行 `magpie plugin off magpie-gemini-web && magpie plugin on magpie-gemini-web` 或重启 Magpie App 才会生效。
113
- >
114
- > 0.1.0 登录时若留空 base URL,会把空字符串存进登录信息;0.1.1 起改为存入补全后的默认地址。若遇到网关返回 502「Gemini Web has no endpoint configured」([#3](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/3)),升级后重新登录一次,或登录时显式填写 `http://127.0.0.1:8083/v1`。
115
-
116
- 3. **配置插件选项(开启工具或图片支持)**:
117
- 插件默认关闭工具调用与图片输入以确保安全和兼容性。若 Coding Agent(如 Claude Code / OpenCode / Cursor)需要使用工具调用,**必须显式开启 `allowTools`**,否则发送工具请求时将被插件直接返回 400 拦截:
118
-
119
- ```bash
120
- # 显式开启工具调用与图片输入
121
- magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
122
-
123
- # 仅保持默认保守配置(关闭工具与图片)
124
- magpie plugin options magpie-gemini-web '{"allowTools":false,"allowImages":false}'
125
- ```
126
-
127
- ---
128
-
129
- ## 4. 插件选项说明 (Plugin Options)
130
-
131
- 所有数值均为客户端本地保护预算,非上游实测上限:
132
-
133
- | 选项名 | 类型 | 默认值 | 说明 |
134
- | :--- | :--- | :--- | :--- |
135
- | `baseURL` | string | `http://127.0.0.1:8083/v1` | 后端 OpenAI 兼容 Base URL(必须以 `/v1` 结尾,仅允许本地 loopback HTTP 或远程 HTTPS) |
136
- | `allowTools` | boolean | `false` | 是否开启实验性工具调用。**默认为 false**,Coding Agent 若使用工具需设为 true |
137
- | `allowImages` | boolean | `false` | 是否允许图片输入。**默认为 false** |
138
- | `contextLimit` | integer | `32768` | 客户端声明的上下文上限预算 |
139
- | `outputLimit` | integer | `8192` | 客户端声明的最大输出 Token 预算(需 $\le$ `contextLimit`) |
140
- | `requestTimeoutMs`| integer | `240000` | 转发单次请求的超时时间(毫秒,默认 4 分钟) |
141
- | `modelTimeoutMs` | integer | `10000` | 获取模型列表的超时时间(毫秒,默认 10 秒) |
142
-
143
- ### 思考档位(effort)
144
-
145
- 后端没有 effort 参数,扩展思考是单独的 `-thinking` 模型。插件把有 `-thinking` 版的模型声明为 `low` / `high` 两档,可以在 Magpie 里直接切换:
146
-
147
- | 档位 | 实际调用 | 说明 |
148
- | :--- | :--- | :--- |
149
- | `low`(或不指定) | `gemini-x` | 网页端默认档:Argon 为「低」档,Flash 不输出思考链,3.1 Pro 仍会少量思考 |
150
- | `high` | `gemini-x-thinking` | 网页端扩展思考(上游显示为 High),带 `reasoning_content` |
49
+ - 后端只监听 `127.0.0.1`;Cookie 只交给本机后端,Magpie 保存的是本地生成的 API key。
50
+ - 后端二进制锁定官方 v4.22.0,下载和每次启动都校验 SHA-256。支持 macOS、Linux、Windows(x64 / arm64)。
51
+ - 数据目录:`~/.local/share/magpie-gemini-web`(可用 `GEMINI_WEB_DATA_DIR` 修改),日志在 `backend.log`。
52
+ - 插件限制并发为 1,但无法保证账号不被 Google 风控,请自行承担风险。
151
53
 
152
- - 插件读取请求里的 `reasoning_effort`:`none` / `minimal` / `low` 走普通版,其他值(如 `medium`、`xhigh`)走 `-thinking` 版。经 Magpie 调用时,Magpie 会先把 Responses API 的 `reasoning.effort`、Anthropic Messages 的 `thinking.budget_tokens` 换算成最接近的已声明档位;Anthropic 的 `output_config.effort` 不会被转发。
153
- - 直接选 `-thinking` 模型照常可用,档位设置对它不生效。
154
- - 网页端 Argon 的「中」档上游尚未支持(见 [#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
155
- - 升级插件后,执行 `magpie provider models gemini-web` 重新拉取模型列表,档位才会出现。
54
+ ## 用量
156
55
 
157
- ---
56
+ Magpie 的额度卡片会显示 Google 账号的 **5 小时** 和 **每周** 用量与重置时间,和 <https://gemini.google.com/usage> 页面一致。每个 Google 账号单独一张卡片,标题是它的邮箱:
158
57
 
159
- ## 5. 本地开发与实验性 Argon 支持 (Local Development & Argon Support)
58
+ - 一键配置添加的账号会自动成为单独的一张卡片;
59
+ - 后端里已有的账号(比如旧版本添加的),在 Magpie 里给 Gemini Web 再添加一个账号,选 **「添加本地后端里已有的 Google 账号」**,从列表里选择即可。之后可以删掉旧的 `API key …` 登录,它显示的是整个账号池合在一起的用量(每个窗口取用得最多的那个账号)。
160
60
 
161
- 官方后端自 v4.22.0 起已支持 Gemini 4 Argon。本项目的补丁版在其基础上增加 Argon 身份校验与延迟优化,适合拥有 Argon 访问权限(付费 / Ultra)的账号:
61
+ 注意:请求最终由后端在账号池里轮流挑选 Google 账号,Magpie 在几张卡片之间怎么分配请求,不决定实际用的是哪个账号。只有一键配置的本地后端能显示用量;「连接已有后端」模式下读不到账号的 Cookie。
162
62
 
163
- ### 5.1 架构与协议保护机制
164
- - **模型标识**(模型定义来自上游 v4.22.0):均为 HexID `95a4c0abcba24364`、Mode 8。
63
+ ## 局限
165
64
 
166
- | 模型 | 网页思考档位 | 上游自报模型名 |
167
- | :--- | :--- | :--- |
168
- | `gemini-4-argon` | 低 | `4 Argon` |
169
- | `gemini-4-argon-thinking` | 高(带 `reasoning_content`) | `4 Argon High` |
65
+ - 工具调用由上游用提示词模拟,不一定可靠,Flash 系列经常不调用工具。
66
+ - 只开放 `gemini-` 开头的文本模型;`cost` 显示为 0 不代表 Google 的实际配额。
170
67
 
171
- 网页端的「中」档上游尚未支持(见 [#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
172
- - **账号权限要求**:必须在后端管理面板配置拥有 Argon 访问权限的 Google 登录 Cookie;无 Cookie 时后端自动隐藏这两个模型。
173
- - **结构化元数据校验**:由于 Google Web 对无权限、Cookie 失效或配额耗尽的请求会静默降级,后端在底层响应解析层(`argon.go`)对 Argon 请求实行严格的结构化元数据身份校验(解析 `wrb.fr` payload 中的模型 hex 与显示名,而非依赖模型自述文本),并按请求的思考档位只接受对应的显示名(普通档 `4 Argon`、扩展思考 `4 Argon High`),档位被静默替换同样视为不符。流式请求逐帧校验:确认 Argon 身份之前的帧先暂存、不发给客户端,确认后放行并继续增量转发(实测身份与首段正文同帧到达)。身份缺失或首次出现即不符时请求报错,不发送任何未验证内容;若放行之后上游又报告了其他模型,后端立即停止读取并发送流错误,但已发出的内容无法撤回。非流式请求仍完整读取后再校验。
174
- - **协议验证状态**:`4.22.0-argon.1` 已通过真实 Magpie 网关验证两档的普通对话和 SSE:`gemini-4-argon` 后端记录实际模型 `4 Argon`,`gemini-4-argon-thinking` 记录 `4 Argon High` 并返回 `reasoning_content`。工具调用沿用 `4.21.0-argon.2` 的结论:首轮工具调用名称与参数正确,第二轮含正确测试值但附带额外说明,不代表工具兼容性已完全解决。本地验收记录不随公开仓库或 npm 包分发。
175
- - **延迟优化**(自 `4.21.0-argon.2` 起;2026-10-14 直连后端实测,单次样本、上游波动大,仅供参考):
176
- - XSRF token 按账号身份缓存,不再因每次响应刷新 SIDCC 而失效,登录态请求的前置开销由约 0.9–1.3 秒降至约 10 毫秒;Cookie 轮转后在后台重取 token。
177
- - Argon 流式首字约 5.4 秒(同次总耗时 9.8 秒),不再等待整段生成结束。
178
- - 工具调用块闭合且函数名有效时即结束读取上游并返回 `tool_calls`,Argon 首轮工具调用约 6.1 秒。
179
- - 超过 `max_prompt_bytes` 的请求转附件时,工具定义(紧凑 JSON)与末尾格式提醒保留在正文,附件只含对话历史;158KB 带工具请求实测可正确发起工具调用并回传结果。
180
- - 客户端断开或超时后,后端中止上游读取、上传与重试等待,并在请求记录中记为 499。
68
+ ## Argon 增强版(可选)
181
69
 
182
- ### 5.2 本地构建与安装步骤 (macOS arm64)
70
+ 官方后端已支持 `gemini-4-argon`(需付费 / Ultra 账号)。本仓库的补丁版额外校验返回的确实是 Argon(防止被静默降级),并降低延迟。需要 Go 1.26+:
183
71
 
184
- 需要 upstream `go.mod` 声明的 Go 版本(Go 1.26+)与 Git。从插件根目录执行:
185
72
  ```bash
73
+ git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git && cd magpie-gemini-web
186
74
  git clone https://github.com/zexadev/gemini-web2api-go.git backend-source
187
75
  git -C backend-source checkout a65b1d1a5ac614c1b455857334b4f1c73ab8fcfb
188
76
  git -C backend-source apply ../patches/argon.patch
77
+ npm run backend:build:argon
78
+ npm run backend:install:argon
189
79
  ```
190
80
 
191
- 源码基于 upstream tag `v4.22.0`(锁定 commit `a65b1d1a5ac614c1b455857334b4f1c73ab8fcfb`)。
192
-
193
- > **从 `4.21.0-argon.*` 升级**:旧补丁新增的文件会与新补丁冲突,请删除 `backend-source/` 后按上面三行重新获取源码,再从下方第 1 步开始。
194
-
195
- 1. **构建 Argon 增强版后端**:
196
- ```bash
197
- npm run backend:build:argon
198
- ```
199
- 该命令将在 `backend-source/` 目录执行 Go 编译,并在 `backend-build/` 生成校验清单 `manifest.json` 与二进制(版本 `4.22.0-argon.1`)。
200
-
201
- 2. **安装到本地数据目录**:
202
- ```bash
203
- npm run backend:install:argon
204
- ```
205
- 安全地将构建好的 Argon 二进制安装到 `~/.local/share/magpie-gemini-web/bin/`,并同步更新编译清单,保留已有配置与密钥。
206
-
207
- 3. **启动 Argon 后端**:
208
- ```bash
209
- npm run backend:start:argon
210
- ```
211
-
212
- > **版本切换提示**:如果重新执行官方标准的 `npm run backend:install`,将自动下载官方原版二进制并覆盖当前版本:Argon 模型仍可用,但不再有身份校验与延迟优化;重新执行 `npm run backend:install:argon` 即可切回 Argon 版本。
213
-
214
- ---
81
+ 安装后重启 Magpie 即可,插件会在后台启动 Argon 版(如有旧后端在运行,先 `kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`)。运行 `npm run backend:install` 可切回官方版。
215
82
 
216
- ## 6. 局限性与风险提示
83
+ ## 许可证
217
84
 
218
- 1. **实验性工具调用(Prompt Emulation)**:
219
- - Gemini 网页版无原生 OpenAI 格式 Function Calling 协议,上游通过系统提示词模拟实现。
220
- - 工具调用块需要完整解析后才以 `tool_calls` 返回(块外正文仍实时流式输出),准确度受模型指令遵循能力影响;实测 Flash 系列经常拒绝调用模拟工具。
221
- - 默认配置下(`allowTools: false`),若请求包含 `tools` 或 `functions`,插件会直接拒绝并返回 400 提示,绝不静默剥离工具参数。
222
- 2. **媒体输入类型限制**:
223
- - 即使开启 `allowImages: true`,插件也仅允许 `text` 与包含有效 URL 的 `image_url`,其他媒体类型(如 `input_audio` 等)将被严格拒绝。
224
- 3. **计费与配额说明**:
225
- - 动态获取的模型目录中 `cost` 统一声明为 `0`,表示本插件不向客户端计收商业 API 费用,**不代表 Google 网页端的实际使用限额或剩余配额**。Token 计数由后端估算。
226
- 4. **模型范围过滤**:
227
- - 插件仅暴露以 `gemini-` 开头且非多媒体专用模型(自动过滤 `image`、`video`、`music`、`canvas`、`research` 等变体)。
85
+ MIT。上游 gemini-web2api-go 同为 MIT(见 `LICENSE-UPSTREAM`)。
package/index.mjs CHANGED
@@ -1,9 +1,73 @@
1
- import { normalizeBaseURL, settings, credentials, listModels, forward } from './lib/backend.mjs';
1
+ import { normalizeBaseURL, settings, credentials, listModels, forward, UNREACHABLE_HEADER } from './lib/backend.mjs';
2
+ import { ensureBackend, localBackend } from './lib/native.mjs';
3
+ import { parseCookieInput, importCookie } from './lib/setup.mjs';
4
+ import { usage, poolChoices, accountEmail } from './lib/usage.mjs';
2
5
 
3
6
  const ID = 'gemini-web';
7
+ const GEMINI_URL = 'https://gemini.google.com/app';
8
+
9
+ // The backend keeps the session alive by rotating its cookies, which signs the browser it came
10
+ // from out of Google. A private window that is closed afterwards gives the backend a session of
11
+ // its own.
12
+ const COOKIE_STEPS = [
13
+ '请用浏览器的无痕 / 隐私窗口(推荐 Firefox),不要用你平时登录着 Google 的窗口:后端会定时刷新这份登录,和浏览器共用会被 Google 踢出登录。',
14
+ `1. 在无痕窗口打开 ${GEMINI_URL} 并登录要使用的 Google 账号`,
15
+ '2. 按 F12(macOS 按 ⌥⌘I)打开开发者工具,切到 Network(网络)面板,刷新页面',
16
+ '3. 点列表里任意一个 gemini.google.com 的请求 → Headers(标头)→ Request Headers(请求标头)',
17
+ '4. 找到 cookie 那一行,复制它的值,粘贴到下面',
18
+ '5. 直接关掉无痕窗口,不要点「退出登录」(退出会让这份 Cookie 失效)',
19
+ 'Cookie 只保存在本机的 Gemini Web 后端里,不会发到别处。'
20
+ ].join('\n');
21
+
22
+ // signInFor is the Magpie sign-in for one account of the local backend's pool: the backend's
23
+ // key, with the account's id for its usage card and its Google email, which names the card and
24
+ // 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
+ async function signInFor(local, account) {
27
+ const email = account.email || await accountEmail(account).catch(() => undefined);
28
+ return {
29
+ type: 'success',
30
+ key: local.apiKey,
31
+ metadata: { baseURL: local.baseURL, account: account.id, ...(email ? { email } : {}) }
32
+ };
33
+ }
34
+
35
+ // managedBackend is the local backend this plugin set up, when baseURL points at it.
36
+ function managedBackend(baseURL) {
37
+ const local = localBackend();
38
+ return local && local.baseURL === baseURL ? local : null;
39
+ }
40
+
41
+ // forwardManaged is forward(), but when the backend this plugin manages can't be reached it
42
+ // starts the backend and tries once more.
43
+ async function forwardManaged(input, init, getAuth, opts) {
44
+ const retryInput = input instanceof Request ? input.clone() : input;
45
+ const res = await forward(input, init, getAuth, opts);
46
+ if (res.headers.get(UNREACHABLE_HEADER) !== 'unreachable') return res;
47
+ let c;
48
+ try {
49
+ c = credentials(await getAuth(), opts);
50
+ } catch {
51
+ return res;
52
+ }
53
+ if (!managedBackend(c.baseURL)) return res;
54
+ try {
55
+ await ensureBackend();
56
+ } catch {
57
+ return res;
58
+ }
59
+ return forward(retryInput, init, getAuth, opts);
60
+ }
4
61
 
5
62
  export const GeminiWebPlugin = async (_input, options = {}) => {
6
63
  const opts = settings(options);
64
+
65
+ // Bring a backend set up earlier back after Magpie or the computer restarts. Not awaited:
66
+ // requests that arrive first start it themselves through forwardManaged.
67
+ if (localBackend()) {
68
+ ensureBackend().catch(err => console.error(`[gemini-web] could not start the local backend: ${err.message}`));
69
+ }
70
+
7
71
  return {
8
72
  config: async cfg => {
9
73
  cfg.provider ??= {};
@@ -13,8 +77,32 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
13
77
  provider: ID,
14
78
  maxConcurrency: 1,
15
79
  methods: [{
80
+ type: 'oauth',
81
+ label: '一键配置本地 Gemini Web(推荐)',
82
+ // Installs and starts the pinned backend, then takes the Gemini cookie the user pastes.
83
+ // The result carries the backend's own API key, so Magpie saves an API-key sign-in,
84
+ // one for each Google account (see signInFor).
85
+ async authorize() {
86
+ const local = await ensureBackend();
87
+ return {
88
+ url: GEMINI_URL,
89
+ method: 'code',
90
+ instructions: COOKIE_STEPS,
91
+ async callback(code) {
92
+ let cookie, added;
93
+ try {
94
+ cookie = parseCookieInput(code);
95
+ added = await importCookie(local, cookie);
96
+ } catch (err) {
97
+ return { type: 'failed', error: err.message };
98
+ }
99
+ return signInFor(local, { id: added.id, cookie });
100
+ }
101
+ };
102
+ }
103
+ }, {
16
104
  type: 'api',
17
- label: 'Gemini Web backend API key',
105
+ label: '连接已有后端(自定义地址 + API key)',
18
106
  placeholder: 'Backend API key (not a Google cookie)',
19
107
  prompts: [{
20
108
  type: 'text',
@@ -35,6 +123,38 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
35
123
  type: 'success',
36
124
  metadata: { baseURL: normalizeBaseURL(inputs?.baseURL || opts.baseURL) }
37
125
  })
126
+ }, {
127
+ type: 'oauth',
128
+ label: '添加本地后端里已有的 Google 账号',
129
+ // Magpie asks through ask(), listing the pool as it is now; prompts holds the question
130
+ // for OpenCode, and makes Magpie hand authorize the answer.
131
+ prompts: [{ type: 'select', key: 'account', message: '选择 Google 账号', options: [] }],
132
+ async ask(inputs) {
133
+ if (inputs?.account) return null;
134
+ if (!localBackend()) throw new Error('还没有本地后端:请先用「一键配置本地 Gemini Web」');
135
+ const choices = await poolChoices();
136
+ if (!choices.length) throw new Error('本地后端里没有启用的 Google 账号');
137
+ return {
138
+ type: 'select',
139
+ key: 'account',
140
+ message: '选择 Google 账号',
141
+ placeholder: '',
142
+ options: choices.map(c => ({ label: c.label, value: String(c.id), hint: c.hint }))
143
+ };
144
+ },
145
+ async authorize(inputs) {
146
+ const local = localBackend();
147
+ if (!local) throw new Error('还没有本地后端:请先用「一键配置本地 Gemini Web」');
148
+ const id = Number(inputs?.account);
149
+ const chosen = (await poolChoices()).find(c => c.id === id);
150
+ if (!chosen) throw new Error(`本地后端里没有启用的账号 #${inputs?.account}`);
151
+ return {
152
+ url: '',
153
+ method: 'auto',
154
+ instructions: '',
155
+ callback: async () => signInFor(local, chosen)
156
+ };
157
+ }
38
158
  }],
39
159
  async loader(getAuth) {
40
160
  const auth = await getAuth();
@@ -43,15 +163,41 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
43
163
  return {
44
164
  apiKey: c.key,
45
165
  baseURL: c.baseURL,
46
- fetch: (input, init) => forward(input, init, getAuth, opts)
166
+ fetch: (input, init) => forwardManaged(input, init, getAuth, opts)
47
167
  };
168
+ },
169
+ // Magpie's usage card: the 5-hour and weekly limits of each Google account in the local
170
+ // backend's pool, as gemini.google.com/usage shows them. A backend this plugin doesn't
171
+ // manage keeps its accounts out of reach. No read marks the sign-in: the Magpie key
172
+ // belongs to the backend, not to Google.
173
+ async usage(getAuth) {
174
+ const auth = await getAuth();
175
+ if (auth?.type !== 'api') return { error: 'Gemini Web 还没有登录', windows: [], signIn: 'kept' };
176
+ let c;
177
+ try {
178
+ c = credentials(auth, opts);
179
+ } catch (err) {
180
+ return { error: err.message, windows: [], signIn: 'kept' };
181
+ }
182
+ if (!managedBackend(c.baseURL)) {
183
+ return { error: '只能读取插件自己管理的本地后端的额度(连接已有后端时读不到)', windows: [], signIn: 'kept' };
184
+ }
185
+ const account = auth.metadata?.account;
186
+ return { ...(await usage(Number.isInteger(account) ? { account } : {})), signIn: 'kept' };
48
187
  }
49
188
  },
50
189
  provider: {
51
190
  id: ID,
52
191
  async models(provider, { auth } = {}) {
53
192
  if (auth?.type !== 'api') return provider.models;
54
- return listModels(credentials(auth, opts), opts);
193
+ const c = credentials(auth, opts);
194
+ try {
195
+ return await listModels(c, opts);
196
+ } catch (err) {
197
+ if (!err.unreachable || !managedBackend(c.baseURL)) throw err;
198
+ await ensureBackend();
199
+ return listModels(c, opts);
200
+ }
55
201
  }
56
202
  }
57
203
  };
package/lib/backend.mjs CHANGED
@@ -12,6 +12,10 @@ const LOW_EFFORTS = new Set(['none', 'minimal', 'low']);
12
12
  const MODEL_CACHE_MS = 10 * 60 * 1000;
13
13
  const knownModels = new Map();
14
14
 
15
+ // Set on the 502 forward() returns when the backend can't be reached at all, so a caller that
16
+ // manages the backend can start it and retry.
17
+ export const UNREACHABLE_HEADER = 'X-Gemini-Web-Backend';
18
+
15
19
  const KNOWN_OPTIONS = new Set([
16
20
  'baseURL',
17
21
  'allowTools',
@@ -210,6 +214,7 @@ export async function listModels(c, opts) {
210
214
  } catch (err) {
211
215
  const error = new Error('Failed to reach backend models endpoint: ' + (err?.message || err));
212
216
  error.status = 502;
217
+ error.unreachable = true;
213
218
  throw error;
214
219
  }
215
220
 
@@ -485,7 +490,7 @@ export async function forward(input, init, getAuth, opts) {
485
490
  if (err?.name === 'AbortError') {
486
491
  throw err;
487
492
  }
488
- return makeOpenAIError(502, 'Bad gateway: failed to reach backend service');
493
+ return makeOpenAIError(502, 'Bad gateway: failed to reach backend service', { [UNREACHABLE_HEADER]: 'unreachable' });
489
494
  }
490
495
 
491
496
  const respHeaders = new Headers(upstreamRes.headers);
package/lib/native.mjs CHANGED
@@ -4,13 +4,35 @@ import os from 'node:os';
4
4
  import crypto from 'node:crypto';
5
5
  import { spawn } from 'node:child_process';
6
6
 
7
- export const PINNED_SHA256 = 'c69ca75cf43017cf2a62dbf5c12b07f6bde66f403f714c32209b1ad350ed24c9';
8
- export const PINNED_URL = 'https://github.com/zexadev/gemini-web2api-go/releases/download/v4.22.0/gemini-web2api-go_v4.22.0_darwin_arm64';
7
+ export const BACKEND_VERSION = 'v4.22.0';
8
+ const RELEASE_BASE = `https://github.com/zexadev/gemini-web2api-go/releases/download/${BACKEND_VERSION}/`;
9
+
10
+ // Upstream release assets by `${process.platform}-${process.arch}`, SHA-256 from the release's SHA256SUMS.txt.
11
+ export const PINNED_BINARIES = {
12
+ 'darwin-arm64': { asset: 'gemini-web2api-go_v4.22.0_darwin_arm64', sha256: 'c69ca75cf43017cf2a62dbf5c12b07f6bde66f403f714c32209b1ad350ed24c9' },
13
+ 'darwin-x64': { asset: 'gemini-web2api-go_v4.22.0_darwin_amd64', sha256: '384e617bcc30aacac6b7c4e4d0490e88d282b13b29f7a0a20d645b69514cd405' },
14
+ 'linux-x64': { asset: 'gemini-web2api-go_v4.22.0_linux_amd64', sha256: '21fcc81e0ad8ce0921e6b6c2cafce1089852530142b37e0289355c6e72df5299' },
15
+ 'linux-arm64': { asset: 'gemini-web2api-go_v4.22.0_linux_arm64', sha256: 'f2822fa0f1a7d2b37c2bc9cbb30a95c2e8028462453a110389511408bdfd4fd7' },
16
+ 'win32-x64': { asset: 'gemini-web2api-go_v4.22.0_windows_amd64.exe', sha256: '17e37f7e5c813b2a8d90c9a0265e6cde3bcfa4e814822af999fe8c4178f02729' },
17
+ 'win32-arm64': { asset: 'gemini-web2api-go_v4.22.0_windows_arm64.exe', sha256: 'b42e951bfc08d7fe8bdb436dca87fc6a1b7948bb8d10e110653bcf13a790756c' }
18
+ };
19
+
20
+ export function pinnedBinary(platform = process.platform, arch = process.arch) {
21
+ const pin = PINNED_BINARIES[`${platform}-${arch}`];
22
+ if (!pin) {
23
+ throw new Error(`No gemini-web2api-go ${BACKEND_VERSION} build for ${platform}/${arch}. Download one manually from https://github.com/zexadev/gemini-web2api-go/releases/tag/${BACKEND_VERSION}`);
24
+ }
25
+ return { url: RELEASE_BASE + pin.asset, sha256: pin.sha256 };
26
+ }
9
27
 
10
28
  export function getDataDir(env = process.env) {
11
29
  return env.GEMINI_WEB_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'magpie-gemini-web');
12
30
  }
13
31
 
32
+ export function binaryPath(dataDir, platform = process.platform) {
33
+ return path.join(dataDir, 'bin', platform === 'win32' ? 'gemini-web2api-go.exe' : 'gemini-web2api-go');
34
+ }
35
+
14
36
  export function computeFileHash(filePath) {
15
37
  const content = fs.readFileSync(filePath);
16
38
  return crypto.createHash('sha256').update(content).digest('hex');
@@ -19,14 +41,11 @@ export function computeFileHash(filePath) {
19
41
  export async function installBackend(options = {}) {
20
42
  const platform = options.platform || process.platform;
21
43
  const arch = options.arch || process.arch;
22
- const expectedHash = options.expectedHash || PINNED_SHA256;
23
- const downloadUrl = options.url || PINNED_URL;
44
+ const pin = options.expectedHash && options.url ? null : pinnedBinary(platform, arch);
45
+ const expectedHash = options.expectedHash || pin.sha256;
46
+ const downloadUrl = options.url || pin.url;
24
47
  const fetchFn = options.fetchFn || globalThis.fetch;
25
48
 
26
- if (platform !== 'darwin' || arch !== 'arm64') {
27
- throw new Error('Automatic binary install currently supports macOS arm64 only. For other platforms, download gemini-web2api-go v4.22.0 manually from https://github.com/zexadev/gemini-web2api-go/releases/tag/v4.22.0');
28
- }
29
-
30
49
  const dataDir = options.dataDir || getDataDir();
31
50
  fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
32
51
  fs.chmodSync(dataDir, 0o700);
@@ -35,7 +54,7 @@ export async function installBackend(options = {}) {
35
54
  fs.mkdirSync(binDir, { recursive: true, mode: 0o700 });
36
55
  fs.chmodSync(binDir, 0o700);
37
56
 
38
- const binPath = path.join(binDir, 'gemini-web2api-go');
57
+ const binPath = binaryPath(dataDir, platform);
39
58
  let binaryReady = false;
40
59
 
41
60
  if (fs.existsSync(binPath)) {
@@ -51,7 +70,7 @@ export async function installBackend(options = {}) {
51
70
  if (!binaryReady) {
52
71
  console.log(`Downloading backend binary from ${downloadUrl}...`);
53
72
  const res = await fetchFn(downloadUrl, {
54
- signal: AbortSignal.timeout(60000)
73
+ signal: AbortSignal.timeout(300000)
55
74
  });
56
75
  if (!res.ok) {
57
76
  throw new Error(`Failed to download binary: HTTP ${res.status}`);
@@ -78,6 +97,11 @@ export async function installBackend(options = {}) {
78
97
  }
79
98
  }
80
99
 
100
+ return { binPath, ...ensureDataFiles(dataDir) };
101
+ }
102
+
103
+ // ensureDataFiles creates config.json and secrets.json when missing; existing ones are kept.
104
+ function ensureDataFiles(dataDir) {
81
105
  const configPath = path.join(dataDir, 'config.json');
82
106
  if (!fs.existsSync(configPath)) {
83
107
  const defaultConfig = {
@@ -107,16 +131,58 @@ export async function installBackend(options = {}) {
107
131
  fs.chmodSync(secretsPath, 0o600);
108
132
  }
109
133
 
110
- return { binPath, configPath, secretsPath };
134
+ return { configPath, secretsPath };
111
135
  }
112
136
 
113
137
  export function startBackend(options = {}) {
114
138
  const dataDir = options.dataDir || getDataDir();
115
- const binPath = path.join(dataDir, 'bin', 'gemini-web2api-go');
139
+ const expectedHash = options.expectedHash || pinnedBinary().sha256;
140
+ const spawnFn = options.spawnFn || spawn;
141
+ const { binPath, args, env } = prepareLaunch(dataDir, [expectedHash]);
142
+
143
+ const child = spawnFn(binPath, args, {
144
+ env,
145
+ stdio: 'inherit'
146
+ });
147
+
148
+ const onSigInt = () => child.kill('SIGINT');
149
+ const onSigTerm = () => child.kill('SIGTERM');
150
+ process.on('SIGINT', onSigInt);
151
+ process.on('SIGTERM', onSigTerm);
152
+
153
+ const cleanupListeners = () => {
154
+ process.removeListener('SIGINT', onSigInt);
155
+ process.removeListener('SIGTERM', onSigTerm);
156
+ };
157
+
158
+ child.on('error', () => {
159
+ cleanupListeners();
160
+ console.error('Failed to start backend process');
161
+ process.exitCode = 1;
162
+ });
163
+
164
+ child.on('exit', (code, signal) => {
165
+ cleanupListeners();
166
+ if (typeof options.exitFn === 'function') {
167
+ options.exitFn(code, signal);
168
+ return;
169
+ }
170
+ if (signal) {
171
+ process.kill(process.pid, signal);
172
+ } else {
173
+ process.exit(code ?? 0);
174
+ }
175
+ });
176
+
177
+ return child;
178
+ }
179
+
180
+ // prepareLaunch checks the installed backend before it is run: a binary whose hash is one of
181
+ // trustedHashes, a loopback-only config and well-formed secrets. It returns what to spawn.
182
+ function prepareLaunch(dataDir, trustedHashes, platform = process.platform) {
183
+ const binPath = binaryPath(dataDir, platform);
116
184
  const configPath = path.join(dataDir, 'config.json');
117
185
  const secretsPath = path.join(dataDir, 'secrets.json');
118
- const expectedHash = options.expectedHash || PINNED_SHA256;
119
- const spawnFn = options.spawnFn || spawn;
120
186
 
121
187
  if (!fs.existsSync(binPath)) {
122
188
  throw new Error(`Binary not found at ${binPath}. Run 'npm run backend:install' first.`);
@@ -129,8 +195,8 @@ export function startBackend(options = {}) {
129
195
  }
130
196
 
131
197
  const actualHash = computeFileHash(binPath);
132
- if (actualHash !== expectedHash) {
133
- throw new Error(`Binary integrity verification failed: expected ${expectedHash}, got ${actualHash}`);
198
+ if (!trustedHashes.includes(actualHash)) {
199
+ throw new Error(`Binary integrity verification failed: expected ${trustedHashes.join(' or ')}, got ${actualHash}`);
134
200
  }
135
201
 
136
202
  let config;
@@ -167,41 +233,151 @@ export function startBackend(options = {}) {
167
233
  ADMIN_TOKEN: secrets.adminToken
168
234
  };
169
235
 
170
- const child = spawnFn(binPath, args, {
171
- env,
172
- stdio: 'inherit'
173
- });
236
+ return { binPath, args, env, port: config.port, secrets };
237
+ }
174
238
 
175
- const onSigInt = () => child.kill('SIGINT');
176
- const onSigTerm = () => child.kill('SIGTERM');
177
- process.on('SIGINT', onSigInt);
178
- process.on('SIGTERM', onSigTerm);
239
+ // trustedHashes is what a backend binary in dataDir may hash to: the pinned upstream build for
240
+ // this platform, plus a local Argon build installed by `backend:install:argon`.
241
+ function trustedHashes(dataDir, platform, arch) {
242
+ const hashes = [];
243
+ try {
244
+ hashes.push(pinnedBinary(platform, arch).sha256);
245
+ } catch {}
246
+ try {
247
+ const argon = JSON.parse(fs.readFileSync(path.join(dataDir, 'argon-build.json'), 'utf8'));
248
+ if (/^[a-f0-9]{64}$/.test(argon.sha256)) hashes.push(argon.sha256);
249
+ } catch {}
250
+ return hashes;
251
+ }
179
252
 
180
- const cleanupListeners = () => {
181
- process.removeListener('SIGINT', onSigInt);
182
- process.removeListener('SIGTERM', onSigTerm);
183
- };
253
+ function readLocalSetup(dataDir) {
254
+ try {
255
+ const config = JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8'));
256
+ const secrets = JSON.parse(fs.readFileSync(path.join(dataDir, 'secrets.json'), 'utf8'));
257
+ if (config.host !== '127.0.0.1' || !Number.isInteger(config.port) || typeof secrets.apiKey !== 'string' || typeof secrets.adminToken !== 'string') {
258
+ return null;
259
+ }
260
+ const origin = `http://127.0.0.1:${config.port}`;
261
+ return { origin, baseURL: `${origin}/v1`, apiKey: secrets.apiKey, adminToken: secrets.adminToken };
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
184
266
 
185
- child.on('error', () => {
186
- cleanupListeners();
187
- console.error('Failed to start backend process');
188
- process.exitCode = 1;
189
- });
267
+ // localBackend returns the backend this plugin manages in dataDir ({ origin, baseURL, apiKey,
268
+ // adminToken }), or null when none has been set up there.
269
+ export function localBackend(options = {}) {
270
+ const dataDir = options.dataDir || getDataDir();
271
+ const platform = options.platform || process.platform;
272
+ if (!fs.existsSync(binaryPath(dataDir, platform))) return null;
273
+ return readLocalSetup(dataDir);
274
+ }
190
275
 
191
- child.on('exit', (code, signal) => {
192
- cleanupListeners();
193
- if (typeof options.exitFn === 'function') {
194
- options.exitFn(code, signal);
195
- return;
276
+ // backendState says whether the backend at setup answers with this setup's API key.
277
+ async function backendState(setup, fetchFn) {
278
+ let res;
279
+ try {
280
+ res = await fetchFn(`${setup.baseURL}/models`, {
281
+ headers: { Authorization: `Bearer ${setup.apiKey}` },
282
+ signal: AbortSignal.timeout(2000)
283
+ });
284
+ } catch {
285
+ return 'down';
286
+ }
287
+ return res.ok ? 'up' : 'foreign';
288
+ }
289
+
290
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
291
+
292
+ // withLock serializes setup across plugin hosts (Magpie may run more than one) by a lock file.
293
+ async function withLock(dataDir, fn, timeoutMs) {
294
+ const lockPath = path.join(dataDir, '.setup.lock');
295
+ const deadline = Date.now() + timeoutMs;
296
+ for (;;) {
297
+ try {
298
+ fs.closeSync(fs.openSync(lockPath, 'wx', 0o600));
299
+ break;
300
+ } catch (err) {
301
+ if (err.code !== 'EEXIST') throw err;
302
+ try {
303
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > timeoutMs) {
304
+ fs.rmSync(lockPath, { force: true });
305
+ continue;
306
+ }
307
+ } catch {}
308
+ if (Date.now() > deadline) throw new Error(`Timed out waiting for another Gemini Web setup (${lockPath})`);
309
+ await sleep(300);
196
310
  }
197
- if (signal) {
198
- process.kill(process.pid, signal);
311
+ }
312
+ try {
313
+ return await fn();
314
+ } finally {
315
+ fs.rmSync(lockPath, { force: true });
316
+ }
317
+ }
318
+
319
+ // ensureBackend makes sure the local backend is installed and running, and returns how to reach
320
+ // it. A backend already answering with this setup's key is left alone. Otherwise a missing or
321
+ // untrusted binary is (re)installed from the pinned upstream release, config and secrets are
322
+ // created when missing, and the backend is started in the background (logging to backend.log)
323
+ // so it outlives the caller.
324
+ export async function ensureBackend(options = {}) {
325
+ const dataDir = options.dataDir || getDataDir();
326
+ const platform = options.platform || process.platform;
327
+ const arch = options.arch || process.arch;
328
+ const fetchFn = options.fetchFn || globalThis.fetch;
329
+ const spawnFn = options.spawnFn || spawn;
330
+ const startTimeoutMs = options.startTimeoutMs ?? 20000;
331
+
332
+ let setup = readLocalSetup(dataDir);
333
+ if (setup && fs.existsSync(binaryPath(dataDir, platform)) && await backendState(setup, fetchFn) === 'up') {
334
+ return setup;
335
+ }
336
+
337
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
338
+ return withLock(dataDir, async () => {
339
+ setup = readLocalSetup(dataDir);
340
+ if (setup && fs.existsSync(binaryPath(dataDir, platform)) && await backendState(setup, fetchFn) === 'up') {
341
+ return setup;
342
+ }
343
+
344
+ const trusted = trustedHashes(dataDir, platform, arch);
345
+ const binPath = binaryPath(dataDir, platform);
346
+ if (!fs.existsSync(binPath) || !trusted.includes(computeFileHash(binPath))) {
347
+ await installBackend({ dataDir, platform, arch, fetchFn });
199
348
  } else {
200
- process.exit(code ?? 0);
349
+ ensureDataFiles(dataDir);
350
+ }
351
+ setup = readLocalSetup(dataDir);
352
+ if (!setup) throw new Error(`Invalid Gemini Web backend config in ${dataDir}`);
353
+ if (await backendState(setup, fetchFn) === 'foreign') {
354
+ throw new Error(`${setup.origin} is already used by another service; change "port" in ${path.join(dataDir, 'config.json')}`);
201
355
  }
202
- });
203
356
 
204
- return child;
357
+ const launch = prepareLaunch(dataDir, trusted, platform);
358
+ const logPath = path.join(dataDir, 'backend.log');
359
+ const log = fs.openSync(logPath, 'a', 0o600);
360
+ try {
361
+ const child = spawnFn(launch.binPath, launch.args, {
362
+ env: launch.env,
363
+ detached: true,
364
+ stdio: ['ignore', log, log],
365
+ windowsHide: true
366
+ });
367
+ child.on?.('error', () => {});
368
+ if (child.pid) fs.writeFileSync(path.join(dataDir, 'backend.pid'), `${child.pid}\n`, { mode: 0o600 });
369
+ child.unref?.();
370
+ } finally {
371
+ fs.closeSync(log);
372
+ }
373
+
374
+ const deadline = Date.now() + startTimeoutMs;
375
+ while (Date.now() < deadline) {
376
+ if (await backendState(setup, fetchFn) === 'up') return setup;
377
+ await sleep(300);
378
+ }
379
+ throw new Error(`Gemini Web backend did not start within ${Math.round(startTimeoutMs / 1000)}s; see ${logPath}`);
380
+ }, 600000);
205
381
  }
206
382
 
207
383
  export async function statusBackend(options = {}) {
package/lib/setup.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // Sign-in helpers for the one-click setup: turn what the user pasted into a Cookie header and
2
+ // add it to the local backend's account pool through its admin API.
3
+
4
+ // The sign-in cookies a gemini.google.com request carries; without these the backend serves
5
+ // the request anonymously (or the account check fails).
6
+ export const REQUIRED_COOKIES = ['SID', 'HSID', 'SSID', 'APISID', 'SAPISID', '__Secure-1PSID'];
7
+
8
+ // Domains whose cookies the browser sends to gemini.google.com.
9
+ const GEMINI_COOKIE_DOMAIN = /^\.?(gemini\.)?google\.com$/i;
10
+
11
+ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
12
+
13
+ function pairsFromHeader(text) {
14
+ const pairs = [];
15
+ for (const part of text.split(/;|\r?\n/)) {
16
+ const p = part.trim();
17
+ const i = p.indexOf('=');
18
+ if (i > 0) pairs.push([p.slice(0, i).trim(), p.slice(i + 1).trim()]);
19
+ }
20
+ return pairs;
21
+ }
22
+
23
+ // DevTools › Application › Cookies rows copied as text: name, value, domain, path, … separated
24
+ // by tabs. Only cookies for google.com / gemini.google.com are kept.
25
+ function pairsFromTable(text) {
26
+ const pairs = [];
27
+ for (const line of text.split(/\r?\n/)) {
28
+ const cols = line.split('\t').map(c => c.trim());
29
+ if (cols.length < 3 || !COOKIE_NAME.test(cols[0]) || !cols[1]) continue;
30
+ if (GEMINI_COOKIE_DOMAIN.test(cols[2])) pairs.push([cols[0], cols[1]]);
31
+ }
32
+ return pairs;
33
+ }
34
+
35
+ function pairsFromJSON(text) {
36
+ let v;
37
+ try {
38
+ v = JSON.parse(text);
39
+ } catch {
40
+ return null;
41
+ }
42
+ if (Array.isArray(v)) {
43
+ // Cookie-editor style exports: [{ name, value, domain }, …]
44
+ return v.filter(c => c && typeof c.name === 'string' && typeof c.value === 'string' &&
45
+ (typeof c.domain !== 'string' || GEMINI_COOKIE_DOMAIN.test(c.domain)))
46
+ .map(c => [c.name, c.value]);
47
+ }
48
+ if (v && typeof v === 'object') {
49
+ if (typeof v.cookie === 'string') return pairsFromHeader(v.cookie);
50
+ return Object.entries(v).filter(([, val]) => typeof val === 'string').map(([k, val]) => [k, val]);
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // parseCookieInput accepts the Cookie request header (with or without the "cookie:" prefix),
56
+ // rows copied from DevTools' cookie table, or a JSON export, and returns a "k=v; k=v" header.
57
+ // It throws a message meant for the user when the sign-in cookies are missing.
58
+ export function parseCookieInput(input) {
59
+ let text = String(input ?? '').trim().replace(/^['"]|['"]$/g, '').trim();
60
+ text = text.replace(/^cookie\s*:\s*/i, '');
61
+ if (!text) throw new Error('没有收到 Cookie:请粘贴 gemini.google.com 请求头里 cookie 一行的值');
62
+
63
+ let pairs = null;
64
+ if (text.startsWith('{') || text.startsWith('[')) pairs = pairsFromJSON(text);
65
+ if (!pairs && text.includes('\t')) pairs = pairsFromTable(text);
66
+ if (!pairs) pairs = pairsFromHeader(text);
67
+
68
+ const cookies = new Map();
69
+ for (const [name, value] of pairs) {
70
+ if (!COOKIE_NAME.test(name) || /[\x00-\x1f\x7f;]/.test(value) || !value) continue;
71
+ if (!cookies.has(name)) cookies.set(name, value);
72
+ }
73
+
74
+ const missing = REQUIRED_COOKIES.filter(n => !cookies.has(n));
75
+ if (missing.length) {
76
+ throw new Error(`Cookie 不完整,缺少 ${missing.join('、')}。请在开发者工具 Network 面板里,从发往 gemini.google.com 的请求的请求头复制 cookie 一行的完整值(不要从 Application 面板逐条复制)。`);
77
+ }
78
+ return [...cookies].map(([n, v]) => `${n}=${v}`).join('; ');
79
+ }
80
+
81
+ async function adminCall(setup, fetchFn, method, path, body) {
82
+ let res;
83
+ try {
84
+ res = await fetchFn(`${setup.origin}/admin/api/${path}`, {
85
+ method,
86
+ headers: {
87
+ Authorization: `Bearer ${setup.adminToken}`,
88
+ ...(body ? { 'Content-Type': 'application/json' } : {})
89
+ },
90
+ body: body ? JSON.stringify(body) : undefined,
91
+ redirect: 'error',
92
+ signal: AbortSignal.timeout(60000)
93
+ });
94
+ } catch (err) {
95
+ throw new Error(`连不上本地 Gemini Web 后端(${setup.origin}):${err?.message || err}`);
96
+ }
97
+ let data = {};
98
+ try {
99
+ data = await res.json();
100
+ } catch {}
101
+ if (!res.ok) throw new Error(`本地后端拒绝了请求(HTTP ${res.status}):${data.error || 'unknown error'}`);
102
+ return data;
103
+ }
104
+
105
+ // importCookie adds the cookie to the backend's account pool and checks it signs in. An
106
+ // account that fails the check is removed again, and the error says why.
107
+ export async function importCookie(setup, cookie, options = {}) {
108
+ const fetchFn = options.fetchFn || globalThis.fetch;
109
+ const label = options.label || `Magpie ${new Date().toISOString().slice(0, 10)}`;
110
+ const added = await adminCall(setup, fetchFn, 'POST', 'cookies', {
111
+ label,
112
+ cookie,
113
+ note: 'Added by Magpie one-click setup'
114
+ });
115
+ if (!Number.isInteger(added.id)) throw new Error('本地后端没有返回新账号的 id');
116
+
117
+ let check;
118
+ try {
119
+ check = await adminCall(setup, fetchFn, 'POST', `cookies/${added.id}/check`);
120
+ } catch (err) {
121
+ await adminCall(setup, fetchFn, 'DELETE', `cookies/${added.id}`).catch(() => {});
122
+ throw err;
123
+ }
124
+ if (!check.ok) {
125
+ await adminCall(setup, fetchFn, 'DELETE', `cookies/${added.id}`).catch(() => {});
126
+ throw new Error(`这个 Cookie 无法登录 Gemini:${check.detail || '检测未通过'}`);
127
+ }
128
+ return { id: added.id, detail: check.detail || '' };
129
+ }
package/lib/usage.mjs ADDED
@@ -0,0 +1,193 @@
1
+ // Reads how much of each Gemini account's usage limits is gone, the way the Usage page at
2
+ // gemini.google.com/usage does: a persisted GraphQL query whose getUsageInfo lists a 5-hour and
3
+ // a weekly pool. The cookies come from the local backend's account pool, which also writes back
4
+ // the cookies Google rotates, so the copy there is always the live one.
5
+ import path from 'node:path';
6
+ import { getDataDir } from './native.mjs';
7
+
8
+ const EXECUTE_QUERY = 'https://gemini.google.com/_/BardChatUi/graphql/schemas/GEMINI_WEB_GRAPHQL/executeQuery';
9
+ // UsagePageQuery, and the variables the Usage page sends with it.
10
+ const USAGE_QUERY = '2/dWbCvkVB0X16r9NPnxifeOAAqdCqMf0xeWvau9yPy7g=';
11
+ const USAGE_VARIABLES = {
12
+ g1Request: {
13
+ entrypoints: [
14
+ 'GEMINI_USAGE_SETTINGS_UPGRADE_BANNER',
15
+ 'GEMINI_USAGE_SETTINGS_AI_CREDITS_UPSELL_CARD',
16
+ 'GEMINI_USAGE_SETTINGS_UPGRADE_AND_CREDITS_BANNER'
17
+ ]
18
+ },
19
+ workspaceRequest: {
20
+ recommendationsForSurfaceRequests: [{
21
+ recommendationSurface: { rpOnramp: 'GEMINI_CHAT_USAGE_LIMITS_UPSELL', version: '1' },
22
+ maxRecommendationCount: 1
23
+ }]
24
+ }
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';
27
+
28
+ // The pools the Usage page shows, under the names Magpie gives the same windows elsewhere.
29
+ const POOLS = [
30
+ { type: 'GXU_FIVE_HOURLY', name: '5 hours', span: 5 * 3600 },
31
+ { type: 'GXU_WEEKLY', name: '7 days', span: 7 * 24 * 3600 }
32
+ ];
33
+
34
+ // openReadOnly opens the backend's SQLite database without writing to it: bun:sqlite under
35
+ // Magpie's Bun, node:sqlite under Node.
36
+ async function openReadOnly(file) {
37
+ if (globalThis.Bun) {
38
+ const { Database } = await import('bun:sqlite');
39
+ const db = new Database(file, { readonly: true });
40
+ return { all: sql => db.query(sql).all(), close: () => db.close() };
41
+ }
42
+ const { DatabaseSync } = await import('node:sqlite');
43
+ const db = new DatabaseSync(file, { readOnly: true });
44
+ return { all: sql => db.prepare(sql).all(), close: () => db.close() };
45
+ }
46
+
47
+ // poolAccounts lists the enabled accounts in the backend's pool, each with the proxy it is
48
+ // bound to, if any.
49
+ export async function poolAccounts(dataDir = getDataDir()) {
50
+ const db = await openReadOnly(path.join(dataDir, 'gemini.db'));
51
+ 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
54
+ WHERE a.status = 'enabled' ORDER BY a.id`);
55
+ } finally {
56
+ db.close();
57
+ }
58
+ }
59
+
60
+ // parseUsage takes the pools out of an executeQuery reply: a JSON array of payloads, which
61
+ // may start with the )]}' guard.
62
+ export function parseUsage(text) {
63
+ const start = text.indexOf('[');
64
+ if (start < 0) throw new Error('Gemini 返回了无法识别的用量数据');
65
+ const payloads = JSON.parse(text.slice(start));
66
+ for (const p of Array.isArray(payloads) ? payloads : []) {
67
+ const info = p?.data?.viewer?.rpcs?.getUsageInfo;
68
+ if (info) return { tier: info.subscriptionTier, pools: Array.isArray(info.pools) ? info.pools : [] };
69
+ if (p?.errors?.length) throw new Error(`Gemini 拒绝了用量查询:${p.errors[0]?.message || 'unknown error'}`);
70
+ }
71
+ throw new Error('Gemini 的回复里没有用量数据');
72
+ }
73
+
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.
76
+ async function geminiRequest(url, account, headers, options) {
77
+ const fetchFn = options.fetchFn || globalThis.fetch;
78
+ const init = {
79
+ headers: { ...headers, Cookie: account.cookie, 'User-Agent': USER_AGENT },
80
+ redirect: 'manual',
81
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15000)
82
+ };
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
+ const res = await fetchFn(url, init);
86
+ if (res.status >= 300 && res.status < 400 || res.status === 401 || res.status === 403) {
87
+ throw new Error('Cookie 已失效,请重新走一遍一键配置');
88
+ }
89
+ if (!res.ok) throw new Error(`Gemini 返回 HTTP ${res.status}`);
90
+ return res.text();
91
+ }
92
+
93
+ // accountUsage asks Gemini for one account's usage pools.
94
+ export async function accountUsage(account, options = {}) {
95
+ const url = `${EXECUTE_QUERY}?${new URLSearchParams({ query_signature: USAGE_QUERY, variables: JSON.stringify(USAGE_VARIABLES) })}`;
96
+ return parseUsage(await geminiRequest(url, account, {
97
+ Accept: 'application/json',
98
+ Origin: 'https://gemini.google.com',
99
+ Referer: 'https://gemini.google.com/usage'
100
+ }, options));
101
+ }
102
+
103
+ // 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();
106
+ const EMAIL_FIELD = /"oPEP7c":"([^"@\s]+@[^"\s]+)"/;
107
+
108
+ export async function accountEmail(account, options = {}) {
109
+ 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);
112
+ const email = EMAIL_FIELD.exec(page)?.[1];
113
+ if (email) emails.set(account.id, email);
114
+ return email;
115
+ }
116
+
117
+ // windowsOf turns an account's pools into Magpie usage windows, 5 hours then 7 days.
118
+ export function windowsOf(pools) {
119
+ const out = [];
120
+ for (const { type, name, span } of POOLS) {
121
+ const p = pools.find(x => x?.poolType === type);
122
+ if (typeof p?.percentageUsed !== 'number' || !(p.percentageUsed >= 0)) continue;
123
+ const w = { name, used: Math.min(100, Math.round(p.percentageUsed * 100)) };
124
+ const at = Date.parse(p.gxu?.windowRefreshesAt ?? '');
125
+ if (Number.isFinite(at)) w.resetsAt = new Date(at).toISOString();
126
+ w.span = span;
127
+ out.push(w);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ const shortName = who => who.replace(/@gmail\.com$/i, '');
133
+ const soonest = w => (w.resetsAt ? Date.parse(w.resetsAt) : Infinity);
134
+
135
+ // poolWindows makes one card of several accounts. The backend takes them in turn, so the pool
136
+ // runs short once any one does: each window is its tightest account's, the most used and of
137
+ // those the soonest to start again, as Magpie's own card reads a family of model windows. The
138
+ // accounts' own figures, and any account that couldn't be read, go in its tooltip.
139
+ export function poolWindows(read) {
140
+ const out = [];
141
+ for (const { name } of POOLS) {
142
+ const each = read.flatMap(r => (r.windows || []).filter(w => w.name === name).map(w => ({ ...w, who: r.who })));
143
+ if (!each.length) continue;
144
+ const top = each.reduce((a, b) => (b.used > a.used || (b.used === a.used && soonest(b) < soonest(a)) ? b : a));
145
+ const { who, ...w } = top;
146
+ const parts = each.map(x => `${shortName(x.who)} ${x.used}%`);
147
+ for (const r of read) if (r.error) parts.push(`${shortName(r.who)} 读取失败`);
148
+ out.push({ ...w, display: `各账号已用 ${parts.join(',')}` });
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // usage is the Magpie usage card: one account's 5-hour and weekly windows, as
154
+ // gemini.google.com/usage shows them, when the sign-in names its backend account; else the
155
+ // whole pool's, as one card. Accounts go by their Google emails (#id until that is known).
156
+ // Magpie hides a card's windows behind an error, so a pool account that can't be read only
157
+ // says so in the tooltip while the others still show.
158
+ export async function usage(options = {}) {
159
+ let accounts;
160
+ try {
161
+ accounts = await (options.poolAccounts || poolAccounts)(options.dataDir);
162
+ } catch (err) {
163
+ return { error: `读不到本地后端的账号池:${err?.message || err}`, windows: [] };
164
+ }
165
+ if (options.account !== undefined) {
166
+ accounts = accounts.filter(a => a.id === options.account);
167
+ if (!accounts.length) return { error: `本地后端里已经没有这个账号(#${options.account}),可能被停用或删除了`, windows: [] };
168
+ }
169
+ if (!accounts.length) return { error: '本地后端里没有启用的 Google 账号', windows: [] };
170
+
171
+ const read = await Promise.all(accounts.map(async a => {
172
+ const [u, email] = await Promise.allSettled([accountUsage(a, options), accountEmail(a, options)]);
173
+ const who = (email.status === 'fulfilled' && email.value) || `#${a.id}`;
174
+ return u.status === 'fulfilled' ? { who, windows: windowsOf(u.value.pools) } : { who, error: u.reason?.message || String(u.reason) };
175
+ }));
176
+ const user = read.length > 1 ? `${read[0].who} +${read.length - 1}` : read[0].who;
177
+ const several = read.length > 1;
178
+ const windows = several ? poolWindows(read) : read[0].windows || [];
179
+ if (!windows.length) {
180
+ const error = read.filter(r => r.error).map(r => (several ? `${r.who}:` : '') + r.error).join(';');
181
+ return { user, error: error || 'Gemini 没有返回用量窗口', windows: [] };
182
+ }
183
+ return { user, windows };
184
+ }
185
+
186
+ // poolChoices lists the pool's accounts for a sign-in to pick one, each by its email.
187
+ export async function poolChoices(options = {}) {
188
+ const accounts = await (options.poolAccounts || poolAccounts)(options.dataDir);
189
+ return Promise.all(accounts.map(async a => {
190
+ const email = await accountEmail(a, options).catch(() => undefined);
191
+ return { id: a.id, email, label: email || `#${a.id}`, hint: email ? `#${a.id}` : a.label };
192
+ }));
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Gemini Web provider for Magpie, backed by gemini-web2api-go",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -23,6 +23,7 @@
23
23
  "scripts/",
24
24
  "patches/",
25
25
  "README.md",
26
+ "README.en.md",
26
27
  "LICENSE",
27
28
  "LICENSE-UPSTREAM"
28
29
  ],