diff --git a/internal/signature/claude_validation.go b/internal/signature/claude_validation.go index 4bad747e..a44f741b 100644 --- a/internal/signature/claude_validation.go +++ b/internal/signature/claude_validation.go @@ -226,6 +226,40 @@ func NormalizeClaudeThinkingSignature(rawSignature string, opts ...ClaudeSignatu } } +// NormalizeClaudeProviderNativeThinkingSignature strips any cache prefix, +// validates the signature, and returns the single-layer E-form expected by +// Claude-native providers. +func NormalizeClaudeProviderNativeThinkingSignature(rawSignature string, opts ...ClaudeSignatureValidationOptions) (string, error) { + opt := claudeSignatureValidationOptions(opts) + sig := stripClaudeSignaturePrefix(rawSignature) + if sig == "" { + return "", fmt.Errorf("empty signature") + } + + if len(sig) > MaxClaudeThinkingSignatureLen { + return "", fmt.Errorf("signature exceeds maximum length (%d bytes)", MaxClaudeThinkingSignatureLen) + } + + switch sig[0] { + case 'E': + if err := validateClaudeSingleLayerSignature(sig, opt); err != nil { + return "", err + } + return sig, nil + case 'R': + if err := validateClaudeDoubleLayerSignature(sig, opt); err != nil { + return "", err + } + decoded, err := base64.StdEncoding.DecodeString(sig) + if err != nil { + return "", fmt.Errorf("invalid double-layer signature: base64 decode failed: %w", err) + } + return string(decoded), nil + default: + return "", fmt.Errorf("invalid signature: expected 'E' or 'R' prefix, got %q", string(sig[0])) + } +} + func validateClaudeDoubleLayerSignature(sig string, opt ClaudeSignatureValidationOptions) error { decoded, err := base64.StdEncoding.DecodeString(sig) if err != nil { diff --git a/internal/signature/provider_compatibility.go b/internal/signature/provider_compatibility.go index 6cdb896f..885a92e9 100644 --- a/internal/signature/provider_compatibility.go +++ b/internal/signature/provider_compatibility.go @@ -229,6 +229,24 @@ func CompatibleSignatureForProviderBlock(targetProvider SignatureProvider, rawSi return decision.NormalizedSignature, true } +// CompatibleAntigravityClaudeThinkingSignature returns the double-layer R-form +// required by Antigravity Claude replay. It only accepts signatures that are +// strictly identifiable as Claude, so Gemini E-prefixed envelopes cannot slip +// through the looser Antigravity bypass normalization path. +func CompatibleAntigravityClaudeThinkingSignature(rawSignature string) (string, bool) { + if DetectSignatureProviderForBlock(rawSignature, SignatureBlockKindClaudeThinking) != SignatureProviderClaude { + return "", false + } + normalized, err := NormalizeClaudeThinkingSignature( + SignaturePayloadWithoutProviderPrefix(rawSignature), + ClaudeSignatureValidationOptions{Strict: true}, + ) + if err != nil { + return "", false + } + return normalized, true +} + func normalizeSignatureTargetProvider(provider SignatureProvider) SignatureProvider { switch provider { case SignatureProviderGeminiBypass: @@ -255,7 +273,7 @@ func normalizeCompatibleSignatureForProvider(targetProvider SignatureProvider, r payload := SignaturePayloadWithoutProviderPrefix(rawSignature) switch normalizeSignatureTargetProvider(targetProvider) { case SignatureProviderClaude: - normalized, err := NormalizeClaudeThinkingSignature(payload) + normalized, err := NormalizeClaudeProviderNativeThinkingSignature(payload) if err != nil { return "" } diff --git a/internal/signature/provider_compatibility_test.go b/internal/signature/provider_compatibility_test.go index 5768d11c..dcb5b829 100644 --- a/internal/signature/provider_compatibility_test.go +++ b/internal/signature/provider_compatibility_test.go @@ -61,6 +61,42 @@ func TestDetectSignatureProvider_Gemini3EPrefixDoesNotLookClaude(t *testing.T) { } } +func TestCompatibleSignatureForProvider_ClaudeUsesProviderNativeEForm(t *testing.T) { + nativeSig := testClaudeThinkingSignature() + doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig)) + + normalized, ok := CompatibleSignatureForProvider(SignatureProviderClaude, doubleEncoded) + if !ok { + t.Fatal("double-layer Claude signature should be compatible") + } + if normalized != nativeSig { + t.Fatalf("CompatibleSignatureForProvider(Claude) = %q, want provider-native %q", normalized, nativeSig) + } +} + +func TestCompatibleAntigravityClaudeThinkingSignature_UsesDoubleLayerRForm(t *testing.T) { + nativeSig := testClaudeThinkingSignature() + expected := base64.StdEncoding.EncodeToString([]byte(nativeSig)) + + normalized, ok := CompatibleAntigravityClaudeThinkingSignature(nativeSig) + if !ok { + t.Fatal("Claude signature should be compatible with Antigravity Claude") + } + if normalized != expected { + t.Fatalf("CompatibleAntigravityClaudeThinkingSignature = %q, want %q", normalized, expected) + } +} + +func TestCompatibleAntigravityClaudeThinkingSignature_RejectsGeminiEPrefix(t *testing.T) { + geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34}) + if !strings.HasPrefix(geminiSig, "E") { + t.Fatalf("test signature should start with E, got %q", geminiSig[:1]) + } + if normalized, ok := CompatibleAntigravityClaudeThinkingSignature(geminiSig); ok || normalized != "" { + t.Fatalf("Gemini E-prefix signature normalized=%q ok=%v, want rejected", normalized, ok) + } +} + func TestDetectSignatureProvider_DoesNotClassifyArbitraryBase64AsGemini(t *testing.T) { opaque := testGeminiThoughtSignature([]byte{0x45, 0x12}) if got := DetectSignatureProvider(opaque); got != SignatureProviderUnknown { @@ -172,9 +208,9 @@ func TestSanitizeClaudeMessagesSignaturesForModel_NormalizesSameProviderClaude(t nativeSig := testClaudeThinkingSignature() sig := "claude#" + nativeSig input := []byte(`{"model":"claude-sonnet","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`) - expectedSig, err := NormalizeClaudeThinkingSignature(nativeSig) + expectedSig, err := NormalizeClaudeProviderNativeThinkingSignature(nativeSig) if err != nil { - t.Fatalf("NormalizeClaudeThinkingSignature failed: %v", err) + t.Fatalf("NormalizeClaudeProviderNativeThinkingSignature failed: %v", err) } output, report := SanitizeClaudeMessagesSignaturesForModel(input, "claude-sonnet-4-5") -- 2.51.2 From aee7a5fbc533298974e4ba5ccb5392b9143279dd Mon Sep 17 00:00:00 2001 From: sususu98 Date: Fri, 29 May 2026 12:18:25 +0800 Subject: [PATCH 2/2] feat: intercept incompatible signature replay --- .../runtime/executor/antigravity_executor.go | 47 +++- .../antigravity_executor_signature_test.go | 203 ++++++++++---- internal/runtime/executor/claude_executor.go | 36 +++ .../runtime/executor/claude_executor_test.go | 57 ++++ internal/runtime/executor/codex_executor.go | 3 + .../executor/codex_executor_signature_test.go | 138 ++++++++++ .../executor/codex_websockets_executor.go | 2 + .../executor/openai_compat_executor.go | 1 + .../executor/openai_responses_signature.go | 68 +++++ .../signature/claude_messages_sanitize.go | 32 ++- internal/signature/gemini_sanitize.go | 140 ++++++++++ internal/signature/gemini_sanitize_test.go | 122 +++++++++ .../signature/provider_compatibility_test.go | 55 ++++ .../claude/antigravity_claude_request.go | 194 ++++++++++++-- .../claude/antigravity_claude_request_test.go | 248 ++++++++++++++++-- .../claude/signature_validation.go | 4 + .../gemini/antigravity_gemini_request.go | 221 ++++++++++++++-- .../gemini/antigravity_gemini_request_test.go | 147 +++++++++++ .../antigravity_openai-responses_request.go | 192 ++++++++++++++ ...tigravity_openai-responses_request_test.go | 176 +++++++++++++ .../claude_openai-responses_request.go | 5 +- .../claude_openai-responses_request_test.go | 87 +++++- .../codex/claude/codex_claude_request.go | 39 +-- .../gemini/gemini-cli_gemini_request.go | 15 +- .../gemini-cli_openai_request.go | 17 +- .../gemini-cli/gemini_gemini-cli_request.go | 15 +- .../gemini/gemini/gemini_gemini_request.go | 15 +- .../chat-completions/gemini_openai_request.go | 17 +- .../gemini_openai_signature_test.go | 51 ++++ .../gemini_openai-responses_request.go | 7 +- .../gemini_openai-responses_request_test.go | 66 +++++ .../openai/claude/openai_claude_request.go | 13 + .../claude/openai_claude_request_test.go | 134 +++++++--- .../openai/openai_responses_signature_test.go | 86 ++++++ 34 files changed, 2400 insertions(+), 253 deletions(-) create mode 100644 internal/runtime/executor/codex_executor_signature_test.go create mode 100644 internal/runtime/executor/openai_responses_signature.go create mode 100644 internal/signature/gemini_sanitize.go create mode 100644 internal/signature/gemini_sanitize_test.go create mode 100644 internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go create mode 100644 internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go create mode 100644 internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go create mode 100644 sdk/api/handlers/openai/openai_responses_signature_test.go diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 408a490d..6388856e 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -245,7 +245,9 @@ func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []b return rawJSON, nil } // Always strip thinking blocks with invalid signatures (empty or non-Claude-format). + before := countClaudeThinkingBlocks(rawJSON) rawJSON = antigravityclaude.StripEmptySignatureThinkingBlocks(rawJSON) + logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "prefix_cleanup", "empty_or_non_claude_signature") if cache.SignatureCacheEnabled() { return rawJSON, nil } @@ -254,12 +256,51 @@ func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []b // by dropping unsigned thinking blocks silently (no 400). return rawJSON, nil } - if err := antigravityclaude.ValidateClaudeBypassSignatures(rawJSON); err != nil { - return rawJSON, statusErr{code: http.StatusBadRequest, msg: err.Error()} - } + before = countClaudeThinkingBlocks(rawJSON) + rawJSON = antigravityclaude.StripInvalidBypassSignatureThinkingBlocks(rawJSON) + logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "strict_bypass", "invalid_antigravity_claude_signature") return rawJSON, nil } +func countClaudeThinkingBlocks(rawJSON []byte) int { + messages := gjson.GetBytes(rawJSON, "messages") + if !messages.IsArray() { + return 0 + } + + count := 0 + messages.ForEach(func(_, message gjson.Result) bool { + content := message.Get("content") + if !content.IsArray() { + return true + } + content.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "thinking" { + count++ + } + return true + }) + return true + }) + return count +} + +func logAntigravitySignatureStrip(before, after int, stage, reason string) { + removed := before - after + if removed <= 0 { + return + } + log.WithFields(log.Fields{ + "component": "signature_sanitizer", + "executor": "antigravity", + "target_provider": "claude", + "action": "drop_thinking_blocks", + "stage": stage, + "reason": reason, + "count": removed, + }).Debug("antigravity executor: dropped Claude thinking blocks with invalid signatures") +} + // Identifier returns the executor identifier. func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType } diff --git a/internal/runtime/executor/antigravity_executor_signature_test.go b/internal/runtime/executor/antigravity_executor_signature_test.go index 7d84bfe8..8383614d 100644 --- a/internal/runtime/executor/antigravity_executor_signature_test.go +++ b/internal/runtime/executor/antigravity_executor_signature_test.go @@ -4,16 +4,17 @@ import ( "bytes" "context" "encoding/base64" - "net/http" - "net/http/httptest" - "sync/atomic" + "fmt" + "strings" "testing" "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/tidwall/gjson" ) func testGeminiSignaturePayload() string { @@ -56,7 +57,38 @@ func invalidClaudeThinkingPayload() []byte { }`) } -func TestAntigravityExecutor_StrictBypassRejectsInvalidSignature(t *testing.T) { +func newSignatureDebugHook(t *testing.T) *test.Hook { + t.Helper() + + previousLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + hook := test.NewLocal(log.StandardLogger()) + t.Cleanup(func() { + hook.Reset() + log.SetLevel(previousLevel) + }) + return hook +} + +func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden string) { + t.Helper() + + if forbidden == "" { + return + } + for _, entry := range hook.AllEntries() { + if strings.Contains(entry.Message, forbidden) { + t.Fatalf("debug log leaked signature in message: %q", entry.Message) + } + for key, value := range entry.Data { + if strings.Contains(fmt.Sprint(value), forbidden) { + t.Fatalf("debug log leaked signature in field %q: %v", key, value) + } + } + } +} + +func TestAntigravityExecutor_StrictBypassStripsInvalidSignature(t *testing.T) { previousCache := cache.SignatureCacheEnabled() previousStrict := cache.SignatureBypassStrictMode() cache.SetSignatureCacheEnabled(false) @@ -66,67 +98,122 @@ func TestAntigravityExecutor_StrictBypassRejectsInvalidSignature(t *testing.T) { cache.SetSignatureBypassStrictMode(previousStrict) }) - var hits atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hits.Add(1) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}}`)) - })) - defer server.Close() - - executor := NewAntigravityExecutor(nil) - auth := testAntigravityAuth(server.URL) payload := invalidClaudeThinkingPayload() - opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude"), OriginalRequest: payload} - req := cliproxyexecutor.Request{Model: "claude-sonnet-4-5-thinking", Payload: payload} - - tests := []struct { - name string - invoke func() error - }{ - { - name: "execute", - invoke: func() error { - _, err := executor.Execute(context.Background(), auth, req, opts) - return err - }, - }, - { - name: "stream", - invoke: func() error { - _, err := executor.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{SourceFormat: opts.SourceFormat, OriginalRequest: payload, Stream: true}) - return err - }, - }, - { - name: "count tokens", - invoke: func() error { - _, err := executor.CountTokens(context.Background(), auth, req, opts) - return err - }, - }, + from := sdktranslator.FromString("claude") + + output, err := validateAntigravityRequestSignatures(from, payload) + if err != nil { + t.Fatalf("strict bypass should strip invalid signatures instead of rejecting request: %v", err) + } + parts := gjson.GetBytes(output, "messages.0.content").Array() + if len(parts) != 1 { + t.Fatalf("content length = %d, want 1 after invalid thinking strip: %s", len(parts), output) } + if got := parts[0].Get("type").String(); got != "text" { + t.Fatalf("remaining part type = %q, want text: %s", got, output) + } +} - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - err := tt.invoke() - if err == nil { - t.Fatal("expected invalid signature to return an error") - } - statusProvider, ok := err.(interface{ StatusCode() int }) - if !ok { - t.Fatalf("expected status error, got %T: %v", err, err) +func TestAntigravityExecutor_StrictBypassLogsStrippedInvalidSignature(t *testing.T) { + previousCache := cache.SignatureCacheEnabled() + previousStrict := cache.SignatureBypassStrictMode() + cache.SetSignatureCacheEnabled(false) + cache.SetSignatureBypassStrictMode(true) + t.Cleanup(func() { + cache.SetSignatureCacheEnabled(previousCache) + cache.SetSignatureBypassStrictMode(previousStrict) + }) + + hook := newSignatureDebugHook(t) + rawSignature := testFakeClaudeSignature() + payload := []byte(`{ + "model": "claude-sonnet-4-5-thinking", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "bad", "signature": "` + rawSignature + `"}, + {"type": "text", "text": "hello"} + ] } - if statusProvider.StatusCode() != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", statusProvider.StatusCode(), http.StatusBadRequest) + ] + }`) + from := sdktranslator.FromString("claude") + + if _, err := validateAntigravityRequestSignatures(from, payload); err != nil { + t.Fatalf("strict bypass should strip invalid signatures instead of rejecting request: %v", err) + } + + found := false + for _, entry := range hook.AllEntries() { + if entry.Level != log.DebugLevel { + continue + } + if entry.Data["component"] != "signature_sanitizer" || + entry.Data["executor"] != "antigravity" || + entry.Data["action"] != "drop_thinking_blocks" || + entry.Data["stage"] != "strict_bypass" { + continue + } + if entry.Data["count"] != 1 { + t.Fatalf("debug drop count = %v, want 1", entry.Data["count"]) + } + found = true + } + if !found { + t.Fatal("expected debug log for stripped Antigravity Claude thinking signature") + } + assertSignatureDebugDoesNotLeak(t, hook, rawSignature) +} + +func TestClaudeExecutor_LogsSanitizedClaudeUpstreamSignatures(t *testing.T) { + hook := newSignatureDebugHook(t) + rawSignature := "skip_thought_signature_validator" + body := []byte(`{ + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "bad", "signature": "` + rawSignature + `"}, + {"type": "text", "text": "hello"}, + {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {}, "signature": "` + rawSignature + `"} + ] } - }) + ] + }`) + + output := sanitizeClaudeMessagesForClaudeUpstreamWithDebug(context.Background(), body, "claude-sonnet-4-5") + parts := gjson.GetBytes(output, "messages.0.content").Array() + if len(parts) != 2 { + t.Fatalf("content length = %d, want 2 after invalid thinking strip: %s", len(parts), output) + } + if parts[1].Get("signature").Exists() { + t.Fatalf("tool_use signature should be removed before Claude upstream: %s", output) } - if got := hits.Load(); got != 0 { - t.Fatalf("expected invalid signature to be rejected before upstream request, got %d upstream hits", got) + found := false + for _, entry := range hook.AllEntries() { + if entry.Level != log.DebugLevel { + continue + } + if entry.Data["component"] != "signature_sanitizer" || + entry.Data["executor"] != "claude" || + entry.Data["action"] != "sanitize_claude_messages" { + continue + } + if entry.Data["dropped_blocks"] != 1 { + t.Fatalf("dropped_blocks = %v, want 1", entry.Data["dropped_blocks"]) + } + if entry.Data["dropped_signatures"] != 1 { + t.Fatalf("dropped_signatures = %v, want 1", entry.Data["dropped_signatures"]) + } + found = true + } + if !found { + t.Fatal("expected debug log for Claude upstream signature sanitization") } + assertSignatureDebugDoesNotLeak(t, hook, rawSignature) } func TestAntigravityExecutor_NonStrictBypassSkipsPrecheck(t *testing.T) { diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 626a90ab..6d6b975f 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -22,6 +22,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -44,6 +45,38 @@ type ClaudeExecutor struct { // Previously "proxy_" was used but this is a detectable fingerprint difference. const claudeToolPrefix = "" +func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string) []byte { + sanitized, report := sigcompat.SanitizeClaudeMessagesForClaudeUpstream(body, baseModel) + logClaudeSignatureSanitizeReport(ctx, baseModel, report) + return sanitized +} + +func logClaudeSignatureSanitizeReport(ctx context.Context, baseModel string, report sigcompat.SignatureSanitizeReport) { + if report.DroppedBlocks == 0 && report.DroppedSignatures == 0 && report.ReplacedSignatures == 0 { + return + } + + fields := log.Fields{ + "component": "signature_sanitizer", + "executor": "claude", + "action": "sanitize_claude_messages", + "target_provider": string(report.TargetProvider), + "target_model": baseModel, + "preserved": report.Preserved, + "dropped_blocks": report.DroppedBlocks, + "dropped_signatures": report.DroppedSignatures, + "replaced_signatures": report.ReplacedSignatures, + } + if len(report.Decisions) > 0 { + decision := report.Decisions[0] + fields["first_block_kind"] = string(decision.BlockKind) + fields["first_detected_provider"] = string(decision.DetectedProvider) + fields["first_reason"] = decision.Reason + } + + helps.LogWithRequestID(ctx).WithFields(fields).Debug("claude executor: sanitized signature history before upstream") +} + // oauthToolRenameMap maps OpenCode-style (lowercase) tool names to Claude Code-style // (TitleCase) names. Anthropic uses tool name fingerprinting to detect third-party // clients on OAuth traffic. Renaming to official names avoids extra-usage billing. @@ -195,6 +228,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if oauthToken { bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled()) } + bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel) // Enable cch signing by default for OAuth tokens (not just experimental flag). // Claude Code always computes cch; missing or invalid cch is a detectable fingerprint. if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) { @@ -372,6 +406,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if oauthToken { bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled()) } + bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel) // Enable cch signing by default for OAuth tokens (not just experimental flag). if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) { bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream) @@ -613,6 +648,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut if isClaudeOAuthToken(apiKey) { body, _ = prepareClaudeOAuthToolNamesForUpstream(body, claudeToolPrefix, auth.ToolPrefixDisabled()) } + body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel) url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index f5bca55a..2ac32ebd 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1251,6 +1251,63 @@ func TestClaudeExecutor_CountTokens_AppliesCacheControlGuards(t *testing.T) { } } +func TestClaudeExecutor_ExecuteSanitizesSignaturesBeforeUpstream(t *testing.T) { + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + seenBody = bytes.Clone(body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-4-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + }} + + payload := []byte(`{ + "model": "claude-sonnet-4-5", + "max_tokens": 16, + "messages": [ + {"role":"assistant","content":[ + {"type":"thinking","thinking":"drop this","signature":""}, + {"type":"text","text":"I will run git status."}, + {"type":"tool_use","id":"Bash-1","name":"Bash","input":{"command":"git status"},"signature":"bad","thoughtSignature":"bad2","model":"claude-opus-4-1"} + ]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"Bash-1","content":"ok"}]} + ] + }`) + + if _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-4-5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("claude"), + Stream: false, + }); err != nil { + t.Fatalf("Execute error: %v", err) + } + + parts := gjson.GetBytes(seenBody, "messages.0.content").Array() + if len(parts) != 2 { + t.Fatalf("messages.0.content length = %d, want 2; body=%s", len(parts), seenBody) + } + if parts[0].Get("type").String() != "text" { + t.Fatalf("first remaining part = %s, want text", parts[0].Raw) + } + toolUse := parts[1] + if toolUse.Get("type").String() != "tool_use" { + t.Fatalf("second remaining part = %s, want tool_use", toolUse.Raw) + } + for _, path := range []string{"signature", "thoughtSignature", "model"} { + if toolUse.Get(path).Exists() { + t.Fatalf("tool_use.%s should be removed before upstream: %s", path, seenBody) + } + } +} + func hasTTLOrderingViolation(payload []byte) bool { seen5m := false violates := false diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index a5899efb..a96e805c 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -285,6 +285,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { body = ensureImageGenerationTool(body, baseModel, auth) } + body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses" @@ -443,6 +444,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { body = ensureImageGenerationTool(body, baseModel, auth) } + body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" @@ -546,6 +548,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { body = ensureImageGenerationTool(body, baseModel, auth) } + body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses" diff --git a/internal/runtime/executor/codex_executor_signature_test.go b/internal/runtime/executor/codex_executor_signature_test.go new file mode 100644 index 00000000..0702dd6c --- /dev/null +++ b/internal/runtime/executor/codex_executor_signature_test.go @@ -0,0 +1,138 @@ +package executor + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func validCodexReasoningEncryptedContentForTest() string { + payload := make([]byte, 1+8+16+16+32) + payload[0] = 0x80 + for i := 9; i < len(payload); i++ { + payload[i] = byte(i) + } + return base64.RawURLEncoding.EncodeToString(payload) +} + +func newCodexSignatureTestAuth(serverURL string) *cliproxyauth.Auth { + return &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": serverURL, + "api_key": "test", + }} +} + +func TestCodexExecutorDropsInvalidReasoningEncryptedContentFromFinalRequest(t *testing.T) { + validEncryptedContent := validCodexReasoningEncryptedContentForTest() + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + gotBody = body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null}}\n\n")) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + _, err := executor.Execute(context.Background(), newCodexSignatureTestAuth(server.URL), cliproxyexecutor.Request{ + Model: "gpt-5.4", + Payload: []byte(`{"model":"gpt-5.4","input":[` + + `{"id":"rs_bad","type":"reasoning","encrypted_content":"gAAAAABqFTIa\u2026abc","summary":[]},` + + `{"id":"rs_non_string","type":"reasoning","encrypted_content":123,"summary":[]},` + + `{"id":"rs_good","type":"reasoning","encrypted_content":"` + validEncryptedContent + `","summary":[]},` + + `{"role":"user","content":"hello","encrypted_content":"leave-message-alone"}` + + `]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Stream: false, + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() { + t.Fatalf("invalid reasoning encrypted_content exists, want removed; body=%s", string(gotBody)) + } + if gjson.GetBytes(gotBody, "input.1.encrypted_content").Exists() { + t.Fatalf("non-string reasoning encrypted_content exists, want removed; body=%s", string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "input.2.encrypted_content").String(); got != validEncryptedContent { + t.Fatalf("valid reasoning encrypted_content = %q, want preserved", got) + } + if got := gjson.GetBytes(gotBody, "input.3.encrypted_content").String(); got != "leave-message-alone" { + t.Fatalf("non-reasoning encrypted_content = %q, want untouched", got) + } +} + +func TestCodexExecutorExecuteStreamDropsInvalidReasoningEncryptedContentFromFinalRequest(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + gotBody = body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null}}\n\n")) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + result, err := executor.ExecuteStream(context.Background(), newCodexSignatureTestAuth(server.URL), cliproxyexecutor.Request{ + Model: "gpt-5.4", + Payload: []byte(`{"model":"gpt-5.4","stream":true,"input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"bad","summary":[]}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Stream: true, + }) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + for range result.Chunks { + } + if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() { + t.Fatalf("invalid stream reasoning encrypted_content exists, want removed; body=%s", string(gotBody)) + } +} + +func TestCodexExecutorCompactDropsInvalidReasoningEncryptedContentFromFinalRequest(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + gotBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"resp_1","object":"response.compaction","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + _, err := executor.Execute(context.Background(), newCodexSignatureTestAuth(server.URL), cliproxyexecutor.Request{ + Model: "gpt-5.4", + Payload: []byte(`{"model":"gpt-5.4","input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"bad","summary":[]}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Alt: "responses/compact", + Stream: false, + }) + if err != nil { + t.Fatalf("Execute compact error: %v", err) + } + if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() { + t.Fatalf("invalid compact reasoning encrypted_content exists, want removed; body=%s", string(gotBody)) + } +} diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 5594356b..8339114f 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -213,6 +213,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { body = ensureImageGenerationTool(body, baseModel, auth) } + body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body) httpURL := strings.TrimSuffix(baseURL, "/") + "/responses" wsURL, err := buildCodexResponsesWebsocketURL(httpURL) @@ -417,6 +418,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { body = ensureImageGenerationTool(body, baseModel, auth) } + body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body) httpURL := strings.TrimSuffix(baseURL, "/") + "/responses" wsURL, err := buildCodexResponsesWebsocketURL(httpURL) diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 8475e372..2be71afc 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -125,6 +125,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil { translated = updated } + translated = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "openai compat executor", translated) } reporter.SetTranslatedReasoningEffort(translated, to.String()) diff --git a/internal/runtime/executor/openai_responses_signature.go b/internal/runtime/executor/openai_responses_signature.go new file mode 100644 index 00000000..e3a59f2f --- /dev/null +++ b/internal/runtime/executor/openai_responses_signature.go @@ -0,0 +1,68 @@ +package executor + +import ( + "context" + "fmt" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func sanitizeOpenAIResponsesReasoningEncryptedContent(ctx context.Context, provider string, body []byte) []byte { + input := gjson.GetBytes(body, "input") + if !input.Exists() || !input.IsArray() { + return body + } + provider = strings.TrimSpace(provider) + if provider == "" { + provider = "openai responses upstream" + } + + updated := body + for index, item := range input.Array() { + if strings.TrimSpace(item.Get("type").String()) != "reasoning" { + continue + } + + encryptedContentPath := fmt.Sprintf("input.%d.encrypted_content", index) + encryptedContent := gjson.GetBytes(updated, encryptedContentPath) + if !encryptedContent.Exists() { + continue + } + + reason := "" + switch encryptedContent.Type { + case gjson.String: + rawSignature := encryptedContent.String() + if rawSignature != strings.TrimSpace(rawSignature) { + reason = "encrypted_content has leading or trailing whitespace" + } else if _, err := signature.InspectGPTReasoningSignature(rawSignature); err != nil { + reason = err.Error() + } + case gjson.Null: + reason = "encrypted_content is null" + default: + reason = fmt.Sprintf("encrypted_content must be a string, got %s", encryptedContent.Type.String()) + } + if reason == "" { + continue + } + + next, err := sjson.DeleteBytes(updated, encryptedContentPath) + if err != nil { + helps.LogWithRequestID(ctx).Debugf("%s: failed to drop invalid reasoning encrypted_content at input[%d]: %v", provider, index, err) + continue + } + updated = next + + itemID := strings.TrimSpace(gjson.GetBytes(updated, fmt.Sprintf("input.%d.id", index)).String()) + if itemID == "" { + itemID = fmt.Sprintf("input[%d]", index) + } + helps.LogWithRequestID(ctx).Debugf("%s: dropped invalid reasoning encrypted_content at input[%d] item_id=%q reason=%s", provider, index, itemID, reason) + } + return updated +} diff --git a/internal/signature/claude_messages_sanitize.go b/internal/signature/claude_messages_sanitize.go index aec08879..4389704c 100644 --- a/internal/signature/claude_messages_sanitize.go +++ b/internal/signature/claude_messages_sanitize.go @@ -9,10 +9,11 @@ import ( ) type ClaudeMessagesSignatureSanitizeOptions struct { - TargetProvider SignatureProvider - TargetModel string - DropEmptyMessages bool - DropToolSignatures bool + TargetProvider SignatureProvider + TargetModel string + DropEmptyMessages bool + DropToolSignatures bool + DropEmptyThinkingPlaceholders bool } type SignatureSanitizeReport struct { @@ -35,6 +36,20 @@ func SanitizeClaudeMessagesSignaturesForModel(payload []byte, targetModel string }) } +// SanitizeClaudeMessagesForClaudeUpstream prepares a Claude /v1/messages body +// for native Claude upstreams. Invalid thinking blocks are dropped, valid +// thinking signatures are normalized to Claude provider-native E-form, and +// tool_use blocks keep only their tool-call payload. +func SanitizeClaudeMessagesForClaudeUpstream(payload []byte, targetModel string) ([]byte, SignatureSanitizeReport) { + return SanitizeClaudeMessagesSignaturesForTarget(payload, ClaudeMessagesSignatureSanitizeOptions{ + TargetProvider: SignatureProviderClaude, + TargetModel: targetModel, + DropEmptyMessages: true, + DropToolSignatures: true, + DropEmptyThinkingPlaceholders: true, + }) +} + // 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 @@ -103,7 +118,7 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag continue } - if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) { + if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) && !opts.DropEmptyThinkingPlaceholders { keptParts = append(keptParts, part.Raw) continue } @@ -162,7 +177,7 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag func stripClaudeToolUseSignatureFields(part gjson.Result) (string, bool) { updated := part.Raw changed := false - for _, sigPath := range claudeToolUseSignaturePaths() { + for _, sigPath := range claudeToolUseProvenancePaths() { if !gjson.Get(updated, sigPath).Exists() { continue } @@ -231,11 +246,16 @@ func sanitizeClaudeToolUseSignature(part gjson.Result, targetProvider SignatureP 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 { diff --git a/internal/signature/gemini_sanitize.go b/internal/signature/gemini_sanitize.go new file mode 100644 index 00000000..e639255c --- /dev/null +++ b/internal/signature/gemini_sanitize.go @@ -0,0 +1,140 @@ +package signature + +import ( + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// GeminiReplaySignatureOrBypass returns a Gemini-replayable thoughtSignature. +// Compatible Gemini signatures are normalized and preserved. Missing, unknown, +// or cross-provider signatures are replaced with Gemini's bypass sentinel. +func GeminiReplaySignatureOrBypass(rawSignature string, blockKind SignatureBlockKind) string { + if signature, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, rawSignature, blockKind); ok { + return signature + } + decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind) + if decision.Action == SignatureActionReplaceWithGeminiBypass && decision.ReplacementSignature != "" { + return decision.ReplacementSignature + } + return GeminiSkipThoughtSignatureValidator +} + +// SanitizeGeminiRequestThoughtSignatures applies Gemini replay policy to a +// Gemini-shaped request. Model-turn functionCall, thought, and signed parts keep +// compatible Gemini signatures and use the bypass sentinel otherwise. User-turn +// functionResponse parts must not carry thoughtSignature fields. +func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) []byte { + contentsPath = strings.TrimSpace(contentsPath) + if contentsPath == "" { + contentsPath = "contents" + } + + contents := gjson.GetBytes(payload, contentsPath) + if !contents.IsArray() { + return payload + } + + contents.ForEach(func(contentIdx, content gjson.Result) bool { + isModelTurn := content.Get("role").String() == "model" + parts := content.Get("parts") + if !parts.IsArray() { + return true + } + + parts.ForEach(func(partIdx, part gjson.Result) bool { + partPath := fmt.Sprintf("%s.%d.parts.%d", contentsPath, contentIdx.Int(), partIdx.Int()) + if part.Get("functionResponse").Exists() { + _, hadSignature := geminiPartThoughtSignature(part) + payload = deleteGeminiPartThoughtSignatureFields(payload, partPath) + if hadSignature { + logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), SignatureCompatibilityDecision{ + TargetProvider: SignatureProviderGemini, + BlockKind: SignatureBlockKindGeminiModelPart, + Action: SignatureActionDropSignature, + Reason: "user-turn functionResponse parts cannot replay thought signatures", + }, "", true) + } + return true + } + if !isModelTurn { + return true + } + + hasFunctionCall := part.Get("functionCall").Exists() + hasThought := part.Get("thought").Exists() + rawSignature, hasSignature := geminiPartThoughtSignature(part) + if !hasFunctionCall && !hasThought && !hasSignature { + return true + } + + blockKind := SignatureBlockKindGeminiModelPart + if hasFunctionCall { + blockKind = SignatureBlockKindGeminiFunctionCall + } + payload = deleteGeminiPartThoughtSignatureFields(payload, partPath) + decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind) + replaySignature := GeminiReplaySignatureOrBypass(rawSignature, blockKind) + payload, _ = sjson.SetBytes(payload, partPath+".thoughtSignature", replaySignature) + if decision.Action != SignatureActionPreserve { + logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), decision, rawSignature, hasSignature) + } + return true + }) + return true + }) + + return payload +} + +func logGeminiThoughtSignatureSanitize(contentsPath string, contentIndex, partIndex int, decision SignatureCompatibilityDecision, rawSignature string, hasSignature bool) { + log.WithFields(log.Fields{ + "component": "signature_sanitizer", + "target_provider": string(SignatureProviderGemini), + "action": string(decision.Action), + "reason": decision.Reason, + "contents_path": contentsPath, + "content_index": contentIndex, + "part_index": partIndex, + "block_kind": string(decision.BlockKind), + "detected_provider": string(decision.DetectedProvider), + "has_signature": hasSignature, + "signature_length": len(strings.TrimSpace(rawSignature)), + }).Debug("gemini request: sanitized thoughtSignature before upstream") +} + +func geminiPartThoughtSignature(part gjson.Result) (string, bool) { + for _, path := range []string{ + "thoughtSignature", + "thought_signature", + "functionCall.thoughtSignature", + "functionCall.thought_signature", + "functionResponse.thoughtSignature", + "functionResponse.thought_signature", + "extra_content.google.thought_signature", + } { + result := part.Get(path) + if result.Exists() { + return result.String(), true + } + } + return "", false +} + +func deleteGeminiPartThoughtSignatureFields(payload []byte, partPath string) []byte { + for _, path := range []string{ + "thoughtSignature", + "thought_signature", + "functionCall.thoughtSignature", + "functionCall.thought_signature", + "functionResponse.thoughtSignature", + "functionResponse.thought_signature", + "extra_content.google.thought_signature", + } { + payload, _ = sjson.DeleteBytes(payload, partPath+"."+path) + } + return payload +} diff --git a/internal/signature/gemini_sanitize_test.go b/internal/signature/gemini_sanitize_test.go new file mode 100644 index 00000000..8faf8a85 --- /dev/null +++ b/internal/signature/gemini_sanitize_test.go @@ -0,0 +1,122 @@ +package signature + +import ( + "fmt" + "strings" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/tidwall/gjson" +) + +func newSignatureDebugHook(t *testing.T) *test.Hook { + t.Helper() + + previousLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + hook := test.NewLocal(log.StandardLogger()) + t.Cleanup(func() { + hook.Reset() + log.SetLevel(previousLevel) + }) + return hook +} + +func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden string) { + t.Helper() + + if forbidden == "" { + return + } + for _, entry := range hook.AllEntries() { + if strings.Contains(entry.Message, forbidden) { + t.Fatalf("debug log leaked signature in message: %q", entry.Message) + } + for key, value := range entry.Data { + if strings.Contains(fmt.Sprint(value), forbidden) { + t.Fatalf("debug log leaked signature in field %q: %v", key, value) + } + } + } +} + +func TestSanitizeGeminiRequestThoughtSignaturesPreservesGeminiSignature(t *testing.T) { + sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39}) + input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + sig + `"}]}]}`) + + out := SanitizeGeminiRequestThoughtSignatures(input, "contents") + + if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != sig { + t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, sig, string(out)) + } +} + +func TestSanitizeGeminiRequestThoughtSignaturesReplacesBase64UUIDFunctionCall(t *testing.T) { + sig := testGeminiThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3")) + input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{},"thoughtSignature":"` + sig + `"}}]}]}`) + + out := SanitizeGeminiRequestThoughtSignatures(input, "contents") + + if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator { + t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "contents.0.parts.0.functionCall.thoughtSignature").Exists() { + t.Fatalf("nested functionCall thoughtSignature should be removed. Output: %s", string(out)) + } +} + +func TestSanitizeGeminiRequestThoughtSignaturesLogsBypassReplacement(t *testing.T) { + hook := newSignatureDebugHook(t) + sig := testGeminiThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3")) + input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{},"thoughtSignature":"` + sig + `"}}]}]}`) + + out := SanitizeGeminiRequestThoughtSignatures(input, "contents") + if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator { + t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out)) + } + + found := false + for _, entry := range hook.AllEntries() { + if entry.Level != log.DebugLevel { + continue + } + if entry.Data["component"] != "signature_sanitizer" || + entry.Data["target_provider"] != string(SignatureProviderGemini) || + entry.Data["action"] != "replace_with_gemini_bypass" { + continue + } + if entry.Data["block_kind"] != string(SignatureBlockKindGeminiFunctionCall) { + t.Fatalf("block_kind = %v, want %s", entry.Data["block_kind"], SignatureBlockKindGeminiFunctionCall) + } + found = true + } + if !found { + t.Fatal("expected debug log for Gemini thoughtSignature bypass replacement") + } + assertSignatureDebugDoesNotLeak(t, hook, sig) +} + +func TestSanitizeGeminiRequestThoughtSignaturesReplacesField2WrappedUUIDFunctionCall(t *testing.T) { + sig := testGemini3ThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3")) + input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + sig + `"}]}]}}`) + + out := SanitizeGeminiRequestThoughtSignatures(input, "request.contents") + + if got := gjson.GetBytes(out, "request.contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator { + t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out)) + } +} + +func TestSanitizeGeminiRequestThoughtSignaturesRemovesFunctionResponseSignature(t *testing.T) { + input := []byte(`{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"result":"ok"},"thoughtSignature":"bad"},"thoughtSignature":"bad"}]}]}`) + + out := SanitizeGeminiRequestThoughtSignatures(input, "contents") + + if gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").Exists() { + t.Fatalf("functionResponse top-level thoughtSignature should be removed. Output: %s", string(out)) + } + if gjson.GetBytes(out, "contents.0.parts.0.functionResponse.thoughtSignature").Exists() { + t.Fatalf("functionResponse nested thoughtSignature should be removed. Output: %s", string(out)) + } +} diff --git a/internal/signature/provider_compatibility_test.go b/internal/signature/provider_compatibility_test.go index dcb5b829..541bfa15 100644 --- a/internal/signature/provider_compatibility_test.go +++ b/internal/signature/provider_compatibility_test.go @@ -282,3 +282,58 @@ func TestSanitizeClaudeMessagesSignaturesForModel_DropsEmptyAssistantMessage(t * t.Fatalf("remaining role = %q, want user", got) } } + +func TestSanitizeClaudeMessagesForClaudeUpstream_DropsInvalidThinkingAndCleansToolUse(t *testing.T) { + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop me","signature":""},{"type":"text","text":"answer"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"git status"},"signature":"bad","thoughtSignature":"bad2","thought_signature":"bad3","model":"claude-sonnet-4-5","extra_content":{"google":{"thought_signature":"bad4"}}}]}]}`) + + output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5") + if report.DroppedBlocks != 1 { + t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report) + } + parts := gjson.GetBytes(output, "messages.0.content").Array() + if len(parts) != 2 { + t.Fatalf("content length = %d, want 2: %s", len(parts), output) + } + if parts[0].Get("type").String() != "text" { + t.Fatalf("first remaining part = %s, want text", parts[0].Raw) + } + toolUse := parts[1] + if toolUse.Get("type").String() != "tool_use" { + t.Fatalf("second remaining part = %s, want tool_use", toolUse.Raw) + } + if got := toolUse.Get("id").String(); got != "toolu_1" { + t.Fatalf("tool_use id = %q, want toolu_1", got) + } + for _, path := range []string{ + "signature", + "thoughtSignature", + "thought_signature", + "model", + "extra_content", + } { + if toolUse.Get(path).Exists() { + t.Fatalf("tool_use.%s should be removed: %s", path, toolUse.Raw) + } + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstream_NormalizesValidThinkingAndDropsEmptyMessage(t *testing.T) { + nativeSig := testClaudeThinkingSignature() + doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig)) + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + doubleEncoded + `"},{"type":"text","text":"answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"drop"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`) + + output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5") + if report.Preserved != 1 || report.DroppedBlocks != 1 { + t.Fatalf("unexpected report: %+v", report) + } + messages := gjson.GetBytes(output, "messages").Array() + if len(messages) != 2 { + t.Fatalf("messages length = %d, want 2: %s", len(messages), output) + } + if got := messages[0].Get("content.0.signature").String(); got != nativeSig { + t.Fatalf("signature = %q, want provider-native %q", got, nativeSig) + } + if got := messages[1].Get("role").String(); got != "user" { + t.Fatalf("remaining second role = %q, want user", got) + } +} diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 456475f1..fe2c8cde 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" @@ -18,15 +19,30 @@ import ( ) func resolveThinkingSignature(modelName, thinkingText, rawSignature string) string { + targetProvider := sigcompat.SignatureProviderFromModelName(modelName) + if targetProvider == sigcompat.SignatureProviderGemini { + return resolveProviderCompatibleSignature(targetProvider, rawSignature, sigcompat.SignatureBlockKindGeminiModelPart) + } if cache.SignatureCacheEnabled() { return resolveCacheModeSignature(modelName, thinkingText, rawSignature) } - return resolveBypassModeSignature(rawSignature) + if signature := resolveProviderCompatibleSignature(targetProvider, rawSignature, sigcompat.SignatureBlockKindUnknown); signature != "" { + return signature + } + return resolveBypassModeSignatureForProvider(targetProvider, rawSignature) } func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) string { + targetProvider := sigcompat.SignatureProviderFromModelName(modelName) if thinkingText != "" { if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" { + if targetProvider == sigcompat.SignatureProviderClaude { + signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(cachedSig) + if !ok { + return "" + } + return signature + } return cachedSig } } @@ -43,6 +59,13 @@ func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) str } } if cache.HasValidSignature(modelName, clientSignature) { + if targetProvider == sigcompat.SignatureProviderClaude { + signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(clientSignature) + if !ok { + return "" + } + return signature + } return clientSignature } @@ -50,9 +73,23 @@ func resolveCacheModeSignature(modelName, thinkingText, rawSignature string) str } func resolveBypassModeSignature(rawSignature string) string { + return resolveBypassModeSignatureForProvider(sigcompat.SignatureProviderClaude, rawSignature) +} + +func resolveBypassModeSignatureForProvider(targetProvider sigcompat.SignatureProvider, rawSignature string) string { if rawSignature == "" { return "" } + if targetProvider != sigcompat.SignatureProviderClaude && targetProvider != sigcompat.SignatureProviderUnknown { + return "" + } + if targetProvider == sigcompat.SignatureProviderClaude { + signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature) + if !ok { + return "" + } + return signature + } normalized, err := normalizeClaudeBypassSignature(rawSignature) if err != nil { return "" @@ -61,12 +98,143 @@ func resolveBypassModeSignature(rawSignature string) string { } func hasResolvedThinkingSignature(modelName, signature string) bool { + targetProvider := sigcompat.SignatureProviderFromModelName(modelName) + if targetProvider == sigcompat.SignatureProviderClaude { + _, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(signature) + return ok + } + if _, ok := sigcompat.CompatibleSignatureForProvider(targetProvider, signature); ok { + return true + } if cache.SignatureCacheEnabled() { return cache.HasValidSignature(modelName, signature) } return signature != "" } +func resolveProviderCompatibleSignature(targetProvider sigcompat.SignatureProvider, rawSignature string, blockKind sigcompat.SignatureBlockKind) string { + if rawSignature == "" { + return "" + } + if targetProvider == sigcompat.SignatureProviderClaude { + signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature) + if !ok { + return "" + } + return signature + } + signature, ok := sigcompat.CompatibleSignatureForProviderBlock(targetProvider, rawSignature, blockKind) + if !ok { + return "" + } + return signature +} + +func resolveToolUseThoughtSignature(modelName string, contentResult gjson.Result, allowSyntheticFallback bool) string { + targetProvider := sigcompat.SignatureProviderFromModelName(modelName) + if targetProvider == sigcompat.SignatureProviderGemini { + for _, path := range []string{ + "signature", + "thought_signature", + "extra_content.google.thought_signature", + } { + if signatureResult := contentResult.Get(path); signatureResult.Exists() { + if signature := resolveProviderCompatibleSignature(targetProvider, signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall); signature != "" { + return signature + } + } + } + if allowSyntheticFallback { + return sigcompat.GeminiSkipThoughtSignatureValidator + } + return "" + } + + for _, path := range []string{ + "signature", + "thought_signature", + "extra_content.google.thought_signature", + } { + if signatureResult := contentResult.Get(path); signatureResult.Exists() { + if signature := resolveProviderCompatibleSignature(targetProvider, signatureResult.String(), sigcompat.SignatureBlockKindUnknown); signature != "" { + return signature + } + } + } + if targetProvider == sigcompat.SignatureProviderClaude { + return "" + } + return sigcompat.GeminiSkipThoughtSignatureValidator +} + +func firstToolUseSignatureField(contentResult gjson.Result) (string, string, bool) { + for _, path := range []string{ + "signature", + "thought_signature", + "extra_content.google.thought_signature", + } { + signatureResult := contentResult.Get(path) + if signatureResult.Exists() { + return path, signatureResult.String(), true + } + } + return "", "", false +} + +func logDroppedAntigravityThinkingSignature(modelName string, messageIndex, contentIndex int, thinkingText string, signatureResult gjson.Result) { + rawSignature := signatureResult.String() + fields := log.Fields{ + "component": "signature_sanitizer", + "translator": "antigravity_claude", + "target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)), + "action": "drop_thinking_block", + "reason": "missing_or_incompatible_signature", + "model": modelName, + "message_index": messageIndex, + "content_index": contentIndex, + "thinking_length": len(thinkingText), + "has_signature": signatureResult.Exists(), + "signature_length": len(strings.TrimSpace(rawSignature)), + } + if signatureResult.Exists() { + fields["detected_provider"] = string(sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking)) + } + log.WithFields(fields).Debug("antigravity claude translator: dropped thinking block with incompatible signature") +} + +func logDroppedAntigravityEmptyThinking(modelName string, messageIndex, contentIndex int) { + log.WithFields(log.Fields{ + "component": "signature_sanitizer", + "translator": "antigravity_claude", + "target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)), + "action": "drop_thinking_block", + "reason": "empty_thinking_text", + "model": modelName, + "message_index": messageIndex, + "content_index": contentIndex, + }).Debug("antigravity claude translator: dropped empty thinking block") +} + +func logDroppedAntigravityToolUseSignature(modelName string, messageIndex, contentIndex int, contentResult gjson.Result) { + path, rawSignature, ok := firstToolUseSignatureField(contentResult) + if !ok { + return + } + log.WithFields(log.Fields{ + "component": "signature_sanitizer", + "translator": "antigravity_claude", + "target_provider": string(sigcompat.SignatureProviderFromModelName(modelName)), + "action": "drop_tool_use_signature", + "reason": "missing_or_incompatible_signature", + "model": modelName, + "message_index": messageIndex, + "content_index": contentIndex, + "signature_path": path, + "signature_length": len(strings.TrimSpace(rawSignature)), + "detected_provider": string(sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindUnknown)), + }).Debug("antigravity claude translator: dropped tool_use signature field") +} + // ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format. // It extracts the model name, system instruction, message contents, and tool declarations // from the raw JSON request and returns them in the format expected by the Gemini CLI API. @@ -147,19 +315,14 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ if contentsResult.IsArray() { contentResults := contentsResult.Array() numContents := len(contentResults) - var currentMessageThinkingSignature string for j := 0; j < numContents; j++ { contentResult := contentResults[j] contentTypeResult := contentResult.Get("type") if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" { // Use GetThinkingText to handle wrapped thinking objects thinkingText := thinking.GetThinkingText(contentResult) - signature := resolveThinkingSignature(modelName, thinkingText, contentResult.Get("signature").String()) - - // Store for subsequent tool_use in the same message - if hasResolvedThinkingSignature(modelName, signature) { - currentMessageThinkingSignature = signature - } + signatureResult := contentResult.Get("signature") + signature := resolveThinkingSignature(modelName, thinkingText, signatureResult.String()) // Skip unsigned thinking blocks instead of converting them to text. isUnsigned := !hasResolvedThinkingSignature(modelName, signature) @@ -168,7 +331,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Claude requires assistant messages to start with thinking blocks when thinking is enabled // Converting to text would break this requirement if isUnsigned { - // log.Debugf("Dropping unsigned thinking block (no valid signature)") + logDroppedAntigravityThinkingSignature(modelName, i, j, thinkingText, signatureResult) enableThoughtTranslate = false continue } @@ -178,6 +341,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // omits the required inner "thinking" field, causing: // 400 "messages.N.content.0.thinking.thinking: Field required" if thinkingText == "" { + logDroppedAntigravityEmptyThinking(modelName, i, j) continue } @@ -226,15 +390,11 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ if argsRaw != "" { partJSON := []byte(`{}`) - // Use skip_thought_signature_validator for tool calls without valid thinking signature - // This is the approach used in opencode-google-antigravity-auth for Gemini - // and also works for Claude through Antigravity API - const skipSentinel = "skip_thought_signature_validator" - if hasResolvedThinkingSignature(modelName, currentMessageThinkingSignature) { - partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", currentMessageThinkingSignature) + signature := resolveToolUseThoughtSignature(modelName, contentResult, true) + if signature != "" { + partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature) } else { - // No valid signature - use skip sentinel to bypass validation - partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", skipSentinel) + logDroppedAntigravityToolUseSignature(modelName, i, j, contentResult) } if functionID != "" { diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index f4ffa3e4..017078d4 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -3,10 +3,13 @@ package claude import ( "bytes" "encoding/base64" + "fmt" "strings" "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" "github.com/tidwall/gjson" "google.golang.org/protobuf/encoding/protowire" ) @@ -22,6 +25,13 @@ func testAnthropicNativeSignature(t *testing.T) string { return signature } +func testAntigravityClaudeSignature(t *testing.T) (string, string) { + t.Helper() + + native := testAnthropicNativeSignature(t) + return native, base64.StdEncoding.EncodeToString([]byte(native)) +} + func testMinimalAnthropicSignature(t *testing.T) string { t.Helper() @@ -70,6 +80,37 @@ func uint64Ptr(v uint64) *uint64 { return &v } +func newSignatureDebugHook(t *testing.T) *test.Hook { + t.Helper() + + previousLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + hook := test.NewLocal(log.StandardLogger()) + t.Cleanup(func() { + hook.Reset() + log.SetLevel(previousLevel) + }) + return hook +} + +func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden string) { + t.Helper() + + if forbidden == "" { + return + } + for _, entry := range hook.AllEntries() { + if strings.Contains(entry.Message, forbidden) { + t.Fatalf("debug log leaked signature in message: %q", entry.Message) + } + for key, value := range entry.Data { + if strings.Contains(fmt.Sprint(value), forbidden) { + t.Fatalf("debug log leaked signature in field %q: %v", key, value) + } + } + } +} + func TestConvertClaudeRequestToAntigravity_StripsClaudeCodeAttribution(t *testing.T) { inputJSON := []byte(`{ "model": "claude-sonnet-4-5", @@ -114,6 +155,23 @@ func testGeminiRawSignature(t *testing.T) string { return signature } +func testGeminiEPrefixSignature(t *testing.T) string { + t.Helper() + + inner := []byte{} + inner = protowire.AppendTag(inner, 1, protowire.BytesType) + inner = protowire.AppendBytes(inner, []byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34}) + + payload := []byte{} + payload = protowire.AppendTag(payload, 2, protowire.BytesType) + payload = protowire.AppendBytes(payload, inner) + signature := base64.StdEncoding.EncodeToString(payload) + if !strings.HasPrefix(signature, "E") { + t.Fatalf("test signature should start with E, got %q", signature[:1]) + } + return signature +} + func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) { inputJSON := []byte(`{ "model": "claude-3-5-sonnet-20240620", @@ -182,8 +240,7 @@ func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) { func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { cache.ClearSignatureCache("") - // Valid signature must be at least 50 characters - validSignature := "abc123validSignature1234567890123456789012345678901234567890" + nativeSignature, antigravitySignature := testAntigravityClaudeSignature(t) thinkingText := "Let me think..." // Pre-cache the signature (simulating a previous response for the same thinking text) @@ -197,14 +254,14 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { { "role": "assistant", "content": [ - {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}, + {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"}, {"type": "text", "text": "Answer"} ] } ] }`) - cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature) + cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) @@ -217,8 +274,8 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) { if firstPart.Get("text").String() != thinkingText { t.Error("thinking text mismatch") } - if firstPart.Get("thoughtSignature").String() != validSignature { - t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, firstPart.Get("thoughtSignature").String()) + if firstPart.Get("thoughtSignature").String() != antigravitySignature { + t.Errorf("Expected thoughtSignature '%s', got '%s'", antigravitySignature, firstPart.Get("thoughtSignature").String()) } } @@ -563,7 +620,7 @@ func TestConvertClaudeRequestToAntigravity_BypassModeNormalizesESignature(t *tes }) thinkingText := "Let me think..." - cachedSignature := "cachedSignature1234567890123456789012345678901234567890123" + cachedSignature := base64.StdEncoding.EncodeToString([]byte(testMinimalAnthropicSignature(t))) rawSignature := testAnthropicNativeSignature(t) expectedSignature := base64.StdEncoding.EncodeToString([]byte(rawSignature)) @@ -750,6 +807,57 @@ func TestConvertClaudeRequestToAntigravity_BypassModeDropsInvalidSignature(t *te } } +func TestConvertClaudeRequestToAntigravity_LogsDroppedInvalidThinkingSignature(t *testing.T) { + cache.ClearSignatureCache("") + previous := cache.SignatureCacheEnabled() + cache.SetSignatureCacheEnabled(false) + t.Cleanup(func() { + cache.SetSignatureCacheEnabled(previous) + cache.ClearSignatureCache("") + }) + + hook := newSignatureDebugHook(t) + invalidRawSignature := testNonAnthropicRawSignature(t) + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5-thinking", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me think...", "signature": "` + invalidRawSignature + `"}, + {"type": "text", "text": "Answer"} + ] + } + ] + }`) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) + parts := gjson.GetBytes(output, "request.contents.0.parts").Array() + if len(parts) != 1 || parts[0].Get("text").String() != "Answer" { + t.Fatalf("expected invalid thinking block to be dropped, output: %s", output) + } + + found := false + for _, entry := range hook.AllEntries() { + if entry.Level != log.DebugLevel { + continue + } + if entry.Data["component"] != "signature_sanitizer" || + entry.Data["translator"] != "antigravity_claude" || + entry.Data["action"] != "drop_thinking_block" { + continue + } + if entry.Data["model"] != "claude-sonnet-4-5-thinking" { + t.Fatalf("model field = %v, want claude-sonnet-4-5-thinking", entry.Data["model"]) + } + found = true + } + if !found { + t.Fatal("expected debug log for dropped Antigravity Claude thinking signature") + } + assertSignatureDebugDoesNotLeak(t, hook, invalidRawSignature) +} + func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiSignature(t *testing.T) { cache.ClearSignatureCache("") previous := cache.SignatureCacheEnabled() @@ -784,6 +892,42 @@ func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiSignature(t *tes } } +func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiEPrefixSignature(t *testing.T) { + cache.ClearSignatureCache("") + previous := cache.SignatureCacheEnabled() + cache.SetSignatureCacheEnabled(false) + t.Cleanup(func() { + cache.SetSignatureCacheEnabled(previous) + cache.ClearSignatureCache("") + }) + + geminiSig := testGeminiEPrefixSignature(t) + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5-thinking", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "hmm", "signature": "` + geminiSig + `"}, + {"type": "text", "text": "Answer"} + ] + } + ] + }`) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) + parts := gjson.GetBytes(output, "request.contents.0.parts").Array() + if len(parts) != 1 { + t.Fatalf("expected Gemini E-prefix signed thinking block to be dropped, got %d parts: %s", len(parts), output) + } + if parts[0].Get("text").String() != "Answer" { + t.Fatalf("expected remaining text part, got %s", parts[0].Raw) + } + if strings.Contains(string(output), geminiSig) { + t.Fatalf("Gemini E-prefix signature should not be forwarded. Output: %s", output) + } +} + func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) { cache.ClearSignatureCache("") @@ -935,18 +1079,67 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) { if funcCall.Get("id").String() != "call_123" { t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String()) } - // Verify skip_thought_signature_validator is added (bypass for tools without valid thinking) - expectedSig := "skip_thought_signature_validator" - actualSig := parts[0].Get("thoughtSignature").String() - if actualSig != expectedSig { - t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, actualSig) + if parts[0].Get("thoughtSignature").Exists() { + t.Errorf("Expected no thoughtSignature without valid Claude thinking signature, got '%s'", parts[0].Get("thoughtSignature").String()) + } +} + +func TestConvertClaudeRequestToAntigravity_ToolUse_DropsInvalidThoughtSignatureOnly(t *testing.T) { + hook := newSignatureDebugHook(t) + rawSignature := "skip_thought_signature_validator" + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_123", + "name": "get_weather", + "input": "{\"location\": \"Paris\"}", + "signature": "` + rawSignature + `" + } + ] + } + ] + }`) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) + part := gjson.GetBytes(output, "request.contents.0.parts.0") + + if !part.Get("functionCall").Exists() { + t.Fatalf("functionCall should be preserved, output: %s", output) + } + if got := part.Get("functionCall.name").String(); got != "get_weather" { + t.Fatalf("functionCall.name = %q, want get_weather", got) + } + if part.Get("thoughtSignature").Exists() { + t.Fatalf("invalid thoughtSignature should be removed, output: %s", output) + } + + found := false + for _, entry := range hook.AllEntries() { + if entry.Level != log.DebugLevel { + continue + } + if entry.Data["component"] != "signature_sanitizer" || + entry.Data["translator"] != "antigravity_claude" || + entry.Data["action"] != "drop_tool_use_signature" { + continue + } + found = true + } + if !found { + t.Fatal("expected debug log for dropped Antigravity Claude tool_use signature") } + assertSignatureDebugDoesNotLeak(t, hook, rawSignature) } -func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) { +func TestConvertClaudeRequestToAntigravity_ToolUse_DoesNotReuseThinkingSignature(t *testing.T) { cache.ClearSignatureCache("") - validSignature := "abc123validSignature1234567890123456789012345678901234567890" + nativeSignature, _ := testAntigravityClaudeSignature(t) thinkingText := "Let me think..." inputJSON := []byte(`{ @@ -959,7 +1152,7 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) { { "role": "assistant", "content": [ - {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}, + {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"}, { "type": "tool_use", "id": "call_123", @@ -971,18 +1164,17 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) { ] }`) - cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature) + cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) - // Check function call has the signature from the preceding thinking block (now in contents.1) part := gjson.Get(outputStr, "request.contents.1.parts.1") if part.Get("functionCall.name").String() != "get_weather" { t.Errorf("Expected functionCall, got %s", part.Raw) } - if part.Get("thoughtSignature").String() != validSignature { - t.Errorf("Expected thoughtSignature '%s' on tool_use, got '%s'", validSignature, part.Get("thoughtSignature").String()) + if part.Get("thoughtSignature").Exists() { + t.Fatalf("tool_use should not reuse preceding thinking thoughtSignature, output: %s", output) } } @@ -990,7 +1182,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) { cache.ClearSignatureCache("") // Case: text block followed by thinking block -> should be reordered to thinking first - validSignature := "abc123validSignature1234567890123456789012345678901234567890" + nativeSignature, _ := testAntigravityClaudeSignature(t) thinkingText := "Planning..." inputJSON := []byte(`{ @@ -1004,13 +1196,13 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) { "role": "assistant", "content": [ {"type": "text", "text": "Here is the plan."}, - {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"} + {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"} ] } ] }`) - cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature) + cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) @@ -1137,7 +1329,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderParallelFunctionCalls(t *testi func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionCall(t *testing.T) { cache.ClearSignatureCache("") - validSignature := "abc123validSignature1234567890123456789012345678901234567890" + nativeSignature, _ := testAntigravityClaudeSignature(t) thinkingText := "Let me think about this..." inputJSON := []byte(`{ @@ -1151,7 +1343,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionC "role": "assistant", "content": [ {"type": "text", "text": "Before thinking"}, - {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}, + {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"}, { "type": "tool_use", "id": "call_xyz", @@ -1164,7 +1356,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionC ] }`) - cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature) + cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) @@ -1536,7 +1728,7 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin cache.ClearSignatureCache("") // Last assistant message ends with signed thinking block - should be kept - validSignature := "abc123validSignature1234567890123456789012345678901234567890" + nativeSignature, _ := testAntigravityClaudeSignature(t) thinkingText := "Valid thinking..." inputJSON := []byte(`{ @@ -1550,13 +1742,13 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin "role": "assistant", "content": [ {"type": "text", "text": "Here is my answer"}, - {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"} + {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + nativeSignature + `"} ] } ] }`) - cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature) + cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, nativeSignature) output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) outputStr := string(output) diff --git a/internal/translator/antigravity/claude/signature_validation.go b/internal/translator/antigravity/claude/signature_validation.go index f0acbf8e..9431a4c7 100644 --- a/internal/translator/antigravity/claude/signature_validation.go +++ b/internal/translator/antigravity/claude/signature_validation.go @@ -17,6 +17,10 @@ func StripEmptySignatureThinkingBlocks(payload []byte) []byte { return signature.StripInvalidClaudeThinkingBlocks(payload, signature.ClaudeSignatureValidationOptions{PrefixOnly: true}) } +func StripInvalidBypassSignatureThinkingBlocks(payload []byte) []byte { + return signature.StripInvalidClaudeThinkingBlocks(payload, claudeBypassSignatureValidationOptions()) +} + func ValidateClaudeBypassSignatures(inputRawJSON []byte) error { return signature.ValidateClaudeThinkingSignatures(inputRawJSON, claudeBypassSignatureValidationOptions()) } diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index f0082175..1beaecff 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -6,9 +6,11 @@ package gemini import ( + "encoding/json" "fmt" "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" @@ -98,28 +100,213 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } - // Gemini-specific handling for non-Claude models: - // - Replace client-provided thoughtSignature values with the skip sentinel. - // - Add the same sentinel to functionCall and thinking parts so upstream can bypass signature validation. - if !strings.Contains(strings.ToLower(modelName), "claude") { - const skipSentinel = "skip_thought_signature_validator" - - gjson.GetBytes(rawJSON, "request.contents").ForEach(func(contentIdx, content gjson.Result) bool { - if content.Get("role").String() == "model" { - content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool { - if part.Get("functionCall").Exists() || part.Get("thought").Exists() || part.Get("thoughtSignature").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", contentIdx.Int(), partIdx.Int()), skipSentinel) - } - return true - }) - } - return true - }) + if strings.Contains(strings.ToLower(modelName), "claude") { + rawJSON = sanitizeAntigravityClaudeGeminiRequestSignatures(modelName, rawJSON) + } else { + rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents") } return common.AttachDefaultSafetySettings(rawJSON, "request.safetySettings") } +func sanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON []byte) []byte { + var root map[string]any + if err := json.Unmarshal(rawJSON, &root); err != nil { + log.WithError(err).Debug("antigravity gemini translator: failed to parse request for Claude signature sanitize") + return rawJSON + } + + request, ok := root["request"].(map[string]any) + if !ok { + return rawJSON + } + contents, ok := request["contents"].([]any) + if !ok { + return rawJSON + } + + changed := false + rewrittenContents := make([]any, 0, len(contents)) + for contentIndex, contentValue := range contents { + content, ok := contentValue.(map[string]any) + if !ok { + rewrittenContents = append(rewrittenContents, contentValue) + continue + } + + parts, ok := content["parts"].([]any) + if !ok { + rewrittenContents = append(rewrittenContents, content) + continue + } + + isModelTurn := content["role"] == "model" + rewrittenParts := make([]any, 0, len(parts)) + for partIndex, partValue := range parts { + part, ok := partValue.(map[string]any) + if !ok { + rewrittenParts = append(rewrittenParts, partValue) + continue + } + + rawSignature, hasSignature := antigravityClaudeGeminiPartThoughtSignature(part) + if hasFunctionResponsePart(part) { + if hasSignature { + changed = true + deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part) + logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "functionResponse parts cannot replay Claude thinking signatures", contentIndex, partIndex, rawSignature) + } + rewrittenParts = append(rewrittenParts, part) + continue + } + if !isModelTurn { + if hasSignature { + changed = true + deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part) + logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "non-model parts cannot replay Claude thinking signatures", contentIndex, partIndex, rawSignature) + } + rewrittenParts = append(rewrittenParts, part) + continue + } + + if part["thought"] == true { + normalized, compatible := signature.CompatibleAntigravityClaudeThinkingSignature(rawSignature) + if !compatible { + changed = true + logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_thinking_block", "missing_or_incompatible_signature", contentIndex, partIndex, rawSignature) + continue + } + if text, _ := part["text"].(string); strings.TrimSpace(text) == "" { + changed = true + logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_thinking_block", "empty_thinking_text", contentIndex, partIndex, rawSignature) + continue + } + if normalized != rawSignature { + changed = true + logAntigravityClaudeGeminiSignatureSanitize(modelName, "normalize_signature", "compatible_claude_signature", contentIndex, partIndex, rawSignature) + } + deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part) + part["thoughtSignature"] = normalized + rewrittenParts = append(rewrittenParts, part) + continue + } + + if hasSignature { + changed = true + deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part) + logAntigravityClaudeGeminiSignatureSanitize(modelName, "drop_signature", "non-thinking parts should not carry Claude thinking signatures", contentIndex, partIndex, rawSignature) + } + rewrittenParts = append(rewrittenParts, part) + } + + if len(rewrittenParts) == 0 { + changed = true + continue + } + content["parts"] = rewrittenParts + rewrittenContents = append(rewrittenContents, content) + } + + if !changed { + return rawJSON + } + request["contents"] = rewrittenContents + out, err := json.Marshal(root) + if err != nil { + log.WithError(err).Debug("antigravity gemini translator: failed to marshal Claude signature sanitize") + return rawJSON + } + return out +} + +func antigravityClaudeGeminiPartThoughtSignature(part map[string]any) (string, bool) { + for _, path := range [][]string{ + {"thoughtSignature"}, + {"thought_signature"}, + {"functionCall", "thoughtSignature"}, + {"functionCall", "thought_signature"}, + {"functionResponse", "thoughtSignature"}, + {"functionResponse", "thought_signature"}, + {"extra_content", "google", "thought_signature"}, + } { + if value, ok := stringAtPath(part, path...); ok { + return value, true + } + } + return "", false +} + +func deleteAntigravityClaudeGeminiPartThoughtSignatureFields(part map[string]any) { + for _, path := range [][]string{ + {"thoughtSignature"}, + {"thought_signature"}, + {"functionCall", "thoughtSignature"}, + {"functionCall", "thought_signature"}, + {"functionResponse", "thoughtSignature"}, + {"functionResponse", "thought_signature"}, + {"extra_content", "google", "thought_signature"}, + } { + deleteAtPath(part, path...) + } +} + +func hasFunctionResponsePart(part map[string]any) bool { + _, ok := part["functionResponse"] + if ok { + return true + } + _, ok = part["function_response"] + return ok +} + +func stringAtPath(value map[string]any, path ...string) (string, bool) { + var current any = value + for _, key := range path { + m, ok := current.(map[string]any) + if !ok { + return "", false + } + current, ok = m[key] + if !ok { + return "", false + } + } + s, ok := current.(string) + return s, ok +} + +func deleteAtPath(value map[string]any, path ...string) { + if len(path) == 0 { + return + } + current := value + for _, key := range path[:len(path)-1] { + next, ok := current[key].(map[string]any) + if !ok { + return + } + current = next + } + delete(current, path[len(path)-1]) +} + +func logAntigravityClaudeGeminiSignatureSanitize(modelName, action, reason string, contentIndex, partIndex int, rawSignature string) { + fields := log.Fields{ + "component": "signature_sanitizer", + "translator": "antigravity_gemini", + "target_provider": string(signature.SignatureProviderClaude), + "action": action, + "reason": reason, + "model": modelName, + "content_index": contentIndex, + "part_index": partIndex, + "has_signature": strings.TrimSpace(rawSignature) != "", + "signature_length": len(strings.TrimSpace(rawSignature)), + "detected_provider": string(signature.DetectSignatureProviderForBlock(rawSignature, signature.SignatureBlockKindClaudeThinking)), + } + log.WithFields(fields).Debug("antigravity gemini translator: sanitized Claude target thoughtSignature before upstream") +} + // FunctionCallGroup represents a group of function calls and their responses type FunctionCallGroup struct { ResponsesNeeded int diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go index 3ee381d8..9707f39c 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go @@ -1,10 +1,13 @@ package gemini import ( + "encoding/base64" "fmt" "testing" + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/tidwall/gjson" + "google.golang.org/protobuf/encoding/protowire" ) func TestConvertGeminiRequestToAntigravity_ReplacesClientSignatureOnFunctionCall(t *testing.T) { @@ -105,6 +108,128 @@ func TestConvertGeminiRequestToAntigravity_SkipsUppercaseClaudeModel(t *testing. } } +func TestConvertGeminiRequestToAntigravity_ClaudeModelNormalizesStrictClaudeThoughtSignature(t *testing.T) { + nativeSig := testAntigravityGeminiClaudeSignature(t) + expectedSig, ok := signature.CompatibleAntigravityClaudeThinkingSignature(nativeSig) + if !ok { + t.Fatal("test Claude signature should be compatible with Antigravity Claude") + } + + inputJSON := []byte(`{ + "model": "claude-opus-4-6-thinking", + "contents": [ + { + "role": "model", + "parts": [ + {"text": "internal reasoning", "thought": true, "thoughtSignature": "` + nativeSig + `"}, + {"text": "visible answer"} + ] + }, + { + "role": "user", + "parts": [{"text": "continue"}] + } + ] + }`) + + output := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false) + + part := gjson.GetBytes(output, "request.contents.0.parts.0") + if !part.Get("thought").Bool() { + t.Fatalf("first part should remain thought. Output: %s", output) + } + if got := part.Get("thoughtSignature").String(); got != expectedSig { + t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, expectedSig, output) + } +} + +func TestConvertGeminiRequestToAntigravity_ClaudeModelDropsNonStrictEPrefixThoughtSignature(t *testing.T) { + looseEPrefix := base64.StdEncoding.EncodeToString([]byte{0x12, 0x01, 0x02}) + if looseEPrefix[0] != 'E' { + t.Fatalf("test signature should start with E, got %q", looseEPrefix[:1]) + } + + inputJSON := []byte(`{ + "model": "claude-opus-4-6-thinking", + "contents": [ + { + "role": "model", + "parts": [ + {"text": "must not reach Claude", "thought": true, "thoughtSignature": "` + looseEPrefix + `"}, + {"text": "visible answer"} + ] + }, + { + "role": "user", + "parts": [{"text": "continue"}] + } + ] + }`) + + output := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false) + + if gjson.GetBytes(output, `request.contents.#.parts.#(thought=true)#`).Int() != 0 { + t.Fatalf("non-strict E-prefix thought block should be dropped. Output: %s", output) + } + if got := gjson.GetBytes(output, "request.contents.0.parts.0.text").String(); got != "visible answer" { + t.Fatalf("visible text = %q, want visible answer. Output: %s", got, output) + } +} + +func TestConvertGeminiRequestToAntigravity_ClaudeModelDropsEmptyThoughtText(t *testing.T) { + nativeSig := testAntigravityGeminiClaudeSignature(t) + inputJSON := []byte(`{ + "model": "claude-opus-4-6-thinking", + "contents": [ + { + "role": "model", + "parts": [ + {"text": "", "thought": true, "thoughtSignature": "` + nativeSig + `"}, + {"text": "visible answer"} + ] + }, + { + "role": "user", + "parts": [{"text": "continue"}] + } + ] + }`) + + output := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false) + + if gjson.GetBytes(output, `request.contents.#.parts.#(thought=true)#`).Int() != 0 { + t.Fatalf("empty-text thought block should be dropped for Antigravity Claude. Output: %s", output) + } + if got := gjson.GetBytes(output, "request.contents.0.parts.0.text").String(); got != "visible answer" { + t.Fatalf("visible text = %q, want visible answer. Output: %s", got, output) + } +} + +func TestConvertGeminiRequestToAntigravity_ClaudeModelStripsUnneededFunctionCallSignature(t *testing.T) { + nativeSig := testAntigravityGeminiClaudeSignature(t) + inputJSON := []byte(`{ + "model": "claude-opus-4-6-thinking", + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "test_tool", "args": {}}, "thoughtSignature": "` + nativeSig + `"} + ] + } + ] + }`) + + output := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false) + + part := gjson.GetBytes(output, "request.contents.0.parts.0") + if !part.Get("functionCall").Exists() { + t.Fatalf("functionCall should be preserved. Output: %s", output) + } + if part.Get("thoughtSignature").Exists() { + t.Fatalf("functionCall thoughtSignature should be stripped for Claude target. Output: %s", output) + } +} + func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *testing.T) { // functionCall without signature should get skip_thought_signature_validator inputJSON := []byte(`{ @@ -130,6 +255,28 @@ func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *test } } +func testAntigravityGeminiClaudeSignature(t *testing.T) string { + t.Helper() + channelBlock := []byte{} + channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType) + channelBlock = protowire.AppendVarint(channelBlock, 12) + channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType) + channelBlock = protowire.AppendVarint(channelBlock, 2) + channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType) + channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6") + + container := []byte{} + container = protowire.AppendTag(container, 1, protowire.BytesType) + container = protowire.AppendBytes(container, channelBlock) + + payload := []byte{} + payload = protowire.AppendTag(payload, 2, protowire.BytesType) + payload = protowire.AppendBytes(payload, container) + payload = protowire.AppendTag(payload, 3, protowire.VarintType) + payload = protowire.AppendVarint(payload, 1) + return base64.StdEncoding.EncodeToString(payload) +} + func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) { // Multiple functionCalls should all get skip_thought_signature_validator inputJSON := []byte(`{ diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go index 94a6b852..491fcded 100644 --- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go @@ -1,12 +1,204 @@ package responses import ( + "encoding/json" + "strings" + + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini" . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" ) func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte { rawJSON := inputRawJSON rawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream) + rawJSON = rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName, inputRawJSON, rawJSON) return ConvertGeminiRequestToAntigravity(modelName, rawJSON, stream) } + +type antigravityClaudeReasoningSignature struct { + Signature string + HasRawSignature bool + RawSignatureLen int + DetectedProvider sigcompat.SignatureProvider +} + +func rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName string, inputRawJSON, geminiJSON []byte) []byte { + if sigcompat.SignatureProviderFromModelName(modelName) != sigcompat.SignatureProviderClaude { + return geminiJSON + } + + reasoningSignatures := antigravityClaudeReasoningSignatures(inputRawJSON) + if len(reasoningSignatures) == 0 { + return geminiJSON + } + + var root map[string]any + if err := json.Unmarshal(geminiJSON, &root); err != nil { + log.WithError(err).Debug("antigravity responses translator: failed to parse Gemini request for Claude signature rewrite") + return geminiJSON + } + + contents, ok := root["contents"].([]any) + if !ok { + return geminiJSON + } + + reasoningIndex := 0 + changed := false + rewrittenContents := make([]any, 0, len(contents)) + for contentIndex, contentValue := range contents { + content, ok := contentValue.(map[string]any) + if !ok { + rewrittenContents = append(rewrittenContents, contentValue) + continue + } + + parts, ok := content["parts"].([]any) + if !ok { + rewrittenContents = append(rewrittenContents, content) + continue + } + + rewrittenParts := make([]any, 0, len(parts)) + for partIndex, partValue := range parts { + part, ok := partValue.(map[string]any) + if !ok || part["thought"] != true { + rewrittenParts = append(rewrittenParts, partValue) + continue + } + + var reasoningSig antigravityClaudeReasoningSignature + if reasoningIndex < len(reasoningSignatures) { + reasoningSig = reasoningSignatures[reasoningIndex] + } + reasoningIndex++ + + if reasoningSig.Signature == "" { + changed = true + logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig) + continue + } + if text, _ := part["text"].(string); strings.TrimSpace(text) == "" { + changed = true + logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig) + continue + } + + if currentSignature, _ := part["thoughtSignature"].(string); currentSignature != reasoningSig.Signature { + changed = true + logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig) + } + part["thoughtSignature"] = reasoningSig.Signature + rewrittenParts = append(rewrittenParts, part) + } + + if len(rewrittenParts) == 0 { + changed = true + continue + } + content["parts"] = rewrittenParts + rewrittenContents = append(rewrittenContents, content) + } + + if !changed { + return geminiJSON + } + + root["contents"] = rewrittenContents + out, err := json.Marshal(root) + if err != nil { + log.WithError(err).Debug("antigravity responses translator: failed to marshal Claude signature rewrite") + return geminiJSON + } + return out +} + +func antigravityClaudeReasoningSignatures(inputRawJSON []byte) []antigravityClaudeReasoningSignature { + input := gjson.GetBytes(inputRawJSON, "input") + if !input.IsArray() { + return nil + } + + signatures := make([]antigravityClaudeReasoningSignature, 0) + input.ForEach(func(_, item gjson.Result) bool { + itemType := item.Get("type").String() + if itemType == "" && item.Get("role").Exists() { + itemType = "message" + } + if itemType != "reasoning" { + return true + } + + rawSignatureResult := item.Get("encrypted_content") + rawSignature := rawSignatureResult.String() + signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature) + reasoningSignature := antigravityClaudeReasoningSignature{ + HasRawSignature: rawSignatureResult.Exists(), + RawSignatureLen: len(rawSignature), + DetectedProvider: sigcompat.SignatureProviderUnknown, + } + if rawSignature != "" { + reasoningSignature.DetectedProvider = sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking) + } + if ok { + reasoningSignature.Signature = signature + } + signatures = append(signatures, reasoningSignature) + return true + }) + return signatures +} + +func logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) { + log.WithFields(log.Fields{ + "component": "signature_sanitizer", + "translator": "antigravity_openai_responses", + "target_provider": string(sigcompat.SignatureProviderClaude), + "action": "drop_thinking_block", + "reason": "missing_or_incompatible_signature", + "model": modelName, + "content_index": contentIndex, + "part_index": partIndex, + "reasoning_index": reasoningIndex, + "has_signature": sig.HasRawSignature, + "signature_length": sig.RawSignatureLen, + "detected_provider": string(sig.DetectedProvider), + }).Debug("antigravity responses translator: dropped Claude reasoning block with incompatible encrypted_content") +} + +func logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) { + log.WithFields(log.Fields{ + "component": "signature_sanitizer", + "translator": "antigravity_openai_responses", + "target_provider": string(sigcompat.SignatureProviderClaude), + "action": "drop_thinking_block", + "reason": "empty_thinking_text", + "model": modelName, + "content_index": contentIndex, + "part_index": partIndex, + "reasoning_index": reasoningIndex, + "has_signature": sig.HasRawSignature, + "signature_length": sig.RawSignatureLen, + "detected_provider": string(sig.DetectedProvider), + }).Debug("antigravity responses translator: dropped Claude reasoning block with empty thinking text") +} + +func logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) { + log.WithFields(log.Fields{ + "component": "signature_sanitizer", + "translator": "antigravity_openai_responses", + "target_provider": string(sigcompat.SignatureProviderClaude), + "action": "normalize_signature", + "reason": "compatible_claude_signature", + "model": modelName, + "content_index": contentIndex, + "part_index": partIndex, + "reasoning_index": reasoningIndex, + "has_signature": sig.HasRawSignature, + "signature_length": sig.RawSignatureLen, + "detected_provider": string(sig.DetectedProvider), + }).Debug("antigravity responses translator: normalized Claude reasoning encrypted_content before upstream") +} diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go new file mode 100644 index 00000000..7fce3b20 --- /dev/null +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go @@ -0,0 +1,176 @@ +package responses + +import ( + "encoding/base64" + "strings" + "testing" + + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" + "github.com/tidwall/gjson" + "google.golang.org/protobuf/encoding/protowire" +) + +func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningKeepsClaudeSignature(t *testing.T) { + nativeSig := testAntigravityResponsesClaudeSignature(t) + antigravitySig, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(nativeSig) + if !ok { + t.Fatal("test Claude signature should be compatible with Antigravity Claude") + } + + tests := []struct { + name string + encrypted string + }{ + { + name: "Claude native E signature", + encrypted: nativeSig, + }, + { + name: "Antigravity double-layer R signature", + encrypted: antigravitySig, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := []byte(`{ + "model": "claude-opus-4-6-thinking", + "input": [ + { + "id": "rs_prev", + "type": "reasoning", + "encrypted_content": "` + tt.encrypted + `", + "summary": [{"type": "summary_text", "text": "internal reasoning"}] + }, + { + "role": "assistant", + "content": [{"type": "output_text", "text": "visible answer"}] + }, + { + "role": "user", + "content": [{"type": "input_text", "text": "continue"}] + } + ] + }`) + + out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false) + part := gjson.GetBytes(out, "request.contents.0.parts.0") + if !part.Get("thought").Bool() { + t.Fatalf("first part should remain a thought block. Output: %s", out) + } + if got := part.Get("thoughtSignature").String(); got != antigravitySig { + t.Fatalf("thoughtSignature prefix/len = %q/%d, want %q/%d. Output: %s", + firstByte(got), len(got), firstByte(antigravitySig), len(antigravitySig), out) + } + if got := part.Get("text").String(); got != "internal reasoning" { + t.Fatalf("thought text = %q, want internal reasoning. Output: %s", got, out) + } + }) + } +} + +func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsIncompatibleSignature(t *testing.T) { + raw := []byte(`{ + "model": "claude-opus-4-6-thinking", + "input": [ + { + "id": "rs_prev", + "type": "reasoning", + "encrypted_content": "` + testAntigravityResponsesGPTSignature() + `", + "summary": [{"type": "summary_text", "text": "must not reach Claude"}] + }, + { + "role": "assistant", + "content": [{"type": "output_text", "text": "visible answer"}] + }, + { + "role": "user", + "content": [{"type": "input_text", "text": "continue"}] + } + ] + }`) + + out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false) + if strings.Contains(string(out), sigcompat.GeminiSkipThoughtSignatureValidator) { + t.Fatalf("Claude target must not receive Gemini bypass signature. Output: %s", out) + } + if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 { + t.Fatalf("incompatible reasoning block should be dropped. Output: %s", out) + } + if strings.Contains(string(out), "must not reach Claude") { + t.Fatalf("incompatible reasoning text should be dropped. Output: %s", out) + } + if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" { + t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out) + } +} + +func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsEmptyThinkingText(t *testing.T) { + rawSignature := testAntigravityResponsesClaudeSignature(t) + raw := []byte(`{ + "model": "claude-opus-4-6-thinking", + "input": [ + { + "id": "rs_prev", + "type": "reasoning", + "encrypted_content": "` + rawSignature + `", + "summary": [] + }, + { + "role": "assistant", + "content": [{"type": "output_text", "text": "visible answer"}] + }, + { + "role": "user", + "content": [{"type": "input_text", "text": "continue"}] + } + ] + }`) + + out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false) + if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 { + t.Fatalf("empty-text reasoning block should be dropped for Antigravity Claude. Output: %s", out) + } + if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" { + t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out) + } +} + +func testAntigravityResponsesClaudeSignature(t *testing.T) string { + t.Helper() + channelBlock := []byte{} + channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType) + channelBlock = protowire.AppendVarint(channelBlock, 12) + channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType) + channelBlock = protowire.AppendVarint(channelBlock, 2) + channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType) + channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6") + + container := []byte{} + container = protowire.AppendTag(container, 1, protowire.BytesType) + container = protowire.AppendBytes(container, channelBlock) + + payload := []byte{} + payload = protowire.AppendTag(payload, 2, protowire.BytesType) + payload = protowire.AppendBytes(payload, container) + payload = protowire.AppendTag(payload, 3, protowire.VarintType) + payload = protowire.AppendVarint(payload, 1) + return base64.StdEncoding.EncodeToString(payload) +} + +func testAntigravityResponsesGPTSignature() string { + payload := make([]byte, 1+8+16+16+32) + payload[0] = 0x80 + payload[8] = 1 + for i := 9; i < len(payload); i++ { + payload[i] = byte(i) + } + return base64.URLEncoding.EncodeToString(payload) +} + +func firstByte(s string) string { + if s == "" { + return "" + } + return s[:1] +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 2208688b..d37b7156 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -439,8 +440,8 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } func convertResponsesReasoningToClaudeThinking(item gjson.Result) []byte { - signature := item.Get("encrypted_content").String() - if signature == "" { + signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderClaude, item.Get("encrypted_content").String()) + if !ok { return nil } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index cb867e05..da3cfc39 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -1,19 +1,22 @@ package responses import ( + "encoding/base64" "testing" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/tidwall/gjson" + "google.golang.org/protobuf/encoding/protowire" ) func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *testing.T) { - signature := "claude_sig_request" + rawSignature, expectedSignature := testClaudeResponsesThinkingSignature(t) raw := []byte(`{ "model":"claude-test", "input":[ { "type":"reasoning", - "encrypted_content":"` + signature + `", + "encrypted_content":"` + rawSignature + `", "summary":[{"type":"summary_text","text":"internal reasoning"}] }, { @@ -39,8 +42,8 @@ func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *t if got := assistant.Get("content.0.type").String(); got != "thinking" { t.Fatalf("first content type = %q, want thinking. Output: %s", got, string(out)) } - if got := assistant.Get("content.0.signature").String(); got != signature { - t.Fatalf("thinking signature = %q, want %q", got, signature) + if got := assistant.Get("content.0.signature").String(); got != expectedSignature { + t.Fatalf("thinking signature = %q, want %q", got, expectedSignature) } if got := assistant.Get("content.0.thinking").String(); got != "internal reasoning" { t.Fatalf("thinking text = %q, want internal reasoning", got) @@ -57,13 +60,13 @@ func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *t } func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBeforeUser(t *testing.T) { - signature := "claude_sig_only" + rawSignature, expectedSignature := testClaudeResponsesThinkingSignature(t) raw := []byte(`{ "model":"claude-test", "input":[ { "type":"reasoning", - "encrypted_content":"` + signature + `", + "encrypted_content":"` + rawSignature + `", "summary":[] }, { @@ -81,8 +84,8 @@ func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBefo if got := thinking.Get("type").String(); got != "thinking" { t.Fatalf("first content type = %q, want thinking. Output: %s", got, string(out)) } - if got := thinking.Get("signature").String(); got != signature { - t.Fatalf("thinking signature = %q, want %q", got, signature) + if got := thinking.Get("signature").String(); got != expectedSignature { + t.Fatalf("thinking signature = %q, want %q", got, expectedSignature) } if got := thinking.Get("thinking").String(); got != "" { t.Fatalf("thinking text = %q, want empty", got) @@ -91,3 +94,71 @@ func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBefo t.Fatalf("second message role = %q, want user. Output: %s", got, string(out)) } } + +func TestConvertOpenAIResponsesRequestToClaude_DropsIncompatibleReasoningSignature(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "input":[ + { + "type":"reasoning", + "encrypted_content":"` + testGPTResponsesReasoningSignature() + `", + "summary":[{"type":"summary_text","text":"must not become Claude thinking"}] + }, + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"continue"}] + } + ] + }`) + + out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false) + + if gjson.GetBytes(out, "messages.0.content.0.type").String() == "thinking" { + t.Fatalf("GPT encrypted_content should not become Claude thinking. Output: %s", string(out)) + } + if gjson.GetBytes(out, "messages.0.content.0.signature").Exists() { + t.Fatalf("incompatible signature should not be forwarded. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "messages.0.role").String(); got != "user" { + t.Fatalf("first message role = %q, want user. Output: %s", got, string(out)) + } +} + +func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) { + t.Helper() + channelBlock := []byte{} + channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType) + channelBlock = protowire.AppendVarint(channelBlock, 12) + channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType) + channelBlock = protowire.AppendVarint(channelBlock, 2) + channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType) + channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6") + + container := []byte{} + container = protowire.AppendTag(container, 1, protowire.BytesType) + container = protowire.AppendBytes(container, channelBlock) + + payload := []byte{} + payload = protowire.AppendTag(payload, 2, protowire.BytesType) + payload = protowire.AppendBytes(payload, container) + payload = protowire.AppendTag(payload, 3, protowire.VarintType) + payload = protowire.AppendVarint(payload, 1) + + rawSignature := base64.StdEncoding.EncodeToString(payload) + normalized, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderClaude, rawSignature) + if !ok { + t.Fatal("test Claude signature should be compatible") + } + return rawSignature, normalized +} + +func testGPTResponsesReasoningSignature() string { + payload := make([]byte, 1+8+16+16+32) + payload[0] = 0x80 + payload[8] = 1 + for i := 9; i < len(payload); i++ { + payload[i] = byte(i) + } + return base64.URLEncoding.EncodeToString(payload) +} diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index b7a42d2c..d9f889e2 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -7,12 +7,12 @@ package claude import ( "crypto/sha256" - "encoding/base64" "encoding/hex" "fmt" "strconv" "strings" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" @@ -133,8 +133,8 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return } - signature := part.Get("signature").String() - if !isFernetLikeReasoningSignature(signature) { + signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, part.Get("signature").String()) + if !ok { return } @@ -334,39 +334,6 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return template } -// isFernetLikeReasoningSignature checks only the encrypted_content envelope shape -// observed in OpenAI reasoning signatures. It does not authenticate source or payload type. -func isFernetLikeReasoningSignature(signature string) bool { - const ( - fernetVersionLen = 1 - fernetTimestamp = 8 - fernetIV = 16 - fernetHMAC = 32 - aesBlockSize = 16 - ) - - signature = strings.TrimSpace(signature) - if !strings.HasPrefix(signature, "gAAAA") { - return false - } - - decoded, err := base64.URLEncoding.DecodeString(signature) - if err != nil { - decoded, err = base64.RawURLEncoding.DecodeString(signature) - if err != nil { - return false - } - } - - minLen := fernetVersionLen + fernetTimestamp + fernetIV + aesBlockSize + fernetHMAC - if len(decoded) < minLen || decoded[0] != 0x80 { - return false - } - - ciphertextLen := len(decoded) - fernetVersionLen - fernetTimestamp - fernetIV - fernetHMAC - return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0 -} - // shortenCodexCallIDIfNeeded keeps Claude tool IDs within the OpenAI Responses // API call_id limit while preserving a stable, low-collision mapping. func shortenCodexCallIDIfNeeded(id string) string { diff --git a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go index 83dc6260..36277575 100644 --- a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go +++ b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go @@ -9,6 +9,7 @@ import ( "fmt" "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" @@ -97,19 +98,7 @@ func ConvertGeminiRequestToGeminiCLI(_ string, inputRawJSON []byte, _ bool) []by } } - gjson.GetBytes(rawJSON, "request.contents").ForEach(func(key, content gjson.Result) bool { - if content.Get("role").String() == "model" { - content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { - if part.Get("functionCall").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } else if part.Get("thoughtSignature").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } - return true - }) - } - return true - }) + rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents") // Filter out contents with empty parts to avoid Gemini API error: // "required oneof field 'data' must have one initialized field" diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index 1aa3132b..c0c7a8de 100644 --- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" @@ -255,7 +256,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo fargs := tc.Get("function.arguments").String() node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", openAIToolCallGeminiThoughtSignature(tc)) p++ if fid != "" { fIDs = append(fIDs, fid) @@ -397,5 +398,19 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo return common.AttachDefaultSafetySettings(out, "request.safetySettings") } +func openAIToolCallGeminiThoughtSignature(toolCall gjson.Result) string { + for _, path := range []string{ + "extra_content.google.thought_signature", + "function.extra_content.google.thought_signature", + "thoughtSignature", + "thought_signature", + } { + if signatureResult := toolCall.Get(path); signatureResult.Exists() { + return sigcompat.GeminiReplaySignatureOrBypass(signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall) + } + } + return geminiCLIFunctionThoughtSignature +} + // itoa converts int to string without strconv import for few usages. func itoa(i int) string { return fmt.Sprintf("%d", i) } diff --git a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go index 71e7b4a5..0d1da6c7 100644 --- a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go +++ b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go @@ -8,6 +8,7 @@ package geminiCLI import ( "fmt" + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" @@ -45,19 +46,7 @@ func ConvertGeminiCLIRequestToGemini(_ string, inputRawJSON []byte, _ bool) []by } } - gjson.GetBytes(rawJSON, "contents").ForEach(func(key, content gjson.Result) bool { - if content.Get("role").String() == "model" { - content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { - if part.Get("functionCall").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } else if part.Get("thoughtSignature").Exists() { - rawJSON, _ = sjson.SetBytes(rawJSON, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } - return true - }) - } - return true - }) + rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "contents") return common.AttachDefaultSafetySettings(rawJSON, "safetySettings") } diff --git a/internal/translator/gemini/gemini/gemini_gemini_request.go b/internal/translator/gemini/gemini/gemini_gemini_request.go index 35e22d71..6c36dfd8 100644 --- a/internal/translator/gemini/gemini/gemini_gemini_request.go +++ b/internal/translator/gemini/gemini/gemini_gemini_request.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" @@ -78,19 +79,7 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte return true }) - gjson.GetBytes(out, "contents").ForEach(func(key, content gjson.Result) bool { - if content.Get("role").String() == "model" { - content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { - if part.Get("functionCall").Exists() { - out, _ = sjson.SetBytes(out, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } else if part.Get("thoughtSignature").Exists() { - out, _ = sjson.SetBytes(out, fmt.Sprintf("contents.%d.parts.%d.thoughtSignature", key.Int(), partKey.Int()), "skip_thought_signature_validator") - } - return true - }) - } - return true - }) + out = signature.SanitizeGeminiRequestThoughtSignatures(out, "contents") if gjson.GetBytes(rawJSON, "generationConfig.responseSchema").Exists() { strJson, _ := util.RenameKey(string(out), "generationConfig.responseSchema", "generationConfig.responseJsonSchema") diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index 20eaec76..bf4e9805 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" log "github.com/sirupsen/logrus" @@ -261,7 +262,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) fargs := tc.Get("function.arguments").String() node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) - node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature) + node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", openAIToolCallGeminiThoughtSignature(tc)) p++ if fid != "" { fIDs = append(fIDs, fid) @@ -411,5 +412,19 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) return out } +func openAIToolCallGeminiThoughtSignature(toolCall gjson.Result) string { + for _, path := range []string{ + "extra_content.google.thought_signature", + "function.extra_content.google.thought_signature", + "thoughtSignature", + "thought_signature", + } { + if signatureResult := toolCall.Get(path); signatureResult.Exists() { + return sigcompat.GeminiReplaySignatureOrBypass(signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall) + } + } + return geminiFunctionThoughtSignature +} + // itoa converts int to string without strconv import for few usages. func itoa(i int) string { return fmt.Sprintf("%d", i) } diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go new file mode 100644 index 00000000..4d4326a8 --- /dev/null +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go @@ -0,0 +1,51 @@ +package chat_completions + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" + "github.com/tidwall/gjson" +) + +const capturedGeminiToolCallThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" + +func TestConvertOpenAIRequestToGemini_ToolCallSignatureCompatibility(t *testing.T) { + tests := []struct { + name string + rawSignature string + wantSignature string + }{ + { + name: "Gemini signature is preserved", + rawSignature: "gemini#" + capturedGeminiToolCallThoughtSignature, + wantSignature: capturedGeminiToolCallThoughtSignature, + }, + { + name: "unknown signature uses bypass", + rawSignature: "not-a-provider-signature", + wantSignature: signature.GeminiSkipThoughtSignatureValidator, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := []byte(`{ + "model": "gemini-3.5-flash", + "messages": [{ + "role": "assistant", + "tool_calls": [{ + "id": "call_123", + "type": "function", + "function": {"name": "lookup", "arguments": "{\"q\":\"Paris\"}"}, + "extra_content": {"google": {"thought_signature": "` + tt.rawSignature + `"}} + }] + }] + }`) + + output := ConvertOpenAIRequestToGemini("gemini-3.5-flash", input, false) + if got := gjson.GetBytes(output, "contents.0.parts.0.thoughtSignature").String(); got != tt.wantSignature { + t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output) + } + }) + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index e7417576..29d66df5 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -4,6 +4,7 @@ import ( "encoding/json" "strings" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" @@ -355,7 +356,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte thoughtContent := []byte(`{"role":"model","parts":[]}`) thought := []byte(`{"text":"","thoughtSignature":"","thought":true}`) thought, _ = sjson.SetBytes(thought, "text", item.Get("summary.0.text").String()) - thought, _ = sjson.SetBytes(thought, "thoughtSignature", item.Get("encrypted_content").String()) + thought, _ = sjson.SetBytes(thought, "thoughtSignature", openAIResponsesGeminiThoughtSignature(item.Get("encrypted_content").String())) thoughtContent, _ = sjson.SetRawBytes(thoughtContent, "parts.-1", thought) out, _ = sjson.SetRawBytes(out, "contents.-1", thoughtContent) @@ -454,3 +455,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte result = common.AttachDefaultSafetySettings(result, "safetySettings") return result } + +func openAIResponsesGeminiThoughtSignature(rawSignature string) string { + return sigcompat.GeminiReplaySignatureOrBypass(rawSignature, sigcompat.SignatureBlockKindGeminiModelPart) +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go new file mode 100644 index 00000000..35418689 --- /dev/null +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go @@ -0,0 +1,66 @@ +package responses + +import ( + "encoding/base64" + "testing" + + "github.com/tidwall/gjson" +) + +const testResponsesGeminiThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" + +func TestConvertOpenAIResponsesRequestToGemini_ReasoningSignatureCompatibility(t *testing.T) { + tests := []struct { + name string + encrypted string + wantSignature string + }{ + { + name: "GPT encrypted_content uses Gemini bypass", + encrypted: validResponsesGPTReasoningSignature(), + wantSignature: geminiResponsesThoughtSignature, + }, + { + name: "Gemini encrypted_content is preserved", + encrypted: "gemini#" + testResponsesGeminiThoughtSignature, + wantSignature: testResponsesGeminiThoughtSignature, + }, + { + name: "Missing encrypted_content uses Gemini bypass", + encrypted: "", + wantSignature: geminiResponsesThoughtSignature, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := []byte(`{ + "model": "gpt-5", + "input": [{ + "type": "reasoning", + "encrypted_content": "` + tt.encrypted + `", + "summary": [{"type": "summary_text", "text": "reasoning summary"}] + }] + }`) + + output := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", input, false) + part := gjson.GetBytes(output, "contents.0.parts.0") + if got := part.Get("thoughtSignature").String(); got != tt.wantSignature { + t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output) + } + if got := part.Get("text").String(); got != "reasoning summary" { + t.Fatalf("thought text = %q, want reasoning summary. Output: %s", got, output) + } + }) + } +} + +func validResponsesGPTReasoningSignature() string { + raw := make([]byte, 1+8+16+16+32) + raw[0] = 0x80 + raw[8] = 1 + for i := 9; i < len(raw); i++ { + raw[i] = byte(i) + } + return base64.URLEncoding.EncodeToString(raw) +} diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 98954b38..7ff7a582 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -8,6 +8,7 @@ package claude import ( "strings" + sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" @@ -147,6 +148,9 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream case "thinking": // Only map thinking to reasoning_content for assistant messages (security: prevent injection) if role == "assistant" { + if !shouldMapClaudeThinkingToGPTReasoning(part) { + return true + } thinkingText := thinking.GetThinkingText(part) // Skip empty or whitespace-only thinking if strings.TrimSpace(thinkingText) != "" { @@ -329,6 +333,15 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream return out } +func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result) bool { + signature := part.Get("signature") + if !signature.Exists() || strings.TrimSpace(signature.String()) == "" { + return false + } + _, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, signature.String()) + return ok +} + func convertClaudeContentPart(part gjson.Result) (string, bool) { partType := part.Get("type").String() diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go index 9c6ba77c..9e2d771a 100644 --- a/internal/translator/openai/claude/openai_claude_request_test.go +++ b/internal/translator/openai/claude/openai_claude_request_test.go @@ -1,6 +1,7 @@ package claude import ( + "encoding/base64" "testing" "github.com/tidwall/gjson" @@ -18,7 +19,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { wantHasContent bool }{ { - name: "AC1: assistant message with thinking and text", + name: "AC1: unsigned assistant thinking is dropped", inputJSON: `{ "model": "claude-3-opus", "messages": [{ @@ -29,8 +30,8 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { ] }] }`, - wantReasoningContent: "Let me analyze this step by step...", - wantHasReasoningContent: true, + wantReasoningContent: "", + wantHasReasoningContent: false, wantContentText: "Here is my response.", wantHasContent: true, }, @@ -52,7 +53,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { wantHasContent: true, }, { - name: "AC3: thinking-only message preserved with reasoning_content", + name: "AC3: unsigned thinking-only message is dropped", inputJSON: `{ "model": "claude-3-opus", "messages": [{ @@ -62,11 +63,10 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { ] }] }`, - wantReasoningContent: "Internal reasoning only.", - wantHasReasoningContent: true, + wantReasoningContent: "", + wantHasReasoningContent: false, wantContentText: "", - // For OpenAI compatibility, content field is set to empty string "" when no text content exists - wantHasContent: false, + wantHasContent: false, }, { name: "AC4: thinking in user role must be ignored", @@ -139,7 +139,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { wantHasContent: true, }, { - name: "Multiple thinking parts concatenated", + name: "Unsigned thinking parts are dropped", inputJSON: `{ "model": "claude-3-opus", "messages": [{ @@ -151,13 +151,13 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { ] }] }`, - wantReasoningContent: "First thought.\n\nSecond thought.", - wantHasReasoningContent: true, + wantReasoningContent: "", + wantHasReasoningContent: false, wantContentText: "Final answer.", wantHasContent: true, }, { - name: "Mixed thinking and redacted_thinking", + name: "Mixed unsigned thinking and redacted_thinking", inputJSON: `{ "model": "claude-3-opus", "messages": [{ @@ -169,8 +169,8 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { ] }] }`, - wantReasoningContent: "Visible thought.", - wantHasReasoningContent: true, + wantReasoningContent: "", + wantHasReasoningContent: false, wantContentText: "Answer.", wantHasContent: true, }, @@ -246,9 +246,73 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) { } } -// TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved tests AC3: -// that a message with only thinking content is preserved (not dropped). -func TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T) { +func TestConvertClaudeRequestToOpenAI_SignedThinkingCompatibility(t *testing.T) { + tests := []struct { + name string + signature string + wantReasoningContent string + wantHasReasoningContent bool + }{ + { + name: "GPT-compatible signature keeps reasoning_content", + signature: validGPTChatReasoningSignature(), + wantReasoningContent: "provider state", + wantHasReasoningContent: true, + }, + { + name: "Claude signature drops reasoning_content", + signature: "claude#EjQ=", + wantReasoningContent: "", + wantHasReasoningContent: false, + }, + { + name: "Gemini signature drops reasoning_content", + signature: "gemini#EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA", + wantReasoningContent: "", + wantHasReasoningContent: false, + }, + { + name: "Unknown signature drops reasoning_content", + signature: "not-a-provider-signature", + wantReasoningContent: "", + wantHasReasoningContent: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputJSON := `{ + "model": "claude-3-opus", + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "provider state", "signature": "` + tt.signature + `"}, + {"type": "text", "text": "visible answer"} + ] + }] + }` + + result := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false) + assistantMsg := gjson.GetBytes(result, "messages.0") + gotReasoningContent := assistantMsg.Get("reasoning_content").String() + gotHasReasoningContent := assistantMsg.Get("reasoning_content").Exists() + + if gotHasReasoningContent != tt.wantHasReasoningContent { + t.Fatalf("reasoning_content exists = %v, want %v. Output: %s", gotHasReasoningContent, tt.wantHasReasoningContent, string(result)) + } + if gotReasoningContent != tt.wantReasoningContent { + t.Fatalf("reasoning_content = %q, want %q. Output: %s", gotReasoningContent, tt.wantReasoningContent, string(result)) + } + if got := assistantMsg.Get("content.0.text").String(); got != "visible answer" { + t.Fatalf("visible content = %q, want visible answer. Output: %s", got, string(result)) + } + }) + } +} + +// TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped verifies +// that unsigned Claude thinking is not migrated into GPT reasoning state. +func TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped(t *testing.T) { inputJSON := `{ "model": "claude-3-opus", "messages": [ @@ -272,24 +336,24 @@ func TestConvertClaudeRequestToOpenAI_ThinkingOnlyMessagePreserved(t *testing.T) messages := resultJSON.Get("messages").Array() - // Should have: user + assistant (thinking-only) + user = 3 messages - if len(messages) != 3 { - t.Fatalf("Expected 3 messages, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw) - } - - // Check the assistant message (index 1) has reasoning_content - assistantMsg := messages[1] - if assistantMsg.Get("role").String() != "assistant" { - t.Errorf("Expected message[1] to be assistant, got %s", assistantMsg.Get("role").String()) + if len(messages) != 2 { + t.Fatalf("Expected unsigned thinking-only assistant message to be dropped, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw) } - - if !assistantMsg.Get("reasoning_content").Exists() { - t.Error("Expected assistant message to have reasoning_content") + for _, message := range messages { + if message.Get("reasoning_content").Exists() { + t.Fatalf("unsigned thinking should not produce reasoning_content. Messages: %v", resultJSON.Get("messages").Raw) + } } +} - if assistantMsg.Get("reasoning_content").String() != "Let me calculate: 2+2=4" { - t.Errorf("Unexpected reasoning_content: %s", assistantMsg.Get("reasoning_content").String()) +func validGPTChatReasoningSignature() string { + raw := make([]byte, 1+8+16+16+32) + raw[0] = 0x80 + raw[8] = 1 + for i := 9; i < len(raw); i++ { + raw[i] = byte(i) } + return base64.URLEncoding.EncodeToString(raw) } func TestConvertClaudeRequestToOpenAI_SystemMessageScenarios(t *testing.T) { @@ -667,8 +731,7 @@ func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *t resultJSON := gjson.ParseBytes(result) messages := resultJSON.Get("messages").Array() - // New behavior: all content, thinking, and tool_calls unified in single assistant message - // Expect: assistant(content[pre,post] + tool_calls + reasoning_content[t1+t2]) + // Unsigned thinking is dropped, while text and tool_calls remain unified. if len(messages) != 1 { t.Fatalf("Expected 1 message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) } @@ -691,9 +754,8 @@ func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *t t.Fatalf("Expected assistant message to have tool_calls") } - // Should have combined reasoning_content from both thinking blocks - if got := assistantMsg.Get("reasoning_content").String(); got != "t1\n\nt2" { - t.Fatalf("Expected reasoning_content %q, got %q", "t1\n\nt2", got) + if assistantMsg.Get("reasoning_content").Exists() { + t.Fatalf("unsigned thinking should not produce reasoning_content: %s", assistantMsg.Raw) } } diff --git a/sdk/api/handlers/openai/openai_responses_signature_test.go b/sdk/api/handlers/openai/openai_responses_signature_test.go new file mode 100644 index 00000000..7bb610ae --- /dev/null +++ b/sdk/api/handlers/openai/openai_responses_signature_test.go @@ -0,0 +1,86 @@ +package openai + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" +) + +func TestOpenAIResponsesForwardsInvalidReasoningEncryptedContentToExecutor(t *testing.T) { + gin.SetMode(gin.TestMode) + executor := &compactCaptureExecutor{} + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor) + + auth := &coreauth.Auth{ID: "signature-auth-responses", Provider: executor.Identifier(), Status: coreauth.StatusActive} + if _, err := manager.Register(context.Background(), auth); err != nil { + t.Fatalf("Register auth: %v", err) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-signature-model"}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + }) + + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewOpenAIResponsesAPIHandler(base) + router := gin.New() + router.POST("/v1/responses", h.Responses) + + body := `{"model":"test-signature-model","stream":false,"input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"gAAAAABqFTIa\u2026abc","summary":[]}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + if executor.calls != 1 { + t.Fatalf("executor calls = %d, want 1", executor.calls) + } +} + +func TestOpenAIResponsesCompactForwardsInvalidReasoningEncryptedContentToExecutor(t *testing.T) { + gin.SetMode(gin.TestMode) + executor := &compactCaptureExecutor{} + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor) + + auth := &coreauth.Auth{ID: "signature-auth-compact", Provider: executor.Identifier(), Status: coreauth.StatusActive} + if _, err := manager.Register(context.Background(), auth); err != nil { + t.Fatalf("Register auth: %v", err) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-signature-compact-model"}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + }) + + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + h := NewOpenAIResponsesAPIHandler(base) + router := gin.New() + router.POST("/v1/responses/compact", h.Compact) + + body := `{"model":"test-signature-compact-model","input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"bad","summary":[]}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + if executor.calls != 1 { + t.Fatalf("executor calls = %d, want 1", executor.calls) + } + if executor.alt != "responses/compact" { + t.Fatalf("alt = %q, want responses/compact", executor.alt) + } +}