magpie-gemini-web 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -21,7 +21,7 @@ The plugin provides two independent providers that can be used individually or t
21
21
  | **Image generation** | Nano Banana models, for Magpie's image generation (Magpie v0.1.2075+, see below) | Not supported |
22
22
  | **Sign-in method** | Sign in directly in an isolated popup browser window | Copy `cookie` from a private/incognito window |
23
23
  | **Usage cards** | Shows models currently on cooldown and recovery times | Syncs **5-hour** & **weekly** usage and reset times from the web |
24
- | **Local backend** | AIStudio2API (`v0.2.18`) | gemini-web2api-go (`4.26.0-magpie.1` optimized build) |
24
+ | **Local backend** | AIStudio2API (`v0.2.18`) | gemini-web2api-go (`4.26.0-magpie.2` optimized build) |
25
25
 
26
26
  ---
27
27
 
@@ -58,7 +58,7 @@ Install **magpie-gemini-web** from the Magpie plugin market, then sign in to eit
58
58
  - *Migrating older Gemini Web accounts:* For accounts added in earlier versions, add a Gemini Web account in Magpie and choose **「添加本地后端里已有的 Google 账号」** (Add an existing Google account from the local backend) to create individual cards, then remove the old pooled `API key …` card.
59
59
  - **Automatic account rotation:** The local backend selects which Google account serves each request (Gemini Web rotates across the pool; AI Studio uses one account until cooldown and switches to the next). Switching cards in Magpie does not change which account the backend picks.
60
60
  - **Usage reporting:**
61
- - `gemini-web`: Available only with the one-click local backend. To minimize requests to Google, usage is refreshed only after an account is used or a quota window resets (at least 10 minutes apart, and at most once every 6 hours when idle).
61
+ - `gemini-web`: Available only with the one-click local backend. Usage is read from Google again whenever Magpie asks for it (at most once every 30 seconds per account), so pressing refresh shows the current allowance, including usage from the Gemini app, the website or other devices. On its own, Magpie asks about once a minute while the Usage page or the menu bar's allowances panel is open, whenever it updates allowances shown in the menu bar (every 3 minutes by default), and at most once a minute while it routes requests among accounts; otherwise it doesn't ask. A failed read isn't retried on its own for 10 minutes; pressing refresh in that time retries it at once (press it again if the first press doesn't).
62
62
  - `ai-studio`: AI Studio does not report remaining total quota; cards display models currently on cooldown and their recovery times, or prompt when re-authentication is needed.
63
63
 
64
64
  ---
@@ -67,10 +67,15 @@ Install **magpie-gemini-web** from the Magpie plugin market, then sign in to eit
67
67
 
68
68
  `ai-studio` lists the Nano Banana image models your account can use (such as `gemini-3.1-flash-image` and `gemini-3-pro-image`), for Magpie's image generation. **This needs Magpie v0.1.2075 or later**, the first to take a plugin's Gemini image models as models that draw.
69
69
 
70
- - **No need to tick them, or to pick them for a chat:** Magpie's Settings list them under **Images → Image generation**. Pick one there, or leave it on Automatic.
71
- - **How agents draw:** an agent calls Magpie's `generate_image` tool (the Magpie Image MCP from the Library), which draws with the model picked there; reference images make it an edit. To try it from the command line: `magpie tool generate_image "a magpie on a branch, watercolour" --size 16:9 -o magpie.png`.
70
+ **Drawing from an agent** (pi, Claude Code, Codex and the rest alike):
71
+
72
+ 1. **Pick the model to draw with:** Magpie's Settings list these models under **Images → Image generation**. Pick one (such as `ai-studio/gemini-nano-banana-2.1`), or leave it on Automatic. There's no need to tick them in any model list.
73
+ 2. **Give the agent the drawing tool:** click **Give agents Magpie Image…** under that row, tick the agents that should draw in the Library it opens, then start a new session in the agent. With the Library turned off, the row says **Turn on the Library** instead: click that first. From the command line: `magpie library mcp add magpie-image magpie mcp image agents=pi` (several agents separated by commas, as in `agents=pi,claude`).
74
+ 3. **Chat as usual:** keep the agent on its usual chat model and ask it to draw something. It calls the `generate_image` tool, which draws with the model from step 1 and saves the image in the project's `generated-images/` folder; reference images make it an edit.
75
+
76
+ - **Don't pick an image model as the chat model:** Magpie still keeps a plugin's models that draw in its chat model lists ([yetone/magpie#2792](https://github.com/yetone/magpie/issues/2792)). Picked there, they show no picture: an agent such as pi has no place for an image in the model's reply, so the image the model draws is dropped, while the model believes it was shown. They take no tool calls either, and have 32K–128K context windows.
77
+ - **Drawing from the command line:** `magpie tool generate_image "a magpie on a branch, watercolour" --size 16:9 -o magpie.png`; add `--model ai-studio/gemini-3-pro-image` to draw with another model this once. If the terminal can't find the `magpie` command (step 2's command too), click **Add to PATH** on the **Command line** row of Magpie's Settings, then open a new terminal.
72
78
  - **Size:** `size` gives the aspect ratio. A ratio (such as `16:9` or `1:4`) is used as it is when the model takes it, else the nearest one it does; a size such as `1536x1024` is made the nearest usual ratio by Magpie first. `quality` makes no difference for these models.
73
- - **Don't tick them as chat models:** Magpie still keeps a plugin's models that draw in its chat model lists ([yetone/magpie#2792](https://github.com/yetone/magpie/issues/2792)). They take no tool calls and have 32K–128K context windows, so they make poor chat models for agents.
74
79
  - If you don't want the image models, turn them off with `studio.imageModels` (see the options below).
75
80
 
76
81
  ---
@@ -103,7 +108,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
103
108
  | :--- | :--- | :--- | :--- |
104
109
  | `allowTools` | `true` | Shared (override via `studio.allowTools`) | Allow tool calls. When `false`, requests with tools return 400 |
105
110
  | `allowImages` | Web: `false`; Studio: `true` | Shared (override via `studio.allowImages`) | Allow image input in a chat (an image model's reference images don't need it). Gemini Web reads images only when signed in and returns 400 otherwise; in testing, `gemini-3.6-flash` waited until the timeout on a chat with an image, while `gemini-3.5-flash-lite` and `gemini-3.8-flash` answered |
106
- | `requestTimeoutMs` / `modelTimeoutMs` | Web: `240000` / `10000`; Studio: `1800000` / `10000` | Shared (override via `studio.*`) | Timeout (ms) per backend wait or model list fetch; resets on stream data, explicit settings take precedence |
111
+ | `requestTimeoutMs` / `modelTimeoutMs` | Web: `570000` / `10000`; Studio: `1800000` / `10000` | Shared (override via `studio.*`) | Timeout (ms) per backend wait or model list fetch; resets on stream data, explicit settings take precedence |
107
112
  | `stripCitations` | `true` | `gemini-web` only | Strip `[cite: 1]` and `[cite_start]` markers from replies and tool arguments (not supported on AI Studio) |
108
113
  | `proxy` | none | `gemini-web` only | Proxy for the managed Gemini Web backend to reach Google (`http` / `https` / `socks5` / `socks5h`), or set `GEMINI_WEB_PROXY` env var / enter during sign-in |
109
114
  | `baseURL` | `http://127.0.0.1:8083/v1` | `gemini-web` only | Default Gemini Web backend URL |
@@ -164,7 +169,7 @@ When a plugin error asks you to run a command, it includes the full ready-to-cop
164
169
  - Both backends listen on `127.0.0.1` only. Cookies and sessions remain on your machine; Magpie stores only a locally generated API key.
165
170
  - **Gemini Web data directory:** `~/.local/share/magpie-gemini-web` (override with `GEMINI_WEB_DATA_DIR`), logs at `backend.log`. Supports macOS, Linux, and Windows (x64 / arm64).
166
171
  - **AI Studio data directory:** `~/.local/share/magpie-ai-studio` (override with `AI_STUDIO_DATA_DIR`), logs at `backend.log` (rotated on next start after exceeding 10 MB). Uses port 2048 by default (or picks a free port and saves it in `config.json`). Supports macOS, Linux (x64 / arm64), and Windows x64.
167
- - Concurrency is capped at 1, but Google account rate-limiting or flagging cannot be completely ruled out—use at your own risk.
172
+ - Gemini Web takes at most 2 requests at once per sign-in (AI Studio 1), adjustable in Magpie's provider settings. Google account rate-limiting or flagging cannot be completely ruled out—use at your own risk.
168
173
 
169
174
  **AI Studio upgrades and model selection:**
170
175
  - The plugin pins SHA-256-verified `v0.2.18-magpie.1`, based on upstream `v0.2.18`. Upgrade the plugin to update the backend. Official releases at or below that upstream version, and managed backends an older plugin installed, are upgraded on the next managed startup, preserving accounts, port and sign-in credentials.
@@ -177,7 +182,7 @@ When a plugin error asks you to run a command, it includes the full ready-to-cop
177
182
  - Models with `-build` or `assistant_route` are filtered out: they inject the Build coding assistant's own system prompt and are not offered as ordinary Agent models. Refresh with `magpie provider models ai-studio` after upgrading.
178
183
 
179
184
  **Gemini Web optimized backend upgrades & rollback:**
180
- - One-click setup uses [4.26.0-magpie.1](https://github.com/BUKOWSKIREAL/magpie-gemini-web/releases/tag/backend-v4.26.0-magpie.1) by default (built from pinned upstream v4.26.0 with XSRF caching, request cancellation propagation, early tool-call completion, and Argon model verification).
185
+ - One-click setup uses [4.26.0-magpie.2](https://github.com/BUKOWSKIREAL/magpie-gemini-web/releases/tag/backend-v4.26.0-magpie.2) by default (built from pinned upstream v4.26.0 with XSRF caching, request cancellation propagation, early tool-call completion, and Argon model verification). Chat requests are timed by silence: while Google queues a request it sends a heartbeat every 60 s, and the backend waits through it for the first words. A try is given up, and retried, only after Google has sent nothing for `request_timeout_sec` (180 s by default), so a long reply that keeps coming is never cut off either.
181
186
  - **Upgrading:** Running backends are not interrupted automatically. Stop the existing process (PID in `backend.pid` inside the data directory); the next Magpie launch or request will upgrade the binary while preserving config and cookies.
182
187
  - **Rollback to upstream / restore optimized build** (run in the plugin directory after stopping the backend):
183
188
  ```bash
package/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
  | **图片生成** | Nano Banana 系列模型,供 Magpie 的图片生成使用(需要 Magpie v0.1.2075+,见下文) | 不支持 |
22
22
  | **登录方式** | 弹出独立浏览器窗口直接登录 Google | 用无痕窗口登录并复制 `cookie` |
23
23
  | **额度卡片** | 显示冷却中的模型与恢复时间 | 同步网页版 **5 小时** 与 **每周** 用量及重置时间 |
24
- | **本地后端** | AIStudio2API (`v0.2.18`) | gemini-web2api-go (`4.26.0-magpie.1` 优化版) |
24
+ | **本地后端** | AIStudio2API (`v0.2.18`) | gemini-web2api-go (`4.26.0-magpie.2` 优化版) |
25
25
 
26
26
  ---
27
27
 
@@ -58,7 +58,7 @@
58
58
  - *旧版迁移(Gemini Web)*:若后端里已有早期版本添加的账号,在登录时选 **「添加本地后端里已有的 Google 账号」** 即可生成独立卡片,随后可删掉旧的 `API key …` 汇总卡片。
59
59
  - **账号池自动调度**:请求实际使用哪个账号由本地后端决定(Gemini Web 在账号池中轮流挑选;AI Studio 先用一个,进入冷却再切下一个)。在 Magpie 界面切换卡片不会改变后端的账号调度。
60
60
  - **用量刷新说明**:
61
- - `gemini-web`:仅一键配置的本地后端可读取用量。为减少向 Google 发请求,仅在账号被使用或窗口重置后低频刷新(最短间隔 10 分钟,闲置时最多 6 小时刷新一次)。
61
+ - `gemini-web`:仅一键配置的本地后端可读取用量。Magpie 每次来查都会向 Google 重新读取(同一账号 30 秒内只读一次),点刷新即显示最新额度,包括在 Gemini 官方 App、网页或其他设备上的用量。Magpie 自己来查的频率:用量页或菜单栏的额度面板开着时约每分钟一次;菜单栏显示任何一家额度时随菜单栏更新(默认每 3 分钟);经 Magpie 转发请求时最多每分钟一次;都没有时不查。读取失败后 10 分钟内不自动重试,这期间点刷新会立即重试,若未生效再点一次。
62
62
  - `ai-studio`:AI Studio 不提供剩余总额度,卡片会展示当前账号正在冷却的模型、恢复时间或重新登录提示。
63
63
 
64
64
  ---
@@ -67,10 +67,15 @@
67
67
 
68
68
  `ai-studio` 会列出账号可用的 Nano Banana 系列图像模型(如 `gemini-3.1-flash-image`、`gemini-3-pro-image`),供 Magpie 的图片生成使用。**需要 Magpie v0.1.2075 及以上**:从这个版本起,Magpie 才会把插件的 Gemini 图像模型列为出图模型。
69
69
 
70
- - **不用勾选,也不用在对话里选它们**:Magpie 设置的 **Images → Image generation** 会直接列出这些模型,选中一个,或保持 Automatic(自动)即可。
71
- - **Agent 怎么出图**:Agent 调用 Magpie 的 `generate_image` 工具(Library 里的 Magpie Image MCP),Magpie 用上面选的模型出图;带参考图就是改图。也可以在命令行试:`magpie tool generate_image "a magpie on a branch, watercolour" --size 16:9 -o magpie.png`。
70
+ **让 Agent 出图**(pi、Claude Code、Codex 等都一样):
71
+
72
+ 1. **选画图模型**:Magpie 设置的 **Images → Image generation** 会直接列出这些模型,选中一个(如 `ai-studio/gemini-nano-banana-2.1`),或保持 Automatic(自动)。不用在模型列表里勾选它们。
73
+ 2. **把画图工具交给 Agent**:点这一行下面的 **Give agents Magpie Image…**,在打开的 Library 里勾选要出图的 Agent,然后新开一个 Agent 会话。Library 被关掉时,这里显示的是 **Turn on the Library**,先点它。也可以用命令行:`magpie library mcp add magpie-image magpie mcp image agents=pi`(多个 Agent 用逗号隔开,如 `agents=pi,claude`)。
74
+ 3. **照常对话**:Agent 继续用平常的对话模型,跟它说「画一张……」就行。它会调用 `generate_image` 工具,用第 1 步选的模型出图,图片存进项目的 `generated-images/` 文件夹;带参考图就是改图。
75
+
76
+ - **别把图像模型选成对话模型**:Magpie 目前会把能出图的插件模型也留在对话模型列表里([yetone/magpie#2792](https://github.com/yetone/magpie/issues/2792))。选了也看不到图:pi 等 Agent 的回复里放不下图片,模型画出的图会被直接丢掉,模型却以为已经发出去了。它们也不支持工具调用,上下文只有 32K–128K。
77
+ - **在命令行出图**:`magpie tool generate_image "a magpie on a branch, watercolour" --size 16:9 -o magpie.png`,加 `--model ai-studio/gemini-3-pro-image` 可以临时换模型。终端里找不到 `magpie` 命令时(第 2 步的命令也一样),在 Magpie 设置的 **Command line** 一行点 **Add to PATH**,再开一个新终端。
72
78
  - **尺寸**:`size` 决定宽高比:写比例(如 `16:9`、`1:4`)时,模型支持就照用,不支持就取它支持的最接近的比例;写 `1536x1024` 这样的尺寸时,Magpie 先换成最接近的常用比例。`quality` 对这些模型不起作用。
73
- - **别把它们勾进对话模型**:Magpie 目前会把能出图的插件模型也留在对话模型列表里([yetone/magpie#2792](https://github.com/yetone/magpie/issues/2792))。它们不支持工具调用,上下文也只有 32K–128K,不适合当 Agent 的对话模型。
74
79
  - 不需要图像模型时,可以用 `studio.imageModels` 关掉(见下方选项)。
75
80
 
76
81
  ---
@@ -103,7 +108,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
103
108
  | :--- | :--- | :--- | :--- |
104
109
  | `allowTools` | `true` | 共用(可用 `studio.allowTools` 单独覆盖) | 允许工具调用。设为 `false` 时带工具的请求返回 400 |
105
110
  | `allowImages` | Web:`false`;Studio:`true` | 共用(可用 `studio.allowImages` 单独覆盖) | 允许对话中的图片输入(图像模型的参考图不受它限制)。Gemini Web 读图需要登录,未登录时返回 400;实测 `gemini-3.6-flash` 带图请求会一直等到超时,`gemini-3.5-flash-lite`、`gemini-3.8-flash` 正常 |
106
- | `requestTimeoutMs` / `modelTimeoutMs` | Web:`240000` / `10000`;Studio:`1800000` / `10000` | 共用(`studio.*` 可覆盖) | 单次等待后端 / 拉取模型列表的超时(毫秒);收到流数据后重新计时,显式配置优先 |
111
+ | `requestTimeoutMs` / `modelTimeoutMs` | Web:`570000` / `10000`;Studio:`1800000` / `10000` | 共用(`studio.*` 可覆盖) | 单次等待后端 / 拉取模型列表的超时(毫秒);收到流数据后重新计时,显式配置优先 |
107
112
  | `stripCitations` | `true` | 仅 `gemini-web` | 剥离回复与工具参数中的 `[cite: 1]`、`[cite_start]` 标记(AI Studio 不支持剥离) |
108
113
  | `proxy` | 无 | 仅 `gemini-web` | 一键配置后端访问 Google 的代理(`http` / `https` / `socks5` / `socks5h`),也可用环境变量 `GEMINI_WEB_PROXY` 或在登录第一步填写 |
109
114
  | `baseURL` | `http://127.0.0.1:8083/v1` | 仅 `gemini-web` | Gemini Web 默认后端地址 |
@@ -164,7 +169,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
164
169
  - 两个后端均只监听 `127.0.0.1`,Cookie 与登录态仅保存在本机,Magpie 仅保存本地生成的随机 API Key。
165
170
  - **Gemini Web 数据目录**:`~/.local/share/magpie-gemini-web`(可通过 `GEMINI_WEB_DATA_DIR` 修改),日志位于 `backend.log`。支持 macOS、Linux、Windows(x64 / arm64)。
166
171
  - **AI Studio 数据目录**:`~/.local/share/magpie-ai-studio`(可通过 `AI_STUDIO_DATA_DIR` 修改),日志位于 `backend.log`(超 10 MB 下次启动自动轮转)。默认使用 2048 端口,被占用时自动顺延并记录在 `config.json`。支持 macOS、Linux(x64 / arm64)与 Windows x64。
167
- - 插件将请求并发限制为 1,但仍无法完全排除 Google 账号风控风险,请自行评估使用。
172
+ - Gemini Web 每个登录同时最多 2 个请求(AI Studio 为 1),可在 Magpie 的 provider 设置里调整;仍无法完全排除 Google 账号风控风险,请自行评估使用。
168
173
 
169
174
  **AI Studio 升级与模型范围:**
170
175
  - 插件固定使用经过 SHA-256 校验的 `v0.2.18-magpie.1`(基于上游 `v0.2.18`),通过升级插件更新后端。同基线或更旧的官方版、以及旧版插件装的托管后端,在下次管理启动时自动升级,保留账号、端口与登录凭据。
@@ -177,7 +182,7 @@ magpie plugin options magpie-gemini-web '{"studio":{"proxy":"http://127.0.0.1:78
177
182
  - 模型列表过滤 `-build` / `assistant_route` 模型:这类模型附带 Build 写代码助手的系统提示词,不作为普通 Agent 模型提供。升级后可执行 `magpie provider models ai-studio` 刷新列表。
178
183
 
179
184
  **Gemini Web 优化后端升级与回退:**
180
- - 插件默认使用本仓库发布的 [4.26.0-magpie.1](https://github.com/BUKOWSKIREAL/magpie-gemini-web/releases/tag/backend-v4.26.0-magpie.1)(基于上游 v4.26.0 集成 XSRF 缓存、请求取消传播、工具调用提前收尾与 Argon 校验补丁)。
185
+ - 插件默认使用本仓库发布的 [4.26.0-magpie.2](https://github.com/BUKOWSKIREAL/magpie-gemini-web/releases/tag/backend-v4.26.0-magpie.2)(基于上游 v4.26.0 集成 XSRF 缓存、请求取消传播、工具调用提前收尾与 Argon 校验补丁)。对话请求按上游沉默计时:Google 让请求排队时每 60 秒发一次心跳,后端会一直等到出字;连续 `request_timeout_sec`(默认 180 秒)没有任何数据才放弃这一次并重试,还在出字的长回复也不会被截断。
181
186
  - **旧版升级**:插件不会打断运行中的后端。先停止旧进程(PID 见数据目录 `backend.pid`),下次启动 Magpie 或发请求时会自动更新二进制并保留原有配置与账号。
182
187
  - **回退官方版 / 恢复优化版**(停止后端后在插件目录执行):
183
188
  ```bash
package/index.mjs CHANGED
@@ -102,7 +102,11 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
102
102
  auth: {
103
103
  provider: ID,
104
104
  icon: PLUGIN_ICON,
105
- maxConcurrency: 1,
105
+ // Two at once per sign-in, as a person with two Gemini tabs: an agent's first turn and
106
+ // the session title it asks for alongside no longer wait one behind the other, which
107
+ // Gemini Web can take minutes to answer (#33). The backend still caps each way out to
108
+ // Google (per-IP limits), and Magpie's own setting on the provider goes over this.
109
+ maxConcurrency: 2,
106
110
  methods: [{
107
111
  type: 'oauth',
108
112
  label: '一键配置本地 Gemini Web(推荐)',
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "4.26.0-magpie.1",
2
+ "version": "4.26.0-magpie.2",
3
3
  "repository": "BUKOWSKIREAL/magpie-gemini-web",
4
- "tag": "backend-v4.26.0-magpie.1",
4
+ "tag": "backend-v4.26.0-magpie.2",
5
5
  "upstream": {
6
6
  "repository": "https://github.com/zexadev/gemini-web2api-go.git",
7
7
  "commit": "7ce02d89512058b2bd147fb2dc4e40187f5135ad"
@@ -9,28 +9,28 @@
9
9
  "goVersion": "1.27.1",
10
10
  "binaries": {
11
11
  "darwin-arm64": {
12
- "asset": "gemini-web2api-go_v4.26.0-magpie.1_darwin_arm64",
13
- "sha256": "ae2581dfba30d7566c2c55cd53b0b65c56aab5e5f592ccac983a65c4444f069e"
12
+ "asset": "gemini-web2api-go_v4.26.0-magpie.2_darwin_arm64",
13
+ "sha256": "2407896cf76917499cca4cc4c932a774ba65cb6ccbf8b6901fb2c47a29c89e41"
14
14
  },
15
15
  "darwin-x64": {
16
- "asset": "gemini-web2api-go_v4.26.0-magpie.1_darwin_amd64",
17
- "sha256": "e220a5c53284c0c17ca9bc1a3e454448c95b84155fc460c17e0c8fcbae3b9d88"
16
+ "asset": "gemini-web2api-go_v4.26.0-magpie.2_darwin_amd64",
17
+ "sha256": "73774ee8a4687baa5c39cb1911f038e2293326c876e373745385a41378622b3d"
18
18
  },
19
19
  "linux-x64": {
20
- "asset": "gemini-web2api-go_v4.26.0-magpie.1_linux_amd64",
21
- "sha256": "cbbb9784823301f4f90e44d33a087d4d745770f4f35d77735fbae370087a7a43"
20
+ "asset": "gemini-web2api-go_v4.26.0-magpie.2_linux_amd64",
21
+ "sha256": "b320b050916090ab34e7025596a1daf8d2cb83880908b38786b24c0d3d621c74"
22
22
  },
23
23
  "linux-arm64": {
24
- "asset": "gemini-web2api-go_v4.26.0-magpie.1_linux_arm64",
25
- "sha256": "cf503f30ed3cfd358de926cf58b455b9ce7951dfa51b24584bd11559e9cfe7c0"
24
+ "asset": "gemini-web2api-go_v4.26.0-magpie.2_linux_arm64",
25
+ "sha256": "9ac7e4feed66a17d1719b2a0057130098640ee0f6cea2d6192e6938bd4f5259d"
26
26
  },
27
27
  "win32-x64": {
28
- "asset": "gemini-web2api-go_v4.26.0-magpie.1_windows_amd64.exe",
29
- "sha256": "e0d0bc52acd1c0c7dc7d1bdc8eb3da09dee2be8ed9a36173ab3be1bf4c58f667"
28
+ "asset": "gemini-web2api-go_v4.26.0-magpie.2_windows_amd64.exe",
29
+ "sha256": "16574f5863dae4dc806e1066c9bd515161c9250cbc7e949d8b8e46eca072628a"
30
30
  },
31
31
  "win32-arm64": {
32
- "asset": "gemini-web2api-go_v4.26.0-magpie.1_windows_arm64.exe",
33
- "sha256": "d038049af66365f0a6ab634664ca3d3715dd26e42d67f4c7f0b4f2ccba64f143"
32
+ "asset": "gemini-web2api-go_v4.26.0-magpie.2_windows_arm64.exe",
33
+ "sha256": "8bcefc92a56d26736651cd009c15bf4c0858e41a04f65a0d83d06df7f94296b3"
34
34
  }
35
35
  }
36
36
  }
package/lib/backend.mjs CHANGED
@@ -165,7 +165,13 @@ export function settings(options = {}) {
165
165
  throw new Error('Invalid option outputLimit: cannot exceed contextLimit');
166
166
  }
167
167
 
168
- let requestTimeoutMs = 240000;
168
+ // The backend sends nothing until a reply's first words, and Gemini Web can queue a request
169
+ // for minutes before them (3.6 Flash: 48 s to 6.5 minutes), sending the backend only a heartbeat
170
+ // a minute, which it waits through. The plugin can't tell that queue from a stuck backend, so
171
+ // it waits as long as it may: under the 10 minutes Magpie gives a provider to begin answering,
172
+ // so this timeout reaches the client rather than Magpie's. That also outlasts
173
+ // 4.26.0-magpie.1's three tries of 180 s each, 2 s apart: 544 s (#33).
174
+ let requestTimeoutMs = 570000;
169
175
  if (options.requestTimeoutMs !== undefined) {
170
176
  if (!Number.isInteger(options.requestTimeoutMs) || options.requestTimeoutMs <= 0) {
171
177
  throw new Error('Invalid option requestTimeoutMs: must be a positive integer');
package/lib/usage.mjs CHANGED
@@ -35,14 +35,26 @@ const CLIENT_HEADERS = {
35
35
  'Sec-CH-UA-Platform': '"Windows"'
36
36
  };
37
37
 
38
- // When an account's pools are read again. They move only while the account is used, which the
39
- // backend records (usedAt, see poolAccounts), or when a window starts over; use from the Gemini
40
- // app itself is caught by USAGE_MAX_AGE_MS. No two reads of an account are closer than
41
- // USAGE_MIN_GAP_MS: Magpie set to read allowances automatically asks every few minutes, far
42
- // more often than a person opens the Usage page. A failed read stands that long too, so a
43
- // cookie Google turned away isn't asked about again on every refresh.
44
- export const USAGE_MIN_GAP_MS = 10 * 60 * 1000;
45
- export const USAGE_MAX_AGE_MS = 6 * 3600 * 1000;
38
+ // When an account's pools are read again: whenever Magpie asks, once the last reading is
39
+ // USAGE_FRESH_MS old. Magpie keeps an account's reading a minute itself (loginUsageCache in its
40
+ // internal/provider/login_usage.go) and dates the card by when it got it, so a reading kept here
41
+ // any longer would show old figures as new, and a refresh would show them again (#34); use from
42
+ // the Gemini app, the website or another device leaves no trace here to read again on. On its
43
+ // own Magpie asks about once a minute while its Usage page or tray panel is open, whenever it
44
+ // brings the menu bar's allowances up to date (every 3 minutes unless set otherwise) and, a
45
+ // minute apart, while it routes requests among the accounts; it asks sooner only when it puts
46
+ // its minute aside. USAGE_FRESH_MS takes in asks that come together, a refresh pressed twice; a
47
+ // read under way is shared.
48
+ // A failed read stands USAGE_RETRY_MS, so a cookie Google turned away, or an account it is
49
+ // limiting, isn't asked about again every minute. Magpie asking again within
50
+ // USAGE_ASKED_AGAIN_MS of the last time tries it again at once: it does that only when it put
51
+ // its minute aside, for a card's refresh button, the Usage page or tray panel brought up or
52
+ // refreshed, `magpie quota` or a sign-in check. Its hook doesn't tell a refresh (auth.usage is
53
+ // given getAuth and the provider alone), so one pressed later than that tries again only when
54
+ // pressed once more.
55
+ export const USAGE_FRESH_MS = 30 * 1000;
56
+ export const USAGE_RETRY_MS = 10 * 60 * 1000;
57
+ export const USAGE_ASKED_AGAIN_MS = 60 * 1000;
46
58
 
47
59
  // The pools the Usage page shows, under the names Magpie gives the same windows elsewhere.
48
60
  const POOLS = [
@@ -64,17 +76,13 @@ async function openReadOnly(file) {
64
76
  }
65
77
 
66
78
  // poolAccounts lists the enabled accounts in the backend's pool, each with the proxy it is
67
- // bound to, if any, and whether that proxy is enabled (proxyEnabled), whether the pool has
68
- // proxies at all (pooled: disabled ones count, as they do for the backend), and when the
69
- // backend last used it (usedAt, Unix seconds): the later of when it last took the account for
70
- // a request and when it last finished one.
79
+ // bound to, if any, and whether that proxy is enabled (proxyEnabled), and whether the pool has
80
+ // proxies at all (pooled: disabled ones count, as they do for the backend).
71
81
  export async function poolAccounts(dataDir = getDataDir()) {
72
82
  const db = await openReadOnly(path.join(dataDir, 'gemini.db'));
73
83
  try {
74
84
  return db.all(`SELECT a.id, a.label, a.cookie, p.url AS proxy, p.enabled AS proxyEnabled,
75
- EXISTS (SELECT 1 FROM proxies) AS pooled,
76
- MAX(a.last_used_at, COALESCE((SELECT MAX(r.ts) FROM requests r
77
- WHERE r.ts >= a.last_used_at AND r.account_id = a.id), 0)) AS usedAt
85
+ EXISTS (SELECT 1 FROM proxies) AS pooled
78
86
  FROM accounts a LEFT JOIN proxies p ON p.id = a.proxy_id
79
87
  WHERE a.status = 'enabled' ORDER BY a.id`);
80
88
  } finally {
@@ -160,42 +168,48 @@ export async function accountUsage(account, options = {}) {
160
168
  }, options));
161
169
  }
162
170
 
163
- // The last read of each account: id -> { at, read, pools }, pools once it has succeeded.
171
+ // The last read of each account: id -> { at, read, pools, failed }, pools once it has
172
+ // succeeded, failed once it hasn't, neither while it is under way.
164
173
  const reads = new Map();
174
+ // When Magpie last asked for each account's usage: id -> ms.
175
+ const asked = new Map();
165
176
 
166
- // due says whether the account's last read is to be made again (see USAGE_MIN_GAP_MS).
167
- function due(last, account, options) {
168
- const now = Date.now();
169
- const age = now - last.at;
170
- if (age < (options.minGapMs ?? USAGE_MIN_GAP_MS)) return false;
171
- if (!last.pools || age >= (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return true;
172
- if ((account.usedAt ?? 0) >= Math.floor(last.at / 1000)) return true;
173
- return last.pools.some(p => {
174
- const at = Date.parse(p?.gxu?.windowRefreshesAt ?? '');
175
- return at > last.at && at <= now;
176
- });
177
+ // due says whether the account's last read is to be made again (see USAGE_FRESH_MS); one under
178
+ // way never is. again: Magpie asked within USAGE_ASKED_AGAIN_MS of the time before.
179
+ function due(last, again, options) {
180
+ const age = Date.now() - last.at;
181
+ const fresh = options.freshMs ?? USAGE_FRESH_MS;
182
+ if (last.pools) return age >= fresh;
183
+ return !!last.failed && age >= (again ? fresh : (options.retryMs ?? USAGE_RETRY_MS));
177
184
  }
178
185
 
179
186
  function readUsage(account, options) {
187
+ const now = Date.now();
188
+ const again = now - asked.get(account.id) < (options.askedAgainMs ?? USAGE_ASKED_AGAIN_MS);
189
+ asked.set(account.id, now);
180
190
  const last = reads.get(account.id);
181
- if (last && !due(last, account, options)) return last.read;
182
- const entry = { at: Date.now() };
183
- entry.read = accountUsage(account, options).then(u => ((entry.pools = u.pools), u));
191
+ if (last && !due(last, again, options)) return last.read;
192
+ const entry = { at: now };
193
+ entry.read = accountUsage(account, options).then(
194
+ u => ((entry.pools = u.pools), u),
195
+ err => { entry.failed = true; throw err; }
196
+ );
184
197
  reads.set(account.id, entry);
185
198
  return entry.read;
186
199
  }
187
200
 
188
201
  // The Google account's email, from the signed-in app page's bootstrap data (WIZ_global_data's
189
202
  // oPEP7c), kept per backend account: the page is large and the email doesn't change. A page
190
- // that didn't give it is asked for again no sooner than USAGE_MAX_AGE_MS: the email only names
203
+ // that didn't give it is asked for again no sooner than EMAIL_RETRY_MS: the email only names
191
204
  // a card. An account with no certain way out (egress) isn't asked yet, and that isn't a try.
192
205
  const emails = new Map(); // id -> { email }, or { at } since the last try that got none
193
206
  const EMAIL_FIELD = /"oPEP7c":"([^"@\s]+@[^"\s]+)"/;
207
+ const EMAIL_RETRY_MS = 6 * 3600 * 1000;
194
208
 
195
209
  export async function accountEmail(account, options = {}) {
196
210
  const known = emails.get(account.id);
197
211
  if (known?.email) return known.email;
198
- if (known && Date.now() - known.at < (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return undefined;
212
+ if (known && Date.now() - known.at < (options.emailRetryMs ?? EMAIL_RETRY_MS)) return undefined;
199
213
  egress(account, options);
200
214
  emails.set(account.id, { at: Date.now() });
201
215
  const page = await geminiRequest('https://gemini.google.com/app', account, {
@@ -250,7 +264,7 @@ export function poolWindows(read) {
250
264
  // usage is the Magpie usage card: one account's 5-hour and weekly windows, as
251
265
  // gemini.google.com/usage shows them, when the sign-in names its backend account; else the
252
266
  // whole pool's, as one card. Accounts go by their Google emails (#id until that is known).
253
- // An account is read from Google again only when due (see USAGE_MIN_GAP_MS).
267
+ // An account is read from Google again only when due (see USAGE_FRESH_MS).
254
268
  // Magpie hides a card's windows behind an error, so a pool account that can't be read only
255
269
  // says so in the tooltip while the others still show.
256
270
  export async function usage(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Gemini Web and Google AI Studio provider for Magpie, backed by gemini-web2api-go and AIStudio2API",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -83,6 +83,6 @@
83
83
  },
84
84
  "magpie": {
85
85
  "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImEiIHgxPSIyIiB5MT0iMyIgeDI9IjIwIiB5Mj0iMjEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjMDZCNkQ0Ii8+PHN0b3Agb2Zmc2V0PSI1MCUiIHN0b3AtY29sb3I9IiMzQjgyRjYiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM4QjVDRjYiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYiIgeDE9IjE0IiB5MT0iMiIgeDI9IjIyIiB5Mj0iMTAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjOEI1Q0Y2Ii8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjRUM0ODk5Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTEwIDMuMkMxMC4zNSAzLjIgMTAuNjIgMy40NSAxMC43MiAzLjhDMTEuNDUgNi44NSAxMy4xNSA4LjU1IDE2LjIgOS4yOEMxNi41NSA5LjM4IDE2LjggOS42NSAxNi44IDEwQzE2LjggMTAuMzUgMTYuNTUgMTAuNjIgMTYuMiAxMC43MkMxMy4xNSAxMS40NSAxMS40NSAxMy4xNSAxMC43MiAxNi4yQzEwLjYyIDE2LjU1IDEwLjM1IDE2LjggMTAgMTYuOEM5LjY1IDE2LjggOS4zOCAxNi41NSA5LjI4IDE2LjJDOC41NSAxMy4xNSA2Ljg1IDExLjQ1IDMuOCAxMC43MkMzLjQ1IDEwLjYyIDMuMiAxMC4zNSAzLjIgMTBDMy4yIDkuNjUgMy40NSA5LjM4IDMuOCA5LjI4QzYuODUgOC41NSA4LjU1IDYuODUgOS4yOCAzLjhDOS4zOCAzLjQ1IDkuNjUgMy4yIDEwIDMuMloiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0wLjUgMi41KSBzY2FsZSgxLjEyKSIgZmlsbD0idXJsKCNhKSIvPjxwYXRoIGQ9Ik0xOC4yIDEuOEMxOC40MiAxLjggMTguNiAxLjk1IDE4LjY2IDIuMTZDMTkuMDggMy44MiAyMC4wMiA0Ljc2IDIxLjY4IDUuMThDMjEuODkgNS4yNCAyMi4wNCA1LjQyIDIyLjA0IDUuNjRDMjIuMDQgNS44NiAyMS44OSA2LjA0IDIxLjY4IDYuMUMyMC4wMiA2LjUyIDE5LjA4IDcuNDYgMTguNjYgOS4xMkMxOC42IDkuMzMgMTguNDIgOS40OCAxOC4yIDkuNDhDMTcuOTggOS40OCAxNy44IDkuMzMgMTcuNzQgOS4xMkMxNy4zMiA3LjQ2IDE2LjM4IDYuNTIgMTQuNzIgNi4xQzE0LjUxIDYuMDQgMTQuMzYgNS44NiAxNC4zNiA1LjY0QzE0LjM2IDUuNDIgMTQuNTEgNS4yNCAxNC43MiA1LjE4QzE2LjM4IDQuNzYgMTcuMzIgMy44MiAxNy43NCAyLjE2QzE3LjggMS45NSAxNy45OCAxLjggMTguMiAxLjhaIiBmaWxsPSJ1cmwoI2IpIi8+PGNpcmNsZSBjeD0iMTcuOCIgY3k9IjE4LjIiIHI9IjEuMzUiIGZpbGw9IiM2MzY2RjEiLz48L3N2Zz4=",
86
- "maxConcurrency": 1
86
+ "maxConcurrency": 2
87
87
  }
88
88
  }
@@ -1,3 +1,23 @@
1
+ diff --git a/go.mod b/go.mod
2
+ index c6a75e2..db2c8f5 100644
3
+ --- a/go.mod
4
+ +++ b/go.mod
5
+ @@ -9,6 +9,7 @@ require (
6
+ github.com/google/uuid v1.6.0
7
+ github.com/lib/pq v1.12.3
8
+ github.com/pkoukk/tiktoken-go v0.1.8
9
+ + golang.org/x/net v0.59.0
10
+ modernc.org/sqlite v1.51.0
11
+ )
12
+
13
+ @@ -29,7 +30,6 @@ require (
14
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
15
+ github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
16
+ golang.org/x/crypto v0.57.0 // indirect
17
+ - golang.org/x/net v0.59.0 // indirect
18
+ golang.org/x/sys v0.48.0 // indirect
19
+ golang.org/x/text v0.42.0 // indirect
20
+ modernc.org/libc v1.72.3 // indirect
1
21
  diff --git a/internal/app/admin_cookies.go b/internal/app/admin_cookies.go
2
22
  index f17a675..c90f08a 100644
3
23
  --- a/internal/app/admin_cookies.go
@@ -50,6 +70,20 @@ index f17a675..c90f08a 100644
50
70
  if p.Label != nil || p.Note != nil {
51
71
  // 只改传了的字段:没传的沿用当前值。
52
72
  var curLabel, curNote string
73
+ diff --git a/internal/app/admin_ui/index.html b/internal/app/admin_ui/index.html
74
+ index a765bb1..eb073b6 100644
75
+ --- a/internal/app/admin_ui/index.html
76
+ +++ b/internal/app/admin_ui/index.html
77
+ @@ -1020,7 +1020,8 @@ const RT_GROUPS = [
78
+ { k: 'retry_attempts', label: '重试次数', type: 'int', min: 1, max: 10,
79
+ hint: '含首次;流式已吐字后不再重试' },
80
+ { k: 'retry_delay_sec', label: '重试间隔(秒)', type: 'int', min: 0, max: 60 },
81
+ - { k: 'request_timeout_sec', label: '上游超时(秒)', type: 'int', min: 5, max: 600 },
82
+ + { k: 'request_timeout_sec', label: '上游超时(秒)', type: 'int', min: 5, max: 600,
83
+ + hint: '对话请求按上游沉默计时:这么久一个字节都没来才放弃这一次(没出字前会重试)。排队时上游每 60 秒发一次心跳,回复一直在出也不会截断。其它请求(取页面、上传等)仍是整次请求的上限' },
84
+ ],
85
+ },
86
+ {
53
87
  diff --git a/internal/app/argon.go b/internal/app/argon.go
54
88
  new file mode 100644
55
89
  index 0000000..a2a5e77
@@ -939,10 +973,165 @@ index 0000000..f36c700
939
973
  + t.Fatal("没有记录这次请求")
940
974
  +}
941
975
  diff --git a/internal/app/client.go b/internal/app/client.go
942
- index c63b3a9..de635f7 100644
976
+ index c63b3a9..da8ccb8 100644
943
977
  --- a/internal/app/client.go
944
978
  +++ b/internal/app/client.go
945
- @@ -200,6 +200,37 @@ func currentBrowserUA() string {
979
+ @@ -1,9 +1,11 @@
980
+ package app
981
+
982
+ import (
983
+ + "context"
984
+ "crypto/sha1"
985
+ "encoding/hex"
986
+ "fmt"
987
+ + "net"
988
+ "net/http"
989
+ "net/url"
990
+ "os"
991
+ @@ -11,8 +13,10 @@ import (
992
+ "sync"
993
+ "time"
994
+
995
+ + fhttp "github.com/bogdanfinn/fhttp"
996
+ tls_client "github.com/bogdanfinn/tls-client"
997
+ "github.com/bogdanfinn/tls-client/profiles"
998
+ + "golang.org/x/net/proxy"
999
+ )
1000
+
1001
+ var (
1002
+ @@ -56,10 +60,20 @@ func resolveProfile(name string) profiles.ClientProfile {
1003
+ // For proxied connections we use stdlib (see getStdlibClient) — tls-client's SOCKS5
1004
+ // implementation is fragile compared to net/http.
1005
+ func getTLSClient() tls_client.HttpClient {
1006
+ + return tlsClientWithin(rtCfg().RequestTimeout)
1007
+ +}
1008
+ +
1009
+ +// getTLSStreamClient 同 getTLSClient,但不限整次请求的时长,给 StreamGenerate 用:
1010
+ +// 还在出字的回复可以比 request_timeout_sec 长,上游沉默太久由 stallWatch 掐掉。
1011
+ +func getTLSStreamClient() tls_client.HttpClient {
1012
+ + return tlsClientWithin(0)
1013
+ +}
1014
+ +
1015
+ +// tlsClientWithin 是整次请求限时 timeout 秒(0 = 不限)的 tls-client。
1016
+ +func tlsClientWithin(timeout int) tls_client.HttpClient {
1017
+ imp := effectiveImpersonate()
1018
+ // 缓存 key 带上超时:超时是建客户端时定死的,面板改了 request_timeout_sec
1019
+ // 要换一个新客户端才生效(只按 imp 缓存时要重启进程才生效)。
1020
+ - timeout := rtCfg().RequestTimeout
1021
+ key := fmt.Sprintf("%s|%d", imp, timeout)
1022
+ clientCacheMu.RLock()
1023
+ if c, ok := clientCache[key]; ok {
1024
+ @@ -73,12 +87,7 @@ func getTLSClient() tls_client.HttpClient {
1025
+ if c, ok := clientCache[key]; ok {
1026
+ return c
1027
+ }
1028
+ - opts := []tls_client.HttpClientOption{
1029
+ - tls_client.WithTimeoutSeconds(timeout),
1030
+ - tls_client.WithClientProfile(resolveProfile(imp)),
1031
+ - tls_client.WithNotFollowRedirects(),
1032
+ - }
1033
+ - client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), opts...)
1034
+ + client, err := newTLSClient(imp, timeout)
1035
+ if err != nil {
1036
+ fmt.Fprintf(os.Stderr, "[client] tls-client init failed: %v\n", err)
1037
+ os.Exit(1)
1038
+ @@ -87,6 +96,67 @@ func getTLSClient() tls_client.HttpClient {
1039
+ return client
1040
+ }
1041
+
1042
+ +// newTLSClient 新建一个指纹 imp、整次请求限时 timeout 秒(0 = 不限)的 tls-client,
1043
+ +// 拨号用 tlsDialer(见下)。测试在 extra 里加上不校验证书,别的配置跟线上同一套。
1044
+ +func newTLSClient(imp string, timeout int, extra ...tls_client.HttpClientOption) (tls_client.HttpClient, error) {
1045
+ + opts := append([]tls_client.HttpClientOption{
1046
+ + tls_client.WithTimeoutSeconds(timeout),
1047
+ + tls_client.WithClientProfile(resolveProfile(imp)),
1048
+ + tls_client.WithNotFollowRedirects(),
1049
+ + tls_client.WithProxyDialerFactory(newTLSDialer),
1050
+ + }, extra...)
1051
+ + return tls_client.NewHttpClient(tls_client.NewNoopLogger(), opts...)
1052
+ +}
1053
+ +
1054
+ +// ─── tls-client 的拨号 ───────────────────────────────────────────────────────
1055
+ +//
1056
+ +// tls-client 的 HTTP/2 连接断了(空闲被关、对端 GOAWAY)之后,下一个请求要重新拨号,
1057
+ +// 这一步却不带请求的 ctx:tls-client 的 dialTLSHTTP2 传的是 context.Background(),fhttp
1058
+ +// 的连接池也只是干等拨号结束。整次请求的超时、stallWatch、客户端断开都管不到它;拨号时
1059
+ +// 它还占着这个客户端的锁,同一个客户端里要新建连接的请求都得等它。tls-client 自带的
1060
+ +// 拨号器只拿整次请求的超时去限 TCP 连接(不限时的客户端就完全不限),TLS 握手从来不限:
1061
+ +// 对方连上了却不回话,这个客户端就一直卡死。
1062
+ +//
1063
+ +// 所以换成自己的拨号器,两步各自限时,数值同 Go 标准库 http.DefaultTransport 的拨号、
1064
+ +// 握手超时:TCP 30 秒连不上算失败;连上后对方 10 秒内一个字节都没回(握手没有回音)就
1065
+ +// 断开。tls-client 只连 Google 的 https,对方回的第一个字节总是握手。
1066
+ +var (
1067
+ + dialTimeout = 30 * time.Second
1068
+ + handshakeTimeout = 10 * time.Second
1069
+ +)
1070
+ +
1071
+ +// newTLSDialer 当 tls-client 的 ProxyDialerFactory 用。我们不给 tls-client 配代理(走代理
1072
+ +// 用 stdlib,见下),它就只管直连,tls-client 传进来的参数都用不上。
1073
+ +func newTLSDialer(string, time.Duration, *net.TCPAddr, fhttp.Header, tls_client.Logger) (proxy.ContextDialer, error) {
1074
+ + return tlsDialer{}, nil
1075
+ +}
1076
+ +
1077
+ +type tlsDialer struct{}
1078
+ +
1079
+ +func (tlsDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
1080
+ + d := net.Dialer{Timeout: dialTimeout}
1081
+ + conn, err := d.DialContext(ctx, network, addr)
1082
+ + if err != nil {
1083
+ + return nil, err
1084
+ + }
1085
+ + conn.SetReadDeadline(time.Now().Add(handshakeTimeout))
1086
+ + return &answeredConn{Conn: conn}, nil
1087
+ +}
1088
+ +
1089
+ +// answeredConn 在对方头一回回话时撤掉拨号时设的读超时,之后这条连接怎么用都不再限时。
1090
+ +type answeredConn struct {
1091
+ + net.Conn
1092
+ + once sync.Once
1093
+ +}
1094
+ +
1095
+ +func (c *answeredConn) Read(p []byte) (int, error) {
1096
+ + n, err := c.Conn.Read(p)
1097
+ + if n > 0 {
1098
+ + c.once.Do(func() { c.Conn.SetReadDeadline(time.Time{}) })
1099
+ + }
1100
+ + return n, err
1101
+ +}
1102
+ +
1103
+ // ─── Stdlib HTTP client for proxied requests ─────────────────────────────────
1104
+ //
1105
+ // 走代理时改用 stdlib 而不是 tls-client,因为:
1106
+ @@ -112,13 +182,26 @@ var (
1107
+ // getStdlibClient returns an http.Client routed through proxyURL.
1108
+ // proxyURL must not be empty (caller checks).
1109
+ func getStdlibClient(proxyURL string) *http.Client {
1110
+ + return stdlibClientWithin(proxyURL, rtCfg().RequestTimeout)
1111
+ +}
1112
+ +
1113
+ +// getStdlibStreamClient 同 getStdlibClient,但不限整次请求的时长(见 getTLSStreamClient)。
1114
+ +func getStdlibStreamClient(proxyURL string) *http.Client {
1115
+ + return stdlibClientWithin(proxyURL, 0)
1116
+ +}
1117
+ +
1118
+ +// stdlibClientWithin 是走 proxyURL、整次请求限时 timeout 秒(0 = 不限)的 http.Client。
1119
+ +func stdlibClientWithin(proxyURL string, timeout int) *http.Client {
1120
+ // 同 getTLSClient:超时进 key,面板改超时后换新客户端。
1121
+ - timeout := rtCfg().RequestTimeout
1122
+ key := fmt.Sprintf("%s|%d", proxyURL, timeout)
1123
+ if cached, ok := stdlibClientCache.Load(key); ok {
1124
+ return cached.(*http.Client)
1125
+ }
1126
+ t := &http.Transport{
1127
+ + // 跟 Google 的 TLS 握手限时,同 tlsDialer:对话请求不限整次时长,握手没回音时
1128
+ + // 不必等满 stallWatch 的上限。连代理那步不另外限:配了 DialContext,Transport 会
1129
+ + // 悄悄关掉 HTTP/2;它本来就带着请求的 ctx,stallWatch 管得到。
1130
+ + TLSHandshakeTimeout: handshakeTimeout,
1131
+ MaxIdleConns: 100,
1132
+ MaxIdleConnsPerHost: 20,
1133
+ IdleConnTimeout: 90 * time.Second,
1134
+ @@ -200,6 +283,37 @@ func currentBrowserUA() string {
946
1135
  return ChromeUA
947
1136
  }
948
1137
 
@@ -980,7 +1169,7 @@ index c63b3a9..de635f7 100644
980
1169
  // applyChromeHeaders 给 stdlib 请求填上应用层 header(根据 impersonate 配置自动适配 Chrome 或 Firefox)。
981
1170
  func applyChromeHeaders(req *http.Request) {
982
1171
  applyBrowserHeaders(req)
983
- @@ -207,21 +238,5 @@ func applyChromeHeaders(req *http.Request) {
1172
+ @@ -207,21 +321,5 @@ func applyChromeHeaders(req *http.Request) {
984
1173
 
985
1174
  // applyBrowserHeaders 给 stdlib 请求填上对应 impersonate 配置的应用层 header。
986
1175
  func applyBrowserHeaders(req *http.Request) {
@@ -1003,6 +1192,119 @@ index c63b3a9..de635f7 100644
1003
1192
  - req.Header.Set("Sec-Fetch-Site", "same-origin")
1004
1193
  + setChromeHeaders(req.Header)
1005
1194
  }
1195
+ diff --git a/internal/app/client_timeout_test.go b/internal/app/client_timeout_test.go
1196
+ index 94c0b70..2a4b697 100644
1197
+ --- a/internal/app/client_timeout_test.go
1198
+ +++ b/internal/app/client_timeout_test.go
1199
+ @@ -1,6 +1,12 @@
1200
+ package app
1201
+
1202
+ import (
1203
+ + "context"
1204
+ + "errors"
1205
+ + "net"
1206
+ + "net/http"
1207
+ + "net/http/httptest"
1208
+ + "sync"
1209
+ "testing"
1210
+ "time"
1211
+ )
1212
+ @@ -27,3 +33,95 @@ func TestClientCacheFollowsTimeout(t *testing.T) {
1213
+ t.Fatal("tls client nil")
1214
+ }
1215
+ }
1216
+ +
1217
+ +// tls-client 的 HTTP/2 连接断了要重拨时,这一步不带请求的 ctx(见 tlsDialer):对方连上了
1218
+ +// 却不回话,以前请求就一直卡着,stallWatch 和客户端断开都掐不掉。
1219
+ +func TestTLSRedialGivesUpOnASilentServer(t *testing.T) {
1220
+ + prev := handshakeTimeout
1221
+ + handshakeTimeout = 200 * time.Millisecond
1222
+ + t.Cleanup(func() { handshakeTimeout = prev })
1223
+ + useInsecureTLSClients(t)
1224
+ +
1225
+ + ln, err := net.Listen("tcp", "127.0.0.1:0")
1226
+ + if err != nil {
1227
+ + t.Fatal(err)
1228
+ + }
1229
+ + quiet := &quietAfterFirst{Listener: ln}
1230
+ + t.Cleanup(quiet.closeHeld)
1231
+ + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1232
+ + w.WriteHeader(200)
1233
+ + }))
1234
+ + srv.Listener.Close()
1235
+ + srv.Listener = quiet
1236
+ + srv.EnableHTTP2 = true
1237
+ + srv.StartTLS()
1238
+ + t.Cleanup(srv.Close)
1239
+ +
1240
+ + send := func() error {
1241
+ + _, _, _, _, err := doGeminiRequestStop(context.Background(), srv.URL+"/StreamGenerate", "f.req=x",
1242
+ + map[string]string{}, "", nil, nil)
1243
+ + return err
1244
+ + }
1245
+ + if err := send(); err != nil {
1246
+ + t.Fatalf("头一个请求就失败了:%v", err)
1247
+ + }
1248
+ + srv.CloseClientConnections() // 断掉这条 HTTP/2 连接,下一个请求只能重拨
1249
+ + time.Sleep(50 * time.Millisecond) // 等客户端看到连接断了
1250
+ +
1251
+ + done := make(chan error, 1)
1252
+ + go func() { done <- send() }()
1253
+ + select {
1254
+ + case err := <-done:
1255
+ + var ne net.Error
1256
+ + if !errors.As(err, &ne) || !ne.Timeout() {
1257
+ + t.Fatalf("对方不回话应报超时,实际 %v", err)
1258
+ + }
1259
+ + case <-time.After(5 * time.Second):
1260
+ + t.Fatal("重拨时对方不回话,请求一直卡着")
1261
+ + }
1262
+ + if n := quiet.accepted(); n != 2 {
1263
+ + t.Fatalf("应该重拨一次,实际连上来 %d 次", n)
1264
+ + }
1265
+ +}
1266
+ +
1267
+ +// quietAfterFirst 只把第一条连接交给服务器,之后连上来的都留着不理:TCP 连得上,
1268
+ +// TLS 握手没有回音。
1269
+ +type quietAfterFirst struct {
1270
+ + net.Listener
1271
+ + mu sync.Mutex
1272
+ + n int
1273
+ + held []net.Conn
1274
+ +}
1275
+ +
1276
+ +func (l *quietAfterFirst) Accept() (net.Conn, error) {
1277
+ + for {
1278
+ + c, err := l.Listener.Accept()
1279
+ + if err != nil {
1280
+ + return nil, err
1281
+ + }
1282
+ + l.mu.Lock()
1283
+ + l.n++
1284
+ + first := l.n == 1
1285
+ + if !first {
1286
+ + l.held = append(l.held, c)
1287
+ + }
1288
+ + l.mu.Unlock()
1289
+ + if first {
1290
+ + return c, nil
1291
+ + }
1292
+ + }
1293
+ +}
1294
+ +
1295
+ +func (l *quietAfterFirst) accepted() int {
1296
+ + l.mu.Lock()
1297
+ + defer l.mu.Unlock()
1298
+ + return l.n
1299
+ +}
1300
+ +
1301
+ +func (l *quietAfterFirst) closeHeld() {
1302
+ + l.mu.Lock()
1303
+ + defer l.mu.Unlock()
1304
+ + for _, c := range l.held {
1305
+ + c.Close()
1306
+ + }
1307
+ +}
1006
1308
  diff --git a/internal/app/context_file.go b/internal/app/context_file.go
1007
1309
  index bfc577b..cb60661 100644
1008
1310
  --- a/internal/app/context_file.go
@@ -1198,7 +1500,7 @@ index 6a88ac2..dc828d6 100644
1198
1500
  if extractSAPISID(a.Cookie) == "" {
1199
1501
  detail += ",但缺 SAPISID(算不出授权头,部分接口会被当匿名)"
1200
1502
  diff --git a/internal/app/gemini.go b/internal/app/gemini.go
1201
- index 7c9186f..a6b0213 100644
1503
+ index 7c9186f..c04b69c 100644
1202
1504
  --- a/internal/app/gemini.go
1203
1505
  +++ b/internal/app/gemini.go
1204
1506
  @@ -3,6 +3,7 @@ package app
@@ -1471,7 +1773,7 @@ index 7c9186f..a6b0213 100644
1471
1773
  // upstreamModelRe 匹配响应帧里服务端自报的模型显示名(帧的 [42] 位)。
1472
1774
  // 形如 ...,"fbb127bbb056c959",null,null,"3.6 Flash",true,...
1473
1775
  var upstreamModelRe = regexp.MustCompile(`\\"[0-9a-f]{16}\\",null,null,\\"([^"\\]{1,40})\\"`)
1474
- @@ -964,10 +1033,43 @@ func buildGeminiHeaders(cookieStr, sapisid, hexID string) map[string]string {
1776
+ @@ -964,10 +1033,122 @@ func buildGeminiHeaders(cookieStr, sapisid, hexID string) map[string]string {
1475
1777
  // 实测号活一两小时就失效 —— 所以这些必须收下来并写回账号。
1476
1778
  func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL string,
1477
1779
  onLine func(string) error) (int, []byte, int64, []string, error) {
@@ -1504,11 +1806,90 @@ index 7c9186f..a6b0213 100644
1504
1806
  + return s.r.Read(p)
1505
1807
  +}
1506
1808
  +
1809
+ +// stallLimit 是 StreamGenerate 上游最多能沉默多久:request_timeout_sec,0 = 不限
1810
+ +// (同客户端 Timeout 的 0)。测试里换短。
1811
+ +var stallLimit = func() time.Duration { return time.Duration(rtCfg().RequestTimeout) * time.Second }
1812
+ +
1813
+ +// StallError 说上游沉默了 Limit 这么久,这次请求被 stallWatch 掐掉了。
1814
+ +type StallError struct{ Limit time.Duration }
1815
+ +
1816
+ +func (e *StallError) Error() string {
1817
+ + return fmt.Sprintf("上游 %s 没有任何数据(request_timeout_sec)", e.Limit)
1818
+ +}
1819
+ +
1820
+ +// stallWatch 盯着一次 StreamGenerate:上游 limit 这么久一个字节都没来(连上了却迟迟
1821
+ +// 不回、或者回复写到一半停住)就取消这次请求,没出字的照常重试。计的是沉默,不是整次
1822
+ +// 请求。
1823
+ +//
1824
+ +// 原来客户端的 Timeout 管整次请求,而上游会让请求排很久的队:同一个号问一句「你是谁」,
1825
+ +// 3.6 Flash 首字在 48–393 秒之间(3.5 Flash-Lite 4 秒)。排队时上游每 60 秒发一个
1826
+ +// 38 字节的心跳帧,出字后 1 秒内写完。整次限时 180 秒,排队超过 180 秒的请求一律被
1827
+ +// 掐掉重发,重发又从头排队,三次都掐完才报错;还在出字的长回复也被拦腰截断。按沉默
1828
+ +// 计时,心跳就让排队的请求一直等下去,只有连心跳都断了(limit 默认 180 秒,等于连着
1829
+ +// 丢三个)才算卡住。
1830
+ +type stallWatch struct {
1831
+ + ctx context.Context
1832
+ + limit time.Duration
1833
+ + timer *time.Timer
1834
+ + cancel context.CancelCauseFunc
1835
+ +}
1836
+ +
1837
+ +func newStallWatch(ctx context.Context, limit time.Duration) *stallWatch {
1838
+ + ctx, cancel := context.WithCancelCause(ctx)
1839
+ + w := &stallWatch{ctx: ctx, limit: limit, cancel: cancel}
1840
+ + if limit > 0 {
1841
+ + w.timer = time.AfterFunc(limit, func() { cancel(&StallError{Limit: limit}) })
1842
+ + }
1843
+ + return w
1844
+ +}
1845
+ +
1846
+ +// heard 说上游又来了数据,沉默从现在重新算。
1847
+ +func (w *stallWatch) heard() {
1848
+ + if w.timer != nil {
1849
+ + w.timer.Reset(w.limit)
1850
+ + }
1851
+ +}
1852
+ +
1853
+ +// done 结束盯梢。
1854
+ +func (w *stallWatch) done() {
1855
+ + if w.timer != nil {
1856
+ + w.timer.Stop()
1857
+ + }
1858
+ + w.cancel(nil)
1859
+ +}
1860
+ +
1861
+ +// explain 把被 stallWatch 掐掉的请求报的 context canceled 换成 StallError;
1862
+ +// 别的错误(包括客户端断开)原样返回。
1863
+ +func (w *stallWatch) explain(err error) error {
1864
+ + var stall *StallError
1865
+ + if err != nil && errors.As(context.Cause(w.ctx), &stall) {
1866
+ + return stall
1867
+ + }
1868
+ + return err
1869
+ +}
1870
+ +
1871
+ +// heardReader 每读到数据就告诉 stallWatch 一声。
1872
+ +type heardReader struct {
1873
+ + r io.Reader
1874
+ + w *stallWatch
1875
+ +}
1876
+ +
1877
+ +func (h heardReader) Read(p []byte) (int, error) {
1878
+ + n, err := h.r.Read(p)
1879
+ + if n > 0 {
1880
+ + h.w.heard()
1881
+ + }
1882
+ + return n, err
1883
+ +}
1884
+ +
1507
1885
  +// doGeminiRequestStop 同 doGeminiRequest,stop 置位后提前结束读取(见 stopReader),
1508
- +// ctx 结束时中止连接和读取。
1886
+ +// ctx 结束时中止连接和读取。整次请求不限时,上游沉默超过 stallLimit 才放弃(见 stallWatch)。
1509
1887
  +func doGeminiRequestStop(ctx context.Context, endpoint, body string, headers map[string]string, proxyURL string,
1510
1888
  + onLine func(string) error, stop *atomic.Bool) (int, []byte, int64, []string, error) {
1511
1889
  sendAt := time.Now()
1890
+ + watch := newStallWatch(ctx, stallLimit())
1891
+ + defer watch.done()
1892
+ + ctx = watch.ctx
1512
1893
  if proxyURL != "" {
1513
1894
  // 走 stdlib 的 http.ProxyURL,已知能过 socks5/socks5h。
1514
1895
  - req, err := http.NewRequest("POST", endpoint, strings.NewReader(body))
@@ -1516,16 +1897,25 @@ index 7c9186f..a6b0213 100644
1516
1897
  if err != nil {
1517
1898
  return 0, nil, 0, nil, err
1518
1899
  }
1519
- @@ -981,7 +1083,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1520
- return 0, nil, 0, nil, err
1900
+ @@ -975,36 +1156,37 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1901
+ for k, v := range headers {
1902
+ req.Header.Set(k, v)
1903
+ }
1904
+ - client := getStdlibClient(proxyURL)
1905
+ + client := getStdlibStreamClient(proxyURL)
1906
+ resp, err := client.Do(req)
1907
+ if err != nil {
1908
+ - return 0, nil, 0, nil, err
1909
+ + return 0, nil, 0, nil, watch.explain(err)
1521
1910
  }
1522
1911
  defer resp.Body.Close()
1523
1912
  - raw, ttfb, err := readBody(resp.Body, onLine, sendAt)
1524
- + raw, ttfb, err := readModelCheckedBody(stopReader{resp.Body, stop}, onLine, sendAt, resp.StatusCode, headers["x-goog-ext-525001261-jspb"])
1913
+ + raw, ttfb, err := readModelCheckedBody(stopReader{heardReader{resp.Body, watch}, stop}, onLine, sendAt, resp.StatusCode, headers["x-goog-ext-525001261-jspb"])
1525
1914
  if err != nil {
1526
- return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
1915
+ - return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
1916
+ + return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), watch.explain(err)
1527
1917
  }
1528
- @@ -989,10 +1091,11 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1918
+ return resp.StatusCode, raw, ttfb, resp.Header.Values("Set-Cookie"), nil
1529
1919
  }
1530
1920
 
1531
1921
  // 直连 → tls-client,保留 chrome146 TLS/HTTP2 真指纹
@@ -1538,16 +1928,23 @@ index 7c9186f..a6b0213 100644
1538
1928
  for k, v := range headers {
1539
1929
  req.Header.Set(k, v)
1540
1930
  }
1541
- @@ -1002,7 +1105,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1542
- return 0, nil, 0, nil, err
1931
+ - client := getTLSClient()
1932
+ + client := getTLSStreamClient()
1933
+ resp, err := client.Do(req)
1934
+ if err != nil {
1935
+ - return 0, nil, 0, nil, err
1936
+ + return 0, nil, 0, nil, watch.explain(err)
1543
1937
  }
1544
1938
  defer resp.Body.Close()
1545
1939
  - raw, ttfb, err := readBody(resp.Body, onLine, sendAt)
1546
- + raw, ttfb, err := readModelCheckedBody(stopReader{resp.Body, stop}, onLine, sendAt, resp.StatusCode, headers["x-goog-ext-525001261-jspb"])
1940
+ + raw, ttfb, err := readModelCheckedBody(stopReader{heardReader{resp.Body, watch}, stop}, onLine, sendAt, resp.StatusCode, headers["x-goog-ext-525001261-jspb"])
1547
1941
  if err != nil {
1548
- return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
1942
+ - return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
1943
+ + return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), watch.explain(err)
1549
1944
  }
1550
- @@ -1313,6 +1416,7 @@ func probeGemini(prompt, proxyURL string) ProbeResult {
1945
+ return resp.StatusCode, raw, ttfb, resp.Header.Values("Set-Cookie"), nil
1946
+ }
1947
+ @@ -1313,6 +1495,7 @@ func probeGemini(prompt, proxyURL string) ProbeResult {
1551
1948
  res.Diagnostic = "构建请求失败: " + e.Error()
1552
1949
  return res
1553
1950
  }
@@ -1645,26 +2042,23 @@ index 00eb305..2b4c6ca 100644
1645
2042
  name, mc, err := resolveModel(c.name)
1646
2043
  diff --git a/internal/app/headers_test.go b/internal/app/headers_test.go
1647
2044
  new file mode 100644
1648
- index 0000000..9613045
2045
+ index 0000000..ef9b476
1649
2046
  --- /dev/null
1650
2047
  +++ b/internal/app/headers_test.go
1651
- @@ -0,0 +1,98 @@
2048
+ @@ -0,0 +1,74 @@
1652
2049
  +package app
1653
2050
  +
1654
2051
  +import (
1655
2052
  + "context"
1656
- + "fmt"
1657
2053
  + "net/http"
1658
2054
  + "net/http/httptest"
1659
2055
  + "testing"
1660
- +
1661
- + tls_client "github.com/bogdanfinn/tls-client"
1662
2056
  +)
1663
2057
  +
1664
2058
  +// 同一个账号的请求不能因为走没走代理而呈现两种客户端:两条路径都要带齐 chromeHeaders,
1665
2059
  +// 调用方自己的 header 照样生效。直连那条以前一个都不带,UA 是 Go-http-client/2.0。
1666
2060
  +//
1667
- +// 直连走 tls-client,这里换成跳过证书校验的同款客户端,打本机的 HTTPS 测试服务器;
2061
+ +// 直连走 tls-client,这里换成不校验证书的同款客户端,打本机的 HTTPS 测试服务器;
1668
2062
  +// 代理那条把 http:// 目标以绝对 URI 发给代理,测试服务器本身就能当代理(同 cancel_test)。
1669
2063
  +func TestDirectAndProxyPathsSendSameBrowserHeaders(t *testing.T) {
1670
2064
  + got := map[string]http.Header{}
@@ -1681,28 +2075,7 @@ index 0000000..9613045
1681
2075
  + proxy := httptest.NewServer(record("proxy"))
1682
2076
  + defer proxy.Close()
1683
2077
  +
1684
- + imp := effectiveImpersonate()
1685
- + key := fmt.Sprintf("%s|%d", imp, rtCfg().RequestTimeout)
1686
- + insecure, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
1687
- + tls_client.WithClientProfile(resolveProfile(imp)),
1688
- + tls_client.WithInsecureSkipVerify(),
1689
- + tls_client.WithNotFollowRedirects())
1690
- + if err != nil {
1691
- + t.Fatal(err)
1692
- + }
1693
- + clientCacheMu.Lock()
1694
- + prev, had := clientCache[key]
1695
- + clientCache[key] = insecure
1696
- + clientCacheMu.Unlock()
1697
- + t.Cleanup(func() {
1698
- + clientCacheMu.Lock()
1699
- + defer clientCacheMu.Unlock()
1700
- + if had {
1701
- + clientCache[key] = prev
1702
- + } else {
1703
- + delete(clientCache, key)
1704
- + }
1705
- + })
2078
+ + useInsecureTLSClients(t)
1706
2079
  +
1707
2080
  + own := map[string]string{"Accept-Language": "en-US,en;q=0.9", "X-Same-Domain": "1"}
1708
2081
  + paths := []struct {
@@ -2063,6 +2436,65 @@ index 0000000..236a51d
2063
2436
  + t.Fatalf("非流式: stop=%v reads=%d tracker=%q", lh.stop.Load(), src.reads, lh.tracker.emitted)
2064
2437
  + }
2065
2438
  +}
2439
+ diff --git a/internal/app/main_test.go b/internal/app/main_test.go
2440
+ index 38e1a44..e3f7636 100644
2441
+ --- a/internal/app/main_test.go
2442
+ +++ b/internal/app/main_test.go
2443
+ @@ -1,9 +1,12 @@
2444
+ package app
2445
+
2446
+ import (
2447
+ + "fmt"
2448
+ "os"
2449
+ "path/filepath"
2450
+ "testing"
2451
+ +
2452
+ + tls_client "github.com/bogdanfinn/tls-client"
2453
+ )
2454
+
2455
+ // TestMain 把 DB 指向临时目录再跑测试。
2456
+ @@ -31,3 +34,41 @@ func withPoolCookie(t *testing.T) {
2457
+ }
2458
+ t.Cleanup(func() { _ = accountDelete(id) })
2459
+ }
2460
+ +
2461
+ +// useInsecureTLSClients 把直连用的两个 tls-client 换成不校验证书的同款,好打本机的
2462
+ +// HTTPS 测试服务器。限时的那个给上传等请求,不限时的给 StreamGenerate(getTLSStreamClient);
2463
+ +// 除了不校验证书,配置跟线上一样(newTLSClient)。
2464
+ +func useInsecureTLSClients(t *testing.T) {
2465
+ + t.Helper()
2466
+ + imp := effectiveImpersonate()
2467
+ + for _, timeout := range []int{rtCfg().RequestTimeout, 0} {
2468
+ + c, err := newTLSClient(imp, timeout, tls_client.WithInsecureSkipVerify())
2469
+ + if err != nil {
2470
+ + t.Fatal(err)
2471
+ + }
2472
+ + key := fmt.Sprintf("%s|%d", imp, timeout)
2473
+ + clientCacheMu.Lock()
2474
+ + prev, had := clientCache[key]
2475
+ + clientCache[key] = c
2476
+ + clientCacheMu.Unlock()
2477
+ + t.Cleanup(func() {
2478
+ + clientCacheMu.Lock()
2479
+ + defer clientCacheMu.Unlock()
2480
+ + if had {
2481
+ + clientCache[key] = prev
2482
+ + } else {
2483
+ + delete(clientCache, key)
2484
+ + }
2485
+ + })
2486
+ + }
2487
+ +}
2488
+ +
2489
+ +// withRequestTimeout 把面板的 request_timeout_sec 临时改成 sec 秒。
2490
+ +func withRequestTimeout(t *testing.T, sec int) {
2491
+ + t.Helper()
2492
+ + rtMu.Lock()
2493
+ + saved := rtVal.RequestTimeout
2494
+ + rtVal.RequestTimeout = sec
2495
+ + rtMu.Unlock()
2496
+ + t.Cleanup(func() { rtMu.Lock(); rtVal.RequestTimeout = saved; rtMu.Unlock() })
2497
+ +}
2066
2498
  diff --git a/internal/app/mcp_media.go b/internal/app/mcp_media.go
2067
2499
  index acb1131..a8bab02 100644
2068
2500
  --- a/internal/app/mcp_media.go
@@ -2294,7 +2726,7 @@ index 376171f..87b91fe 100644
2294
2726
  // 返回 (mode, forcedName):mode ∈ {"auto","none","required"};
2295
2727
  // forcedName 非空表示客户端点名了某个函数。
2296
2728
  diff --git a/internal/app/server.go b/internal/app/server.go
2297
- index b451d12..b0dbbd3 100644
2729
+ index b451d12..5d3cf5b 100644
2298
2730
  --- a/internal/app/server.go
2299
2731
  +++ b/internal/app/server.go
2300
2732
  @@ -1,6 +1,7 @@
@@ -2310,19 +2742,19 @@ index b451d12..b0dbbd3 100644
2310
2742
  )
2311
2743
 
2312
2744
  -const Version = "4.26.0"
2313
- +const Version = "4.26.0-magpie.1"
2745
+ +const Version = "4.26.0-magpie.2"
2314
2746
 
2315
2747
  func writeJSON(w http.ResponseWriter, status int, data interface{}) {
2316
2748
  body, _ := json.Marshal(data)
2317
2749
  @@ -43,6 +44,7 @@ func handleRoot(w http.ResponseWriter, r *http.Request) {
2318
- writeJSON(w, 200, map[string]interface{}{
2319
2750
  "status": "ok",
2320
2751
  "version": Version,
2321
2752
  "models": modelNames,
2322
2753
  + "capabilities": map[string]bool{"account_proxy_binding": true},
2323
2754
  })
2324
2755
  }
2325
- @@ -130,9 +131,9 @@ func rejectUnsupported(req map[string]interface{}, messages []map[string]interfa
2756
+
2757
+ @@ -130,9 +132,9 @@ func rejectUnsupported(req map[string]interface{}, messages []map[string]interfa
2326
2758
  return nil
2327
2759
  }
2328
2760
 
@@ -2334,7 +2766,7 @@ index b451d12..b0dbbd3 100644
2334
2766
  if err != nil {
2335
2767
  // res 非 nil:失败时它只带归属(哪个号 / 哪个出口),给 recordRequest 用
2336
2768
  return "", nil, res, err
2337
- @@ -268,7 +269,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
2769
+ @@ -268,7 +270,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
2338
2770
  return
2339
2771
  }
2340
2772
 
@@ -2343,7 +2775,7 @@ index b451d12..b0dbbd3 100644
2343
2775
  if strings.TrimSpace(prompt) == "" && len(images) == 0 {
2344
2776
  writeJSON(w, 400, map[string]interface{}{"error": map[string]string{"message": "empty prompt"}})
2345
2777
  return
2346
- @@ -305,11 +306,14 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
2778
+ @@ -305,11 +307,14 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
2347
2779
  var res *StreamResult
2348
2780
  if rtCfg().MultiTurn && len(images) == 0 && modelCfg.Tool == 0 {
2349
2781
  // 多轮:按历史前缀识别续接,命中就只发新消息、历史留服务端。带 tools 也走这条。
@@ -2360,7 +2792,7 @@ index b451d12..b0dbbd3 100644
2360
2792
  mde, downgraded := err.(*ModelDowngradedError)
2361
2793
  status := 502
2362
2794
  if downgraded {
2363
- @@ -400,6 +404,21 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
2795
+ @@ -400,6 +405,21 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
2364
2796
  })
2365
2797
  }
2366
2798
 
@@ -2382,7 +2814,7 @@ index b451d12..b0dbbd3 100644
2382
2814
  // usageOf 是 buildUsageWithReasoning 的便捷包装,res 可能为 nil。
2383
2815
  func usageOf(prompt, text string, res *StreamResult) map[string]int {
2384
2816
  r := ""
2385
- @@ -575,7 +594,7 @@ func handleResponses(w http.ResponseWriter, r *http.Request) {
2817
+ @@ -575,7 +595,7 @@ func handleResponses(w http.ResponseWriter, r *http.Request) {
2386
2818
  return
2387
2819
  }
2388
2820
 
@@ -2391,7 +2823,7 @@ index b451d12..b0dbbd3 100644
2391
2823
  if strings.TrimSpace(prompt) == "" && len(images) == 0 {
2392
2824
  writeJSON(w, 400, map[string]interface{}{"error": map[string]string{"message": "empty input"}})
2393
2825
  return
2394
- @@ -669,11 +688,14 @@ func handleResponses(w http.ResponseWriter, r *http.Request) {
2826
+ @@ -669,11 +689,14 @@ func handleResponses(w http.ResponseWriter, r *http.Request) {
2395
2827
  var toolCalls []ToolCall
2396
2828
  var res *StreamResult
2397
2829
  if rtCfg().MultiTurn && len(images) == 0 && modelCfg.Tool == 0 {
@@ -2408,6 +2840,211 @@ index b451d12..b0dbbd3 100644
2408
2840
  mde, downgraded := err.(*ModelDowngradedError)
2409
2841
  status := 502
2410
2842
  if downgraded {
2843
+ diff --git a/internal/app/stall_test.go b/internal/app/stall_test.go
2844
+ new file mode 100644
2845
+ index 0000000..cfdd6cf
2846
+ --- /dev/null
2847
+ +++ b/internal/app/stall_test.go
2848
+ @@ -0,0 +1,199 @@
2849
+ +package app
2850
+ +
2851
+ +import (
2852
+ + "context"
2853
+ + "errors"
2854
+ + "fmt"
2855
+ + "io"
2856
+ + "net/http"
2857
+ + "net/http/httptest"
2858
+ + "testing"
2859
+ + "time"
2860
+ +)
2861
+ +
2862
+ +func withStallLimit(t *testing.T, d time.Duration) {
2863
+ + t.Helper()
2864
+ + prev := stallLimit
2865
+ + stallLimit = func() time.Duration { return d }
2866
+ + t.Cleanup(func() { stallLimit = prev })
2867
+ +}
2868
+ +
2869
+ +// 每个用例都在两条路上各跑一遍,假上游的行为一样:代理那条(同 cancel_test:http:// 目标
2870
+ +// 以绝对 URI 发给代理,httptest 服务器本身就能当上游),直连那条(HTTPS + HTTP/2 的测试
2871
+ +// 服务器,tls-client 换成不校验证书的同款,见 useInsecureTLSClients)。
2872
+ +var upstreamPaths = []struct {
2873
+ + name string
2874
+ + start func(t *testing.T, h http.Handler) (endpoint, proxyURL string)
2875
+ +}{
2876
+ + {"proxy", func(t *testing.T, h http.Handler) (string, string) {
2877
+ + srv := httptest.NewServer(h)
2878
+ + t.Cleanup(srv.Close)
2879
+ + return "http://upstream.invalid/StreamGenerate", srv.URL
2880
+ + }},
2881
+ + {"direct", func(t *testing.T, h http.Handler) (string, string) {
2882
+ + useInsecureTLSClients(t)
2883
+ + srv := httptest.NewUnstartedServer(h)
2884
+ + srv.EnableHTTP2 = true
2885
+ + srv.StartTLS()
2886
+ + t.Cleanup(srv.Close)
2887
+ + return srv.URL + "/StreamGenerate", ""
2888
+ + }},
2889
+ +}
2890
+ +
2891
+ +// 一直在出字的回复比 request_timeout_sec 长也不能被截断:以前整次请求限时,
2892
+ +// 180 秒时还在写的回复被拦腰掐掉。
2893
+ +func TestStallWatchKeepsAReplyThatKeepsComing(t *testing.T) {
2894
+ + withRequestTimeout(t, 1) // 整次限时的客户端 1 秒就掐,回复要写 1.2 秒
2895
+ + withStallLimit(t, 150*time.Millisecond)
2896
+ + for _, p := range upstreamPaths {
2897
+ + t.Run(p.name, func(t *testing.T) {
2898
+ + endpoint, proxyURL := p.start(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2899
+ + w.WriteHeader(200)
2900
+ + for i := 0; i < 20; i++ {
2901
+ + fmt.Fprintf(w, "frame %d\n", i)
2902
+ + w.(http.Flusher).Flush()
2903
+ + time.Sleep(60 * time.Millisecond)
2904
+ + }
2905
+ + }))
2906
+ +
2907
+ + lines := 0
2908
+ + start := time.Now()
2909
+ + status, _, _, _, err := doGeminiRequestStop(context.Background(), endpoint, "f.req=x",
2910
+ + map[string]string{}, proxyURL, func(line string) error {
2911
+ + lines++
2912
+ + return nil
2913
+ + }, nil)
2914
+ + if err != nil || status != 200 {
2915
+ + t.Fatalf("持续出字的回复被掐了:status=%d err=%v", status, err)
2916
+ + }
2917
+ + if lines != 20 {
2918
+ + t.Fatalf("只读到 %d 行", lines)
2919
+ + }
2920
+ + if took := time.Since(start); took < time.Second {
2921
+ + t.Fatalf("测试前提不成立:回复只用了 %v,没比整次限时长", took)
2922
+ + }
2923
+ + })
2924
+ + }
2925
+ +}
2926
+ +
2927
+ +// 上游让请求排队时只发心跳帧(实测每 60 秒一个),排得比沉默上限还久也要等到出字:
2928
+ +// 以前整次请求限时,排队超过 180 秒的请求一律被掐掉重发。
2929
+ +func TestStallWatchWaitsThroughHeartbeats(t *testing.T) {
2930
+ + withStallLimit(t, 150*time.Millisecond)
2931
+ + for _, p := range upstreamPaths {
2932
+ + t.Run(p.name, func(t *testing.T) {
2933
+ + endpoint, proxyURL := p.start(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2934
+ + w.WriteHeader(200)
2935
+ + w.Write([]byte(")]}'\nfirst frame\n"))
2936
+ + w.(http.Flusher).Flush()
2937
+ + for i := 0; i < 6; i++ {
2938
+ + time.Sleep(100 * time.Millisecond)
2939
+ + w.Write([]byte("heartbeat\n"))
2940
+ + w.(http.Flusher).Flush()
2941
+ + }
2942
+ + w.Write([]byte("answer\n"))
2943
+ + }))
2944
+ +
2945
+ + var got []string
2946
+ + start := time.Now()
2947
+ + _, _, _, _, err := doGeminiRequestStop(context.Background(), endpoint, "f.req=x",
2948
+ + map[string]string{}, proxyURL, func(line string) error {
2949
+ + got = append(got, line)
2950
+ + return nil
2951
+ + }, nil)
2952
+ + if err != nil {
2953
+ + t.Fatalf("还在发心跳的上游被当成卡住了:%v", err)
2954
+ + }
2955
+ + if len(got) == 0 || got[len(got)-1] != "answer" {
2956
+ + t.Fatalf("没等到排队之后的回复:%q", got)
2957
+ + }
2958
+ + if took := time.Since(start); took < 4*150*time.Millisecond {
2959
+ + t.Fatalf("测试前提不成立:排队只用了 %v,没比沉默上限长", took)
2960
+ + }
2961
+ + })
2962
+ + }
2963
+ +}
2964
+ +
2965
+ +// 首帧之后就不再出字的上游,沉默满上限就放弃,报 StallError,并真的断开上游连接。
2966
+ +func TestStallWatchEndsAQuietUpstream(t *testing.T) {
2967
+ + withStallLimit(t, 150*time.Millisecond)
2968
+ + for _, p := range upstreamPaths {
2969
+ + t.Run(p.name, func(t *testing.T) {
2970
+ + upstreamGone := make(chan struct{})
2971
+ + endpoint, proxyURL := p.start(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2972
+ + w.WriteHeader(200)
2973
+ + w.Write([]byte(")]}'\nfirst frame\n"))
2974
+ + w.(http.Flusher).Flush()
2975
+ + select {
2976
+ + case <-r.Context().Done():
2977
+ + close(upstreamGone)
2978
+ + case <-time.After(10 * time.Second):
2979
+ + }
2980
+ + }))
2981
+ +
2982
+ + start := time.Now()
2983
+ + _, _, _, _, err := doGeminiRequestStop(context.Background(), endpoint, "f.req=x",
2984
+ + map[string]string{}, proxyURL, func(string) error { return nil }, nil)
2985
+ + var stall *StallError
2986
+ + if !errors.As(err, &stall) {
2987
+ + t.Fatalf("沉默的上游应报 StallError,实际 %v", err)
2988
+ + }
2989
+ + if took := time.Since(start); took > 3*time.Second {
2990
+ + t.Fatalf("沉默 150ms 就该放弃,实际等了 %v", took)
2991
+ + }
2992
+ + select {
2993
+ + case <-upstreamGone:
2994
+ + case <-time.After(3 * time.Second):
2995
+ + t.Fatal("上游连接没有被关闭")
2996
+ + }
2997
+ + })
2998
+ + }
2999
+ +}
3000
+ +
3001
+ +// 连上了却一直不回响应头,同样按沉默计时。
3002
+ +func TestStallWatchEndsAnUpstreamThatNeverAnswers(t *testing.T) {
3003
+ + withStallLimit(t, 150*time.Millisecond)
3004
+ + for _, p := range upstreamPaths {
3005
+ + t.Run(p.name, func(t *testing.T) {
3006
+ + endpoint, proxyURL := p.start(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3007
+ + io.Copy(io.Discard, r.Body) // 读完请求体,服务端才盯得到连接断开
3008
+ + select {
3009
+ + case <-r.Context().Done():
3010
+ + case <-time.After(10 * time.Second):
3011
+ + }
3012
+ + }))
3013
+ +
3014
+ + _, _, _, _, err := doGeminiRequestStop(context.Background(), endpoint, "f.req=x",
3015
+ + map[string]string{}, proxyURL, nil, nil)
3016
+ + var stall *StallError
3017
+ + if !errors.As(err, &stall) {
3018
+ + t.Fatalf("不回响应头的上游应报 StallError,实际 %v", err)
3019
+ + }
3020
+ + })
3021
+ + }
3022
+ +}
3023
+ +
3024
+ +// 客户端断开不是上游沉默:照旧报 context.Canceled,调用方据此不重试、不记出口的错。
3025
+ +func TestStallWatchLeavesClientCancelAlone(t *testing.T) {
3026
+ + withStallLimit(t, 10*time.Second)
3027
+ + for _, p := range upstreamPaths {
3028
+ + t.Run(p.name, func(t *testing.T) {
3029
+ + endpoint, proxyURL := p.start(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3030
+ + io.Copy(io.Discard, r.Body) // 读完请求体,服务端才盯得到连接断开
3031
+ + select {
3032
+ + case <-r.Context().Done():
3033
+ + case <-time.After(10 * time.Second):
3034
+ + }
3035
+ + }))
3036
+ +
3037
+ + ctx, cancel := context.WithCancel(context.Background())
3038
+ + time.AfterFunc(50*time.Millisecond, cancel)
3039
+ + _, _, _, _, err := doGeminiRequestStop(ctx, endpoint, "f.req=x",
3040
+ + map[string]string{}, proxyURL, nil, nil)
3041
+ + var stall *StallError
3042
+ + if errors.As(err, &stall) || !errors.Is(err, context.Canceled) {
3043
+ + t.Fatalf("客户端断开应报 context.Canceled,实际 %v", err)
3044
+ + }
3045
+ + })
3046
+ + }
3047
+ +}
2411
3048
  diff --git a/internal/app/strict_model.go b/internal/app/strict_model.go
2412
3049
  index ecc17ac..88267c5 100644
2413
3050
  --- a/internal/app/strict_model.go