mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 17:36:47 +08:00
feat: intercept incompatible signature replay
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -282,3 +282,58 @@ func TestSanitizeClaudeMessagesSignaturesForModel_DropsEmptyAssistantMessage(t *
|
||||
t.Fatalf("remaining role = %q, want user", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesForClaudeUpstream_DropsInvalidThinkingAndCleansToolUse(t *testing.T) {
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop me","signature":""},{"type":"text","text":"answer"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"git status"},"signature":"bad","thoughtSignature":"bad2","thought_signature":"bad3","model":"claude-sonnet-4-5","extra_content":{"google":{"thought_signature":"bad4"}}}]}]}`)
|
||||
|
||||
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5")
|
||||
if report.DroppedBlocks != 1 {
|
||||
t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
|
||||
}
|
||||
parts := gjson.GetBytes(output, "messages.0.content").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("content length = %d, want 2: %s", len(parts), output)
|
||||
}
|
||||
if parts[0].Get("type").String() != "text" {
|
||||
t.Fatalf("first remaining part = %s, want text", parts[0].Raw)
|
||||
}
|
||||
toolUse := parts[1]
|
||||
if toolUse.Get("type").String() != "tool_use" {
|
||||
t.Fatalf("second remaining part = %s, want tool_use", toolUse.Raw)
|
||||
}
|
||||
if got := toolUse.Get("id").String(); got != "toolu_1" {
|
||||
t.Fatalf("tool_use id = %q, want toolu_1", got)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"signature",
|
||||
"thoughtSignature",
|
||||
"thought_signature",
|
||||
"model",
|
||||
"extra_content",
|
||||
} {
|
||||
if toolUse.Get(path).Exists() {
|
||||
t.Fatalf("tool_use.%s should be removed: %s", path, toolUse.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeClaudeMessagesForClaudeUpstream_NormalizesValidThinkingAndDropsEmptyMessage(t *testing.T) {
|
||||
nativeSig := testClaudeThinkingSignature()
|
||||
doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig))
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + doubleEncoded + `"},{"type":"text","text":"answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"drop"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`)
|
||||
|
||||
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5")
|
||||
if report.Preserved != 1 || report.DroppedBlocks != 1 {
|
||||
t.Fatalf("unexpected report: %+v", report)
|
||||
}
|
||||
messages := gjson.GetBytes(output, "messages").Array()
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("messages length = %d, want 2: %s", len(messages), output)
|
||||
}
|
||||
if got := messages[0].Get("content.0.signature").String(); got != nativeSig {
|
||||
t.Fatalf("signature = %q, want provider-native %q", got, nativeSig)
|
||||
}
|
||||
if got := messages[1].Get("role").String(); got != "user" {
|
||||
t.Fatalf("remaining second role = %q, want user", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(`{
|
||||
|
||||
+192
@@ -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")
|
||||
}
|
||||
|
||||
+176
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+79
-8
@@ -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"
|
||||
|
||||
+16
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user