## 问题背景
1. Gemini CLI 没有明确的会话标识(如 Claude Code 的 metadata.user_id)
2. thoughtSignature 与具体上游账号强绑定,跨账号使用会导致 400 错误
3. 粘性会话切换账号或 cache 丢失时,旧签名会导致请求失败
## 解决方案
### 1. Gemini CLI 会话标识提取
- 从 `x-gemini-api-privileged-user-id` header 和请求体中的 tmp 目录哈希生成会话标识
- 组合策略:SHA256(privileged-user-id + ":" + tmp_dir_hash)
- 正则提取:`/\.gemini/tmp/([A-Fa-f0-9]{64})`
### 2. 跨账号 thoughtSignature 清理
实现三种场景的智能清理:
1. **Cache 命中 + 账号切换**
- 粘性会话绑定的账号与当前选择的账号不同时清理
2. **同一请求内 failover 切换**
- 通过 sessionBoundAccountID 跟踪,检测重试时的账号切换
3. **Gemini CLI + Cache 未命中 + 含签名**
- 预防性清理,避免 cache 丢失后首次转发就 400
- 仅对 Gemini CLI 请求且请求体包含 thoughtSignature 时触发
## 修改内容
### backend/internal/handler/gemini_v1beta_handler.go
- 添加 `extractGeminiCLISessionHash` 函数提取 Gemini CLI 会话标识
- 添加 `isGeminiCLIRequest` 函数识别 Gemini CLI 请求
- 实现账号切换检测与 thoughtSignature 清理逻辑
- 添加 `geminiCLITmpDirRegex` 正则表达式
### backend/internal/service/gateway_service.go
- 添加 `GetCachedSessionAccountID` 方法查询粘性会话绑定的账号 ID
### backend/internal/service/gemini_native_signature_cleaner.go (新增)
- 实现 `CleanGeminiNativeThoughtSignatures` 函数
- 递归清理 JSON 中的所有 thoughtSignature 字段
- 支持任意 JSON 顶层类型(object/array)
### backend/internal/handler/gemini_cli_session_test.go (新增)
- 测试 Gemini CLI 会话哈希提取逻辑
- 测试 tmp 目录正则匹配
- 覆盖有/无 privileged-user-id 的场景
## 影响范围
- 修复 Gemini CLI 多轮对话时账号切换导致的 400 错误
- 提高粘性会话的稳定性和容错能力
- 不影响其他客户端(Claude Code 等)的会话标识生成
## 测试
- 单元测试:go test -tags=unit ./internal/handler -run TestExtractGeminiCLISessionHash
- 单元测试:go test -tags=unit ./internal/handler -run TestGeminiCLITmpDirRegex
- 编译验证:go build ./cmd/server
73 lines
2.1 KiB
Go
73 lines
2.1 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
)
|
||
|
||
// CleanGeminiNativeThoughtSignatures 从 Gemini 原生 API 请求中移除 thoughtSignature 字段,
|
||
// 以避免跨账号签名验证错误。
|
||
//
|
||
// 当粘性会话切换账号时(例如原账号异常、不可调度等),旧账号返回的 thoughtSignature
|
||
// 会导致新账号的签名验证失败。通过移除这些签名,让新账号重新生成有效的签名。
|
||
//
|
||
// CleanGeminiNativeThoughtSignatures removes thoughtSignature fields from Gemini native API requests
|
||
// to avoid cross-account signature validation errors.
|
||
//
|
||
// When sticky session switches accounts (e.g., original account becomes unavailable),
|
||
// thoughtSignatures from the old account will cause validation failures on the new account.
|
||
// By removing these signatures, we allow the new account to generate valid signatures.
|
||
func CleanGeminiNativeThoughtSignatures(body []byte) []byte {
|
||
if len(body) == 0 {
|
||
return body
|
||
}
|
||
|
||
// 解析 JSON
|
||
var data any
|
||
if err := json.Unmarshal(body, &data); err != nil {
|
||
// 如果解析失败,返回原始 body(可能不是 JSON 或格式不正确)
|
||
return body
|
||
}
|
||
|
||
// 递归清理 thoughtSignature
|
||
cleaned := cleanThoughtSignaturesRecursive(data)
|
||
|
||
// 重新序列化
|
||
result, err := json.Marshal(cleaned)
|
||
if err != nil {
|
||
// 如果序列化失败,返回原始 body
|
||
return body
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// cleanThoughtSignaturesRecursive 递归遍历数据结构,移除所有 thoughtSignature 字段
|
||
func cleanThoughtSignaturesRecursive(data any) any {
|
||
switch v := data.(type) {
|
||
case map[string]any:
|
||
// 创建新的 map,移除 thoughtSignature
|
||
result := make(map[string]any, len(v))
|
||
for key, value := range v {
|
||
// 跳过 thoughtSignature 字段
|
||
if key == "thoughtSignature" {
|
||
continue
|
||
}
|
||
// 递归处理嵌套结构
|
||
result[key] = cleanThoughtSignaturesRecursive(value)
|
||
}
|
||
return result
|
||
|
||
case []any:
|
||
// 递归处理数组中的每个元素
|
||
result := make([]any, len(v))
|
||
for i, item := range v {
|
||
result[i] = cleanThoughtSignaturesRecursive(item)
|
||
}
|
||
return result
|
||
|
||
default:
|
||
// 基本类型(string, number, bool, null)直接返回
|
||
return v
|
||
}
|
||
}
|