Skip to content
Merged
8 changes: 4 additions & 4 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<account>/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/<account>/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/<account>/storage-state.json` and reused after a restart. Chrome-imported accounts renew automatically when their session expires.

Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 导入的账号在登录态失效时自动续签。

Expand Down Expand Up @@ -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` 不生效
- **转写与翻译**:由所选对话模型完成
Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 槽载荷 |
Expand Down
30 changes: 17 additions & 13 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -299,18 +302,18 @@ 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 本地材料

| 来源 | 原始字段 |
| --- | --- |
| `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
Expand All @@ -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 {
Expand Down Expand Up @@ -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` 编码

Expand Down Expand Up @@ -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 执行。

Expand Down
13 changes: 11 additions & 2 deletions internal/adapter/claude_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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())
Expand Down
Loading