magpie-gemini-web 0.1.0 → 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.
@@ -1,96 +1,248 @@
1
1
  diff --git a/internal/app/argon.go b/internal/app/argon.go
2
2
  new file mode 100644
3
- index 0000000..94c7aca
3
+ index 0000000..98944ac
4
4
  --- /dev/null
5
5
  +++ b/internal/app/argon.go
6
- @@ -0,0 +1,81 @@
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
  +
17
- +func validateArgonIdentity(raw []byte) error {
18
- + found := false
19
- + for _, line := range strings.Split(string(raw), "\n") {
20
- + var rows [][]json.RawMessage
21
- + if json.Unmarshal([]byte(line), &rows) != nil {
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
+ +
38
+ +// argonIdentityInLine 检查单行 wrb.fr 帧里的模型身份。
39
+ +// 返回 found=true 表示这行带有 names 之一的 Argon 身份;err 非 nil 表示这行报的是别的模型或档位。
40
+ +func argonIdentityInLine(line string, names []string) (found bool, err error) {
41
+ + var rows [][]json.RawMessage
42
+ + if json.Unmarshal([]byte(line), &rows) != nil {
43
+ + return false, nil
44
+ + }
45
+ + for _, row := range rows {
46
+ + if len(row) < 3 {
47
+ + continue
48
+ + }
49
+ + var kind, payload string
50
+ + if json.Unmarshal(row[0], &kind) != nil || kind != "wrb.fr" || json.Unmarshal(row[2], &payload) != nil {
51
+ + continue
52
+ + }
53
+ + var fields []any
54
+ + if json.Unmarshal([]byte(payload), &fields) != nil {
22
55
  + continue
23
56
  + }
24
- + for _, row := range rows {
25
- + if len(row) < 3 {
57
+ + // Upstream reports model identity as consecutive top-level slots hex,null,null,name.
58
+ + for i := 0; i+3 < len(fields); i++ {
59
+ + id, ok := fields[i].(string)
60
+ + if !ok || len(id) != 16 || fields[i+1] != nil || fields[i+2] != nil {
26
61
  + continue
27
62
  + }
28
- + var kind, payload string
29
- + if json.Unmarshal(row[0], &kind) != nil || kind != "wrb.fr" || json.Unmarshal(row[2], &payload) != nil {
30
- + continue
63
+ + validHex := true
64
+ + for _, c := range id {
65
+ + if !strings.ContainsRune("0123456789abcdef", c) {
66
+ + validHex = false
67
+ + break
68
+ + }
31
69
  + }
32
- + var fields []any
33
- + if json.Unmarshal([]byte(payload), &fields) != nil {
70
+ + name, ok := fields[i+3].(string)
71
+ + if !validHex || !ok {
34
72
  + continue
35
73
  + }
36
- + // Upstream reports model identity as consecutive top-level slots hex,null,null,name.
37
- + for i := 0; i+3 < len(fields); i++ {
38
- + id, ok := fields[i].(string)
39
- + if !ok || len(id) != 16 || fields[i+1] != nil || fields[i+2] != nil {
40
- + continue
41
- + }
42
- + validHex := true
43
- + for _, c := range id {
44
- + if !strings.ContainsRune("0123456789abcdef", c) {
45
- + validHex = false
46
- + break
47
- + }
48
- + }
49
- + name, ok := fields[i+3].(string)
50
- + if !validHex || !ok {
51
- + continue
52
- + }
53
- + if id != hexArgon4 || (name != "4 Argon" && name != "Gemini 4 Argon") {
54
- + return fmt.Errorf("Argon model mismatch: upstream reported %q (%s)", name, id)
55
- + }
56
- + found = true
74
+ + if id != hexArgon4 || !slices.Contains(names, name) {
75
+ + return false, fmt.Errorf("Argon model mismatch: upstream reported %q (%s)", name, id)
57
76
  + }
77
+ + found = true
58
78
  + }
59
79
  + }
80
+ + return found, nil
81
+ +}
82
+ +
83
+ +var errArgonIdentityMissing = fmt.Errorf("Argon identity missing in upstream response; refusing unverified model")
84
+ +
85
+ +func validateArgonIdentity(raw []byte, names []string) error {
86
+ + found := false
87
+ + for _, line := range strings.Split(string(raw), "\n") {
88
+ + ok, err := argonIdentityInLine(line, names)
89
+ + if err != nil {
90
+ + return err
91
+ + }
92
+ + found = found || ok
93
+ + }
60
94
  + if !found {
61
- + return fmt.Errorf("Argon identity missing in upstream response; refusing unverified model")
95
+ + return errArgonIdentityMissing
62
96
  + }
63
97
  + return nil
64
98
  +}
65
99
  +
100
+ +// readModelCheckedBody 对 Argon 请求逐帧校验模型身份后再转发。
101
+ +//
102
+ +// 身份确认之前的行先暂存、不回调;第一次在帧里确认是 Argon 时把暂存的行一次放行,
103
+ +// 之后边读边转发,同时继续检查每一帧。上游对未授权账号会静默降级成别的模型,
104
+ +// 所以:
105
+ +// - 身份始终没出现、或首次出现就不对:一个字都不放行,返回错误;
106
+ +// - 放行之后又报了别的模型:立刻停读并返回错误(调用方据此发流错误收尾)。
107
+ +// 已经发出去的内容收不回来,这是增量转发换首字延迟必须接受的代价。
108
+ +//
109
+ +// 实测身份跟首段正文在同一帧(Argon 探针:首帧 1.9s、身份与首字 9.5s、结束 14.6s),
110
+ +// 所以放行时机就是首字时机,不再要等上游整段生成完。
111
+ +//
112
+ +// onLine 为 nil(非流式)时没有转发,照旧读完整段再校验。
66
113
  +func readModelCheckedBody(r io.Reader, onLine func(string), start time.Time, status int, modelHeader string) ([]byte, int64, error) {
67
114
  + var h []any
68
115
  + isArgon := json.Unmarshal([]byte(modelHeader), &h) == nil && len(h) > 4 && h[4] == hexArgon4
69
116
  + if !isArgon {
70
117
  + return readBody(r, onLine, start)
71
118
  + }
72
- + raw, ttfb, err := readBody(r, nil, start)
73
- + if err != nil || status != 200 {
74
- + return raw, ttfb, err
75
- + }
76
- + if err = validateArgonIdentity(raw); err != nil {
77
- + return raw, ttfb, err
119
+ + names := argonDisplayNames(argonThinkFromHeader(h))
120
+ + if status != 200 || onLine == nil {
121
+ + raw, ttfb, err := readBody(r, nil, start)
122
+ + if err != nil || status != 200 {
123
+ + return raw, ttfb, err
124
+ + }
125
+ + return raw, ttfb, validateArgonIdentity(raw, names)
78
126
  + }
79
- + if onLine != nil {
80
- + for _, line := range strings.Split(string(raw), "\n") {
81
- + if line != "" {
82
- + onLine(line)
127
+ +
128
+ + var pending []string
129
+ + verified := false
130
+ + var identityErr error
131
+ + raw, ttfb, err := readBody(identityGuardReader{r, &identityErr}, func(line string) {
132
+ + if identityErr != nil {
133
+ + return
134
+ + }
135
+ + ok, lerr := argonIdentityInLine(line, names)
136
+ + if lerr != nil {
137
+ + identityErr = lerr
138
+ + return
139
+ + }
140
+ + if !verified {
141
+ + if !ok {
142
+ + if line != "" {
143
+ + pending = append(pending, line)
144
+ + }
145
+ + return
146
+ + }
147
+ + verified = true
148
+ + for _, p := range pending {
149
+ + onLine(p)
83
150
  + }
151
+ + pending = nil
84
152
  + }
153
+ + if line != "" {
154
+ + onLine(line)
155
+ + }
156
+ + }, start)
157
+ + if identityErr != nil {
158
+ + return raw, ttfb, identityErr
159
+ + }
160
+ + if err != nil {
161
+ + return raw, ttfb, err
162
+ + }
163
+ + if !verified {
164
+ + return raw, ttfb, errArgonIdentityMissing
85
165
  + }
86
166
  + return raw, ttfb, nil
87
167
  +}
168
+ +
169
+ +// identityGuardReader 在身份校验失败后让下一次 Read 返回 EOF,不再读上游。
170
+ +type identityGuardReader struct {
171
+ + r io.Reader
172
+ + err *error
173
+ +}
174
+ +
175
+ +func (g identityGuardReader) Read(p []byte) (int, error) {
176
+ + if *g.err != nil {
177
+ + return 0, io.EOF
178
+ + }
179
+ + return g.r.Read(p)
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
+ +}
88
240
  diff --git a/internal/app/argon_test.go b/internal/app/argon_test.go
89
241
  new file mode 100644
90
- index 0000000..4449c45
242
+ index 0000000..3e62433
91
243
  --- /dev/null
92
244
  +++ b/internal/app/argon_test.go
93
- @@ -0,0 +1,229 @@
245
+ @@ -0,0 +1,266 @@
94
246
  +package app
95
247
  +
96
248
  +import (
@@ -125,18 +277,18 @@ index 0000000..4449c45
125
277
  +func TestArgonValidateIdentity(t *testing.T) {
126
278
  + // 1. Correct hex and name passes
127
279
  + validFrame1 := makeSyntheticArgonFrame(hexArgon4, "4 Argon", "ARGON_OK")
128
- + if err := validateArgonIdentity(validFrame1); err != nil {
280
+ + if err := validateArgonIdentity(validFrame1, argonDisplayNames(thinkingNormal)); err != nil {
129
281
  + t.Fatalf("expected valid Argon frame to pass, got: %v", err)
130
282
  + }
131
283
  +
132
284
  + validFrame2 := makeSyntheticArgonFrame(hexArgon4, "Gemini 4 Argon", "ARGON_OK")
133
- + if err := validateArgonIdentity(validFrame2); err != nil {
285
+ + if err := validateArgonIdentity(validFrame2, argonDisplayNames(thinkingNormal)); err != nil {
134
286
  + t.Fatalf("expected valid Gemini 4 Argon frame to pass, got: %v", err)
135
287
  + }
136
288
  +
137
289
  + // 2. Flash hex/name fails with mismatch
138
290
  + flashFrame := makeSyntheticArgonFrame(hexFlash36, "3.6 Flash", "FLASH")
139
- + 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") {
140
292
  + t.Fatalf("expected Argon model mismatch for Flash frame, got: %v", err)
141
293
  + }
142
294
  +
@@ -145,13 +297,13 @@ index 0000000..4449c45
145
297
  + pBytes, _ := json.Marshal(emptyFields)
146
298
  + emptyFrame, _ := json.Marshal([][]any{{"wrb.fr", "rpc1", string(pBytes), nil}})
147
299
  + emptyFrame = append(emptyFrame, '\n')
148
- + 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") {
149
301
  + t.Fatalf("expected Argon identity missing, got: %v", err)
150
302
  + }
151
303
  +
152
304
  + // 4. Malformed frame fails
153
305
  + malformed := []byte(")]}'\nnot-valid-json\n")
154
- + 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") {
155
307
  + t.Fatalf("expected Argon identity missing for malformed JSON, got: %v", err)
156
308
  + }
157
309
  +
@@ -161,13 +313,13 @@ index 0000000..4449c45
161
313
  + fBytes, _ := json.Marshal(forgedFields)
162
314
  + forgedFrame, _ := json.Marshal([][]any{{"wrb.fr", "rpc1", string(fBytes), nil}})
163
315
  + forgedFrame = append(forgedFrame, '\n')
164
- + 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") {
165
317
  + t.Fatalf("expected forged text without model slots to fail identity check, got: %v", err)
166
318
  + }
167
319
  +
168
320
  + // 6. Matching then mismatching frames fails
169
321
  + mixedFrames := append(validFrame1, flashFrame...)
170
- + 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") {
171
323
  + t.Fatalf("expected matching then mismatching frames to fail, got: %v", err)
172
324
  + }
173
325
  +}
@@ -320,55 +472,702 @@ index 0000000..4449c45
320
472
  + t.Fatal("gemini-4-argon should be available when pool cookie exists")
321
473
  + }
322
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
+ +}
591
+ diff --git a/internal/app/cancel_test.go b/internal/app/cancel_test.go
592
+ new file mode 100644
593
+ index 0000000..e5f1b6f
594
+ --- /dev/null
595
+ +++ b/internal/app/cancel_test.go
596
+ @@ -0,0 +1,105 @@
597
+ +package app
598
+ +
599
+ +import (
600
+ + "context"
601
+ + "errors"
602
+ + "net/http"
603
+ + "net/http/httptest"
604
+ + "strings"
605
+ + "testing"
606
+ + "time"
607
+ +)
608
+ +
609
+ +// 客户端断开后必须真的掐掉上游连接:上游那头要能看到连接断了,而不是一直生成到结束。
610
+ +//
611
+ +// 假上游挂在代理那条路上(stdlib 客户端 + 代理),http:// 目标会以绝对 URI 发给代理,
612
+ +// 所以 httptest 服务器本身就能当上游,不用碰真网络。
613
+ +func TestCancelAbortsUpstreamStream(t *testing.T) {
614
+ + upstreamGone := make(chan struct{})
615
+ + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
616
+ + w.WriteHeader(200)
617
+ + w.Write([]byte(")]}'\nfirst frame\n"))
618
+ + w.(http.Flusher).Flush()
619
+ + select {
620
+ + case <-r.Context().Done():
621
+ + close(upstreamGone)
622
+ + case <-time.After(10 * time.Second): // 模拟一个还要生成很久的上游
623
+ + }
624
+ + }))
625
+ + defer srv.Close()
626
+ +
627
+ + ctx, cancel := context.WithCancel(context.Background())
628
+ + defer cancel()
629
+ + start := time.Now()
630
+ + _, _, _, _, err := doGeminiRequestStop(ctx, "http://upstream.invalid/StreamGenerate", "f.req=x",
631
+ + map[string]string{}, srv.URL, func(line string) {
632
+ + if line == "first frame" {
633
+ + cancel() // 客户端在收到首帧后走了
634
+ + }
635
+ + }, nil)
636
+ + if err == nil {
637
+ + t.Fatal("取消后应返回错误")
638
+ + }
639
+ + if took := time.Since(start); took > 3*time.Second {
640
+ + t.Fatalf("取消后仍等了 %v 才返回", took)
641
+ + }
642
+ + select {
643
+ + case <-upstreamGone:
644
+ + case <-time.After(3 * time.Second):
645
+ + t.Fatal("上游连接没有被关闭")
646
+ + }
647
+ +}
648
+ +
649
+ +func TestSleepCtxCancels(t *testing.T) {
650
+ + ctx, cancel := context.WithCancel(context.Background())
651
+ + go func() { time.Sleep(20 * time.Millisecond); cancel() }()
652
+ + start := time.Now()
653
+ + if err := sleepCtx(ctx, 10*time.Second); !errors.Is(err, context.Canceled) {
654
+ + t.Fatalf("sleepCtx = %v", err)
655
+ + }
656
+ + if time.Since(start) > time.Second {
657
+ + t.Fatal("重试等待没有随取消提前结束")
658
+ + }
659
+ + if err := sleepCtx(context.Background(), time.Millisecond); err != nil {
660
+ + t.Fatal(err)
661
+ + }
662
+ +}
663
+ +
664
+ +// 已经断开的请求不能再去占限流 slot、挑号、发上游。
665
+ +func TestStreamGenerateSkipsWhenCancelled(t *testing.T) {
666
+ + ctx, cancel := context.WithCancel(context.Background())
667
+ + cancel()
668
+ + _, err := streamGenerateWithFiles(ctx, "hi", promptCtx{Latest: "hi"}, Models["gemini-3.5-flash-lite"], nil, nil, nil, nil)
669
+ + if !errors.Is(err, context.Canceled) {
670
+ + t.Fatalf("err = %v", err)
671
+ + }
672
+ +}
673
+ +
674
+ +// 客户端断开:不写响应(没人收),requests 表记 499 而不是 502。
675
+ +func TestChatCompletionsClientGoneRecords499(t *testing.T) {
676
+ + ctx, cancel := context.WithCancel(context.Background())
677
+ + cancel()
678
+ + body := `{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"cancel-probe-499"}]}`
679
+ + req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)).WithContext(ctx)
680
+ + rec := httptest.NewRecorder()
681
+ + handleChatCompletions(rec, req)
682
+ + if rec.Body.Len() != 0 {
683
+ + t.Fatalf("客户端已断开仍写了响应: %q", rec.Body.String())
684
+ + }
685
+ +
686
+ + deadline := time.Now().Add(3 * time.Second)
687
+ + for time.Now().Before(deadline) {
688
+ + var status int
689
+ + var errStr string
690
+ + row := getDB().QueryRow(`SELECT status, error FROM requests WHERE prompt_chars=? ORDER BY id DESC LIMIT 1`,
691
+ + len("cancel-probe-499"))
692
+ + if row.Scan(&status, &errStr) == nil {
693
+ + if status != statusClientClosed || errStr != "client disconnected" {
694
+ + t.Fatalf("记录 = %d %q,应为 499 client disconnected", status, errStr)
695
+ + }
696
+ + return
697
+ + }
698
+ + time.Sleep(20 * time.Millisecond)
699
+ + }
700
+ + t.Fatal("没有记录这次请求")
701
+ +}
702
+ diff --git a/internal/app/context_file.go b/internal/app/context_file.go
703
+ index bfc577b..cb60661 100644
704
+ --- a/internal/app/context_file.go
705
+ +++ b/internal/app/context_file.go
706
+ @@ -1,6 +1,7 @@
707
+ package app
708
+
709
+ import (
710
+ + "context"
711
+ "fmt"
712
+ "strings"
713
+ )
714
+ @@ -76,7 +77,11 @@ func contextFilePrompt(latest string, budget int) string {
715
+ //
716
+ // 上传失败**不静默回退到超长内联**:那样上游会把最新提问截掉,客户端拿到一个
717
+ // 答非所问的 200 却看不出问题。
718
+ -func prepareContextFile(prompt, latest string, budget int, cookie, proxyURL string) (
719
+ +//
720
+ +// 附件里只放对话历史,工具协议(pc.Head / pc.Tail)留在正文:agentic 客户端的
721
+ +// 请求动辄十几万字节,几乎每轮都走这条路,工具说明和末尾锚点一旦进了附件,模型
722
+ +// 就不再稳定地吐 ```tool_call```。工具说明本身就超预算时才退回整段进附件。
723
+ +func prepareContextFile(ctx context.Context, prompt string, pc promptCtx, budget int, cookie, proxyURL string) (
724
+ string, []fileRef, bool, error) {
725
+ if budget <= 0 || len(prompt) <= budget {
726
+ return prompt, nil, false, nil
727
+ @@ -84,11 +89,50 @@ func prepareContextFile(prompt, latest string, budget int, cookie, proxyURL stri
728
+ if cookie == "" {
729
+ return prompt, nil, false, nil // 匿名:引用会被回 1100,转了也没用
730
+ }
731
+ - ref, err := uploadBytes(cookie, proxyURL, []byte(prompt), contextFileName)
732
+ + history, inline := planContextFile(prompt, pc, budget)
733
+ + ref, err := uploadBytesCtx(ctx, cookie, proxyURL, []byte(history), contextFileName)
734
+ if err != nil {
735
+ return prompt, nil, false, fmt.Errorf("超长对话转附件失败: %w", err)
736
+ }
737
+ - logf("[context] prompt %d 字节超过 %d,已转成附件 %s", len(prompt), budget, contextFileName)
738
+ + logf("[context] prompt %d 字节超过 %d,已转成附件 %s(附件 %d 字节,内联 %d 字节)",
739
+ + len(prompt), budget, contextFileName, len(history), len(inline))
740
+ files := []fileRef{{Ref: ref, Name: contextFileName, Kind: 3, Mime: "text/plain"}}
741
+ - return contextFilePrompt(latest, budget), files, true, nil
742
+ + return inline, files, true, nil
743
+ +}
744
+ +
745
+ +// planContextFile 决定附件里放什么、正文里内联什么。
746
+ +func planContextFile(prompt string, pc promptCtx, budget int) (history, inline string) {
747
+ + history, inline = prompt, contextFilePrompt(pc.Latest, budget)
748
+ + if h, ok := stripToolProtocol(prompt, pc); ok {
749
+ + if withTools := joinPromptParts(pc.Head, inline, pc.Tail); len(withTools) <= budget {
750
+ + history, inline = h, withTools
751
+ + }
752
+ + }
753
+ + return history, inline
754
+ +}
755
+ +
756
+ +// stripToolProtocol 从拼好的 prompt 里去掉首尾的工具协议,只留对话历史。
757
+ +// 没有工具协议、或 prompt 不是按 pc 拼出来的,返回 ok=false。
758
+ +func stripToolProtocol(prompt string, pc promptCtx) (string, bool) {
759
+ + if pc.Head == "" && pc.Tail == "" {
760
+ + return prompt, false
761
+ + }
762
+ + body := prompt
763
+ + if pc.Head != "" {
764
+ + if !strings.HasPrefix(body, pc.Head) {
765
+ + return prompt, false
766
+ + }
767
+ + body = strings.TrimPrefix(body[len(pc.Head):], "\n\n")
768
+ + }
769
+ + if pc.Tail != "" {
770
+ + if !strings.HasSuffix(body, pc.Tail) {
771
+ + return prompt, false
772
+ + }
773
+ + body = strings.TrimSuffix(body[:len(body)-len(pc.Tail)], "\n\n")
774
+ + }
775
+ + return body, true
776
+ +}
777
+ +
778
+ +func joinPromptParts(parts ...string) string {
779
+ + return promptParts{body: parts}.join()
780
+ }
781
+ diff --git a/internal/app/conversation.go b/internal/app/conversation.go
782
+ index aef12eb..cbf1371 100644
783
+ --- a/internal/app/conversation.go
784
+ +++ b/internal/app/conversation.go
785
+ @@ -1,6 +1,7 @@
786
+ package app
787
+
788
+ import (
789
+ + "context"
790
+ "crypto/sha256"
791
+ "encoding/hex"
792
+ "encoding/json"
793
+ @@ -244,8 +245,11 @@ const webUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
794
+ // streamGenerateConv 发一路会话的一轮。turn==0 是首轮(建会话),>0 是续接(只发新消息)。
795
+ // 整条会话绑定 conv.cookie / conv.proxyID,中途不换号不换出口。成功后回填 conv 的
796
+ // cid/rid/rcid/tok26 和 turn+1,供下一轮续接。
797
+ -func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
798
+ +func streamGenerateConv(ctx context.Context, prompt string, mc ModelConfig, conv *convState,
799
+ onDelta, onReasoning func(string)) (*StreamResult, error) {
800
+ + if err := ctx.Err(); err != nil {
801
+ + return &StreamResult{}, err
802
+ + }
803
+ p, slotOK, slotErr := acquireSlot(conv.proxyID)
804
+ if !slotOK {
805
+ return &StreamResult{}, slotErr
806
+ @@ -365,13 +369,19 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
807
+ t0 := time.Now()
808
+ var lastErr error
809
+ for attempt := 0; attempt < rtCfg().RetryAttempts; attempt++ {
810
+ - statusCode, raw, ttfb, setCookie, err := doGeminiRequest(endpoint, body, geminiHeaders, proxyURL, lineCB)
811
+ + if cerr := ctx.Err(); cerr != nil {
812
+ + return &StreamResult{ProxyID: p.ID, ProxyName: p.Name, AccountID: conv.accountID}, cerr
813
+ + }
814
+ + statusCode, raw, ttfb, setCookie, err := doGeminiRequestStop(ctx, endpoint, body, geminiHeaders, proxyURL, lineCB, nil)
815
+ if len(setCookie) > 0 && conv.cookie != "" {
816
+ if merged := mergeSetCookie(conv.cookie, setCookie); merged != conv.cookie {
817
+ conv.cookie = merged
818
+ }
819
+ }
820
+ if err != nil || statusCode != 200 || !hasContentFrame(string(raw)) {
821
+ + if cerr := ctx.Err(); cerr != nil {
822
+ + return &StreamResult{ProxyID: p.ID, ProxyName: p.Name, AccountID: conv.accountID}, cerr
823
+ + }
824
+ if err == nil && statusCode == 200 {
825
+ lastErr = fmt.Errorf("续接无内容帧(会话可能已失效,raw %d 字节)", len(raw))
826
+ } else if err != nil {
827
+ @@ -386,7 +396,9 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
828
+ break
829
+ }
830
+ if attempt < rtCfg().RetryAttempts-1 {
831
+ - time.Sleep(time.Duration(rtCfg().RetryDelaySec) * time.Second)
832
+ + if werr := sleepCtx(ctx, time.Duration(rtCfg().RetryDelaySec)*time.Second); werr != nil {
833
+ + return &StreamResult{ProxyID: p.ID, ProxyName: p.Name, AccountID: conv.accountID}, werr
834
+ + }
835
+ }
836
+ continue
837
+ }
838
+ @@ -427,7 +439,7 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
839
+ // - 没命中 → 新建会话,首轮发全量拼接的历史(含 tools 指令,把当前上下文交给服务端)。
840
+ //
841
+ // 支持 tools(续接里也解析 ```tool_call``` 围栏);图片走原单轮路径(callGemini)。
842
+ -func callGeminiConv(messages []map[string]interface{}, mc ModelConfig,
843
+ +func callGeminiConv(ctx context.Context, messages []map[string]interface{}, mc ModelConfig,
844
+ tools []map[string]interface{}, toolChoice interface{},
845
+ onDelta, onReasoning func(string)) (string, []ToolCall, *StreamResult, error) {
846
+ parentKey := convParentKey(messages)
847
+ @@ -462,7 +474,7 @@ func callGeminiConv(messages []map[string]interface{}, mc ModelConfig,
848
+ logf("[conv] 续接命中 turn=%d,只发 %d 字节(历史留服务端)", conv.turn, len(prompt))
849
+ }
850
+
851
+ - res, err := streamGenerateConv(prompt, mc, conv, onDelta, onReasoning)
852
+ + res, err := streamGenerateConv(ctx, prompt, mc, conv, onDelta, onReasoning)
853
+ if err != nil {
854
+ if !fresh {
855
+ // 续接失败:这路会话作废,客户端重试时会当新会话全量重发。
323
856
  diff --git a/internal/app/gemini.go b/internal/app/gemini.go
324
- index 200c728..077a413 100644
857
+ index 723725f..a1e5221 100644
325
858
  --- a/internal/app/gemini.go
326
859
  +++ b/internal/app/gemini.go
327
- @@ -27,6 +27,7 @@ const (
328
- // "当前 Flash" 泛指针,老批次号发它拿到的是 3.6,会冒充 3.7。老批次号发这个主 hex
329
- // 会干净降级成 3.5 Flash-Lite(跟 3.1 Pro 一样),所以 gate 成要 cookie。
330
- hexFlash38 = "56fdd199312815e2" // 3.8 Flash(原记 3.7;Google 把这个 hex 原地升成 3.8,HAR 响应帧显示名已是 "3.8 Flash")
331
- + hexArgon4 = "95a4c0abcba24364"
332
- )
860
+ @@ -3,6 +3,7 @@ package app
861
+ import (
862
+ "bufio"
863
+ "bytes"
864
+ + "context"
865
+ "encoding/json"
866
+ "fmt"
867
+ "io"
868
+ @@ -10,6 +11,7 @@ import (
869
+ "net/url"
870
+ "regexp"
871
+ "strings"
872
+ + "sync/atomic"
873
+ "time"
874
+
875
+ fhttp "github.com/bogdanfinn/fhttp"
876
+ @@ -311,7 +313,7 @@ func (d *deltaTracker) Push(fullText string) string {
877
+ // 自己做。一旦已经吐过内容就不再重试——重试会让客户端收到重复文本。
878
+ func streamGenerate(prompt, latest string, mc ModelConfig,
879
+ onDelta, onReasoning func(string)) (*StreamResult, error) {
880
+ - return streamGenerateWithFiles(prompt, latest, mc, nil, onDelta, onReasoning)
881
+ + return streamGenerateWithFiles(context.Background(), prompt, promptCtx{Latest: latest}, mc, nil, onDelta, onReasoning, nil)
882
+ }
333
883
 
334
- // innerSlots 是 payload 里 inner 数组的长度。浏览器发 97-98 槽,我们原来只开 80,
335
- @@ -72,6 +73,7 @@ var Models = map[string]ModelConfig{
336
- "gemini-3.6-flash": {HexID: hexFlash36, Mode: 1, Desc: "Latest all-around model"},
337
- "gemini-3.5-flash-lite": {HexID: hexFlashLite, Mode: 6, Desc: "Fastest, lightweight"},
338
- "gemini-3.1-pro": {HexID: hexPro31, Mode: 3, Desc: "Most capable; needs a signed-in cookie (downgraded to Flash-Lite without one)"},
339
- + "gemini-4-argon": {HexID: hexArgon4, Mode: 8, Desc: "Gemini 4 Argon; low thinking; requires a signed-in account with Argon access"},
340
- // 3.8 Flash:Google 把原 3.7 那个 hex 原地升级了(同 hex,服务端显示名从 3.7 变 3.8)。
341
- // 实测是**付费号专属**(不是灰度):只有付费 Gemini 账号有,免费号降级成 3.5 Flash-Lite。
342
- // gemini-3.7-flash 保留为别名(同 hex)。
343
- @@ -110,7 +112,7 @@ func hasCookie() bool {
344
- // 逐模型核对等价:那三个正名的 HexID 就是 hexPro31 / hexFlash38,其余登录模型都被
345
- // Thinking 或 Tool 覆盖。
346
- func modelNeedsLogin(mc ModelConfig) bool {
347
- - return mc.HexID == hexPro31 || mc.HexID == hexFlash38 || mc.Thinking || mc.Tool > 0
348
- + return mc.HexID == hexArgon4 || mc.HexID == hexPro31 || mc.HexID == hexFlash38 || mc.Thinking || mc.Tool > 0
884
+ // fileRef 是一个已上传附件的引用。
885
+ @@ -326,8 +328,16 @@ type fileRef struct {
886
+ //
887
+ // 附件填 inner[0][3],形状 [[[ref, 1], "文件名"], …]。附件只在登录态可用:
888
+ // 匿名能把文件传上去,但对话里一引用就被服务端回 1100。
889
+ -func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pendingUpload,
890
+ - onDelta, onReasoning func(string)) (*StreamResult, error) {
891
+ +//
892
+ +// stopWhen 非 nil 时,每帧拿当前累积正文问一次;返回 true 就不再读上游、按已收到
893
+ +// 的内容收尾。用于工具调用:围栏闭合时调用就已确定,上游最后一次改正文之后还要
894
+ +// 再推几秒收尾帧(Argon 实测约 2.3 秒),没必要让客户端干等。
895
+ +//
896
+ +// ctx 是客户端请求的 context:客户端断开后中止上游读取、上传和重试等待,不再替
897
+ +// 已经走掉的请求生成(否则账号照样被占用、重试还会再发一次,限流名额也一直占着)。
898
+ +// 取消导致的失败不记进代理 / cookie 健康度,那不是它们的错。
899
+ +func streamGenerateWithFiles(ctx context.Context, prompt string, pc promptCtx, mc ModelConfig, pending []pendingUpload,
900
+ + onDelta, onReasoning func(string), stopWhen func(string) bool) (*StreamResult, error) {
901
+ var files []fileRef
902
+
903
+ // 先挑号,再按它上次绑的出口挑代理 —— 同一个账号要尽量固定从同一个 IP 出去,
904
+ @@ -337,6 +347,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
905
+ // 不发请求,真正发请求的是下面的 getXSRF,它在拿到 slot 之后。
906
+ // #20 匿名优先:不需要登录态能力时不占用 cookie 账号,走匿名省额度(见 anonFirstEligible)。
907
+ // 带附件(图/视频)时不能走匿名——下面 uploadBytes 那段会因 cookieStr=="" 直接报错。
908
+ + if err := ctx.Err(); err != nil {
909
+ + return nil, err
910
+ + }
911
+ var acct *CookieAccount
912
+ if !anonFirstEligible(mc, len(pending) > 0) {
913
+ if a, ok := pickCookieAccount(); ok {
914
+ @@ -392,6 +405,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
915
+ // 每个号只给一次「轮转后重试」的机会,避免在一个请求里反复打 accounts.google.com
916
+ rotatedOnce := map[int64]bool{}
917
+ for acct != nil && len(tried) < maxCookieTries {
918
+ + if err := ctx.Err(); err != nil {
919
+ + return attrib(err)
920
+ + }
921
+ tried[acct.ID] = true
922
+ // 归属先记上:这一轮失败了也留痕,面板上看得出是哪个号在坏
923
+ cookieID, cookieLabel = acct.ID, accountDisplayName(acct)
924
+ @@ -419,6 +435,8 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
925
+ rotatedOnce[acct.ID] = true
926
+ if _, _, rerr := rotateAccount(*acct); rerr == nil {
927
+ if fresh := accountByID(acct.ID); fresh != nil {
928
+ + // rotateAccount 成功改了 cookie 时已经用新 cookie 重取过 token
929
+ + // (失败则作废了缓存),所以这里命中的缓存就是刚验证过的。
930
+ if tok2, err2 := getXSRF(fresh.Cookie, proxyURL); err2 == nil {
931
+ logf("[cookie] 账号 #%d 轮转后恢复可用", acct.ID)
932
+ acct = fresh
933
+ @@ -458,7 +476,10 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
934
+ }
935
+ nImg, nVid := 0, 0
936
+ for _, u := range pending {
937
+ - ref, uerr := uploadBytes(cookieStr, proxyURL, u.Data, u.Name)
938
+ + ref, uerr := uploadBytesCtx(ctx, cookieStr, proxyURL, u.Data, u.Name)
939
+ + if err := ctx.Err(); err != nil {
940
+ + return attrib(err)
941
+ + }
942
+ if uerr != nil {
943
+ return attrib(fmt.Errorf("上传附件 %s 失败: %w", u.Name, uerr))
944
+ }
945
+ @@ -475,7 +496,10 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
946
+ // prompt 超长时转成文本附件。要等挑完号和出口才能做:上传要 cookie,
947
+ // 而且必须走跟正式请求同一个出口。
948
+ budget := rtCfg().MaxPromptBytes
949
+ - if p, f, used, ferr := prepareContextFile(prompt, latest, budget, cookieStr, proxyURL); ferr != nil {
950
+ + if p, f, used, ferr := prepareContextFile(ctx, prompt, pc, budget, cookieStr, proxyURL); ferr != nil {
951
+ + if err := ctx.Err(); err != nil {
952
+ + return attrib(err)
953
+ + }
954
+ return attrib(ferr)
955
+ } else if used {
956
+ prompt = p
957
+ @@ -596,32 +620,18 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
958
+ xsrfRetried := false // XSRF 自愈只做一次,避免死循环
959
+ t0 := time.Now()
960
+
961
+ - tracker := &deltaTracker{}
962
+ - rtracker := &deltaTracker{}
963
+ - var lineCB func(string)
964
+ - if onDelta != nil || onReasoning != nil {
965
+ - lineCB = func(line string) {
966
+ - // 思考链先推完才轮到正文,所以先处理它,客户端拿到的顺序才对。
967
+ - if onReasoning != nil {
968
+ - if r := reasoningInLine(line); r != "" {
969
+ - if d := rtracker.Push(r); d != "" {
970
+ - onReasoning(d)
971
+ - }
972
+ - }
973
+ - }
974
+ - if onDelta == nil {
975
+ - return
976
+ - }
977
+ - for _, t := range textsInLine(line) {
978
+ - if d := tracker.Push(t); d != "" {
979
+ - onDelta(d)
980
+ - }
981
+ - }
982
+ - }
983
+ + lh := newLineHandler(onDelta, onReasoning, stopWhen)
984
+ + tracker, rtracker := lh.tracker, lh.rtracker
985
+ +
986
+ + retryWait := func() error {
987
+ + return sleepCtx(ctx, time.Duration(rtCfg().RetryDelaySec)*time.Second)
988
+ }
989
+
990
+ for attempt := 0; attempt < rtCfg().RetryAttempts; attempt++ {
991
+ - statusCode, raw, ttfb, setCookie, err := doGeminiRequest(endpoint, body, geminiHeaders, proxyURL, lineCB)
992
+ + if err := ctx.Err(); err != nil {
993
+ + return attrib(err)
994
+ + }
995
+ + statusCode, raw, ttfb, setCookie, err := doGeminiRequestStop(ctx, endpoint, body, geminiHeaders, proxyURL, lh.cb, &lh.stop)
996
+ if len(setCookie) > 0 && cookieID > 0 {
997
+ if merged := mergeSetCookie(cookieStr, setCookie); merged != cookieStr {
998
+ cookieStr = merged
999
+ @@ -629,6 +639,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1000
+ }
1001
+ }
1002
+ if err != nil {
1003
+ + if cerr := ctx.Err(); cerr != nil {
1004
+ + return attrib(cerr) // 客户端断开,不是出口的错
1005
+ + }
1006
+ lastErr = err
1007
+ if pickedOK {
1008
+ recordProxyResult(picked.ID, false, err.Error())
1009
+ @@ -639,7 +652,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1010
+ }
1011
+ if attempt < rtCfg().RetryAttempts-1 {
1012
+ logf("retry %d/%d: %v", attempt+1, rtCfg().RetryAttempts, err)
1013
+ - time.Sleep(time.Duration(rtCfg().RetryDelaySec) * time.Second)
1014
+ + if werr := retryWait(); werr != nil {
1015
+ + return attrib(werr)
1016
+ + }
1017
+ }
1018
+ continue
1019
+ }
1020
+ @@ -667,7 +682,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1021
+ break
1022
+ }
1023
+ if attempt < rtCfg().RetryAttempts-1 {
1024
+ - time.Sleep(time.Duration(rtCfg().RetryDelaySec) * time.Second)
1025
+ + if werr := retryWait(); werr != nil {
1026
+ + return attrib(werr)
1027
+ + }
1028
+ }
1029
+ continue
1030
+ }
1031
+ @@ -690,7 +707,9 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1032
+ }
1033
+ if attempt < rtCfg().RetryAttempts-1 {
1034
+ logf("retry %d/%d: 空响应(无内容帧,%d 字节)", attempt+1, rtCfg().RetryAttempts, len(raw))
1035
+ - time.Sleep(time.Duration(rtCfg().RetryDelaySec) * time.Second)
1036
+ + if werr := retryWait(); werr != nil {
1037
+ + return attrib(werr)
1038
+ + }
1039
+ }
1040
+ continue
1041
+ }
1042
+ @@ -750,6 +769,54 @@ func streamGenerateWithFiles(prompt, latest string, mc ModelConfig, pending []pe
1043
+ return attrib(lastErr)
349
1044
  }
350
1045
 
351
- // anonFirstEligible 判断「匿名优先」开关下这次请求能不能走匿名(不占 cookie 账号):
352
- @@ -823,7 +825,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1046
+ +// lineHandler 是每读到一行上游数据时的处理:推思考链、推正文增量、判断能否提前收尾。
1047
+ +//
1048
+ +// tracker / rtracker 记的是**已经发给客户端的**正文 / 思考链,调用方据此决定还能
1049
+ +// 不能重试(吐过内容就不能重试,否则重复)。非流式也要逐帧看正文才能判断能否提前
1050
+ +// 收尾,所以没有 onDelta 时另用一份 watch 记,不污染 tracker。
1051
+ +type lineHandler struct {
1052
+ + tracker, rtracker *deltaTracker
1053
+ + stop atomic.Bool
1054
+ + cb func(string) // nil 表示不需要逐行处理
1055
+ +}
1056
+ +
1057
+ +func newLineHandler(onDelta, onReasoning func(string), stopWhen func(string) bool) *lineHandler {
1058
+ + lh := &lineHandler{tracker: &deltaTracker{}, rtracker: &deltaTracker{}}
1059
+ + if onDelta == nil && onReasoning == nil && stopWhen == nil {
1060
+ + return lh
1061
+ + }
1062
+ + watch := lh.tracker
1063
+ + if onDelta == nil {
1064
+ + watch = &deltaTracker{}
1065
+ + }
1066
+ + lh.cb = func(line string) {
1067
+ + // 思考链先推完才轮到正文,所以先处理它,客户端拿到的顺序才对。
1068
+ + if onReasoning != nil {
1069
+ + if r := reasoningInLine(line); r != "" {
1070
+ + if d := lh.rtracker.Push(r); d != "" {
1071
+ + onReasoning(d)
1072
+ + }
1073
+ + }
1074
+ + }
1075
+ + if onDelta == nil && stopWhen == nil {
1076
+ + return
1077
+ + }
1078
+ + grew := false
1079
+ + for _, t := range textsInLine(line) {
1080
+ + if d := watch.Push(t); d != "" {
1081
+ + grew = true
1082
+ + if onDelta != nil {
1083
+ + onDelta(d)
1084
+ + }
1085
+ + }
1086
+ + }
1087
+ + if grew && stopWhen != nil && stopWhen(watch.emitted) {
1088
+ + lh.stop.Store(true)
1089
+ + }
1090
+ + }
1091
+ + return lh
1092
+ +}
1093
+ +
1094
+ // upstreamModelRe 匹配响应帧里服务端自报的模型显示名(帧的 [42] 位)。
1095
+ // 形如 ...,"fbb127bbb056c959",null,null,"3.6 Flash",true,...
1096
+ var upstreamModelRe = regexp.MustCompile(`\\"[0-9a-f]{16}\\",null,null,\\"([^"\\]{1,40})\\"`)
1097
+ @@ -819,10 +886,43 @@ func buildGeminiHeaders(cookieStr, sapisid, hexID string) map[string]string {
1098
+ // 实测号活一两小时就失效 —— 所以这些必须收下来并写回账号。
1099
+ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL string,
1100
+ onLine func(string)) (int, []byte, int64, []string, error) {
1101
+ + return doGeminiRequestStop(context.Background(), endpoint, body, headers, proxyURL, onLine, nil)
1102
+ +}
1103
+ +
1104
+ +// sleepCtx 等 d;ctx 先结束就提前返回它的错误。
1105
+ +func sleepCtx(ctx context.Context, d time.Duration) error {
1106
+ + t := time.NewTimer(d)
1107
+ + defer t.Stop()
1108
+ + select {
1109
+ + case <-t.C:
1110
+ + return nil
1111
+ + case <-ctx.Done():
1112
+ + return ctx.Err()
1113
+ + }
1114
+ +}
1115
+ +
1116
+ +// stopReader 在 stop 置位后让下一次 Read 直接返回 EOF:调用方已经拿到想要的内容,
1117
+ +// 剩下的上游帧不再等。已经读进 Scanner 缓冲的行照常处理完。
1118
+ +type stopReader struct {
1119
+ + r io.Reader
1120
+ + stop *atomic.Bool
1121
+ +}
1122
+ +
1123
+ +func (s stopReader) Read(p []byte) (int, error) {
1124
+ + if s.stop != nil && s.stop.Load() {
1125
+ + return 0, io.EOF
1126
+ + }
1127
+ + return s.r.Read(p)
1128
+ +}
1129
+ +
1130
+ +// doGeminiRequestStop 同 doGeminiRequest,stop 置位后提前结束读取(见 stopReader),
1131
+ +// ctx 结束时中止连接和读取。
1132
+ +func doGeminiRequestStop(ctx context.Context, endpoint, body string, headers map[string]string, proxyURL string,
1133
+ + onLine func(string), stop *atomic.Bool) (int, []byte, int64, []string, error) {
1134
+ sendAt := time.Now()
1135
+ if proxyURL != "" {
1136
+ // 走 stdlib 的 http.ProxyURL,已知能过 socks5/socks5h。
1137
+ - req, err := http.NewRequest("POST", endpoint, strings.NewReader(body))
1138
+ + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(body))
1139
+ if err != nil {
1140
+ return 0, nil, 0, nil, err
1141
+ }
1142
+ @@ -836,7 +936,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
353
1143
  return 0, nil, 0, nil, err
354
1144
  }
355
1145
  defer resp.Body.Close()
356
1146
  - raw, ttfb, err := readBody(resp.Body, onLine, sendAt)
357
- + raw, ttfb, err := readModelCheckedBody(resp.Body, onLine, sendAt, resp.StatusCode, headers["x-goog-ext-525001261-jspb"])
1147
+ + raw, ttfb, err := readModelCheckedBody(stopReader{resp.Body, stop}, onLine, sendAt, resp.StatusCode, headers["x-goog-ext-525001261-jspb"])
358
1148
  if err != nil {
359
1149
  return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
360
1150
  }
361
- @@ -844,7 +846,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
1152
+ }
1153
+
1154
+ // 直连 → tls-client,保留 chrome146 TLS/HTTP2 真指纹
1155
+ - req, err := fhttp.NewRequest("POST", endpoint, strings.NewReader(body))
1156
+ + req, err := fhttp.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(body))
1157
+ if err != nil {
1158
+ return 0, nil, 0, nil, err
1159
+ }
1160
+ @@ -857,7 +957,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
362
1161
  return 0, nil, 0, nil, err
363
1162
  }
364
1163
  defer resp.Body.Close()
365
1164
  - raw, ttfb, err := readBody(resp.Body, onLine, sendAt)
366
- + raw, ttfb, err := readModelCheckedBody(resp.Body, onLine, sendAt, resp.StatusCode, headers["x-goog-ext-525001261-jspb"])
1165
+ + raw, ttfb, err := readModelCheckedBody(stopReader{resp.Body, stop}, onLine, sendAt, resp.StatusCode, headers["x-goog-ext-525001261-jspb"])
367
1166
  if err != nil {
368
1167
  return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
369
1168
  }
370
1169
  diff --git a/internal/app/gemini_test.go b/internal/app/gemini_test.go
371
- index 9dc2541..f709b15 100644
1170
+ index 00eb305..2b4c6ca 100644
372
1171
  --- a/internal/app/gemini_test.go
373
1172
  +++ b/internal/app/gemini_test.go
374
1173
  @@ -15,6 +15,7 @@ func TestModelHeader(t *testing.T) {
@@ -379,33 +1178,850 @@ index 9dc2541..f709b15 100644
379
1178
  }
380
1179
  for _, c := range cases {
381
1180
  name, mc, err := resolveModel(c.name)
382
- @@ -50,13 +51,13 @@ func TestModelHeader(t *testing.T) {
1181
+ diff --git a/internal/app/latency_test.go b/internal/app/latency_test.go
1182
+ new file mode 100644
1183
+ index 0000000..9329309
1184
+ --- /dev/null
1185
+ +++ b/internal/app/latency_test.go
1186
+ @@ -0,0 +1,299 @@
1187
+ +package app
1188
+ +
1189
+ +import (
1190
+ + "bytes"
1191
+ + "encoding/json"
1192
+ + "fmt"
1193
+ + "io"
1194
+ + "strings"
1195
+ + "sync/atomic"
1196
+ + "testing"
1197
+ + "time"
1198
+ +)
1199
+ +
1200
+ +// SIDCC 每个响应都刷新,XSRF 缓存不能因此失效,否则每个登录态请求都多抓一次 /app。
1201
+ +func TestXSRFCacheSurvivesSIDCCRefresh(t *testing.T) {
1202
+ + cookie := "SAPISID=sap; __Secure-1PSID=psid; SIDCC=old; __Secure-1PSIDCC=old"
1203
+ + refreshed := mergeSetCookie(cookie, []string{"SIDCC=new; Path=/", "__Secure-1PSIDCC=new; Path=/"})
1204
+ + if refreshed == cookie {
1205
+ + t.Fatal("测试前提不成立:合并后 cookie 没变")
1206
+ + }
1207
+ + if cookieKey(cookie) != cookieKey(refreshed) {
1208
+ + t.Fatal("同一账号刷新 SIDCC 后缓存键变了")
1209
+ + }
1210
+ + if cookieKey(cookie) == cookieKey("SAPISID=other; __Secure-1PSID=psid2") {
1211
+ + t.Fatal("不同账号撞了缓存键")
1212
+ + }
1213
+ + if cookieKey("a=1") == cookieKey("a=2") {
1214
+ + t.Fatal("没有身份项时应退回按整串区分")
1215
+ + }
1216
+ +
1217
+ + key := cookieKey(cookie)
1218
+ + xsrfMu.Lock()
1219
+ + xsrfCache[key] = xsrfEntry{token: "tok", fetched: time.Now()}
1220
+ + xsrfMu.Unlock()
1221
+ + t.Cleanup(func() { invalidateXSRF(cookie) })
1222
+ +
1223
+ + // 命中缓存时不发网络请求;这里 proxyURL 指向不可达地址,真去抓会报错。
1224
+ + tok, err := getXSRF(refreshed, "http://127.0.0.1:1")
1225
+ + if err != nil || tok != "tok" {
1226
+ + t.Fatalf("刷新后的 cookie 没命中缓存: tok=%q err=%v", tok, err)
1227
+ + }
1228
+ +}
1229
+ +
1230
+ +func toolsFixture(names ...string) []map[string]interface{} {
1231
+ + var out []map[string]interface{}
1232
+ + for _, n := range names {
1233
+ + out = append(out, map[string]interface{}{
1234
+ + "type": "function",
1235
+ + "function": map[string]interface{}{
1236
+ + "name": n,
1237
+ + "description": "Run <cmd> & report",
1238
+ + "parameters": map[string]interface{}{
1239
+ + "type": "object",
1240
+ + "properties": map[string]interface{}{
1241
+ + "command": map[string]interface{}{"type": "string"},
1242
+ + },
1243
+ + "required": []interface{}{"command"},
1244
+ + },
1245
+ + },
1246
+ + })
1247
+ + }
1248
+ + return out
1249
+ +}
1250
+ +
1251
+ +func TestToolSchemaIsCompact(t *testing.T) {
1252
+ + msgs := []map[string]interface{}{{"role": "user", "content": "hi"}}
1253
+ + p, pc := messagesToPrompt(msgs, toolsFixture("bash"), nil)
1254
+ + if strings.Contains(pc.Head, "\n \"") {
1255
+ + t.Error("工具定义仍是缩进格式")
1256
+ + }
1257
+ + if strings.Contains(pc.Head, `\u00`) || !strings.Contains(pc.Head, "Run <cmd> & report") {
1258
+ + t.Error("工具描述里的 HTML 字符被转义了")
1259
+ + }
1260
+ + if pc.Tail == "" || !strings.HasPrefix(p, pc.Head) || !strings.HasSuffix(p, pc.Tail) {
1261
+ + t.Fatal("prompt 首尾应分别是工具说明和末尾锚点")
1262
+ + }
1263
+ + if p != buildPrompt(msgs, toolsFixture("bash"), nil) {
1264
+ + t.Fatal("messagesToPrompt 和 buildPrompt 结果不一致")
1265
+ + }
1266
+ +}
1267
+ +
1268
+ +// 超长转附件时,工具说明和末尾锚点留在正文,附件里只有历史。
1269
+ +func TestContextFileKeepsToolProtocolInline(t *testing.T) {
1270
+ + msgs := longConversation(300)
1271
+ + prompt, pc := messagesToPrompt(msgs, toolsFixture("bash", "read"), nil)
1272
+ + budget := len(prompt) / 2
1273
+ +
1274
+ + history, inline := planContextFile(prompt, pc, budget)
1275
+ + if strings.Contains(history, pc.Head) || strings.Contains(history, pc.Tail) {
1276
+ + t.Fatal("工具协议跟着历史进了附件")
1277
+ + }
1278
+ + if !strings.HasPrefix(inline, pc.Head) || !strings.HasSuffix(inline, pc.Tail) {
1279
+ + t.Fatal("正文里没保留工具协议")
1280
+ + }
1281
+ + if !strings.Contains(inline, contextFileName) || !strings.Contains(inline, "读取一下 task.md") {
1282
+ + t.Fatal("正文缺少附件指引或最新提问")
1283
+ + }
1284
+ + if !strings.Contains(history, "历史填充内容") || len(inline) > budget {
1285
+ + t.Fatalf("附件缺历史或正文超预算(%d > %d)", len(inline), budget)
1286
+ + }
1287
+ +
1288
+ + // 工具说明本身就超预算:退回整段进附件,至少不撞墙。
1289
+ + history, inline = planContextFile(prompt, pc, len(pc.Head)/2)
1290
+ + if history != prompt || strings.Contains(inline, pc.Head) {
1291
+ + t.Fatal("工具说明超预算时应整段进附件")
1292
+ + }
1293
+ +
1294
+ + // 没有工具:跟原来一样整段进附件。
1295
+ + plain, ppc := messagesToPrompt(msgs, nil, nil)
1296
+ + if history, _ := planContextFile(plain, ppc, len(plain)/2); history != plain {
1297
+ + t.Fatal("没有工具时附件内容变了")
1298
+ + }
1299
+ +}
1300
+ +
1301
+ +func TestToolCallComplete(t *testing.T) {
1302
+ + done := toolCallComplete(toolsFixture("bash"))
1303
+ + cases := []struct {
1304
+ + text string
1305
+ + want bool
1306
+ + }{
1307
+ + {"先说两句", false},
1308
+ + {"```tool_call\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}", false}, // 未闭合
1309
+ + {"```tool_call\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}\n``", false}, // 闭合到一半
1310
+ + {"```tool_call\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}\n```", true},
1311
+ + {"好的\n```tool_call {\"name\":\"bash\",\"arguments\":{}}```", true},
1312
+ + {"```tool_call\n{\"name\": \"rm_rf\", \"arguments\": {}}\n```", false}, // 不认识的函数
1313
+ + {"```tool_call\n{\"name\": \"bash\", \"arguments\": {\"command\": \"echo ```\"}}\n```", false}, // 参数里有围栏,解析不完整
1314
+ + }
1315
+ + for _, c := range cases {
1316
+ + if got := done(c.text); got != c.want {
1317
+ + t.Errorf("toolCallComplete(%q) = %v, want %v", c.text, got, c.want)
1318
+ + }
1319
+ + }
1320
+ + if toolCallComplete(nil) != nil {
1321
+ + t.Error("没有工具时不应做判定")
1322
+ + }
1323
+ +}
1324
+ +
1325
+ +// countingReader 按块吐数据并记录被读了几次。
1326
+ +type countingReader struct {
1327
+ + chunks [][]byte
1328
+ + reads int
1329
+ +}
1330
+ +
1331
+ +func (r *countingReader) Read(p []byte) (int, error) {
1332
+ + if r.reads >= len(r.chunks) {
1333
+ + return 0, io.EOF
1334
+ + }
1335
+ + n := copy(p, r.chunks[r.reads])
1336
+ + r.reads++
1337
+ + return n, nil
1338
+ +}
1339
+ +
1340
+ +func TestStopReaderEndsReadEarly(t *testing.T) {
1341
+ + src := &countingReader{chunks: [][]byte{[]byte("a\n"), []byte("b\n"), []byte("c\n"), []byte("d\n")}}
1342
+ + var stop atomic.Bool
1343
+ + var got []string
1344
+ + _, _, err := readBody(stopReader{src, &stop}, func(line string) {
1345
+ + got = append(got, line)
1346
+ + if line == "b" {
1347
+ + stop.Store(true)
1348
+ + }
1349
+ + }, time.Now())
1350
+ + if err != nil {
1351
+ + t.Fatal(err)
1352
+ + }
1353
+ + if strings.Join(got, ",") != "a,b" || src.reads != 2 {
1354
+ + t.Fatalf("置位后仍在读上游: lines=%v reads=%d", got, src.reads)
1355
+ + }
1356
+ +}
1357
+ +
1358
+ +func argonLine(hexID, name, answer string) []byte {
1359
+ + return makeSyntheticArgonFrame(hexID, name, answer)
1360
+ +}
1361
+ +
1362
+ +func TestArgonIncrementalForwarding(t *testing.T) {
1363
+ + header := fmt.Sprintf(`[1,null,null,null,"%s"]`, hexArgon4)
1364
+ + noID := argonLine("", "", "x")
1365
+ +
1366
+ + // 身份晚到:之前的帧先扣住,确认后按原顺序放行。
1367
+ + var got []string
1368
+ + body := bytes.Join([][]byte{[]byte(")]}'\n"), noID, argonLine(hexArgon4, "4 Argon", "ok")}, nil)
1369
+ + _, _, err := readModelCheckedBody(bytes.NewReader(body), func(l string) { got = append(got, l) }, time.Now(), 200, header)
1370
+ + if err != nil || len(got) != 3 || got[0] != ")]}'" {
1371
+ + t.Fatalf("晚到身份: err=%v lines=%d", err, len(got))
1372
+ + }
1373
+ +
1374
+ + // 确认之后增量转发:每读一块就要回调,不能攒到结尾。
1375
+ + calls := 0
1376
+ + inc := &incrementalReader{
1377
+ + chunks: [][]byte{argonLine(hexArgon4, "4 Argon", "a"), argonLine(hexArgon4, "4 Argon", "ab"), noID},
1378
+ + callbacksCalled: &calls,
1379
+ + }
1380
+ + if _, _, err := readModelCheckedBody(inc, func(string) { calls++ }, time.Now(), 200, header); err != nil || calls != 3 {
1381
+ + t.Fatalf("确认后未增量转发: err=%v calls=%d", err, calls)
1382
+ + }
1383
+ +
1384
+ + // 先对后错:已放行的收不回,但必须报错并停读。
1385
+ + got = nil
1386
+ + src := &countingReader{chunks: [][]byte{
1387
+ + argonLine(hexArgon4, "4 Argon", "a"),
1388
+ + argonLine(hexFlash36, "3.6 Flash", "b"),
1389
+ + argonLine(hexArgon4, "4 Argon", "c"),
1390
+ + }}
1391
+ + _, _, err = readModelCheckedBody(src, func(l string) { got = append(got, l) }, time.Now(), 200, header)
1392
+ + if err == nil || !strings.Contains(err.Error(), "mismatch") || len(got) != 1 || src.reads != 2 {
1393
+ + t.Fatalf("先对后错: err=%v lines=%d reads=%d", err, len(got), src.reads)
1394
+ + }
1395
+ +
1396
+ + // 始终没有身份:一行都不放行。
1397
+ + got = nil
1398
+ + _, _, err = readModelCheckedBody(bytes.NewReader(bytes.Repeat(noID, 3)), func(l string) { got = append(got, l) }, time.Now(), 200, header)
1399
+ + if err == nil || !strings.Contains(err.Error(), "identity missing") || len(got) != 0 {
1400
+ + t.Fatalf("无身份: err=%v lines=%d", err, len(got))
1401
+ + }
1402
+ +
1403
+ + // 非流式(onLine 为 nil)仍整段校验。
1404
+ + if _, _, err := readModelCheckedBody(bytes.NewReader(noID), nil, time.Now(), 200, header); err == nil {
1405
+ + t.Fatal("非流式无身份应报错")
1406
+ + }
1407
+ +}
1408
+ +
1409
+ +// 轮转后重取失败时必须作废缓存,否则失效的号会一直拿着旧 token 被当成可用。
1410
+ +func TestRefreshXSRFFailureDropsCache(t *testing.T) {
1411
+ + cookie := "SAPISID=sap2; __Secure-1PSID=psid2"
1412
+ + xsrfMu.Lock()
1413
+ + xsrfCache[cookieKey(cookie)] = xsrfEntry{token: "stale", fetched: time.Now()}
1414
+ + xsrfMu.Unlock()
1415
+ + t.Cleanup(func() { invalidateXSRF(cookie) })
1416
+ +
1417
+ + if err := refreshXSRF(cookie, "http://127.0.0.1:1"); err == nil {
1418
+ + t.Fatal("不可达出口应报错")
1419
+ + }
1420
+ + xsrfMu.Lock()
1421
+ + _, ok := xsrfCache[cookieKey(cookie)]
1422
+ + xsrfMu.Unlock()
1423
+ + if ok {
1424
+ + t.Fatal("重取失败后缓存仍在")
1425
+ + }
1426
+ +}
1427
+ +
1428
+ +// genFrame 造一行 StreamGenerate 帧:inner[4] 带累积正文,argon=true 时带上模型身份槽。
1429
+ +func genFrame(text string, argon bool) []byte {
1430
+ + inner := make([]any, 43)
1431
+ + inner[0] = "padding-to-pass-the-minimum-frame-length-checks-in-textsInLine"
1432
+ + inner[4] = []any{[]any{"rc_test", []any{text}}}
1433
+ + if argon {
1434
+ + inner[39], inner[40], inner[41], inner[42] = hexArgon4, nil, nil, "4 Argon"
1435
+ + }
1436
+ + ib, _ := json.Marshal(inner)
1437
+ + line, _ := json.Marshal([]any{[]any{"wrb.fr", nil, string(ib)}})
1438
+ + return append(line, '\n')
1439
+ +}
1440
+ +
1441
+ +// 端到端:工具块闭合那一帧之后不再读上游,客户端只收到围栏外的正文,最终能解析出调用。
1442
+ +func TestToolCallStopsUpstreamRead(t *testing.T) {
1443
+ + call := "```tool_call\n{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}\n```"
1444
+ + texts := []string{"好的", "好的\n```tool_call\n{\"name\": \"ba", "好的\n" + call, "好的\n" + call, "好的\n" + call}
1445
+ + for _, argon := range []bool{false, true} {
1446
+ + hexID := hexFlash36
1447
+ + if argon {
1448
+ + hexID = hexArgon4
1449
+ + }
1450
+ + var frames [][]byte
1451
+ + for _, tx := range texts {
1452
+ + frames = append(frames, genFrame(tx, argon))
1453
+ + }
1454
+ + src := &countingReader{chunks: frames}
1455
+ + var sent strings.Builder
1456
+ + gate := newToolFenceGate(func(s string) { sent.WriteString(s) })
1457
+ + lh := newLineHandler(gate.Push, nil, toolCallComplete(toolsFixture("bash")))
1458
+ +
1459
+ + raw, _, err := readModelCheckedBody(stopReader{src, &lh.stop}, lh.cb, time.Now(), 200,
1460
+ + fmt.Sprintf(`[1,null,null,null,"%s"]`, hexID))
1461
+ + if err != nil {
1462
+ + t.Fatalf("argon=%v: %v", argon, err)
1463
+ + }
1464
+ + if !lh.stop.Load() || src.reads != 3 {
1465
+ + t.Fatalf("argon=%v: 工具块闭合后仍在读上游(reads=%d,应为 3)", argon, src.reads)
1466
+ + }
1467
+ + if sent.String() != "好的\n" {
1468
+ + t.Fatalf("argon=%v: 围栏原文漏给了客户端: %q", argon, sent.String())
1469
+ + }
1470
+ + _, calls := parseToolCalls(extractResponseText(string(raw)))
1471
+ + if len(calls) != 1 || calls[0].Function.Name != "bash" || calls[0].Function.Arguments != `{"command":"ls"}` {
1472
+ + t.Fatalf("argon=%v: 提前收尾后解析不出调用: %+v", argon, calls)
1473
+ + }
1474
+ + }
1475
+ +
1476
+ + // 非流式(onDelta 为 nil)同样提前收尾,但不能把 tracker 记成「已发给客户端」,否则失败时不能重试。
1477
+ + src := &countingReader{chunks: [][]byte{genFrame("好的\n"+call, false), genFrame("好的\n"+call, false)}}
1478
+ + lh := newLineHandler(nil, nil, toolCallComplete(toolsFixture("bash")))
1479
+ + if _, _, err := readModelCheckedBody(stopReader{src, &lh.stop}, lh.cb, time.Now(), 200, `[1,null,null,null,"x"]`); err != nil {
1480
+ + t.Fatal(err)
1481
+ + }
1482
+ + if !lh.stop.Load() || src.reads != 1 || lh.tracker.emitted != "" {
1483
+ + t.Fatalf("非流式: stop=%v reads=%d tracker=%q", lh.stop.Load(), src.reads, lh.tracker.emitted)
1484
+ + }
1485
+ +}
1486
+ diff --git a/internal/app/messages.go b/internal/app/messages.go
1487
+ index 376171f..87b91fe 100644
1488
+ --- a/internal/app/messages.go
1489
+ +++ b/internal/app/messages.go
1490
+ @@ -46,8 +46,50 @@ type ToolCallFunction struct {
1491
+ // 出口才知道能不能做,所以判断放在 streamGenerate 里。最新那条消息单独返回,
1492
+ // 转附件时用来内联,好让模型不必去文件里找问题。
1493
+ func messagesToPrompt(messages []map[string]interface{}, tools []map[string]interface{},
1494
+ - toolChoice interface{}) (string, string) {
1495
+ - return buildPrompt(messages, tools, toolChoice), latestUserMessage(messages)
1496
+ + toolChoice interface{}) (string, promptCtx) {
1497
+ + pp := buildPromptParts(messages, tools, toolChoice)
1498
+ + return pp.join(), promptCtx{Latest: latestUserMessage(messages), Head: pp.head, Tail: pp.tail}
1499
+ +}
1500
+ +
1501
+ +// promptCtx 是拼好的 prompt 之外、超长转附件时仍要内联的部分。
1502
+ +//
1503
+ +// 工具协议(Head:格式说明 + schema;Tail:末尾锚点提醒)不能跟着历史一起进附件:
1504
+ +// 末尾锚点是模型肯吐 ```tool_call``` 的关键(见 buildPromptParts 里的判据),
1505
+ +// 整段塞进 message.txt 后正文只剩一句「去读附件」,锚点就失效了。
1506
+ +type promptCtx struct {
1507
+ + Latest string // 最新那条用户消息
1508
+ + Head string // 工具格式说明与定义,没有工具时为空
1509
+ + Tail string // 末尾的工具锚点提醒,没有工具时为空
1510
+ +}
1511
+ +
1512
+ +// promptParts 是 prompt 的三段:工具说明、对话历史、末尾提醒。
1513
+ +type promptParts struct {
1514
+ + head string
1515
+ + body []string
1516
+ + tail string
1517
+ +}
1518
+ +
1519
+ +func (pp promptParts) join() string {
1520
+ + var nonEmpty []string
1521
+ + for _, p := range append(append([]string{pp.head}, pp.body...), pp.tail) {
1522
+ + if p != "" {
1523
+ + nonEmpty = append(nonEmpty, p)
1524
+ + }
1525
+ + }
1526
+ + return strings.Join(nonEmpty, "\n\n")
1527
+ +}
1528
+ +
1529
+ +// compactJSON 不缩进、不转义 HTML 字符。工具 schema 原来用 MarshalIndent,
1530
+ +// 缩进空白和 \u003c 这类转义全算进 prompt 字节预算,agentic 客户端几十个工具时
1531
+ +// 很容易把请求推过转附件的门槛。
1532
+ +func compactJSON(v interface{}) string {
1533
+ + var b strings.Builder
1534
+ + enc := json.NewEncoder(&b)
1535
+ + enc.SetEscapeHTML(false)
1536
+ + if err := enc.Encode(v); err != nil {
1537
+ + return ""
1538
+ + }
1539
+ + return strings.TrimSuffix(b.String(), "\n")
1540
+ }
1541
+
1542
+ // latestUserMessage 取最后一条 user 消息的正文,没有则返回空串。
1543
+ @@ -65,6 +107,12 @@ func latestUserMessage(messages []map[string]interface{}) string {
1544
+
1545
+ func buildPrompt(messages []map[string]interface{}, tools []map[string]interface{},
1546
+ toolChoice interface{}) string {
1547
+ + return buildPromptParts(messages, tools, toolChoice).join()
1548
+ +}
1549
+ +
1550
+ +func buildPromptParts(messages []map[string]interface{}, tools []map[string]interface{},
1551
+ + toolChoice interface{}) promptParts {
1552
+ + var pp promptParts
1553
+ var parts []string
1554
+
1555
+ toolsInjected := false
1556
+ @@ -89,7 +137,7 @@ func buildPrompt(messages []map[string]interface{}, tools []map[string]interface
1557
+ })
1558
+ }
1559
+ if len(defs) > 0 {
1560
+ - defsJSON, _ := json.MarshalIndent(defs, "", " ")
1561
+ + defsJSON := compactJSON(defs)
1562
+ rule := "Only use tool_call blocks when needed."
1563
+ switch {
1564
+ case forced != "":
1565
+ @@ -102,13 +150,13 @@ func buildPrompt(messages []map[string]interface{}, tools []map[string]interface
1566
+ "and nothing else — do not answer the question yourself, even if you " +
1567
+ "know the answer."
1568
+ }
1569
+ - parts = append(parts, fmt.Sprintf(
1570
+ + pp.head = fmt.Sprintf(
1571
+ "[System instruction]: You have access to tools. "+
1572
+ "To call a tool, respond with:\n"+
1573
+ "```tool_call\n{\"name\": \"func_name\", \"arguments\": {...}}\n```\n"+
1574
+ "%s\n\n"+
1575
+ - "Available tools:\n%s", rule, string(defsJSON),
1576
+ - ))
1577
+ + "Available tools:\n%s", rule, defsJSON,
1578
+ + )
1579
+ toolsInjected = true
383
1580
  }
384
1581
  }
385
- // 守的是「不许冒出假模型」:thinking 版复用同样的 hex,所以看**去重后的 hex 数**,
386
- - // 不是条目数。服务端清单(otAQ7b)里 3.6/3.5-lite/3.1-pro + 灰度放出的 3.7 = 4 个。
387
- + // 不是条目数。服务端清单(otAQ7b)里 3.6/3.5-lite/3.1-pro + 灰度放出的 3.7 + 4-argon = 5 个。
388
- hexes := map[string]bool{}
389
- for _, m := range Models {
390
- hexes[m.HexID] = true
1582
+ @@ -165,33 +213,27 @@ func buildPrompt(messages []map[string]interface{}, tools []map[string]interface
1583
+ // 工具名(shell_command)0/4 抄假名 —— 示例教的是「动作」不是名字,故不必按客户端
1584
+ // 动态生成 schema。
1585
+ if toolsInjected {
1586
+ - parts = append(parts,
1587
+ - "[System instruction — highest priority]: To run a command or read a file you MUST "+
1588
+ - "output a ```tool_call``` block — this is the ONLY execution channel. A "+
1589
+ - "```powershell/```bash/```cmd/```python code block is NOT executed, it is only shown "+
1590
+ - "to the user; never use one to run anything, and never claim you lack tools.\n"+
1591
+ - "Act, do not explain: when the request needs a tool, your reply is ONE ```tool_call``` "+
1592
+ - "block and nothing else — do not list the commands you 'would' run, do not output more "+
1593
+ - "than one tool_call, do not add prose. If several steps are needed, do the FIRST one "+
1594
+ - "now; you will be called again with its result to continue.\n"+
1595
+ - "Know when to STOP: once a tool result shows a step succeeded (for example it exited "+
1596
+ - "with code 0, even with no printed output), that step is DONE — do not run it again and "+
1597
+ - "do not retry the same thing a different way. When the whole request is accomplished, "+
1598
+ - "stop calling tools and reply with a short final answer instead of another tool_call.\n"+
1599
+ - "Example of a correct turn — user asks \"list the files here and tell me the size of "+
1600
+ - "README.md\", you reply with exactly one block and nothing else:\n"+
1601
+ - "```tool_call\n{\"name\": \"run_command\", \"arguments\": {\"command\": \"ls -la\"}}\n```\n"+
1602
+ - "(In your block use one of the real tool names listed above with its own arguments.) "+
1603
+ - "When the request needs a tool, emit the tool_call block now.")
1604
+ + pp.tail = "[System instruction — highest priority]: To run a command or read a file you MUST " +
1605
+ + "output a ```tool_call``` block — this is the ONLY execution channel. A " +
1606
+ + "```powershell/```bash/```cmd/```python code block is NOT executed, it is only shown " +
1607
+ + "to the user; never use one to run anything, and never claim you lack tools.\n" +
1608
+ + "Act, do not explain: when the request needs a tool, your reply is ONE ```tool_call``` " +
1609
+ + "block and nothing else — do not list the commands you 'would' run, do not output more " +
1610
+ + "than one tool_call, do not add prose. If several steps are needed, do the FIRST one " +
1611
+ + "now; you will be called again with its result to continue.\n" +
1612
+ + "Know when to STOP: once a tool result shows a step succeeded (for example it exited " +
1613
+ + "with code 0, even with no printed output), that step is DONE — do not run it again and " +
1614
+ + "do not retry the same thing a different way. When the whole request is accomplished, " +
1615
+ + "stop calling tools and reply with a short final answer instead of another tool_call.\n" +
1616
+ + "Example of a correct turn — user asks \"list the files here and tell me the size of " +
1617
+ + "README.md\", you reply with exactly one block and nothing else:\n" +
1618
+ + "```tool_call\n{\"name\": \"run_command\", \"arguments\": {\"command\": \"ls -la\"}}\n```\n" +
1619
+ + "(In your block use one of the real tool names listed above with its own arguments.) " +
1620
+ + "When the request needs a tool, emit the tool_call block now."
391
1621
  }
392
- - if len(hexes) != 4 {
393
- - t.Errorf("只应存在 4 个真模型 hex, got %d", len(hexes))
394
- + if len(hexes) != 5 {
395
- + t.Errorf("只应存在 5 个真模型 hex, got %d", len(hexes))
1622
+
1623
+ - var nonEmpty []string
1624
+ - for _, p := range parts {
1625
+ - if p != "" {
1626
+ - nonEmpty = append(nonEmpty, p)
1627
+ - }
1628
+ - }
1629
+ - return strings.Join(nonEmpty, "\n\n")
1630
+ + pp.body = parts
1631
+ + return pp
1632
+ }
1633
+
1634
+ func contentToString(content interface{}) string {
1635
+ @@ -392,6 +434,47 @@ func parseToolCalls(text string) (string, []ToolCall) {
1636
+ return strings.TrimSpace(clean), toolCalls
1637
+ }
1638
+
1639
+ +// toolCallComplete 返回一个判定函数:累积正文里出现了**闭合且能解析**、函数名在
1640
+ +// tools 里的 ```tool_call``` 块时为真,表示这一轮的工具调用已经确定,可以不等上游
1641
+ +// 收尾。没有工具时返回 nil(不做判定)。
1642
+ +//
1643
+ +// 提示词要求一轮只出一个 tool_call 块、不带别的正文(见 buildPromptParts 末尾锚点),
1644
+ +// 所以第一个合法块闭合就收尾。函数名不认识或 JSON 不完整时不收尾,照旧等上游
1645
+ +// 结束 —— 宁可慢,不把半截或错的调用当成结果。
1646
+ +func toolCallComplete(tools []map[string]interface{}) func(string) bool {
1647
+ + names := map[string]bool{}
1648
+ + for _, tool := range tools {
1649
+ + fn := tool
1650
+ + if f, ok := tool["function"].(map[string]interface{}); ok {
1651
+ + fn = f
1652
+ + }
1653
+ + if n := getStr(fn, "name"); n != "" {
1654
+ + names[n] = true
1655
+ + }
1656
+ + }
1657
+ + if len(names) == 0 {
1658
+ + return nil
1659
+ + }
1660
+ + return func(text string) bool {
1661
+ + i := strings.Index(text, toolFenceOpen)
1662
+ + if i < 0 {
1663
+ + return false
1664
+ + }
1665
+ + m := toolCallRe.FindStringSubmatch(text[i:])
1666
+ + if m == nil {
1667
+ + return false
1668
+ + }
1669
+ + var data struct {
1670
+ + Name string `json:"name"`
1671
+ + Arguments map[string]interface{} `json:"arguments"`
1672
+ + }
1673
+ + if json.Unmarshal([]byte(strings.TrimSpace(m[1])), &data) != nil {
1674
+ + return false
1675
+ + }
1676
+ + return names[data.Name]
1677
+ + }
1678
+ +}
1679
+ +
1680
+ // parseToolChoice 解析 OpenAI 的 tool_choice。
1681
+ // 返回 (mode, forcedName):mode ∈ {"auto","none","required"};
1682
+ // 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
+ + }
396
1700
  }
397
- // 反过来:每个真 hex 都应该有一个 thinking 版
398
- for _, base := range []string{hexFlash36, hexFlashLite, hexPro31, hexFlash38} {
1701
+ if len(names) > 0 {
1702
+ logf("[rotate] 账号 #%d 刷新了 %s", a.ID, strings.Join(names, ", "))
399
1703
  diff --git a/internal/app/server.go b/internal/app/server.go
400
- index 2ce8d11..8f52a30 100644
1704
+ index c3a7df1..eb02909 100644
401
1705
  --- a/internal/app/server.go
402
1706
  +++ b/internal/app/server.go
403
- @@ -9,7 +9,7 @@ import (
1707
+ @@ -1,6 +1,7 @@
1708
+ package app
1709
+
1710
+ import (
1711
+ + "context"
1712
+ "encoding/json"
1713
+ "fmt"
1714
+ "io"
1715
+ @@ -9,7 +10,7 @@ import (
404
1716
  "time"
405
1717
  )
406
1718
 
407
- -const Version = "4.21.0"
408
- +const Version = "4.21.0-argon.1"
1719
+ -const Version = "4.22.0"
1720
+ +const Version = "4.22.0-argon.1"
409
1721
 
410
1722
  func writeJSON(w http.ResponseWriter, status int, data interface{}) {
411
1723
  body, _ := json.Marshal(data)
1724
+ @@ -129,9 +130,9 @@ func rejectUnsupported(req map[string]interface{}, messages []map[string]interfa
1725
+ return nil
1726
+ }
1727
+
1728
+ -func callGemini(prompt, latest string, mc ModelConfig, tools []map[string]interface{},
1729
+ +func callGemini(ctx context.Context, prompt string, pc promptCtx, mc ModelConfig, tools []map[string]interface{},
1730
+ images []pendingUpload, onDelta, onReasoning func(string)) (string, []ToolCall, *StreamResult, error) {
1731
+ - res, err := streamGenerateWithFiles(prompt, latest, mc, images, onDelta, onReasoning)
1732
+ + res, err := streamGenerateWithFiles(ctx, prompt, pc, mc, images, onDelta, onReasoning, toolCallComplete(tools))
1733
+ if err != nil {
1734
+ // res 非 nil:失败时它只带归属(哪个号 / 哪个出口),给 recordRequest 用
1735
+ return "", nil, res, err
1736
+ @@ -267,7 +268,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
1737
+ return
1738
+ }
1739
+
1740
+ - prompt, latest := messagesToPrompt(messages, tools, req["tool_choice"])
1741
+ + prompt, pc := messagesToPrompt(messages, tools, req["tool_choice"])
1742
+ if strings.TrimSpace(prompt) == "" && len(images) == 0 {
1743
+ writeJSON(w, 400, map[string]interface{}{"error": map[string]string{"message": "empty prompt"}})
1744
+ return
1745
+ @@ -304,11 +305,14 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
1746
+ var res *StreamResult
1747
+ if rtCfg().MultiTurn && len(images) == 0 && modelCfg.Tool == 0 {
1748
+ // 多轮:按历史前缀识别续接,命中就只发新消息、历史留服务端。带 tools 也走这条。
1749
+ - text, toolCalls, res, err = callGeminiConv(messages, modelCfg, tools, req["tool_choice"], onDelta, onReasoning)
1750
+ + text, toolCalls, res, err = callGeminiConv(r.Context(), messages, modelCfg, tools, req["tool_choice"], onDelta, onReasoning)
1751
+ } else {
1752
+ - text, toolCalls, res, err = callGemini(prompt, latest, modelCfg, tools, images, onDelta, onReasoning)
1753
+ + text, toolCalls, res, err = callGemini(r.Context(), prompt, pc, modelCfg, tools, images, onDelta, onReasoning)
1754
+ }
1755
+ if err != nil {
1756
+ + if clientGone(r, "chat.completions", modelName, prompt, res, stream) {
1757
+ + return
1758
+ + }
1759
+ recordRequest("chat.completions", modelName, prompt, "", res, 502, err.Error(), stream)
1760
+ if sse != nil && sse.Started() {
1761
+ sse.Fail(err) // 已经开流,HTTP 状态码改不了了
1762
+ @@ -390,6 +394,21 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
1763
+ })
1764
+ }
1765
+
1766
+ +// statusClientClosed 是客户端先断开时记进 requests 表的状态码(沿用 nginx 的 499)。
1767
+ +const statusClientClosed = 499
1768
+ +
1769
+ +// clientGone 判断失败是不是因为客户端已经断开;是的话记一条 499 并返回 true。
1770
+ +//
1771
+ +// 这时连接已经没了,再写 502 / 流错误没人收。记成 502 会让「用户自己取消」看起来
1772
+ +// 像上游故障;499 + "client disconnected" 在面板的失败列表里一眼能分清。
1773
+ +func clientGone(r *http.Request, endpoint, model, prompt string, res *StreamResult, stream bool) bool {
1774
+ + if r.Context().Err() == nil {
1775
+ + return false
1776
+ + }
1777
+ + recordRequest(endpoint, model, prompt, "", res, statusClientClosed, "client disconnected", stream)
1778
+ + return true
1779
+ +}
1780
+ +
1781
+ // usageOf 是 buildUsageWithReasoning 的便捷包装,res 可能为 nil。
1782
+ func usageOf(prompt, text string, res *StreamResult) map[string]int {
1783
+ r := ""
1784
+ @@ -565,7 +584,7 @@ func handleResponses(w http.ResponseWriter, r *http.Request) {
1785
+ return
1786
+ }
1787
+
1788
+ - prompt, latest := messagesToPrompt(messages, tools, req["tool_choice"])
1789
+ + prompt, pc := messagesToPrompt(messages, tools, req["tool_choice"])
1790
+ if strings.TrimSpace(prompt) == "" && len(images) == 0 {
1791
+ writeJSON(w, 400, map[string]interface{}{"error": map[string]string{"message": "empty input"}})
1792
+ return
1793
+ @@ -659,11 +678,14 @@ func handleResponses(w http.ResponseWriter, r *http.Request) {
1794
+ var toolCalls []ToolCall
1795
+ var res *StreamResult
1796
+ if rtCfg().MultiTurn && len(images) == 0 && modelCfg.Tool == 0 {
1797
+ - text, toolCalls, res, err = callGeminiConv(messages, modelCfg, tools, req["tool_choice"], onDelta, nil)
1798
+ + text, toolCalls, res, err = callGeminiConv(r.Context(), messages, modelCfg, tools, req["tool_choice"], onDelta, nil)
1799
+ } else {
1800
+ - text, toolCalls, res, err = callGemini(prompt, latest, modelCfg, tools, images, onDelta, nil)
1801
+ + text, toolCalls, res, err = callGemini(r.Context(), prompt, pc, modelCfg, tools, images, onDelta, nil)
1802
+ }
1803
+ if err != nil {
1804
+ + if clientGone(r, "responses", modelName, prompt, res, stream) {
1805
+ + return
1806
+ + }
1807
+ recordRequest("responses", modelName, prompt, "", res, 502, err.Error(), stream)
1808
+ if stream {
1809
+ writeEvent("response.failed", map[string]interface{}{
1810
+ diff --git a/internal/app/upload.go b/internal/app/upload.go
1811
+ index 7747a72..09e8a04 100644
1812
+ --- a/internal/app/upload.go
1813
+ +++ b/internal/app/upload.go
1814
+ @@ -2,6 +2,7 @@ package app
1815
+
1816
+ import (
1817
+ "bytes"
1818
+ + "context"
1819
+ "fmt"
1820
+ "io"
1821
+ "net/http"
1822
+ @@ -20,6 +21,11 @@ const uploadHost = "https://push.clients6.google.com/upload/"
1823
+ // 匿名也能传成功,但传上去的文件在对话里引用会被回 1100,所以调用方要自己确保有 cookie。
1824
+ // proxyURL 必须跟正式请求同一出口,否则在 Google 眼里是两个会话共用文件。
1825
+ func uploadBytes(cookie, proxyURL string, data []byte, filename string) (string, error) {
1826
+ + return uploadBytesCtx(context.Background(), cookie, proxyURL, data, filename)
1827
+ +}
1828
+ +
1829
+ +// uploadBytesCtx 同 uploadBytes,ctx 取消(客户端断开)时中止上传。
1830
+ +func uploadBytesCtx(ctx context.Context, cookie, proxyURL string, data []byte, filename string) (string, error) {
1831
+ pushID, pctx, err := getUploadTokens(cookie, proxyURL)
1832
+ if err != nil {
1833
+ return "", fmt.Errorf("取上传页面参数失败: %w", err)
1834
+ @@ -63,7 +69,7 @@ func uploadBytes(cookie, proxyURL string, data []byte, filename string) (string,
1835
+ "X-Goog-Upload-Protocol": "resumable",
1836
+ "X-Goog-Upload-Header-Content-Length": strconv.Itoa(len(data)),
1837
+ })
1838
+ - status, respHead, body, err := uploadPost(uploadHost, startHeaders,
1839
+ + status, respHead, body, err := uploadPostCtx(ctx, uploadHost, startHeaders,
1840
+ []byte("File name: "+sanitizeUploadName(filename)), proxyURL)
1841
+ if err != nil {
1842
+ return "", fmt.Errorf("上传 start 失败: %w", err)
1843
+ @@ -82,7 +88,7 @@ func uploadBytes(cookie, proxyURL string, data []byte, filename string) (string,
1844
+ "X-Goog-Upload-Command": "upload, finalize",
1845
+ "X-Goog-Upload-Offset": "0",
1846
+ })
1847
+ - status, _, body, err = uploadPost(putURL, upHeaders, data, proxyURL)
1848
+ + status, _, body, err = uploadPostCtx(ctx, putURL, upHeaders, data, proxyURL)
1849
+ if err != nil {
1850
+ return "", fmt.Errorf("上传 finalize 失败: %w", err)
1851
+ }
1852
+ @@ -109,9 +115,14 @@ func sanitizeUploadName(name string) string {
1853
+
1854
+ // uploadPost 走跟主请求相同的出口发 POST,响应头一起带回(start 要取 x-goog-upload-url)。
1855
+ func uploadPost(url string, headers map[string]string, body []byte, proxyURL string) (
1856
+ + int, map[string]string, []byte, error) {
1857
+ + return uploadPostCtx(context.Background(), url, headers, body, proxyURL)
1858
+ +}
1859
+ +
1860
+ +func uploadPostCtx(ctx context.Context, url string, headers map[string]string, body []byte, proxyURL string) (
1861
+ int, map[string]string, []byte, error) {
1862
+ if proxyURL != "" {
1863
+ - req, err := http.NewRequest("POST", url, bytes.NewReader(body))
1864
+ + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
1865
+ if err != nil {
1866
+ return 0, nil, nil, err
1867
+ }
1868
+ @@ -131,7 +142,7 @@ func uploadPost(url string, headers map[string]string, body []byte, proxyURL str
1869
+ }
1870
+ return resp.StatusCode, h, b, err
1871
+ }
1872
+ - req, err := fhttp.NewRequest("POST", url, bytes.NewReader(body))
1873
+ + req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
1874
+ if err != nil {
1875
+ return 0, nil, nil, err
1876
+ }
1877
+ diff --git a/internal/app/videos.go b/internal/app/videos.go
1878
+ index 390b580..0365a78 100644
1879
+ --- a/internal/app/videos.go
1880
+ +++ b/internal/app/videos.go
1881
+ @@ -10,6 +10,7 @@ package app
1882
+ // 只是把「阻塞等结果」挪到后台 goroutine,前台立即返 id。
1883
+
1884
+ import (
1885
+ + "context"
1886
+ "encoding/json"
1887
+ "net/http"
1888
+ "strings"
1889
+ @@ -105,7 +106,7 @@ func runVideoJob(j *videoJob) {
1890
+ finishVideoJob(j, nil, "", err.Error())
1891
+ return
1892
+ }
1893
+ - _, _, res, err := callGemini(j.Prompt, j.Prompt, mc, nil, nil, nil, nil)
1894
+ + _, _, res, err := callGemini(context.Background(), j.Prompt, promptCtx{Latest: j.Prompt}, mc, nil, nil, nil, nil)
1895
+ if err != nil {
1896
+ recordRequest("videos", j.Model, j.Prompt, "", res, 502, err.Error(), false)
1897
+ finishVideoJob(j, nil, "", err.Error())
1898
+ diff --git a/internal/app/xsrf.go b/internal/app/xsrf.go
1899
+ index c43b104..55634b6 100644
1900
+ --- a/internal/app/xsrf.go
1901
+ +++ b/internal/app/xsrf.go
1902
+ @@ -27,15 +27,23 @@ var (
1903
+ )
1904
+
1905
+ type xsrfEntry struct {
1906
+ - token string
1907
+ - pushID string // 上传文件用的 Push-ID 头
1908
+ - pctx string // 上传文件用的 X-Client-Pctx 头
1909
+ - fetched time.Time
1910
+ + token string
1911
+ + pushID string // 上传文件用的 Push-ID 头
1912
+ + pctx string // 上传文件用的 X-Client-Pctx 头
1913
+ + fetched time.Time
1914
+ + refreshing bool // 后台刷新正在飞,避免并发请求重复抓页面
1915
+ }
1916
+
1917
+ // 页面上的 token 没写明有效期,取个保守值定期重取。
1918
+ const xsrfTTL = 20 * time.Minute
1919
+
1920
+ +// xsrfRefreshAfter 之后命中缓存时照常返回,但在后台重抓一次页面。
1921
+ +//
1922
+ +// /app 页面约 770KB,抓一次 1-3 秒,不能放在请求的关键路径上;但同步重抓顺带
1923
+ +// 起到「cookie 还活着吗」的探测作用(失效的号抓不到 SNlM0e)。后台刷新把这个探测
1924
+ +// 挪出关键路径:刷新失败就丢掉缓存,下一个请求同步重取时照样报出失效、走换号逻辑。
1925
+ +const xsrfRefreshAfter = 5 * time.Minute
1926
+ +
1927
+ var snlm0eRe = regexp.MustCompile(`"SNlM0e":"([^"]{10,200})"`)
1928
+
1929
+ // 上传要的两个页面参数,跟 XSRF token 同页取,省一次页面请求。
1930
+ @@ -44,8 +52,18 @@ var pushIDRe = regexp.MustCompile(`"qKIAYe":"([^"]{4,400})"`)
1931
+ var pctxRe = regexp.MustCompile(`"Ylro7b":"([^"]{4,400})"`)
1932
+
1933
+ // cookieKey 用 cookie 的短摘要当缓存键,避免把整串凭证塞进 map key。
1934
+ +//
1935
+ +// 摘要只取账号身份(SAPISID + __Secure-1PSID,见 cookieIdentity),不取整串:
1936
+ +// 上游几乎每个响应都刷新 *SIDCC,streamGenerate 合并 Set-Cookie 后写回账号,
1937
+ +// 按整串取键的话下一个请求必然 miss,每次都要多抓一次 /app 页面(实测登录态每个
1938
+ +// 请求多出约 0.9 秒,且不计入 ttfb_ms / total_ms)。token 跟会话绑定、不跟 SIDCC
1939
+ +// 绑定;真过期了上游回 xsrf 错误,调用方 invalidate 后重取。
1940
+ func cookieKey(cookie string) string {
1941
+ - sum := sha1.Sum([]byte(cookie))
1942
+ + id := cookieIdentity(cookie)
1943
+ + if id == "|" { // 两项都没有:退回整串,至少不同 cookie 不会撞键
1944
+ + id = cookie
1945
+ + }
1946
+ + sum := sha1.Sum([]byte(id))
1947
+ return hex.EncodeToString(sum[:8])
1948
+ }
1949
+
1950
+ @@ -67,12 +85,9 @@ func getXSRF(cookie, proxyURL string) (string, error) {
1951
+ }
1952
+ key := cookieKey(cookie)
1953
+
1954
+ - xsrfMu.Lock()
1955
+ - if e, ok := xsrfCache[key]; ok && time.Since(e.fetched) < xsrfTTL {
1956
+ - xsrfMu.Unlock()
1957
+ + if e, ok := cachedAppTokens(key, cookie, proxyURL); ok {
1958
+ return e.token, nil
1959
+ }
1960
+ - xsrfMu.Unlock()
1961
+
1962
+ e, err := fetchAppTokens(cookie, proxyURL)
1963
+ if err != nil {
1964
+ @@ -84,16 +99,59 @@ func getXSRF(cookie, proxyURL string) (string, error) {
1965
+ return e.token, nil
1966
+ }
1967
+
1968
+ +// cachedAppTokens 返回未过期的缓存项;过了 xsrfRefreshAfter 时顺带踢一次后台刷新。
1969
+ +func cachedAppTokens(key, cookie, proxyURL string) (xsrfEntry, bool) {
1970
+ + xsrfMu.Lock()
1971
+ + defer xsrfMu.Unlock()
1972
+ + e, ok := xsrfCache[key]
1973
+ + if !ok || time.Since(e.fetched) >= xsrfTTL {
1974
+ + return xsrfEntry{}, false
1975
+ + }
1976
+ + if !e.refreshing && time.Since(e.fetched) >= xsrfRefreshAfter {
1977
+ + e.refreshing = true
1978
+ + xsrfCache[key] = e
1979
+ + go refreshAppTokens(key, cookie, proxyURL)
1980
+ + }
1981
+ + return e, true
1982
+ +}
1983
+ +
1984
+ +// refreshAppTokens 后台重抓页面。失败就丢掉缓存,让下一个请求同步重取并报出失效。
1985
+ +func refreshAppTokens(key, cookie, proxyURL string) {
1986
+ + if err := storeAppTokens(key, cookie, proxyURL); err != nil {
1987
+ + logf("[xsrf] 后台刷新失败,已作废缓存: %v", err)
1988
+ + }
1989
+ +}
1990
+ +
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
+ +func storeAppTokens(key, cookie, proxyURL string) error {
2003
+ + e, err := fetchAppTokens(cookie, proxyURL)
2004
+ + xsrfMu.Lock()
2005
+ + defer xsrfMu.Unlock()
2006
+ + if err != nil {
2007
+ + delete(xsrfCache, key)
2008
+ + return err
2009
+ + }
2010
+ + xsrfCache[key] = e
2011
+ + return nil
2012
+ +}
2013
+ +
2014
+ // getUploadTokens 取上传要用的 Push-ID / X-Client-Pctx,跟 XSRF token 同一份缓存。
2015
+ func getUploadTokens(cookie, proxyURL string) (pushID, pctx string, err error) {
2016
+ key := cookieKey(cookie)
2017
+
2018
+ - xsrfMu.Lock()
2019
+ - if e, ok := xsrfCache[key]; ok && time.Since(e.fetched) < xsrfTTL {
2020
+ - xsrfMu.Unlock()
2021
+ + if e, ok := cachedAppTokens(key, cookie, proxyURL); ok {
2022
+ return e.pushID, e.pctx, nil
2023
+ }
2024
+ - xsrfMu.Unlock()
2025
+
2026
+ e, err := fetchAppTokens(cookie, proxyURL)
2027
+ if err != nil {