magpie-gemini-web 0.1.3 → 0.2.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.md CHANGED
@@ -4,125 +4,88 @@ Gemini Web provider plugin for [Magpie](https://usemagpie.ai/)(遵循 OpenCode
4
4
 
5
5
  ---
6
6
 
7
- ## 1. 为什么选择此方案(选型理由与架构说明)
8
-
9
- 将 Gemini 网页版接入 AI Agent 工具链通常面临复杂的网页抓包协议、Cookie 会话维持及接口兼容问题。本方案采用独立原生后端 + Magpie 接入插件的解耦架构:
10
-
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 许可证。
22
-
23
- ---
24
-
25
- ## 2. 运行环境要求
26
-
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` 指向上游监听地址即可。
32
-
33
- ---
34
-
35
- ## 3. 快速上手
36
-
37
- 插件与后端需要分别安装。Magpie 安装插件时不会执行安装脚本,也不会自动启动 Go 后端。先获取管理脚本:
7
+ ## 1. 快速上手(推荐:一键配置)
8
+
9
+ 1. 在 Magpie 插件市场搜索并安装 **magpie-gemini-web**。
10
+ 2. 在 Magpie 里登录 Gemini Web 提供商,选择 **「一键配置本地 Gemini Web(推荐)」**(命令行为 `magpie plugin login gemini-web`)。插件会:
11
+ - 下载官方 [gemini-web2api-go](https://github.com/zexadev/gemini-web2api-go) v4.22.0 后端,并按发布页的 SHA-256 校验,校验不通过就不安装;
12
+ - 生成本地随机密钥,在后台启动后端(只监听 `127.0.0.1:8083`);
13
+ - 打开 <https://gemini.google.com/app>,提示你复制 Cookie:
14
+ 1. 确认网页已登录要使用的 Google 账号;
15
+ 2. 按 F12(macOS 按 ⌥⌘I)打开开发者工具,切到 **Network(网络)**,刷新页面;
16
+ 3. 点任意一个 `gemini.google.com` 的请求 → **Headers(标头)** → **Request Headers(请求标头)**;
17
+ 4. 复制 `cookie` 那一行的值,粘贴回 Magpie。
18
+ 3. 插件把 Cookie 导入本地后端并检测能否登录 Gemini;检测通过即完成登录,检测失败会删掉这条账号并提示原因(例如缺少哪些 Cookie、账号未登录)。全程不需要手动填写 API key。
19
+
20
+ 之后每次 Magpie 启动,插件发现本地后端没在运行就会自动把它拉起来;请求时发现后端掉线也会先重启再重试一次。后端日志在数据目录下的 `backend.log`。
21
+
22
+ > 要再加一个 Google 账号,重新走一遍一键配置即可,新账号会加入后端的账号池。
23
+ >
24
+ > Cookie 只发给本机的后端,保存在它的本地 SQLite 数据库里;Magpie 保存的是后端的本地 API key,而不是 Cookie。
25
+
26
+ 需要工具调用或图片输入时再开启插件选项(默认关闭,见第 4 节):
38
27
  ```bash
39
- git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git
40
- cd magpie-gemini-web
28
+ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
41
29
  ```
42
- 官方后端自 v4.22.0 起已包含 `gemini-4-argon` / `gemini-4-argon-thinking`,但不核对上游实际返回的模型:无权限、Cookie 失效或配额耗尽时会被静默降级成 3.5 Flash-Lite。需要身份校验与延迟优化时,请直接按第 5 节构建补丁版后端,再完成 Cookie 配置和插件登录;不要先启动官方后端占用同一端口。
43
30
 
44
- ### 步骤 1:安装后端二进制(macOS arm64)
31
+ **支持的平台**:一键配置为以下平台锁定了官方二进制及其 SHA-256:macOS(Apple Silicon / Intel)、Linux(x64 / arm64)、Windows(x64 / arm64)。其他平台请用第 3 节的「连接已有后端」。
45
32
 
46
- 运行内置下载安装脚本(锁定版本 v4.22.0,SHA-256 为 `c69ca75cf43017cf2a62dbf5c12b07f6bde66f403f714c32209b1ad350ed24c9`):
33
+ ---
47
34
 
48
- ```bash
49
- npm run backend:install
50
- ```
35
+ ## 2. 架构与安全说明
51
36
 
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`)。
37
+ 1. **架构解耦**:
38
+ - **Magpie 插件层**(本仓库):纯 JavaScript (ESM),无第三方依赖。负责认证与一键配置、动态模型目录 (`models`)、请求转发与输入过滤 (`forward`),以及管理本地后端进程。
39
+ - **原生后端层**(`gemini-web2api-go`):上游独立 Go 进程,维护 Google 账号 Cookie 池并提供 OpenAI 兼容接口。
40
+ 2. **凭证隔离**:一键配置时插件只把你粘贴的 Cookie 交给本机后端的管理接口,自身不保存、不转发到其他地方;之后插件与 Magpie 仅凭本地随机 `apiKey` 访问后端。
41
+ 3. **本地回环**:上游默认监听 `0.0.0.0`;本插件生成的配置**锁定为 `127.0.0.1:8083`**,启动前还会再次校验 host 与二进制哈希。若 8083 已被别的服务占用,一键配置会报错而不会覆盖,可修改数据目录下 `config.json` 的 `port`。
42
+ 4. **请求过滤**:转发前剥离调用方的 `Cookie`、`Authorization`、`x-api-key`、`anthropic-*` 等头部。
43
+ 5. **并发抑制**:插件声明 `maxConcurrency: 1`,降低请求频率,但**不能保证账号不被 Google 风控或封禁**;请知悉使用网页逆向服务的风险。
44
+ 6. **开源协议**:上游项目与本插件均为 MIT 许可证。
56
45
 
57
- `secrets.json` 位于上述数据目录下(默认 `~/.local/share/magpie-gemini-web/secrets.json`),包含两个本地随机凭证,均与 Google 账号无关:
46
+ **数据目录**:默认 `~/.local/share/magpie-gemini-web`(可用环境变量 `GEMINI_WEB_DATA_DIR` 自定义),包含:
58
47
 
59
- | 字段 | 用途 |
48
+ | 文件 | 内容 |
60
49
  | :--- | :--- |
61
- | `apiKey` | 插件访问后端的密钥(`gw_` 开头),在 Magpie 登录插件时填写 |
62
- | `adminToken` | 后端管理面板 `http://127.0.0.1:8083/admin` 的登录口令 |
63
-
64
- 只要不删除 `secrets.json`,重复执行 `backend:install` 不会重新生成这两个值;若重新生成了 `apiKey`,需要在 Magpie 中重新登录插件。
65
-
66
- ### 步骤 2:启动后端服务
50
+ | `bin/gemini-web2api-go` | 后端二进制(权限 `0700`) |
51
+ | `config.json` | 后端配置,host 固定 `127.0.0.1`(权限 `0600`) |
52
+ | `secrets.json` | 本地随机生成的 `apiKey`(`gw_` 开头,插件访问后端用)和 `adminToken`(管理面板 `http://127.0.0.1:8083/admin` 的口令),均与 Google 账号无关(权限 `0600`) |
53
+ | `gemini.db` | 后端的账号与请求记录 |
54
+ | `backend.log` / `backend.pid` | 插件在后台启动的后端的日志与进程号 |
67
55
 
56
+ 只要不删除 `secrets.json`,重新配置不会更换这两个值。要查看或管理已导入的账号,可打开管理面板,用 `adminToken` 登录;把它复制到剪贴板(macOS,不在终端显示):
68
57
  ```bash
69
- npm run backend:start
58
+ 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
70
59
  ```
71
60
 
72
- - 该服务在前台运行,会将日志输出至终端。
73
- - 可在另一终端执行以下命令确认后端已监听并响应:
74
- ```bash
75
- npm run backend:status
76
- ```
77
-
78
- > 后端成功启动并列出基础模型目录,只代表本地服务通路正常,不代表全部能力可用。2026-10-14 已通过真实 Magpie 网关验证匿名 Flash Lite 普通对话;工具调用在非流式、流式和直连后端对照中均未成功,`tool_choice` 未得到满足。上游未返回缓存 token 统计,Magpie 显示的 0 不代表 Google 的实际缓存命中情况。登录态模型需要有效 Cookie。后续 Argon 补丁已验证真实 Magpie 普通对话、SSE 和首轮工具调用;工具结果回传含正确值但未严格遵循仅回显要求,图片和 Argon 缓存未验证。本地验收记录不随公开仓库或 npm 包分发。
79
-
80
- ### 步骤 3:在后端管理后台配置 Google Cookie
61
+ ---
81
62
 
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。
63
+ ## 3. 连接已有后端(自定义地址 + API key)
92
64
 
93
- ### 步骤 4:在 Magpie 中加载插件与登录
65
+ 已经自己运行了 gemini-web2api-go(其他机器、反向代理或自定义端口)时,登录时选 **「连接已有后端(自定义地址 + API key)」**:
94
66
 
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`。
67
+ - **Backend OpenAI base URL**:留空使用默认的 `http://127.0.0.1:8083/v1`,或填入你的地址(必须以 `/v1` 结尾;本地只允许 loopback HTTP,远程须 HTTPS)。
68
+ - **API key**:后端的 `API_KEY`(不是 Google Cookie)。
100
69
 
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` 即可直接输出。
70
+ 这种方式下插件不会安装或启动任何后端,Cookie 也需要你自己在后端管理面板导入(关键项包括 `SID`、`HSID`、`SSID`、`APISID`、`SAPISID`、`__Secure-1PSID`、`__Secure-1PSIDTS`,仅有两个 `__Secure-*` 值未必足够)。
111
71
 
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`。
72
+ > 重新登录(例如更换 API key 或 base URL)后,正在运行的 Magpie 不会重新读取 provider 地址,需要执行 `magpie plugin off magpie-gemini-web && magpie plugin on magpie-gemini-web` 或重启 Magpie App 才会生效。
73
+ >
74
+ > 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
75
 
116
- 3. **配置插件选项(开启工具或图片支持)**:
117
- 插件默认关闭工具调用与图片输入以确保安全和兼容性。若 Coding Agent(如 Claude Code / OpenCode / Cursor)需要使用工具调用,**必须显式开启 `allowTools`**,否则发送工具请求时将被插件直接返回 400 拦截:
76
+ ### 命令行管理脚本(开发者)
118
77
 
119
- ```bash
120
- # 显式开启工具调用与图片输入
121
- magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
78
+ 从源码仓库也可以手动管理同一个数据目录里的后端(需要 Node.js >= 22):
79
+ ```bash
80
+ git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git
81
+ cd magpie-gemini-web
82
+ npm run backend:install # 下载并校验 v4.22.0 二进制,创建 config.json / secrets.json
83
+ npm run backend:start # 前台运行后端
84
+ npm run backend:status # 检查后端是否响应
85
+ ```
86
+ 用这些脚本装好的后端,一键配置会直接复用(同一套密钥),不会重复下载。
122
87
 
123
- # 仅保持默认保守配置(关闭工具与图片)
124
- magpie plugin options magpie-gemini-web '{"allowTools":false,"allowImages":false}'
125
- ```
88
+ > 后端成功启动并列出基础模型目录,只代表本地服务通路正常,不代表全部能力可用。2026-10-14 已通过真实 Magpie 网关验证匿名 Flash Lite 普通对话;工具调用在非流式、流式和直连后端对照中均未成功,`tool_choice` 未得到满足。上游未返回缓存 token 统计,Magpie 显示的 0 不代表 Google 的实际缓存命中情况。登录态模型需要有效 Cookie。后续 Argon 补丁已验证真实 Magpie 普通对话、SSE 和首轮工具调用;工具结果回传含正确值但未严格遵循仅回显要求,图片和 Argon 缓存未验证。本地验收记录不随公开仓库或 npm 包分发。
126
89
 
127
90
  ---
128
91
 
@@ -209,6 +172,8 @@ git -C backend-source apply ../patches/argon.patch
209
172
  npm run backend:start:argon
210
173
  ```
211
174
 
175
+ 装好后也可以不手动启动:插件认可已安装 Argon 版的哈希,Magpie 启动时会像官方版一样在后台拉起它。若插件已在后台运行旧版后端,先结束它(`kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`)再启动,否则端口会被占用。
176
+
212
177
  > **版本切换提示**:如果重新执行官方标准的 `npm run backend:install`,将自动下载官方原版二进制并覆盖当前版本:Argon 模型仍可用,但不再有身份校验与延迟优化;重新执行 `npm run backend:install:argon` 即可切回 Argon 版本。
213
178
 
214
179
  ---
package/index.mjs CHANGED
@@ -1,9 +1,54 @@
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';
2
4
 
3
5
  const ID = 'gemini-web';
6
+ const GEMINI_URL = 'https://gemini.google.com/app';
7
+
8
+ const COOKIE_STEPS = [
9
+ `在浏览器打开 ${GEMINI_URL},确认已登录要使用的 Google 账号,然后:`,
10
+ '1. 按 F12(macOS 按 ⌥⌘I)打开开发者工具,切到 Network(网络)面板,刷新页面',
11
+ '2. 点列表里任意一个 gemini.google.com 的请求 → Headers(标头)→ Request Headers(请求标头)',
12
+ '3. 找到 cookie 那一行,复制它的值,粘贴到下面',
13
+ 'Cookie 只保存在本机的 Gemini Web 后端里,不会发到别处。'
14
+ ].join('\n');
15
+
16
+ // managedBackend is the local backend this plugin set up, when baseURL points at it.
17
+ function managedBackend(baseURL) {
18
+ const local = localBackend();
19
+ return local && local.baseURL === baseURL ? local : null;
20
+ }
21
+
22
+ // forwardManaged is forward(), but when the backend this plugin manages can't be reached it
23
+ // starts the backend and tries once more.
24
+ async function forwardManaged(input, init, getAuth, opts) {
25
+ const retryInput = input instanceof Request ? input.clone() : input;
26
+ const res = await forward(input, init, getAuth, opts);
27
+ if (res.headers.get(UNREACHABLE_HEADER) !== 'unreachable') return res;
28
+ let c;
29
+ try {
30
+ c = credentials(await getAuth(), opts);
31
+ } catch {
32
+ return res;
33
+ }
34
+ if (!managedBackend(c.baseURL)) return res;
35
+ try {
36
+ await ensureBackend();
37
+ } catch {
38
+ return res;
39
+ }
40
+ return forward(retryInput, init, getAuth, opts);
41
+ }
4
42
 
5
43
  export const GeminiWebPlugin = async (_input, options = {}) => {
6
44
  const opts = settings(options);
45
+
46
+ // Bring a backend set up earlier back after Magpie or the computer restarts. Not awaited:
47
+ // requests that arrive first start it themselves through forwardManaged.
48
+ if (localBackend()) {
49
+ ensureBackend().catch(err => console.error(`[gemini-web] could not start the local backend: ${err.message}`));
50
+ }
51
+
7
52
  return {
8
53
  config: async cfg => {
9
54
  cfg.provider ??= {};
@@ -13,8 +58,29 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
13
58
  provider: ID,
14
59
  maxConcurrency: 1,
15
60
  methods: [{
61
+ type: 'oauth',
62
+ label: '一键配置本地 Gemini Web(推荐)',
63
+ // Installs and starts the pinned backend, then takes the Gemini cookie the user pastes.
64
+ // The result carries the backend's own API key, so Magpie saves an API-key sign-in.
65
+ async authorize() {
66
+ const local = await ensureBackend();
67
+ return {
68
+ url: GEMINI_URL,
69
+ method: 'code',
70
+ instructions: COOKIE_STEPS,
71
+ async callback(code) {
72
+ try {
73
+ await importCookie(local, parseCookieInput(code));
74
+ } catch (err) {
75
+ return { type: 'failed', error: err.message };
76
+ }
77
+ return { type: 'success', key: local.apiKey, metadata: { baseURL: local.baseURL } };
78
+ }
79
+ };
80
+ }
81
+ }, {
16
82
  type: 'api',
17
- label: 'Gemini Web backend API key',
83
+ label: '连接已有后端(自定义地址 + API key)',
18
84
  placeholder: 'Backend API key (not a Google cookie)',
19
85
  prompts: [{
20
86
  type: 'text',
@@ -43,7 +109,7 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
43
109
  return {
44
110
  apiKey: c.key,
45
111
  baseURL: c.baseURL,
46
- fetch: (input, init) => forward(input, init, getAuth, opts)
112
+ fetch: (input, init) => forwardManaged(input, init, getAuth, opts)
47
113
  };
48
114
  }
49
115
  },
@@ -51,7 +117,14 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
51
117
  id: ID,
52
118
  async models(provider, { auth } = {}) {
53
119
  if (auth?.type !== 'api') return provider.models;
54
- return listModels(credentials(auth, opts), opts);
120
+ const c = credentials(auth, opts);
121
+ try {
122
+ return await listModels(c, opts);
123
+ } catch (err) {
124
+ if (!err.unreachable || !managedBackend(c.baseURL)) throw err;
125
+ await ensureBackend();
126
+ return listModels(c, opts);
127
+ }
55
128
  }
56
129
  }
57
130
  };
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Gemini Web provider for Magpie, backed by gemini-web2api-go",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",