From 3e25c5e976495a6f3ea63ed543723ebc378040e6 Mon Sep 17 00:00:00 2001 From: Vaxaxas <250858849+Vaxaxas@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:04:41 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20=E9=99=8D=E4=BD=8E=E5=A4=9A=E8=BC=AA?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20Token=20=E6=B6=88=E8=80=97=E3=80=81?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Cookie=20302=20=E9=87=8D=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E8=88=87=20Chrome=20=E5=A4=9A=E5=B8=B3=E8=99=9F=E5=8C=AF?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 優化多輪工具提示詞:新增 ContinuationSuffix 精簡協議約束,避免每輪重複拼接完整工具 JSON Schema - 新增對話指紋自動辨識與多種 Session Header 擷取,支援無 Header 客戶端自動延續會話 - 調整 prompt token 計算排除 ThoughtTokens 並防止錯誤重試雙倍扣額 - 將 Google 登入 302 重定向辨識為認證失效,避免純 Cookie 帳號輪換時觸發 reload 引爆 302 - 移除 SQLite immutable=1 參數以支援讀取 WAL,支援無 DBSC 設備綁定之 Chrome 帳號匯入並精細化狀態標籤 Co-Authored-By: Claude Code --- internal/adapter/claude_handler.go | 72 ++++++++++++++++++++-- internal/adapter/conversation.go | 31 ++++++++++ internal/adapter/openai_chat.go | 77 ++++++++++++++++++++++-- internal/adapter/stream.go | 7 +-- internal/adapter/tool_bridge.go | 30 +++++++++ internal/admin/accounts.go | 3 +- internal/chromeauth/auth.go | 1 + internal/chromeauth/platform_windows.go | 21 +++++-- internal/chromeauth/protocol.go | 34 +++++++++-- internal/gemini/client.go | 45 +++++++++++--- internal/gemini/rotate.go | 9 ++- web/src/features/accounts/ImportPage.tsx | 12 +++- web/src/features/accounts/messages.ts | 14 +++++ web/src/features/accounts/types.ts | 1 + 14 files changed, 323 insertions(+), 34 deletions(-) diff --git a/internal/adapter/claude_handler.go b/internal/adapter/claude_handler.go index 11cc014..1d9a8ff 100644 --- a/internal/adapter/claude_handler.go +++ b/internal/adapter/claude_handler.go @@ -3,6 +3,7 @@ package adapter import ( "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -54,7 +55,15 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { return } setUnsupportedParameters(c, unsupportedClaudeParameters(request)) - sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, c.GetHeader("X-Conversation-ID")) + sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, extractHeaderSessionKey(c)) + if sessionKey == "" && len(request.Messages) > 1 { + if lastIdx := findLastClaudeAssistantIndex(request.Messages); lastIdx >= 0 { + fp := claudeMessagesFingerprint(request.System, request.Messages[:lastIdx+1]) + if conversations.has(fp) { + sessionKey = fp + } + } + } responseID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) if request.Stream { @@ -163,6 +172,7 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { if len(calls) > 0 { stopReason = "tool_use" } + bindNextClaudeFingerprint(pool, request.System, request.Messages, result) _ = processor.Finish(stopReason) return } @@ -241,6 +251,7 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { c.Header("X-Response-ID", responseID) c.Header("X-Conversation-ID", result.ConversationID) c.Header("X-Provider-Model", result.ProviderModel) + bindNextClaudeFingerprint(pool, request.System, request.Messages, result) c.JSON(http.StatusOK, response) } } @@ -308,9 +319,14 @@ func buildClaudePrompt(c *gin.Context, client *gemini.Client, request claude.Cla if err != nil { return "", nil, err } - suffix, err := bridge.PromptSuffix() - if err != nil { - return "", nil, err + var suffix string + if continuation { + suffix = bridge.ContinuationSuffix() + } else { + suffix, err = bridge.PromptSuffix() + if err != nil { + return "", nil, err + } } return transcript + suffix, files, nil } @@ -393,3 +409,51 @@ func thinkingSignature(messageID string, blockIndex int) string { digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", messageID, blockIndex))) return base64.StdEncoding.EncodeToString(digest[:]) } + +func findLastClaudeAssistantIndex(messages []claude.Message) int { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "assistant" { + return i + } + } + return -1 +} + +func claudeMessagesFingerprint(system json.RawMessage, messages []claude.Message) string { + if len(messages) == 0 { + return "" + } + h := sha256.New() + if len(system) > 0 { + h.Write(system) + h.Write([]byte{0xFF}) + } + for _, m := range messages { + h.Write([]byte(m.Role)) + h.Write([]byte{0}) + h.Write(m.Content) + h.Write([]byte{0xFF}) + } + sum := h.Sum(nil) + return "fp_cld_" + hex.EncodeToString(sum[:16]) +} + +func bindNextClaudeFingerprint(pool *balancer.AccountPool, system json.RawMessage, messages []claude.Message, result generationResult) { + if result.ConversationID == "" { + return + } + primary := result.Accumulator.Primary() + if primary.Text == "" && len(primary.Codes) == 0 { + return + } + assistantContent, err := json.Marshal(primary.Text) + if err != nil { + return + } + nextMessages := append(messages, claude.Message{Role: "assistant", Content: assistantContent}) + nextFp := claudeMessagesFingerprint(system, nextMessages) + if state, accountID, ctxTokens, ok := conversations.get(result.ConversationID); ok { + conversations.put(state, accountID, ctxTokens, nextFp) + pool.BindSession(nextFp, accountID) + } +} diff --git a/internal/adapter/conversation.go b/internal/adapter/conversation.go index e53528d..40fd536 100644 --- a/internal/adapter/conversation.go +++ b/internal/adapter/conversation.go @@ -2,6 +2,7 @@ package adapter import ( "context" + "strings" "sync" "time" @@ -214,6 +215,16 @@ func (r *conversationRegistry) cleanupLocked(now time.Time) { } } +func (r *conversationRegistry) has(key string) bool { + if key == "" { + return false + } + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.states[key] + return ok && time.Now().Before(entry.expiresAt) +} + func conversationKey(previousResponseID string, conversationID string, headerValue string) string { if previousResponseID != "" { return previousResponseID @@ -223,3 +234,23 @@ func conversationKey(previousResponseID string, conversationID string, headerVal } return headerValue } + +func extractHeaderSessionKey(c interface{ GetHeader(string) string }) string { + if c == nil { + return "" + } + for _, header := range []string{ + "X-Conversation-ID", + "Conversation-ID", + "X-Session-ID", + "Session-ID", + "X-Chat-ID", + "Chat-ID", + "anthropic-session-id", + } { + if val := strings.TrimSpace(c.GetHeader(header)); val != "" { + return val + } + } + return "" +} diff --git a/internal/adapter/openai_chat.go b/internal/adapter/openai_chat.go index e6e1e0e..6cf865c 100644 --- a/internal/adapter/openai_chat.go +++ b/internal/adapter/openai_chat.go @@ -1,6 +1,8 @@ package adapter import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -126,7 +128,15 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { return } - sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, c.GetHeader("X-Conversation-ID")) + sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, extractHeaderSessionKey(c)) + if sessionKey == "" && len(request.Messages) > 1 { + if lastIdx := findLastAssistantIndex(request.Messages); lastIdx >= 0 { + fp := openAIMessagesFingerprint(request.Messages[:lastIdx+1]) + if conversations.has(fp) { + sessionKey = fp + } + } + } allowNewSession := request.PreviousResponseID == "" responseID := fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano()) created := time.Now().Unix() @@ -204,6 +214,7 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { return } usage = addUsage(usage, result.Accumulator.Usage) + bindNextOpenAIFingerprint(pool, request.Messages, result) } recordChoiceUsage(c, n, usage) if request.StreamOptions != nil && request.StreamOptions.IncludeUsage && usage != nil { @@ -232,6 +243,7 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { return } usage = addUsage(usage, result.Accumulator.Usage) + bindNextOpenAIFingerprint(pool, request.Messages, result) if response == nil { response = built continue @@ -320,9 +332,14 @@ func buildOpenAIChatPrompt(c *gin.Context, client *gemini.Client, request ChatRe if err != nil { return "", nil, err } - suffix, err := bridge.PromptSuffix() - if err != nil { - return "", nil, err + var suffix string + if continuation { + suffix = bridge.ContinuationSuffix() + } else { + suffix, err = bridge.PromptSuffix() + if err != nil { + return "", nil, err + } } return transcript + suffix, files, nil } @@ -628,3 +645,55 @@ func openAIUsage(usage *gemini.Usage) gin.H { "prompt_tokens_details": gin.H{"cached_tokens": 0}, "completion_tokens_details": gin.H{"reasoning_tokens": usage.ThoughtTokens}, } } + +func findLastAssistantIndex(messages []ChatMessage) int { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "assistant" || messages[i].Role == "model" { + return i + } + } + return -1 +} + +func openAIMessagesFingerprint(messages []ChatMessage) string { + if len(messages) == 0 { + return "" + } + h := sha256.New() + for _, m := range messages { + h.Write([]byte(m.Role)) + h.Write([]byte{0}) + h.Write(m.Content) + h.Write([]byte{0}) + h.Write([]byte(m.ToolCallID)) + h.Write([]byte{0}) + for _, tc := range m.ToolCalls { + h.Write([]byte(tc.ID)) + h.Write([]byte(tc.Function.Name)) + h.Write([]byte(tc.Function.Arguments)) + } + h.Write([]byte{0xFF}) + } + sum := h.Sum(nil) + return "fp_oai_" + hex.EncodeToString(sum[:16]) +} + +func bindNextOpenAIFingerprint(pool *balancer.AccountPool, messages []ChatMessage, result generationResult) { + if result.ConversationID == "" { + return + } + primary := result.Accumulator.Primary() + if primary.Text == "" && len(primary.Codes) == 0 { + return + } + assistantContent, err := json.Marshal(primary.Text) + if err != nil { + return + } + nextMessages := append(messages, ChatMessage{Role: "assistant", Content: assistantContent}) + nextFp := openAIMessagesFingerprint(nextMessages) + if state, accountID, ctxTokens, ok := conversations.get(result.ConversationID); ok { + conversations.put(state, accountID, ctxTokens, nextFp) + pool.BindSession(nextFp, accountID) + } +} diff --git a/internal/adapter/stream.go b/internal/adapter/stream.go index 82744f2..311e5fc 100644 --- a/internal/adapter/stream.go +++ b/internal/adapter/stream.go @@ -215,9 +215,6 @@ func runGenerationAttempts( } requestAttempts := 1 - if sessionKey == "" || newSession { - requestAttempts = 2 - } excludedAccountIDs := make(map[string]struct{}) availableAccounts := pool.Size() if availableAccounts == 0 { @@ -406,9 +403,9 @@ func runGenerationWithClient( if accumulator.Usage == nil { accumulator.Usage = estimatedUsage(accumulator, promptTokens) } - nextContextTokens := accumulator.Usage.TotalTokens + nextContextTokens := accumulator.Usage.PromptTokens + accumulator.Usage.CompletionTokens primary := accumulator.Primary() - if primary.Text != "" || primary.Thought != "" || len(primary.Codes) > 0 || len(primary.Media) > 0 { + if primary.Text != "" || len(primary.Codes) > 0 || len(primary.Media) > 0 { nextContextTokens++ } diff --git a/internal/adapter/tool_bridge.go b/internal/adapter/tool_bridge.go index 0a7789c..bdba8eb 100644 --- a/internal/adapter/tool_bridge.go +++ b/internal/adapter/tool_bridge.go @@ -105,6 +105,36 @@ func (b ToolBridge) PromptSuffix() (string, error) { return "\n\n" + strings.Join(sections, "\n\n"), nil } +// ContinuationSuffix 返回多轮续接时追加到增量提示词的精简协议约束,省略重复的工具 Schema 定义 +func (b ToolBridge) ContinuationSuffix() string { + if strings.EqualFold(strings.TrimSpace(b.Choice), "none") { + return "\n\n本次请求根据对话和已有工具结果直接回答;禁止调用函数工具、代码执行、Google 搜索和图片生成" + } + var sections []string + if len(b.Definitions) > 0 { + choice := strings.TrimSpace(b.Choice) + if choice == "" { + choice = "auto" + } + rule := "需要调用工具时,只输出一个 JSON 对象;无需调用工具时,直接输出正常回答" + switch strings.ToLower(choice) { + case "required", "any": + rule = "必须调用一个可用工具,只输出一个 JSON 对象" + case "auto": + default: + rule = fmt.Sprintf("必须调用工具 %s,只输出一个 JSON 对象", choice) + } + sections = append(sections, fmt.Sprintf("工具协议(沿用上一轮工具定义):\n%s,格式必须为 {\"tool_calls\":[{\"name\":\"工具名称\",\"arguments\":{}}]},不要输出 Markdown 代码块或额外文字", rule)) + } + if len(b.JSONSchema) > 0 { + sections = append(sections, "结构化输出协议:只输出符合 JSON Schema 的 JSON 值,不要输出 Markdown 代码块或额外文字") + } + if len(sections) == 0 { + return "" + } + return "\n\n" + strings.Join(sections, "\n\n") +} + // Parse 解析严格工具调用并验证结构化输出 func (b ToolBridge) Parse(text string) (string, []ToolCall, error) { trimmed := stripJSONFence(strings.TrimSpace(text)) diff --git a/internal/admin/accounts.go b/internal/admin/accounts.go index 857bbd7..2170a75 100644 --- a/internal/admin/accounts.go +++ b/internal/admin/accounts.go @@ -85,6 +85,7 @@ type chromeProfile struct { Email string `json:"email"` Importable bool `json:"importable"` Imported bool `json:"imported"` + Reason string `json:"reason,omitempty"` } func (h *handler) chromeProfiles(c *gin.Context) { @@ -97,7 +98,7 @@ func (h *handler) chromeProfiles(c *gin.Context) { for _, account := range discovered { profiles = append(profiles, chromeProfile{ ID: chromeauth.AccountID(account.Email), Profile: account.Profile, DisplayName: account.DisplayName, - Email: account.Email, Importable: account.Importable, Imported: account.Imported, + Email: account.Email, Importable: account.Importable, Imported: account.Imported, Reason: account.Reason, }) } c.JSON(http.StatusOK, gin.H{"profiles": profiles}) diff --git a/internal/chromeauth/auth.go b/internal/chromeauth/auth.go index 8c89c00..ad6489c 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -34,6 +34,7 @@ type Account struct { DisplayName string `json:"displayName"` Email string `json:"email"` Importable bool `json:"importable"` + Reason string `json:"reason,omitempty"` } // ImportOptions 保存批量导入参数 diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index e5f98a2..b1bf3ae 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -70,10 +70,23 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { continue } seen[identity.GaiaID] = true - _, encrypted, bindingKey, tokenErr := readTokenService(chromeRoot, profile, identity.GaiaID) + _, encrypted, _, tokenErr := readTokenService(chromeRoot, profile, identity.GaiaID) + importable := tokenErr == nil && strings.HasPrefix(string(encrypted), "v20") + reason := "" + if !importable { + if tokenErr != nil { + if strings.Contains(tokenErr.Error(), "没有账号") { + reason = "not_signed_in" + } else { + reason = "missing_token" + } + } else { + reason = "needs_reauth" + } + } accounts = append(accounts, Account{ Profile: profile, GaiaID: identity.GaiaID, DisplayName: info.Name, Email: identity.Email, - Importable: tokenErr == nil && strings.HasPrefix(string(encrypted), "v20") && len(bindingKey) != 0, + Importable: importable, Reason: reason, }) } } @@ -104,7 +117,7 @@ func profileIdentities(chromeRoot string, profile string) ([]profileIdentity, er // readTokenService 读取 Profile 中指定 Gaia ID 账号的 refresh token 与设备绑定密钥 func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { databasePath := filepath.Join(chromeRoot, profile, "Web Data") - uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro&immutable=1" + uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro" database, err := sql.Open("sqlite", uri) if err != nil { return "", nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) @@ -129,7 +142,7 @@ func readTokenService(chromeRoot string, profile string, gaiaID string) (string, if err := rows.Scan(&service, &encryptedToken, &bindingKey); err != nil { return "", nil, nil, fmt.Errorf("解析 %s token_service: %w", profile, err) } - if len(encryptedToken) == 0 || len(bindingKey) == 0 { + if len(encryptedToken) == 0 { return "", nil, nil, fmt.Errorf("%s 的 token_service 缺少认证材料", profile) } return strings.TrimPrefix(service, "AccountId-"), encryptedToken, bindingKey, nil diff --git a/internal/chromeauth/protocol.go b/internal/chromeauth/protocol.go index 1b8aa95..28582b2 100644 --- a/internal/chromeauth/protocol.go +++ b/internal/chromeauth/protocol.go @@ -63,20 +63,44 @@ type deviceBindingKey interface { } func fetchGoogleCookies(ctx context.Context, gaiaID string, token string, wrappedKey []byte, proxyURL string) ([]multiloginCookie, error) { - bindingKey, err := openDeviceBindingKey(wrappedKey) + client, err := newOAuthClient(proxyURL) if err != nil { return nil, err } - defer bindingKey.Close() - publicKey, spki, err := bindingKey.PublicKey() + defer client.CloseIdleConnections() + + if len(wrappedKey) == 0 { + status, resp, err := requestMultilogin(ctx, client, gaiaID, token, "") + if err != nil { + return nil, err + } + if status == http.StatusOK && resp.Status == "OK" && len(resp.Cookies) > 0 { + for _, required := range []string{"SAPISID", "__Secure-1PSID"} { + found := false + for _, c := range resp.Cookies { + if c.Name == required { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) + } + } + return resp.Cookies, nil + } + return nil, fmt.Errorf("OAuthMultilogin 失败 HTTP %d status %s(该账号缺少设备绑定密钥且会话失效,请在 Chrome 重新登录后导入)", status, resp.Status) + } + + bindingKey, err := openDeviceBindingKey(wrappedKey) if err != nil { return nil, err } - client, err := newOAuthClient(proxyURL) + defer bindingKey.Close() + publicKey, spki, err := bindingKey.PublicKey() if err != nil { return nil, err } - defer client.CloseIdleConnections() firstStatus, first, err := requestMultilogin(ctx, client, gaiaID, token, assertionSentinel) if err != nil { diff --git a/internal/gemini/client.go b/internal/gemini/client.go index bfa7bad..a7a41f0 100644 --- a/internal/gemini/client.go +++ b/internal/gemini/client.go @@ -816,10 +816,19 @@ func ProbeProxy(ctx context.Context, proxyURL string) (time.Duration, error) { return time.Since(started), nil } -// isAuthenticationError 判断错误是否为 HTTP 或帧内的 401、403,BardErrorInfo 1060 等出口限制不属于认证错误 +// isAuthenticationError 判断错误是否为 HTTP 或帧内的 401、403,以及重定向到登录页的 302,BardErrorInfo 1060 等出口限制不属于认证错误 func isAuthenticationError(err error) bool { var protocolErr *ProtocolError - return errors.As(err, &protocolErr) && (protocolErr.HTTPStatus == http.StatusUnauthorized || protocolErr.HTTPStatus == http.StatusForbidden) + if !errors.As(err, &protocolErr) { + return false + } + if protocolErr.HTTPStatus == http.StatusUnauthorized || protocolErr.HTTPStatus == http.StatusForbidden { + return true + } + if protocolErr.HTTPStatus == http.StatusFound || protocolErr.HTTPStatus == http.StatusSeeOther || protocolErr.HTTPStatus == http.StatusTemporaryRedirect { + return errors.Is(protocolErr.Cause, ErrSignedOut) || strings.Contains(protocolErr.Message, "登录") || strings.Contains(protocolErr.Message, "ServiceLogin") + } + return errors.Is(protocolErr.Cause, ErrSignedOut) } func newProtocolID() (string, error) { @@ -858,17 +867,39 @@ func initialReqID() (int64, error) { // ErrCaptcha 表示 Google 以人机验证页(/sorry)拦截了请求,出口 IP 与账号会话被判定为异常流量 var ErrCaptcha = errors.New("请求被 Google 人机验证页拦截") -// responseStatusError 把非 200 响应转为协议错误,重定向到 google.com/sorry 时为人机验证拦截,按 429 处理 +// responseStatusError 把非 200 响应转为协议错误,重定向到 google.com/sorry 时为人机验证拦截,按 429 处理;重定向到登录页时按 401 认证失效处理 func responseStatusError(resp *http.Response, operation string) *ProtocolError { - if location, err := resp.Location(); err == nil && strings.HasSuffix(location.Hostname(), "google.com") && strings.HasPrefix(location.Path, "/sorry") { - return &ProtocolError{ - HTTPStatus: http.StatusTooManyRequests, Code: http.StatusTooManyRequests, Retryable: true, Cause: ErrCaptcha, - Message: fmt.Sprintf("gemini %s 被 Google 人机验证页拦截(HTTP %d 重定向到 %s%s)", operation, resp.StatusCode, location.Host, location.Path), + if location, err := resp.Location(); err == nil { + if strings.HasSuffix(location.Hostname(), "google.com") && strings.HasPrefix(location.Path, "/sorry") { + return &ProtocolError{ + HTTPStatus: http.StatusTooManyRequests, Code: http.StatusTooManyRequests, Retryable: true, Cause: ErrCaptcha, + Message: fmt.Sprintf("gemini %s 被 Google 人机验证页拦截(HTTP %d 重定向到 %s%s)", operation, resp.StatusCode, location.Host, location.Path), + } + } + if isLoginRedirect(location) { + return &ProtocolError{ + HTTPStatus: http.StatusUnauthorized, Code: http.StatusUnauthorized, Retryable: true, Cause: ErrSignedOut, + Message: fmt.Sprintf("gemini %s 登录态失效,被重定向到登录页(HTTP %d 重定向到 %s)", operation, resp.StatusCode, location.String()), + } } } return httpStatusError(resp.StatusCode, operation) } +func isLoginRedirect(location *url.URL) bool { + if location == nil { + return false + } + host := strings.ToLower(location.Host) + path := strings.ToLower(location.Path) + if strings.Contains(host, "accounts.google.com") { + if strings.Contains(path, "servicelogin") || strings.Contains(path, "interactivelogin") || strings.Contains(path, "signin") || strings.Contains(location.RawQuery, "continue=") { + return true + } + } + return false +} + func httpStatusError(status int, operation string) *ProtocolError { return &ProtocolError{ HTTPStatus: status, diff --git a/internal/gemini/rotate.go b/internal/gemini/rotate.go index 7437550..15d7318 100644 --- a/internal/gemini/rotate.go +++ b/internal/gemini/rotate.go @@ -104,7 +104,14 @@ func (r *CookieRotator) rotateSession(ctx context.Context) (bool, error) { if err != nil { return false, err } - if signedIn || !listed { + if signedIn { + return !first, nil + } + if !listed { + // 没有 accounts.google.com Cookie 的纯 Cookie 导入账号,不主动触发 reload 避免引爆 302 + if c.refresh == nil { + return false, nil + } return !first, nil } if c.refresh == nil { diff --git a/web/src/features/accounts/ImportPage.tsx b/web/src/features/accounts/ImportPage.tsx index 9362403..181f07d 100644 --- a/web/src/features/accounts/ImportPage.tsx +++ b/web/src/features/accounts/ImportPage.tsx @@ -85,9 +85,15 @@ function ChromeImport() { {profile.imported ? ( {m.imported} - ) : ( - !profile.importable && {m.notSignedIn} - )} + ) : !profile.importable ? ( + + {profile.reason === 'missing_token' + ? m.missingToken + : profile.reason === 'needs_reauth' + ? m.needsReauth + : m.notSignedIn} + + ) : null} ))} diff --git a/web/src/features/accounts/messages.ts b/web/src/features/accounts/messages.ts index f6e2ca7..5da081c 100644 --- a/web/src/features/accounts/messages.ts +++ b/web/src/features/accounts/messages.ts @@ -642,6 +642,8 @@ export const importMessages = defineMessages({ chromeHint: '选择已登录 Gemini 的 Chrome Profile', imported: '已导入', notSignedIn: '未登录 Google', + missingToken: '缺少认证令牌', + needsReauth: '需要重新登录', importSelected: '导入所选', noProfiles: '未发现 Chrome Profile', cookieTitle: '从 Cookie 导入', @@ -675,6 +677,8 @@ export const importMessages = defineMessages({ chromeHint: '選擇已登入 Gemini 的 Chrome Profile', imported: '已匯入', notSignedIn: '未登入 Google', + missingToken: '缺少認證權杖', + needsReauth: '需要重新登入', importSelected: '匯入所選', noProfiles: '未發現 Chrome Profile', cookieTitle: '從 Cookie 匯入', @@ -708,6 +712,8 @@ export const importMessages = defineMessages({ chromeHint: 'Pick a Chrome profile signed in to Gemini', imported: 'Imported', notSignedIn: 'Not signed in to Google', + missingToken: 'Missing token', + needsReauth: 'Needs re-login', importSelected: 'Import selected', noProfiles: 'No Chrome profile found', cookieTitle: 'Import from cookie', @@ -743,6 +749,8 @@ export const importMessages = defineMessages({ chromeHint: 'Gemini にログイン済みの Chrome プロファイルを選択します', imported: 'インポート済み', notSignedIn: 'Google 未ログイン', + missingToken: 'トークンなし', + needsReauth: '再ログインが必要', importSelected: '選択項目をインポート', noProfiles: 'Chrome プロファイルが見つかりません', cookieTitle: 'Cookie からインポート', @@ -777,6 +785,8 @@ export const importMessages = defineMessages({ chromeHint: 'Gemini에 로그인된 Chrome 프로필을 선택합니다', imported: '가져옴', notSignedIn: 'Google 로그인 안 됨', + missingToken: '토큰 없음', + needsReauth: '다시 로그인 필요', importSelected: '선택 항목 가져오기', noProfiles: 'Chrome 프로필을 찾지 못했습니다', cookieTitle: '쿠키에서 가져오기', @@ -812,6 +822,8 @@ export const importMessages = defineMessages({ chromeHint: 'Choisissez un profil Chrome connecté à Gemini', imported: 'Importé', notSignedIn: 'Non connecté à Google', + missingToken: 'Jeton manquant', + needsReauth: 'Reconnexion requise', importSelected: 'Importer la sélection', noProfiles: 'Aucun profil Chrome trouvé', cookieTitle: 'Importer depuis un cookie', @@ -847,6 +859,8 @@ export const importMessages = defineMessages({ chromeHint: 'Wählen Sie ein Chrome-Profil, das bei Gemini angemeldet ist', imported: 'Importiert', notSignedIn: 'Nicht bei Google angemeldet', + missingToken: 'Token fehlt', + needsReauth: 'Erneute Anmeldung erforderlich', importSelected: 'Auswahl importieren', noProfiles: 'Kein Chrome-Profil gefunden', cookieTitle: 'Aus Cookie importieren', diff --git a/web/src/features/accounts/types.ts b/web/src/features/accounts/types.ts index 4bf0329..c14347e 100644 --- a/web/src/features/accounts/types.ts +++ b/web/src/features/accounts/types.ts @@ -74,6 +74,7 @@ export interface ChromeProfile { email: string importable: boolean imported: boolean + reason?: string } // ChromeProfiles 对应 GET /api/accounts/import/chrome 的响应 From 397e3a7e550d45e3107c46f5601b1e2b2ed6a5a6 Mon Sep 17 00:00:00 2001 From: Vaxaxas <250858849+Vaxaxas@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:21:58 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(auth):=20=E6=94=AF=E6=8F=B4=E5=AD=97?= =?UTF-8?q?=E4=B8=B2=E8=88=87=E6=95=B8=E5=AD=97=E6=A0=BC=E5=BC=8F=E7=9A=84?= =?UTF-8?q?=20authUser=20=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 兼容 string 與 int 形式的 authUser,避免舊版或手工設定的 storage-state.json 報 json unmarshal 錯誤 --- internal/auth/state.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/auth/state.go b/internal/auth/state.go index 7dc4a52..cc3e06b 100644 --- a/internal/auth/state.go +++ b/internal/auth/state.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "sync" @@ -47,6 +48,33 @@ type Metadata struct { Disabled bool `json:"disabled,omitempty"` } +// UnmarshalJSON 兼容数字与字符串形式的 authUser +func (m *Metadata) UnmarshalJSON(data []byte) error { + type Alias Metadata + var aux struct { + Alias + RawAuthUser json.RawMessage `json:"authUser,omitempty"` + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *m = Metadata(aux.Alias) + if len(aux.RawAuthUser) > 0 { + var intVal int + if err := json.Unmarshal(aux.RawAuthUser, &intVal); err == nil { + m.AuthUser = intVal + } else { + var strVal string + if err := json.Unmarshal(aux.RawAuthUser, &strVal); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(strVal)); err == nil { + m.AuthUser = n + } + } + } + } + return nil +} + // StorageState 是兼容 Playwright 的认证状态文件 type StorageState struct { Cookies []StateCookie `json:"cookies"` From 8de8d5e9670c4c63f263f8c3e7135472590d17b7 Mon Sep 17 00:00:00 2001 From: Vaxaxas <250858849+Vaxaxas@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:24:12 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(gemini):=20=E5=B0=87=20/u/N/app=20?= =?UTF-8?q?=E5=BA=8F=E8=99=9F=E5=81=8F=E7=A7=BB=E9=87=8D=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E8=AD=98=E5=88=A5=E7=82=BA=E7=99=BB=E5=85=A5=E6=85=8B=E5=A4=B1?= =?UTF-8?q?=E6=95=88/=E5=BE=85=E8=AA=8D=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 當 Google 將 /u/N/app 重定向回 /app 時識別為 isLoginRedirect,避免拋出原始 HTTP 302 錯誤 --- internal/gemini/client.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/gemini/client.go b/internal/gemini/client.go index a7a41f0..8f721cd 100644 --- a/internal/gemini/client.go +++ b/internal/gemini/client.go @@ -879,7 +879,7 @@ func responseStatusError(resp *http.Response, operation string) *ProtocolError { if isLoginRedirect(location) { return &ProtocolError{ HTTPStatus: http.StatusUnauthorized, Code: http.StatusUnauthorized, Retryable: true, Cause: ErrSignedOut, - Message: fmt.Sprintf("gemini %s 登录态失效,被重定向到登录页(HTTP %d 重定向到 %s)", operation, resp.StatusCode, location.String()), + Message: fmt.Sprintf("gemini %s 登录态失效或账号序号偏移(HTTP %d 重定向到 %s)", operation, resp.StatusCode, location.String()), } } } @@ -897,6 +897,11 @@ func isLoginRedirect(location *url.URL) bool { return true } } + if strings.Contains(host, "gemini.google.com") { + if path == "/app" || path == "/" || strings.HasPrefix(path, "/u/") { + return true + } + } return false } From 1b5f770f79473c99cc4180802443d4b102bb66b9 Mon Sep 17 00:00:00 2001 From: Vaxaxas <250858849+Vaxaxas@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:37:32 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(chromeauth):=20=E8=A4=87=E8=A3=BD=20Web?= =?UTF-8?q?=20Data=20=E5=89=AF=E6=9C=AC=E8=AE=80=E5=8F=96=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20Chrome=20=E9=8E=96=E5=AE=9A=E6=99=82=20SQLITE=5FBUS?= =?UTF-8?q?Y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 當 Chrome 正在執行時,直接開啟 Web Data 會報 database is locked (SQLITE_BUSY),導致所有帳號誤判為 missing_token - 透過拷貝 Web Data 及其 WAL 日志至臨時目錄後再開啟讀取,可完整讀取所有帳號之 v20 token --- internal/chromeauth/platform_windows.go | 57 +++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index b1bf3ae..3d308be 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/json" "fmt" + "io" "os" "path/filepath" "sort" @@ -114,16 +115,64 @@ func profileIdentities(chromeRoot string, profile string) ([]profileIdentity, er return preferences.Accounts, nil } -// readTokenService 读取 Profile 中指定 Gaia ID 账号的 refresh token 与设备绑定密钥 -func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +func openWebData(chromeRoot string, profile string) (*sql.DB, func(), error) { databasePath := filepath.Join(chromeRoot, profile, "Web Data") + cleanup := func() {} + + // 当 Chrome 正在运行时,直接以 mode=ro 打开 Web Data 会触发 SQLITE_BUSY (database is locked)。 + // 为此将 Web Data 及其 WAL 文件拷贝到临时文件后再打开,既能读取最新的 WAL 提交,又避开了 Chrome 的排他锁。 + tempDir, err := os.MkdirTemp("", "gemini-web2api-webdata-*") + if err == nil { + tempDb := filepath.Join(tempDir, "Web Data") + if copyFile(databasePath, tempDb) == nil { + _ = copyFile(databasePath+"-wal", tempDb+"-wal") + _ = copyFile(databasePath+"-shm", tempDb+"-shm") + databasePath = tempDb + cleanup = func() { + _ = os.RemoveAll(tempDir) + } + } else { + _ = os.RemoveAll(tempDir) + } + } + uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro" database, err := sql.Open("sqlite", uri) if err != nil { - return "", nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) + cleanup() + return nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) } - defer database.Close() database.SetMaxOpenConns(1) + return database, func() { + database.Close() + cleanup() + }, nil +} + +// readTokenService 读取 Profile 中指定 Gaia ID 账号的 refresh token 与设备绑定密钥 +func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { + database, closeDb, err := openWebData(chromeRoot, profile) + if err != nil { + return "", nil, nil, err + } + defer closeDb() rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service = ?", "AccountId-"+gaiaID) if err != nil { From eae96811938570c0f7381d9a10ae9a209729e037 Mon Sep 17 00:00:00 2001 From: Vaxaxas <250858849+Vaxaxas@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:45:28 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(auth):=20=E6=94=BE=E5=AF=AC=20validateO?= =?UTF-8?q?AuthMaterial=20=E5=B0=8D=20WrappedBindingKey=20=E7=9A=84?= =?UTF-8?q?=E5=BC=B7=E5=88=B6=E6=A0=A1=E9=A9=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 允許無 DBSC 設備綁定密鑰的帳號正常寫入與載入認證狀態檔,支援非 DBSC 帳號順利匯入 --- internal/auth/state.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/auth/state.go b/internal/auth/state.go index cc3e06b..7e913f3 100644 --- a/internal/auth/state.go +++ b/internal/auth/state.go @@ -34,7 +34,7 @@ type ImportSource struct { type OAuthMaterial struct { GaiaID string `json:"gaiaId"` RefreshToken string `json:"refreshToken"` - WrappedBindingKey []byte `json:"wrappedBindingKey"` + WrappedBindingKey []byte `json:"wrappedBindingKey,omitempty"` } // Metadata 保存账号固定身份 @@ -320,7 +320,7 @@ func validateOAuthMaterial(material *OAuthMaterial) error { if material == nil { return nil } - if strings.TrimSpace(material.GaiaID) == "" || strings.TrimSpace(material.RefreshToken) == "" || len(material.WrappedBindingKey) == 0 { + if strings.TrimSpace(material.GaiaID) == "" || strings.TrimSpace(material.RefreshToken) == "" { return fmt.Errorf("OAuth 续签材料不完整") } return nil From 1ad2227c5632d62b11d41f21f596f9713a9d18b8 Mon Sep 17 00:00:00 2001 From: fukg1thub Date: Tue, 20 Oct 2026 02:18:49 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E6=AD=A3=E8=A6=8F=E5=8C=96=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=AA=BF=E7=94=A8=E8=88=87=E6=80=9D=E8=80=83=E5=8D=80?= =?UTF-8?q?=E5=A1=8A=E6=9C=83=E8=A9=B1=E6=8C=87=E7=B4=8B=E3=80=81=E6=A0=A1?= =?UTF-8?q?=E9=A9=97=E7=BA=8C=E6=8E=A5=E5=B7=A5=E5=85=B7=E9=9B=86=E4=B8=A6?= =?UTF-8?q?=E5=84=AA=E5=8C=96=20Web=20Data=20=E5=96=AE=E6=AC=A1=E8=A4=87?= =?UTF-8?q?=E8=A3=BD=E8=AE=80=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 claude_handler.go 與 openai_chat.go 中正規化訊息指紋計算,拆解 bridge.Parse 工具調用並忽略動態 tool_call id 與 thinking 區塊差異,確保多步工具迴圈穩定命中會話續接並消除 slice 底層陣列污染 - 在 conversation.go 中記錄與比對會話工具集雜湊(toolsHash),當續接輪次新增或變更工具定義時自動回退發送完整 PromptSuffix - 在 platform_windows.go 新增 readProfileTokens 以單一 Profile 為單位複製並讀取一次 Web Data + WAL(不複製 -shm 且移除臨時檔 mode=ro 限制),避免同 Profile 多帳號重複拷貝資料庫 - 在 protocol.go 的 encodeMultiOAuthHeader 中當無 DBSC assertion 時省略 protobuf 第 3 欄位,並在 state.go 補齊 authUser 負數與非法字串校驗 --- internal/adapter/claude_handler.go | 154 +++++++++++++++++++++--- internal/adapter/conversation.go | 79 +++++++++++- internal/adapter/openai_chat.go | 119 ++++++++++++++++-- internal/auth/state.go | 17 ++- internal/chromeauth/platform_windows.go | 61 +++++++--- internal/chromeauth/protocol.go | 4 +- 6 files changed, 385 insertions(+), 49 deletions(-) diff --git a/internal/adapter/claude_handler.go b/internal/adapter/claude_handler.go index 1d9a8ff..e10d0d9 100644 --- a/internal/adapter/claude_handler.go +++ b/internal/adapter/claude_handler.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "strings" "time" @@ -64,6 +65,9 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { } } } + if sessionKey != "" { + c.Set("session_key", sessionKey) + } responseID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) if request.Stream { @@ -172,7 +176,7 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { if len(calls) > 0 { stopReason = "tool_use" } - bindNextClaudeFingerprint(pool, request.System, request.Messages, result) + bindNextClaudeFingerprint(pool, request.System, request.Messages, sessionKey, responseID, result, bridge) _ = processor.Finish(stopReason) return } @@ -251,7 +255,7 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { c.Header("X-Response-ID", responseID) c.Header("X-Conversation-ID", result.ConversationID) c.Header("X-Provider-Model", result.ProviderModel) - bindNextClaudeFingerprint(pool, request.System, request.Messages, result) + bindNextClaudeFingerprint(pool, request.System, request.Messages, sessionKey, responseID, result, bridge) c.JSON(http.StatusOK, response) } } @@ -319,8 +323,12 @@ func buildClaudePrompt(c *gin.Context, client *gemini.Client, request claude.Cla if err != nil { return "", nil, err } + sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, extractHeaderSessionKey(c)) + if sessionKey == "" && c != nil { + sessionKey = c.GetString("session_key") + } var suffix string - if continuation { + if continuation && conversations.hasSameTools(sessionKey, bridge.fingerprint()) { suffix = bridge.ContinuationSuffix() } else { suffix, err = bridge.PromptSuffix() @@ -419,41 +427,157 @@ func findLastClaudeAssistantIndex(messages []claude.Message) int { return -1 } +func writeCanonicalClaudeContent(h io.Writer, raw json.RawMessage) { + if len(raw) == 0 || string(raw) == "null" { + return + } + var str string + if err := json.Unmarshal(raw, &str); err == nil { + if trimmed := strings.TrimSpace(str); trimmed != "" { + _, _ = io.WriteString(h, "text:") + _, _ = io.WriteString(h, trimmed) + _, _ = h.Write([]byte{0}) + } + return + } + var blocks []claude.ContentBlock + if err := json.Unmarshal(raw, &blocks); err == nil { + var textBuf strings.Builder + flushText := func() { + if trimmed := strings.TrimSpace(textBuf.String()); trimmed != "" { + _, _ = io.WriteString(h, "text:") + _, _ = io.WriteString(h, trimmed) + _, _ = h.Write([]byte{0}) + } + textBuf.Reset() + } + for _, b := range blocks { + switch b.Type { + case "thinking", "redacted_thinking", "server_tool_use", "web_search_tool_result": + continue + case "text": + textBuf.WriteString(b.Text) + case "tool_use": + flushText() + _, _ = io.WriteString(h, "tool_use:") + _, _ = io.WriteString(h, b.Name) + _, _ = h.Write([]byte{0}) + if b.Input != nil { + if inputBytes, err := json.Marshal(b.Input); err == nil { + _, _ = h.Write(inputBytes) + } + } + _, _ = h.Write([]byte{0}) + case "tool_result": + flushText() + _, _ = io.WriteString(h, "tool_result:") + _, _ = io.WriteString(h, b.ToolUseID) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(canonicalJSONBytes(b.Content)) + _, _ = h.Write([]byte{0}) + default: + flushText() + _, _ = io.WriteString(h, b.Type) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(canonicalJSONBytes(b.Content)) + _, _ = h.Write([]byte{0}) + } + } + flushText() + return + } + _, _ = h.Write(canonicalJSONBytes(raw)) +} + func claudeMessagesFingerprint(system json.RawMessage, messages []claude.Message) string { if len(messages) == 0 { return "" } h := sha256.New() if len(system) > 0 { - h.Write(system) - h.Write([]byte{0xFF}) + if sysText, err := claude.ParseSystemPrompt(system); err == nil && strings.TrimSpace(sysText) != "" { + _, _ = io.WriteString(h, strings.TrimSpace(sysText)) + _, _ = h.Write([]byte{0xFF}) + } } for _, m := range messages { - h.Write([]byte(m.Role)) - h.Write([]byte{0}) - h.Write(m.Content) - h.Write([]byte{0xFF}) + _, _ = io.WriteString(h, m.Role) + _, _ = h.Write([]byte{0}) + writeCanonicalClaudeContent(h, m.Content) + _, _ = h.Write([]byte{0xFF}) } sum := h.Sum(nil) return "fp_cld_" + hex.EncodeToString(sum[:16]) } -func bindNextClaudeFingerprint(pool *balancer.AccountPool, system json.RawMessage, messages []claude.Message, result generationResult) { +func bindNextClaudeFingerprint(pool *balancer.AccountPool, system json.RawMessage, messages []claude.Message, sessionKey string, responseID string, result generationResult, bridge ToolBridge) { if result.ConversationID == "" { return } + toolsFp := bridge.fingerprint() primary := result.Accumulator.Primary() if primary.Text == "" && len(primary.Codes) == 0 { + conversations.setTools(toolsFp, result.ConversationID, responseID, sessionKey) return } - assistantContent, err := json.Marshal(primary.Text) + content, calls, err := bridge.Parse(primary.Text) if err != nil { + content = primary.Text + calls = nil + } + rendered := renderCandidateMarkdown(content, primary, 0) + renderedWithCitations := renderCitationMarkdown(rendered, primary.Citations) + + buildBlocks := func(textVal string) (json.RawMessage, bool) { + blocks := make([]claude.ContentBlock, 0, 1+len(calls)) + if strings.TrimSpace(textVal) != "" { + blocks = append(blocks, claude.ContentBlock{Type: "text", Text: textVal}) + } + for _, call := range calls { + var input map[string]interface{} + _ = json.Unmarshal(call.Arguments, &input) + blocks = append(blocks, claude.ContentBlock{Type: "tool_use", Name: call.Name, Input: &input}) + } + if len(blocks) == 0 { + return nil, false + } + raw, err := json.Marshal(blocks) + return raw, err == nil + } + + var fps []string + for _, candidateText := range []string{renderedWithCitations, rendered, content} { + assistantContent, ok := buildBlocks(candidateText) + if !ok { + continue + } + nextMessages := make([]claude.Message, len(messages)+1) + copy(nextMessages, messages) + nextMessages[len(messages)] = claude.Message{Role: "assistant", Content: assistantContent} + fp := claudeMessagesFingerprint(system, nextMessages) + if fp != "" { + seen := false + for _, existing := range fps { + if existing == fp { + seen = true + break + } + } + if !seen { + fps = append(fps, fp) + } + } + } + if len(fps) == 0 { + conversations.setTools(toolsFp, result.ConversationID, responseID, sessionKey) return } - nextMessages := append(messages, claude.Message{Role: "assistant", Content: assistantContent}) - nextFp := claudeMessagesFingerprint(system, nextMessages) if state, accountID, ctxTokens, ok := conversations.get(result.ConversationID); ok { - conversations.put(state, accountID, ctxTokens, nextFp) - pool.BindSession(nextFp, accountID) + conversations.put(state, accountID, ctxTokens, fps...) + for _, fp := range fps { + pool.BindSession(fp, accountID) + } } + allKeys := append([]string{result.ConversationID, responseID, sessionKey}, fps...) + conversations.setTools(toolsFp, allKeys...) } diff --git a/internal/adapter/conversation.go b/internal/adapter/conversation.go index 40fd536..10029ec 100644 --- a/internal/adapter/conversation.go +++ b/internal/adapter/conversation.go @@ -2,6 +2,9 @@ package adapter import ( "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "strings" "sync" "time" @@ -27,6 +30,7 @@ type conversationEntry struct { snapshot gemini.ConversationSnapshot accountID string contextTokens int + toolsHash string expiresAt time.Time } @@ -80,8 +84,13 @@ func (r *conversationRegistry) put(state *gemini.ConversationState, accountID st r.mu.Lock() now := time.Now() r.cleanupLocked(now) + var toolsHash string + if prev, ok := r.states[state.Snapshot().CID]; ok { + toolsHash = prev.toolsHash + } entry := conversationEntry{ - snapshot: state.Snapshot(), accountID: accountID, contextTokens: contextTokens, expiresAt: now.Add(r.ttl), + snapshot: state.Snapshot(), accountID: accountID, contextTokens: contextTokens, + toolsHash: toolsHash, expiresAt: now.Add(r.ttl), } for _, key := range keys { if key != "" { @@ -254,3 +263,71 @@ func extractHeaderSessionKey(c interface{ GetHeader(string) string }) string { } return "" } + +func (r *conversationRegistry) hasSameTools(key string, toolsHash string) bool { + if toolsHash == "" { + return true + } + if key == "" { + return false + } + r.mu.Lock() + defer r.mu.Unlock() + entry, ok := r.states[key] + if !ok || time.Now().After(entry.expiresAt) { + return false + } + if entry.toolsHash == "" { + return true + } + return entry.toolsHash == toolsHash +} + +func (r *conversationRegistry) setTools(toolsHash string, keys ...string) { + if toolsHash == "" { + toolsHash = "none" + } + r.mu.Lock() + defer r.mu.Unlock() + for _, key := range keys { + if key == "" { + continue + } + if entry, ok := r.states[key]; ok { + entry.toolsHash = toolsHash + r.states[key] = entry + } + } +} + +func (b ToolBridge) fingerprint() string { + if len(b.Definitions) == 0 && len(b.JSONSchema) == 0 { + return "" + } + h := sha256.New() + if len(b.Definitions) > 0 { + if data, err := json.Marshal(b.Definitions); err == nil { + h.Write(data) + } + } + h.Write([]byte{0xFF}) + if len(b.JSONSchema) > 0 { + h.Write(canonicalJSONBytes(b.JSONSchema)) + } + sum := h.Sum(nil) + return hex.EncodeToString(sum[:16]) +} + +func canonicalJSONBytes(raw []byte) []byte { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return nil + } + var v interface{} + if err := json.Unmarshal([]byte(trimmed), &v); err == nil { + if norm, err := json.Marshal(v); err == nil { + return norm + } + } + return []byte(trimmed) +} diff --git a/internal/adapter/openai_chat.go b/internal/adapter/openai_chat.go index 6cf865c..16309bc 100644 --- a/internal/adapter/openai_chat.go +++ b/internal/adapter/openai_chat.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/Mag1cFall/Gemini-Web2API/internal/balancer" @@ -137,6 +138,9 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { } } } + if sessionKey != "" { + c.Set("session_key", sessionKey) + } allowNewSession := request.PreviousResponseID == "" responseID := fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano()) created := time.Now().Unix() @@ -214,7 +218,9 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { return } usage = addUsage(usage, result.Accumulator.Usage) - bindNextOpenAIFingerprint(pool, request.Messages, result) + if choice == 0 { + bindNextOpenAIFingerprint(pool, request.Messages, sessionKey, responseID, result, bridge) + } } recordChoiceUsage(c, n, usage) if request.StreamOptions != nil && request.StreamOptions.IncludeUsage && usage != nil { @@ -243,7 +249,9 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { return } usage = addUsage(usage, result.Accumulator.Usage) - bindNextOpenAIFingerprint(pool, request.Messages, result) + if choice == 0 { + bindNextOpenAIFingerprint(pool, request.Messages, sessionKey, responseID, result, bridge) + } if response == nil { response = built continue @@ -332,8 +340,12 @@ func buildOpenAIChatPrompt(c *gin.Context, client *gemini.Client, request ChatRe if err != nil { return "", nil, err } + sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, extractHeaderSessionKey(c)) + if sessionKey == "" && c != nil { + sessionKey = c.GetString("session_key") + } var suffix string - if continuation { + if continuation && conversations.hasSameTools(sessionKey, bridge.fingerprint()) { suffix = bridge.ContinuationSuffix() } else { suffix, err = bridge.PromptSuffix() @@ -655,22 +667,54 @@ func findLastAssistantIndex(messages []ChatMessage) int { return -1 } +func normalizeOpenAIMessageContent(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return strings.TrimSpace(text) + } + var parts []map[string]json.RawMessage + if err := json.Unmarshal(raw, &parts); err == nil { + var b strings.Builder + for _, part := range parts { + var partType string + _ = json.Unmarshal(part["type"], &partType) + switch partType { + case "text", "input_text", "output_text", "": + var val string + if json.Unmarshal(part["text"], &val) == nil { + b.WriteString(val) + } + } + } + return strings.TrimSpace(b.String()) + } + return strings.TrimSpace(string(raw)) +} + func openAIMessagesFingerprint(messages []ChatMessage) string { if len(messages) == 0 { return "" } h := sha256.New() for _, m := range messages { - h.Write([]byte(m.Role)) + role := m.Role + if role == "model" { + role = "assistant" + } + h.Write([]byte(role)) h.Write([]byte{0}) - h.Write(m.Content) + h.Write([]byte(normalizeOpenAIMessageContent(m.Content))) h.Write([]byte{0}) h.Write([]byte(m.ToolCallID)) h.Write([]byte{0}) for _, tc := range m.ToolCalls { - h.Write([]byte(tc.ID)) h.Write([]byte(tc.Function.Name)) - h.Write([]byte(tc.Function.Arguments)) + h.Write([]byte{0}) + h.Write(canonicalJSONBytes([]byte(tc.Function.Arguments))) + h.Write([]byte{0}) } h.Write([]byte{0xFF}) } @@ -678,22 +722,71 @@ func openAIMessagesFingerprint(messages []ChatMessage) string { return "fp_oai_" + hex.EncodeToString(sum[:16]) } -func bindNextOpenAIFingerprint(pool *balancer.AccountPool, messages []ChatMessage, result generationResult) { +func bindNextOpenAIFingerprint(pool *balancer.AccountPool, messages []ChatMessage, sessionKey string, responseID string, result generationResult, bridge ToolBridge) { if result.ConversationID == "" { return } + toolsFp := bridge.fingerprint() primary := result.Accumulator.Primary() if primary.Text == "" && len(primary.Codes) == 0 { + conversations.setTools(toolsFp, result.ConversationID, responseID, sessionKey) return } - assistantContent, err := json.Marshal(primary.Text) + content, calls, err := bridge.Parse(primary.Text) if err != nil { + content = primary.Text + calls = nil + } + + var candidates []ChatMessage + if len(calls) > 0 { + toolCalls := make([]OpenAIToolCall, 0, len(calls)) + for _, call := range calls { + var tc OpenAIToolCall + tc.Type = "function" + tc.Function.Name = call.Name + tc.Function.Arguments = string(call.Arguments) + toolCalls = append(toolCalls, tc) + } + candidates = append(candidates, ChatMessage{Role: "assistant", ToolCalls: toolCalls}) + } else { + rendered := renderCandidateMarkdown(content, primary, 0) + for _, textVal := range []string{rendered, content} { + if raw, err := json.Marshal(textVal); err == nil { + candidates = append(candidates, ChatMessage{Role: "assistant", Content: raw}) + } + } + } + + var fps []string + for _, assistantMsg := range candidates { + nextMessages := make([]ChatMessage, len(messages)+1) + copy(nextMessages, messages) + nextMessages[len(messages)] = assistantMsg + fp := openAIMessagesFingerprint(nextMessages) + if fp != "" { + seen := false + for _, existing := range fps { + if existing == fp { + seen = true + break + } + } + if !seen { + fps = append(fps, fp) + } + } + } + if len(fps) == 0 { + conversations.setTools(toolsFp, result.ConversationID, responseID, sessionKey) return } - nextMessages := append(messages, ChatMessage{Role: "assistant", Content: assistantContent}) - nextFp := openAIMessagesFingerprint(nextMessages) if state, accountID, ctxTokens, ok := conversations.get(result.ConversationID); ok { - conversations.put(state, accountID, ctxTokens, nextFp) - pool.BindSession(nextFp, accountID) + conversations.put(state, accountID, ctxTokens, fps...) + for _, fp := range fps { + pool.BindSession(fp, accountID) + } } + allKeys := append([]string{result.ConversationID, responseID, sessionKey}, fps...) + conversations.setTools(toolsFp, allKeys...) } diff --git a/internal/auth/state.go b/internal/auth/state.go index 7e913f3..23a6a3a 100644 --- a/internal/auth/state.go +++ b/internal/auth/state.go @@ -59,16 +59,25 @@ func (m *Metadata) UnmarshalJSON(data []byte) error { return err } *m = Metadata(aux.Alias) - if len(aux.RawAuthUser) > 0 { + if len(aux.RawAuthUser) > 0 && string(aux.RawAuthUser) != "null" { var intVal int if err := json.Unmarshal(aux.RawAuthUser, &intVal); err == nil { + if intVal < 0 { + return fmt.Errorf("authUser 不能为负数: %d", intVal) + } m.AuthUser = intVal } else { var strVal string - if err := json.Unmarshal(aux.RawAuthUser, &strVal); err == nil { - if n, err := strconv.Atoi(strings.TrimSpace(strVal)); err == nil { - m.AuthUser = n + if err := json.Unmarshal(aux.RawAuthUser, &strVal); err != nil { + return fmt.Errorf("解析 authUser: %w", err) + } + trimmed := strings.TrimSpace(strVal) + if trimmed != "" { + n, err := strconv.Atoi(trimmed) + if err != nil || n < 0 { + return fmt.Errorf("无效的 authUser 值: %q", strVal) } + m.AuthUser = n } } } diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index 3d308be..27ca438 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -65,23 +65,25 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { accounts = append(accounts, Account{Profile: profile, DisplayName: info.Name, Email: info.UserName}) continue } + tokens, dbErr := readProfileTokens(chromeRoot, profile) seen := make(map[string]bool, len(identities)) for _, identity := range identities { if identity.GaiaID == "" || seen[identity.GaiaID] { continue } seen[identity.GaiaID] = true - _, encrypted, _, tokenErr := readTokenService(chromeRoot, profile, identity.GaiaID) - importable := tokenErr == nil && strings.HasPrefix(string(encrypted), "v20") + encrypted, hasToken := tokens[identity.GaiaID] + importable := dbErr == nil && hasToken && strings.HasPrefix(string(encrypted), "v20") reason := "" if !importable { - if tokenErr != nil { - if strings.Contains(tokenErr.Error(), "没有账号") { - reason = "not_signed_in" - } else { - reason = "missing_token" - } - } else { + switch { + case dbErr != nil: + reason = "missing_token" + case !hasToken: + reason = "not_signed_in" + case len(encrypted) == 0: + reason = "missing_token" + default: reason = "needs_reauth" } } @@ -135,16 +137,15 @@ func copyFile(src, dst string) error { func openWebData(chromeRoot string, profile string) (*sql.DB, func(), error) { databasePath := filepath.Join(chromeRoot, profile, "Web Data") cleanup := func() {} + uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro&immutable=1" // 当 Chrome 正在运行时,直接以 mode=ro 打开 Web Data 会触发 SQLITE_BUSY (database is locked)。 - // 为此将 Web Data 及其 WAL 文件拷贝到临时文件后再打开,既能读取最新的 WAL 提交,又避开了 Chrome 的排他锁。 - tempDir, err := os.MkdirTemp("", "gemini-web2api-webdata-*") - if err == nil { + // 为此将 Web Data 及其 WAL 文件拷贝到临时文件后再打开,既能由 SQLite 自动重建索引读取最新的 WAL 提交,又避开了 Chrome 的排他锁。 + if tempDir, err := os.MkdirTemp("", "gemini-web2api-webdata-*"); err == nil { tempDb := filepath.Join(tempDir, "Web Data") if copyFile(databasePath, tempDb) == nil { _ = copyFile(databasePath+"-wal", tempDb+"-wal") - _ = copyFile(databasePath+"-shm", tempDb+"-shm") - databasePath = tempDb + uri = "file:" + filepath.ToSlash(tempDb) cleanup = func() { _ = os.RemoveAll(tempDir) } @@ -153,7 +154,6 @@ func openWebData(chromeRoot string, profile string) (*sql.DB, func(), error) { } } - uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro" database, err := sql.Open("sqlite", uri) if err != nil { cleanup() @@ -166,6 +166,37 @@ func openWebData(chromeRoot string, profile string) (*sql.DB, func(), error) { }, nil } +// readProfileTokens 一次性读取指定 Profile 中全部账号的 encrypted_token,避免多账号循环重复拷贝数据库 +func readProfileTokens(chromeRoot string, profile string) (map[string][]byte, error) { + database, closeDb, err := openWebData(chromeRoot, profile) + if err != nil { + return nil, err + } + defer closeDb() + + rows, err := database.Query("SELECT service, encrypted_token FROM token_service") + if err != nil { + return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + } + defer rows.Close() + + tokens := make(map[string][]byte) + for rows.Next() { + var service string + var encryptedToken []byte + if err := rows.Scan(&service, &encryptedToken); err != nil { + return nil, fmt.Errorf("解析 %s token_service: %w", profile, err) + } + if gaiaID, ok := strings.CutPrefix(service, "AccountId-"); ok && gaiaID != "" { + tokens[gaiaID] = encryptedToken + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + } + return tokens, nil +} + // readTokenService 读取 Profile 中指定 Gaia ID 账号的 refresh token 与设备绑定密钥 func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { database, closeDb, err := openWebData(chromeRoot, profile) diff --git a/internal/chromeauth/protocol.go b/internal/chromeauth/protocol.go index 28582b2..d154f5e 100644 --- a/internal/chromeauth/protocol.go +++ b/internal/chromeauth/protocol.go @@ -205,7 +205,9 @@ func findChallenge(response multiloginResponse) string { func encodeMultiOAuthHeader(gaiaID string, token string, assertion string) string { account := appendBytesField(nil, 1, []byte(gaiaID)) account = appendBytesField(account, 2, []byte(token)) - account = appendBytesField(account, 3, []byte(assertion)) + if assertion != "" { + account = appendBytesField(account, 3, []byte(assertion)) + } return base64.RawURLEncoding.EncodeToString(appendBytesField(nil, 1, account)) } From 53064f05057399566d9cd64f9560d499fc0889c2 Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Tue, 20 Oct 2026 14:32:20 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20Chrome=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E3=80=81=E7=99=BB=E5=BD=95=E9=87=8D=E5=AE=9A?= =?UTF-8?q?=E5=90=91=E4=B8=8E=E5=A4=9A=E8=BD=AE=E7=BB=AD=E6=8E=A5=20-=20?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=20Web=20Data=20=E4=B8=8E=20WAL=20=E5=90=8E?= =?UTF-8?q?=E4=BB=A5=20url.URL=20=E6=9E=84=E9=80=A0=E7=9A=84=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=20URI=20=E6=89=93=E5=BC=80=EF=BC=8C=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=97=B6=E6=8A=A5=E9=94=99=20-=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=B2=A1=E6=9C=89=E8=AE=BE=E5=A4=87=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E7=9A=84=E8=B4=A6=E5=8F=B7=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=EF=BC=8C=E7=BB=AD=E7=AD=BE=E4=BB=A5=20MultiBearer=20=E5=A4=B4?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=8F=96=E5=BE=97=E6=98=8E=E6=96=87=20Cookie?= =?UTF-8?q?=20-=20=E5=AF=BC=E5=85=A5=E9=A1=B5=E5=8C=BA=E5=88=86=20Web=20Da?= =?UTF-8?q?ta=20=E8=AF=BB=E5=8F=96=E5=A4=B1=E8=B4=A5=E4=B8=8E=E9=9C=80?= =?UTF-8?q?=E5=9C=A8=20Chrome=20=E9=87=8D=E6=96=B0=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E4=B8=8D=E5=8F=AF=E5=AF=BC=E5=85=A5=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=20-=20=E9=A6=96=E9=A1=B5=20/u/N=20=E9=87=8D=E5=AE=9A?= =?UTF-8?q?=E5=90=91=E5=88=B0=E5=90=8C=E4=B8=80=E4=B8=BB=E6=9C=BA=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E9=A1=B5=E9=9D=A2=E6=97=B6=E6=8C=89=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E5=A4=84=E7=90=86=EF=BC=8C=E6=9C=89=E7=BB=AD?= =?UTF-8?q?=E7=AD=BE=E6=9D=90=E6=96=99=E6=97=B6=E7=BB=AD=E7=AD=BE=20-=20?= =?UTF-8?q?=E5=9B=9E=E4=BC=A0=E5=AE=8C=E6=95=B4=E5=8E=86=E5=8F=B2=E7=9A=84?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=8C=89=E5=8E=86=E5=8F=B2=E7=BB=AD?= =?UTF-8?q?=E6=8E=A5=E7=BD=91=E9=A1=B5=E4=BC=9A=E8=AF=9D=EF=BC=8C=E7=BB=AD?= =?UTF-8?q?=E6=8E=A5=E6=97=B6=E4=B8=8D=E9=87=8D=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E4=B8=8E=20Schema=20-=20=E4=B8=8A=E4=B8=80?= =?UTF-8?q?=E8=BD=AE=E6=80=9D=E8=80=83=E4=B8=8D=E8=AE=A1=E5=85=A5=E4=B8=8B?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E7=9A=84=E4=B8=8A=E4=B8=8B=E6=96=87=20token?= =?UTF-8?q?=20-=20=E6=92=A4=E5=9B=9E=E4=BC=9A=E8=AF=9D=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=A4=B4=E7=BB=AD=E6=8E=A5=E3=80=81=E5=8D=95=E6=AC=A1=E5=B0=9D?= =?UTF-8?q?=E8=AF=95=E3=80=81authUser=20=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E4=B8=8E=E8=BD=AE=E6=8D=A2=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E9=87=8D=E8=AF=BB=20-=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=B8=AD=E8=8B=B1=E6=96=87=20README=E3=80=81=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E4=B8=8E=E5=BC=80=E5=8F=91=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 8 +- README.md | 8 +- docs/development.md | 2 +- docs/protocol.md | 30 ++-- internal/adapter/claude_handler.go | 207 ++--------------------- internal/adapter/conversation.go | 110 +----------- internal/adapter/history.go | 202 ++++++++++++++++++++++ internal/adapter/openai_chat.go | 179 ++------------------ internal/adapter/responses_handler.go | 13 +- internal/adapter/responses_store.go | 6 +- internal/adapter/stream.go | 20 ++- internal/adapter/tool_bridge.go | 56 +++--- internal/auth/state.go | 37 ---- internal/balancer/balancer.go | 13 ++ internal/chromeauth/auth.go | 14 +- internal/chromeauth/platform_windows.go | 135 ++++++--------- internal/chromeauth/protocol.go | 91 +++++----- internal/gemini/client.go | 55 ++---- internal/gemini/rotate.go | 9 +- web/src/features/accounts/ImportPage.tsx | 4 +- web/src/features/accounts/messages.ts | 28 +-- web/src/features/accounts/types.ts | 3 +- 22 files changed, 474 insertions(+), 756 deletions(-) create mode 100644 internal/adapter/history.go diff --git a/README.en.md b/README.en.md index 5eeadbf..9cdc9bf 100644 --- a/README.en.md +++ b/README.en.md @@ -100,7 +100,7 @@ An account can also be selected directly. When a profile is signed in to several .\gemini-web2api.exe setup --email "name@example.com" ``` -You can also paste the one-line value of a request's `Cookie` header. This state works while those cookies remain valid and cannot perform device-bound renewal: +You can also paste the one-line value of a request's `Cookie` header. This state works while those cookies remain valid and cannot perform OAuth renewal: ```powershell .\gemini-web2api.exe setup --cookie "SAPISID=...; __Secure-1PSID=..." --id account-name @@ -124,13 +124,13 @@ When the service runs on a server without Chrome, generate a pairing token on th .\gemini-web2api.exe setup --email "name@example.com" --remote https://example.com ``` -Setup creates `auth//storage-state.json` and verifies the live model catalog. Chrome import also saves the device-bound material needed to renew cookies after Chrome exits. Daily startup is: +Setup creates `auth//storage-state.json` and verifies the live model catalog. Chrome import also saves the OAuth renewal material needed to renew cookies after Chrome exits. Daily startup is: ```powershell .\gemini-web2api.exe ``` -The default auth directory is `auth`, and the default listener is `127.0.0.1:8007`. Linux and macOS can use existing cookies copied in an `auth` directory; device-bound renewal requires setup on the original Windows device. +The default auth directory is `auth`, and the default listener is `127.0.0.1:8007`. Linux and macOS can use existing cookies copied in an `auth` directory; renewal requires setup on the original Windows device. While running, the service rotates each account's sign-in cookies every 10 minutes, as the Gemini website does. Cookies that change in a response are written back to `auth//storage-state.json` and reused after a restart. Chrome-imported accounts renew automatically when their session expires. @@ -211,7 +211,7 @@ GET /v1/models ``` - **Responses**: stored in `data/requests.db` by default and can be retrieved, have their input items listed, and be deleted; `store: false` skips storage. `background: true` returns `queued` immediately for later polling or cancellation -- **Continuation**: `previous_response_id` and conversation IDs keep working across service restarts within `SESSION_TTL` +- **Continuation**: `previous_response_id` and conversation IDs keep working across service restarts within `SESSION_TTL`; clients that resend the full history continue the same web conversation and send only the new messages when the history matches the previous answer - **Files**: uploaded files are stored in `data/files/` and referenced from generation requests by `file_id` - **Speech**: `/v1/audio/speech` uses the website's read-aloud feature and returns mp3, opus, wav, flac, or pcm; `voice` and `speed` have no effect - **Transcription and translation**: performed by the selected chat model diff --git a/README.md b/README.md index 4d410d7..3508fb6 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Windows 首次使用时,直接运行交互式配置: .\gemini-web2api.exe setup --email "name@example.com" ``` -也可以粘贴浏览器请求头中 `Cookie` 字段的一行值;这种状态在现有 Cookie 有效期内使用,不具备设备绑定续签能力: +也可以粘贴浏览器请求头中 `Cookie` 字段的一行值;这种状态在现有 Cookie 有效期内使用,不具备 OAuth 续签能力: ```powershell .\gemini-web2api.exe setup --cookie "SAPISID=...; __Secure-1PSID=..." --id account-name @@ -124,13 +124,13 @@ Cookie 来自登录了多个 Google 账号的浏览器时,用 `--authuser` 指 .\gemini-web2api.exe setup --email "name@example.com" --remote https://example.com ``` -`setup` 会生成 `auth/<账号>/storage-state.json` 并验证模型目录。Chrome 导入还会保存浏览器退出后续签所需的设备绑定材料。日常启动只需运行: +`setup` 会生成 `auth/<账号>/storage-state.json` 并验证模型目录。Chrome 导入还会保存浏览器退出后续签所需的 OAuth 续签材料。日常启动只需运行: ```powershell .\gemini-web2api.exe ``` -默认读取 `auth` 目录并监听 `127.0.0.1:8007`。Linux 和 macOS 可以在现有 Cookie 有效期内使用已经生成的 `auth` 目录;设备绑定续签仍需在原 Windows 设备执行。 +默认读取 `auth` 目录并监听 `127.0.0.1:8007`。Linux 和 macOS 可以在现有 Cookie 有效期内使用已经生成的 `auth` 目录;续签仍需在原 Windows 设备执行。 服务运行期间每 10 分钟为每个账号轮换一次登录 Cookie,与 Gemini 网页相同;响应中变化的 Cookie 写回 `auth/<账号>/storage-state.json`,重启后直接沿用。Chrome 导入的账号在登录态失效时自动续签。 @@ -211,7 +211,7 @@ GET /v1/models ``` - **Responses**:默认保存到 `data/requests.db`,可读取、列出输入与删除,`store: false` 不保存;`background: true` 立即返回 `queued`,之后轮询或取消 -- **续接**:`previous_response_id` 与会话 ID 在服务重启后的 `SESSION_TTL` 内仍可续接 +- **续接**:`previous_response_id` 与会话 ID 在服务重启后的 `SESSION_TTL` 内仍可续接;每轮发送完整历史的客户端在历史与上一轮回答一致时续接同一网页会话,只发送新消息 - **文件**:上传的文件保存在 `data/files/`,生成请求以 `file_id` 引用 - **朗读**:`/v1/audio/speech` 使用官网回答的朗读功能,输出 mp3、opus、wav、flac 或 pcm,`voice` 与 `speed` 不生效 - **转写与翻译**:由所选对话模型完成 diff --git a/docs/development.md b/docs/development.md index 18c165b..40b09bb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -100,7 +100,7 @@ Windows 便携包为桌面版可执行文件与 README、LICENSE、`.env.example | 中间件 | `internal/adapter/middleware.go` | 跨域、请求 ID、跟踪、账本、鉴权 | | 协议解析 | `internal/adapter` 各处理文件 | 请求转为统一生成参数 | | 提示词 | `internal/adapter/prompt.go`、`tool_bridge.go` | 消息序列与工具契约 | -| 会话 | `internal/adapter/conversation.go` | 会话键与网页会话三元组 | +| 会话 | `internal/adapter/conversation.go`、`history.go` | 会话键、网页会话三元组与按历史续接的会话键 | | 选号与重试 | `internal/adapter/stream.go` | 按模型选账号,可重试错误换号 | | 调度 | `internal/balancer/balancer.go` | 账号状态、冷却、会话粘连 | | 上游请求 | `internal/gemini/client.go`、`request.go`、`payload.go` | 请求槽、header、99 槽载荷 | diff --git a/docs/protocol.md b/docs/protocol.md index 27b4b03..7489b47 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -280,15 +280,18 @@ payload = [ ```text Local State ──► App-Bound 主密钥 -Web Data ──► v20 refresh token + wrapped binding key +Web Data ──► v20 refresh token + wrapped binding key(可为空) Preferences ─► Accept-Language │ - ▼ -OAuthMultilogin challenge - │ ES256 设备绑定断言 - ▼ -OAuthMultilogin directed response - │ 逐个 HPKE 解密 cookies[].value + ├─ 有 binding key + │ ▼ + │ OAuthMultilogin challenge + │ │ ES256 设备绑定断言 + │ ▼ + │ OAuthMultilogin directed response + │ │ 逐个 HPKE 解密 cookies[].value + │ ▼ + └─ 没有 binding key:MultiBearer 头的 OAuthMultilogin 返回明文 Cookie ▼ 完整 Google Cookie ``` @@ -299,7 +302,7 @@ OAuthMultilogin directed response | `geminiWeb2api.id` | 本地账号标识 | | `proxy` | 账号固定出口,`direct` 为直连 | | `language` | 请求的 `Accept-Language` | -| `oauth` | Gaia ID、refresh token、wrapped binding key | +| `oauth` | Gaia ID、refresh token、wrapped binding key(可为空) | ### Chrome 本地材料 @@ -307,10 +310,10 @@ OAuthMultilogin directed response | --- | --- | | `Local State` | `profile.info_cache`、`os_crypt.app_bound_encrypted_key` | | Profile `Preferences` | `account_info[].gaia`、`account_info[].email`:Profile 中登录的全部账号 | -| Profile `Web Data` | `SELECT service, encrypted_token, binding_key FROM token_service WHERE service = 'AccountId-GAIA_ID'` | +| Profile `Web Data` | `SELECT service, encrypted_token, binding_key FROM token_service WHERE service LIKE 'AccountId-%'` | | Profile `Preferences` | `intl.accept_languages`,回退 `intl.selected_languages`;按 Chrome 规则在地区语言后补基础语言、q 值从 0.9 起逐项减 0.1(最低 0.1),得到 `Accept-Language` | -`token_service` 每个登录账号一行,`service` 形态为 `AccountId-GAIA_ID`;`account_info` 中没有对应行的账号不可导入。refresh token 密文形态为: +Chrome 运行期间以排他锁打开 `Web Data`,程序把每个 Profile 的 `Web Data` 与 `Web Data-wal` 复制到临时目录后只读打开,读取后删除副本。`token_service` 每个登录账号一行,`service` 形态为 `AccountId-GAIA_ID`;`account_info` 中没有对应行的账号不可导入,refresh token 不以 `v20` 开头的账号需要在 Chrome 重新登录后导入。refresh token 密文形态为: ```text "v20" || NONCE_12_BYTES || AES_256_GCM_CIPHERTEXT_AND_TAG @@ -330,12 +333,13 @@ AES-GCM 使用 32 字节 App-Bound 主密钥和空 AAD。解密结果必须为 1 POST /oauth/multilogin?source=ChromiumAccountReconcilorDice&reuseCookies=0 Host: accounts.google.com Authorization: MultiOAuth BASE64URL_PROTOBUF +Authorization: MultiBearer REFRESH_TOKEN:GAIA_ID Content-Type: application/x-www-form-urlencoded User-Agent: Chrome/155 Content-Length: 1 ``` -`Authorization` 的无填充 Base64URL protobuf 结构为: +设备绑定的账号使用 `MultiOAuth` 头,`token_service.binding_key` 为空的账号使用 `MultiBearer` 头,一次请求返回明文 Cookie。`MultiOAuth` 的无填充 Base64URL protobuf 结构为: ```proto message MultiOAuth { @@ -663,7 +667,7 @@ sec-fetch-dest, sec-fetch-storage-access, referer, accept-encoding, accept-langu | `97` | 确认 Deep Research 计划时为 `[0,0]` | | `98` | `1` | -`GEMINI_SAVE_HISTORY=false` 使用 Temporary Chat。显式网页 session 保存三元组与续接令牌并固定到产生它的账号;无 session 请求相互独立。客户端从会话最新一轮回答继续时按官网追问编码;从较早的回答继续时按官网“编辑该回答之后的提问”编码,从该回答分叉。 +`GEMINI_SAVE_HISTORY=false` 使用 Temporary Chat。显式网页 session 保存三元组与续接令牌并固定到产生它的账号。不带 session 的 Chat、Anthropic Messages 与 Responses 请求以 system、提示词中发送的函数定义(`tool_choice` 为 `none` 时不发送)、响应 JSON Schema 与最后一个 assistant 轮及之前的消息计算会话键;该键由本服务上一轮回答登记且所属账号有空闲请求槽时续接该网页 session,只发送最后一个 assistant 轮之后的消息,续接失败时新建会话发送完整历史;其余无 session 请求相互独立。登记的键按回答正文、渲染后的正文与带来源链接的正文各算一次,工具调用参数按键排序,工具调用 ID、思考块与 `cache_control` 不参与。客户端从会话最新一轮回答继续时按官网追问编码;从较早的回答继续时按官网“编辑该回答之后的提问”编码,从该回答分叉。 ### 完整 `f.req` 编码 @@ -1191,7 +1195,7 @@ OpenAI Chat 与 Completions 的 `n` 大于 1 时与 `/v1/images` 一样逐个生 需要调用工具时,只输出一个 JSON 对象,格式必须为 {"tool_calls":[{"name":"工具名称","arguments":{}}]} ``` -以上内容整体写入 Gemini Web 的普通提示词槽。`none` 使用对话与已有工具结果直接作答,提示中省略函数定义和调用格式,显式响应 JSON Schema 继续生效;`required`/`any` 要求调用任一声明工具,显式函数选择要求调用指定工具。支持 `previous_response_id` 或显式 conversation ID 的客户端会继续同一网页 session;无会话请求默认创建彼此独立的 Temporary Chat。 +以上内容整体写入 Gemini Web 的普通提示词槽。`none` 使用对话与已有工具结果直接作答,提示中省略函数定义和调用格式,显式响应 JSON Schema 继续生效;`required`/`any` 要求调用任一声明工具,显式函数选择要求调用指定工具。支持 `previous_response_id` 或显式 conversation ID 的客户端会继续同一网页 session,回传完整历史的客户端按会话键续接;续接时工具协议沿用会话前文的函数定义与 JSON Schema,只重申调用规则与格式。未续接的无会话请求默认创建彼此独立的 Temporary Chat。 Google Search 与 Code Execution 由网页上游执行。Responses 的 `web_search_call` 根据 citations 输出 `completed` item;Code Execution 当前实测由 3.1 Pro 与 3.6 Flash 执行。 diff --git a/internal/adapter/claude_handler.go b/internal/adapter/claude_handler.go index e10d0d9..6a824d1 100644 --- a/internal/adapter/claude_handler.go +++ b/internal/adapter/claude_handler.go @@ -3,10 +3,8 @@ package adapter import ( "crypto/sha256" "encoding/base64" - "encoding/hex" "encoding/json" "fmt" - "io" "net/http" "strings" "time" @@ -56,17 +54,13 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { return } setUnsupportedParameters(c, unsupportedClaudeParameters(request)) - sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, extractHeaderSessionKey(c)) - if sessionKey == "" && len(request.Messages) > 1 { - if lastIdx := findLastClaudeAssistantIndex(request.Messages); lastIdx >= 0 { - fp := claudeMessagesFingerprint(request.System, request.Messages[:lastIdx+1]) - if conversations.has(fp) { - sessionKey = fp - } - } - } - if sessionKey != "" { - c.Set("session_key", sessionKey) + sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, c.GetHeader("X-Conversation-ID")) + var system string + var turns []historyTurn + if sessionKey == "" { + system, _ = claude.ParseSystemPrompt(request.System) + turns = claudeHistoryTurns(request.Messages) + sessionKey = continuedHistoryKey(system, bridge, turns) } responseID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) @@ -80,7 +74,7 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { result, accountID, err := runGeneration( c.Request.Context(), pool, sessionKey, request.PreviousResponseID == "", request.Model, responseID, generationMedia{}, thinkingLevel, bridge, func(client *gemini.Client, continuation bool) (string, []gemini.FileData, error) { - return buildClaudePrompt(c, client, request, continuation, bridge) + return buildClaudePrompt(c, client, request, continuation, bridge.declared(continuation && isHistoryKey(sessionKey))) }, func(event gemini.Event) error { if event.Kind == gemini.EventMetadata && event.Usage != nil { return processor.ProcessEvent(event) @@ -118,6 +112,7 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { _ = processor.EmitError(streamFailure(c, err)) return } + rememberHistory(pool, result, system, bridge, turns) content, calls, err := bridge.Parse(primary.Text) if err != nil { _ = processor.EmitError(streamFailure(c, err)) @@ -176,7 +171,6 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { if len(calls) > 0 { stopReason = "tool_use" } - bindNextClaudeFingerprint(pool, request.System, request.Messages, sessionKey, responseID, result, bridge) _ = processor.Finish(stopReason) return } @@ -184,7 +178,7 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { result, accountID, err := runGeneration( c.Request.Context(), pool, sessionKey, request.PreviousResponseID == "", request.Model, responseID, generationMedia{}, thinkingLevel, bridge, func(client *gemini.Client, continuation bool) (string, []gemini.FileData, error) { - return buildClaudePrompt(c, client, request, continuation, bridge) + return buildClaudePrompt(c, client, request, continuation, bridge.declared(continuation && isHistoryKey(sessionKey))) }, nil, nil, ) c.Set("account_id", accountID) @@ -202,6 +196,7 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { writeClaudeError(c, http.StatusBadGateway, "api_error", err.Error()) return } + rememberHistory(pool, result, system, bridge, turns) content, calls, err := bridge.Parse(primary.Text) if err != nil { writeClaudeError(c, http.StatusBadGateway, "api_error", err.Error()) @@ -255,7 +250,6 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { c.Header("X-Response-ID", responseID) c.Header("X-Conversation-ID", result.ConversationID) c.Header("X-Provider-Model", result.ProviderModel) - bindNextClaudeFingerprint(pool, request.System, request.Messages, sessionKey, responseID, result, bridge) c.JSON(http.StatusOK, response) } } @@ -323,18 +317,9 @@ func buildClaudePrompt(c *gin.Context, client *gemini.Client, request claude.Cla if err != nil { return "", nil, err } - sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, extractHeaderSessionKey(c)) - if sessionKey == "" && c != nil { - sessionKey = c.GetString("session_key") - } - var suffix string - if continuation && conversations.hasSameTools(sessionKey, bridge.fingerprint()) { - suffix = bridge.ContinuationSuffix() - } else { - suffix, err = bridge.PromptSuffix() - if err != nil { - return "", nil, err - } + suffix, err := bridge.PromptSuffix() + if err != nil { + return "", nil, err } return transcript + suffix, files, nil } @@ -417,167 +402,3 @@ func thinkingSignature(messageID string, blockIndex int) string { digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", messageID, blockIndex))) return base64.StdEncoding.EncodeToString(digest[:]) } - -func findLastClaudeAssistantIndex(messages []claude.Message) int { - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role == "assistant" { - return i - } - } - return -1 -} - -func writeCanonicalClaudeContent(h io.Writer, raw json.RawMessage) { - if len(raw) == 0 || string(raw) == "null" { - return - } - var str string - if err := json.Unmarshal(raw, &str); err == nil { - if trimmed := strings.TrimSpace(str); trimmed != "" { - _, _ = io.WriteString(h, "text:") - _, _ = io.WriteString(h, trimmed) - _, _ = h.Write([]byte{0}) - } - return - } - var blocks []claude.ContentBlock - if err := json.Unmarshal(raw, &blocks); err == nil { - var textBuf strings.Builder - flushText := func() { - if trimmed := strings.TrimSpace(textBuf.String()); trimmed != "" { - _, _ = io.WriteString(h, "text:") - _, _ = io.WriteString(h, trimmed) - _, _ = h.Write([]byte{0}) - } - textBuf.Reset() - } - for _, b := range blocks { - switch b.Type { - case "thinking", "redacted_thinking", "server_tool_use", "web_search_tool_result": - continue - case "text": - textBuf.WriteString(b.Text) - case "tool_use": - flushText() - _, _ = io.WriteString(h, "tool_use:") - _, _ = io.WriteString(h, b.Name) - _, _ = h.Write([]byte{0}) - if b.Input != nil { - if inputBytes, err := json.Marshal(b.Input); err == nil { - _, _ = h.Write(inputBytes) - } - } - _, _ = h.Write([]byte{0}) - case "tool_result": - flushText() - _, _ = io.WriteString(h, "tool_result:") - _, _ = io.WriteString(h, b.ToolUseID) - _, _ = h.Write([]byte{0}) - _, _ = h.Write(canonicalJSONBytes(b.Content)) - _, _ = h.Write([]byte{0}) - default: - flushText() - _, _ = io.WriteString(h, b.Type) - _, _ = h.Write([]byte{0}) - _, _ = h.Write(canonicalJSONBytes(b.Content)) - _, _ = h.Write([]byte{0}) - } - } - flushText() - return - } - _, _ = h.Write(canonicalJSONBytes(raw)) -} - -func claudeMessagesFingerprint(system json.RawMessage, messages []claude.Message) string { - if len(messages) == 0 { - return "" - } - h := sha256.New() - if len(system) > 0 { - if sysText, err := claude.ParseSystemPrompt(system); err == nil && strings.TrimSpace(sysText) != "" { - _, _ = io.WriteString(h, strings.TrimSpace(sysText)) - _, _ = h.Write([]byte{0xFF}) - } - } - for _, m := range messages { - _, _ = io.WriteString(h, m.Role) - _, _ = h.Write([]byte{0}) - writeCanonicalClaudeContent(h, m.Content) - _, _ = h.Write([]byte{0xFF}) - } - sum := h.Sum(nil) - return "fp_cld_" + hex.EncodeToString(sum[:16]) -} - -func bindNextClaudeFingerprint(pool *balancer.AccountPool, system json.RawMessage, messages []claude.Message, sessionKey string, responseID string, result generationResult, bridge ToolBridge) { - if result.ConversationID == "" { - return - } - toolsFp := bridge.fingerprint() - primary := result.Accumulator.Primary() - if primary.Text == "" && len(primary.Codes) == 0 { - conversations.setTools(toolsFp, result.ConversationID, responseID, sessionKey) - return - } - content, calls, err := bridge.Parse(primary.Text) - if err != nil { - content = primary.Text - calls = nil - } - rendered := renderCandidateMarkdown(content, primary, 0) - renderedWithCitations := renderCitationMarkdown(rendered, primary.Citations) - - buildBlocks := func(textVal string) (json.RawMessage, bool) { - blocks := make([]claude.ContentBlock, 0, 1+len(calls)) - if strings.TrimSpace(textVal) != "" { - blocks = append(blocks, claude.ContentBlock{Type: "text", Text: textVal}) - } - for _, call := range calls { - var input map[string]interface{} - _ = json.Unmarshal(call.Arguments, &input) - blocks = append(blocks, claude.ContentBlock{Type: "tool_use", Name: call.Name, Input: &input}) - } - if len(blocks) == 0 { - return nil, false - } - raw, err := json.Marshal(blocks) - return raw, err == nil - } - - var fps []string - for _, candidateText := range []string{renderedWithCitations, rendered, content} { - assistantContent, ok := buildBlocks(candidateText) - if !ok { - continue - } - nextMessages := make([]claude.Message, len(messages)+1) - copy(nextMessages, messages) - nextMessages[len(messages)] = claude.Message{Role: "assistant", Content: assistantContent} - fp := claudeMessagesFingerprint(system, nextMessages) - if fp != "" { - seen := false - for _, existing := range fps { - if existing == fp { - seen = true - break - } - } - if !seen { - fps = append(fps, fp) - } - } - } - if len(fps) == 0 { - conversations.setTools(toolsFp, result.ConversationID, responseID, sessionKey) - return - } - if state, accountID, ctxTokens, ok := conversations.get(result.ConversationID); ok { - conversations.put(state, accountID, ctxTokens, fps...) - for _, fp := range fps { - pool.BindSession(fp, accountID) - } - } - allKeys := append([]string{result.ConversationID, responseID, sessionKey}, fps...) - conversations.setTools(toolsFp, allKeys...) -} diff --git a/internal/adapter/conversation.go b/internal/adapter/conversation.go index 10029ec..e53528d 100644 --- a/internal/adapter/conversation.go +++ b/internal/adapter/conversation.go @@ -2,10 +2,6 @@ package adapter import ( "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "strings" "sync" "time" @@ -30,7 +26,6 @@ type conversationEntry struct { snapshot gemini.ConversationSnapshot accountID string contextTokens int - toolsHash string expiresAt time.Time } @@ -84,13 +79,8 @@ func (r *conversationRegistry) put(state *gemini.ConversationState, accountID st r.mu.Lock() now := time.Now() r.cleanupLocked(now) - var toolsHash string - if prev, ok := r.states[state.Snapshot().CID]; ok { - toolsHash = prev.toolsHash - } entry := conversationEntry{ - snapshot: state.Snapshot(), accountID: accountID, contextTokens: contextTokens, - toolsHash: toolsHash, expiresAt: now.Add(r.ttl), + snapshot: state.Snapshot(), accountID: accountID, contextTokens: contextTokens, expiresAt: now.Add(r.ttl), } for _, key := range keys { if key != "" { @@ -224,16 +214,6 @@ func (r *conversationRegistry) cleanupLocked(now time.Time) { } } -func (r *conversationRegistry) has(key string) bool { - if key == "" { - return false - } - r.mu.Lock() - defer r.mu.Unlock() - entry, ok := r.states[key] - return ok && time.Now().Before(entry.expiresAt) -} - func conversationKey(previousResponseID string, conversationID string, headerValue string) string { if previousResponseID != "" { return previousResponseID @@ -243,91 +223,3 @@ func conversationKey(previousResponseID string, conversationID string, headerVal } return headerValue } - -func extractHeaderSessionKey(c interface{ GetHeader(string) string }) string { - if c == nil { - return "" - } - for _, header := range []string{ - "X-Conversation-ID", - "Conversation-ID", - "X-Session-ID", - "Session-ID", - "X-Chat-ID", - "Chat-ID", - "anthropic-session-id", - } { - if val := strings.TrimSpace(c.GetHeader(header)); val != "" { - return val - } - } - return "" -} - -func (r *conversationRegistry) hasSameTools(key string, toolsHash string) bool { - if toolsHash == "" { - return true - } - if key == "" { - return false - } - r.mu.Lock() - defer r.mu.Unlock() - entry, ok := r.states[key] - if !ok || time.Now().After(entry.expiresAt) { - return false - } - if entry.toolsHash == "" { - return true - } - return entry.toolsHash == toolsHash -} - -func (r *conversationRegistry) setTools(toolsHash string, keys ...string) { - if toolsHash == "" { - toolsHash = "none" - } - r.mu.Lock() - defer r.mu.Unlock() - for _, key := range keys { - if key == "" { - continue - } - if entry, ok := r.states[key]; ok { - entry.toolsHash = toolsHash - r.states[key] = entry - } - } -} - -func (b ToolBridge) fingerprint() string { - if len(b.Definitions) == 0 && len(b.JSONSchema) == 0 { - return "" - } - h := sha256.New() - if len(b.Definitions) > 0 { - if data, err := json.Marshal(b.Definitions); err == nil { - h.Write(data) - } - } - h.Write([]byte{0xFF}) - if len(b.JSONSchema) > 0 { - h.Write(canonicalJSONBytes(b.JSONSchema)) - } - sum := h.Sum(nil) - return hex.EncodeToString(sum[:16]) -} - -func canonicalJSONBytes(raw []byte) []byte { - trimmed := strings.TrimSpace(string(raw)) - if trimmed == "" || trimmed == "null" { - return nil - } - var v interface{} - if err := json.Unmarshal([]byte(trimmed), &v); err == nil { - if norm, err := json.Marshal(v); err == nil { - return norm - } - } - return []byte(trimmed) -} diff --git a/internal/adapter/history.go b/internal/adapter/history.go new file mode 100644 index 0000000..705bb63 --- /dev/null +++ b/internal/adapter/history.go @@ -0,0 +1,202 @@ +package adapter + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/Mag1cFall/Gemini-Web2API/internal/balancer" + "github.com/Mag1cFall/Gemini-Web2API/internal/claude" +) + +// historyKeyPrefix 是按对话历史续接网页会话的会话键前缀 +const historyKeyPrefix = "hist_" + +// historyTurn 是参与历史会话键的一条消息:角色、去掉首尾空白的正文与工具调用,思考、工具调用 ID 与缓存标记不参与 +type historyTurn struct { + Role string `json:"r"` + Text string `json:"t,omitempty"` + Calls []historyCall `json:"c,omitempty"` +} + +// historyCall 是历史中的一次工具调用,参数为规范化后的 JSON +type historyCall struct { + Name string `json:"n"` + Arguments json.RawMessage `json:"a,omitempty"` +} + +// isHistoryKey 判断会话键是否由对话历史得出 +func isHistoryKey(key string) bool { + return strings.HasPrefix(key, historyKeyPrefix) +} + +// historyKey 返回系统指令、提示词发送的工具定义与消息序列对应的会话键,tool_choice 为 none 时提示词不含工具定义 +func historyKey(system string, bridge ToolBridge, turns []historyTurn) string { + tools := bridge.Definitions + if bridge.disallowsHostedTools() { + tools = nil + } + data, _ := json.Marshal(struct { + System string `json:"s"` + Tools []ToolDefinition `json:"d,omitempty"` + Schema json.RawMessage `json:"j,omitempty"` + Turns []historyTurn `json:"m"` + }{strings.TrimSpace(system), tools, canonicalJSON(bridge.JSONSchema), turns}) + sum := sha256.Sum256(data) + return historyKeyPrefix + hex.EncodeToString(sum[:16]) +} + +// continuedHistoryKey 返回请求历史到最后一个 assistant 轮为止的会话键,该键已登记到网页会话时可按增量续接,否则返回空 +func continuedHistoryKey(system string, bridge ToolBridge, turns []historyTurn) string { + for index := len(turns) - 1; index > 0; index-- { + if turns[index].Role != "assistant" { + continue + } + key := historyKey(system, bridge, turns[:index+1]) + if _, _, _, ok := conversations.get(key); ok { + return key + } + return "" + } + return "" +} + +// rememberHistory 把请求历史加上本轮回答后的会话键登记到本轮网页会话,供不带会话标识的客户端下一轮续接;回答按客户端可能回传的几种渲染各登记一次,turns 为空表示请求带有会话标识 +func rememberHistory(pool *balancer.AccountPool, result generationResult, system string, bridge ToolBridge, turns []historyTurn) { + if len(turns) == 0 { + return + } + state, accountID, contextTokens, ok := conversations.get(result.ResponseID) + if !ok { + return + } + primary := result.Accumulator.Primary() + content, calls, err := bridge.Parse(primary.Text) + if err != nil { + return + } + reply := historyTurn{Role: "assistant"} + for _, call := range calls { + reply.Calls = append(reply.Calls, historyCall{Name: call.Name, Arguments: canonicalJSON(call.Arguments)}) + } + rendered := renderCandidateMarkdown(content, primary, 0) + var keys []string + for _, text := range []string{content, rendered, renderCitationMarkdown(rendered, primary.Citations)} { + reply.Text = strings.TrimSpace(text) + key := historyKey(system, bridge, append(turns[:len(turns):len(turns)], reply)) + if len(keys) == 0 || keys[len(keys)-1] != key { + keys = append(keys, key) + } + } + conversations.put(state, accountID, contextTokens, keys...) + for _, key := range keys { + pool.BindSession(key, accountID) + } +} + +// chatHistoryTurns 把 Chat 与 Responses 转换后的消息转为历史会话键的消息序列 +func chatHistoryTurns(messages []ChatMessage) []historyTurn { + turns := make([]historyTurn, 0, len(messages)) + for _, message := range messages { + turn := historyTurn{Role: transcriptRole(message.Role), Text: historyText(message.Content)} + for _, call := range message.ToolCalls { + turn.Calls = append(turn.Calls, historyCall{Name: call.Function.Name, Arguments: canonicalJSON(json.RawMessage(call.Function.Arguments))}) + } + turns = appendHistoryTurn(turns, turn) + } + return turns +} + +// responsesHistoryTurns 把 Responses 的 input 转为历史会话键的消息序列,无法转换时返回空 +func responsesHistoryTurns(raw json.RawMessage) []historyTurn { + input, err := expandCompaction(raw) + if err != nil { + return nil + } + messages, err := responsesMessages(input) + if err != nil { + return nil + } + return chatHistoryTurns(messages) +} + +// claudeHistoryTurns 把 Anthropic 消息转为历史会话键的消息序列 +func claudeHistoryTurns(messages []claude.Message) []historyTurn { + turns := make([]historyTurn, 0, len(messages)) + for _, message := range messages { + turn := historyTurn{Role: transcriptRole(message.Role)} + turn.Text, turn.Calls = historyContent(message.Content) + turns = appendHistoryTurn(turns, turn) + } + return turns +} + +// appendHistoryTurn 追加一条消息,连续的 assistant 消息合并为一轮,与 Responses 把思考、正文与每个函数调用拆成多项的回传一致 +func appendHistoryTurn(turns []historyTurn, turn historyTurn) []historyTurn { + if len(turns) == 0 || turn.Role != "assistant" || turns[len(turns)-1].Role != "assistant" { + return append(turns, turn) + } + last := &turns[len(turns)-1] + if last.Text != "" && turn.Text != "" { + last.Text += "\n" + } + last.Text += turn.Text + last.Calls = append(last.Calls, turn.Calls...) + return turns +} + +// historyText 返回消息内容的正文,内容数组中的工具调用一并写入正文 +func historyText(raw json.RawMessage) string { + text, calls := historyContent(raw) + for _, call := range calls { + text += call.Name + string(call.Arguments) + } + return text +} + +// historyContent 返回消息内容的正文与工具调用:字符串取原文;内容数组拼接文本项与工具结果,tool_use 项取名称与规范化参数,思考与服务端工具项跳过,其余项取去掉缓存标记的规范化 JSON +func historyContent(raw json.RawMessage) (string, []historyCall) { + var text string + if json.Unmarshal(raw, &text) == nil { + return strings.TrimSpace(text), nil + } + var parts []map[string]json.RawMessage + if json.Unmarshal(raw, &parts) != nil { + return strings.TrimSpace(string(canonicalJSON(raw))), nil + } + var builder strings.Builder + var calls []historyCall + for _, part := range parts { + var partType string + _ = json.Unmarshal(part["type"], &partType) + switch partType { + case "thinking", "redacted_thinking", "server_tool_use", "web_search_tool_result": + case "text", "input_text", "output_text": + var value string + _ = json.Unmarshal(part["text"], &value) + builder.WriteString(value) + case "tool_use": + var name string + _ = json.Unmarshal(part["name"], &name) + calls = append(calls, historyCall{Name: name, Arguments: canonicalJSON(part["input"])}) + case "tool_result": + builder.WriteString(historyText(part["content"])) + default: + delete(part, "cache_control") + encoded, _ := json.Marshal(part) + builder.Write(encoded) + } + } + return strings.TrimSpace(builder.String()), calls +} + +// canonicalJSON 返回键按字典序排列的 JSON,无效或空值返回 nil +func canonicalJSON(raw json.RawMessage) json.RawMessage { + var value interface{} + if len(raw) == 0 || json.Unmarshal(raw, &value) != nil || value == nil { + return nil + } + encoded, _ := json.Marshal(value) + return encoded +} diff --git a/internal/adapter/openai_chat.go b/internal/adapter/openai_chat.go index 16309bc..1d7eb0c 100644 --- a/internal/adapter/openai_chat.go +++ b/internal/adapter/openai_chat.go @@ -1,13 +1,10 @@ package adapter import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" "net/http" - "strings" "time" "github.com/Mag1cFall/Gemini-Web2API/internal/balancer" @@ -129,17 +126,11 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { return } - sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, extractHeaderSessionKey(c)) - if sessionKey == "" && len(request.Messages) > 1 { - if lastIdx := findLastAssistantIndex(request.Messages); lastIdx >= 0 { - fp := openAIMessagesFingerprint(request.Messages[:lastIdx+1]) - if conversations.has(fp) { - sessionKey = fp - } - } - } - if sessionKey != "" { - c.Set("session_key", sessionKey) + sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, c.GetHeader("X-Conversation-ID")) + var turns []historyTurn + if sessionKey == "" { + turns = chatHistoryTurns(request.Messages) + sessionKey = continuedHistoryKey("", bridge, turns) } allowNewSession := request.PreviousResponseID == "" responseID := fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano()) @@ -153,7 +144,7 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { result, accountID, err := runGeneration( c.Request.Context(), pool, key, allowNew, request.Model, responseID, generationMedia{}, thinkingLevel, bridge, func(client *gemini.Client, continuation bool) (string, []gemini.FileData, error) { - return buildOpenAIChatPrompt(c, client, request, continuation, bridge) + return buildOpenAIChatPrompt(c, client, request, continuation, bridge.declared(continuation && isHistoryKey(key))) }, onEvent, hasVisibleOutput, ) @@ -214,13 +205,13 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { _ = writeOpenAIStreamError(c.Writer, http.StatusBadGateway, "output_validation_error", streamFailure(c, err)) return } + if choice == 0 { + rememberHistory(pool, result, "", bridge, turns) + } if err := writeOpenAIFinish(c.Writer, responseID, created, streamModel, choice, result, bridge); err != nil { return } usage = addUsage(usage, result.Accumulator.Usage) - if choice == 0 { - bindNextOpenAIFingerprint(pool, request.Messages, sessionKey, responseID, result, bridge) - } } recordChoiceUsage(c, n, usage) if request.StreamOptions != nil && request.StreamOptions.IncludeUsage && usage != nil { @@ -248,10 +239,10 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { writeOpenAIError(c, http.StatusBadGateway, "output_validation_error", err.Error()) return } - usage = addUsage(usage, result.Accumulator.Usage) if choice == 0 { - bindNextOpenAIFingerprint(pool, request.Messages, sessionKey, responseID, result, bridge) + rememberHistory(pool, result, "", bridge, turns) } + usage = addUsage(usage, result.Accumulator.Usage) if response == nil { response = built continue @@ -340,18 +331,9 @@ func buildOpenAIChatPrompt(c *gin.Context, client *gemini.Client, request ChatRe if err != nil { return "", nil, err } - sessionKey := conversationKey(request.PreviousResponseID, request.ConversationID, extractHeaderSessionKey(c)) - if sessionKey == "" && c != nil { - sessionKey = c.GetString("session_key") - } - var suffix string - if continuation && conversations.hasSameTools(sessionKey, bridge.fingerprint()) { - suffix = bridge.ContinuationSuffix() - } else { - suffix, err = bridge.PromptSuffix() - if err != nil { - return "", nil, err - } + suffix, err := bridge.PromptSuffix() + if err != nil { + return "", nil, err } return transcript + suffix, files, nil } @@ -657,136 +639,3 @@ func openAIUsage(usage *gemini.Usage) gin.H { "prompt_tokens_details": gin.H{"cached_tokens": 0}, "completion_tokens_details": gin.H{"reasoning_tokens": usage.ThoughtTokens}, } } - -func findLastAssistantIndex(messages []ChatMessage) int { - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role == "assistant" || messages[i].Role == "model" { - return i - } - } - return -1 -} - -func normalizeOpenAIMessageContent(raw json.RawMessage) string { - if len(raw) == 0 || string(raw) == "null" { - return "" - } - var text string - if err := json.Unmarshal(raw, &text); err == nil { - return strings.TrimSpace(text) - } - var parts []map[string]json.RawMessage - if err := json.Unmarshal(raw, &parts); err == nil { - var b strings.Builder - for _, part := range parts { - var partType string - _ = json.Unmarshal(part["type"], &partType) - switch partType { - case "text", "input_text", "output_text", "": - var val string - if json.Unmarshal(part["text"], &val) == nil { - b.WriteString(val) - } - } - } - return strings.TrimSpace(b.String()) - } - return strings.TrimSpace(string(raw)) -} - -func openAIMessagesFingerprint(messages []ChatMessage) string { - if len(messages) == 0 { - return "" - } - h := sha256.New() - for _, m := range messages { - role := m.Role - if role == "model" { - role = "assistant" - } - h.Write([]byte(role)) - h.Write([]byte{0}) - h.Write([]byte(normalizeOpenAIMessageContent(m.Content))) - h.Write([]byte{0}) - h.Write([]byte(m.ToolCallID)) - h.Write([]byte{0}) - for _, tc := range m.ToolCalls { - h.Write([]byte(tc.Function.Name)) - h.Write([]byte{0}) - h.Write(canonicalJSONBytes([]byte(tc.Function.Arguments))) - h.Write([]byte{0}) - } - h.Write([]byte{0xFF}) - } - sum := h.Sum(nil) - return "fp_oai_" + hex.EncodeToString(sum[:16]) -} - -func bindNextOpenAIFingerprint(pool *balancer.AccountPool, messages []ChatMessage, sessionKey string, responseID string, result generationResult, bridge ToolBridge) { - if result.ConversationID == "" { - return - } - toolsFp := bridge.fingerprint() - primary := result.Accumulator.Primary() - if primary.Text == "" && len(primary.Codes) == 0 { - conversations.setTools(toolsFp, result.ConversationID, responseID, sessionKey) - return - } - content, calls, err := bridge.Parse(primary.Text) - if err != nil { - content = primary.Text - calls = nil - } - - var candidates []ChatMessage - if len(calls) > 0 { - toolCalls := make([]OpenAIToolCall, 0, len(calls)) - for _, call := range calls { - var tc OpenAIToolCall - tc.Type = "function" - tc.Function.Name = call.Name - tc.Function.Arguments = string(call.Arguments) - toolCalls = append(toolCalls, tc) - } - candidates = append(candidates, ChatMessage{Role: "assistant", ToolCalls: toolCalls}) - } else { - rendered := renderCandidateMarkdown(content, primary, 0) - for _, textVal := range []string{rendered, content} { - if raw, err := json.Marshal(textVal); err == nil { - candidates = append(candidates, ChatMessage{Role: "assistant", Content: raw}) - } - } - } - - var fps []string - for _, assistantMsg := range candidates { - nextMessages := make([]ChatMessage, len(messages)+1) - copy(nextMessages, messages) - nextMessages[len(messages)] = assistantMsg - fp := openAIMessagesFingerprint(nextMessages) - if fp != "" { - seen := false - for _, existing := range fps { - if existing == fp { - seen = true - break - } - } - if !seen { - fps = append(fps, fp) - } - } - } - if len(fps) == 0 { - conversations.setTools(toolsFp, result.ConversationID, responseID, sessionKey) - return - } - if state, accountID, ctxTokens, ok := conversations.get(result.ConversationID); ok { - conversations.put(state, accountID, ctxTokens, fps...) - for _, fp := range fps { - pool.BindSession(fp, accountID) - } - } - allKeys := append([]string{result.ConversationID, responseID, sessionKey}, fps...) - conversations.setTools(toolsFp, allKeys...) -} diff --git a/internal/adapter/responses_handler.go b/internal/adapter/responses_handler.go index cc96c14..b417641 100644 --- a/internal/adapter/responses_handler.go +++ b/internal/adapter/responses_handler.go @@ -107,13 +107,18 @@ func ResponsesHandler(pool *balancer.AccountPool, store *requestdb.Store) gin.Ha return } sessionKey := conversationKey(request.PreviousResponseID, conversationID, c.GetHeader("X-Conversation-ID")) + var turns []historyTurn + if sessionKey == "" { + turns = responsesHistoryTurns(request.Input) + sessionKey = continuedHistoryKey(request.Instructions, bridge, turns) + } responseID := fmt.Sprintf("resp_%d", time.Now().UnixNano()) created := time.Now().Unix() media := generationMedia{requireImage: bridge.RequireImageGeneration, aspectRatio: bridge.AspectRatio} if request.Background && !request.Stream { startBackgroundResponse(c, pool, store, request, responsesRun{ id: responseID, created: created, sessionKey: sessionKey, media: media, - thinkingLevel: thinkingLevel, bridge: bridge, includeThoughts: includeThoughts, + thinkingLevel: thinkingLevel, bridge: bridge, includeThoughts: includeThoughts, turns: turns, }) return } @@ -150,7 +155,7 @@ func ResponsesHandler(pool *balancer.AccountPool, store *requestdb.Store) gin.Ha result, accountID, err := runGeneration( ctx, pool, sessionKey, request.PreviousResponseID == "", request.Model, responseID, media, thinkingLevel, bridge, func(client *gemini.Client, continuation bool) (string, []gemini.FileData, error) { - return buildResponsesPrompt(c, client, request, continuation, bridge) + return buildResponsesPrompt(c, client, request, continuation, bridge.declared(continuation && isHistoryKey(sessionKey))) }, func(event gemini.Event) error { if event.Kind == gemini.EventImageProgress { projection.bufferText = true @@ -187,6 +192,7 @@ func ResponsesHandler(pool *balancer.AccountPool, store *requestdb.Store) gin.Ha saveFailedResponse(ctx, store, request, responseID, created, publicError(c, err)) return } + rememberHistory(pool, result, request.Instructions, bridge, turns) applyRequestFields(response, request) if !finishResponseJob(responseID) { saveResponse(ctx, store, request, responseID, created, response) @@ -200,7 +206,7 @@ func ResponsesHandler(pool *balancer.AccountPool, store *requestdb.Store) gin.Ha result, accountID, err := runGeneration( ctx, pool, sessionKey, request.PreviousResponseID == "", request.Model, responseID, media, thinkingLevel, bridge, func(client *gemini.Client, continuation bool) (string, []gemini.FileData, error) { - return buildResponsesPrompt(c, client, request, continuation, bridge) + return buildResponsesPrompt(c, client, request, continuation, bridge.declared(continuation && isHistoryKey(sessionKey))) }, nil, nil, ) c.Set("account_id", accountID) @@ -218,6 +224,7 @@ func ResponsesHandler(pool *balancer.AccountPool, store *requestdb.Store) gin.Ha writeOpenAIError(c, http.StatusBadGateway, "output_validation_error", err.Error()) return } + rememberHistory(pool, result, request.Instructions, bridge, turns) applyRequestFields(response, request) saveResponse(ctx, store, request, responseID, created, response) c.Header("X-Response-ID", responseID) diff --git a/internal/adapter/responses_store.go b/internal/adapter/responses_store.go index bb513b6..8807965 100644 --- a/internal/adapter/responses_store.go +++ b/internal/adapter/responses_store.go @@ -163,7 +163,7 @@ func startBackgroundResponse(c *gin.Context, pool *balancer.AccountPool, store * saveResponse(ctx, store, request, run.id, run.created, running) result, _, err := runGeneration(ctx, pool, run.sessionKey, request.PreviousResponseID == "", request.Model, run.id, run.media, run.thinkingLevel, run.bridge, func(client *gemini.Client, continuation bool) (string, []gemini.FileData, error) { - return buildResponsesPrompt(background, client, request, continuation, run.bridge) + return buildResponsesPrompt(background, client, request, continuation, run.bridge.declared(continuation && isHistoryKey(run.sessionKey))) }, nil, nil) if err == nil { err = inlineResultMedia(ctx, &result) @@ -172,6 +172,9 @@ func startBackgroundResponse(c *gin.Context, pool *balancer.AccountPool, store * if err == nil { response, err = buildResponsesObject(run.id, run.created, request.PreviousResponseID, result, run.bridge, run.includeThoughts) } + if err == nil { + rememberHistory(pool, result, request.Instructions, run.bridge, run.turns) + } status := http.StatusOK cancelled := finishResponseJob(run.id) if err != nil { @@ -271,6 +274,7 @@ type responsesRun struct { thinkingLevel gemini.ThinkingLevel bridge ToolBridge includeThoughts bool + turns []historyTurn } // storedResponseOrNotFound 读取保存的响应,不存在时写入 404 diff --git a/internal/adapter/stream.go b/internal/adapter/stream.go index 311e5fc..91add64 100644 --- a/internal/adapter/stream.go +++ b/internal/adapter/stream.go @@ -199,6 +199,9 @@ func runGenerationAttempts( if !pool.HasModel(requestedModel) { return generationResult{}, "", &gemini.ProtocolError{HTTPStatus: 404, Message: fmt.Sprintf("模型 %q 不可用", modelName)} } + if isHistoryKey(sessionKey) && !pool.SessionIdle(sessionKey, requestedModel) { + sessionKey = "" + } releaseSession := func() {} if allowNewSession && sessionKey != "" { var err error @@ -215,6 +218,9 @@ func runGenerationAttempts( } requestAttempts := 1 + if sessionKey == "" || newSession { + requestAttempts = 2 + } excludedAccountIDs := make(map[string]struct{}) availableAccounts := pool.Size() if availableAccounts == 0 { @@ -249,6 +255,15 @@ func runGenerationAttempts( if ctx.Err() != nil { return generationResult{}, accountID, err } + if isHistoryKey(sessionKey) && !newSession && (isAccountContextError(err) || retryableAccountError(err) && (hasVisibleOutput == nil || !hasVisibleOutput())) { + if retryableAccountError(err) && !isOutputMismatchError(err) { + pool.ReportFailure(accountID, requestedModel, err) + } + trace.AttemptFailed(accountID, upstreamStatus(err), err, true) + excludedAccountIDs[accountID] = struct{}{} + sessionKey, newSession, requestAttempts = "", true, 2 + continue + } if isAccountContextError(err) { if sessionKey != "" && !newSession { return generationResult{}, accountID, err @@ -421,13 +436,14 @@ func runGenerationWithClient( snapshot = conversation.Snapshot() } keys := []string{responseID, snapshot.CID} - if allowNewSession && sessionKey != "" { + remember := allowNewSession && sessionKey != "" && !isHistoryKey(sessionKey) + if remember { keys = append(keys, sessionKey) } conversations.put(conversation, accountID, nextContextTokens, keys...) pool.BindSession(responseID, accountID) pool.BindSession(snapshot.CID, accountID) - if allowNewSession && sessionKey != "" { + if remember { pool.BindSession(sessionKey, accountID) } accumulator.Session = snapshot diff --git a/internal/adapter/tool_bridge.go b/internal/adapter/tool_bridge.go index bdba8eb..73e9238 100644 --- a/internal/adapter/tool_bridge.go +++ b/internal/adapter/tool_bridge.go @@ -38,6 +38,14 @@ type ToolBridge struct { RequireImageGeneration bool // AspectRatio 为生图请求的宽高比 AspectRatio string + // Declared 表示工具定义与 JSON Schema 已在本网页会话的前几轮发送,提示词只重申规则 + Declared bool +} + +// declared 返回标记工具定义是否已在会话中发送的副本 +func (b ToolBridge) declared(value bool) ToolBridge { + b.Declared = value + return b } // PromptSuffix 返回追加到用户提示词的协议约束 @@ -45,9 +53,13 @@ func (b ToolBridge) PromptSuffix() (string, error) { var sections []string if len(b.Definitions) > 0 && !strings.EqualFold(strings.TrimSpace(b.Choice), "none") { - definitions, err := json.Marshal(b.Definitions) - if err != nil { - return "", fmt.Errorf("编码工具定义失败: %w", err) + available := "沿用本会话前文的工具定义" + if !b.Declared { + definitions, err := json.Marshal(b.Definitions) + if err != nil { + return "", fmt.Errorf("编码工具定义失败: %w", err) + } + available = string(definitions) } choice := strings.TrimSpace(b.Choice) @@ -66,14 +78,18 @@ func (b ToolBridge) PromptSuffix() (string, error) { sections = append(sections, fmt.Sprintf(`工具协议: 可用工具:%s 工具选择:%s -%s,格式必须为 {"tool_calls":[{"name":"工具名称","arguments":{}}]},不要输出 Markdown 代码块或额外文字`, definitions, choice, rule)) +%s,格式必须为 {"tool_calls":[{"name":"工具名称","arguments":{}}]},不要输出 Markdown 代码块或额外文字`, available, choice, rule)) } if len(b.JSONSchema) > 0 { if _, err := compileJSONSchema(b.JSONSchema); err != nil { return "", fmt.Errorf("响应 JSON Schema 无效: %w", err) } - sections = append(sections, fmt.Sprintf("结构化输出协议:只输出符合以下 JSON Schema 的 JSON 值,不要输出 Markdown 代码块或额外文字\n%s", b.JSONSchema)) + schema := fmt.Sprintf("以下 JSON Schema 的 JSON 值,不要输出 Markdown 代码块或额外文字\n%s", b.JSONSchema) + if b.Declared { + schema = "本会话前文 JSON Schema 的 JSON 值,不要输出 Markdown 代码块或额外文字" + } + sections = append(sections, "结构化输出协议:只输出符合"+schema) } if !strings.EqualFold(strings.TrimSpace(b.Choice), "none") { @@ -105,36 +121,6 @@ func (b ToolBridge) PromptSuffix() (string, error) { return "\n\n" + strings.Join(sections, "\n\n"), nil } -// ContinuationSuffix 返回多轮续接时追加到增量提示词的精简协议约束,省略重复的工具 Schema 定义 -func (b ToolBridge) ContinuationSuffix() string { - if strings.EqualFold(strings.TrimSpace(b.Choice), "none") { - return "\n\n本次请求根据对话和已有工具结果直接回答;禁止调用函数工具、代码执行、Google 搜索和图片生成" - } - var sections []string - if len(b.Definitions) > 0 { - choice := strings.TrimSpace(b.Choice) - if choice == "" { - choice = "auto" - } - rule := "需要调用工具时,只输出一个 JSON 对象;无需调用工具时,直接输出正常回答" - switch strings.ToLower(choice) { - case "required", "any": - rule = "必须调用一个可用工具,只输出一个 JSON 对象" - case "auto": - default: - rule = fmt.Sprintf("必须调用工具 %s,只输出一个 JSON 对象", choice) - } - sections = append(sections, fmt.Sprintf("工具协议(沿用上一轮工具定义):\n%s,格式必须为 {\"tool_calls\":[{\"name\":\"工具名称\",\"arguments\":{}}]},不要输出 Markdown 代码块或额外文字", rule)) - } - if len(b.JSONSchema) > 0 { - sections = append(sections, "结构化输出协议:只输出符合 JSON Schema 的 JSON 值,不要输出 Markdown 代码块或额外文字") - } - if len(sections) == 0 { - return "" - } - return "\n\n" + strings.Join(sections, "\n\n") -} - // Parse 解析严格工具调用并验证结构化输出 func (b ToolBridge) Parse(text string) (string, []ToolCall, error) { trimmed := stripJSONFence(strings.TrimSpace(text)) diff --git a/internal/auth/state.go b/internal/auth/state.go index 23a6a3a..286177c 100644 --- a/internal/auth/state.go +++ b/internal/auth/state.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" "sync" @@ -48,42 +47,6 @@ type Metadata struct { Disabled bool `json:"disabled,omitempty"` } -// UnmarshalJSON 兼容数字与字符串形式的 authUser -func (m *Metadata) UnmarshalJSON(data []byte) error { - type Alias Metadata - var aux struct { - Alias - RawAuthUser json.RawMessage `json:"authUser,omitempty"` - } - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - *m = Metadata(aux.Alias) - if len(aux.RawAuthUser) > 0 && string(aux.RawAuthUser) != "null" { - var intVal int - if err := json.Unmarshal(aux.RawAuthUser, &intVal); err == nil { - if intVal < 0 { - return fmt.Errorf("authUser 不能为负数: %d", intVal) - } - m.AuthUser = intVal - } else { - var strVal string - if err := json.Unmarshal(aux.RawAuthUser, &strVal); err != nil { - return fmt.Errorf("解析 authUser: %w", err) - } - trimmed := strings.TrimSpace(strVal) - if trimmed != "" { - n, err := strconv.Atoi(trimmed) - if err != nil || n < 0 { - return fmt.Errorf("无效的 authUser 值: %q", strVal) - } - m.AuthUser = n - } - } - } - return nil -} - // StorageState 是兼容 Playwright 的认证状态文件 type StorageState struct { Cookies []StateCookie `json:"cookies"` diff --git a/internal/balancer/balancer.go b/internal/balancer/balancer.go index b930b0e..38e2a97 100644 --- a/internal/balancer/balancer.go +++ b/internal/balancer/balancer.go @@ -349,6 +349,19 @@ func (p *AccountPool) NextForModelExcluding(sessionKey string, modelID string, e return entry.Client, entry.AccountID } +// SessionIdle 判断会话绑定的账号可调度该模型且有空闲请求槽 +func (p *AccountPool) SessionIdle(sessionKey string, modelID string) bool { + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + binding, ok := p.sessions[sessionKey] + if !ok || !now.Before(binding.ExpiresAt) { + return false + } + entry := p.entry(binding.AccountID) + return entry != nil && entry.schedulable(now, modelID) && !entry.Client.Busy() +} + // RetryAfter 返回目标模型最早结束冷却的时间,没有冷却中的账号时为零值 func (p *AccountPool) RetryAfter(modelID string) time.Time { p.mu.Lock() diff --git a/internal/chromeauth/auth.go b/internal/chromeauth/auth.go index ad6489c..fe4a602 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -34,9 +34,17 @@ type Account struct { DisplayName string `json:"displayName"` Email string `json:"email"` Importable bool `json:"importable"` - Reason string `json:"reason,omitempty"` + // Reason 是账号不可导入的原因,不可导入且为空时表示该账号未在 Chrome 登录 + Reason string `json:"reason,omitempty"` } +const ( + // ReasonReadFailed 表示读取 Profile 的 Web Data 失败 + ReasonReadFailed = "read_failed" + // ReasonNeedsReauth 表示 refresh token 不是 App-Bound 加密,需要在 Chrome 重新登录该账号 + ReasonNeedsReauth = "needs_reauth" +) + // ImportOptions 保存批量导入参数 type ImportOptions struct { ChromeRoot string @@ -82,7 +90,7 @@ func Discover(chromeRoot string) ([]Account, error) { return discoverPlatform(chromeRoot) } -// Refresh 使用已保存的设备绑定材料重新签发 Google Cookie +// Refresh 使用已保存的 OAuth 续签材料重新签发 Google Cookie func Refresh(ctx context.Context, material auth.OAuthMaterial, proxy string) ([]gemini.Cookie, error) { if err := ensurePlatformImport(); err != nil { return nil, err @@ -144,7 +152,7 @@ func ImportCookies(ctx context.Context, options CookieImportOptions) (ImportResu return results[0], modelCount, nil } -// Import 通过设备绑定 OAuth 认证材料生成协议账号状态 +// Import 用本机 Chrome 的 OAuth 续签材料生成协议账号状态 func Import(ctx context.Context, options ImportOptions) ([]ImportResult, int, error) { if err := ensurePlatformImport(); err != nil { return nil, 0, err diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index 27ca438..8384084 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" "path/filepath" "sort" @@ -65,32 +66,24 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { accounts = append(accounts, Account{Profile: profile, DisplayName: info.Name, Email: info.UserName}) continue } - tokens, dbErr := readProfileTokens(chromeRoot, profile) + records, recordsErr := readTokenServices(chromeRoot, profile) seen := make(map[string]bool, len(identities)) for _, identity := range identities { if identity.GaiaID == "" || seen[identity.GaiaID] { continue } seen[identity.GaiaID] = true - encrypted, hasToken := tokens[identity.GaiaID] - importable := dbErr == nil && hasToken && strings.HasPrefix(string(encrypted), "v20") - reason := "" - if !importable { - switch { - case dbErr != nil: - reason = "missing_token" - case !hasToken: - reason = "not_signed_in" - case len(encrypted) == 0: - reason = "missing_token" - default: - reason = "needs_reauth" - } + record, found := records[identity.GaiaID] + account := Account{Profile: profile, GaiaID: identity.GaiaID, DisplayName: info.Name, Email: identity.Email} + switch { + case recordsErr != nil: + account.Reason = ReasonReadFailed + case found && !strings.HasPrefix(string(record.encryptedToken), "v20"): + account.Reason = ReasonNeedsReauth + default: + account.Importable = found } - accounts = append(accounts, Account{ - Profile: profile, GaiaID: identity.GaiaID, DisplayName: info.Name, Email: identity.Email, - Importable: importable, Reason: reason, - }) + accounts = append(accounts, account) } } return accounts, nil @@ -117,115 +110,99 @@ func profileIdentities(chromeRoot string, profile string) ([]profileIdentity, er return preferences.Accounts, nil } -func copyFile(src, dst string) error { +// copyFile 把 src 复制为 dst +func copyFile(src string, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } defer out.Close() - _, err = io.Copy(out, in) return err } -func openWebData(chromeRoot string, profile string) (*sql.DB, func(), error) { - databasePath := filepath.Join(chromeRoot, profile, "Web Data") - cleanup := func() {} - uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro&immutable=1" - - // 当 Chrome 正在运行时,直接以 mode=ro 打开 Web Data 会触发 SQLITE_BUSY (database is locked)。 - // 为此将 Web Data 及其 WAL 文件拷贝到临时文件后再打开,既能由 SQLite 自动重建索引读取最新的 WAL 提交,又避开了 Chrome 的排他锁。 - if tempDir, err := os.MkdirTemp("", "gemini-web2api-webdata-*"); err == nil { - tempDb := filepath.Join(tempDir, "Web Data") - if copyFile(databasePath, tempDb) == nil { - _ = copyFile(databasePath+"-wal", tempDb+"-wal") - uri = "file:" + filepath.ToSlash(tempDb) - cleanup = func() { - _ = os.RemoveAll(tempDir) - } - } else { - _ = os.RemoveAll(tempDir) +// openWebData 把 Profile 的 Web Data 与其 WAL 日志复制到临时目录后只读打开,绕过运行中 Chrome 的排他锁并包含尚未检查点的写入 +func openWebData(chromeRoot string, profile string) (database *sql.DB, closeDatabase func(), err error) { + tempDir, err := os.MkdirTemp("", "gemini-web2api-webdata-*") + if err != nil { + return nil, nil, fmt.Errorf("创建 Web Data 临时目录: %w", err) + } + defer func() { + if err != nil { + os.RemoveAll(tempDir) } + }() + databasePath := filepath.Join(chromeRoot, profile, "Web Data") + copyPath := filepath.Join(tempDir, "Web Data") + if err = copyFile(databasePath, copyPath); err != nil { + return nil, nil, fmt.Errorf("复制 %s Web Data: %w", profile, err) } - - database, err := sql.Open("sqlite", uri) - if err != nil { - cleanup() + if err = copyFile(databasePath+"-wal", copyPath+"-wal"); err != nil && !os.IsNotExist(err) { + return nil, nil, fmt.Errorf("复制 %s Web Data-wal: %w", profile, err) + } + uri := url.URL{Scheme: "file", Path: "/" + filepath.ToSlash(copyPath), RawQuery: "mode=ro"} + if database, err = sql.Open("sqlite", uri.String()); err != nil { return nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) } database.SetMaxOpenConns(1) return database, func() { database.Close() - cleanup() + os.RemoveAll(tempDir) }, nil } -// readProfileTokens 一次性读取指定 Profile 中全部账号的 encrypted_token,避免多账号循环重复拷贝数据库 -func readProfileTokens(chromeRoot string, profile string) (map[string][]byte, error) { - database, closeDb, err := openWebData(chromeRoot, profile) +// tokenRecord 是 token_service 中一个账号的加密 refresh token 与 wrapped binding key +type tokenRecord struct { + encryptedToken []byte + bindingKey []byte +} + +// readTokenServices 读取 Profile 中全部 Google 账号的 token_service 记录,键为 Gaia ID +func readTokenServices(chromeRoot string, profile string) (map[string]tokenRecord, error) { + database, closeDatabase, err := openWebData(chromeRoot, profile) if err != nil { return nil, err } - defer closeDb() - - rows, err := database.Query("SELECT service, encrypted_token FROM token_service") + defer closeDatabase() + rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service LIKE 'AccountId-%'") if err != nil { return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } defer rows.Close() - - tokens := make(map[string][]byte) + records := map[string]tokenRecord{} for rows.Next() { var service string - var encryptedToken []byte - if err := rows.Scan(&service, &encryptedToken); err != nil { + var record tokenRecord + if err := rows.Scan(&service, &record.encryptedToken, &record.bindingKey); err != nil { return nil, fmt.Errorf("解析 %s token_service: %w", profile, err) } - if gaiaID, ok := strings.CutPrefix(service, "AccountId-"); ok && gaiaID != "" { - tokens[gaiaID] = encryptedToken - } + records[strings.TrimPrefix(service, "AccountId-")] = record } if err := rows.Err(); err != nil { return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } - return tokens, nil + return records, nil } -// readTokenService 读取 Profile 中指定 Gaia ID 账号的 refresh token 与设备绑定密钥 +// readTokenService 返回一个账号的 Gaia ID、加密 refresh token 与 wrapped binding key func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { - database, closeDb, err := openWebData(chromeRoot, profile) + records, err := readTokenServices(chromeRoot, profile) if err != nil { return "", nil, nil, err } - defer closeDb() - - rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service = ?", "AccountId-"+gaiaID) - if err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) - } - defer rows.Close() - if !rows.Next() { - if err := rows.Err(); err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) - } + record, found := records[gaiaID] + if !found { return "", nil, nil, fmt.Errorf("%s 的 token_service 没有账号 %s", profile, gaiaID) } - var service string - var encryptedToken []byte - var bindingKey []byte - if err := rows.Scan(&service, &encryptedToken, &bindingKey); err != nil { - return "", nil, nil, fmt.Errorf("解析 %s token_service: %w", profile, err) - } - if len(encryptedToken) == 0 { + if len(record.encryptedToken) == 0 { return "", nil, nil, fmt.Errorf("%s 的 token_service 缺少认证材料", profile) } - return strings.TrimPrefix(service, "AccountId-"), encryptedToken, bindingKey, nil + return gaiaID, record.encryptedToken, record.bindingKey, nil } func readProfileLanguage(chromeRoot string, profile string) (string, error) { diff --git a/internal/chromeauth/protocol.go b/internal/chromeauth/protocol.go index d154f5e..7cf66fd 100644 --- a/internal/chromeauth/protocol.go +++ b/internal/chromeauth/protocol.go @@ -26,8 +26,10 @@ import ( "github.com/Mag1cFall/Gemini-Web2API/internal/gemini" ) +// multiloginURL 是 OAuthMultilogin 的地址 +var multiloginURL = "https://accounts.google.com/oauth/multilogin?source=ChromiumAccountReconcilorDice&reuseCookies=0" + const ( - multiloginURL = "https://accounts.google.com/oauth/multilogin?source=ChromiumAccountReconcilorDice&reuseCookies=0" oauthClientID = "77185425430.apps.googleusercontent.com" assertionAudience = "https://accounts.google.com/accountmanager" assertionSentinel = "DBSC_CHALLENGE_IF_REQUIRED" @@ -62,36 +64,36 @@ type deviceBindingKey interface { Close() } +// fetchGoogleCookies 用 refresh token 经 OAuthMultilogin 取得 Google Cookie,有设备绑定密钥时走 DBSC 挑战与 HPKE 解密,没有时一次 MultiBearer 请求取得明文 Cookie func fetchGoogleCookies(ctx context.Context, gaiaID string, token string, wrappedKey []byte, proxyURL string) ([]multiloginCookie, error) { client, err := newOAuthClient(proxyURL) if err != nil { return nil, err } defer client.CloseIdleConnections() - if len(wrappedKey) == 0 { - status, resp, err := requestMultilogin(ctx, client, gaiaID, token, "") - if err != nil { - return nil, err - } - if status == http.StatusOK && resp.Status == "OK" && len(resp.Cookies) > 0 { - for _, required := range []string{"SAPISID", "__Secure-1PSID"} { - found := false - for _, c := range resp.Cookies { - if c.Name == required { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) - } - } - return resp.Cookies, nil - } - return nil, fmt.Errorf("OAuthMultilogin 失败 HTTP %d status %s(该账号缺少设备绑定密钥且会话失效,请在 Chrome 重新登录后导入)", status, resp.Status) + return fetchBearerCookies(ctx, client, gaiaID, token) } + return fetchBoundCookies(ctx, client, gaiaID, token, wrappedKey) +} +// fetchBearerCookies 为没有设备绑定的 token 请求一次 OAuthMultilogin,返回明文 Cookie +func fetchBearerCookies(ctx context.Context, client tls_client.HttpClient, gaiaID string, token string) ([]multiloginCookie, error) { + status, response, err := requestMultilogin(ctx, client, gaiaID, token, "") + if err != nil { + return nil, err + } + if status != http.StatusOK || response.Status != "OK" { + return nil, fmt.Errorf("OAuthMultilogin bearer 阶段失败 HTTP %d status %s", status, response.Status) + } + if err := checkMultiloginCookies(response.Cookies); err != nil { + return nil, err + } + return response.Cookies, nil +} + +// fetchBoundCookies 为设备绑定的 token 取得 DBSC challenge、以设备密钥签发 assertion,再解密服务端返回的 Cookie +func fetchBoundCookies(ctx context.Context, client tls_client.HttpClient, gaiaID string, token string, wrappedKey []byte) ([]multiloginCookie, error) { bindingKey, err := openDeviceBindingKey(wrappedKey) if err != nil { return nil, err @@ -129,31 +131,38 @@ func fetchGoogleCookies(ctx context.Context, gaiaID string, token string, wrappe if len(second.Directed) == 0 || bytes.Equal(second.Directed, []byte("null")) { return nil, fmt.Errorf("OAuthMultilogin 响应缺少 token_binding_directed_response") } - if len(second.Cookies) == 0 { - return nil, fmt.Errorf("OAuthMultilogin 响应缺少 Cookie") + if err := checkMultiloginCookies(second.Cookies); err != nil { + return nil, err + } + for index := range second.Cookies { + if second.Cookies[index].Value, err = hpkeOpen(ephemeralPrivateKey, second.Cookies[index].Value); err != nil { + return nil, err + } } + return second.Cookies, nil +} - names := make(map[string]struct{}, len(second.Cookies)) - for index := range second.Cookies { - cookie := &second.Cookies[index] +// checkMultiloginCookies 核对 OAuthMultilogin 返回的 Cookie 带有名称、值、域,并包含 SAPISID 与 __Secure-1PSID +func checkMultiloginCookies(cookies []multiloginCookie) error { + if len(cookies) == 0 { + return fmt.Errorf("OAuthMultilogin 响应缺少 Cookie") + } + names := make(map[string]struct{}, len(cookies)) + for _, cookie := range cookies { if cookie.Name == "" || cookie.Value == "" { - return nil, fmt.Errorf("OAuthMultilogin Cookie 格式异常") - } - cookie.Value, err = hpkeOpen(ephemeralPrivateKey, cookie.Value) - if err != nil { - return nil, err + return fmt.Errorf("OAuthMultilogin Cookie 格式异常") } if cookie.Domain == "" && (cookie.Host == "" || strings.HasPrefix(cookie.Host, ".")) { - return nil, fmt.Errorf("OAuthMultilogin Cookie 域格式异常") + return fmt.Errorf("OAuthMultilogin Cookie 域格式异常") } names[cookie.Name] = struct{}{} } for _, required := range []string{"SAPISID", "__Secure-1PSID"} { if _, ok := names[required]; !ok { - return nil, fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) + return fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) } } - return second.Cookies, nil + return nil } func newOAuthClient(proxyURL string) (tls_client.HttpClient, error) { @@ -164,13 +173,17 @@ func newOAuthClient(proxyURL string) (tls_client.HttpClient, error) { return client, nil } +// requestMultilogin 发送一次 OAuthMultilogin:带 assertion 时用 MultiOAuth 头,没有设备绑定的 token 用 MultiBearer 头 func requestMultilogin(ctx context.Context, client tls_client.HttpClient, gaiaID string, token string, assertion string) (int, multiloginResponse, error) { - authorization := encodeMultiOAuthHeader(gaiaID, token, assertion) + authorization := "MultiBearer " + token + ":" + gaiaID + if assertion != "" { + authorization = "MultiOAuth " + encodeMultiOAuthHeader(gaiaID, token, assertion) + } request, err := http.NewRequestWithContext(ctx, http.MethodPost, multiloginURL, strings.NewReader(" ")) if err != nil { return 0, multiloginResponse{}, fmt.Errorf("创建 OAuthMultilogin 请求: %w", err) } - request.Header.Set("Authorization", "MultiOAuth "+authorization) + request.Header.Set("Authorization", authorization) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("User-Agent", gemini.ChromeUserAgent) response, err := client.Do(request) @@ -205,9 +218,7 @@ func findChallenge(response multiloginResponse) string { func encodeMultiOAuthHeader(gaiaID string, token string, assertion string) string { account := appendBytesField(nil, 1, []byte(gaiaID)) account = appendBytesField(account, 2, []byte(token)) - if assertion != "" { - account = appendBytesField(account, 3, []byte(assertion)) - } + account = appendBytesField(account, 3, []byte(assertion)) return base64.RawURLEncoding.EncodeToString(appendBytesField(nil, 1, account)) } diff --git a/internal/gemini/client.go b/internal/gemini/client.go index 8f721cd..8443c83 100644 --- a/internal/gemini/client.go +++ b/internal/gemini/client.go @@ -816,19 +816,10 @@ func ProbeProxy(ctx context.Context, proxyURL string) (time.Duration, error) { return time.Since(started), nil } -// isAuthenticationError 判断错误是否为 HTTP 或帧内的 401、403,以及重定向到登录页的 302,BardErrorInfo 1060 等出口限制不属于认证错误 +// isAuthenticationError 判断错误是否为 HTTP 或帧内的 401、403,BardErrorInfo 1060 等出口限制不属于认证错误 func isAuthenticationError(err error) bool { var protocolErr *ProtocolError - if !errors.As(err, &protocolErr) { - return false - } - if protocolErr.HTTPStatus == http.StatusUnauthorized || protocolErr.HTTPStatus == http.StatusForbidden { - return true - } - if protocolErr.HTTPStatus == http.StatusFound || protocolErr.HTTPStatus == http.StatusSeeOther || protocolErr.HTTPStatus == http.StatusTemporaryRedirect { - return errors.Is(protocolErr.Cause, ErrSignedOut) || strings.Contains(protocolErr.Message, "登录") || strings.Contains(protocolErr.Message, "ServiceLogin") - } - return errors.Is(protocolErr.Cause, ErrSignedOut) + return errors.As(err, &protocolErr) && (protocolErr.HTTPStatus == http.StatusUnauthorized || protocolErr.HTTPStatus == http.StatusForbidden) } func newProtocolID() (string, error) { @@ -867,42 +858,22 @@ func initialReqID() (int64, error) { // ErrCaptcha 表示 Google 以人机验证页(/sorry)拦截了请求,出口 IP 与账号会话被判定为异常流量 var ErrCaptcha = errors.New("请求被 Google 人机验证页拦截") -// responseStatusError 把非 200 响应转为协议错误,重定向到 google.com/sorry 时为人机验证拦截,按 429 处理;重定向到登录页时按 401 认证失效处理 +// responseStatusError 把非 200 响应转为协议错误,重定向到 google.com/sorry 时为人机验证拦截,按 429 处理;/u/N 页面重定向到 Gemini 其他页面时表示 Cookie 会话中没有第 N 个账号,按会话失效处理 func responseStatusError(resp *http.Response, operation string) *ProtocolError { - if location, err := resp.Location(); err == nil { - if strings.HasSuffix(location.Hostname(), "google.com") && strings.HasPrefix(location.Path, "/sorry") { - return &ProtocolError{ - HTTPStatus: http.StatusTooManyRequests, Code: http.StatusTooManyRequests, Retryable: true, Cause: ErrCaptcha, - Message: fmt.Sprintf("gemini %s 被 Google 人机验证页拦截(HTTP %d 重定向到 %s%s)", operation, resp.StatusCode, location.Host, location.Path), - } - } - if isLoginRedirect(location) { - return &ProtocolError{ - HTTPStatus: http.StatusUnauthorized, Code: http.StatusUnauthorized, Retryable: true, Cause: ErrSignedOut, - Message: fmt.Sprintf("gemini %s 登录态失效或账号序号偏移(HTTP %d 重定向到 %s)", operation, resp.StatusCode, location.String()), - } + location, err := resp.Location() + if err == nil && strings.HasSuffix(location.Hostname(), "google.com") && strings.HasPrefix(location.Path, "/sorry") { + return &ProtocolError{ + HTTPStatus: http.StatusTooManyRequests, Code: http.StatusTooManyRequests, Retryable: true, Cause: ErrCaptcha, + Message: fmt.Sprintf("gemini %s 被 Google 人机验证页拦截(HTTP %d 重定向到 %s%s)", operation, resp.StatusCode, location.Host, location.Path), } } - return httpStatusError(resp.StatusCode, operation) -} - -func isLoginRedirect(location *url.URL) bool { - if location == nil { - return false - } - host := strings.ToLower(location.Host) - path := strings.ToLower(location.Path) - if strings.Contains(host, "accounts.google.com") { - if strings.Contains(path, "servicelogin") || strings.Contains(path, "interactivelogin") || strings.Contains(path, "signin") || strings.Contains(location.RawQuery, "continue=") { - return true + if err == nil && location.Hostname() == resp.Request.URL.Hostname() && strings.HasPrefix(resp.Request.URL.Path, "/u/") { + return &ProtocolError{ + HTTPStatus: http.StatusUnauthorized, Code: http.StatusUnauthorized, Retryable: true, Cause: ErrSignedOut, + Message: fmt.Sprintf("gemini %s 的 %s 重定向到 %s,Cookie 会话中没有该账号序号", operation, resp.Request.URL.Path, location.Path), } } - if strings.Contains(host, "gemini.google.com") { - if path == "/app" || path == "/" || strings.HasPrefix(path, "/u/") { - return true - } - } - return false + return httpStatusError(resp.StatusCode, operation) } func httpStatusError(status int, operation string) *ProtocolError { diff --git a/internal/gemini/rotate.go b/internal/gemini/rotate.go index 15d7318..7437550 100644 --- a/internal/gemini/rotate.go +++ b/internal/gemini/rotate.go @@ -104,14 +104,7 @@ func (r *CookieRotator) rotateSession(ctx context.Context) (bool, error) { if err != nil { return false, err } - if signedIn { - return !first, nil - } - if !listed { - // 没有 accounts.google.com Cookie 的纯 Cookie 导入账号,不主动触发 reload 避免引爆 302 - if c.refresh == nil { - return false, nil - } + if signedIn || !listed { return !first, nil } if c.refresh == nil { diff --git a/web/src/features/accounts/ImportPage.tsx b/web/src/features/accounts/ImportPage.tsx index 181f07d..caf9328 100644 --- a/web/src/features/accounts/ImportPage.tsx +++ b/web/src/features/accounts/ImportPage.tsx @@ -87,8 +87,8 @@ function ChromeImport() { {m.imported} ) : !profile.importable ? ( - {profile.reason === 'missing_token' - ? m.missingToken + {profile.reason === 'read_failed' + ? m.readFailed : profile.reason === 'needs_reauth' ? m.needsReauth : m.notSignedIn} diff --git a/web/src/features/accounts/messages.ts b/web/src/features/accounts/messages.ts index 5da081c..c4f3dc7 100644 --- a/web/src/features/accounts/messages.ts +++ b/web/src/features/accounts/messages.ts @@ -642,8 +642,8 @@ export const importMessages = defineMessages({ chromeHint: '选择已登录 Gemini 的 Chrome Profile', imported: '已导入', notSignedIn: '未登录 Google', - missingToken: '缺少认证令牌', - needsReauth: '需要重新登录', + readFailed: '无法读取 Chrome 数据', + needsReauth: '需在 Chrome 重新登录', importSelected: '导入所选', noProfiles: '未发现 Chrome Profile', cookieTitle: '从 Cookie 导入', @@ -677,8 +677,8 @@ export const importMessages = defineMessages({ chromeHint: '選擇已登入 Gemini 的 Chrome Profile', imported: '已匯入', notSignedIn: '未登入 Google', - missingToken: '缺少認證權杖', - needsReauth: '需要重新登入', + readFailed: '無法讀取 Chrome 資料', + needsReauth: '需在 Chrome 重新登入', importSelected: '匯入所選', noProfiles: '未發現 Chrome Profile', cookieTitle: '從 Cookie 匯入', @@ -712,8 +712,8 @@ export const importMessages = defineMessages({ chromeHint: 'Pick a Chrome profile signed in to Gemini', imported: 'Imported', notSignedIn: 'Not signed in to Google', - missingToken: 'Missing token', - needsReauth: 'Needs re-login', + readFailed: 'Cannot read Chrome data', + needsReauth: 'Sign in again in Chrome', importSelected: 'Import selected', noProfiles: 'No Chrome profile found', cookieTitle: 'Import from cookie', @@ -749,8 +749,8 @@ export const importMessages = defineMessages({ chromeHint: 'Gemini にログイン済みの Chrome プロファイルを選択します', imported: 'インポート済み', notSignedIn: 'Google 未ログイン', - missingToken: 'トークンなし', - needsReauth: '再ログインが必要', + readFailed: 'Chrome のデータを読み取れません', + needsReauth: 'Chrome で再ログインが必要', importSelected: '選択項目をインポート', noProfiles: 'Chrome プロファイルが見つかりません', cookieTitle: 'Cookie からインポート', @@ -785,8 +785,8 @@ export const importMessages = defineMessages({ chromeHint: 'Gemini에 로그인된 Chrome 프로필을 선택합니다', imported: '가져옴', notSignedIn: 'Google 로그인 안 됨', - missingToken: '토큰 없음', - needsReauth: '다시 로그인 필요', + readFailed: 'Chrome 데이터를 읽을 수 없음', + needsReauth: 'Chrome에서 다시 로그인 필요', importSelected: '선택 항목 가져오기', noProfiles: 'Chrome 프로필을 찾지 못했습니다', cookieTitle: '쿠키에서 가져오기', @@ -822,8 +822,8 @@ export const importMessages = defineMessages({ chromeHint: 'Choisissez un profil Chrome connecté à Gemini', imported: 'Importé', notSignedIn: 'Non connecté à Google', - missingToken: 'Jeton manquant', - needsReauth: 'Reconnexion requise', + readFailed: 'Données Chrome illisibles', + needsReauth: 'Reconnectez-vous dans Chrome', importSelected: 'Importer la sélection', noProfiles: 'Aucun profil Chrome trouvé', cookieTitle: 'Importer depuis un cookie', @@ -859,8 +859,8 @@ export const importMessages = defineMessages({ chromeHint: 'Wählen Sie ein Chrome-Profil, das bei Gemini angemeldet ist', imported: 'Importiert', notSignedIn: 'Nicht bei Google angemeldet', - missingToken: 'Token fehlt', - needsReauth: 'Erneute Anmeldung erforderlich', + readFailed: 'Chrome-Daten nicht lesbar', + needsReauth: 'In Chrome erneut anmelden', importSelected: 'Auswahl importieren', noProfiles: 'Kein Chrome-Profil gefunden', cookieTitle: 'Aus Cookie importieren', diff --git a/web/src/features/accounts/types.ts b/web/src/features/accounts/types.ts index c14347e..acfc67a 100644 --- a/web/src/features/accounts/types.ts +++ b/web/src/features/accounts/types.ts @@ -74,7 +74,8 @@ export interface ChromeProfile { email: string importable: boolean imported: boolean - reason?: string + // reason 为账号不可导入的原因,不可导入且缺省时为未在 Chrome 登录 + reason?: 'read_failed' | 'needs_reauth' } // ChromeProfiles 对应 GET /api/accounts/import/chrome 的响应