280 lines
9.2 KiB
Go
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
|
|
}
|