Fix/multiple issues (#24)

* fix(gemini): 修复 google_one OAuth 配置和 scopes 问题

- 修复 google_one 类型在 ExchangeCode 和 RefreshToken 中使用内置客户端
- 添加 DefaultGoogleOneScopes,包含 generative-language 和 drive.readonly 权限
- 在 EffectiveOAuthConfig 中为 google_one 类型使用专门的 scopes
- 将 docker-compose.override.yml 重命名为 .example 并添加到 .gitignore
- 完善 docker-compose.override.yml.example 示例文档

解决问题:
1. google_one OAuth 授权后 API 调用返回 403 权限不足
2. 缺少访问 Gemini API 所需的 generative-language scope
3. 缺少获取 Drive 存储配额所需的 drive.readonly scope

* fix(antigravity): 完全跳过 Claude 模型的所有 thinking 块

问题分析:
- 当前代码尝试保留有 signature 的 thinking 块
- 但 Vertex AI 的 signature 是完整性令牌,无法在本地验证
- 导致 400 错误:Invalid signature in thinking block

根本原因:
1. thinking 功能已对非 Gemini 模型禁用 (isThinkingEnabled=false)
2. Vertex AI 要求原样重放 (thinking, signature) 对或完全不发送
3. 本地无法复制 Vertex 的加密验证逻辑

修复方案:
- 对 Claude 模型完全跳过所有 thinking 块(无论是否有 signature)
- 保持 Gemini 模型使用 dummy signature 的行为不变
- 更新测试用例以反映新的预期行为

影响:
- 消除 thinking 相关的 400 错误
- 与现有的 thinking 禁用策略保持一致
- 不影响 Gemini 模型的 thinking 功能

测试:
-  TestBuildParts_ThinkingBlockWithoutSignature 全部通过
-  TestBuildTools_CustomTypeTools 全部通过

参考:Codex review 建议

* fix(gateway): 修复 count_tokens 端点 400 错误

问题分析:
- count_tokens 请求包含 thinking 块时返回 400 错误
- 原因:thinking 块未被过滤,直接转发到上游 API
- 上游 API 拒绝无效的 thinking signature

根本原因:
1. /v1/messages 请求通过 TransformClaudeToGemini 过滤 thinking 块
2. count_tokens 请求绕过转换,直接转发原始请求体
3. 导致包含无效 signature 的 thinking 块被发送到上游

修复方案:
- 创建 FilterThinkingBlocks 工具函数
- 在 buildCountTokensRequest 中应用过滤(1 行修改)
- 与 /v1/messages 行为保持一致

实现细节:
- FilterThinkingBlocks: 解析 JSON,过滤 thinking 块,重新序列化
- 失败安全:解析/序列化失败时返回原始请求体
- 性能优化:仅在发现 thinking 块时重新序列化

测试:
-  6 个单元测试全部通过
-  覆盖正常过滤、无 thinking 块、无效 JSON 等场景
-  现有测试不受影响

影响:
- 消除 count_tokens 的 400 错误
- 不影响 Antigravity 账号(仍返回模拟响应)
- 适用于所有账号类型(OAuth、API Key)

文件修改:
- backend/internal/service/gateway_request.go: +62 行(新函数)
- backend/internal/service/gateway_service.go: +2 行(应用过滤)
- backend/internal/service/gateway_request_test.go: +62 行(测试)

* fix(gateway): 增强 thinking 块过滤逻辑

基于 Codex 分析和建议的改进:

问题分析:
- 新错误:signature: Field required(signature 字段缺失)
- 旧错误:Invalid signature(signature 存在但无效)
- 两者都说明 thinking 块在请求中是危险的

Codex 建议:
- 保持 Option A:完全跳过所有 thinking 块
- 原因:thinking 块应该是只输出的,除非有服务端来源证明
- 在无状态代理中,无法安全区分上游来源 vs 客户端注入

改进内容:

1. 增强 FilterThinkingBlocks 函数
   - 过滤显式的 thinking 块:{"type":"thinking", ...}
   - 过滤无 type 的 thinking 对象:{"thinking": {...}}
   - 保留 tool_use 等其他类型块中的 thinking 字段
   - 修复:只在实际过滤时更新 content 数组

2. 扩展过滤范围
   - 将 FilterThinkingBlocks 应用到 /v1/messages 主路径
   - 之前只应用于 count_tokens,现在两个端点都过滤
   - 防止所有端点的 thinking 相关 400 错误

3. 改进测试
   - 新增:过滤无 type discriminator 的 thinking 块
   - 新增:不过滤 tool_use 中的 thinking 字段
   - 使用 containsThinkingBlock 辅助函数验证

测试:
-  8 个测试用例全部通过
-  覆盖各种 thinking 块格式
-  确保不误伤其他类型的块

影响:
- 消除 signature required 和 invalid signature 错误
- 统一 /v1/messages 和 count_tokens 的行为
- 更健壮的 thinking 块检测逻辑

参考:Codex review 和代码改进

* refactor: 根据 Codex 审查建议进行代码优化

基于 Codex 代码审查的 P1 和 P2 改进:

P1 改进(重要问题):

1. 优化日志输出
   - 移除 thinking 块跳过时的 log.Printf
   - 避免高频请求下的日志噪音
   - 添加注释说明可通过指标监控

2. 清理遗留代码
   - 删除未使用的 isValidThoughtSignature 函数(27行)
   - 该函数在改为完全跳过 thinking 块后不再需要

P2 改进(性能优化):

3. 添加快速路径检查
   - 在 FilterThinkingBlocks 中添加 bytes.Contains 预检查
   - 如果请求体不包含 "thinking" 字符串,直接返回
   - 避免不必要的 JSON 解析,提升性能

技术细节:
- request_transformer.go: -27行(删除函数),+1行(优化注释)
- gateway_request.go: +5行(快速路径 + bytes 导入)

测试:
-  TestBuildParts_ThinkingBlockWithoutSignature 全部通过
-  TestFilterThinkingBlocks 全部通过(8个测试用例)

影响:
- 减少日志噪音
- 提升性能(快速路径)
- 代码更简洁(删除未使用代码)

参考:Codex 代码审查建议

* fix: 修复 golangci-lint 检查问题

- 格式化 gateway_request_test.go
- 使用 switch 语句替代 if-else 链(staticcheck QF1003)

* fix(antigravity): 修复 thinking signature 处理并实现 Auto 模式降级

问题分析:
1. 原先代码错误地禁用了 Claude via Vertex 的 thinkingConfig
2. 历史 thinking 块的 signature 被完全跳过,导致验证失败
3. 跨模型混用时 dummy signature 会导致 400 错误

修复内容:

**request_transformer.go**:
- 删除第 38-43 行的错误逻辑(禁用 thinkingConfig)
- 引入 thoughtSignatureMode(Preserve/Dummy)策略
- Claude 模式:透传真实 signature,过滤空/dummy
- Gemini 模式:使用 dummy signature
- 支持 signature-only thinking 块
- tool_use 的 signature 也透传

**antigravity_gateway_service.go**:
- 新增 isSignatureRelatedError() 检测 signature 相关错误
- 新增 stripThinkingFromClaudeRequest() 移除 thinking 块
- 实现 Auto 模式:检测 400 + signature 关键词时自动降级重试
- 重试时完全移除 thinking 配置和消息中的 thinking 块
- 最多重试一次,避免循环

**测试**:
- 更新并新增测试覆盖 Claude preserve/Gemini dummy 模式
- 新增 tool_use signature 处理测试
- 所有测试通过(6/6)

影响:
-  Claude via Vertex 可以正常使用 thinking 功能
-  历史 signature 正确透传,避免验证失败
-  跨模型混用时自动过滤无效 signature
-  错误驱动降级,自动修复 signature 问题
-  不影响纯 Claude API 和其他渠道

参考:Codex 深度分析和实现建议

* fix(lint): 修复 gofmt 格式问题

* fix(antigravity): 修复 stripThinkingFromClaudeRequest 遗漏 untyped thinking blocks

问题:
- Codex 审查指出 stripThinkingFromClaudeRequest 只移除了 type="thinking" 的块
- 没有处理没有 type 字段的 thinking 对象(如 {"thinking": "...", "signature": "..."})
- 导致重试时仍包含无效 thinking 块,上游 400 错误持续

修复:
- 添加检查:跳过没有 type 但有 thinking 字段的块
- 现在会移除两种格式:
  1. {"type": "thinking", "thinking": "...", "signature": "..."}
  2. {"thinking": "...", "signature": "..."}(untyped)

测试:所有测试通过

参考:Codex P1 审查意见
This commit is contained in:
IanShaw
2026-01-02 17:47:49 +08:00
committed by GitHub
parent 68671749d8
commit 7fdc2b2d29
12 changed files with 627 additions and 102 deletions

1
.gitignore vendored
View File

@@ -118,3 +118,4 @@ docs/
code-reviews/
AGENTS.md
backend/cmd/server/server
deploy/docker-compose.override.yml

View File

@@ -20,12 +20,18 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
// 检测是否启用 thinking
requestedThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
// 为避免 Claude 模型的 thought signature/消息块约束导致 400上游要求 thinking 块开头等),
// Gemini 模型默认不启用 thinking除非未来支持完整签名链路
isThinkingEnabled := requestedThinkingEnabled && allowDummyThought
// antigravity(v1internal) 下Gemini 与 Claude 的 “thinking” 都可能涉及 thoughtSignature 链路:
// - Gemini:支持 dummy signature 跳过校验
// - Claude需要透传上游签名否则容易 400
isThinkingEnabled := requestedThinkingEnabled
thoughtSignatureMode := thoughtSignatureModePreserve
if allowDummyThought {
thoughtSignatureMode = thoughtSignatureModeDummy
}
// 1. 构建 contents
contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, thoughtSignatureMode)
if err != nil {
return nil, fmt.Errorf("build contents: %w", err)
}
@@ -34,15 +40,7 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
systemInstruction := buildSystemInstruction(claudeReq.System, claudeReq.Model)
// 3. 构建 generationConfig
reqForGen := claudeReq
if requestedThinkingEnabled && !allowDummyThought {
log.Printf("[Warning] Disabling thinking for non-Gemini model in antigravity transform: model=%s", mappedModel)
// shallow copy to avoid mutating caller's request
clone := *claudeReq
clone.Thinking = nil
reqForGen = &clone
}
generationConfig := buildGenerationConfig(reqForGen)
generationConfig := buildGenerationConfig(claudeReq)
// 4. 构建 tools
tools := buildTools(claudeReq.Tools)
@@ -131,7 +129,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string) *GeminiCon
}
// buildContents 构建 contents
func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isThinkingEnabled, allowDummyThought bool) ([]GeminiContent, error) {
func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isThinkingEnabled bool, thoughtSignatureMode thoughtSignatureMode) ([]GeminiContent, error) {
var contents []GeminiContent
for i, msg := range messages {
@@ -140,11 +138,13 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
role = "model"
}
parts, err := buildParts(msg.Content, toolIDToName, allowDummyThought)
parts, err := buildParts(msg.Content, toolIDToName, thoughtSignatureMode)
if err != nil {
return nil, fmt.Errorf("build parts for message %d: %w", i, err)
}
allowDummyThought := thoughtSignatureMode == thoughtSignatureModeDummy
// 只有 Gemini 模型支持 dummy thinking block workaround
// 只对最后一条 assistant 消息添加Pre-fill 场景)
// 历史 assistant 消息不能添加没有 signature 的 dummy thinking block
@@ -183,37 +183,19 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
// 参考: https://ai.google.dev/gemini-api/docs/thought-signatures
const dummyThoughtSignature = "skip_thought_signature_validator"
// isValidThoughtSignature 验证 thought signature 是否有效
// Claude API 要求 signature 必须是 base64 编码的字符串,长度至少 32 字节
func isValidThoughtSignature(signature string) bool {
// 空字符串无效
if signature == "" {
return false
}
// buildParts 构建消息的 parts
type thoughtSignatureMode int
// signature 应该是 base64 编码,长度至少 40 个字符(约 30 字节)
// 参考 Claude API 文档和实际观察到的有效 signature
if len(signature) < 40 {
log.Printf("[Debug] Signature too short: len=%d", len(signature))
return false
}
// 检查是否是有效的 base64 字符
// base64 字符集: A-Z, a-z, 0-9, +, /, =
for i, c := range signature {
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') &&
(c < '0' || c > '9') && c != '+' && c != '/' && c != '=' {
log.Printf("[Debug] Invalid base64 character at position %d: %c (code=%d)", i, c, c)
return false
}
}
return true
}
const (
thoughtSignatureModePreserve thoughtSignatureMode = iota
thoughtSignatureModeDummy
)
// buildParts 构建消息的 parts
// allowDummyThought: 只有 Gemini 模型支持 dummy thought signature
func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDummyThought bool) ([]GeminiPart, error) {
// thoughtSignatureMode:
// - dummy: 用 dummy signature 跳过 Gemini thoughtSignature 校验
// - preserve: 透传输入中的 signature主要用于 Claude via Vertex 的签名链路)
func buildParts(content json.RawMessage, toolIDToName map[string]string, thoughtSignatureMode thoughtSignatureMode) ([]GeminiPart, error) {
var parts []GeminiPart
// 尝试解析为字符串
@@ -239,7 +221,9 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
}
case "thinking":
if allowDummyThought {
signature := strings.TrimSpace(block.Signature)
if thoughtSignatureMode == thoughtSignatureModeDummy {
// Gemini 模型可以使用 dummy signature
parts = append(parts, GeminiPart{
Text: block.Thinking,
@@ -249,20 +233,27 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
continue
}
// Claude 模型:仅在提供有效 signature 时保留 thinking block否则跳过以避免上游校验失败。
signature := strings.TrimSpace(block.Signature)
// Claude via Vertex
// - signature 是上游返回的完整性令牌;本地不需要/无法验证,只能透传
// - 缺失/无效 signature例如来自 Gemini 的 dummy signature会导致上游 400
if signature == "" || signature == dummyThoughtSignature {
log.Printf("[Warning] Skipping thinking block for Claude model (missing or dummy signature)")
continue
}
if !isValidThoughtSignature(signature) {
log.Printf("[Debug] Thinking signature may be invalid (passing through anyway): len=%d", len(signature))
// 兼容:用 Claude 的 "thinking" 块承载两类东西
// 1) 真正的 thought 文本thinking != ""-> Gemini thought part
// 2) 仅承载 signature 的空 thinking 块thinking == ""-> Gemini signature-only part
if strings.TrimSpace(block.Thinking) == "" {
parts = append(parts, GeminiPart{
ThoughtSignature: signature,
})
} else {
parts = append(parts, GeminiPart{
Text: block.Thinking,
Thought: true,
ThoughtSignature: signature,
})
}
parts = append(parts, GeminiPart{
Text: block.Thinking,
Thought: true,
ThoughtSignature: signature,
})
case "image":
if block.Source != nil && block.Source.Type == "base64" {
@@ -287,10 +278,15 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
ID: block.ID,
},
}
// 只有 Gemini 模型使用 dummy signature
// Claude 模型不设置 signature避免验证问题
if allowDummyThought {
switch thoughtSignatureMode {
case thoughtSignatureModeDummy:
part.ThoughtSignature = dummyThoughtSignature
case thoughtSignatureModePreserve:
// Claude via Vertex透传 tool_use 的 signature如果有
// 注意:跨模型混用时可能出现 dummy signature这里直接丢弃以避免 400。
if sig := strings.TrimSpace(block.Signature); sig != "" && sig != dummyThoughtSignature {
part.ThoughtSignature = sig
}
}
parts = append(parts, part)

View File

@@ -8,11 +8,11 @@ import (
// TestBuildParts_ThinkingBlockWithoutSignature 测试thinking block无signature时的处理
func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
tests := []struct {
name string
content string
allowDummyThought bool
expectedParts int
description string
name string
content string
thoughtMode thoughtSignatureMode
expectedParts int
description string
}{
{
name: "Claude model - skip thinking block without signature",
@@ -21,20 +21,20 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
{"type": "text", "text": "World"}
]`,
allowDummyThought: false,
expectedParts: 2, // 只有两个text block
description: "Claude模型应该跳过无signature的thinking block",
thoughtMode: thoughtSignatureModePreserve,
expectedParts: 2, // 只有两个text block
description: "Claude模型应该跳过无signature的thinking block",
},
{
name: "Claude model - keep thinking block with signature",
name: "Claude model - preserve thinking block with signature",
content: `[
{"type": "text", "text": "Hello"},
{"type": "thinking", "thinking": "Let me think...", "signature": "valid_sig"},
{"type": "thinking", "thinking": "Let me think...", "signature": "sig_real_123"},
{"type": "text", "text": "World"}
]`,
allowDummyThought: false,
expectedParts: 3, // 三个block都保留
description: "Claude模型应该保留有signaturethinking block",
thoughtMode: thoughtSignatureModePreserve,
expectedParts: 3,
description: "Claude模型应透传带 signaturethinking block(用于 Vertex 签名链路)",
},
{
name: "Gemini model - use dummy signature",
@@ -43,16 +43,27 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
{"type": "text", "text": "World"}
]`,
allowDummyThought: true,
expectedParts: 3, // 三个block都保留thinking使用dummy signature
description: "Gemini模型应该为无signature的thinking block使用dummy signature",
thoughtMode: thoughtSignatureModeDummy,
expectedParts: 3, // 三个block都保留thinking使用dummy signature
description: "Gemini模型应该为无signature的thinking block使用dummy signature",
},
{
name: "Claude model - signature-only thinking block becomes signature-only part",
content: `[
{"type": "text", "text": "Hello"},
{"type": "thinking", "thinking": "", "signature": "sig_only_456"},
{"type": "text", "text": "World"}
]`,
thoughtMode: thoughtSignatureModePreserve,
expectedParts: 3,
description: "Claude模型应将空 thinking + signature 映射为 signature-only part便于 roundtrip",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
toolIDToName := make(map[string]string)
parts, err := buildParts(json.RawMessage(tt.content), toolIDToName, tt.allowDummyThought)
parts, err := buildParts(json.RawMessage(tt.content), toolIDToName, tt.thoughtMode)
if err != nil {
t.Fatalf("buildParts() error = %v", err)
@@ -61,10 +72,71 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
if len(parts) != tt.expectedParts {
t.Errorf("%s: got %d parts, want %d parts", tt.description, len(parts), tt.expectedParts)
}
switch tt.name {
case "Claude model - preserve thinking block with signature":
if len(parts) != 3 {
t.Fatalf("expected 3 parts, got %d", len(parts))
}
if !parts[1].Thought || parts[1].ThoughtSignature != "sig_real_123" {
t.Fatalf("expected thought part with signature sig_real_123, got thought=%v signature=%q",
parts[1].Thought, parts[1].ThoughtSignature)
}
case "Claude model - signature-only thinking block becomes signature-only part":
if len(parts) != 3 {
t.Fatalf("expected 3 parts, got %d", len(parts))
}
if parts[1].Thought || parts[1].Text != "" || parts[1].ThoughtSignature != "sig_only_456" {
t.Fatalf("expected signature-only part, got thought=%v text=%q signature=%q",
parts[1].Thought, parts[1].Text, parts[1].ThoughtSignature)
}
case "Gemini model - use dummy signature":
if len(parts) != 3 {
t.Fatalf("expected 3 parts, got %d", len(parts))
}
if !parts[1].Thought || parts[1].ThoughtSignature != dummyThoughtSignature {
t.Fatalf("expected dummy thought signature, got thought=%v signature=%q",
parts[1].Thought, parts[1].ThoughtSignature)
}
}
})
}
}
func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
content := `[
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}, "signature": "sig_tool_abc"}
]`
t.Run("Claude preserve tool_use signature", func(t *testing.T) {
toolIDToName := make(map[string]string)
parts, err := buildParts(json.RawMessage(content), toolIDToName, thoughtSignatureModePreserve)
if err != nil {
t.Fatalf("buildParts() error = %v", err)
}
if len(parts) != 1 || parts[0].FunctionCall == nil {
t.Fatalf("expected 1 functionCall part, got %+v", parts)
}
if parts[0].ThoughtSignature != "sig_tool_abc" {
t.Fatalf("expected tool signature sig_tool_abc, got %q", parts[0].ThoughtSignature)
}
})
t.Run("Gemini uses dummy tool_use signature", func(t *testing.T) {
toolIDToName := make(map[string]string)
parts, err := buildParts(json.RawMessage(content), toolIDToName, thoughtSignatureModeDummy)
if err != nil {
t.Fatalf("buildParts() error = %v", err)
}
if len(parts) != 1 || parts[0].FunctionCall == nil {
t.Fatalf("expected 1 functionCall part, got %+v", parts)
}
if parts[0].ThoughtSignature != dummyThoughtSignature {
t.Fatalf("expected dummy tool signature %q, got %q", dummyThoughtSignature, parts[0].ThoughtSignature)
}
})
}
// TestBuildTools_CustomTypeTools 测试custom类型工具转换
func TestBuildTools_CustomTypeTools(t *testing.T) {
tests := []struct {

View File

@@ -26,6 +26,10 @@ const (
// https://www.googleapis.com/auth/generative-language.retriever (often with cloud-platform).
DefaultAIStudioScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever"
// DefaultScopes for Google One (personal Google accounts with Gemini access)
// Includes generative-language for Gemini API access and drive.readonly for storage tier detection
DefaultGoogleOneScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
// GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth.
GeminiCLIRedirectURI = "https://codeassist.google.com/authcode"

View File

@@ -172,14 +172,19 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error
if effective.Scopes == "" {
// Use different default scopes based on OAuth type
if oauthType == "ai_studio" {
switch oauthType {
case "ai_studio":
// Built-in client can't request some AI Studio scopes (notably generative-language).
if isBuiltinClient {
effective.Scopes = DefaultCodeAssistScopes
} else {
effective.Scopes = DefaultAIStudioScopes
}
} else {
case "google_one":
// Google One accounts need generative-language scope for Gemini API access
// and drive.readonly scope for storage tier detection
effective.Scopes = DefaultGoogleOneScopes
default:
// Default to Code Assist scopes
effective.Scopes = DefaultCodeAssistScopes
}

View File

@@ -30,13 +30,14 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c
// Use different OAuth clients based on oauthType:
// - code_assist: always use built-in Gemini CLI OAuth client (public)
// - google_one: same as code_assist, uses built-in client for personal Google accounts
// - ai_studio: requires a user-provided OAuth client
oauthCfgInput := geminicli.OAuthConfig{
ClientID: c.cfg.Gemini.OAuth.ClientID,
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
Scopes: c.cfg.Gemini.OAuth.Scopes,
}
if oauthType == "code_assist" {
if oauthType == "code_assist" || oauthType == "google_one" {
oauthCfgInput.ClientID = ""
oauthCfgInput.ClientSecret = ""
}
@@ -77,7 +78,7 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
Scopes: c.cfg.Gemini.OAuth.Scopes,
}
if oauthType == "code_assist" {
if oauthType == "code_assist" || oauthType == "google_one" {
oauthCfgInput.ClientID = ""
oauthCfgInput.ClientSecret = ""
}

View File

@@ -307,6 +307,74 @@ func (s *AntigravityGatewayService) unwrapV1InternalResponse(body []byte) ([]byt
return body, nil
}
// isSignatureRelatedError 检测是否为 signature 相关的 400 错误
func isSignatureRelatedError(statusCode int, body []byte) bool {
if statusCode != 400 {
return false
}
bodyStr := strings.ToLower(string(body))
keywords := []string{
"signature",
"thought_signature",
"thoughtsignature",
"thinking",
"invalid signature",
"signature validation",
}
for _, keyword := range keywords {
if strings.Contains(bodyStr, keyword) {
return true
}
}
return false
}
// stripThinkingFromClaudeRequest 从 Claude 请求中移除所有 thinking 相关内容
func stripThinkingFromClaudeRequest(req *antigravity.ClaudeRequest) *antigravity.ClaudeRequest {
// 创建副本
stripped := *req
// 移除 thinking 配置
stripped.Thinking = nil
// 移除消息中的 thinking 块
if len(stripped.Messages) > 0 {
newMessages := make([]antigravity.ClaudeMessage, 0, len(stripped.Messages))
for _, msg := range stripped.Messages {
newMsg := msg
// 如果 content 是数组,过滤 thinking 块
var blocks []map[string]any
if err := json.Unmarshal(msg.Content, &blocks); err == nil {
filtered := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
// 跳过有 type="thinking" 的块
if blockType, ok := block["type"].(string); ok && blockType == "thinking" {
continue
}
// 跳过没有 type 但有 thinking 字段的块untyped thinking blocks
if _, hasType := block["type"]; !hasType {
if _, hasThinking := block["thinking"]; hasThinking {
continue
}
}
filtered = append(filtered, block)
}
if newContent, err := json.Marshal(filtered); err == nil {
newMsg.Content = newContent
}
}
newMessages = append(newMessages, newMsg)
}
stripped.Messages = newMessages
}
return &stripped
}
// Forward 转发 Claude 协议请求Claude → Gemini 转换)
func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
startTime := time.Now()
@@ -414,11 +482,70 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
if s.shouldFailoverUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
// Auto 模式:检测 signature 错误并自动降级重试
if isSignatureRelatedError(resp.StatusCode, respBody) && claudeReq.Thinking != nil {
log.Printf("[Antigravity] Detected signature-related error, retrying without thinking blocks (account: %s, model: %s)", account.Name, mappedModel)
// 关闭原始响应释放连接respBody 已读取到内存)
_ = resp.Body.Close()
// 移除 thinking 块并重试一次
strippedReq := stripThinkingFromClaudeRequest(&claudeReq)
strippedBody, err := antigravity.TransformClaudeToGemini(strippedReq, projectID, mappedModel)
if err != nil {
log.Printf("[Antigravity] Failed to transform stripped request: %v", err)
// 降级失败,返回原始错误
if s.shouldFailoverUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody)
}
// 发送降级请求
retryReq, err := antigravity.NewAPIRequest(ctx, action, accessToken, strippedBody)
if err != nil {
log.Printf("[Antigravity] Failed to create retry request: %v", err)
if s.shouldFailoverUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody)
}
retryResp, err := s.httpUpstream.Do(retryReq, proxyURL, account.ID, account.Concurrency)
if err != nil {
log.Printf("[Antigravity] Retry request failed: %v", err)
if s.shouldFailoverUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody)
}
// 如果重试成功,使用重试的响应(不要 return让后面的代码处理响应
if retryResp.StatusCode < 400 {
log.Printf("[Antigravity] Retry succeeded after stripping thinking blocks (account: %s, model: %s)", account.Name, mappedModel)
resp = retryResp
} else {
// 重试也失败,返回重试的错误
retryRespBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 2<<20))
_ = retryResp.Body.Close()
log.Printf("[Antigravity] Retry also failed with status %d: %s", retryResp.StatusCode, string(retryRespBody))
s.handleUpstreamError(ctx, account, retryResp.StatusCode, retryResp.Header, retryRespBody)
if s.shouldFailoverUpstreamError(retryResp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: retryResp.StatusCode}
}
return nil, s.writeMappedClaudeError(c, retryResp.StatusCode, retryRespBody)
}
}
return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody)
// 不是 signature 错误,或者已经没有 thinking 块,直接返回错误
if resp.StatusCode >= 400 {
if s.shouldFailoverUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody)
}
}
requestID := resp.Header.Get("x-request-id")

View File

@@ -1,6 +1,7 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
)
@@ -70,3 +71,85 @@ func ParseGatewayRequest(body []byte) (*ParsedRequest, error) {
return parsed, nil
}
// FilterThinkingBlocks removes thinking blocks from request body
// Returns filtered body or original body if filtering fails (fail-safe)
// This prevents 400 errors from invalid thinking block signatures
func FilterThinkingBlocks(body []byte) []byte {
// Fast path: if body doesn't contain "thinking", skip parsing
if !bytes.Contains(body, []byte("thinking")) {
return body
}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
return body // Return original on parse error
}
messages, ok := req["messages"].([]any)
if !ok {
return body // No messages array
}
filtered := false
for _, msg := range messages {
msgMap, ok := msg.(map[string]any)
if !ok {
continue
}
content, ok := msgMap["content"].([]any)
if !ok {
continue
}
// Filter thinking blocks from content array
newContent := make([]any, 0, len(content))
filteredThisMessage := false
for _, block := range content {
blockMap, ok := block.(map[string]any)
if !ok {
newContent = append(newContent, block)
continue
}
blockType, _ := blockMap["type"].(string)
// Explicit Anthropic-style thinking block: {"type":"thinking", ...}
if blockType == "thinking" {
filtered = true
filteredThisMessage = true
continue // Skip thinking blocks
}
// Some clients send the "thinking" object without a "type" discriminator.
// Vertex/Claude still expects a signature for any thinking block, so we drop it.
// We intentionally do not drop other typed blocks (e.g. tool_use) that might
// legitimately contain a "thinking" key inside their payload.
if blockType == "" {
if _, hasThinking := blockMap["thinking"]; hasThinking {
filtered = true
filteredThisMessage = true
continue // Skip thinking blocks
}
}
newContent = append(newContent, block)
}
if filteredThisMessage {
msgMap["content"] = newContent
}
}
if !filtered {
return body // No changes needed
}
// Re-serialize
newBody, err := json.Marshal(req)
if err != nil {
return body // Return original on marshal error
}
return newBody
}

View File

@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
@@ -38,3 +39,115 @@ func TestParseGatewayRequest_InvalidStreamType(t *testing.T) {
_, err := ParseGatewayRequest(body)
require.Error(t, err)
}
func TestFilterThinkingBlocks(t *testing.T) {
containsThinkingBlock := func(body []byte) bool {
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
return false
}
messages, ok := req["messages"].([]any)
if !ok {
return false
}
for _, msg := range messages {
msgMap, ok := msg.(map[string]any)
if !ok {
continue
}
content, ok := msgMap["content"].([]any)
if !ok {
continue
}
for _, block := range content {
blockMap, ok := block.(map[string]any)
if !ok {
continue
}
blockType, _ := blockMap["type"].(string)
if blockType == "thinking" {
return true
}
if blockType == "" {
if _, hasThinking := blockMap["thinking"]; hasThinking {
return true
}
}
}
}
return false
}
tests := []struct {
name string
input string
shouldFilter bool
expectError bool
}{
{
name: "filters thinking blocks",
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"},{"type":"thinking","thinking":"internal","signature":"invalid"},{"type":"text","text":"World"}]}]}`,
shouldFilter: true,
},
{
name: "handles no thinking blocks",
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}`,
shouldFilter: false,
},
{
name: "handles invalid JSON gracefully",
input: `{invalid json`,
shouldFilter: false,
expectError: true,
},
{
name: "handles multiple messages with thinking blocks",
input: `{"messages":[{"role":"user","content":[{"type":"text","text":"A"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"think"},{"type":"text","text":"B"}]}]}`,
shouldFilter: true,
},
{
name: "filters thinking blocks without type discriminator",
input: `{"messages":[{"role":"assistant","content":[{"thinking":{"text":"internal"}},{"type":"text","text":"B"}]}]}`,
shouldFilter: true,
},
{
name: "does not filter tool_use input fields named thinking",
input: `{"messages":[{"role":"user","content":[{"type":"tool_use","id":"t1","name":"foo","input":{"thinking":"keepme","x":1}},{"type":"text","text":"Hello"}]}]}`,
shouldFilter: false,
},
{
name: "handles empty messages array",
input: `{"messages":[]}`,
shouldFilter: false,
},
{
name: "handles missing messages field",
input: `{"model":"claude-3"}`,
shouldFilter: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FilterThinkingBlocks([]byte(tt.input))
if tt.expectError {
// For invalid JSON, should return original
require.Equal(t, tt.input, string(result))
return
}
if tt.shouldFilter {
require.False(t, containsThinkingBlock(result))
} else {
// Ensure we don't rewrite JSON when no filtering is needed.
require.Equal(t, tt.input, string(result))
}
// Verify valid JSON returned (unless input was invalid)
var parsed map[string]any
err := json.Unmarshal(result, &parsed)
require.NoError(t, err)
})
}
}

View File

@@ -1136,6 +1136,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
// Filter thinking blocks from request body (prevents 400 errors from missing/invalid signatures).
// We apply this for the main /v1/messages path as well as count_tokens.
body = FilterThinkingBlocks(body)
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
@@ -1862,6 +1866,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
// Filter thinking blocks from request body (prevents 400 errors from invalid signatures)
body = FilterThinkingBlocks(body)
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err

View File

@@ -1,21 +0,0 @@
# =============================================================================
# Docker Compose Override for Local Development
# =============================================================================
# This file automatically extends docker-compose-test.yml
# Usage: docker-compose -f docker-compose-test.yml up -d
# =============================================================================
services:
# ===========================================================================
# PostgreSQL - 暴露端口用于本地开发
# ===========================================================================
postgres:
ports:
- "127.0.0.1:5432:5432"
# ===========================================================================
# Redis - 暴露端口用于本地开发
# ===========================================================================
redis:
ports:
- "127.0.0.1:6379:6379"

View File

@@ -0,0 +1,137 @@
# =============================================================================
# Docker Compose Override Configuration Example
# =============================================================================
# This file provides examples for customizing the Docker Compose setup.
# Copy this file to docker-compose.override.yml and modify as needed.
#
# Usage:
# cp docker-compose.override.yml.example docker-compose.override.yml
# # Edit docker-compose.override.yml with your settings
# docker-compose up -d
#
# IMPORTANT: docker-compose.override.yml is gitignored and will not be committed.
# =============================================================================
# =============================================================================
# Scenario 1: Use External Database and Redis (Recommended for Production)
# =============================================================================
# Use this when you have PostgreSQL and Redis running on the host machine
# or on separate servers.
#
# Prerequisites:
# - PostgreSQL running on host (accessible via host.docker.internal)
# - Redis running on host (accessible via host.docker.internal)
# - Update DATABASE_PORT and REDIS_PORT in .env file if using non-standard ports
#
# Security Notes:
# - Ensure PostgreSQL pg_hba.conf allows connections from Docker network
# - Use strong passwords for database and Redis
# - Consider using SSL/TLS for database connections in production
# =============================================================================
services:
sub2api:
# Remove dependencies on containerized postgres/redis
depends_on: []
# Enable access to host machine services
extra_hosts:
- "host.docker.internal:host-gateway"
# Override database and Redis connection settings
environment:
# PostgreSQL Configuration
DATABASE_HOST: host.docker.internal
DATABASE_PORT: "5678" # Change to your PostgreSQL port
# DATABASE_USER: postgres # Uncomment to override
# DATABASE_PASSWORD: your_password # Uncomment to override
# DATABASE_DBNAME: sub2api # Uncomment to override
# Redis Configuration
REDIS_HOST: host.docker.internal
REDIS_PORT: "6379" # Change to your Redis port
# REDIS_PASSWORD: your_redis_password # Uncomment if Redis requires auth
# REDIS_DB: 0 # Uncomment to override
# Disable containerized PostgreSQL
postgres:
deploy:
replicas: 0
scale: 0
# Disable containerized Redis
redis:
deploy:
replicas: 0
scale: 0
# =============================================================================
# Scenario 2: Development with Local Services (Alternative)
# =============================================================================
# Uncomment this section if you want to use the containerized postgres/redis
# but expose their ports for local development tools.
#
# Usage: Comment out Scenario 1 above and uncomment this section.
# =============================================================================
# services:
# sub2api:
# # Keep default dependencies
# pass
#
# postgres:
# ports:
# - "127.0.0.1:5432:5432" # Expose PostgreSQL on localhost
#
# redis:
# ports:
# - "127.0.0.1:6379:6379" # Expose Redis on localhost
# =============================================================================
# Scenario 3: Custom Network Configuration
# =============================================================================
# Uncomment if you need to connect to an existing Docker network
# =============================================================================
# networks:
# default:
# external: true
# name: your-existing-network
# =============================================================================
# Scenario 4: Resource Limits (Production)
# =============================================================================
# Uncomment to set resource limits for the sub2api container
# =============================================================================
# services:
# sub2api:
# deploy:
# resources:
# limits:
# cpus: '2.0'
# memory: 2G
# reservations:
# cpus: '1.0'
# memory: 1G
# =============================================================================
# Scenario 5: Custom Volumes
# =============================================================================
# Uncomment to mount additional volumes (e.g., for logs, backups)
# =============================================================================
# services:
# sub2api:
# volumes:
# - ./logs:/app/logs
# - ./backups:/app/backups
# =============================================================================
# Additional Notes
# =============================================================================
# - This file overrides settings in docker-compose.yml
# - Environment variables in .env file take precedence
# - For more information, see: https://docs.docker.com/compose/extends/
# - Check the main README.md for detailed configuration instructions
# =============================================================================