Skip to content

Commit b3d6d5d

Browse files
committed
refactor: extract signature validation
1 parent 2bcc762 commit b3d6d5d

11 files changed

Lines changed: 2561 additions & 421 deletions

internal/signature/claude.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package signature
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
7+
"github.com/tidwall/gjson"
8+
"github.com/tidwall/sjson"
9+
)
10+
11+
// StripInvalidClaudeThinkingBlocks removes Claude thinking blocks whose
12+
// signatures are empty or not valid Claude thinking signatures after stripping
13+
// an optional cache prefix, unless the validation options allow an empty
14+
// thinking placeholder.
15+
func StripInvalidClaudeThinkingBlocks(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
16+
messages := gjson.GetBytes(payload, "messages")
17+
if !messages.IsArray() {
18+
return payload
19+
}
20+
opt := claudeSignatureValidationOptions(opts)
21+
messageResults := messages.Array()
22+
keptMessages := make([]string, 0, len(messageResults))
23+
modified := false
24+
for _, msg := range messageResults {
25+
content := msg.Get("content")
26+
if !content.IsArray() {
27+
keptMessages = append(keptMessages, msg.Raw)
28+
continue
29+
}
30+
contentResults := content.Array()
31+
keptParts := make([]string, 0, len(contentResults))
32+
stripped := false
33+
for _, part := range contentResults {
34+
if part.Get("type").String() == "thinking" && shouldStripClaudeThinkingBlock(part, opt) {
35+
stripped = true
36+
continue
37+
}
38+
keptParts = append(keptParts, part.Raw)
39+
}
40+
if stripped {
41+
modified = true
42+
updated, _ := sjson.SetRaw(msg.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
43+
keptMessages = append(keptMessages, updated)
44+
continue
45+
}
46+
keptMessages = append(keptMessages, msg.Raw)
47+
}
48+
if !modified {
49+
return payload
50+
}
51+
output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
52+
return output
53+
}
54+
55+
// StripInvalidClaudeThinkingBlocksAndEmptyMessages also removes messages whose
56+
// content becomes empty after invalid thinking blocks are removed.
57+
func StripInvalidClaudeThinkingBlocksAndEmptyMessages(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
58+
stripped := StripInvalidClaudeThinkingBlocks(payload, opts...)
59+
if bytes.Equal(stripped, payload) {
60+
return payload
61+
}
62+
messages := gjson.GetBytes(stripped, "messages")
63+
if !messages.IsArray() {
64+
return stripped
65+
}
66+
kept := make([]string, 0, len(messages.Array()))
67+
for _, message := range messages.Array() {
68+
content := message.Get("content")
69+
if content.IsArray() && len(content.Array()) == 0 {
70+
continue
71+
}
72+
kept = append(kept, message.Raw)
73+
}
74+
stripped, _ = sjson.SetRawBytes(stripped, "messages", []byte("["+strings.Join(kept, ",")+"]"))
75+
return stripped
76+
}
77+
78+
func shouldStripClaudeThinkingBlock(part gjson.Result, opt ClaudeSignatureValidationOptions) bool {
79+
if opt.AllowEmptySignatureWithEmptyText && isEmptyClaudeThinkingPlaceholder(part) {
80+
return false
81+
}
82+
return !IsValidClaudeThinkingSignature(part.Get("signature").String(), opt)
83+
}
84+
85+
func isEmptyClaudeThinkingPlaceholder(part gjson.Result) bool {
86+
if strings.TrimSpace(part.Get("signature").String()) != "" {
87+
return false
88+
}
89+
return strings.TrimSpace(claudeThinkingBlockText(part)) == ""
90+
}
91+
92+
func claudeThinkingBlockText(part gjson.Result) string {
93+
if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
94+
return text.String()
95+
}
96+
97+
thinkingField := part.Get("thinking")
98+
if !thinkingField.Exists() {
99+
return ""
100+
}
101+
if thinkingField.Type == gjson.String {
102+
return thinkingField.String()
103+
}
104+
if thinkingField.IsObject() {
105+
if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
106+
return inner.String()
107+
}
108+
if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
109+
return inner.String()
110+
}
111+
}
112+
return ""
113+
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package signature
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/tidwall/gjson"
8+
"github.com/tidwall/sjson"
9+
)
10+
11+
type ClaudeMessagesSignatureSanitizeOptions struct {
12+
TargetProvider SignatureProvider
13+
TargetModel string
14+
DropEmptyMessages bool
15+
DropToolSignatures bool
16+
}
17+
18+
type SignatureSanitizeReport struct {
19+
TargetProvider SignatureProvider
20+
Preserved int
21+
DroppedBlocks int
22+
DroppedSignatures int
23+
ReplacedSignatures int
24+
Decisions []SignatureCompatibilityDecision
25+
}
26+
27+
// SanitizeClaudeMessagesSignaturesForModel removes or preserves Claude
28+
// /v1/messages signed history according to the provider family implied by
29+
// targetModel.
30+
func SanitizeClaudeMessagesSignaturesForModel(payload []byte, targetModel string) ([]byte, SignatureSanitizeReport) {
31+
return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{
32+
TargetProvider: SignatureProviderFromModelName(targetModel),
33+
TargetModel: targetModel,
34+
DropEmptyMessages: true,
35+
})
36+
}
37+
38+
// SanitizeClaudeMessagesSignaturesForTarget applies provider-aware signature
39+
// compatibility rules to Claude /v1/messages history. Compatible thinking
40+
// signatures are preserved. Incompatible thinking blocks are removed so a user
41+
// can continue a conversation after switching between Claude, GPT/Codex,
42+
// and Gemini models.
43+
func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessagesSignatureSanitizeOptions) ([]byte, SignatureSanitizeReport) {
44+
targetProvider := normalizeSignatureTargetProvider(opts.TargetProvider)
45+
if targetProvider == SignatureProviderUnknown && opts.TargetModel != "" {
46+
targetProvider = SignatureProviderFromModelName(opts.TargetModel)
47+
}
48+
report := SignatureSanitizeReport{TargetProvider: targetProvider}
49+
50+
messages := gjson.GetBytes(payload, "messages")
51+
if !messages.IsArray() {
52+
return payload, report
53+
}
54+
55+
messageResults := messages.Array()
56+
keptMessages := make([]string, 0, len(messageResults))
57+
modified := false
58+
59+
for i, message := range messageResults {
60+
content := message.Get("content")
61+
if !content.IsArray() {
62+
keptMessages = append(keptMessages, message.Raw)
63+
continue
64+
}
65+
66+
contentResults := content.Array()
67+
keptParts := make([]string, 0, len(contentResults))
68+
messageModified := false
69+
70+
for j, part := range contentResults {
71+
partType := part.Get("type").String()
72+
if partType == "tool_use" {
73+
if opts.DropToolSignatures {
74+
updatedPart, changed := stripClaudeToolUseSignatureFields(part)
75+
if changed {
76+
messageModified = true
77+
report.DroppedSignatures++
78+
}
79+
keptParts = append(keptParts, updatedPart)
80+
continue
81+
}
82+
updatedPart, changed, decisions := sanitizeClaudeToolUseSignature(part, targetProvider, i, j)
83+
report.Decisions = append(report.Decisions, decisions...)
84+
if changed {
85+
messageModified = true
86+
}
87+
for _, decision := range decisions {
88+
switch decision.Action {
89+
case SignatureActionPreserve:
90+
report.Preserved++
91+
case SignatureActionReplaceWithGeminiBypass:
92+
report.ReplacedSignatures++
93+
default:
94+
report.DroppedSignatures++
95+
}
96+
}
97+
keptParts = append(keptParts, updatedPart)
98+
continue
99+
}
100+
101+
if partType != "thinking" {
102+
keptParts = append(keptParts, part.Raw)
103+
continue
104+
}
105+
106+
if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) {
107+
keptParts = append(keptParts, part.Raw)
108+
continue
109+
}
110+
111+
rawSignature := part.Get("signature").String()
112+
decision := DecideSignatureCompatibility(targetProvider, rawSignature, SignatureBlockKindClaudeThinking)
113+
decision.Reason = fmt.Sprintf("messages[%d].content[%d]: %s", i, j, decision.Reason)
114+
report.Decisions = append(report.Decisions, decision)
115+
116+
switch decision.Action {
117+
case SignatureActionPreserve:
118+
report.Preserved++
119+
if decision.NormalizedSignature != "" && decision.NormalizedSignature != rawSignature {
120+
updated, _ := sjson.Set(part.Raw, "signature", decision.NormalizedSignature)
121+
keptParts = append(keptParts, updated)
122+
messageModified = true
123+
continue
124+
}
125+
keptParts = append(keptParts, part.Raw)
126+
case SignatureActionReplaceWithGeminiBypass:
127+
report.ReplacedSignatures++
128+
updated, _ := sjson.Set(part.Raw, "signature", decision.ReplacementSignature)
129+
keptParts = append(keptParts, updated)
130+
messageModified = true
131+
case SignatureActionDropSignature:
132+
report.DroppedSignatures++
133+
updated, _ := sjson.Delete(part.Raw, "signature")
134+
keptParts = append(keptParts, updated)
135+
messageModified = true
136+
default:
137+
report.DroppedBlocks++
138+
messageModified = true
139+
}
140+
}
141+
142+
if messageModified {
143+
modified = true
144+
if len(keptParts) == 0 && opts.DropEmptyMessages {
145+
continue
146+
}
147+
updated, _ := sjson.SetRaw(message.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
148+
keptMessages = append(keptMessages, updated)
149+
continue
150+
}
151+
152+
keptMessages = append(keptMessages, message.Raw)
153+
}
154+
155+
if !modified {
156+
return payload, report
157+
}
158+
output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
159+
return output, report
160+
}
161+
162+
func stripClaudeToolUseSignatureFields(part gjson.Result) (string, bool) {
163+
updated := part.Raw
164+
changed := false
165+
for _, sigPath := range claudeToolUseSignaturePaths() {
166+
if !gjson.Get(updated, sigPath).Exists() {
167+
continue
168+
}
169+
updated, _ = sjson.Delete(updated, sigPath)
170+
changed = true
171+
}
172+
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
173+
updated = cleaned
174+
changed = true
175+
}
176+
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
177+
updated = cleaned
178+
changed = true
179+
}
180+
return updated, changed
181+
}
182+
183+
func sanitizeClaudeToolUseSignature(part gjson.Result, targetProvider SignatureProvider, messageIdx, partIdx int) (string, bool, []SignatureCompatibilityDecision) {
184+
updated := part.Raw
185+
changed := false
186+
var decisions []SignatureCompatibilityDecision
187+
188+
for _, sigPath := range claudeToolUseSignaturePaths() {
189+
sigResult := part.Get(sigPath)
190+
if !sigResult.Exists() {
191+
continue
192+
}
193+
194+
blockKind := SignatureBlockKindGeminiFunctionCall
195+
if targetProvider == SignatureProviderClaude {
196+
blockKind = SignatureBlockKindClaudeThinking
197+
} else if targetProvider == SignatureProviderGPT {
198+
blockKind = SignatureBlockKindGPTReasoning
199+
}
200+
decision := DecideSignatureCompatibility(targetProvider, sigResult.String(), blockKind)
201+
decision.Reason = fmt.Sprintf("messages[%d].content[%d].%s: %s", messageIdx, partIdx, sigPath, decision.Reason)
202+
decisions = append(decisions, decision)
203+
204+
switch decision.Action {
205+
case SignatureActionPreserve:
206+
if decision.NormalizedSignature != "" && decision.NormalizedSignature != sigResult.String() {
207+
updated, _ = sjson.Set(updated, sigPath, decision.NormalizedSignature)
208+
changed = true
209+
}
210+
case SignatureActionReplaceWithGeminiBypass:
211+
updated, _ = sjson.Set(updated, sigPath, decision.ReplacementSignature)
212+
changed = true
213+
default:
214+
updated, _ = sjson.Delete(updated, sigPath)
215+
changed = true
216+
}
217+
}
218+
219+
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
220+
updated = cleaned
221+
changed = true
222+
}
223+
if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
224+
updated = cleaned
225+
changed = true
226+
}
227+
228+
return updated, changed, decisions
229+
}
230+
231+
func claudeToolUseSignaturePaths() []string {
232+
return []string{
233+
"signature",
234+
"thought_signature",
235+
"extra_content.google.thought_signature",
236+
}
237+
}
238+
239+
func deleteEmptyJSONObjectPath(raw, path string) (string, bool) {
240+
result := gjson.Get(raw, path)
241+
if !result.Exists() || !result.IsObject() || len(result.Map()) != 0 {
242+
return raw, false
243+
}
244+
updated, err := sjson.Delete(raw, path)
245+
if err != nil {
246+
return raw, false
247+
}
248+
return updated, true
249+
}

0 commit comments

Comments
 (0)