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.
- package/README.md +50 -15
- package/index.mjs +7 -1
- package/lib/native.mjs +3 -3
- package/package.json +1 -1
- package/patches/argon.patch +1716 -100
- package/scripts/backend-argon.mjs +2 -2
package/patches/argon.patch
CHANGED
|
@@ -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..
|
|
3
|
+
index 0000000..98944ac
|
|
4
4
|
--- /dev/null
|
|
5
5
|
+++ b/internal/app/argon.go
|
|
6
|
-
@@ -0,0 +1,
|
|
6
|
+
@@ -0,0 +1,174 @@
|
|
7
7
|
+package app
|
|
8
8
|
+
|
|
9
9
|
+import (
|
|
10
10
|
+ "encoding/json"
|
|
11
11
|
+ "fmt"
|
|
12
12
|
+ "io"
|
|
13
|
+
+ "slices"
|
|
13
14
|
+ "strings"
|
|
14
15
|
+ "time"
|
|
15
16
|
+)
|
|
16
17
|
+
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
+
|
|
21
|
-
+
|
|
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
|
-
+
|
|
25
|
-
+
|
|
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
|
-
+
|
|
29
|
-
+
|
|
30
|
-
+
|
|
63
|
+
+ validHex := true
|
|
64
|
+
+ for _, c := range id {
|
|
65
|
+
+ if !strings.ContainsRune("0123456789abcdef", c) {
|
|
66
|
+
+ validHex = false
|
|
67
|
+
+ break
|
|
68
|
+
+ }
|
|
31
69
|
+ }
|
|
32
|
-
+
|
|
33
|
-
+ if
|
|
70
|
+
+ name, ok := fields[i+3].(string)
|
|
71
|
+
+ if !validHex || !ok {
|
|
34
72
|
+ continue
|
|
35
73
|
+ }
|
|
36
|
-
+
|
|
37
|
-
+
|
|
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
|
|
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
|
-
+
|
|
73
|
-
+ if
|
|
74
|
-
+
|
|
75
|
-
+
|
|
76
|
-
+
|
|
77
|
-
+
|
|
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
|
-
+
|
|
80
|
-
+
|
|
81
|
-
+
|
|
82
|
-
+
|
|
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..
|
|
242
|
+
index 0000000..3e62433
|
|
91
243
|
--- /dev/null
|
|
92
244
|
+++ b/internal/app/argon_test.go
|
|
93
|
-
@@ -0,0 +1,
|
|
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
|
|
857
|
+
index 723725f..a1e5221 100644
|
|
325
858
|
--- a/internal/app/gemini.go
|
|
326
859
|
+++ b/internal/app/gemini.go
|
|
327
|
-
@@ -
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
+
|
|
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
|
-
//
|
|
335
|
-
@@ -
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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 +
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
393
|
-
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
|
1704
|
+
index c3a7df1..eb02909 100644
|
|
401
1705
|
--- a/internal/app/server.go
|
|
402
1706
|
+++ b/internal/app/server.go
|
|
403
|
-
@@ -
|
|
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.
|
|
408
|
-
+const Version = "4.
|
|
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 {
|