Merge pull request #3615 from sususu98/codex/signature-intercept-on-pr

feat: intercept incompatible signature replay
This commit is contained in:
Luis Pater
2026-05-29 22:09:10 +08:00
committed by GitHub
36 changed files with 2492 additions and 257 deletions
@@ -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 }
@@ -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}
from := sdktranslator.FromString("claude")
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
},
},
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)
}
}
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"}
]
}
]
}`)
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)
}
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")
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 + `"}
]
}
statusProvider, ok := err.(interface{ StatusCode() int })
if !ok {
t.Fatalf("expected status error, got %T: %v", err, err)
}
if statusProvider.StatusCode() != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", statusProvider.StatusCode(), http.StatusBadRequest)
}
})
]
}`)
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) {
@@ -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))
@@ -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
@@ -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"
@@ -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))
}
}
@@ -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)
@@ -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())
@@ -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
}
+26 -6
View File
@@ -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 {
+34
View File
@@ -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 {
+140
View File
@@ -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
}
+122
View File
@@ -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))
}
}
+19 -1
View File
@@ -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 ""
}
@@ -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")
@@ -246,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)
}
}
@@ -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 != "" {
@@ -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_WithSignature(t *testing.T) {
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_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)
@@ -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())
}
@@ -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
@@ -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(`{
@@ -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")
}
@@ -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]
}
@@ -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
}
@@ -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)
}
@@ -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 {
@@ -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"
@@ -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) }
@@ -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")
}
@@ -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")
@@ -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) }
@@ -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)
}
})
}
}
@@ -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)
}
@@ -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)
}
@@ -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()
@@ -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)
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)
}
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)
}
}
}
// 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 !assistantMsg.Get("reasoning_content").Exists() {
t.Error("Expected assistant message to have reasoning_content")
}
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)
}
}
@@ -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)
}
}