magpie-gemini-web 0.1.1 → 0.1.2

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
@@ -1,6 +1,6 @@
1
1
  # magpie-gemini-web
2
2
 
3
- Gemini Web provider plugin for [Magpie](https://usemagpie.ai/)(遵循 OpenCode v1 插件接口规范),基于 [zexadev/gemini-web2api-go](https://github.com/zexadev/gemini-web2api-go) (v4.21.0) 独立原生后端构建。
3
+ Gemini Web provider plugin for [Magpie](https://usemagpie.ai/)(遵循 OpenCode v1 插件接口规范),基于 [zexadev/gemini-web2api-go](https://github.com/zexadev/gemini-web2api-go) (v4.22.0) 独立原生后端构建。
4
4
 
5
5
  ---
6
6
 
@@ -39,11 +39,11 @@ Gemini Web provider plugin for [Magpie](https://usemagpie.ai/)(遵循 OpenCode
39
39
  git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git
40
40
  cd magpie-gemini-web
41
41
  ```
42
- 首次使用 Argon 请直接按第 5 节构建补丁版后端,再完成 Cookie 配置和插件登录;不要先启动官方后端占用同一端口。
42
+ 官方后端自 v4.22.0 起已包含 `gemini-4-argon` / `gemini-4-argon-thinking`,但不核对上游实际返回的模型:无权限、Cookie 失效或配额耗尽时会被静默降级成 3.5 Flash-Lite。需要身份校验与延迟优化时,请直接按第 5 节构建补丁版后端,再完成 Cookie 配置和插件登录;不要先启动官方后端占用同一端口。
43
43
 
44
44
  ### 步骤 1:安装后端二进制(macOS arm64)
45
45
 
46
- 运行内置下载安装脚本(锁定版本 v4.21.0,SHA-256 为 `f27006e6a5146a01f596235fb485d6b2118dcf90e61563f653078cbd74a63ed6`):
46
+ 运行内置下载安装脚本(锁定版本 v4.22.0,SHA-256 为 `c69ca75cf43017cf2a62dbf5c12b07f6bde66f403f714c32209b1ad350ed24c9`):
47
47
 
48
48
  ```bash
49
49
  npm run backend:install
@@ -54,6 +54,15 @@ npm run backend:install
54
54
  2. 初始化 `config.json`(将 host 显式绑定为 `127.0.0.1`,权限 `0600`)。
55
55
  3. 生成随机的 `apiKey` 和 `adminToken` 并写入 `secrets.json`(权限 `0600`)。
56
56
 
57
+ `secrets.json` 位于上述数据目录下(默认 `~/.local/share/magpie-gemini-web/secrets.json`),包含两个本地随机凭证,均与 Google 账号无关:
58
+
59
+ | 字段 | 用途 |
60
+ | :--- | :--- |
61
+ | `apiKey` | 插件访问后端的密钥(`gw_` 开头),在 Magpie 登录插件时填写 |
62
+ | `adminToken` | 后端管理面板 `http://127.0.0.1:8083/admin` 的登录口令 |
63
+
64
+ 只要不删除 `secrets.json`,重复执行 `backend:install` 不会重新生成这两个值;若重新生成了 `apiKey`,需要在 Magpie 中重新登录插件。
65
+
57
66
  ### 步骤 2:启动后端服务
58
67
 
59
68
  ```bash
@@ -74,7 +83,10 @@ npm run backend:start
74
83
  ```
75
84
  http://127.0.0.1:8083/admin
76
85
  ```
77
- - 输入 `secrets.json` 中的 `adminToken` 登录管理面板。
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
+ ```
78
90
  - 在 Cookie 管理页面导入 Gemini 网页请求的完整 Cookie header;上游文档列出的关键项包括 `SID`、`HSID`、`SSID`、`APISID`、`SAPISID`、`__Secure-1PSID`、`__Secure-1PSIDTS`。仅有两个 `__Secure-*` 值未必足够。
79
91
  - **注意**:Cookie 仅保存在后端的本地 SQLite 数据库中,插件绝不存储或转发 Cookie。
80
92
 
@@ -90,8 +102,12 @@ http://127.0.0.1:8083/admin
90
102
  ```bash
91
103
  magpie plugin login gemini-web
92
104
  ```
93
- - **Gemini Web backend API key**:填入 `secrets.json` 中的 `apiKey`。
94
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` 即可直接输出。
95
111
 
96
112
  > 重新登录(例如更换 API key 或 base URL)后,正在运行的 Magpie 不会重新读取 provider 地址,需要执行 `magpie plugin off magpie-gemini-web && magpie plugin on magpie-gemini-web` 或重启 Magpie App 才会生效。
97
113
  >
@@ -128,14 +144,21 @@ http://127.0.0.1:8083/admin
128
144
 
129
145
  ## 5. 本地开发与实验性 Argon 支持 (Local Development & Argon Support)
130
146
 
131
- 针对具备 Gemini 4 Argon 访问权限的账号,本项目提供了本地构建与接入支持:
147
+ 官方后端自 v4.22.0 起已支持 Gemini 4 Argon。本项目的补丁版在其基础上增加 Argon 身份校验与延迟优化,适合拥有 Argon 访问权限(付费 / Ultra)的账号:
132
148
 
133
149
  ### 5.1 架构与协议保护机制
134
- - **模型标识**:`gemini-4-argon`(固定 HexID `95a4c0abcba24364`,Mode 8,仅支持 low thinking 基础档位)。
135
- - **账号权限要求**:必须在后端管理面板配置拥有 Argon 访问权限的 Google 登录 Cookie;无 Cookie 或未获授权的账号无法使用该模型(未配 Cookie 时后端自动隐藏)。
136
- - **结构化元数据校验**:由于 Google Web 存在对未授权模型静默降级的问题,后端在底层响应解析层(`argon.go`)对 Argon 请求实行严格的结构化元数据身份校验(解析 `wrb.fr` payload 中的模型 hex 与显示名,而非依赖模型自述文本)。流式请求逐帧校验:确认 Argon 身份之前的帧先暂存、不发给客户端,确认后放行并继续增量转发(实测身份与首段正文同帧到达)。身份缺失或首次出现即不符时请求报错,不发送任何未验证内容;若放行之后上游又报告了其他模型,后端立即停止读取并发送流错误,但已发出的内容无法撤回。非流式请求仍完整读取后再校验。
137
- - **协议验证状态**:已通过真实 Magpie 网关验证普通对话和 SSE;后端记录的实际模型均为 `4 Argon`。首轮工具调用名称与参数正确,第二轮含正确测试值但附带额外说明,不代表工具兼容性已完全解决。本地验收记录不随公开仓库或 npm 包分发。
138
- - **`4.21.0-argon.2` 延迟优化**(2026-10-14 直连后端实测,单次样本、上游波动大,仅供参考):
150
+ - **模型标识**(模型定义来自上游 v4.22.0):均为 HexID `95a4c0abcba24364`、Mode 8。
151
+
152
+ | 模型 | 网页思考档位 | 上游自报模型名 |
153
+ | :--- | :--- | :--- |
154
+ | `gemini-4-argon` | 低 | `4 Argon` |
155
+ | `gemini-4-argon-thinking` | 高(带 `reasoning_content`) | `4 Argon High` |
156
+
157
+ 网页端的「中」档上游尚未支持(见 [#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
158
+ - **账号权限要求**:必须在后端管理面板配置拥有 Argon 访问权限的 Google 登录 Cookie;无 Cookie 时后端自动隐藏这两个模型。
159
+ - **结构化元数据校验**:由于 Google Web 对无权限、Cookie 失效或配额耗尽的请求会静默降级,后端在底层响应解析层(`argon.go`)对 Argon 请求实行严格的结构化元数据身份校验(解析 `wrb.fr` payload 中的模型 hex 与显示名,而非依赖模型自述文本),并按请求的思考档位只接受对应的显示名(普通档 `4 Argon`、扩展思考 `4 Argon High`),档位被静默替换同样视为不符。流式请求逐帧校验:确认 Argon 身份之前的帧先暂存、不发给客户端,确认后放行并继续增量转发(实测身份与首段正文同帧到达)。身份缺失或首次出现即不符时请求报错,不发送任何未验证内容;若放行之后上游又报告了其他模型,后端立即停止读取并发送流错误,但已发出的内容无法撤回。非流式请求仍完整读取后再校验。
160
+ - **协议验证状态**:`4.22.0-argon.1` 已通过真实 Magpie 网关验证两档的普通对话和 SSE:`gemini-4-argon` 后端记录实际模型 `4 Argon`,`gemini-4-argon-thinking` 记录 `4 Argon High` 并返回 `reasoning_content`。工具调用沿用 `4.21.0-argon.2` 的结论:首轮工具调用名称与参数正确,第二轮含正确测试值但附带额外说明,不代表工具兼容性已完全解决。本地验收记录不随公开仓库或 npm 包分发。
161
+ - **延迟优化**(自 `4.21.0-argon.2` 起;2026-10-14 直连后端实测,单次样本、上游波动大,仅供参考):
139
162
  - XSRF token 按账号身份缓存,不再因每次响应刷新 SIDCC 而失效,登录态请求的前置开销由约 0.9–1.3 秒降至约 10 毫秒;Cookie 轮转后在后台重取 token。
140
163
  - Argon 流式首字约 5.4 秒(同次总耗时 9.8 秒),不再等待整段生成结束。
141
164
  - 工具调用块闭合且函数名有效时即结束读取上游并返回 `tool_calls`,Argon 首轮工具调用约 6.1 秒。
@@ -147,17 +170,19 @@ http://127.0.0.1:8083/admin
147
170
  需要 upstream `go.mod` 声明的 Go 版本(Go 1.26+)与 Git。从插件根目录执行:
148
171
  ```bash
149
172
  git clone https://github.com/zexadev/gemini-web2api-go.git backend-source
150
- git -C backend-source checkout 93d9b384b2453c495942080f5838b8e71d6c1e6f
173
+ git -C backend-source checkout a65b1d1a5ac614c1b455857334b4f1c73ab8fcfb
151
174
  git -C backend-source apply ../patches/argon.patch
152
175
  ```
153
176
 
154
- 源码基于 upstream tag `v4.21.0`(锁定 commit `93d9b384b2453c495942080f5838b8e71d6c1e6f`):
177
+ 源码基于 upstream tag `v4.22.0`(锁定 commit `a65b1d1a5ac614c1b455857334b4f1c73ab8fcfb`)。
178
+
179
+ > **从 `4.21.0-argon.*` 升级**:旧补丁新增的文件会与新补丁冲突,请删除 `backend-source/` 后按上面三行重新获取源码,再从下方第 1 步开始。
155
180
 
156
181
  1. **构建 Argon 增强版后端**:
157
182
  ```bash
158
183
  npm run backend:build:argon
159
184
  ```
160
- 该命令将在 `backend-source/` 目录执行 Go 编译,并在 `backend-build/` 生成校验清单 `manifest.json` 与二进制(版本 `4.21.0-argon.2`)。
185
+ 该命令将在 `backend-source/` 目录执行 Go 编译,并在 `backend-build/` 生成校验清单 `manifest.json` 与二进制(版本 `4.22.0-argon.1`)。
161
186
 
162
187
  2. **安装到本地数据目录**:
163
188
  ```bash
@@ -170,7 +195,7 @@ git -C backend-source apply ../patches/argon.patch
170
195
  npm run backend:start:argon
171
196
  ```
172
197
 
173
- > **版本切换提示**:如果重新执行官方标准的 `npm run backend:install`,将自动下载官方原版二进制并覆盖当前版本,Argon 模型将暂时不可用;重新执行 `npm run backend:install:argon` 即可切回 Argon 版本。
198
+ > **版本切换提示**:如果重新执行官方标准的 `npm run backend:install`,将自动下载官方原版二进制并覆盖当前版本:Argon 模型仍可用,但不再有身份校验与延迟优化;重新执行 `npm run backend:install:argon` 即可切回 Argon 版本。
174
199
 
175
200
  ---
176
201
 
package/lib/native.mjs CHANGED
@@ -4,8 +4,8 @@ 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 = 'f27006e6a5146a01f596235fb485d6b2118dcf90e61563f653078cbd74a63ed6';
8
- export const PINNED_URL = 'https://github.com/zexadev/gemini-web2api-go/releases/download/v4.21.0/gemini-web2api-go_v4.21.0_darwin_arm64';
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';
9
9
 
10
10
  export function getDataDir(env = process.env) {
11
11
  return env.GEMINI_WEB_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'magpie-gemini-web');
@@ -24,7 +24,7 @@ export async function installBackend(options = {}) {
24
24
  const fetchFn = options.fetchFn || globalThis.fetch;
25
25
 
26
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.21.0 manually from https://github.com/zexadev/gemini-web2api-go/releases/tag/v4.21.0');
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
28
  }
29
29
 
30
30
  const dataDir = options.dataDir || getDataDir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Gemini Web provider for Magpie, backed by gemini-web2api-go",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -1,22 +1,43 @@
1
1
  diff --git a/internal/app/argon.go b/internal/app/argon.go
2
2
  new file mode 100644
3
- index 0000000..366b005
3
+ index 0000000..98944ac
4
4
  --- /dev/null
5
5
  +++ b/internal/app/argon.go
6
- @@ -0,0 +1,152 @@
6
+ @@ -0,0 +1,174 @@
7
7
  +package app
8
8
  +
9
9
  +import (
10
10
  + "encoding/json"
11
11
  + "fmt"
12
12
  + "io"
13
+ + "slices"
13
14
  + "strings"
14
15
  + "time"
15
16
  +)
16
17
  +
18
+ +// argonDisplayNames 是上游对 Argon 各思考档位自报的显示名:普通档 "4 Argon",
19
+ +// 扩展思考(think=2,上游叫 High)"4 Argon High"。按请求的档位只认对应名字,
20
+ +// 档位被静默换掉也算身份不符。
21
+ +func argonDisplayNames(think int) []string {
22
+ + if think == thinkingExtended {
23
+ + return []string{"4 Argon High", "Gemini 4 Argon High"}
24
+ + }
25
+ + return []string{"4 Argon", "Gemini 4 Argon"}
26
+ +}
27
+ +
28
+ +// argonThinkFromHeader 从模型 header 下标 15 取思考档位;最小形式没有这一槽,按普通档。
29
+ +func argonThinkFromHeader(h []any) int {
30
+ + if len(h) > 15 {
31
+ + if f, ok := h[15].(float64); ok {
32
+ + return int(f)
33
+ + }
34
+ + }
35
+ + return thinkingNormal
36
+ +}
37
+ +
17
38
  +// argonIdentityInLine 检查单行 wrb.fr 帧里的模型身份。
18
- +// 返回 found=true 表示这行带有正确的 Argon 身份;err 非 nil 表示这行报的是别的模型。
19
- +func argonIdentityInLine(line string) (found bool, err error) {
39
+ +// 返回 found=true 表示这行带有 names 之一的 Argon 身份;err 非 nil 表示这行报的是别的模型或档位。
40
+ +func argonIdentityInLine(line string, names []string) (found bool, err error) {
20
41
  + var rows [][]json.RawMessage
21
42
  + if json.Unmarshal([]byte(line), &rows) != nil {
22
43
  + return false, nil
@@ -50,7 +71,7 @@ index 0000000..366b005
50
71
  + if !validHex || !ok {
51
72
  + continue
52
73
  + }
53
- + if id != hexArgon4 || (name != "4 Argon" && name != "Gemini 4 Argon") {
74
+ + if id != hexArgon4 || !slices.Contains(names, name) {
54
75
  + return false, fmt.Errorf("Argon model mismatch: upstream reported %q (%s)", name, id)
55
76
  + }
56
77
  + found = true
@@ -61,10 +82,10 @@ index 0000000..366b005
61
82
  +
62
83
  +var errArgonIdentityMissing = fmt.Errorf("Argon identity missing in upstream response; refusing unverified model")
63
84
  +
64
- +func validateArgonIdentity(raw []byte) error {
85
+ +func validateArgonIdentity(raw []byte, names []string) error {
65
86
  + found := false
66
87
  + for _, line := range strings.Split(string(raw), "\n") {
67
- + ok, err := argonIdentityInLine(line)
88
+ + ok, err := argonIdentityInLine(line, names)
68
89
  + if err != nil {
69
90
  + return err
70
91
  + }
@@ -95,12 +116,13 @@ index 0000000..366b005
95
116
  + if !isArgon {
96
117
  + return readBody(r, onLine, start)
97
118
  + }
119
+ + names := argonDisplayNames(argonThinkFromHeader(h))
98
120
  + if status != 200 || onLine == nil {
99
121
  + raw, ttfb, err := readBody(r, nil, start)
100
122
  + if err != nil || status != 200 {
101
123
  + return raw, ttfb, err
102
124
  + }
103
- + return raw, ttfb, validateArgonIdentity(raw)
125
+ + return raw, ttfb, validateArgonIdentity(raw, names)
104
126
  + }
105
127
  +
106
128
  + var pending []string
@@ -110,7 +132,7 @@ index 0000000..366b005
110
132
  + if identityErr != nil {
111
133
  + return
112
134
  + }
113
- + ok, lerr := argonIdentityInLine(line)
135
+ + ok, lerr := argonIdentityInLine(line, names)
114
136
  + if lerr != nil {
115
137
  + identityErr = lerr
116
138
  + return
@@ -156,12 +178,71 @@ index 0000000..366b005
156
178
  + }
157
179
  + return g.r.Read(p)
158
180
  +}
181
+ diff --git a/internal/app/argon_probe_test.go b/internal/app/argon_probe_test.go
182
+ new file mode 100644
183
+ index 0000000..dd9e687
184
+ --- /dev/null
185
+ +++ b/internal/app/argon_probe_test.go
186
+ @@ -0,0 +1,53 @@
187
+ +package app
188
+ +
189
+ +import (
190
+ + "encoding/json"
191
+ + "fmt"
192
+ + "net/url"
193
+ + "os"
194
+ + "regexp"
195
+ + "strings"
196
+ + "testing"
197
+ + "time"
198
+ + "github.com/google/uuid"
199
+ +)
200
+ +
201
+ +// Explicitly opted-in local protocol probe. Does not log cookies, tokens, or raw account responses.
202
+ +func TestArgonProtocolProbe(t *testing.T) {
203
+ + cookie := os.Getenv("ARGON_PROBE_COOKIE")
204
+ + if cookie == "" { t.Skip("local authenticated probe not enabled") }
205
+ + dbPath := cfg.DBPath
206
+ + cfg = defaultConfig(); cfg.DBPath = dbPath; cfg.RequestTimeout = 65
207
+ + initRuntimeConfig()
208
+ + page, err := fetchAppPage(cookie, ""); if err != nil { t.Fatal("page fetch failed") }
209
+ + tok := snlm0eRe.FindSubmatch(page); if len(tok)<2 {t.Fatal("login token unavailable")}
210
+ + bl := regexp.MustCompile(`"cfb2h":"([^"]+)"`).FindSubmatch(page)
211
+ + if len(bl)<2 {t.Fatal("build version unavailable")}
212
+ + headers := buildGeminiHeaders(cookie,extractSAPISID(cookie), "")
213
+ + delete(headers,"x-goog-ext-525001261-jspb")
214
+ + freq,_:=json.Marshal([]any{[]any{[]any{"otAQ7b","[]",nil,"generic"}}})
215
+ + form:=url.Values{"f.req":{string(freq)},"at":{string(tok[1])}}
216
+ + endpoint:="https://gemini.google.com/_/BardChatUi/data/batchexecute?rpcids=otAQ7b&hl=en&rt=c&bl="+url.QueryEscape(string(bl[1]))
217
+ + status,_,catalog,err:=uploadPost(endpoint,headers,[]byte(form.Encode()),"")
218
+ + if err!=nil {t.Fatal("catalog request failed")}
219
+ + t.Logf("catalog_status=%d argon_hex_present=%v argon_name_present=%v",status,strings.Contains(string(catalog),"95a4c0abcba24364"),strings.Contains(string(catalog),"Argon"))
220
+ + if !strings.Contains(string(catalog),"95a4c0abcba24364") {t.Fatal("Argon not confirmed in account catalog")}
221
+ + if os.Getenv("ARGON_PROBE_CATALOG_ONLY")=="1" { return }
222
+ + inner:=make([]any,97)
223
+ + inner[0]=[]any{"Reply with exactly ARGON_OK.",0,nil,nil,nil,nil,0}
224
+ + inner[1]=[]any{"en"};inner[2]=[]any{"","","",nil,nil,nil,nil,nil,nil,""}
225
+ + inner[6]=[]any{0};inner[7]=1;inner[10]=1;inner[11]=0;inner[17]=[]any{[]any{0}}
226
+ + inner[18]=0;inner[27]=1;inner[30]=[]any{4};inner[41]=[]any{1};inner[45]=1
227
+ + inner[53]=0;inner[59]=uuid.NewString();inner[61]=[]any{};inner[68]=1;inner[79]=8;inner[80]=1;inner[91]=0;inner[96]=0
228
+ + ib,_:=json.Marshal(inner);outer,_:=json.Marshal([]any{nil,string(ib)})
229
+ + form=url.Values{"f.req":{string(outer)},"at":{string(tok[1])}}
230
+ + headers=buildGeminiHeaders(cookie,extractSAPISID(cookie),"95a4c0abcba24364")
231
+ + headers["x-goog-ext-525001261-jspb"]=buildModelHeader("95a4c0abcba24364",8,1,uuid.NewString())
232
+ + headers["x-goog-ext-525005358-jspb"]=fmt.Sprintf(`["%s",1]`,inner[59])
233
+ + endpoint="https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?hl=en&rt=c&bl="+url.QueryEscape(string(bl[1]))
234
+ + start:=time.Now();status,raw,_,_,err:=doGeminiRequest(endpoint,form.Encode(),headers,"",nil)
235
+ + if err!=nil {t.Fatalf("generation transport failed (%T)",err)}
236
+ + actual:=extractUpstreamModel(string(raw));answer:=extractResponseText(string(raw))
237
+ + t.Logf("generation_status=%d mode=8 think=1 actual_model=%q answer=%q elapsed_ms=%d raw_bytes=%d",status,actual,answer,time.Since(start).Milliseconds(),len(raw))
238
+ + if status!=200 || !strings.Contains(strings.ToLower(actual),"argon") || strings.TrimSpace(answer)!="ARGON_OK" {t.Fatal("Argon generation not verified")}
239
+ +}
159
240
  diff --git a/internal/app/argon_test.go b/internal/app/argon_test.go
160
241
  new file mode 100644
161
- index 0000000..4449c45
242
+ index 0000000..3e62433
162
243
  --- /dev/null
163
244
  +++ b/internal/app/argon_test.go
164
- @@ -0,0 +1,229 @@
245
+ @@ -0,0 +1,266 @@
165
246
  +package app
166
247
  +
167
248
  +import (
@@ -196,18 +277,18 @@ index 0000000..4449c45
196
277
  +func TestArgonValidateIdentity(t *testing.T) {
197
278
  + // 1. Correct hex and name passes
198
279
  + validFrame1 := makeSyntheticArgonFrame(hexArgon4, "4 Argon", "ARGON_OK")
199
- + if err := validateArgonIdentity(validFrame1); err != nil {
280
+ + if err := validateArgonIdentity(validFrame1, argonDisplayNames(thinkingNormal)); err != nil {
200
281
  + t.Fatalf("expected valid Argon frame to pass, got: %v", err)
201
282
  + }
202
283
  +
203
284
  + validFrame2 := makeSyntheticArgonFrame(hexArgon4, "Gemini 4 Argon", "ARGON_OK")
204
- + if err := validateArgonIdentity(validFrame2); err != nil {
285
+ + if err := validateArgonIdentity(validFrame2, argonDisplayNames(thinkingNormal)); err != nil {
205
286
  + t.Fatalf("expected valid Gemini 4 Argon frame to pass, got: %v", err)
206
287
  + }
207
288
  +
208
289
  + // 2. Flash hex/name fails with mismatch
209
290
  + flashFrame := makeSyntheticArgonFrame(hexFlash36, "3.6 Flash", "FLASH")
210
- + if err := validateArgonIdentity(flashFrame); err == nil || !strings.Contains(err.Error(), "Argon model mismatch") {
291
+ + if err := validateArgonIdentity(flashFrame, argonDisplayNames(thinkingNormal)); err == nil || !strings.Contains(err.Error(), "Argon model mismatch") {
211
292
  + t.Fatalf("expected Argon model mismatch for Flash frame, got: %v", err)
212
293
  + }
213
294
  +
@@ -216,13 +297,13 @@ index 0000000..4449c45
216
297
  + pBytes, _ := json.Marshal(emptyFields)
217
298
  + emptyFrame, _ := json.Marshal([][]any{{"wrb.fr", "rpc1", string(pBytes), nil}})
218
299
  + emptyFrame = append(emptyFrame, '\n')
219
- + if err := validateArgonIdentity(emptyFrame); err == nil || !strings.Contains(err.Error(), "Argon identity missing") {
300
+ + if err := validateArgonIdentity(emptyFrame, argonDisplayNames(thinkingNormal)); err == nil || !strings.Contains(err.Error(), "Argon identity missing") {
220
301
  + t.Fatalf("expected Argon identity missing, got: %v", err)
221
302
  + }
222
303
  +
223
304
  + // 4. Malformed frame fails
224
305
  + malformed := []byte(")]}'\nnot-valid-json\n")
225
- + if err := validateArgonIdentity(malformed); err == nil || !strings.Contains(err.Error(), "Argon identity missing") {
306
+ + if err := validateArgonIdentity(malformed, argonDisplayNames(thinkingNormal)); err == nil || !strings.Contains(err.Error(), "Argon identity missing") {
226
307
  + t.Fatalf("expected Argon identity missing for malformed JSON, got: %v", err)
227
308
  + }
228
309
  +
@@ -232,13 +313,13 @@ index 0000000..4449c45
232
313
  + fBytes, _ := json.Marshal(forgedFields)
233
314
  + forgedFrame, _ := json.Marshal([][]any{{"wrb.fr", "rpc1", string(fBytes), nil}})
234
315
  + forgedFrame = append(forgedFrame, '\n')
235
- + if err := validateArgonIdentity(forgedFrame); err == nil || !strings.Contains(err.Error(), "Argon identity missing") {
316
+ + if err := validateArgonIdentity(forgedFrame, argonDisplayNames(thinkingNormal)); err == nil || !strings.Contains(err.Error(), "Argon identity missing") {
236
317
  + t.Fatalf("expected forged text without model slots to fail identity check, got: %v", err)
237
318
  + }
238
319
  +
239
320
  + // 6. Matching then mismatching frames fails
240
321
  + mixedFrames := append(validFrame1, flashFrame...)
241
- + if err := validateArgonIdentity(mixedFrames); err == nil || !strings.Contains(err.Error(), "Argon model mismatch") {
322
+ + if err := validateArgonIdentity(mixedFrames, argonDisplayNames(thinkingNormal)); err == nil || !strings.Contains(err.Error(), "Argon model mismatch") {
242
323
  + t.Fatalf("expected matching then mismatching frames to fail, got: %v", err)
243
324
  + }
244
325
  +}
@@ -391,6 +472,122 @@ index 0000000..4449c45
391
472
  + t.Fatal("gemini-4-argon should be available when pool cookie exists")
392
473
  + }
393
474
  +}
475
+ +
476
+ +// -thinking 版上游自报 "4 Argon High":按请求 header 的思考位只认对应档位的名字,
477
+ +// 档位被静默换掉(要 High 回了普通档,或反过来)同样报身份不符。
478
+ +func TestArgonIdentityByThinkTier(t *testing.T) {
479
+ + low := Models["gemini-4-argon"]
480
+ + high := Models["gemini-4-argon-thinking"]
481
+ + if low.Thinking || !high.Thinking || high.HexID != hexArgon4 || high.Mode != 8 {
482
+ + t.Fatalf("unexpected Argon model table: low=%+v high=%+v", low, high)
483
+ + }
484
+ + lowHeader := buildModelHeader(low.HexID, low.Mode, thinkingNormal, "uuid-x")
485
+ + highHeader := buildModelHeader(high.HexID, high.Mode, thinkingExtended, "uuid-x")
486
+ +
487
+ + cases := []struct {
488
+ + header, name string
489
+ + ok bool
490
+ + }{
491
+ + {lowHeader, "4 Argon", true},
492
+ + {highHeader, "4 Argon High", true},
493
+ + {highHeader, "Gemini 4 Argon High", true},
494
+ + {lowHeader, "4 Argon High", false},
495
+ + {highHeader, "4 Argon", false},
496
+ + }
497
+ + for _, c := range cases {
498
+ + frame := makeSyntheticArgonFrame(hexArgon4, c.name, "ok")
499
+ + var got []string
500
+ + _, _, err := readModelCheckedBody(bytes.NewReader(frame), func(l string) { got = append(got, l) }, time.Now(), 200, c.header)
501
+ + if c.ok && (err != nil || len(got) == 0) {
502
+ + t.Errorf("%q with header %s: want pass, err=%v lines=%d", c.name, c.header, err, len(got))
503
+ + }
504
+ + if !c.ok && (err == nil || !strings.Contains(err.Error(), "Argon model mismatch") || len(got) != 0) {
505
+ + t.Errorf("%q with header %s: want mismatch and no output, err=%v lines=%d", c.name, c.header, err, len(got))
506
+ + }
507
+ + if _, _, err := readModelCheckedBody(bytes.NewReader(frame), nil, time.Now(), 200, c.header); (err == nil) != c.ok {
508
+ + t.Errorf("%q non-stream with header %s: ok=%v err=%v", c.name, c.header, c.ok, err)
509
+ + }
510
+ + }
511
+ +}
512
+ diff --git a/internal/app/argon_timing_probe_test.go b/internal/app/argon_timing_probe_test.go
513
+ new file mode 100644
514
+ index 0000000..4ee5a3e
515
+ --- /dev/null
516
+ +++ b/internal/app/argon_timing_probe_test.go
517
+ @@ -0,0 +1,73 @@
518
+ +package app
519
+ +
520
+ +import (
521
+ + "encoding/json"
522
+ + "fmt"
523
+ + "net/url"
524
+ + "os"
525
+ + "regexp"
526
+ + "strings"
527
+ + "testing"
528
+ + "time"
529
+ + "github.com/google/uuid"
530
+ + fhttp "github.com/bogdanfinn/fhttp"
531
+ +)
532
+ +
533
+ +// Explicitly opted-in local protocol probe. Does not log cookies, tokens, or raw account responses.
534
+ +func TestArgonTimingProbe(t *testing.T) {
535
+ + cookie := os.Getenv("ARGON_PROBE_COOKIE")
536
+ + if cookie == "" { t.Skip("local authenticated probe not enabled") }
537
+ + dbPath := cfg.DBPath
538
+ + cfg = defaultConfig(); cfg.DBPath = dbPath; cfg.RequestTimeout = 65
539
+ + initRuntimeConfig()
540
+ + page, err := fetchAppPage(cookie, ""); if err != nil { t.Fatal("page fetch failed") }
541
+ + tok := snlm0eRe.FindSubmatch(page); if len(tok)<2 {t.Fatal("login token unavailable")}
542
+ + bl := regexp.MustCompile(`"cfb2h":"([^"]+)"`).FindSubmatch(page)
543
+ + if len(bl)<2 {t.Fatal("build version unavailable")}
544
+ + headers := buildGeminiHeaders(cookie,extractSAPISID(cookie), "")
545
+ + delete(headers,"x-goog-ext-525001261-jspb")
546
+ + freq,_:=json.Marshal([]any{[]any{[]any{"otAQ7b","[]",nil,"generic"}}})
547
+ + form:=url.Values{"f.req":{string(freq)},"at":{string(tok[1])}}
548
+ + endpoint:="https://gemini.google.com/_/BardChatUi/data/batchexecute?rpcids=otAQ7b&hl=en&rt=c&bl="+url.QueryEscape(string(bl[1]))
549
+ + status,_,catalog,err:=uploadPost(endpoint,headers,[]byte(form.Encode()),"")
550
+ + if err!=nil {t.Fatal("catalog request failed")}
551
+ + t.Logf("catalog_status=%d argon_hex_present=%v argon_name_present=%v",status,strings.Contains(string(catalog),"95a4c0abcba24364"),strings.Contains(string(catalog),"Argon"))
552
+ + if !strings.Contains(string(catalog),"95a4c0abcba24364") {t.Fatal("Argon not confirmed in account catalog")}
553
+ + if os.Getenv("ARGON_PROBE_CATALOG_ONLY")=="1" { return }
554
+ + inner:=make([]any,97)
555
+ + inner[0]=[]any{"Explain how rain forms in 12 short numbered sentences. Begin directly with sentence 1.",0,nil,nil,nil,nil,0}
556
+ + inner[1]=[]any{"en"};inner[2]=[]any{"","","",nil,nil,nil,nil,nil,nil,""}
557
+ + inner[6]=[]any{0};inner[7]=1;inner[10]=1;inner[11]=0;inner[17]=[]any{[]any{0}}
558
+ + inner[18]=0;inner[27]=1;inner[30]=[]any{4};inner[41]=[]any{1};inner[45]=1
559
+ + inner[53]=0;inner[59]=uuid.NewString();inner[61]=[]any{};inner[68]=1;inner[79]=8;inner[80]=1;inner[91]=0;inner[96]=0
560
+ + ib,_:=json.Marshal(inner);outer,_:=json.Marshal([]any{nil,string(ib)})
561
+ + form=url.Values{"f.req":{string(outer)},"at":{string(tok[1])}}
562
+ + headers=buildGeminiHeaders(cookie,extractSAPISID(cookie),"95a4c0abcba24364")
563
+ + headers["x-goog-ext-525001261-jspb"]=buildModelHeader("95a4c0abcba24364",8,1,uuid.NewString())
564
+ + headers["x-goog-ext-525005358-jspb"]=fmt.Sprintf(`["%s",1]`,inner[59])
565
+ + endpoint="https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate?hl=en&rt=c&bl="+url.QueryEscape(string(bl[1]))
566
+ + start:=time.Now()
567
+ + req,e:=fhttp.NewRequest("POST",endpoint,strings.NewReader(form.Encode()));if e!=nil{t.Fatal("request construction failed")}
568
+ + for k,v:=range headers{req.Header.Set(k,v)}
569
+ + resp,e:=getTLSClient().Do(req);if e!=nil{t.Fatalf("transport failed (%T)",e)}
570
+ + defer resp.Body.Close()
571
+ + status=resp.StatusCode
572
+ + firstIdentity,firstText,firstReasoning,lastText:=int64(-1),int64(-1),int64(-1),int64(-1)
573
+ + lineCount,textFrames:=0,0
574
+ + lastContent:=""
575
+ + raw,firstLine,err:=readBody(resp.Body,func(line string){
576
+ + lineCount++;ms:=time.Since(start).Milliseconds()
577
+ + if firstIdentity<0 && validateArgonIdentity([]byte(line), argonDisplayNames(thinkingNormal))==nil{firstIdentity=ms}
578
+ + if firstReasoning<0 && reasoningInLine(line)!=""{firstReasoning=ms}
579
+ + texts:=textsInLine(line)
580
+ + if len(texts)>0 && strings.Join(texts,"\n")!=lastContent {
581
+ + lastContent=strings.Join(texts,"\n");textFrames++;lastText=ms
582
+ + if firstText<0{firstText=ms}
583
+ + }
584
+ + },start)
585
+ + t.Logf("TIMING first_line_ms=%d first_identity_ms=%d first_text_ms=%d first_reasoning_ms=%d last_text_ms=%d complete_ms=%d lines=%d text_updates=%d",firstLine,firstIdentity,firstText,firstReasoning,lastText,time.Since(start).Milliseconds(),lineCount,textFrames)
586
+ + if err!=nil {t.Fatalf("generation transport failed (%T)",err)}
587
+ + actual:=extractUpstreamModel(string(raw));answer:=extractResponseText(string(raw))
588
+ + t.Logf("generation_status=%d mode=8 think=1 actual_model=%q answer_bytes=%d elapsed_ms=%d raw_bytes=%d",status,actual,len(answer),time.Since(start).Milliseconds(),len(raw))
589
+ + if status!=200 || !strings.Contains(strings.ToLower(actual),"argon") || strings.TrimSpace(answer)=="" {t.Fatal("Argon generation not verified")}
590
+ +}
394
591
  diff --git a/internal/app/cancel_test.go b/internal/app/cancel_test.go
395
592
  new file mode 100644
396
593
  index 0000000..e5f1b6f
@@ -657,7 +854,7 @@ index aef12eb..cbf1371 100644
657
854
  if !fresh {
658
855
  // 续接失败:这路会话作废,客户端重试时会当新会话全量重发。
659
856
  diff --git a/internal/app/gemini.go b/internal/app/gemini.go
660
- index 200c728..83256f3 100644
857
+ index 723725f..a1e5221 100644
661
858
  --- a/internal/app/gemini.go
662
859
  +++ b/internal/app/gemini.go
663
860
  @@ -3,6 +3,7 @@ package app
@@ -676,32 +873,7 @@ index 200c728..83256f3 100644
676
873
  "time"
677
874
 
678
875
  fhttp "github.com/bogdanfinn/fhttp"
679
- @@ -27,6 +29,7 @@ const (
680
- // "当前 Flash" 泛指针,老批次号发它拿到的是 3.6,会冒充 3.7。老批次号发这个主 hex
681
- // 会干净降级成 3.5 Flash-Lite(跟 3.1 Pro 一样),所以 gate 成要 cookie。
682
- hexFlash38 = "56fdd199312815e2" // 3.8 Flash(原记 3.7;Google 把这个 hex 原地升成 3.8,HAR 响应帧显示名已是 "3.8 Flash")
683
- + hexArgon4 = "95a4c0abcba24364"
684
- )
685
-
686
- // innerSlots 是 payload 里 inner 数组的长度。浏览器发 97-98 槽,我们原来只开 80,
687
- @@ -72,6 +75,7 @@ var Models = map[string]ModelConfig{
688
- "gemini-3.6-flash": {HexID: hexFlash36, Mode: 1, Desc: "Latest all-around model"},
689
- "gemini-3.5-flash-lite": {HexID: hexFlashLite, Mode: 6, Desc: "Fastest, lightweight"},
690
- "gemini-3.1-pro": {HexID: hexPro31, Mode: 3, Desc: "Most capable; needs a signed-in cookie (downgraded to Flash-Lite without one)"},
691
- + "gemini-4-argon": {HexID: hexArgon4, Mode: 8, Desc: "Gemini 4 Argon; low thinking; requires a signed-in account with Argon access"},
692
- // 3.8 Flash:Google 把原 3.7 那个 hex 原地升级了(同 hex,服务端显示名从 3.7 变 3.8)。
693
- // 实测是**付费号专属**(不是灰度):只有付费 Gemini 账号有,免费号降级成 3.5 Flash-Lite。
694
- // gemini-3.7-flash 保留为别名(同 hex)。
695
- @@ -110,7 +114,7 @@ func hasCookie() bool {
696
- // 逐模型核对等价:那三个正名的 HexID 就是 hexPro31 / hexFlash38,其余登录模型都被
697
- // Thinking 或 Tool 覆盖。
698
- func modelNeedsLogin(mc ModelConfig) bool {
699
- - return mc.HexID == hexPro31 || mc.HexID == hexFlash38 || mc.Thinking || mc.Tool > 0
700
- + return mc.HexID == hexArgon4 || mc.HexID == hexPro31 || mc.HexID == hexFlash38 || mc.Thinking || mc.Tool > 0
701
- }
702
-
703
- // anonFirstEligible 判断「匿名优先」开关下这次请求能不能走匿名(不占 cookie 账号):
704
- @@ -298,7 +302,7 @@ func (d *deltaTracker) Push(fullText string) string {
876
+ @@ -311,7 +313,7 @@ func (d *deltaTracker) Push(fullText string) string {
705
877
  // 自己做。一旦已经吐过内容就不再重试——重试会让客户端收到重复文本。
706
878
  func streamGenerate(prompt, latest string, mc ModelConfig,
707
879
  onDelta, onReasoning func(string)) (*StreamResult, error) {
@@ -710,7 +882,7 @@ index 200c728..83256f3 100644
710
882
  }
711
883
 
712
884
  // fileRef 是一个已上传附件的引用。
713
- @@ -313,8 +317,16 @@ type fileRef struct {
885
+ @@ -326,8 +328,16 @@ type fileRef struct {
714
886
  //
715
887
  // 附件填 inner[0][3],形状 [[[ref, 1], "文件名"], …]。附件只在登录态可用:
716
888
  // 匿名能把文件传上去,但对话里一引用就被服务端回 1100。
@@ -729,7 +901,7 @@ index 200c728..83256f3 100644
729
901
  var files []fileRef
730
902
 
731
903
  // 先挑号,再按它上次绑的出口挑代理 —— 同一个账号要尽量固定从同一个 IP 出去,
732
- @@ -324,6 +336,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
904
+ @@ -337,6 +347,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
733
905
  // 不发请求,真正发请求的是下面的 getXSRF,它在拿到 slot 之后。
734
906
  // #20 匿名优先:不需要登录态能力时不占用 cookie 账号,走匿名省额度(见 anonFirstEligible)。
735
907
  // 带附件(图/视频)时不能走匿名——下面 uploadBytes 那段会因 cookieStr=="" 直接报错。
@@ -739,7 +911,7 @@ index 200c728..83256f3 100644
739
911
  var acct *CookieAccount
740
912
  if !anonFirstEligible(mc, len(pending) > 0) {
741
913
  if a, ok := pickCookieAccount(); ok {
742
- @@ -379,6 +394,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
914
+ @@ -392,6 +405,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
743
915
  // 每个号只给一次「轮转后重试」的机会,避免在一个请求里反复打 accounts.google.com
744
916
  rotatedOnce := map[int64]bool{}
745
917
  for acct != nil && len(tried) < maxCookieTries {
@@ -749,7 +921,7 @@ index 200c728..83256f3 100644
749
921
  tried[acct.ID] = true
750
922
  // 归属先记上:这一轮失败了也留痕,面板上看得出是哪个号在坏
751
923
  cookieID, cookieLabel = acct.ID, accountDisplayName(acct)
752
- @@ -406,6 +424,8 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
924
+ @@ -419,6 +435,8 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
753
925
  rotatedOnce[acct.ID] = true
754
926
  if _, _, rerr := rotateAccount(*acct); rerr == nil {
755
927
  if fresh := accountByID(acct.ID); fresh != nil {
@@ -758,7 +930,7 @@ index 200c728..83256f3 100644
758
930
  if tok2, err2 := getXSRF(fresh.Cookie, proxyURL); err2 == nil {
759
931
  logf("[cookie] 账号 #%d 轮转后恢复可用", acct.ID)
760
932
  acct = fresh
761
- @@ -445,7 +465,10 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
933
+ @@ -458,7 +476,10 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
762
934
  }
763
935
  nImg, nVid := 0, 0
764
936
  for _, u := range pending {
@@ -770,7 +942,7 @@ index 200c728..83256f3 100644
770
942
  if uerr != nil {
771
943
  return attrib(fmt.Errorf("上传附件 %s 失败: %w", u.Name, uerr))
772
944
  }
773
- @@ -462,7 +485,10 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
945
+ @@ -475,7 +496,10 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
774
946
  // prompt 超长时转成文本附件。要等挑完号和出口才能做:上传要 cookie,
775
947
  // 而且必须走跟正式请求同一个出口。
776
948
  budget := rtCfg().MaxPromptBytes
@@ -782,7 +954,7 @@ index 200c728..83256f3 100644
782
954
  return attrib(ferr)
783
955
  } else if used {
784
956
  prompt = p
785
- @@ -583,32 +609,18 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
957
+ @@ -596,32 +620,18 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
786
958
  xsrfRetried := false // XSRF 自愈只做一次,避免死循环
787
959
  t0 := time.Now()
788
960
 
@@ -824,7 +996,7 @@ index 200c728..83256f3 100644
824
996
  if len(setCookie) > 0 && cookieID > 0 {
825
997
  if merged := mergeSetCookie(cookieStr, setCookie); merged != cookieStr {
826
998
  cookieStr = merged
827
- @@ -616,6 +628,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
999
+ @@ -629,6 +639,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
828
1000
  }
829
1001
  }
830
1002
  if err != nil {
@@ -834,7 +1006,7 @@ index 200c728..83256f3 100644
834
1006
  lastErr = err
835
1007
  if pickedOK {
836
1008
  recordProxyResult(picked.ID, false, err.Error())
837
- @@ -626,7 +641,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1009
+ @@ -639,7 +652,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
838
1010
  }
839
1011
  if attempt < rtCfg().RetryAttempts-1 {
840
1012
  logf("retry %d/%d: %v", attempt+1, rtCfg().RetryAttempts, err)
@@ -845,7 +1017,7 @@ index 200c728..83256f3 100644
845
1017
  }
846
1018
  continue
847
1019
  }
848
- @@ -654,7 +671,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1020
+ @@ -667,7 +682,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
849
1021
  break
850
1022
  }
851
1023
  if attempt < rtCfg().RetryAttempts-1 {
@@ -856,7 +1028,7 @@ index 200c728..83256f3 100644
856
1028
  }
857
1029
  continue
858
1030
  }
859
- @@ -677,7 +696,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1031
+ @@ -690,7 +707,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
860
1032
  }
861
1033
  if attempt < rtCfg().RetryAttempts-1 {
862
1034
  logf("retry %d/%d: 空响应(无内容帧,%d 字节)", attempt+1, rtCfg().RetryAttempts, len(raw))
@@ -867,7 +1039,7 @@ index 200c728..83256f3 100644
867
1039
  }
868
1040
  continue
869
1041
  }
870
- @@ -737,6 +758,54 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1042
+ @@ -750,6 +769,54 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
871
1043
  return attrib(lastErr)
872
1044
  }
873
1045
 
@@ -922,7 +1094,7 @@ index 200c728..83256f3 100644
922
1094
  // upstreamModelRe 匹配响应帧里服务端自报的模型显示名(帧的 [42] 位)。
923
1095
  // 形如 ...,"fbb127bbb056c959",null,null,"3.6 Flash",true,...
924
1096
  var upstreamModelRe = regexp.MustCompile(`\\"[0-9a-f]{16}\\",null,null,\\"([^"\\]{1,40})\\"`)
925
- @@ -806,10 +875,43 @@ func buildGeminiHeaders(cookieStr, sapisid, hexID string) map[string]string {
1097
+ @@ -819,10 +886,43 @@ func buildGeminiHeaders(cookieStr, sapisid, hexID string) map[string]string {
926
1098
  // 实测号活一两小时就失效 —— 所以这些必须收下来并写回账号。
927
1099
  func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL string,
928
1100
  onLine func(string)) (int, []byte, int64, []string, error) {
@@ -967,7 +1139,7 @@ index 200c728..83256f3 100644
967
1139
  if err != nil {
968
1140
  return 0, nil, 0, nil, err
969
1141
  }
970
- @@ -823,7 +925,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1142
+ @@ -836,7 +936,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
971
1143
  return 0, nil, 0, nil, err
972
1144
  }
973
1145
  defer resp.Body.Close()
@@ -976,7 +1148,7 @@ index 200c728..83256f3 100644
976
1148
  if err != nil {
977
1149
  return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
978
1150
  }
979
- @@ -831,7 +933,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1151
+ @@ -844,7 +944,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
980
1152
  }
981
1153
 
982
1154
  // 直连 → tls-client,保留 chrome146 TLS/HTTP2 真指纹
@@ -985,7 +1157,7 @@ index 200c728..83256f3 100644
985
1157
  if err != nil {
986
1158
  return 0, nil, 0, nil, err
987
1159
  }
988
- @@ -844,7 +946,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1160
+ @@ -857,7 +957,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
989
1161
  return 0, nil, 0, nil, err
990
1162
  }
991
1163
  defer resp.Body.Close()
@@ -995,7 +1167,7 @@ index 200c728..83256f3 100644
995
1167
  return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
996
1168
  }
997
1169
  diff --git a/internal/app/gemini_test.go b/internal/app/gemini_test.go
998
- index 9dc2541..f709b15 100644
1170
+ index 00eb305..2b4c6ca 100644
999
1171
  --- a/internal/app/gemini_test.go
1000
1172
  +++ b/internal/app/gemini_test.go
1001
1173
  @@ -15,6 +15,7 @@ func TestModelHeader(t *testing.T) {
@@ -1006,23 +1178,6 @@ index 9dc2541..f709b15 100644
1006
1178
  }
1007
1179
  for _, c := range cases {
1008
1180
  name, mc, err := resolveModel(c.name)
1009
- @@ -50,13 +51,13 @@ func TestModelHeader(t *testing.T) {
1010
- }
1011
- }
1012
- // 守的是「不许冒出假模型」:thinking 版复用同样的 hex,所以看**去重后的 hex 数**,
1013
- - // 不是条目数。服务端清单(otAQ7b)里 3.6/3.5-lite/3.1-pro + 灰度放出的 3.7 = 4 个。
1014
- + // 不是条目数。服务端清单(otAQ7b)里 3.6/3.5-lite/3.1-pro + 灰度放出的 3.7 + 4-argon = 5 个。
1015
- hexes := map[string]bool{}
1016
- for _, m := range Models {
1017
- hexes[m.HexID] = true
1018
- }
1019
- - if len(hexes) != 4 {
1020
- - t.Errorf("只应存在 4 个真模型 hex, got %d", len(hexes))
1021
- + if len(hexes) != 5 {
1022
- + t.Errorf("只应存在 5 个真模型 hex, got %d", len(hexes))
1023
- }
1024
- // 反过来:每个真 hex 都应该有一个 thinking 版
1025
- for _, base := range []string{hexFlash36, hexFlashLite, hexPro31, hexFlash38} {
1026
1181
  diff --git a/internal/app/latency_test.go b/internal/app/latency_test.go
1027
1182
  new file mode 100644
1028
1183
  index 0000000..9329309
@@ -1546,7 +1701,7 @@ index e082b15..908a052 100644
1546
1701
  if len(names) > 0 {
1547
1702
  logf("[rotate] 账号 #%d 刷新了 %s", a.ID, strings.Join(names, ", "))
1548
1703
  diff --git a/internal/app/server.go b/internal/app/server.go
1549
- index 2ce8d11..4ae16b2 100644
1704
+ index c3a7df1..eb02909 100644
1550
1705
  --- a/internal/app/server.go
1551
1706
  +++ b/internal/app/server.go
1552
1707
  @@ -1,6 +1,7 @@
@@ -1561,8 +1716,8 @@ index 2ce8d11..4ae16b2 100644
1561
1716
  "time"
1562
1717
  )
1563
1718
 
1564
- -const Version = "4.21.0"
1565
- +const Version = "4.21.0-argon.2"
1719
+ -const Version = "4.22.0"
1720
+ +const Version = "4.22.0-argon.1"
1566
1721
 
1567
1722
  func writeJSON(w http.ResponseWriter, status int, data interface{}) {
1568
1723
  body, _ := json.Marshal(data)
@@ -9,7 +9,7 @@ import { installBackend, startBackend, getDataDir } from '../lib/native.mjs';
9
9
  const root = fileURLToPath(new URL('..', import.meta.url));
10
10
  const source = path.join(root, 'backend-source');
11
11
  const build = path.join(root, 'backend-build');
12
- const pinned = '93d9b384b2453c495942080f5838b8e71d6c1e6f';
12
+ const pinned = 'a65b1d1a5ac614c1b455857334b4f1c73ab8fcfb';
13
13
 
14
14
  const command = process.argv[2];
15
15
 
@@ -41,7 +41,7 @@ async function main() {
41
41
  const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
42
42
 
43
43
  const manifest = {
44
- version: '4.21.0-argon.2',
44
+ version: '4.22.0-argon.1',
45
45
  sourceCommit: pinned,
46
46
  sha256,
47
47
  platform: 'darwin',