fix(backend): 改进 thinking/tool block 签名处理和重试策略
主要改动: - request_transformer: thinking block 缺少签名时降级为文本而非丢弃,保留内容并在上层禁用 thinking mode - antigravity_gateway_service: 新增两阶段降级策略,先处理 thinking blocks,如仍失败且涉及 tool 签名错误则进一步降级 tool blocks - gateway_request: 新增 FilterSignatureSensitiveBlocksForRetry 函数,支持将 tool_use/tool_result 降级为文本 - gateway_request: 改进 FilterThinkingBlocksForRetry,禁用顶层 thinking 配置以避免结构约束冲突 - gateway_service: 实现保守的两阶段重试逻辑,优先保留内容,仅在必要时降级工具调用 - 新增 antigravity_gateway_service_test.go 测试签名块剥离逻辑 - 更新相关测试用例以验证降级行为 此修复解决了跨平台/账户切换时历史消息签名失效导致的请求失败问题。
This commit is contained in:
@@ -22,7 +22,7 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
|
||||
allowDummyThought := strings.HasPrefix(mappedModel, "gemini-")
|
||||
|
||||
// 1. 构建 contents
|
||||
contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
|
||||
contents, strippedThinking, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build contents: %w", err)
|
||||
}
|
||||
@@ -31,7 +31,15 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
|
||||
systemInstruction := buildSystemInstruction(claudeReq.System, claudeReq.Model)
|
||||
|
||||
// 3. 构建 generationConfig
|
||||
generationConfig := buildGenerationConfig(claudeReq)
|
||||
reqForConfig := claudeReq
|
||||
if strippedThinking {
|
||||
// If we had to downgrade thinking blocks to plain text due to missing/invalid signatures,
|
||||
// disable upstream thinking mode to avoid signature/structure validation errors.
|
||||
reqCopy := *claudeReq
|
||||
reqCopy.Thinking = nil
|
||||
reqForConfig = &reqCopy
|
||||
}
|
||||
generationConfig := buildGenerationConfig(reqForConfig)
|
||||
|
||||
// 4. 构建 tools
|
||||
tools := buildTools(claudeReq.Tools)
|
||||
@@ -120,8 +128,9 @@ 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, allowDummyThought bool) ([]GeminiContent, bool, error) {
|
||||
var contents []GeminiContent
|
||||
strippedThinking := false
|
||||
|
||||
for i, msg := range messages {
|
||||
role := msg.Role
|
||||
@@ -129,9 +138,12 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
|
||||
role = "model"
|
||||
}
|
||||
|
||||
parts, err := buildParts(msg.Content, toolIDToName, allowDummyThought)
|
||||
parts, strippedThisMsg, err := buildParts(msg.Content, toolIDToName, allowDummyThought)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build parts for message %d: %w", i, err)
|
||||
return nil, false, fmt.Errorf("build parts for message %d: %w", i, err)
|
||||
}
|
||||
if strippedThisMsg {
|
||||
strippedThinking = true
|
||||
}
|
||||
|
||||
// 只有 Gemini 模型支持 dummy thinking block workaround
|
||||
@@ -165,7 +177,7 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
|
||||
})
|
||||
}
|
||||
|
||||
return contents, nil
|
||||
return contents, strippedThinking, nil
|
||||
}
|
||||
|
||||
// dummyThoughtSignature 用于跳过 Gemini 3 thought_signature 验证
|
||||
@@ -174,8 +186,9 @@ const dummyThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// buildParts 构建消息的 parts
|
||||
// allowDummyThought: 只有 Gemini 模型支持 dummy thought signature
|
||||
func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDummyThought bool) ([]GeminiPart, error) {
|
||||
func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDummyThought bool) ([]GeminiPart, bool, error) {
|
||||
var parts []GeminiPart
|
||||
strippedThinking := false
|
||||
|
||||
// 尝试解析为字符串
|
||||
var textContent string
|
||||
@@ -183,13 +196,13 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
||||
if textContent != "(no content)" && strings.TrimSpace(textContent) != "" {
|
||||
parts = append(parts, GeminiPart{Text: strings.TrimSpace(textContent)})
|
||||
}
|
||||
return parts, nil
|
||||
return parts, false, nil
|
||||
}
|
||||
|
||||
// 解析为内容块数组
|
||||
var blocks []ContentBlock
|
||||
if err := json.Unmarshal(content, &blocks); err != nil {
|
||||
return nil, fmt.Errorf("parse content blocks: %w", err)
|
||||
return nil, false, fmt.Errorf("parse content blocks: %w", err)
|
||||
}
|
||||
|
||||
for _, block := range blocks {
|
||||
@@ -208,8 +221,11 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
||||
if block.Signature != "" {
|
||||
part.ThoughtSignature = block.Signature
|
||||
} else if !allowDummyThought {
|
||||
// Claude 模型需要有效 signature,跳过无 signature 的 thinking block
|
||||
log.Printf("Warning: skipping thinking block without signature for Claude model")
|
||||
// Claude 模型需要有效 signature;在缺失时降级为普通文本,并在上层禁用 thinking mode。
|
||||
if strings.TrimSpace(block.Thinking) != "" {
|
||||
parts = append(parts, GeminiPart{Text: block.Thinking})
|
||||
}
|
||||
strippedThinking = true
|
||||
continue
|
||||
} else {
|
||||
// Gemini 模型使用 dummy signature
|
||||
@@ -276,7 +292,7 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
||||
}
|
||||
}
|
||||
|
||||
return parts, nil
|
||||
return parts, strippedThinking, nil
|
||||
}
|
||||
|
||||
// parseToolResultContent 解析 tool_result 的 content
|
||||
|
||||
@@ -15,15 +15,15 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Claude model - drop thinking without signature",
|
||||
name: "Claude model - downgrade thinking to text without signature",
|
||||
content: `[
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
|
||||
{"type": "text", "text": "World"}
|
||||
]`,
|
||||
allowDummyThought: false,
|
||||
expectedParts: 2, // thinking 内容被丢弃
|
||||
description: "Claude模型应丢弃无signature的thinking block内容",
|
||||
expectedParts: 3, // thinking 内容降级为普通 text part
|
||||
description: "Claude模型缺少signature时应将thinking降级为text,并在上层禁用thinking mode",
|
||||
},
|
||||
{
|
||||
name: "Claude model - preserve thinking block with signature",
|
||||
@@ -52,7 +52,7 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||
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.allowDummyThought)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("buildParts() error = %v", err)
|
||||
@@ -71,6 +71,17 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||
t.Fatalf("expected thought part with signature sig_real_123, got thought=%v signature=%q",
|
||||
parts[1].Thought, parts[1].ThoughtSignature)
|
||||
}
|
||||
case "Claude model - downgrade thinking to text without signature":
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
if parts[1].Thought {
|
||||
t.Fatalf("expected downgraded text part, got thought=%v signature=%q",
|
||||
parts[1].Thought, parts[1].ThoughtSignature)
|
||||
}
|
||||
if parts[1].Text != "Let me think..." {
|
||||
t.Fatalf("expected downgraded text %q, got %q", "Let me think...", parts[1].Text)
|
||||
}
|
||||
case "Gemini model - use dummy signature":
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("expected 3 parts, got %d", len(parts))
|
||||
@@ -91,7 +102,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
|
||||
|
||||
t.Run("Gemini uses dummy tool_use signature", func(t *testing.T) {
|
||||
toolIDToName := make(map[string]string)
|
||||
parts, err := buildParts(json.RawMessage(content), toolIDToName, true)
|
||||
parts, _, err := buildParts(json.RawMessage(content), toolIDToName, true)
|
||||
if err != nil {
|
||||
t.Fatalf("buildParts() error = %v", err)
|
||||
}
|
||||
@@ -105,7 +116,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
|
||||
|
||||
t.Run("Claude model - preserve valid signature for tool_use", func(t *testing.T) {
|
||||
toolIDToName := make(map[string]string)
|
||||
parts, err := buildParts(json.RawMessage(content), toolIDToName, false)
|
||||
parts, _, err := buildParts(json.RawMessage(content), toolIDToName, false)
|
||||
if err != nil {
|
||||
t.Fatalf("buildParts() error = %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user