vibe-proxy/backend/internal/signature/claude_messages_sanitize.go
2026-08-24 00:10:41 +02:00

280 lines
9.2 KiB
Go

package signature
import (
"fmt"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type ClaudeMessagesSignatureSanitizeOptions struct {
TargetProvider SignatureProvider
TargetModel string
DropEmptyMessages bool
DropToolSignatures bool
DropEmptyThinkingPlaceholders bool
// PreserveEmptyThinkingBlocks preserves compatibility-mode thinking blocks
// together with their original signatures, including opaque signatures.
PreserveEmptyThinkingBlocks bool
}
type SignatureSanitizeReport struct {
TargetProvider SignatureProvider
Preserved int
DroppedBlocks int
DroppedSignatures int
ReplacedSignatures int
Decisions []SignatureCompatibilityDecision
}
// SanitizeClaudeMessagesSignaturesForModel removes or preserves Claude
// /v1/messages signed history according to the provider family implied by
// targetModel.
func SanitizeClaudeMessagesSignaturesForModel(payload []byte, targetModel string) ([]byte, SignatureSanitizeReport) {
return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{
TargetProvider: SignatureProviderFromModelName(targetModel),
TargetModel: targetModel,
DropEmptyMessages: true,
})
}
// SanitizeClaudeMessagesForClaudeUpstream prepares a Claude /v1/messages body
// for Claude-compatible upstreams. Valid Claude signatures are normalized to
// provider-native E-form, valid Claude CAIS signatures are kept,
// incompatible thinking blocks are dropped, and tool_use blocks keep only their
// tool-call payload.
func SanitizeClaudeMessagesForClaudeUpstream(payload []byte, targetModel string, preserveEmptyThinkingBlocks ...bool) ([]byte, SignatureSanitizeReport) {
preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0]
return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{
TargetProvider: SignatureProviderClaude,
TargetModel: targetModel,
DropEmptyMessages: true,
DropToolSignatures: true,
DropEmptyThinkingPlaceholders: !preserveEmpty,
PreserveEmptyThinkingBlocks: preserveEmpty,
})
}
// SanitizeClaudeMessagesSignaturesForTarget applies provider-aware signature
// compatibility rules to Claude /v1/messages history. Compatible thinking
// signatures are preserved. Incompatible thinking blocks are removed so a user
// can continue a conversation after switching between Claude, GPT/Codex,
// and Gemini models.
func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessagesSignatureSanitizeOptions) ([]byte, SignatureSanitizeReport) {
targetProvider := normalizeSignatureTargetProvider(opts.TargetProvider)
if targetProvider == SignatureProviderUnknown && opts.TargetModel != "" {
targetProvider = SignatureProviderFromModelName(opts.TargetModel)
}
report := SignatureSanitizeReport{TargetProvider: targetProvider}
messages := gjson.GetBytes(payload, "messages")
if !messages.IsArray() {
return payload, report
}
messageResults := messages.Array()
keptMessages := make([]string, 0, len(messageResults))
modified := false
for i, message := range messageResults {
content := message.Get("content")
if !content.IsArray() {
keptMessages = append(keptMessages, message.Raw)
continue
}
contentResults := content.Array()
keptParts := make([]string, 0, len(contentResults))
messageModified := false
for j, part := range contentResults {
partType := part.Get("type").String()
if partType == "tool_use" {
if opts.DropToolSignatures {
updatedPart, changed := stripClaudeToolUseSignatureFields(part)
if changed {
messageModified = true
report.DroppedSignatures++
}
keptParts = append(keptParts, updatedPart)
continue
}
updatedPart, changed, decisions := sanitizeClaudeToolUseSignature(part, targetProvider, opts.TargetModel, i, j)
report.Decisions = append(report.Decisions, decisions...)
if changed {
messageModified = true
}
for _, decision := range decisions {
switch decision.Action {
case SignatureActionPreserve:
report.Preserved++
case SignatureActionReplaceWithGeminiBypass:
report.ReplacedSignatures++
default:
report.DroppedSignatures++
}
}
keptParts = append(keptParts, updatedPart)
continue
}
if partType != "thinking" {
keptParts = append(keptParts, part.Raw)
continue
}
rawSignature := part.Get("signature").String()
if opts.PreserveEmptyThinkingBlocks {
report.Preserved++
keptParts = append(keptParts, part.Raw)
continue
}
if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) && !opts.DropEmptyThinkingPlaceholders {
keptParts = append(keptParts, part.Raw)
continue
}
decision := DecideSignatureCompatibilityForModel(targetProvider, opts.TargetModel, rawSignature, SignatureBlockKindClaudeThinking)
decision.Reason = fmt.Sprintf("messages[%d].content[%d]: %s", i, j, decision.Reason)
report.Decisions = append(report.Decisions, decision)
switch decision.Action {
case SignatureActionPreserve:
report.Preserved++
if decision.NormalizedSignature != "" && decision.NormalizedSignature != rawSignature {
updated, _ := sjson.Set(part.Raw, "signature", decision.NormalizedSignature)
keptParts = append(keptParts, updated)
messageModified = true
continue
}
keptParts = append(keptParts, part.Raw)
case SignatureActionReplaceWithGeminiBypass:
report.ReplacedSignatures++
updated, _ := sjson.Set(part.Raw, "signature", decision.ReplacementSignature)
keptParts = append(keptParts, updated)
messageModified = true
case SignatureActionDropSignature:
report.DroppedSignatures++
updated, _ := sjson.Delete(part.Raw, "signature")
keptParts = append(keptParts, updated)
messageModified = true
default:
report.DroppedBlocks++
messageModified = true
}
}
if messageModified {
modified = true
if len(keptParts) == 0 && opts.DropEmptyMessages {
continue
}
updated, _ := sjson.SetRaw(message.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
keptMessages = append(keptMessages, updated)
continue
}
keptMessages = append(keptMessages, message.Raw)
}
if !modified {
return payload, report
}
output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
return output, report
}
func stripClaudeToolUseSignatureFields(part gjson.Result) (string, bool) {
updated := part.Raw
changed := false
for _, sigPath := range claudeToolUseProvenancePaths() {
if !gjson.Get(updated, sigPath).Exists() {
continue
}
updated, _ = sjson.Delete(updated, sigPath)
changed = true
}
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
updated = cleaned
changed = true
}
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
updated = cleaned
changed = true
}
return updated, changed
}
func sanitizeClaudeToolUseSignature(part gjson.Result, targetProvider SignatureProvider, targetModel string, messageIdx, partIdx int) (string, bool, []SignatureCompatibilityDecision) {
updated := part.Raw
changed := false
var decisions []SignatureCompatibilityDecision
for _, sigPath := range claudeToolUseSignaturePaths() {
sigResult := part.Get(sigPath)
if !sigResult.Exists() {
continue
}
blockKind := SignatureBlockKindGeminiFunctionCall
if targetProvider == SignatureProviderClaude {
blockKind = SignatureBlockKindClaudeThinking
} else if targetProvider == SignatureProviderGPT {
blockKind = SignatureBlockKindGPTReasoning
}
decision := DecideSignatureCompatibilityForModel(targetProvider, targetModel, sigResult.String(), blockKind)
decision.Reason = fmt.Sprintf("messages[%d].content[%d].%s: %s", messageIdx, partIdx, sigPath, decision.Reason)
decisions = append(decisions, decision)
switch decision.Action {
case SignatureActionPreserve:
if decision.NormalizedSignature != "" && decision.NormalizedSignature != sigResult.String() {
updated, _ = sjson.Set(updated, sigPath, decision.NormalizedSignature)
changed = true
}
case SignatureActionReplaceWithGeminiBypass:
updated, _ = sjson.Set(updated, sigPath, decision.ReplacementSignature)
changed = true
default:
updated, _ = sjson.Delete(updated, sigPath)
changed = true
}
}
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
updated = cleaned
changed = true
}
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
updated = cleaned
changed = true
}
return updated, changed, decisions
}
func claudeToolUseSignaturePaths() []string {
return []string{
"signature",
"thoughtSignature",
"thought_signature",
"extra_content.google.thought_signature",
}
}
func claudeToolUseProvenancePaths() []string {
return append(claudeToolUseSignaturePaths(), "model")
}
func deleteEmptyJSONObjectPath(raw, path string) (string, bool) {
result := gjson.Get(raw, path)
if !result.Exists() || !result.IsObject() || len(result.Map()) != 0 {
return raw, false
}
updated, err := sjson.Delete(raw, path)
if err != nil {
return raw, false
}
return updated, true
}