641 lines
26 KiB
Go
641 lines
26 KiB
Go
package signature
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"google.golang.org/protobuf/encoding/protowire"
|
|
)
|
|
|
|
func TestStripInvalidClaudeThinkingBlocks_RemovesGPTEncryptedContent(t *testing.T) {
|
|
input := []byte(`{
|
|
"messages": [
|
|
{"role":"assistant","content":[
|
|
{"type":"thinking","thinking":"codex reasoning","signature":"gAAAAABopenai-encrypted-content"},
|
|
{"type":"text","text":"Answer"}
|
|
]},
|
|
{"role":"user","content":[{"type":"text","text":"next"}]}
|
|
]
|
|
}`)
|
|
|
|
out := StripInvalidClaudeThinkingBlocks(input)
|
|
content := gjson.GetBytes(out, "messages.0.content").Array()
|
|
if len(content) != 1 {
|
|
t.Fatalf("messages.0.content length = %d, want 1: %s", len(content), string(out))
|
|
}
|
|
if got := content[0].Get("text").String(); got != "Answer" {
|
|
t.Fatalf("remaining content text = %q, want Answer", got)
|
|
}
|
|
if strings.Contains(string(out), "gAAAAABopenai-encrypted-content") || strings.Contains(string(out), "codex reasoning") {
|
|
t.Fatalf("invalid thinking block was preserved: %s", string(out))
|
|
}
|
|
}
|
|
|
|
func TestStripInvalidClaudeThinkingBlocksAndEmptyMessages_DropsMessagesLeftEmpty(t *testing.T) {
|
|
input := []byte(`{
|
|
"messages": [
|
|
{"role":"assistant","content":[
|
|
{"type":"thinking","thinking":"codex reasoning","signature":"gAAAAABopenai-encrypted-content"}
|
|
]},
|
|
{"role":"user","content":[{"type":"text","text":"next"}]}
|
|
]
|
|
}`)
|
|
|
|
out := StripInvalidClaudeThinkingBlocksAndEmptyMessages(input)
|
|
messages := gjson.GetBytes(out, "messages").Array()
|
|
if len(messages) != 1 {
|
|
t.Fatalf("messages length = %d, want 1: %s", len(messages), string(out))
|
|
}
|
|
if got := messages[0].Get("role").String(); got != "user" {
|
|
t.Fatalf("remaining role = %q, want user", got)
|
|
}
|
|
if strings.Contains(string(out), "gAAAAABopenai-encrypted-content") || strings.Contains(string(out), "codex reasoning") {
|
|
t.Fatalf("invalid thinking block was preserved: %s", string(out))
|
|
}
|
|
}
|
|
|
|
func TestStripInvalidClaudeThinkingBlocks_RemovesMalformedEPrefix(t *testing.T) {
|
|
input := []byte(`{
|
|
"messages": [{"role":"assistant","content":[
|
|
{"type":"thinking","thinking":"bad","signature":"Ebad"},
|
|
{"type":"text","text":"Answer"}
|
|
]}]
|
|
}`)
|
|
|
|
out := StripInvalidClaudeThinkingBlocks(input)
|
|
content := gjson.GetBytes(out, "messages.0.content").Array()
|
|
if len(content) != 1 {
|
|
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
|
|
}
|
|
if strings.Contains(string(out), "Ebad") || strings.Contains(string(out), "bad") {
|
|
t.Fatalf("malformed E-prefix thinking block was preserved: %s", string(out))
|
|
}
|
|
}
|
|
|
|
func TestStripInvalidClaudeThinkingBlocks_Base64OnlyKeepsDecodableEPrefix(t *testing.T) {
|
|
input := []byte(`{
|
|
"messages": [{"role":"assistant","content":[
|
|
{"type":"thinking","thinking":"bad","signature":"Ebad"},
|
|
{"type":"text","text":"Answer"}
|
|
]}]
|
|
}`)
|
|
|
|
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Base64Only: true})
|
|
content := gjson.GetBytes(out, "messages.0.content").Array()
|
|
if len(content) != 2 {
|
|
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
|
|
}
|
|
}
|
|
|
|
func TestStripInvalidClaudeThinkingBlocks_Base64OnlyRemovesInvalidBase64(t *testing.T) {
|
|
input := []byte(`{
|
|
"messages": [{"role":"assistant","content":[
|
|
{"type":"thinking","thinking":"bad","signature":"E!!!invalid!!!"},
|
|
{"type":"text","text":"Answer"}
|
|
]}]
|
|
}`)
|
|
|
|
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Base64Only: true})
|
|
content := gjson.GetBytes(out, "messages.0.content").Array()
|
|
if len(content) != 1 {
|
|
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
|
|
}
|
|
if strings.Contains(string(out), "E!!!invalid!!!") || strings.Contains(string(out), "bad") {
|
|
t.Fatalf("invalid-base64 thinking block was preserved: %s", string(out))
|
|
}
|
|
}
|
|
|
|
func TestStripInvalidClaudeThinkingBlocks_AllowsEmptySignatureEmptyTextPlaceholder(t *testing.T) {
|
|
input := []byte(`{
|
|
"messages": [{"role":"assistant","content":[
|
|
{"type":"thinking","text":"","signature":""},
|
|
{"type":"text","text":"Answer"}
|
|
]}]
|
|
}`)
|
|
|
|
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{
|
|
Base64Only: true,
|
|
AllowEmptySignatureWithEmptyText: true,
|
|
})
|
|
content := gjson.GetBytes(out, "messages.0.content").Array()
|
|
if len(content) != 2 {
|
|
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
|
|
}
|
|
}
|
|
|
|
func TestStripInvalidClaudeThinkingBlocks_StrictRemovesMalformedClaudeTree(t *testing.T) {
|
|
sig := base64.StdEncoding.EncodeToString([]byte{0x12, 0xFF, 0xFE, 0xFD})
|
|
input := []byte(`{
|
|
"messages": [{"role":"assistant","content":[
|
|
{"type":"thinking","thinking":"bad","signature":"` + sig + `"},
|
|
{"type":"text","text":"Answer"}
|
|
]}]
|
|
}`)
|
|
|
|
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Strict: true})
|
|
content := gjson.GetBytes(out, "messages.0.content").Array()
|
|
if len(content) != 1 {
|
|
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
|
|
}
|
|
if strings.Contains(string(out), sig) || strings.Contains(string(out), "bad") {
|
|
t.Fatalf("strict-invalid thinking block was preserved: %s", string(out))
|
|
}
|
|
}
|
|
|
|
func TestStripInvalidClaudeThinkingBlocks_KeepsClaudeSignaturePrefixes(t *testing.T) {
|
|
singleLayer := base64.StdEncoding.EncodeToString([]byte{0x12, 0x34})
|
|
doubleLayer := base64.StdEncoding.EncodeToString([]byte(singleLayer))
|
|
input := []byte(`{
|
|
"messages": [{"role":"assistant","content":[
|
|
{"type":"thinking","thinking":"one","signature":"` + singleLayer + `"},
|
|
{"type":"thinking","thinking":"two","signature":"modelGroup#` + doubleLayer + `"}
|
|
]}]
|
|
}`)
|
|
|
|
out := StripInvalidClaudeThinkingBlocks(input)
|
|
content := gjson.GetBytes(out, "messages.0.content").Array()
|
|
if len(content) != 2 {
|
|
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
|
|
}
|
|
}
|
|
|
|
const observedFable5Sample = "CAISqwIKiAEIEBgCKkBHRlRBsNiptQUWfPoOhuQKwi5LnncZVO9bB5jqOs76D7uBtgktML0zqJtNmLHXHHcgD6lk4MQu4QBXzFd1lbC3Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okZDk3NDM5NzUtNGJiMC00OTM2LTllMjgtZDViMGQyMWJkYzQ4EgxCGh+XVFFFeySAjtAaDL/A1LltGu6MMJ+eXSIwsN0oBpDrqLv22UBfkMnTotnIbkvkOyb9xZHgigG6OZVHaI3gThm+maLKmgO5PrFLKlDFYp+YZksy/wKwszJlnLTPzAK+NUlfzagOE1ymtZTXhAYK260XyFYmg/te/C231+Fr/hoX+EJoUBnrn0gD7hqMISOT+TaFEuOXYsN517GfaxgB"
|
|
|
|
const observedContextID = "d9743975-4bb0-4936-9e28-d5b0d21bdc48"
|
|
|
|
// claudeCAISParts builds Claude CAIS signatures field by field so tests can
|
|
// assert both the observed layout and the upstream drift the validator must
|
|
// tolerate or reject.
|
|
type claudeCAISParts struct {
|
|
includeTopEnvelope bool
|
|
topEnvelope uint64
|
|
includeTopTrailer bool
|
|
includeContainer bool
|
|
includeChannelBlock bool
|
|
includeChannelID bool
|
|
channelID uint64
|
|
channelIDAsBytes bool
|
|
includeChannelVerion bool
|
|
includeSignature bool
|
|
signatureLen int
|
|
includeModelText bool
|
|
modelText []byte
|
|
includeField7 bool
|
|
blockKind string
|
|
contextID string
|
|
}
|
|
|
|
// defaultClaudeCAISParts mirrors the layout observed on claude-fable-5 and
|
|
// claude-opus-5 responses.
|
|
func defaultClaudeCAISParts(model string) claudeCAISParts {
|
|
return claudeCAISParts{
|
|
includeTopEnvelope: true,
|
|
topEnvelope: 2,
|
|
includeTopTrailer: true,
|
|
includeContainer: true,
|
|
includeChannelBlock: true,
|
|
includeChannelID: true,
|
|
channelID: 16,
|
|
includeChannelVerion: true,
|
|
includeSignature: true,
|
|
signatureLen: 64,
|
|
includeModelText: true,
|
|
modelText: []byte(model),
|
|
includeField7: true,
|
|
blockKind: "thinking",
|
|
contextID: observedContextID,
|
|
}
|
|
}
|
|
|
|
func (p claudeCAISParts) encode() string {
|
|
var channelBlock []byte
|
|
if p.includeChannelID {
|
|
if p.channelIDAsBytes {
|
|
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.BytesType)
|
|
channelBlock = protowire.AppendBytes(channelBlock, []byte{0x10})
|
|
} else {
|
|
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
|
channelBlock = protowire.AppendVarint(channelBlock, p.channelID)
|
|
}
|
|
}
|
|
if p.includeChannelVerion {
|
|
channelBlock = protowire.AppendTag(channelBlock, 3, protowire.VarintType)
|
|
channelBlock = protowire.AppendVarint(channelBlock, 2)
|
|
}
|
|
if p.includeSignature {
|
|
channelBlock = protowire.AppendTag(channelBlock, 5, protowire.BytesType)
|
|
channelBlock = protowire.AppendBytes(channelBlock, make([]byte, p.signatureLen))
|
|
}
|
|
if p.includeModelText {
|
|
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
|
channelBlock = protowire.AppendBytes(channelBlock, p.modelText)
|
|
}
|
|
if p.includeField7 {
|
|
channelBlock = protowire.AppendTag(channelBlock, 7, protowire.VarintType)
|
|
channelBlock = protowire.AppendVarint(channelBlock, 1)
|
|
}
|
|
if p.blockKind != "" {
|
|
channelBlock = protowire.AppendTag(channelBlock, 8, protowire.BytesType)
|
|
channelBlock = protowire.AppendString(channelBlock, p.blockKind)
|
|
}
|
|
if p.contextID != "" {
|
|
channelBlock = protowire.AppendTag(channelBlock, 11, protowire.BytesType)
|
|
channelBlock = protowire.AppendString(channelBlock, p.contextID)
|
|
}
|
|
|
|
var container []byte
|
|
if p.includeChannelBlock {
|
|
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
|
container = protowire.AppendBytes(container, channelBlock)
|
|
}
|
|
|
|
var payload []byte
|
|
if p.includeTopEnvelope {
|
|
payload = protowire.AppendTag(payload, 1, protowire.VarintType)
|
|
payload = protowire.AppendVarint(payload, p.topEnvelope)
|
|
}
|
|
if p.includeContainer {
|
|
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
|
payload = protowire.AppendBytes(payload, container)
|
|
}
|
|
if p.includeTopTrailer {
|
|
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
|
payload = protowire.AppendVarint(payload, 1)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(payload)
|
|
}
|
|
|
|
func testClaudeCAISSignature(model string) string {
|
|
return defaultClaudeCAISParts(model).encode()
|
|
}
|
|
|
|
func TestClaudeCAISSignature_ObservedFable5Sample(t *testing.T) {
|
|
if !IsValidClaudeCAISSignature(observedFable5Sample) {
|
|
t.Fatal("IsValidClaudeCAISSignature(observedFable5Sample) = false, want true")
|
|
}
|
|
|
|
info, err := InspectClaudeCAISSignature(observedFable5Sample)
|
|
if err != nil {
|
|
t.Fatalf("InspectClaudeCAISSignature failed: %v", err)
|
|
}
|
|
|
|
if info.ModelText != "claude-fable-5" {
|
|
t.Fatalf("ModelText = %q, want %q", info.ModelText, "claude-fable-5")
|
|
}
|
|
if info.BlockKind != "thinking" {
|
|
t.Fatalf("BlockKind = %q, want %q", info.BlockKind, "thinking")
|
|
}
|
|
expectedUUID := "d9743975-4bb0-4936-9e28-d5b0d21bdc48"
|
|
if info.ContextID != expectedUUID {
|
|
t.Fatalf("ContextID = %q, want %q", info.ContextID, expectedUUID)
|
|
}
|
|
if info.FirstByte != 0x08 {
|
|
t.Fatalf("FirstByte = 0x%02x, want 0x08", info.FirstByte)
|
|
}
|
|
}
|
|
|
|
func TestClaudeCAISSignature_DetectSignatureProvider(t *testing.T) {
|
|
prefixes := []string{
|
|
"",
|
|
"ccmax#",
|
|
"claude-code-max#",
|
|
"claude_code_max#",
|
|
"cais#",
|
|
"claude-cais#",
|
|
"claude_cais#",
|
|
"claude#",
|
|
}
|
|
for _, prefix := range prefixes {
|
|
sig := prefix + observedFable5Sample
|
|
got := DetectSignatureProvider(sig)
|
|
if got != SignatureProviderClaude {
|
|
t.Errorf("DetectSignatureProvider(%q) = %q, want %q", sig, got, SignatureProviderClaude)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClaudeCAISSignature_ObservedOpus5Layout(t *testing.T) {
|
|
signature := testClaudeCAISSignature("claude-opus-5")
|
|
info, err := InspectClaudeCAISSignature(signature)
|
|
if err != nil {
|
|
t.Fatalf("InspectClaudeCAISSignature failed: %v", err)
|
|
}
|
|
if info.ModelText != "claude-opus-5" {
|
|
t.Fatalf("ModelText = %q, want claude-opus-5", info.ModelText)
|
|
}
|
|
decision := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "claude-opus-5", signature, SignatureBlockKindClaudeThinking)
|
|
if !decision.Compatible || decision.NormalizedSignature != signature || decision.DetectedProvider != SignatureProviderClaude {
|
|
t.Fatalf("same-model opus-5 decision = %+v, want preserved with DetectedProvider=claude", decision)
|
|
}
|
|
}
|
|
|
|
func TestClaudeCAISSignature_NotCompatibleWithGemini(t *testing.T) {
|
|
if normalized, ok := CompatibleSignatureForProvider(SignatureProviderGemini, observedFable5Sample); ok || normalized != "" {
|
|
t.Fatalf("CompatibleSignatureForProvider(Gemini) = %q, %v; want empty and false", normalized, ok)
|
|
}
|
|
if IsSignatureCompatibleWithProvider(SignatureProviderGemini, observedFable5Sample) {
|
|
t.Fatal("IsSignatureCompatibleWithProvider(Gemini) = true, want false")
|
|
}
|
|
if isRecognizedGeminiProviderSignature(observedFable5Sample, SignatureBlockKindUnknown) {
|
|
t.Fatal("isRecognizedGeminiProviderSignature = true, want false")
|
|
}
|
|
if _, err := InspectGeminiThoughtSignature(observedFable5Sample); err == nil {
|
|
t.Fatal("InspectGeminiThoughtSignature should fail for Claude CAIS signature")
|
|
}
|
|
}
|
|
|
|
func TestClaudeCAISSignature_CompatibleWithAllClaudeTargets(t *testing.T) {
|
|
decision := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "claude-fable-5", observedFable5Sample, SignatureBlockKindClaudeThinking)
|
|
if !decision.Compatible || decision.Action != SignatureActionPreserve || decision.NormalizedSignature != observedFable5Sample || decision.DetectedProvider != SignatureProviderClaude {
|
|
t.Fatalf("DecideSignatureCompatibilityForModel(Claude, claude-fable-5) = %+v, want compatible & preserved with DetectedProvider=claude", decision)
|
|
}
|
|
|
|
decisionCase := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "CLAUDE-FABLE-5", observedFable5Sample, SignatureBlockKindClaudeThinking)
|
|
if !decisionCase.Compatible || decisionCase.Action != SignatureActionPreserve {
|
|
t.Fatalf("DecideSignatureCompatibilityForModel case-insensitive failed: %+v", decisionCase)
|
|
}
|
|
|
|
decisionDiff := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "claude-opus-5", observedFable5Sample, SignatureBlockKindClaudeThinking)
|
|
if !decisionDiff.Compatible || decisionDiff.Action != SignatureActionPreserve || decisionDiff.NormalizedSignature != observedFable5Sample {
|
|
t.Fatalf("DecideSignatureCompatibilityForModel(Claude, claude-opus-5) = %+v, want compatible & preserved", decisionDiff)
|
|
}
|
|
|
|
opus5Sig := testClaudeCAISSignature("claude-opus-5")
|
|
decisionOpusToOpus48 := DecideSignatureCompatibilityForModel(SignatureProviderClaude, "claude-opus-4-8", opus5Sig, SignatureBlockKindClaudeThinking)
|
|
if !decisionOpusToOpus48.Compatible || decisionOpusToOpus48.Action != SignatureActionPreserve || decisionOpusToOpus48.NormalizedSignature != opus5Sig {
|
|
t.Fatalf("DecideSignatureCompatibilityForModel(Claude, claude-opus-4-8) with opus-5 signature = %+v, want compatible & preserved", decisionOpusToOpus48)
|
|
}
|
|
|
|
if normalized, ok := CompatibleSignatureForProvider(SignatureProviderClaude, observedFable5Sample); !ok || normalized != observedFable5Sample {
|
|
t.Fatalf("CompatibleSignatureForProvider(Claude, observedFable5Sample) = %q, %v; want %q, true", normalized, ok, observedFable5Sample)
|
|
}
|
|
|
|
decisionGemini := DecideSignatureCompatibilityForModel(SignatureProviderGemini, "claude-fable-5", observedFable5Sample, SignatureBlockKindClaudeThinking)
|
|
if decisionGemini.Compatible {
|
|
t.Fatalf("DecideSignatureCompatibilityForModel(Gemini, claude-fable-5) = %+v, want incompatible", decisionGemini)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeClaudeMessagesForClaudeUpstream_ClaudeCAIS(t *testing.T) {
|
|
inputSame := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + observedFable5Sample + `"},{"type":"text","text":"answer"}]}]}`)
|
|
|
|
outputSame, reportSame := SanitizeClaudeMessagesForClaudeUpstream(inputSame, "claude-fable-5")
|
|
if reportSame.Preserved != 1 || reportSame.DroppedBlocks != 0 {
|
|
t.Fatalf("unexpected report for same model: %+v", reportSame)
|
|
}
|
|
if got := gjson.GetBytes(outputSame, "messages.0.content.0.signature").String(); got != observedFable5Sample {
|
|
t.Fatalf("signature = %q, want preserved %q", got, observedFable5Sample)
|
|
}
|
|
|
|
inputTool := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + observedFable5Sample + `"},{"type":"text","text":"answer"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"pwd"},"signature":"` + observedFable5Sample + `"}]}]}`)
|
|
outputTool, reportTool := SanitizeClaudeMessagesForClaudeUpstream(inputTool, "claude-fable-5")
|
|
if reportTool.Preserved != 1 {
|
|
t.Fatalf("unexpected report for tool input: %+v", reportTool)
|
|
}
|
|
partsTool := gjson.GetBytes(outputTool, "messages.0.content").Array()
|
|
if len(partsTool) != 3 {
|
|
t.Fatalf("content len = %d, want 3", len(partsTool))
|
|
}
|
|
if partsTool[0].Get("signature").String() != observedFable5Sample {
|
|
t.Fatalf("thinking block signature lost: %s", partsTool[0].Raw)
|
|
}
|
|
if partsTool[2].Get("signature").Exists() {
|
|
t.Fatalf("tool_use signature should be stripped: %s", partsTool[2].Raw)
|
|
}
|
|
|
|
inputDiff := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + observedFable5Sample + `"},{"type":"text","text":"answer"}]}]}`)
|
|
outputDiff, reportDiff := SanitizeClaudeMessagesForClaudeUpstream(inputDiff, "claude-opus-5")
|
|
if reportDiff.Preserved != 1 || reportDiff.DroppedBlocks != 0 {
|
|
t.Fatalf("unexpected report for cross model: %+v", reportDiff)
|
|
}
|
|
partsDiff := gjson.GetBytes(outputDiff, "messages.0.content").Array()
|
|
if len(partsDiff) != 2 {
|
|
t.Fatalf("content len = %d, want 2: %s", len(partsDiff), outputDiff)
|
|
}
|
|
if got := partsDiff[0].Get("signature").String(); got != observedFable5Sample {
|
|
t.Fatalf("thinking signature = %q, want %q", got, observedFable5Sample)
|
|
}
|
|
}
|
|
|
|
// TestClaudeCAISSignature_ToleratesUpstreamFieldDrift pins the deliberately
|
|
// structural validation: rejecting a signature drops the whole thinking block,
|
|
// so incidental values observed today must not become hard requirements.
|
|
func TestClaudeCAISSignature_ToleratesUpstreamFieldDrift(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
parts claudeCAISParts
|
|
}{
|
|
{"observed layout", defaultClaudeCAISParts("claude-opus-5")},
|
|
{"new channel id", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.channelID = 17
|
|
return p
|
|
}()},
|
|
{"new envelope version", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.topEnvelope = 3
|
|
return p
|
|
}()},
|
|
{"no top-level trailer", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.includeTopTrailer = false
|
|
return p
|
|
}()},
|
|
{"no channel version", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.includeChannelVerion = false
|
|
return p
|
|
}()},
|
|
{"longer signature bytes", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.signatureLen = 96
|
|
return p
|
|
}()},
|
|
{"no field 7", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.includeField7 = false
|
|
return p
|
|
}()},
|
|
{"other block kind", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.blockKind = "redacted_thinking"
|
|
return p
|
|
}()},
|
|
{"no block kind", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.blockKind = ""
|
|
return p
|
|
}()},
|
|
{"no context id", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.contextID = ""
|
|
return p
|
|
}()},
|
|
{"unreleased model name", func() claudeCAISParts {
|
|
p := defaultClaudeCAISParts("claude-opus-6-preview")
|
|
return p
|
|
}()},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
sig := tc.parts.encode()
|
|
if _, err := InspectClaudeCAISSignature(sig); err != nil {
|
|
t.Fatalf("InspectClaudeCAISSignature failed: %v", err)
|
|
}
|
|
if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindClaudeThinking); got != SignatureProviderClaude {
|
|
t.Fatalf("DetectSignatureProviderForBlock = %q, want %q", got, SignatureProviderClaude)
|
|
}
|
|
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"}]}]}`)
|
|
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-opus-5")
|
|
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
|
t.Fatalf("report = %+v, want preserved thinking block", report)
|
|
}
|
|
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
|
|
t.Fatalf("signature = %q, want preserved %q", got, sig)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClaudeCAISSignature_RejectsMalformedPayloads(t *testing.T) {
|
|
truncated := func() string {
|
|
decoded, err := base64.StdEncoding.DecodeString(observedFable5Sample)
|
|
if err != nil {
|
|
t.Fatalf("decode observed sample: %v", err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(decoded[:len(decoded)/2])
|
|
}()
|
|
|
|
cases := []struct {
|
|
name string
|
|
signature string
|
|
}{
|
|
{"empty", ""},
|
|
{"whitespace only", " "},
|
|
{"not base64", "CAIS!!!not-base64"},
|
|
{"truncated payload", truncated},
|
|
// 'E' prefix is the classic Claude form and must not reach CAIS parsing.
|
|
{"classic claude prefix", base64.StdEncoding.EncodeToString([]byte{0x12, 0x00})},
|
|
// 'C' prefix but a non-0x08 marker byte, the only way to reach the marker
|
|
// check (a 'C' prefix constrains the first byte to 0x08-0x0b).
|
|
{"wrong marker byte", base64.StdEncoding.EncodeToString([]byte{0x0a, 0x00})},
|
|
{"no container", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.includeContainer = false
|
|
return p.encode()
|
|
}()},
|
|
{"no channel block", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.includeChannelBlock = false
|
|
return p.encode()
|
|
}()},
|
|
{"no channel id", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.includeChannelID = false
|
|
return p.encode()
|
|
}()},
|
|
{"channel id wrong wire type", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.channelIDAsBytes = true
|
|
return p.encode()
|
|
}()},
|
|
{"no signature bytes", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.includeSignature = false
|
|
return p.encode()
|
|
}()},
|
|
{"empty signature bytes", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.signatureLen = 0
|
|
return p.encode()
|
|
}()},
|
|
{"no model text", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.includeModelText = false
|
|
return p.encode()
|
|
}()},
|
|
{"foreign model text", func() string {
|
|
p := defaultClaudeCAISParts("gemini-3-pro")
|
|
return p.encode()
|
|
}()},
|
|
{"invalid utf-8 model text", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.modelText = []byte{'c', 'l', 'a', 'u', 'd', 'e', '-', 0xff, 0xfe}
|
|
return p.encode()
|
|
}()},
|
|
{"non-uuid context id", func() string {
|
|
p := defaultClaudeCAISParts("claude-opus-5")
|
|
p.contextID = "not-a-canonical-uuid-value-000000000"
|
|
return p.encode()
|
|
}()},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if IsValidClaudeCAISSignature(tc.signature) {
|
|
t.Fatalf("IsValidClaudeCAISSignature(%q) = true, want false", tc.signature)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestClaudeCAISSignature_DoesNotShadowClassicClaudeSignature guards the
|
|
// detection order: CAIS validation runs before classic Claude validation, so it
|
|
// must not claim E/R signatures and change how they are normalized.
|
|
func TestClaudeCAISSignature_DoesNotShadowClassicClaudeSignature(t *testing.T) {
|
|
classic := testClaudeThinkingSignature()
|
|
if IsValidClaudeCAISSignature(classic) {
|
|
t.Fatal("IsValidClaudeCAISSignature(classic Claude signature) = true, want false")
|
|
}
|
|
if got := DetectSignatureProviderForBlock(classic, SignatureBlockKindClaudeThinking); got != SignatureProviderClaude {
|
|
t.Fatalf("DetectSignatureProviderForBlock(classic) = %q, want %q", got, SignatureProviderClaude)
|
|
}
|
|
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + classic + `"}]}]}`)
|
|
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-6")
|
|
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
|
t.Fatalf("report = %+v, want preserved classic thinking block", report)
|
|
}
|
|
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != classic {
|
|
t.Fatalf("signature = %q, want provider-native E-form %q", got, classic)
|
|
}
|
|
}
|
|
|
|
// TestClaudeCAISSignature_CachePrefixSurvivesClaudeUpstreamSanitize covers the
|
|
// cached-signature path: cache.GetModelGroup collapses every Claude model to the
|
|
// "claude" prefix, so a CAIS signature reaches the sanitizer as "claude#..." and
|
|
// must be replayed with the prefix stripped instead of being dropped.
|
|
func TestClaudeCAISSignature_CachePrefixSurvivesClaudeUpstreamSanitize(t *testing.T) {
|
|
for _, prefix := range []string{"claude#", "anthropic#", "cais#", "ccmax#"} {
|
|
t.Run(prefix, func(t *testing.T) {
|
|
prefixed := prefix + observedFable5Sample
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + prefixed + `"}]}]}`)
|
|
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-fable-5")
|
|
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
|
t.Fatalf("report = %+v, want preserved thinking block", report)
|
|
}
|
|
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != observedFable5Sample {
|
|
t.Fatalf("signature = %q, want unprefixed %q", got, observedFable5Sample)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompatibleAntigravityClaudeThinkingSignature_RejectsClaudeCAIS(t *testing.T) {
|
|
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature(observedFable5Sample); ok || normalized != "" {
|
|
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature(ClaudeCAIS) = %q, %v; want empty and false", normalized, ok)
|
|
}
|
|
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature("ccmax#" + observedFable5Sample); ok || normalized != "" {
|
|
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature(ccmax#ClaudeCAIS) = %q, %v; want empty and false", normalized, ok)
|
|
}
|
|
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature("cais#" + observedFable5Sample); ok || normalized != "" {
|
|
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature(cais#ClaudeCAIS) = %q, %v; want empty and false", normalized, ok)
|
|
}
|
|
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature("claude-cais#" + observedFable5Sample); ok || normalized != "" {
|
|
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature(claude-cais#ClaudeCAIS) = %q, %v; want empty and false", normalized, ok)
|
|
}
|
|
}
|