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 11cc014..6a824d1 100644 --- a/internal/adapter/claude_handler.go +++ b/internal/adapter/claude_handler.go @@ -55,6 +55,13 @@ func ClaudeMessagesHandler(pool *balancer.AccountPool) gin.HandlerFunc { } setUnsupportedParameters(c, unsupportedClaudeParameters(request)) 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()) if request.Stream { @@ -67,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) @@ -105,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)) @@ -170,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) @@ -188,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()) 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 e6e1e0e..1d7eb0c 100644 --- a/internal/adapter/openai_chat.go +++ b/internal/adapter/openai_chat.go @@ -127,6 +127,11 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { } 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()) created := time.Now().Unix() @@ -139,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, ) @@ -200,6 +205,9 @@ 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 } @@ -231,6 +239,9 @@ func ChatCompletionHandler(pool *balancer.AccountPool) gin.HandlerFunc { writeOpenAIError(c, http.StatusBadGateway, "output_validation_error", err.Error()) return } + if choice == 0 { + rememberHistory(pool, result, "", bridge, turns) + } usage = addUsage(usage, result.Accumulator.Usage) if response == nil { response = built 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 82744f2..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 @@ -252,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 @@ -406,9 +418,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++ } @@ -424,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 0a7789c..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") { 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/auth/state.go b/internal/auth/state.go index 7dc4a52..286177c 100644 --- a/internal/auth/state.go +++ b/internal/auth/state.go @@ -33,7 +33,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 保存账号固定身份 @@ -292,7 +292,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 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 8c89c00..fe4a602 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -34,8 +34,17 @@ type Account struct { DisplayName string `json:"displayName"` Email string `json:"email"` Importable bool `json:"importable"` + // 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 @@ -81,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 @@ -143,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 e5f98a2..8384084 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -6,6 +6,8 @@ import ( "database/sql" "encoding/json" "fmt" + "io" + "net/url" "os" "path/filepath" "sort" @@ -64,17 +66,24 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { accounts = append(accounts, Account{Profile: profile, DisplayName: info.Name, Email: info.UserName}) continue } + 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, bindingKey, tokenErr := readTokenService(chromeRoot, profile, identity.GaiaID) - 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, - }) + 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) } } return accounts, nil @@ -101,38 +110,99 @@ 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) { - databasePath := filepath.Join(chromeRoot, profile, "Web Data") - uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro&immutable=1" - database, err := sql.Open("sqlite", uri) +// 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 +} + +// 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("打开 %s Web Data: %w", profile, err) + 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) + } + 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) } - defer database.Close() database.SetMaxOpenConns(1) + return database, func() { + database.Close() + os.RemoveAll(tempDir) + }, nil +} + +// tokenRecord 是 token_service 中一个账号的加密 refresh token 与 wrapped binding key +type tokenRecord struct { + encryptedToken []byte + bindingKey []byte +} - rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service = ?", "AccountId-"+gaiaID) +// 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, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + return nil, err + } + 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() - if !rows.Next() { - if err := rows.Err(); err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + records := map[string]tokenRecord{} + for rows.Next() { + var service string + var record tokenRecord + if err := rows.Scan(&service, &record.encryptedToken, &record.bindingKey); err != nil { + return nil, fmt.Errorf("解析 %s token_service: %w", profile, err) } - return "", nil, nil, fmt.Errorf("%s 的 token_service 没有账号 %s", profile, gaiaID) + records[strings.TrimPrefix(service, "AccountId-")] = record + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } - 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) + return records, nil +} + +// readTokenService 返回一个账号的 Gaia ID、加密 refresh token 与 wrapped binding key +func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { + records, err := readTokenServices(chromeRoot, profile) + if err != nil { + return "", nil, nil, err + } + record, found := records[gaiaID] + if !found { + return "", nil, nil, fmt.Errorf("%s 的 token_service 没有账号 %s", profile, gaiaID) } - if len(encryptedToken) == 0 || len(bindingKey) == 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 1b8aa95..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,21 +64,45 @@ 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) { - 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 { + 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 } - client, err := newOAuthClient(proxyURL) + 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 + } + 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 { @@ -105,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 } - - names := make(map[string]struct{}, len(second.Cookies)) for index := range second.Cookies { - cookie := &second.Cookies[index] - if cookie.Name == "" || cookie.Value == "" { - return nil, fmt.Errorf("OAuthMultilogin Cookie 格式异常") - } - cookie.Value, err = hpkeOpen(ephemeralPrivateKey, cookie.Value) - if err != nil { + if second.Cookies[index].Value, err = hpkeOpen(ephemeralPrivateKey, second.Cookies[index].Value); err != nil { return nil, err } + } + return second.Cookies, nil +} + +// 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 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) { @@ -140,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) diff --git a/internal/gemini/client.go b/internal/gemini/client.go index bfa7bad..8443c83 100644 --- a/internal/gemini/client.go +++ b/internal/gemini/client.go @@ -858,14 +858,21 @@ 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 处理;/u/N 页面重定向到 Gemini 其他页面时表示 Cookie 会话中没有第 N 个账号,按会话失效处理 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") { + 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), } } + 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), + } + } return httpStatusError(resp.StatusCode, operation) } diff --git a/web/src/features/accounts/ImportPage.tsx b/web/src/features/accounts/ImportPage.tsx index 9362403..caf9328 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 === 'read_failed' + ? m.readFailed + : 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..c4f3dc7 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', + readFailed: '无法读取 Chrome 数据', + needsReauth: '需在 Chrome 重新登录', importSelected: '导入所选', noProfiles: '未发现 Chrome Profile', cookieTitle: '从 Cookie 导入', @@ -675,6 +677,8 @@ export const importMessages = defineMessages({ chromeHint: '選擇已登入 Gemini 的 Chrome Profile', imported: '已匯入', notSignedIn: '未登入 Google', + readFailed: '無法讀取 Chrome 資料', + needsReauth: '需在 Chrome 重新登入', 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', + readFailed: 'Cannot read Chrome data', + needsReauth: 'Sign in again in Chrome', 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 未ログイン', + readFailed: 'Chrome のデータを読み取れません', + needsReauth: 'Chrome で再ログインが必要', importSelected: '選択項目をインポート', noProfiles: 'Chrome プロファイルが見つかりません', cookieTitle: 'Cookie からインポート', @@ -777,6 +785,8 @@ export const importMessages = defineMessages({ chromeHint: 'Gemini에 로그인된 Chrome 프로필을 선택합니다', imported: '가져옴', notSignedIn: 'Google 로그인 안 됨', + readFailed: 'Chrome 데이터를 읽을 수 없음', + needsReauth: 'Chrome에서 다시 로그인 필요', 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', + 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', @@ -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', + 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 4bf0329..acfc67a 100644 --- a/web/src/features/accounts/types.ts +++ b/web/src/features/accounts/types.ts @@ -74,6 +74,8 @@ export interface ChromeProfile { email: string importable: boolean imported: boolean + // reason 为账号不可导入的原因,不可导入且缺省时为未在 Chrome 登录 + reason?: 'read_failed' | 'needs_reauth' } // ChromeProfiles 对应 GET /api/accounts/import/chrome 的响应