magpie-gemini-web 0.1.1 → 0.1.3
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 +54 -15
- package/lib/backend.mjs +45 -1
- package/lib/native.mjs +3 -3
- package/package.json +1 -1
- package/patches/argon.patch +237 -82
- package/scripts/backend-argon.mjs +2 -2
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.
|
|
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
|
-
|
|
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.
|
|
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
|
>
|
|
@@ -124,18 +140,39 @@ http://127.0.0.1:8083/admin
|
|
|
124
140
|
| `requestTimeoutMs`| integer | `240000` | 转发单次请求的超时时间(毫秒,默认 4 分钟) |
|
|
125
141
|
| `modelTimeoutMs` | integer | `10000` | 获取模型列表的超时时间(毫秒,默认 10 秒) |
|
|
126
142
|
|
|
143
|
+
### 思考档位(effort)
|
|
144
|
+
|
|
145
|
+
后端没有 effort 参数,扩展思考是单独的 `-thinking` 模型。插件把有 `-thinking` 版的模型声明为 `low` / `high` 两档,可以在 Magpie 里直接切换:
|
|
146
|
+
|
|
147
|
+
| 档位 | 实际调用 | 说明 |
|
|
148
|
+
| :--- | :--- | :--- |
|
|
149
|
+
| `low`(或不指定) | `gemini-x` | 网页端默认档:Argon 为「低」档,Flash 不输出思考链,3.1 Pro 仍会少量思考 |
|
|
150
|
+
| `high` | `gemini-x-thinking` | 网页端扩展思考(上游显示为 High),带 `reasoning_content` |
|
|
151
|
+
|
|
152
|
+
- 插件读取请求里的 `reasoning_effort`:`none` / `minimal` / `low` 走普通版,其他值(如 `medium`、`xhigh`)走 `-thinking` 版。经 Magpie 调用时,Magpie 会先把 Responses API 的 `reasoning.effort`、Anthropic Messages 的 `thinking.budget_tokens` 换算成最接近的已声明档位;Anthropic 的 `output_config.effort` 不会被转发。
|
|
153
|
+
- 直接选 `-thinking` 模型照常可用,档位设置对它不生效。
|
|
154
|
+
- 网页端 Argon 的「中」档上游尚未支持(见 [#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
|
|
155
|
+
- 升级插件后,执行 `magpie provider models gemini-web` 重新拉取模型列表,档位才会出现。
|
|
156
|
+
|
|
127
157
|
---
|
|
128
158
|
|
|
129
159
|
## 5. 本地开发与实验性 Argon 支持 (Local Development & Argon Support)
|
|
130
160
|
|
|
131
|
-
|
|
161
|
+
官方后端自 v4.22.0 起已支持 Gemini 4 Argon。本项目的补丁版在其基础上增加 Argon 身份校验与延迟优化,适合拥有 Argon 访问权限(付费 / Ultra)的账号:
|
|
132
162
|
|
|
133
163
|
### 5.1 架构与协议保护机制
|
|
134
|
-
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
-
|
|
164
|
+
- **模型标识**(模型定义来自上游 v4.22.0):均为 HexID `95a4c0abcba24364`、Mode 8。
|
|
165
|
+
|
|
166
|
+
| 模型 | 网页思考档位 | 上游自报模型名 |
|
|
167
|
+
| :--- | :--- | :--- |
|
|
168
|
+
| `gemini-4-argon` | 低 | `4 Argon` |
|
|
169
|
+
| `gemini-4-argon-thinking` | 高(带 `reasoning_content`) | `4 Argon High` |
|
|
170
|
+
|
|
171
|
+
网页端的「中」档上游尚未支持(见 [#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
|
|
172
|
+
- **账号权限要求**:必须在后端管理面板配置拥有 Argon 访问权限的 Google 登录 Cookie;无 Cookie 时后端自动隐藏这两个模型。
|
|
173
|
+
- **结构化元数据校验**:由于 Google Web 对无权限、Cookie 失效或配额耗尽的请求会静默降级,后端在底层响应解析层(`argon.go`)对 Argon 请求实行严格的结构化元数据身份校验(解析 `wrb.fr` payload 中的模型 hex 与显示名,而非依赖模型自述文本),并按请求的思考档位只接受对应的显示名(普通档 `4 Argon`、扩展思考 `4 Argon High`),档位被静默替换同样视为不符。流式请求逐帧校验:确认 Argon 身份之前的帧先暂存、不发给客户端,确认后放行并继续增量转发(实测身份与首段正文同帧到达)。身份缺失或首次出现即不符时请求报错,不发送任何未验证内容;若放行之后上游又报告了其他模型,后端立即停止读取并发送流错误,但已发出的内容无法撤回。非流式请求仍完整读取后再校验。
|
|
174
|
+
- **协议验证状态**:`4.22.0-argon.1` 已通过真实 Magpie 网关验证两档的普通对话和 SSE:`gemini-4-argon` 后端记录实际模型 `4 Argon`,`gemini-4-argon-thinking` 记录 `4 Argon High` 并返回 `reasoning_content`。工具调用沿用 `4.21.0-argon.2` 的结论:首轮工具调用名称与参数正确,第二轮含正确测试值但附带额外说明,不代表工具兼容性已完全解决。本地验收记录不随公开仓库或 npm 包分发。
|
|
175
|
+
- **延迟优化**(自 `4.21.0-argon.2` 起;2026-10-14 直连后端实测,单次样本、上游波动大,仅供参考):
|
|
139
176
|
- XSRF token 按账号身份缓存,不再因每次响应刷新 SIDCC 而失效,登录态请求的前置开销由约 0.9–1.3 秒降至约 10 毫秒;Cookie 轮转后在后台重取 token。
|
|
140
177
|
- Argon 流式首字约 5.4 秒(同次总耗时 9.8 秒),不再等待整段生成结束。
|
|
141
178
|
- 工具调用块闭合且函数名有效时即结束读取上游并返回 `tool_calls`,Argon 首轮工具调用约 6.1 秒。
|
|
@@ -147,17 +184,19 @@ http://127.0.0.1:8083/admin
|
|
|
147
184
|
需要 upstream `go.mod` 声明的 Go 版本(Go 1.26+)与 Git。从插件根目录执行:
|
|
148
185
|
```bash
|
|
149
186
|
git clone https://github.com/zexadev/gemini-web2api-go.git backend-source
|
|
150
|
-
git -C backend-source checkout
|
|
187
|
+
git -C backend-source checkout a65b1d1a5ac614c1b455857334b4f1c73ab8fcfb
|
|
151
188
|
git -C backend-source apply ../patches/argon.patch
|
|
152
189
|
```
|
|
153
190
|
|
|
154
|
-
源码基于 upstream tag `v4.
|
|
191
|
+
源码基于 upstream tag `v4.22.0`(锁定 commit `a65b1d1a5ac614c1b455857334b4f1c73ab8fcfb`)。
|
|
192
|
+
|
|
193
|
+
> **从 `4.21.0-argon.*` 升级**:旧补丁新增的文件会与新补丁冲突,请删除 `backend-source/` 后按上面三行重新获取源码,再从下方第 1 步开始。
|
|
155
194
|
|
|
156
195
|
1. **构建 Argon 增强版后端**:
|
|
157
196
|
```bash
|
|
158
197
|
npm run backend:build:argon
|
|
159
198
|
```
|
|
160
|
-
该命令将在 `backend-source/` 目录执行 Go 编译,并在 `backend-build/` 生成校验清单 `manifest.json` 与二进制(版本 `4.
|
|
199
|
+
该命令将在 `backend-source/` 目录执行 Go 编译,并在 `backend-build/` 生成校验清单 `manifest.json` 与二进制(版本 `4.22.0-argon.1`)。
|
|
161
200
|
|
|
162
201
|
2. **安装到本地数据目录**:
|
|
163
202
|
```bash
|
|
@@ -170,7 +209,7 @@ git -C backend-source apply ../patches/argon.patch
|
|
|
170
209
|
npm run backend:start:argon
|
|
171
210
|
```
|
|
172
211
|
|
|
173
|
-
> **版本切换提示**:如果重新执行官方标准的 `npm run backend:install
|
|
212
|
+
> **版本切换提示**:如果重新执行官方标准的 `npm run backend:install`,将自动下载官方原版二进制并覆盖当前版本:Argon 模型仍可用,但不再有身份校验与延迟优化;重新执行 `npm run backend:install:argon` 即可切回 Argon 版本。
|
|
174
213
|
|
|
175
214
|
---
|
|
176
215
|
|
package/lib/backend.mjs
CHANGED
|
@@ -4,6 +4,14 @@ const MEDIA_SPECIAL = /(?:^|[-_])(image|music|canvas|video|research)(?:[-_]|$)/i
|
|
|
4
4
|
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
5
5
|
const NEWLINE_CHARS = /[\r\n]/;
|
|
6
6
|
|
|
7
|
+
// The backend has no effort parameter: extended thinking is a separate "<id>-thinking" model.
|
|
8
|
+
// A base model with such a twin offers low (itself) and high (the twin). Magpie maps any other
|
|
9
|
+
// level to the nearest declared one before it reaches us; direct callers get the same rule.
|
|
10
|
+
const THINKING_SUFFIX = '-thinking';
|
|
11
|
+
const LOW_EFFORTS = new Set(['none', 'minimal', 'low']);
|
|
12
|
+
const MODEL_CACHE_MS = 10 * 60 * 1000;
|
|
13
|
+
const knownModels = new Map();
|
|
14
|
+
|
|
7
15
|
const KNOWN_OPTIONS = new Set([
|
|
8
16
|
'baseURL',
|
|
9
17
|
'allowTools',
|
|
@@ -253,8 +261,12 @@ export async function listModels(c, opts) {
|
|
|
253
261
|
throw new Error('No usable Gemini models found in backend response');
|
|
254
262
|
}
|
|
255
263
|
|
|
264
|
+
const ids = new Set(validIds);
|
|
265
|
+
knownModels.set(modelCacheKey(c), { ids, at: Date.now() });
|
|
266
|
+
|
|
256
267
|
const result = Object.create(null);
|
|
257
268
|
for (const id of validIds) {
|
|
269
|
+
const hasTwin = !id.endsWith(THINKING_SUFFIX) && ids.has(id + THINKING_SUFFIX);
|
|
258
270
|
result[id] = {
|
|
259
271
|
id,
|
|
260
272
|
providerID: 'gemini-web',
|
|
@@ -291,7 +303,7 @@ export async function listModels(c, opts) {
|
|
|
291
303
|
},
|
|
292
304
|
options: {},
|
|
293
305
|
headers: {},
|
|
294
|
-
variants: {},
|
|
306
|
+
variants: hasTwin ? { low: {}, high: {} } : {},
|
|
295
307
|
status: 'active'
|
|
296
308
|
};
|
|
297
309
|
}
|
|
@@ -299,6 +311,32 @@ export async function listModels(c, opts) {
|
|
|
299
311
|
return result;
|
|
300
312
|
}
|
|
301
313
|
|
|
314
|
+
function modelCacheKey(c) {
|
|
315
|
+
return `${c.baseURL}\n${c.key}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// thinkingTwin returns the model a chat request should run on for its reasoning_effort, or null
|
|
319
|
+
// to leave it alone. The twin is only used when the backend lists it; if the list can't be
|
|
320
|
+
// fetched the request goes out unchanged.
|
|
321
|
+
async function thinkingTwin(body, c, opts) {
|
|
322
|
+
const effort = body.reasoning_effort;
|
|
323
|
+
const model = body.model;
|
|
324
|
+
if (typeof effort !== 'string' || LOW_EFFORTS.has(effort)) return null;
|
|
325
|
+
if (typeof model !== 'string' || model.endsWith(THINKING_SUFFIX)) return null;
|
|
326
|
+
|
|
327
|
+
let cached = knownModels.get(modelCacheKey(c));
|
|
328
|
+
if (!cached || Date.now() - cached.at > MODEL_CACHE_MS) {
|
|
329
|
+
try {
|
|
330
|
+
await listModels(c, opts);
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
cached = knownModels.get(modelCacheKey(c));
|
|
335
|
+
}
|
|
336
|
+
const twin = model + THINKING_SUFFIX;
|
|
337
|
+
return cached?.ids.has(twin) ? twin : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
302
340
|
export async function forward(input, init, getAuth, opts) {
|
|
303
341
|
const req = new Request(input, init);
|
|
304
342
|
|
|
@@ -417,6 +455,12 @@ export async function forward(input, init, getAuth, opts) {
|
|
|
417
455
|
}
|
|
418
456
|
}
|
|
419
457
|
}
|
|
458
|
+
|
|
459
|
+
const twin = await thinkingTwin(body, c, opts);
|
|
460
|
+
if (twin) {
|
|
461
|
+
body.model = twin;
|
|
462
|
+
rawBody = JSON.stringify(body);
|
|
463
|
+
}
|
|
420
464
|
}
|
|
421
465
|
|
|
422
466
|
const timeoutSignal = AbortSignal.timeout(opts.requestTimeoutMs);
|
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 = '
|
|
8
|
-
export const PINNED_URL = 'https://github.com/zexadev/gemini-web2api-go/releases/download/v4.
|
|
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.
|
|
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
package/patches/argon.patch
CHANGED
|
@@ -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..
|
|
3
|
+
index 0000000..98944ac
|
|
4
4
|
--- /dev/null
|
|
5
5
|
+++ b/internal/app/argon.go
|
|
6
|
-
@@ -0,0 +1,
|
|
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
|
|
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 || (
|
|
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..
|
|
242
|
+
index 0000000..3e62433
|
|
162
243
|
--- /dev/null
|
|
163
244
|
+++ b/internal/app/argon_test.go
|
|
164
|
-
@@ -0,0 +1,
|
|
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
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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
|
|
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
|
|
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.
|
|
1565
|
-
+const Version = "4.
|
|
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 = '
|
|
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.
|
|
44
|
+
version: '4.22.0-argon.1',
|
|
45
45
|
sourceCommit: pinned,
|
|
46
46
|
sha256,
|
|
47
47
|
platform: 'darwin',
|