Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions internal/runtime/executor/claude_thinking_replay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,10 @@ func TestClaudeExecutorCompatThinkingReplayRestoresOmittedBlock(t *testing.T) {
if got := content[0].Get("type").String(); got != "thinking" {
t.Fatalf("restored first content type = %q, want thinking", got)
}
if got := content[0].Get("signature").String(); got != "EgI=" {
t.Fatalf("restored signature = %q, want EgI=", got)
// Cache-born EgI= is not a Claude envelope. Restore the omitted block;
// sanitizer must clear the signature before the compat upstream sees it.
if got := content[0].Get("signature").String(); got != "" {
t.Fatalf("restored signature = %q, want empty", got)
}
}

Expand Down Expand Up @@ -240,8 +242,8 @@ func TestClaudeExecutorCompatThinkingReplayRestoresOmittedBlockInStream(t *testi
if len(content) != 2 || content[0].Get("type").String() != "thinking" {
t.Fatalf("second streamed assistant content = %s, want restored thinking and tool_use", gjson.GetBytes(requestBodies[1], "messages.1.content").Raw)
}
if got := content[0].Get("signature").String(); got != "EgI=" {
t.Fatalf("restored streamed signature = %q, want EgI=", got)
if got := content[0].Get("signature").String(); got != "" {
t.Fatalf("restored streamed signature = %q, want empty", got)
}
}

Expand Down Expand Up @@ -359,12 +361,18 @@ func TestClaudeExecutorCompatThinkingReplayRestoresMultipleOmittedBlocks(t *test
}
firstContent := gjson.GetBytes(requestBodies[2], "messages.1.content").Array()
secondContent := gjson.GetBytes(requestBodies[2], "messages.3.content").Array()
if len(firstContent) != 2 || firstContent[0].Get("type").String() != "thinking" || firstContent[0].Get("signature").String() != "EgI=" {
if len(firstContent) != 2 || firstContent[0].Get("type").String() != "thinking" {
t.Fatalf("first omitted turn was not restored: %s", gjson.GetBytes(requestBodies[2], "messages.1.content").Raw)
}
if len(secondContent) != 2 || secondContent[0].Get("type").String() != "thinking" || secondContent[0].Get("signature").String() != "EgM=" {
if got := firstContent[0].Get("signature").String(); got != "" {
t.Fatalf("first restored signature = %q, want empty", got)
}
if len(secondContent) != 2 || secondContent[0].Get("type").String() != "thinking" {
t.Fatalf("second omitted turn was not restored: %s", gjson.GetBytes(requestBodies[2], "messages.3.content").Raw)
}
if got := secondContent[0].Get("signature").String(); got != "" {
t.Fatalf("second restored signature = %q, want empty", got)
}
}

func internalcacheClearClaudeThinkingReplay(t *testing.T) {
Expand Down
52 changes: 48 additions & 4 deletions internal/signature/claude_messages_sanitize.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ type ClaudeMessagesSignatureSanitizeOptions struct {
DropEmptyMessages bool
DropToolSignatures bool
DropEmptyThinkingPlaceholders bool
// PreserveEmptyThinkingBlocks preserves compatibility-mode thinking blocks
// together with their original signatures, including opaque signatures.
// PreserveEmptyThinkingBlocks preserves compatibility-mode thinking block
// shape. Signatures still go through DecideSignatureCompatibilityForModel;
// foreign or opaque values are cleared to an empty signature member.
PreserveEmptyThinkingBlocks bool
}

Expand Down Expand Up @@ -124,10 +125,53 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag
continue
}

// Replay provenance is added only internally by the executor after the
// sanitizer has already run. Any client-supplied marker is untrusted and
// must be stripped so it cannot bypass signature validation.
if part.Get("_cliproxy_replay_provenance").Exists() {
updated, _ := sjson.Delete(part.Raw, "_cliproxy_replay_provenance")
part = gjson.Parse(updated)
messageModified = true
}

rawSignature := part.Get("signature").String()
if opts.PreserveEmptyThinkingBlocks {
report.Preserved++
keptParts = append(keptParts, part.Raw)
// Compat mode keeps the block shape. The signature still has to be
// normalized, emulated, or stripped so an incompatible blob is not
// forwarded as a Claude signature.
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
} else {
keptParts = append(keptParts, part.Raw)
}
case SignatureActionReplaceWithGeminiBypass:
report.ReplacedSignatures++
updated, _ := sjson.Set(part.Raw, "signature", decision.ReplacementSignature)
keptParts = append(keptParts, updated)
messageModified = true
default:
// DropBlock, DropSignature, or NoCompatibleReplacement: keep the
// block shape for the compat endpoint and preserve empty placeholders
// with their required signature member.
if isEmptyClaudeThinkingPlaceholder(part) {
report.Preserved++
keptParts = append(keptParts, part.Raw)
} else {
report.DroppedSignatures++
updated, _ := sjson.Set(part.Raw, "signature", "")
keptParts = append(keptParts, updated)
}
messageModified = true
}
continue
}
if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) && !opts.DropEmptyThinkingPlaceholders {
Expand Down
117 changes: 112 additions & 5 deletions internal/signature/claude_messages_sanitize_compat_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package signature

import (
"bytes"
"encoding/base64"
"testing"

"github.com/tidwall/gjson"
Expand All @@ -16,12 +18,99 @@ func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesEmptyThinkingInCompatMo

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer dropped empty thinking: %s", withCompat)
if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() {
t.Fatalf("compat sanitizer dropped empty thinking or its signature member: %s", withCompat)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesOpaqueThinkingSignatureInCompatMode(t *testing.T) {
func TestSanitizeClaudeMessagesForClaudeUpstreamRetainsEmptySignatureOnGeminiPrefixInCompatMode(t *testing.T) {
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"gemini#EgI="}]}]}`)

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer did not retain empty signature member on foreign-prefixed thinking block: %s", withCompat)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamRetainsEmptySignatureOnMislabeledClaudePrefixInCompatMode(t *testing.T) {
geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"claude#` + geminiSig + `"}]}]}`)

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer did not retain empty signature member on mislabeled claude# block: %s", withCompat)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamRetainsEmptySignatureOnNestedClaudePrefixInCompatMode(t *testing.T) {
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"claude#vendor#EgI="}]}]}`)

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer did not retain empty signature member on nested claude# block: %s", withCompat)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamRetainsEmptySignatureOnUnknownVendorPrefixInCompatMode(t *testing.T) {
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"vendor#EgI="}]}]}`)

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer did not retain empty signature member on unknown-vendor block: %s", withCompat)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamNormalizesWhitespacePaddedShortSignatureInCompatMode(t *testing.T) {
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":" EgI= "}]}]}`)

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() {
t.Fatalf("compat sanitizer dropped the thinking block: %s", withCompat)
}
if got := part.Get("signature").String(); got != "" {
t.Fatalf("compat sanitizer forwarded short signature %q, want empty signature", got)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamRejectsGrokOpaqueERInCompatMode(t *testing.T) {
// Grok/xAI encrypted_content is uniformly distributed and can base64-encode
// to a string starting with 'E' or 'R', but it is not a valid Claude
// thinking signature and must be cleared before forwarding.
grokLike := bytes.Repeat([]byte{0x12, 0xff, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33}, 4)
sig := base64.StdEncoding.EncodeToString(grokLike)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + sig + `"}]}]}`)

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer did not clear Grok-style E/R opaque signature: %s", withCompat)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamStripsClientReplayProvenanceMarkerInCompatMode(t *testing.T) {
// Client-supplied _cliproxy_replay_provenance must not bypass signature
// validation. The marker is stripped and the foreign signature is cleared.
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"opaque-foreign-sig","_cliproxy_replay_provenance":true}]}]}`)

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" {
t.Fatalf("compat sanitizer dropped the thinking block: %s", withCompat)
}
if part.Get("_cliproxy_replay_provenance").Exists() {
t.Fatalf("compat sanitizer did not strip client-supplied replay provenance marker: %s", withCompat)
}
if part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer preserved foreign signature via client-supplied marker: %s", withCompat)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamStripsOpaqueThinkingSignatureInCompatMode(t *testing.T) {
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"opaque-deepseek-id"}]}]}`)

withoutCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4")
Expand All @@ -31,7 +120,25 @@ func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesOpaqueThinkingSignature

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || part.Get("signature").String() != "opaque-deepseek-id" {
t.Fatalf("compat sanitizer dropped opaque signature: %s", withCompat)
if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer did not retain empty signature member on opaque-signature block: %s", withCompat)
}
}

func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesValidClaudeSignatureInCompatMode(t *testing.T) {
sig := testClaudeThinkingSignature()
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + sig + `"}]}]}`)

withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true)
part := gjson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" {
t.Fatalf("compat sanitizer dropped a valid Claude thinking block: %s", withCompat)
}
got := part.Get("signature").String()
if got == "" {
t.Fatalf("compat sanitizer stripped a valid Claude signature")
}
if part.Get("_cliproxy_replay_provenance").Exists() {
t.Fatalf("compat sanitizer leaked a provenance marker: %s", withCompat)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque
appendEvent := func(event, payload string) {
output = translatorcommon.AppendSSEEventString(output, event, payload, 3)
}
appendSignatureDelta := func(signature string) {
if signature == "" {
return
}
if (*param).(*Params).ResponseType != 2 {
if (*param).(*Params).ResponseType != 0 {
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
(*param).(*Params).ResponseIndex++
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex))
(*param).(*Params).ResponseType = 2
}
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, (*param).(*Params).ResponseIndex)), "delta.signature", signature)
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
}

// Initialize the streaming session with a message_start event
// This is only sent for the very first response chunk to establish the streaming session
Expand Down Expand Up @@ -107,6 +123,18 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque
// Extract the different types of content from each part
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
thoughtSignatureResult := partResult.Get("thoughtSignature")
if !thoughtSignatureResult.Exists() {
thoughtSignatureResult = partResult.Get("thought_signature")
}
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""

// Signature-only part: emit as a thinking carrier. Do not treat a
// signature on visible text as thought — that reroutes the answer.
if hasThoughtSignature && !functionCallResult.Exists() && (!partTextResult.Exists() || partTextResult.String() == "") {
appendSignatureDelta(thoughtSignatureResult.String())
continue
}

// Handle text content (both regular content and thinking)
if partTextResult.Exists() {
Expand All @@ -117,6 +145,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String())
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
appendSignatureDelta(thoughtSignatureResult.String())
} else {
// Transition from another state to thinking
// First, close any existing content block
Expand All @@ -136,9 +165,15 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque
appendEvent("content_block_delta", string(data))
(*param).(*Params).ResponseType = 2 // Set state to thinking
(*param).(*Params).HasContent = true
appendSignatureDelta(thoughtSignatureResult.String())
}
} else {
// Process regular text content (user-visible output)
// Process regular text content (user-visible output).
// A thoughtSignature on visible text must not reroute the answer
// into a thinking block; emit a carrier thinking block first.
if hasThoughtSignature {
appendSignatureDelta(thoughtSignatureResult.String())
}
// Continue existing text block if already in content state
if (*param).(*Params).ResponseType == 1 {
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String())
Expand Down Expand Up @@ -269,6 +304,7 @@ func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, orig
parts := root.Get("response.candidates.0.content.parts")
textBuilder := strings.Builder{}
thinkingBuilder := strings.Builder{}
var thinkingSignature string
toolIDCounter := 0
hasToolCall := false

Expand All @@ -283,24 +319,52 @@ func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, orig
}

flushThinking := func() {
if thinkingBuilder.Len() == 0 {
if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
return
}
block := []byte(`{"type":"thinking","thinking":""}`)
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
if thinkingBuilder.Len() > 0 {
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
}
if thinkingSignature != "" {
block, _ = sjson.SetBytes(block, "signature", thinkingSignature)
}
out, _ = sjson.SetRawBytes(out, "content.-1", block)
thinkingBuilder.Reset()
thinkingSignature = ""
}

if parts.IsArray() {
for _, part := range parts.Array() {
thoughtSignature := part.Get("thoughtSignature").String()
if thoughtSignature == "" {
thoughtSignature = part.Get("thought_signature").String()
}

if thoughtSignature != "" && !part.Get("text").Exists() && !part.Get("functionCall").Exists() {
flushText()
thinkingSignature = thoughtSignature
flushThinking()
continue
}

if text := part.Get("text"); text.Exists() && text.String() != "" {
if part.Get("thought").Bool() {
flushText()
thinkingBuilder.WriteString(text.String())
if thoughtSignature != "" {
thinkingSignature = thoughtSignature
}
continue
}
flushThinking()
// Visible text stays text even when Gemini attached a signature.
if thoughtSignature != "" {
flushText()
thinkingSignature = thoughtSignature
flushThinking()
} else {
flushThinking()
}
textBuilder.WriteString(text.String())
continue
}
Expand All @@ -323,6 +387,12 @@ func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, orig
out, _ = sjson.SetRawBytes(out, "content.-1", toolBlock)
continue
}

if thoughtSignature != "" {
flushText()
thinkingSignature = thoughtSignature
flushThinking()
}
}
}

Expand Down
Loading
Loading