magpie-gemini-web 0.3.2 → 0.3.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.en.md +20 -2
- package/README.md +24 -2
- package/index.mjs +8 -5
- package/lib/backend.mjs +4 -2
- package/lib/usage.mjs +118 -16
- package/package.json +1 -1
- package/patches/argon.patch +231 -51
- package/scripts/backend-argon.mjs +1 -1
package/README.en.md
CHANGED
|
@@ -43,7 +43,7 @@ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
|
|
|
43
43
|
|
|
44
44
|
## Thinking effort
|
|
45
45
|
|
|
46
|
-
You can switch a model between `low` and `high` effort in Magpie if it has a `-thinking` version. `low` uses the base model and `high` uses the `-thinking` model with extended thinking. After upgrading the plugin, run `magpie provider models gemini-web` to refresh the model list. Argon's medium tier will be added once upstream supports it ([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5)).
|
|
46
|
+
You can switch a model between `low` and `high` effort in Magpie if it has a `-thinking` version. `low` uses the base model and `high` uses the `-thinking` model with extended thinking, so the `-thinking` model isn't listed on its own. After upgrading the plugin, run `magpie provider models gemini-web` to refresh the model list. Argon's medium tier will be added once upstream supports it ([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5)).
|
|
47
47
|
|
|
48
48
|
## Security and data
|
|
49
49
|
|
|
@@ -61,6 +61,15 @@ Magpie's usage cards show each Google account's **5-hour** and **weekly** usage
|
|
|
61
61
|
|
|
62
62
|
Note that the backend picks the Google account for each request from its pool in turn: how Magpie spreads requests over the cards doesn't decide which account serves them. Only the one-click local backend has usage; a backend you connect to yourself keeps its cookies out of the plugin's reach.
|
|
63
63
|
|
|
64
|
+
To send Google fewer requests, usage isn't read on every refresh. An account is read again once the backend has used it or one of its windows has reset, and never twice within 10 minutes. An idle account is read at most every 6 hours, which also picks up what you used in the Gemini web app.
|
|
65
|
+
|
|
66
|
+
Usage and emails are read the way the backend reaches Google for that account. When that way isn't certain, nothing is read and the card says why:
|
|
67
|
+
|
|
68
|
+
- the backend has a proxy pool, but the account isn't bound to a proxy yet (the backend binds it on its first request);
|
|
69
|
+
- the account's proxy is disabled, or is a SOCKS proxy.
|
|
70
|
+
|
|
71
|
+
So with a proxy pool, an account just added by one-click setup has no email yet. Send one request with it, sign in to it again with **「添加本地后端里已有的 Google 账号」**, then remove the sign-in without an email.
|
|
72
|
+
|
|
64
73
|
## Limitations
|
|
65
74
|
|
|
66
75
|
- Upstream emulates tool calls with prompts, so they aren't always reliable. Flash models often don't call tools.
|
|
@@ -68,7 +77,7 @@ Note that the backend picks the Google account for each request from its pool in
|
|
|
68
77
|
|
|
69
78
|
## Argon patched build (optional)
|
|
70
79
|
|
|
71
|
-
The official backend already supports `gemini-4-argon`, which needs a paid or Ultra account. This repo's patched build also verifies that responses really come from Argon, so a silent downgrade is caught, and it lowers latency. Building it requires Go 1.26+:
|
|
80
|
+
The official backend already supports `gemini-4-argon`, which needs a paid or Ultra account. This repo's patched build also verifies that responses really come from Argon, so a silent downgrade is caught, and it lowers latency. It also makes the backend's requests look more like a browser's: on a direct connection every request carries the full set of Chrome headers (the official build's direct chat requests say `Go-http-client/2.0`), and refreshing the cookies to keep a session alive no longer fetches the `/app` page right away. Building it requires Go 1.26+:
|
|
72
81
|
|
|
73
82
|
```bash
|
|
74
83
|
git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git && cd magpie-gemini-web
|
|
@@ -81,6 +90,15 @@ npm run backend:install:argon
|
|
|
81
90
|
|
|
82
91
|
Restart Magpie when the install finishes, and the plugin starts the Argon build in the background. If an older backend is still running, stop it first with `kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`. Run `npm run backend:install` to switch back to the official build.
|
|
83
92
|
|
|
93
|
+
When `patches/argon.patch` changes (after you pull a new version of this repo, say), apply it again to clean sources and rebuild. Once the old backend is stopped, the plugin starts the new one on the next request:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git -C backend-source checkout -- . && git -C backend-source clean -fdq
|
|
97
|
+
git -C backend-source apply ../patches/argon.patch
|
|
98
|
+
npm run backend:build:argon && npm run backend:install:argon
|
|
99
|
+
kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)
|
|
100
|
+
```
|
|
101
|
+
|
|
84
102
|
## License
|
|
85
103
|
|
|
86
104
|
MIT. The upstream gemini-web2api-go project is also MIT (see `LICENSE-UPSTREAM`).
|
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
|
|
|
43
43
|
|
|
44
44
|
## 思考档位
|
|
45
45
|
|
|
46
|
-
有 `-thinking` 版本的模型可以在 Magpie 里切换 `low` / `high`:`low` 用普通模型,`high` 用 `-thinking`
|
|
46
|
+
有 `-thinking` 版本的模型可以在 Magpie 里切换 `low` / `high`:`low` 用普通模型,`high` 用 `-thinking` 模型(扩展思考)。`-thinking` 模型因此不再单独列出。升级插件后执行 `magpie provider models gemini-web` 刷新模型列表。Argon 的「中」档等上游支持([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
|
|
47
47
|
|
|
48
48
|
## 安全与数据
|
|
49
49
|
|
|
@@ -61,6 +61,15 @@ Magpie 的额度卡片会显示 Google 账号的 **5 小时** 和 **每周** 用
|
|
|
61
61
|
|
|
62
62
|
注意:请求最终由后端在账号池里轮流挑选 Google 账号,Magpie 在几张卡片之间怎么分配请求,不决定实际用的是哪个账号。只有一键配置的本地后端能显示用量;「连接已有后端」模式下读不到账号的 Cookie。
|
|
63
63
|
|
|
64
|
+
为了少向 Google 发请求,用量不会每次刷新都去查:账号经后端用过、或者某个窗口到了重置时间,才重新读取,两次至少间隔 10 分钟;账号闲着时最多每 6 小时读一次(顺带反映你在 Gemini 网页里的用量)。
|
|
65
|
+
|
|
66
|
+
读取用量和邮箱时,走的是后端访问 Google 的同一个出口。出口不确定时不读取,卡片上会写明原因:
|
|
67
|
+
|
|
68
|
+
- 配了代理池,但账号还没绑定代理(后端在它第一次请求时绑定);
|
|
69
|
+
- 账号绑定的代理已停用,或者是 SOCKS 代理。
|
|
70
|
+
|
|
71
|
+
所以配了代理池时,一键配置刚添加的账号暂时没有邮箱。先用它发一次请求,再用「添加本地后端里已有的 Google 账号」重新登录它,然后删掉没有邮箱的那个登录。
|
|
72
|
+
|
|
64
73
|
## 局限
|
|
65
74
|
|
|
66
75
|
- 工具调用由上游用提示词模拟,不一定可靠,Flash 系列经常不调用工具。
|
|
@@ -68,7 +77,7 @@ Magpie 的额度卡片会显示 Google 账号的 **5 小时** 和 **每周** 用
|
|
|
68
77
|
|
|
69
78
|
## Argon 增强版(可选)
|
|
70
79
|
|
|
71
|
-
官方后端已支持 `gemini-4-argon`(需付费 / Ultra 账号)。本仓库的补丁版额外校验返回的确实是 Argon
|
|
80
|
+
官方后端已支持 `gemini-4-argon`(需付费 / Ultra 账号)。本仓库的补丁版额外校验返回的确实是 Argon(防止被静默降级),并降低延迟。它也让后端的请求更像浏览器:直连时每个请求都带齐 Chrome 请求头(官方版直连时,对话请求的 UA 是 `Go-http-client/2.0`),保活刷新 Cookie 后也不再立即重新抓取 `/app` 页面。需要 Go 1.26+:
|
|
72
81
|
|
|
73
82
|
```bash
|
|
74
83
|
git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git && cd magpie-gemini-web
|
|
@@ -81,6 +90,19 @@ npm run backend:install:argon
|
|
|
81
90
|
|
|
82
91
|
安装后重启 Magpie 即可,插件会在后台启动 Argon 版(如有旧后端在运行,先 `kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`)。运行 `npm run backend:install` 可切回官方版。
|
|
83
92
|
|
|
93
|
+
`patches/argon.patch` 更新后(比如拉取了本仓库的新版本),需要在干净的源码上重新应用补丁并重新构建。停掉旧后端后,插件会在下一个请求时启动新版:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git -C backend-source checkout -- . && git -C backend-source clean -fdq
|
|
97
|
+
git -C backend-source apply ../patches/argon.patch
|
|
98
|
+
npm run backend:build:argon && npm run backend:install:argon
|
|
99
|
+
kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 友链
|
|
103
|
+
|
|
104
|
+
[LINUX DO](https://linux.do/) — 真诚、友善、团结、专业的技术社区。
|
|
105
|
+
|
|
84
106
|
## 许可证
|
|
85
107
|
|
|
86
108
|
MIT。上游 gemini-web2api-go 同为 MIT(见 `LICENSE-UPSTREAM`)。
|
package/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { normalizeBaseURL, settings, credentials, listModels, forward, UNREACHABLE_HEADER } from './lib/backend.mjs';
|
|
2
2
|
import { ensureBackend, localBackend } from './lib/native.mjs';
|
|
3
3
|
import { parseCookieInput, importCookie } from './lib/setup.mjs';
|
|
4
|
-
import { usage, poolChoices, accountEmail } from './lib/usage.mjs';
|
|
4
|
+
import { usage, poolChoices, poolAccounts, accountEmail } from './lib/usage.mjs';
|
|
5
5
|
|
|
6
6
|
const ID = 'gemini-web';
|
|
7
7
|
const GEMINI_URL = 'https://gemini.google.com/app';
|
|
@@ -22,7 +22,9 @@ const COOKIE_STEPS = [
|
|
|
22
22
|
// signInFor is the Magpie sign-in for one account of the local backend's pool: the backend's
|
|
23
23
|
// key, with the account's id for its usage card and its Google email, which names the card and
|
|
24
24
|
// keeps each Google account a sign-in of its own (Magpie takes two sign-ins with one email for
|
|
25
|
-
// one account, and two with neither for one when their key is the same).
|
|
25
|
+
// one account, and two with neither for one when their key is the same). The account is the
|
|
26
|
+
// pool's own entry (see poolAccounts), which says the way out to read the email by; without
|
|
27
|
+
// one the email isn't read.
|
|
26
28
|
async function signInFor(local, account) {
|
|
27
29
|
const email = account.email || await accountEmail(account).catch(() => undefined);
|
|
28
30
|
return {
|
|
@@ -96,7 +98,8 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
|
|
|
96
98
|
} catch (err) {
|
|
97
99
|
return { type: 'failed', error: err.message };
|
|
98
100
|
}
|
|
99
|
-
|
|
101
|
+
const account = (await poolAccounts().catch(() => [])).find(a => a.id === added.id);
|
|
102
|
+
return signInFor(local, account || { id: added.id });
|
|
100
103
|
}
|
|
101
104
|
};
|
|
102
105
|
}
|
|
@@ -182,8 +185,8 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
|
|
|
182
185
|
if (!managedBackend(c.baseURL)) {
|
|
183
186
|
return { error: '只能读取插件自己管理的本地后端的额度(连接已有后端时读不到)', windows: [], signIn: 'kept' };
|
|
184
187
|
}
|
|
185
|
-
const account = auth.metadata
|
|
186
|
-
return { ...(await usage(Number.isInteger(account) ? { account } : {})), signIn: 'kept' };
|
|
188
|
+
const { account, email } = auth.metadata || {};
|
|
189
|
+
return { ...(await usage(Number.isInteger(account) ? { account, email } : {})), signIn: 'kept' };
|
|
187
190
|
}
|
|
188
191
|
},
|
|
189
192
|
provider: {
|
package/lib/backend.mjs
CHANGED
|
@@ -7,8 +7,9 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
|
7
7
|
const NEWLINE_CHARS = /[\r\n]/;
|
|
8
8
|
|
|
9
9
|
// The backend has no effort parameter: extended thinking is a separate "<id>-thinking" model.
|
|
10
|
-
// A base model with such a twin offers low (itself) and high (the twin)
|
|
11
|
-
//
|
|
10
|
+
// A base model with such a twin offers low (itself) and high (the twin), and the twin isn't
|
|
11
|
+
// listed on its own; a request that names it still goes through. Magpie maps any other level to
|
|
12
|
+
// the nearest declared one before it reaches us; direct callers get the same rule.
|
|
12
13
|
const THINKING_SUFFIX = '-thinking';
|
|
13
14
|
const LOW_EFFORTS = new Set(['none', 'minimal', 'low']);
|
|
14
15
|
const MODEL_CACHE_MS = 10 * 60 * 1000;
|
|
@@ -285,6 +286,7 @@ export async function listModels(c, opts) {
|
|
|
285
286
|
|
|
286
287
|
const result = Object.create(null);
|
|
287
288
|
for (const id of validIds) {
|
|
289
|
+
if (id.endsWith(THINKING_SUFFIX) && ids.has(id.slice(0, -THINKING_SUFFIX.length))) continue;
|
|
288
290
|
const hasTwin = !id.endsWith(THINKING_SUFFIX) && ids.has(id + THINKING_SUFFIX);
|
|
289
291
|
result[id] = {
|
|
290
292
|
id,
|
package/lib/usage.mjs
CHANGED
|
@@ -23,7 +23,26 @@ const USAGE_VARIABLES = {
|
|
|
23
23
|
}]
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
|
-
|
|
26
|
+
// The browser the backend says it is when it reaches Google (gemini-web2api-go v4.22.0:
|
|
27
|
+
// ChromeUA and applyChromeHeaders in client.go), so these reads come from the same client as
|
|
28
|
+
// its own requests on the account. The TLS handshake is still Bun's, as the backend's own is
|
|
29
|
+
// Go's when it goes through a proxy.
|
|
30
|
+
const CLIENT_HEADERS = {
|
|
31
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
|
|
32
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
33
|
+
'Sec-CH-UA': '"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"',
|
|
34
|
+
'Sec-CH-UA-Mobile': '?0',
|
|
35
|
+
'Sec-CH-UA-Platform': '"Windows"'
|
|
36
|
+
};
|
|
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;
|
|
27
46
|
|
|
28
47
|
// The pools the Usage page shows, under the names Magpie gives the same windows elsewhere.
|
|
29
48
|
const POOLS = [
|
|
@@ -45,12 +64,18 @@ async function openReadOnly(file) {
|
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
// poolAccounts lists the enabled accounts in the backend's pool, each with the proxy it is
|
|
48
|
-
// bound to, if any
|
|
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.
|
|
49
71
|
export async function poolAccounts(dataDir = getDataDir()) {
|
|
50
72
|
const db = await openReadOnly(path.join(dataDir, 'gemini.db'));
|
|
51
73
|
try {
|
|
52
|
-
return db.all(`SELECT a.id, a.label, a.cookie, p.url AS proxy
|
|
53
|
-
|
|
74
|
+
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
|
|
78
|
+
FROM accounts a LEFT JOIN proxies p ON p.id = a.proxy_id
|
|
54
79
|
WHERE a.status = 'enabled' ORDER BY a.id`);
|
|
55
80
|
} finally {
|
|
56
81
|
db.close();
|
|
@@ -71,17 +96,49 @@ export function parseUsage(text) {
|
|
|
71
96
|
throw new Error('Gemini 的回复里没有用量数据');
|
|
72
97
|
}
|
|
73
98
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
99
|
+
// The variables that send Bun's fetch, or Node's with NODE_USE_ENV_PROXY, through a proxy
|
|
100
|
+
// even when the request names none.
|
|
101
|
+
const PROXY_ENV = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
|
|
102
|
+
|
|
103
|
+
// egress is the proxy a read for the account goes through: the way the backend reaches Google
|
|
104
|
+
// for it, since a read from anywhere else would show Google the session at a second address.
|
|
105
|
+
// A read is made only when that way is certain, as poolAccounts tells it:
|
|
106
|
+
// - a pool without proxies (none at all, not just none enabled): the backend goes direct,
|
|
107
|
+
// and so does the read, unless proxy variables would take it elsewhere;
|
|
108
|
+
// - an account bound to an enabled proxy: that proxy, which its requests and keepalive both
|
|
109
|
+
// take. Only Bun's fetch takes a proxy (a fetchFn passed in stands for it), and only an
|
|
110
|
+
// HTTP(S) one;
|
|
111
|
+
// - anything else is unknown: an account not bound yet (the backend binds one on its first
|
|
112
|
+
// request, and keeps it alive direct until then), one bound to a disabled proxy (its
|
|
113
|
+
// requests take another, its keepalive still that one), or one poolAccounts didn't list.
|
|
114
|
+
function egress(account, options) {
|
|
115
|
+
if (account.pooled == null) throw new Error('不知道后端给这个账号走哪个出口,先不读用量');
|
|
116
|
+
if (!account.pooled) {
|
|
117
|
+
const env = options.env ?? process.env;
|
|
118
|
+
const set = PROXY_ENV.find(k => env[k]);
|
|
119
|
+
if (set) throw new Error(`后端直连 Google,但这里设置了 ${set},读取会改走代理,先不读用量`);
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (!account.proxy) throw new Error('这个账号还没绑定代理池里的出口(第一次请求后后端会自动绑定),先不读用量,免得从别的 IP 访问 Google');
|
|
123
|
+
if (!account.proxyEnabled) throw new Error('这个账号绑定的代理已停用,后端的请求和保活会走不同出口,先不读用量');
|
|
124
|
+
if (!/^https?:\/\//i.test(account.proxy) || !(options.fetchFn || globalThis.Bun)) {
|
|
125
|
+
throw new Error('这个账号绑定的代理插件用不了(只支持 HTTP / HTTPS 代理),不读用量,免得从别的 IP 访问 Google');
|
|
126
|
+
}
|
|
127
|
+
return account.proxy;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// geminiRequest is one signed-in request to gemini.google.com as the account, the way out the
|
|
131
|
+
// backend takes (egress). It always names its proxy, none included: Magpie's host gives a fetch
|
|
132
|
+
// that names none Magpie's own proxy, which the backend doesn't take. A redirect is Google
|
|
133
|
+
// sending a dead cookie to sign-in.
|
|
76
134
|
async function geminiRequest(url, account, headers, options) {
|
|
77
135
|
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
78
136
|
const init = {
|
|
79
|
-
headers: { ...headers, Cookie: account.cookie
|
|
137
|
+
headers: { ...CLIENT_HEADERS, ...headers, Cookie: account.cookie },
|
|
80
138
|
redirect: 'manual',
|
|
139
|
+
proxy: egress(account, options),
|
|
81
140
|
signal: AbortSignal.timeout(options.timeoutMs ?? 15000)
|
|
82
141
|
};
|
|
83
|
-
// Bun's fetch takes an HTTP proxy; an account bound to one reaches Google only through it.
|
|
84
|
-
if (/^https?:\/\//i.test(account.proxy || '')) init.proxy = account.proxy;
|
|
85
142
|
const res = await fetchFn(url, init);
|
|
86
143
|
if (res.status >= 300 && res.status < 400 || res.status === 401 || res.status === 403) {
|
|
87
144
|
throw new Error('Cookie 已失效,请重新走一遍一键配置');
|
|
@@ -96,21 +153,61 @@ export async function accountUsage(account, options = {}) {
|
|
|
96
153
|
return parseUsage(await geminiRequest(url, account, {
|
|
97
154
|
Accept: 'application/json',
|
|
98
155
|
Origin: 'https://gemini.google.com',
|
|
99
|
-
Referer: 'https://gemini.google.com/usage'
|
|
156
|
+
Referer: 'https://gemini.google.com/usage',
|
|
157
|
+
'Sec-Fetch-Dest': 'empty',
|
|
158
|
+
'Sec-Fetch-Mode': 'cors',
|
|
159
|
+
'Sec-Fetch-Site': 'same-origin'
|
|
100
160
|
}, options));
|
|
101
161
|
}
|
|
102
162
|
|
|
163
|
+
// The last read of each account: id -> { at, read, pools }, pools once it has succeeded.
|
|
164
|
+
const reads = new Map();
|
|
165
|
+
|
|
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
|
+
}
|
|
178
|
+
|
|
179
|
+
function readUsage(account, options) {
|
|
180
|
+
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));
|
|
184
|
+
reads.set(account.id, entry);
|
|
185
|
+
return entry.read;
|
|
186
|
+
}
|
|
187
|
+
|
|
103
188
|
// The Google account's email, from the signed-in app page's bootstrap data (WIZ_global_data's
|
|
104
|
-
// oPEP7c), kept per backend account: the page is large and the email doesn't change.
|
|
105
|
-
|
|
189
|
+
// 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
|
|
191
|
+
// a card. An account with no certain way out (egress) isn't asked yet, and that isn't a try.
|
|
192
|
+
const emails = new Map(); // id -> { email }, or { at } since the last try that got none
|
|
106
193
|
const EMAIL_FIELD = /"oPEP7c":"([^"@\s]+@[^"\s]+)"/;
|
|
107
194
|
|
|
108
195
|
export async function accountEmail(account, options = {}) {
|
|
109
196
|
const known = emails.get(account.id);
|
|
110
|
-
if (known) return known;
|
|
111
|
-
|
|
197
|
+
if (known?.email) return known.email;
|
|
198
|
+
if (known && Date.now() - known.at < (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return undefined;
|
|
199
|
+
egress(account, options);
|
|
200
|
+
emails.set(account.id, { at: Date.now() });
|
|
201
|
+
const page = await geminiRequest('https://gemini.google.com/app', account, {
|
|
202
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
203
|
+
'Sec-Fetch-Dest': 'document',
|
|
204
|
+
'Sec-Fetch-Mode': 'navigate',
|
|
205
|
+
'Sec-Fetch-Site': 'none',
|
|
206
|
+
'Sec-Fetch-User': '?1',
|
|
207
|
+
'Upgrade-Insecure-Requests': '1'
|
|
208
|
+
}, options);
|
|
112
209
|
const email = EMAIL_FIELD.exec(page)?.[1];
|
|
113
|
-
if (email) emails.set(account.id, email);
|
|
210
|
+
if (email) emails.set(account.id, { email });
|
|
114
211
|
return email;
|
|
115
212
|
}
|
|
116
213
|
|
|
@@ -153,6 +250,7 @@ export function poolWindows(read) {
|
|
|
153
250
|
// usage is the Magpie usage card: one account's 5-hour and weekly windows, as
|
|
154
251
|
// gemini.google.com/usage shows them, when the sign-in names its backend account; else the
|
|
155
252
|
// 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).
|
|
156
254
|
// Magpie hides a card's windows behind an error, so a pool account that can't be read only
|
|
157
255
|
// says so in the tooltip while the others still show.
|
|
158
256
|
export async function usage(options = {}) {
|
|
@@ -165,11 +263,15 @@ export async function usage(options = {}) {
|
|
|
165
263
|
if (options.account !== undefined) {
|
|
166
264
|
accounts = accounts.filter(a => a.id === options.account);
|
|
167
265
|
if (!accounts.length) return { error: `本地后端里已经没有这个账号(#${options.account}),可能被停用或删除了`, windows: [] };
|
|
266
|
+
// The sign-in already names its account's email: no app page to fetch for it.
|
|
267
|
+
if (typeof options.email === 'string' && options.email && !emails.get(options.account)?.email) {
|
|
268
|
+
emails.set(options.account, { email: options.email });
|
|
269
|
+
}
|
|
168
270
|
}
|
|
169
271
|
if (!accounts.length) return { error: '本地后端里没有启用的 Google 账号', windows: [] };
|
|
170
272
|
|
|
171
273
|
const read = await Promise.all(accounts.map(async a => {
|
|
172
|
-
const [u, email] = await Promise.allSettled([
|
|
274
|
+
const [u, email] = await Promise.allSettled([readUsage(a, options), accountEmail(a, options)]);
|
|
173
275
|
const who = (email.status === 'fulfilled' && email.value) || `#${a.id}`;
|
|
174
276
|
return u.status === 'fulfilled' ? { who, windows: windowsOf(u.value.pools) } : { who, error: u.reason?.message || String(u.reason) };
|
|
175
277
|
}));
|
package/package.json
CHANGED
package/patches/argon.patch
CHANGED
|
@@ -699,6 +699,53 @@ index 0000000..e5f1b6f
|
|
|
699
699
|
+ }
|
|
700
700
|
+ t.Fatal("没有记录这次请求")
|
|
701
701
|
+}
|
|
702
|
+
diff --git a/internal/app/client.go b/internal/app/client.go
|
|
703
|
+
index 1750391..e613956 100644
|
|
704
|
+
--- a/internal/app/client.go
|
|
705
|
+
+++ b/internal/app/client.go
|
|
706
|
+
@@ -154,17 +154,32 @@ func makeSAPISIDHash(sapisid string) string {
|
|
707
|
+
return fmt.Sprintf("SAPISIDHASH %d_%s", ts, hex.EncodeToString(h[:]))
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
-// ChromeUA 是给 stdlib 走代理时用的 Chrome 146 真实 UA 模板。
|
|
711
|
+
+// ChromeUA 是 Chrome 146 的真实 UA 模板。
|
|
712
|
+
const ChromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
|
713
|
+
|
|
714
|
+
-// applyChromeHeaders 给 stdlib 请求填上模拟 Chrome 146 的应用层 header。
|
|
715
|
+
-// utls 那层做 TLS/HTTP2 指纹我们做不到(走代理),但 header 一定要齐。
|
|
716
|
+
+// chromeHeaders 是模拟 Chrome 146 的应用层 header,两条传输路径都要带齐:
|
|
717
|
+
+// 走代理时 utls 那层的 TLS/HTTP2 指纹我们做不到(stdlib),但 header 一定要齐;
|
|
718
|
+
+// 直连时 tls-client 只管指纹、不填 header,什么都不带的话 UA 是 Go-http-client/2.0,
|
|
719
|
+
+// 跟 Chrome 的握手对不上,同一个账号也会因为走没走代理而呈现两种客户端。
|
|
720
|
+
+var chromeHeaders = [][2]string{
|
|
721
|
+
+ {"User-Agent", ChromeUA},
|
|
722
|
+
+ {"Sec-CH-UA", `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`},
|
|
723
|
+
+ {"Sec-CH-UA-Mobile", "?0"},
|
|
724
|
+
+ {"Sec-CH-UA-Platform", `"Windows"`},
|
|
725
|
+
+ {"Sec-Fetch-Dest", "empty"},
|
|
726
|
+
+ {"Sec-Fetch-Mode", "cors"},
|
|
727
|
+
+ {"Sec-Fetch-Site", "same-origin"},
|
|
728
|
+
+}
|
|
729
|
+
+
|
|
730
|
+
+// setChromeHeaders 把 chromeHeaders 填进 net/http 或 fhttp 的 Header(两者都有 Set)。
|
|
731
|
+
+// 调用方之后再写自己的 header,同名的以调用方为准。
|
|
732
|
+
+func setChromeHeaders(h interface{ Set(key, value string) }) {
|
|
733
|
+
+ for _, kv := range chromeHeaders {
|
|
734
|
+
+ h.Set(kv[0], kv[1])
|
|
735
|
+
+ }
|
|
736
|
+
+}
|
|
737
|
+
+
|
|
738
|
+
+// applyChromeHeaders 给 stdlib 请求填上 chromeHeaders。
|
|
739
|
+
func applyChromeHeaders(req *http.Request) {
|
|
740
|
+
- req.Header.Set("User-Agent", ChromeUA)
|
|
741
|
+
- req.Header.Set("Sec-CH-UA", `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`)
|
|
742
|
+
- req.Header.Set("Sec-CH-UA-Mobile", "?0")
|
|
743
|
+
- req.Header.Set("Sec-CH-UA-Platform", `"Windows"`)
|
|
744
|
+
- req.Header.Set("Sec-Fetch-Dest", "empty")
|
|
745
|
+
- req.Header.Set("Sec-Fetch-Mode", "cors")
|
|
746
|
+
- req.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
747
|
+
+ setChromeHeaders(req.Header)
|
|
748
|
+
}
|
|
702
749
|
diff --git a/internal/app/context_file.go b/internal/app/context_file.go
|
|
703
750
|
index bfc577b..cb60661 100644
|
|
704
751
|
--- a/internal/app/context_file.go
|
|
@@ -779,7 +826,7 @@ index bfc577b..cb60661 100644
|
|
|
779
826
|
+ return promptParts{body: parts}.join()
|
|
780
827
|
}
|
|
781
828
|
diff --git a/internal/app/conversation.go b/internal/app/conversation.go
|
|
782
|
-
index aef12eb..
|
|
829
|
+
index aef12eb..8ba963f 100644
|
|
783
830
|
--- a/internal/app/conversation.go
|
|
784
831
|
+++ b/internal/app/conversation.go
|
|
785
832
|
@@ -1,6 +1,7 @@
|
|
@@ -790,7 +837,23 @@ index aef12eb..cbf1371 100644
|
|
|
790
837
|
"crypto/sha256"
|
|
791
838
|
"encoding/hex"
|
|
792
839
|
"encoding/json"
|
|
793
|
-
@@ -
|
|
840
|
+
@@ -205,7 +206,7 @@ func getAnonSession(proxyURL string) (string, error) {
|
|
841
|
+
if err != nil {
|
|
842
|
+
return nil, nil, nil, err
|
|
843
|
+
}
|
|
844
|
+
- r.Header.Set("User-Agent", webUA)
|
|
845
|
+
+ setChromeHeaders(r.Header)
|
|
846
|
+
resp, err := getTLSClient().Do(r)
|
|
847
|
+
if err != nil {
|
|
848
|
+
return nil, nil, nil, err
|
|
849
|
+
@@ -237,15 +238,14 @@ func getAnonSession(proxyURL string) (string, error) {
|
|
850
|
+
return strings.Join(pairs, "; "), nil
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
-// webUA 是匿名 GET /app 的 User-Agent(tls-client 路径要手动带)。
|
|
854
|
+
-const webUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
855
|
+
- "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
|
856
|
+
-
|
|
794
857
|
// streamGenerateConv 发一路会话的一轮。turn==0 是首轮(建会话),>0 是续接(只发新消息)。
|
|
795
858
|
// 整条会话绑定 conv.cookie / conv.proxyID,中途不换号不换出口。成功后回填 conv 的
|
|
796
859
|
// cid/rid/rcid/tok26 和 turn+1,供下一轮续接。
|
|
@@ -803,7 +866,7 @@ index aef12eb..cbf1371 100644
|
|
|
803
866
|
p, slotOK, slotErr := acquireSlot(conv.proxyID)
|
|
804
867
|
if !slotOK {
|
|
805
868
|
return &StreamResult{}, slotErr
|
|
806
|
-
@@ -365,13 +
|
|
869
|
+
@@ -365,13 +365,19 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
|
|
807
870
|
t0 := time.Now()
|
|
808
871
|
var lastErr error
|
|
809
872
|
for attempt := 0; attempt < rtCfg().RetryAttempts; attempt++ {
|
|
@@ -824,7 +887,7 @@ index aef12eb..cbf1371 100644
|
|
|
824
887
|
if err == nil && statusCode == 200 {
|
|
825
888
|
lastErr = fmt.Errorf("续接无内容帧(会话可能已失效,raw %d 字节)", len(raw))
|
|
826
889
|
} else if err != nil {
|
|
827
|
-
@@ -386,7 +
|
|
890
|
+
@@ -386,7 +392,9 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
|
|
828
891
|
break
|
|
829
892
|
}
|
|
830
893
|
if attempt < rtCfg().RetryAttempts-1 {
|
|
@@ -835,7 +898,7 @@ index aef12eb..cbf1371 100644
|
|
|
835
898
|
}
|
|
836
899
|
continue
|
|
837
900
|
}
|
|
838
|
-
@@ -427,7 +
|
|
901
|
+
@@ -427,7 +435,7 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
|
|
839
902
|
// - 没命中 → 新建会话,首轮发全量拼接的历史(含 tools 指令,把当前上下文交给服务端)。
|
|
840
903
|
//
|
|
841
904
|
// 支持 tools(续接里也解析 ```tool_call``` 围栏);图片走原单轮路径(callGemini)。
|
|
@@ -844,7 +907,7 @@ index aef12eb..cbf1371 100644
|
|
|
844
907
|
tools []map[string]interface{}, toolChoice interface{},
|
|
845
908
|
onDelta, onReasoning func(string)) (string, []ToolCall, *StreamResult, error) {
|
|
846
909
|
parentKey := convParentKey(messages)
|
|
847
|
-
@@ -462,7 +
|
|
910
|
+
@@ -462,7 +470,7 @@ func callGeminiConv(messages []map[string]interface{}, mc ModelConfig,
|
|
848
911
|
logf("[conv] 续接命中 turn=%d,只发 %d 字节(历史留服务端)", conv.turn, len(prompt))
|
|
849
912
|
}
|
|
850
913
|
|
|
@@ -854,7 +917,7 @@ index aef12eb..cbf1371 100644
|
|
|
854
917
|
if !fresh {
|
|
855
918
|
// 续接失败:这路会话作废,客户端重试时会当新会话全量重发。
|
|
856
919
|
diff --git a/internal/app/gemini.go b/internal/app/gemini.go
|
|
857
|
-
index 723725f..
|
|
920
|
+
index 723725f..150b022 100644
|
|
858
921
|
--- a/internal/app/gemini.go
|
|
859
922
|
+++ b/internal/app/gemini.go
|
|
860
923
|
@@ -3,6 +3,7 @@ package app
|
|
@@ -1148,7 +1211,7 @@ index 723725f..a1e5221 100644
|
|
|
1148
1211
|
if err != nil {
|
|
1149
1212
|
return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
|
|
1150
1213
|
}
|
|
1151
|
-
@@ -844,
|
|
1214
|
+
@@ -844,10 +944,11 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
|
|
1152
1215
|
}
|
|
1153
1216
|
|
|
1154
1217
|
// 直连 → tls-client,保留 chrome146 TLS/HTTP2 真指纹
|
|
@@ -1157,7 +1220,11 @@ index 723725f..a1e5221 100644
|
|
|
1157
1220
|
if err != nil {
|
|
1158
1221
|
return 0, nil, 0, nil, err
|
|
1159
1222
|
}
|
|
1160
|
-
|
|
1223
|
+
+ setChromeHeaders(req.Header)
|
|
1224
|
+
for k, v := range headers {
|
|
1225
|
+
req.Header.Set(k, v)
|
|
1226
|
+
}
|
|
1227
|
+
@@ -857,7 +958,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
|
|
1161
1228
|
return 0, nil, 0, nil, err
|
|
1162
1229
|
}
|
|
1163
1230
|
defer resp.Body.Close()
|
|
@@ -1166,6 +1233,14 @@ index 723725f..a1e5221 100644
|
|
|
1166
1233
|
if err != nil {
|
|
1167
1234
|
return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
|
|
1168
1235
|
}
|
|
1236
|
+
@@ -1159,6 +1260,7 @@ func probeGemini(prompt, proxyURL string) ProbeResult {
|
|
1237
|
+
res.Diagnostic = "构建请求失败: " + e.Error()
|
|
1238
|
+
return res
|
|
1239
|
+
}
|
|
1240
|
+
+ setChromeHeaders(req.Header)
|
|
1241
|
+
for k, v := range headers {
|
|
1242
|
+
req.Header.Set(k, v)
|
|
1243
|
+
}
|
|
1169
1244
|
diff --git a/internal/app/gemini_test.go b/internal/app/gemini_test.go
|
|
1170
1245
|
index 00eb305..2b4c6ca 100644
|
|
1171
1246
|
--- a/internal/app/gemini_test.go
|
|
@@ -1178,12 +1253,114 @@ index 00eb305..2b4c6ca 100644
|
|
|
1178
1253
|
}
|
|
1179
1254
|
for _, c := range cases {
|
|
1180
1255
|
name, mc, err := resolveModel(c.name)
|
|
1256
|
+
diff --git a/internal/app/headers_test.go b/internal/app/headers_test.go
|
|
1257
|
+
new file mode 100644
|
|
1258
|
+
index 0000000..b80a9fa
|
|
1259
|
+
--- /dev/null
|
|
1260
|
+
+++ b/internal/app/headers_test.go
|
|
1261
|
+
@@ -0,0 +1,96 @@
|
|
1262
|
+
+package app
|
|
1263
|
+
+
|
|
1264
|
+
+import (
|
|
1265
|
+
+ "context"
|
|
1266
|
+
+ "net/http"
|
|
1267
|
+
+ "net/http/httptest"
|
|
1268
|
+
+ "testing"
|
|
1269
|
+
+
|
|
1270
|
+
+ tls_client "github.com/bogdanfinn/tls-client"
|
|
1271
|
+
+)
|
|
1272
|
+
+
|
|
1273
|
+
+// 同一个账号的请求不能因为走没走代理而呈现两种客户端:两条路径都要带齐 chromeHeaders,
|
|
1274
|
+
+// 调用方自己的 header 照样生效。直连那条以前一个都不带,UA 是 Go-http-client/2.0。
|
|
1275
|
+
+//
|
|
1276
|
+
+// 直连走 tls-client,这里换成跳过证书校验的同款客户端,打本机的 HTTPS 测试服务器;
|
|
1277
|
+
+// 代理那条把 http:// 目标以绝对 URI 发给代理,测试服务器本身就能当代理(同 cancel_test)。
|
|
1278
|
+
+func TestDirectAndProxyPathsSendSameBrowserHeaders(t *testing.T) {
|
|
1279
|
+
+ got := map[string]http.Header{}
|
|
1280
|
+
+ record := func(name string) http.Handler {
|
|
1281
|
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
1282
|
+
+ got[name] = r.Header.Clone()
|
|
1283
|
+
+ w.WriteHeader(200)
|
|
1284
|
+
+ })
|
|
1285
|
+
+ }
|
|
1286
|
+
+ direct := httptest.NewUnstartedServer(record("direct"))
|
|
1287
|
+
+ direct.EnableHTTP2 = true
|
|
1288
|
+
+ direct.StartTLS()
|
|
1289
|
+
+ defer direct.Close()
|
|
1290
|
+
+ proxy := httptest.NewServer(record("proxy"))
|
|
1291
|
+
+ defer proxy.Close()
|
|
1292
|
+
+
|
|
1293
|
+
+ key := rtCfg().Impersonate
|
|
1294
|
+
+ insecure, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
|
|
1295
|
+
+ tls_client.WithClientProfile(resolveProfile(key)),
|
|
1296
|
+
+ tls_client.WithInsecureSkipVerify(),
|
|
1297
|
+
+ tls_client.WithNotFollowRedirects())
|
|
1298
|
+
+ if err != nil {
|
|
1299
|
+
+ t.Fatal(err)
|
|
1300
|
+
+ }
|
|
1301
|
+
+ clientCacheMu.Lock()
|
|
1302
|
+
+ prev, had := clientCache[key]
|
|
1303
|
+
+ clientCache[key] = insecure
|
|
1304
|
+
+ clientCacheMu.Unlock()
|
|
1305
|
+
+ t.Cleanup(func() {
|
|
1306
|
+
+ clientCacheMu.Lock()
|
|
1307
|
+
+ defer clientCacheMu.Unlock()
|
|
1308
|
+
+ if had {
|
|
1309
|
+
+ clientCache[key] = prev
|
|
1310
|
+
+ } else {
|
|
1311
|
+
+ delete(clientCache, key)
|
|
1312
|
+
+ }
|
|
1313
|
+
+ })
|
|
1314
|
+
+
|
|
1315
|
+
+ own := map[string]string{"Accept-Language": "en-US,en;q=0.9", "X-Same-Domain": "1"}
|
|
1316
|
+
+ paths := []struct {
|
|
1317
|
+
+ name string
|
|
1318
|
+
+ send func(endpoint, proxyURL string)
|
|
1319
|
+
+ }{
|
|
1320
|
+
+ {"StreamGenerate", func(endpoint, proxyURL string) {
|
|
1321
|
+
+ doGeminiRequestStop(context.Background(), endpoint, "f.req=x", own, proxyURL, nil, nil)
|
|
1322
|
+
+ }},
|
|
1323
|
+
+ {"upload", func(endpoint, proxyURL string) {
|
|
1324
|
+
+ uploadPostCtx(context.Background(), endpoint, own, []byte("x"), proxyURL)
|
|
1325
|
+
+ }},
|
|
1326
|
+
+ {"media", func(endpoint, proxyURL string) {
|
|
1327
|
+
+ mediaGetOnce(endpoint, own, proxyURL)
|
|
1328
|
+
+ }},
|
|
1329
|
+
+ {"image", func(endpoint, proxyURL string) {
|
|
1330
|
+
+ fetchImage(endpoint, proxyURL, 0)
|
|
1331
|
+
+ }},
|
|
1332
|
+
+ }
|
|
1333
|
+
+ for _, p := range paths {
|
|
1334
|
+
+ clear(got)
|
|
1335
|
+
+ p.send(direct.URL+"/x", "")
|
|
1336
|
+
+ p.send("http://upstream.invalid/x", proxy.URL)
|
|
1337
|
+
+ d, pr := got["direct"], got["proxy"]
|
|
1338
|
+
+ if d == nil || pr == nil {
|
|
1339
|
+
+ t.Fatalf("%s: 没收到请求(direct=%v proxy=%v)", p.name, d != nil, pr != nil)
|
|
1340
|
+
+ }
|
|
1341
|
+
+ if ua := d.Get("User-Agent"); ua != ChromeUA {
|
|
1342
|
+
+ t.Errorf("%s: 直连 UA = %q", p.name, ua)
|
|
1343
|
+
+ }
|
|
1344
|
+
+ for _, kv := range chromeHeaders {
|
|
1345
|
+
+ if d.Get(kv[0]) != pr.Get(kv[0]) {
|
|
1346
|
+
+ t.Errorf("%s: %s 直连 %q,代理 %q", p.name, kv[0], d.Get(kv[0]), pr.Get(kv[0]))
|
|
1347
|
+
+ }
|
|
1348
|
+
+ }
|
|
1349
|
+
+ if p.name != "image" {
|
|
1350
|
+
+ for k, v := range own {
|
|
1351
|
+
+ if d.Get(k) != v || pr.Get(k) != v {
|
|
1352
|
+
+ t.Errorf("%s: 调用方的 %s 没生效(直连 %q,代理 %q)", p.name, k, d.Get(k), pr.Get(k))
|
|
1353
|
+
+ }
|
|
1354
|
+
+ }
|
|
1355
|
+
+ }
|
|
1356
|
+
+ }
|
|
1357
|
+
+}
|
|
1181
1358
|
diff --git a/internal/app/latency_test.go b/internal/app/latency_test.go
|
|
1182
1359
|
new file mode 100644
|
|
1183
|
-
index 0000000..
|
|
1360
|
+
index 0000000..b30336f
|
|
1184
1361
|
--- /dev/null
|
|
1185
1362
|
+++ b/internal/app/latency_test.go
|
|
1186
|
-
@@ -0,0 +1,
|
|
1363
|
+
@@ -0,0 +1,297 @@
|
|
1187
1364
|
+package app
|
|
1188
1365
|
+
|
|
1189
1366
|
+import (
|
|
@@ -1406,17 +1583,15 @@ index 0000000..9329309
|
|
|
1406
1583
|
+ }
|
|
1407
1584
|
+}
|
|
1408
1585
|
+
|
|
1409
|
-
+//
|
|
1410
|
-
+func
|
|
1586
|
+
+// 后台重取失败时必须作废缓存,否则失效的号会一直拿着旧 token 被当成可用。
|
|
1587
|
+
+func TestRefreshAppTokensFailureDropsCache(t *testing.T) {
|
|
1411
1588
|
+ cookie := "SAPISID=sap2; __Secure-1PSID=psid2"
|
|
1412
1589
|
+ xsrfMu.Lock()
|
|
1413
1590
|
+ xsrfCache[cookieKey(cookie)] = xsrfEntry{token: "stale", fetched: time.Now()}
|
|
1414
1591
|
+ xsrfMu.Unlock()
|
|
1415
1592
|
+ t.Cleanup(func() { invalidateXSRF(cookie) })
|
|
1416
1593
|
+
|
|
1417
|
-
+
|
|
1418
|
-
+ t.Fatal("不可达出口应报错")
|
|
1419
|
-
+ }
|
|
1594
|
+
+ refreshAppTokens(cookieKey(cookie), cookie, "http://127.0.0.1:1")
|
|
1420
1595
|
+ xsrfMu.Lock()
|
|
1421
1596
|
+ _, ok := xsrfCache[cookieKey(cookie)]
|
|
1422
1597
|
+ xsrfMu.Unlock()
|
|
@@ -1483,6 +1658,18 @@ index 0000000..9329309
|
|
|
1483
1658
|
+ t.Fatalf("非流式: stop=%v reads=%d tracker=%q", lh.stop.Load(), src.reads, lh.tracker.emitted)
|
|
1484
1659
|
+ }
|
|
1485
1660
|
+}
|
|
1661
|
+
diff --git a/internal/app/media.go b/internal/app/media.go
|
|
1662
|
+
index 9844778..03610f9 100644
|
|
1663
|
+
--- a/internal/app/media.go
|
|
1664
|
+
+++ b/internal/app/media.go
|
|
1665
|
+
@@ -505,6 +505,7 @@ func mediaGetOnce(rawURL string, headers map[string]string, proxyURL string) (in
|
|
1666
|
+
if err != nil {
|
|
1667
|
+
return 0, nil, nil, err
|
|
1668
|
+
}
|
|
1669
|
+
+ setChromeHeaders(req.Header)
|
|
1670
|
+
for k, v := range headers {
|
|
1671
|
+
req.Header.Set(k, v)
|
|
1672
|
+
}
|
|
1486
1673
|
diff --git a/internal/app/messages.go b/internal/app/messages.go
|
|
1487
1674
|
index 376171f..87b91fe 100644
|
|
1488
1675
|
--- a/internal/app/messages.go
|
|
@@ -1680,26 +1867,6 @@ index 376171f..87b91fe 100644
|
|
|
1680
1867
|
// parseToolChoice 解析 OpenAI 的 tool_choice。
|
|
1681
1868
|
// 返回 (mode, forcedName):mode ∈ {"auto","none","required"};
|
|
1682
1869
|
// forcedName 非空表示客户端点名了某个函数。
|
|
1683
|
-
diff --git a/internal/app/rotate.go b/internal/app/rotate.go
|
|
1684
|
-
index e082b15..908a052 100644
|
|
1685
|
-
--- a/internal/app/rotate.go
|
|
1686
|
-
+++ b/internal/app/rotate.go
|
|
1687
|
-
@@ -95,7 +95,14 @@ func rotateAccount(a CookieAccount) (time.Duration, []string, error) {
|
|
1688
|
-
if cookie != a.Cookie {
|
|
1689
|
-
old := a.Cookie
|
|
1690
|
-
updateAccountCookie(a.ID, cookie)
|
|
1691
|
-
- invalidateXSRF(old)
|
|
1692
|
-
+ // 轮转本身不在请求路径上,顺手用新 cookie 重取 token:既验证轮转结果,
|
|
1693
|
-
+ // 又让下一个请求直接命中缓存。失败时缓存已作废,下个请求同步重取。
|
|
1694
|
-
+ if err := refreshXSRF(cookie, proxyURL); err != nil {
|
|
1695
|
-
+ logf("[rotate] 账号 #%d 轮转后取 XSRF 失败: %v", a.ID, err)
|
|
1696
|
-
+ }
|
|
1697
|
-
+ if cookieKey(old) != cookieKey(cookie) {
|
|
1698
|
-
+ invalidateXSRF(old) // 身份项都变了(理论上不会):旧键别留着
|
|
1699
|
-
+ }
|
|
1700
|
-
}
|
|
1701
|
-
if len(names) > 0 {
|
|
1702
|
-
logf("[rotate] 账号 #%d 刷新了 %s", a.ID, strings.Join(names, ", "))
|
|
1703
1870
|
diff --git a/internal/app/server.go b/internal/app/server.go
|
|
1704
1871
|
index c3a7df1..eb02909 100644
|
|
1705
1872
|
--- a/internal/app/server.go
|
|
@@ -1808,7 +1975,7 @@ index c3a7df1..eb02909 100644
|
|
|
1808
1975
|
if stream {
|
|
1809
1976
|
writeEvent("response.failed", map[string]interface{}{
|
|
1810
1977
|
diff --git a/internal/app/upload.go b/internal/app/upload.go
|
|
1811
|
-
index 7747a72..
|
|
1978
|
+
index 7747a72..2753f93 100644
|
|
1812
1979
|
--- a/internal/app/upload.go
|
|
1813
1980
|
+++ b/internal/app/upload.go
|
|
1814
1981
|
@@ -2,6 +2,7 @@ package app
|
|
@@ -1865,7 +2032,7 @@ index 7747a72..09e8a04 100644
|
|
|
1865
2032
|
if err != nil {
|
|
1866
2033
|
return 0, nil, nil, err
|
|
1867
2034
|
}
|
|
1868
|
-
@@ -131,
|
|
2035
|
+
@@ -131,10 +142,11 @@ func uploadPost(url string, headers map[string]string, body []byte, proxyURL str
|
|
1869
2036
|
}
|
|
1870
2037
|
return resp.StatusCode, h, b, err
|
|
1871
2038
|
}
|
|
@@ -1874,6 +2041,10 @@ index 7747a72..09e8a04 100644
|
|
|
1874
2041
|
if err != nil {
|
|
1875
2042
|
return 0, nil, nil, err
|
|
1876
2043
|
}
|
|
2044
|
+
+ setChromeHeaders(req.Header)
|
|
2045
|
+
for k, v := range headers {
|
|
2046
|
+
req.Header.Set(k, v)
|
|
2047
|
+
}
|
|
1877
2048
|
diff --git a/internal/app/videos.go b/internal/app/videos.go
|
|
1878
2049
|
index 390b580..0365a78 100644
|
|
1879
2050
|
--- a/internal/app/videos.go
|
|
@@ -1895,8 +2066,20 @@ index 390b580..0365a78 100644
|
|
|
1895
2066
|
if err != nil {
|
|
1896
2067
|
recordRequest("videos", j.Model, j.Prompt, "", res, 502, err.Error(), false)
|
|
1897
2068
|
finishVideoJob(j, nil, "", err.Error())
|
|
2069
|
+
diff --git a/internal/app/vision.go b/internal/app/vision.go
|
|
2070
|
+
index b0c9724..b1ca7cb 100644
|
|
2071
|
+
--- a/internal/app/vision.go
|
|
2072
|
+
+++ b/internal/app/vision.go
|
|
2073
|
+
@@ -160,6 +160,7 @@ func fetchImage(src, proxyURL string, idx int) (pendingUpload, error) {
|
|
2074
|
+
if err != nil {
|
|
2075
|
+
return pendingUpload{}, err
|
|
2076
|
+
}
|
|
2077
|
+
+ setChromeHeaders(req.Header)
|
|
2078
|
+
resp, err := getTLSClient().Do(req)
|
|
2079
|
+
if err != nil {
|
|
2080
|
+
return pendingUpload{}, fmt.Errorf("image %d: fetch failed: %w", idx, err)
|
|
1898
2081
|
diff --git a/internal/app/xsrf.go b/internal/app/xsrf.go
|
|
1899
|
-
index c43b104..
|
|
2082
|
+
index c43b104..d3d7145 100644
|
|
1900
2083
|
--- a/internal/app/xsrf.go
|
|
1901
2084
|
+++ b/internal/app/xsrf.go
|
|
1902
2085
|
@@ -27,15 +27,23 @@ var (
|
|
@@ -1961,7 +2144,7 @@ index c43b104..55634b6 100644
|
|
|
1961
2144
|
|
|
1962
2145
|
e, err := fetchAppTokens(cookie, proxyURL)
|
|
1963
2146
|
if err != nil {
|
|
1964
|
-
@@ -84,16 +99,
|
|
2147
|
+
@@ -84,16 +99,48 @@ func getXSRF(cookie, proxyURL string) (string, error) {
|
|
1965
2148
|
return e.token, nil
|
|
1966
2149
|
}
|
|
1967
2150
|
|
|
@@ -1988,17 +2171,6 @@ index c43b104..55634b6 100644
|
|
|
1988
2171
|
+ }
|
|
1989
2172
|
+}
|
|
1990
2173
|
+
|
|
1991
|
-
+// refreshXSRF 立即重抓页面并写回缓存,失败则作废缓存。
|
|
1992
|
-
+//
|
|
1993
|
-
+// 给本来就不在请求关键路径上的调用方用(cookie 轮转):轮转后直接作废的话,
|
|
1994
|
-
+// 缓存按账号身份取键,下一个请求就得同步抓一次页面(实测约 1.2 秒)。
|
|
1995
|
-
+func refreshXSRF(cookie, proxyURL string) error {
|
|
1996
|
-
+ if cookie == "" {
|
|
1997
|
-
+ return nil
|
|
1998
|
-
+ }
|
|
1999
|
-
+ return storeAppTokens(cookieKey(cookie), cookie, proxyURL)
|
|
2000
|
-
+}
|
|
2001
|
-
+
|
|
2002
2174
|
+func storeAppTokens(key, cookie, proxyURL string) error {
|
|
2003
2175
|
+ e, err := fetchAppTokens(cookie, proxyURL)
|
|
2004
2176
|
+ xsrfMu.Lock()
|
|
@@ -2025,3 +2197,11 @@ index c43b104..55634b6 100644
|
|
|
2025
2197
|
|
|
2026
2198
|
e, err := fetchAppTokens(cookie, proxyURL)
|
|
2027
2199
|
if err != nil {
|
|
2200
|
+
@@ -146,6 +193,7 @@ func fetchAppPage(cookie, proxyURL string) ([]byte, error) {
|
|
2201
|
+
if err != nil {
|
|
2202
|
+
return nil, err
|
|
2203
|
+
}
|
|
2204
|
+
+ setChromeHeaders(req.Header)
|
|
2205
|
+
for k, v := range headers {
|
|
2206
|
+
req.Header.Set(k, v)
|
|
2207
|
+
}
|