mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 04:40:52 +08:00
feat(translator/antigravity/claude): support interleaved thinking, signature restoration and system hint injection
This commit is contained in:
@@ -7,8 +7,11 @@ package claude
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -17,6 +20,29 @@ import (
|
|||||||
|
|
||||||
const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
|
const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
|
||||||
|
|
||||||
|
// deriveSessionID generates a stable session ID from the request.
|
||||||
|
// Uses the hash of the first user message to identify the conversation.
|
||||||
|
func deriveSessionID(rawJSON []byte) string {
|
||||||
|
messages := gjson.GetBytes(rawJSON, "messages")
|
||||||
|
if !messages.IsArray() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, msg := range messages.Array() {
|
||||||
|
if msg.Get("role").String() == "user" {
|
||||||
|
content := msg.Get("content").String()
|
||||||
|
if content == "" {
|
||||||
|
// Try to get text from content array
|
||||||
|
content = msg.Get("content.0.text").String()
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
h := sha256.Sum256([]byte(content))
|
||||||
|
return hex.EncodeToString(h[:16])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ConvertClaudeRequestToAntigravity parses and transforms a Claude Code API request into Gemini CLI API format.
|
// 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
|
// 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.
|
// from the raw JSON request and returns them in the format expected by the Gemini CLI API.
|
||||||
@@ -37,7 +63,9 @@ const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
|
|||||||
// - []byte: The transformed request data in Gemini CLI API format
|
// - []byte: The transformed request data in Gemini CLI API format
|
||||||
func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
|
func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||||
rawJSON := bytes.Clone(inputRawJSON)
|
rawJSON := bytes.Clone(inputRawJSON)
|
||||||
rawJSON = bytes.Replace(rawJSON, []byte(`"url":{"type":"string","format":"uri",`), []byte(`"url":{"type":"string",`), -1)
|
|
||||||
|
// Derive session ID for signature caching
|
||||||
|
sessionID := deriveSessionID(rawJSON)
|
||||||
|
|
||||||
// system instruction
|
// system instruction
|
||||||
systemInstructionJSON := ""
|
systemInstructionJSON := ""
|
||||||
@@ -67,13 +95,15 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
messagesResult := gjson.GetBytes(rawJSON, "messages")
|
messagesResult := gjson.GetBytes(rawJSON, "messages")
|
||||||
if messagesResult.IsArray() {
|
if messagesResult.IsArray() {
|
||||||
messageResults := messagesResult.Array()
|
messageResults := messagesResult.Array()
|
||||||
for i := 0; i < len(messageResults); i++ {
|
numMessages := len(messageResults)
|
||||||
|
for i := 0; i < numMessages; i++ {
|
||||||
messageResult := messageResults[i]
|
messageResult := messageResults[i]
|
||||||
roleResult := messageResult.Get("role")
|
roleResult := messageResult.Get("role")
|
||||||
if roleResult.Type != gjson.String {
|
if roleResult.Type != gjson.String {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
role := roleResult.String()
|
originalRole := roleResult.String()
|
||||||
|
role := originalRole
|
||||||
if role == "assistant" {
|
if role == "assistant" {
|
||||||
role = "model"
|
role = "model"
|
||||||
}
|
}
|
||||||
@@ -82,20 +112,47 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
contentsResult := messageResult.Get("content")
|
contentsResult := messageResult.Get("content")
|
||||||
if contentsResult.IsArray() {
|
if contentsResult.IsArray() {
|
||||||
contentResults := contentsResult.Array()
|
contentResults := contentsResult.Array()
|
||||||
for j := 0; j < len(contentResults); j++ {
|
numContents := len(contentResults)
|
||||||
|
for j := 0; j < numContents; j++ {
|
||||||
contentResult := contentResults[j]
|
contentResult := contentResults[j]
|
||||||
contentTypeResult := contentResult.Get("type")
|
contentTypeResult := contentResult.Get("type")
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
||||||
prompt := contentResult.Get("thinking").String()
|
thinkingText := contentResult.Get("thinking").String()
|
||||||
signatureResult := contentResult.Get("signature")
|
signatureResult := contentResult.Get("signature")
|
||||||
signature := geminiCLIClaudeThoughtSignature
|
signature := ""
|
||||||
if signatureResult.Exists() {
|
if signatureResult.Exists() && signatureResult.String() != "" {
|
||||||
signature = signatureResult.String()
|
signature = signatureResult.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P3: Try to restore signature from cache for unsigned thinking blocks
|
||||||
|
if !cache.HasValidSignature(signature) && sessionID != "" && thinkingText != "" {
|
||||||
|
if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" {
|
||||||
|
signature = cachedSig
|
||||||
|
log.Debugf("Restored cached signature for thinking block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2-A: Skip trailing unsigned thinking blocks on last assistant message
|
||||||
|
isLastMessage := (i == numMessages-1)
|
||||||
|
isLastContent := (j == numContents-1)
|
||||||
|
isAssistant := (originalRole == "assistant")
|
||||||
|
isUnsigned := !cache.HasValidSignature(signature)
|
||||||
|
|
||||||
|
if isLastMessage && isLastContent && isAssistant && isUnsigned {
|
||||||
|
// Skip this trailing unsigned thinking block
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sentinel for unsigned thinking blocks that are not trailing
|
||||||
|
// (includes empty string and short/invalid signatures < 50 chars)
|
||||||
|
if isUnsigned {
|
||||||
|
signature = geminiCLIClaudeThoughtSignature
|
||||||
|
}
|
||||||
|
|
||||||
partJSON := `{}`
|
partJSON := `{}`
|
||||||
partJSON, _ = sjson.Set(partJSON, "thought", true)
|
partJSON, _ = sjson.Set(partJSON, "thought", true)
|
||||||
if prompt != "" {
|
if thinkingText != "" {
|
||||||
partJSON, _ = sjson.Set(partJSON, "text", prompt)
|
partJSON, _ = sjson.Set(partJSON, "text", thinkingText)
|
||||||
}
|
}
|
||||||
if signature != "" {
|
if signature != "" {
|
||||||
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", signature)
|
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", signature)
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
package claude
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Hello"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"system": [
|
||||||
|
{"type": "text", "text": "You are helpful"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check model
|
||||||
|
if gjson.Get(outputStr, "model").String() != "claude-sonnet-4-5" {
|
||||||
|
t.Errorf("Expected model 'claude-sonnet-4-5', got '%s'", gjson.Get(outputStr, "model").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check contents exist
|
||||||
|
contents := gjson.Get(outputStr, "request.contents")
|
||||||
|
if !contents.Exists() || !contents.IsArray() {
|
||||||
|
t.Error("request.contents should exist and be an array")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role mapping (assistant -> model)
|
||||||
|
firstContent := gjson.Get(outputStr, "request.contents.0")
|
||||||
|
if firstContent.Get("role").String() != "user" {
|
||||||
|
t.Errorf("Expected role 'user', got '%s'", firstContent.Get("role").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check systemInstruction
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if !sysInstruction.Exists() {
|
||||||
|
t.Error("systemInstruction should exist")
|
||||||
|
}
|
||||||
|
if sysInstruction.Get("parts.0.text").String() != "You are helpful" {
|
||||||
|
t.Error("systemInstruction text mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": [{"type": "text", "text": "Hi"}]},
|
||||||
|
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// assistant should be mapped to model
|
||||||
|
secondContent := gjson.Get(outputStr, "request.contents.1")
|
||||||
|
if secondContent.Get("role").String() != "model" {
|
||||||
|
t.Errorf("Expected role 'model' (mapped from 'assistant'), got '%s'", secondContent.Get("role").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
|
||||||
|
// Valid signature must be at least 50 characters
|
||||||
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check thinking block conversion
|
||||||
|
firstPart := gjson.Get(outputStr, "request.contents.0.parts.0")
|
||||||
|
if !firstPart.Get("thought").Bool() {
|
||||||
|
t.Error("thinking block should have thought: true")
|
||||||
|
}
|
||||||
|
if firstPart.Get("text").String() != "Let me think..." {
|
||||||
|
t.Error("thinking text mismatch")
|
||||||
|
}
|
||||||
|
if firstPart.Get("thoughtSignature").String() != validSignature {
|
||||||
|
t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, firstPart.Get("thoughtSignature").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me think..."},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Without signature, should use sentinel value
|
||||||
|
firstPart := gjson.Get(outputStr, "request.contents.0.parts.0")
|
||||||
|
if firstPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature {
|
||||||
|
t.Errorf("Expected sentinel signature '%s', got '%s'",
|
||||||
|
geminiCLIClaudeThoughtSignature, firstPart.Get("thoughtSignature").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolDeclarations(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "test_tool",
|
||||||
|
"description": "A test tool",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("gemini-1.5-pro", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check tools structure
|
||||||
|
tools := gjson.Get(outputStr, "request.tools")
|
||||||
|
if !tools.Exists() {
|
||||||
|
t.Error("Tools should exist in output")
|
||||||
|
}
|
||||||
|
|
||||||
|
funcDecl := gjson.Get(outputStr, "request.tools.0.functionDeclarations.0")
|
||||||
|
if funcDecl.Get("name").String() != "test_tool" {
|
||||||
|
t.Errorf("Expected tool name 'test_tool', got '%s'", funcDecl.Get("name").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check input_schema renamed to parametersJsonSchema
|
||||||
|
if funcDecl.Get("parametersJsonSchema").Exists() {
|
||||||
|
t.Log("parametersJsonSchema exists (expected)")
|
||||||
|
}
|
||||||
|
if funcDecl.Get("input_schema").Exists() {
|
||||||
|
t.Error("input_schema should be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_123",
|
||||||
|
"name": "get_weather",
|
||||||
|
"input": "{\"location\": \"Paris\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check function call conversion
|
||||||
|
funcCall := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall")
|
||||||
|
if !funcCall.Exists() {
|
||||||
|
t.Error("functionCall should exist")
|
||||||
|
}
|
||||||
|
if funcCall.Get("name").String() != "get_weather" {
|
||||||
|
t.Errorf("Expected function name 'get_weather', got '%s'", funcCall.Get("name").String())
|
||||||
|
}
|
||||||
|
if funcCall.Get("id").String() != "call_123" {
|
||||||
|
t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "get_weather-call-123",
|
||||||
|
"content": "22C sunny"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check function response conversion
|
||||||
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
||||||
|
if !funcResp.Exists() {
|
||||||
|
t.Error("functionResponse should exist")
|
||||||
|
}
|
||||||
|
if funcResp.Get("id").String() != "get_weather-call-123" {
|
||||||
|
t.Errorf("Expected function id, got '%s'", funcResp.Get("id").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) {
|
||||||
|
// Note: This test requires the model to be registered in the registry
|
||||||
|
// with Thinking metadata. If the registry is not populated in test environment,
|
||||||
|
// thinkingConfig won't be added. We'll test the basic structure only.
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [],
|
||||||
|
"thinking": {
|
||||||
|
"type": "enabled",
|
||||||
|
"budget_tokens": 8000
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check thinking config conversion (only if model supports thinking in registry)
|
||||||
|
thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig")
|
||||||
|
if thinkingConfig.Exists() {
|
||||||
|
if thinkingConfig.Get("thinkingBudget").Int() != 8000 {
|
||||||
|
t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int())
|
||||||
|
}
|
||||||
|
if !thinkingConfig.Get("include_thoughts").Bool() {
|
||||||
|
t.Error("include_thoughts should be true")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Log("thinkingConfig not present - model may not be registered in test registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ImageContent(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": "iVBORw0KGgoAAAANSUhEUg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Check inline data conversion
|
||||||
|
inlineData := gjson.Get(outputStr, "request.contents.0.parts.0.inlineData")
|
||||||
|
if !inlineData.Exists() {
|
||||||
|
t.Error("inlineData should exist")
|
||||||
|
}
|
||||||
|
if inlineData.Get("mime_type").String() != "image/png" {
|
||||||
|
t.Error("mime_type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
|
||||||
|
t.Error("data mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_GenerationConfig(t *testing.T) {
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-3-5-sonnet-20240620",
|
||||||
|
"messages": [],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"top_k": 40,
|
||||||
|
"max_tokens": 2000
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
genConfig := gjson.Get(outputStr, "request.generationConfig")
|
||||||
|
if genConfig.Get("temperature").Float() != 0.7 {
|
||||||
|
t.Errorf("Expected temperature 0.7, got %f", genConfig.Get("temperature").Float())
|
||||||
|
}
|
||||||
|
if genConfig.Get("topP").Float() != 0.9 {
|
||||||
|
t.Errorf("Expected topP 0.9, got %f", genConfig.Get("topP").Float())
|
||||||
|
}
|
||||||
|
if genConfig.Get("topK").Float() != 40 {
|
||||||
|
t.Errorf("Expected topK 40, got %f", genConfig.Get("topK").Float())
|
||||||
|
}
|
||||||
|
if genConfig.Get("maxOutputTokens").Float() != 2000 {
|
||||||
|
t.Errorf("Expected maxOutputTokens 2000, got %f", genConfig.Get("maxOutputTokens").Float())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// P2-A: Trailing Unsigned Thinking Block Removal
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *testing.T) {
|
||||||
|
// Last assistant message ends with unsigned thinking block - should be removed
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Hello"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Here is my answer"},
|
||||||
|
{"type": "thinking", "thinking": "I should think more..."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// The last part of the last assistant message should NOT be a thinking block
|
||||||
|
lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
|
||||||
|
if !lastMessageParts.IsArray() {
|
||||||
|
t.Fatal("Last message should have parts array")
|
||||||
|
}
|
||||||
|
parts := lastMessageParts.Array()
|
||||||
|
if len(parts) == 0 {
|
||||||
|
t.Fatal("Last message should have at least one part")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unsigned thinking should be removed, leaving only the text
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
if lastPart.Get("thought").Bool() {
|
||||||
|
t.Error("Trailing unsigned thinking block should be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) {
|
||||||
|
// Last assistant message ends with signed thinking block - should be kept
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Hello"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Here is my answer"},
|
||||||
|
{"type": "thinking", "thinking": "Valid thinking...", "signature": "abc123validSignature1234567890123456789012345678901234567890"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// The signed thinking block should be preserved
|
||||||
|
lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
|
||||||
|
parts := lastMessageParts.Array()
|
||||||
|
if len(parts) < 2 {
|
||||||
|
t.Error("Signed thinking block should be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplied(t *testing.T) {
|
||||||
|
// Middle message has unsigned thinking - should use sentinel (existing behavior)
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Middle thinking..."},
|
||||||
|
{"type": "text", "text": "Answer"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": "Follow up"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// Middle unsigned thinking should have sentinel applied
|
||||||
|
thinkingPart := gjson.Get(outputStr, "request.contents.0.parts.0")
|
||||||
|
if !thinkingPart.Get("thought").Bool() {
|
||||||
|
t.Error("Middle thinking block should be preserved with sentinel")
|
||||||
|
}
|
||||||
|
if thinkingPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature {
|
||||||
|
t.Errorf("Middle unsigned thinking should use sentinel signature, got: %s", thinkingPart.Get("thoughtSignature").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// P2-B: Tool + Thinking System Hint Injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolAndThinking_HintInjected(t *testing.T) {
|
||||||
|
// When both tools and thinking are enabled, hint should be injected into system instruction
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||||
|
"system": [{"type": "text", "text": "You are helpful."}],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather",
|
||||||
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// System instruction should contain the interleaved thinking hint
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if !sysInstruction.Exists() {
|
||||||
|
t.Fatal("systemInstruction should exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if hint is appended
|
||||||
|
sysText := sysInstruction.Get("parts").Array()
|
||||||
|
found := false
|
||||||
|
for _, part := range sysText {
|
||||||
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Interleaved thinking hint should be injected when tools and thinking are both active, got: %v", sysInstruction.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolsOnly_NoHint(t *testing.T) {
|
||||||
|
// When only tools are present (no thinking), hint should NOT be injected
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||||
|
"system": [{"type": "text", "text": "You are helpful."}],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather",
|
||||||
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// System instruction should NOT contain the hint
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if sysInstruction.Exists() {
|
||||||
|
for _, part := range sysInstruction.Get("parts").Array() {
|
||||||
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
||||||
|
t.Error("Hint should NOT be injected when only tools are present (no thinking)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ThinkingOnly_NoHint(t *testing.T) {
|
||||||
|
// When only thinking is enabled (no tools), hint should NOT be injected
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||||
|
"system": [{"type": "text", "text": "You are helpful."}],
|
||||||
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// System instruction should NOT contain the hint (no tools)
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if sysInstruction.Exists() {
|
||||||
|
for _, part := range sysInstruction.Get("parts").Array() {
|
||||||
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
||||||
|
t.Error("Hint should NOT be injected when only thinking is present (no tools)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) {
|
||||||
|
// When tools + thinking but no system instruction, should create one with hint
|
||||||
|
inputJSON := []byte(`{
|
||||||
|
"model": "claude-sonnet-4-5-thinking",
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather",
|
||||||
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
// System instruction should be created with hint
|
||||||
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
||||||
|
if !sysInstruction.Exists() {
|
||||||
|
t.Fatal("systemInstruction should be created when tools + thinking are active")
|
||||||
|
}
|
||||||
|
|
||||||
|
sysText := sysInstruction.Get("parts").Array()
|
||||||
|
found := false
|
||||||
|
for _, part := range sysText {
|
||||||
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Interleaved thinking hint should be in created systemInstruction, got: %v", sysInstruction.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user