Merge pull request #611 from soilSpoon/feature/antigravity
feat(antigravity): Improve Claude model compatibility
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
package util
|
||||
|
||||
import "strings"
|
||||
|
||||
// IsClaudeThinkingModel checks if the model is a Claude thinking model
|
||||
// that requires the interleaved-thinking beta header.
|
||||
func IsClaudeThinkingModel(model string) bool {
|
||||
lower := strings.ToLower(model)
|
||||
return strings.Contains(lower, "claude") && strings.Contains(lower, "thinking")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsClaudeThinkingModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
expected bool
|
||||
}{
|
||||
// Claude thinking models - should return true
|
||||
{"claude-sonnet-4-5-thinking", "claude-sonnet-4-5-thinking", true},
|
||||
{"claude-opus-4-5-thinking", "claude-opus-4-5-thinking", true},
|
||||
{"Claude-Sonnet-Thinking uppercase", "Claude-Sonnet-4-5-Thinking", true},
|
||||
{"claude thinking mixed case", "Claude-THINKING-Model", true},
|
||||
|
||||
// Non-thinking Claude models - should return false
|
||||
{"claude-sonnet-4-5 (no thinking)", "claude-sonnet-4-5", false},
|
||||
{"claude-opus-4-5 (no thinking)", "claude-opus-4-5", false},
|
||||
{"claude-3-5-sonnet", "claude-3-5-sonnet-20240620", false},
|
||||
|
||||
// Non-Claude models - should return false
|
||||
{"gemini-3-pro-preview", "gemini-3-pro-preview", false},
|
||||
{"gemini-thinking model", "gemini-3-pro-thinking", false}, // not Claude
|
||||
{"gpt-4o", "gpt-4o", false},
|
||||
{"empty string", "", false},
|
||||
|
||||
// Edge cases
|
||||
{"thinking without claude", "thinking-model", false},
|
||||
{"claude without thinking", "claude-model", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsClaudeThinkingModel(tt.model)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsClaudeThinkingModel(%q) = %v, expected %v", tt.model, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
|
||||
var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
|
||||
|
||||
// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini/Antigravity API.
|
||||
// CleanJSONSchemaForAntigravity transforms a JSON schema to be compatible with Antigravity API.
|
||||
// It handles unsupported keywords, type flattening, and schema simplification while preserving
|
||||
// semantic information as description hints.
|
||||
func CleanJSONSchemaForGemini(jsonStr string) string {
|
||||
func CleanJSONSchemaForAntigravity(jsonStr string) string {
|
||||
// Phase 1: Convert and add hints
|
||||
jsonStr = convertRefsToHints(jsonStr)
|
||||
jsonStr = convertConstToEnum(jsonStr)
|
||||
@@ -32,6 +32,9 @@ func CleanJSONSchemaForGemini(jsonStr string) string {
|
||||
jsonStr = removeUnsupportedKeywords(jsonStr)
|
||||
jsonStr = cleanupRequiredFields(jsonStr)
|
||||
|
||||
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
|
||||
jsonStr = addEmptySchemaPlaceholder(jsonStr)
|
||||
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
@@ -105,7 +108,8 @@ func addAdditionalPropertiesHints(jsonStr string) string {
|
||||
|
||||
var unsupportedConstraints = []string{
|
||||
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
|
||||
"pattern", "minItems", "maxItems",
|
||||
"pattern", "minItems", "maxItems", "format",
|
||||
"default", "examples", // Claude rejects these in VALIDATED mode
|
||||
}
|
||||
|
||||
func moveConstraintsToDescription(jsonStr string) string {
|
||||
@@ -339,6 +343,52 @@ func cleanupRequiredFields(jsonStr string) string {
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// addEmptySchemaPlaceholder adds a placeholder "reason" property to empty object schemas.
|
||||
// Claude VALIDATED mode requires at least one property in tool schemas.
|
||||
func addEmptySchemaPlaceholder(jsonStr string) string {
|
||||
// Find all "type" fields
|
||||
paths := findPaths(jsonStr, "type")
|
||||
|
||||
// Process from deepest to shallowest (to handle nested objects properly)
|
||||
sortByDepth(paths)
|
||||
|
||||
for _, p := range paths {
|
||||
typeVal := gjson.Get(jsonStr, p)
|
||||
if typeVal.String() != "object" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the parent path (the object containing "type")
|
||||
parentPath := trimSuffix(p, ".type")
|
||||
|
||||
// Check if properties exists and is empty or missing
|
||||
propsPath := joinPath(parentPath, "properties")
|
||||
propsVal := gjson.Get(jsonStr, propsPath)
|
||||
|
||||
needsPlaceholder := false
|
||||
if !propsVal.Exists() {
|
||||
// No properties field at all
|
||||
needsPlaceholder = true
|
||||
} else if propsVal.IsObject() && len(propsVal.Map()) == 0 {
|
||||
// Empty properties object
|
||||
needsPlaceholder = true
|
||||
}
|
||||
|
||||
if needsPlaceholder {
|
||||
// Add placeholder "reason" property
|
||||
reasonPath := joinPath(propsPath, "reason")
|
||||
jsonStr, _ = sjson.Set(jsonStr, reasonPath+".type", "string")
|
||||
jsonStr, _ = sjson.Set(jsonStr, reasonPath+".description", "Brief explanation of why you are calling this tool")
|
||||
|
||||
// Add to required array
|
||||
reqPath := joinPath(parentPath, "required")
|
||||
jsonStr, _ = sjson.Set(jsonStr, reqPath, []string{"reason"})
|
||||
}
|
||||
}
|
||||
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func findPaths(jsonStr, field string) []string {
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCleanJSONSchemaForGemini_ConstToEnum(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -28,11 +30,11 @@ func TestCleanJSONSchemaForGemini_ConstToEnum(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -60,11 +62,11 @@ func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable(t *testing.T) {
|
||||
"required": ["other"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_ConstraintsToDescription(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -81,7 +83,7 @@ func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// minItems should be REMOVED and moved to description
|
||||
if strings.Contains(result, `"minItems"`) {
|
||||
@@ -100,7 +102,7 @@ func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_AnyOfFlattening_SmartSelection(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -131,11 +133,11 @@ func TestCleanJSONSchemaForGemini_AnyOfFlattening_SmartSelection(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_OneOfFlattening(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -158,11 +160,11 @@ func TestCleanJSONSchemaForGemini_OneOfFlattening(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_AllOfMerging(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_AllOfMerging(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
@@ -190,11 +192,11 @@ func TestCleanJSONSchemaForGemini_AllOfMerging(t *testing.T) {
|
||||
"required": ["a", "b"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_RefHandling(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_RefHandling(t *testing.T) {
|
||||
input := `{
|
||||
"definitions": {
|
||||
"User": {
|
||||
@@ -210,21 +212,29 @@ func TestCleanJSONSchemaForGemini_RefHandling(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
// After $ref is converted to placeholder object, empty schema placeholder is also added
|
||||
expected := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer": {
|
||||
"type": "object",
|
||||
"description": "See: User"
|
||||
"description": "See: User",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why you are calling this tool"
|
||||
}
|
||||
},
|
||||
"required": ["reason"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_RefHandling_DescriptionEscaping(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_RefHandling_DescriptionEscaping(t *testing.T) {
|
||||
input := `{
|
||||
"definitions": {
|
||||
"User": {
|
||||
@@ -243,21 +253,29 @@ func TestCleanJSONSchemaForGemini_RefHandling_DescriptionEscaping(t *testing.T)
|
||||
}
|
||||
}`
|
||||
|
||||
// After $ref is converted, empty schema placeholder is also added
|
||||
expected := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customer": {
|
||||
"type": "object",
|
||||
"description": "He said \"hi\"\\nsecond line (See: User)"
|
||||
"description": "He said \"hi\"\\nsecond line (See: User)",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why you are calling this tool"
|
||||
}
|
||||
},
|
||||
"required": ["reason"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) {
|
||||
input := `{
|
||||
"definitions": {
|
||||
"Node": {
|
||||
@@ -270,7 +288,7 @@ func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) {
|
||||
"$ref": "#/definitions/Node"
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
var resMap map[string]interface{}
|
||||
json.Unmarshal([]byte(result), &resMap)
|
||||
@@ -285,7 +303,7 @@ func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_RequiredCleanup(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_RequiredCleanup(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -304,11 +322,11 @@ func TestCleanJSONSchemaForGemini_RequiredCleanup(t *testing.T) {
|
||||
"required": ["a", "b"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_AllOfMerging_DotKeys(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_AllOfMerging_DotKeys(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
@@ -336,11 +354,11 @@ func TestCleanJSONSchemaForGemini_AllOfMerging_DotKeys(t *testing.T) {
|
||||
"required": ["my.param", "b"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_PropertyNameCollision(t *testing.T) {
|
||||
// A tool has an argument named "pattern" - should NOT be treated as a constraint
|
||||
input := `{
|
||||
"type": "object",
|
||||
@@ -364,7 +382,7 @@ func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) {
|
||||
"required": ["pattern"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
|
||||
var resMap map[string]interface{}
|
||||
@@ -375,7 +393,7 @@ func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_DotKeys(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -389,7 +407,7 @@ func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
var resMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result), &resMap); err != nil {
|
||||
@@ -414,7 +432,7 @@ func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_AnyOfAlternativeHints(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -428,7 +446,7 @@ func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
if !strings.Contains(result, "Accepts:") {
|
||||
t.Errorf("Expected alternative types hint, got: %s", result)
|
||||
@@ -438,7 +456,7 @@ func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_NullableHint(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -450,7 +468,7 @@ func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) {
|
||||
"required": ["name"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
if !strings.Contains(result, "(nullable)") {
|
||||
t.Errorf("Expected nullable hint, got: %s", result)
|
||||
@@ -460,7 +478,7 @@ func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable_DotKey(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_TypeFlattening_Nullable_DotKey(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -488,11 +506,11 @@ func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable_DotKey(t *testing.T) {
|
||||
"required": ["other"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_EnumHint(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -504,7 +522,7 @@ func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
if !strings.Contains(result, "Allowed:") {
|
||||
t.Errorf("Expected enum values hint, got: %s", result)
|
||||
@@ -514,7 +532,7 @@ func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_AdditionalPropertiesHint(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_AdditionalPropertiesHint(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -523,14 +541,14 @@ func TestCleanJSONSchemaForGemini_AdditionalPropertiesHint(t *testing.T) {
|
||||
"additionalProperties": false
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
if !strings.Contains(result, "No extra properties allowed") {
|
||||
t.Errorf("Expected additionalProperties hint, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_AnyOfFlattening_PreservesDescription(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -554,11 +572,11 @@ func TestCleanJSONSchemaForGemini_AnyOfFlattening_PreservesDescription(t *testin
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
compareJSON(t, expected, result)
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_SingleEnumNoHint(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -569,14 +587,14 @@ func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
if strings.Contains(result, "Allowed:") {
|
||||
t.Errorf("Single value enum should not add Allowed hint, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForGemini_MultipleNonNullTypes(t *testing.T) {
|
||||
func TestCleanJSONSchemaForAntigravity_MultipleNonNullTypes(t *testing.T) {
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -586,7 +604,7 @@ func TestCleanJSONSchemaForGemini_MultipleNonNullTypes(t *testing.T) {
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForGemini(input)
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
if !strings.Contains(result, "Accepts:") {
|
||||
t.Errorf("Expected multiple types hint, got: %s", result)
|
||||
@@ -676,3 +694,190 @@ func compareJSON(t *testing.T, expectedJSON, actualJSON string) {
|
||||
t.Errorf("JSON mismatch:\nExpected:\n%s\n\nActual:\n%s", string(expBytes), string(actBytes))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Empty Schema Placeholder Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_EmptySchemaPlaceholder(t *testing.T) {
|
||||
// Empty object schema with no properties should get a placeholder
|
||||
input := `{
|
||||
"type": "object"
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// Should have placeholder property added
|
||||
if !strings.Contains(result, `"reason"`) {
|
||||
t.Errorf("Empty schema should have 'reason' placeholder property, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, `"required"`) {
|
||||
t.Errorf("Empty schema should have 'required' with 'reason', got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_EmptyPropertiesPlaceholder(t *testing.T) {
|
||||
// Object with empty properties object
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// Should have placeholder property added
|
||||
if !strings.Contains(result, `"reason"`) {
|
||||
t.Errorf("Empty properties should have 'reason' placeholder, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_NonEmptySchemaUnchanged(t *testing.T) {
|
||||
// Schema with properties should NOT get placeholder
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"}
|
||||
},
|
||||
"required": ["name"]
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// Should NOT have placeholder property
|
||||
if strings.Contains(result, `"reason"`) {
|
||||
t.Errorf("Non-empty schema should NOT have 'reason' placeholder, got: %s", result)
|
||||
}
|
||||
// Original properties should be preserved
|
||||
if !strings.Contains(result, `"name"`) {
|
||||
t.Errorf("Original property 'name' should be preserved, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_NestedEmptySchema(t *testing.T) {
|
||||
// Nested empty object in items should also get placeholder
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// Nested empty object should also get placeholder
|
||||
// Check that the nested object has a reason property
|
||||
parsed := gjson.Parse(result)
|
||||
nestedProps := parsed.Get("properties.items.items.properties")
|
||||
if !nestedProps.Exists() || !nestedProps.Get("reason").Exists() {
|
||||
t.Errorf("Nested empty object should have 'reason' placeholder, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T) {
|
||||
// Empty schema with description should preserve description and add placeholder
|
||||
input := `{
|
||||
"type": "object",
|
||||
"description": "An empty object"
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// Should have both description and placeholder
|
||||
if !strings.Contains(result, `"An empty object"`) {
|
||||
t.Errorf("Description should be preserved, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, `"reason"`) {
|
||||
t.Errorf("Empty schema should have 'reason' placeholder, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Format field handling (ad-hoc patch removal)
|
||||
// ============================================================================
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_FormatFieldRemoval(t *testing.T) {
|
||||
// format:"uri" should be removed and added as hint
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "A URL"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// format should be removed
|
||||
if strings.Contains(result, `"format"`) {
|
||||
t.Errorf("format field should be removed, got: %s", result)
|
||||
}
|
||||
// hint should be added to description
|
||||
if !strings.Contains(result, "format: uri") {
|
||||
t.Errorf("format hint should be added to description, got: %s", result)
|
||||
}
|
||||
// original description should be preserved
|
||||
if !strings.Contains(result, "A URL") {
|
||||
t.Errorf("Original description should be preserved, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_FormatFieldNoDescription(t *testing.T) {
|
||||
// format without description should create description with hint
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// format should be removed
|
||||
if strings.Contains(result, `"format"`) {
|
||||
t.Errorf("format field should be removed, got: %s", result)
|
||||
}
|
||||
// hint should be added
|
||||
if !strings.Contains(result, "format: email") {
|
||||
t.Errorf("format hint should be added, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanJSONSchemaForAntigravity_MultipleFormats(t *testing.T) {
|
||||
// Multiple format fields should all be handled
|
||||
input := `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "format": "uri"},
|
||||
"email": {"type": "string", "format": "email"},
|
||||
"date": {"type": "string", "format": "date-time"}
|
||||
}
|
||||
}`
|
||||
|
||||
result := CleanJSONSchemaForAntigravity(input)
|
||||
|
||||
// All format fields should be removed
|
||||
if strings.Contains(result, `"format"`) {
|
||||
t.Errorf("All format fields should be removed, got: %s", result)
|
||||
}
|
||||
// All hints should be added
|
||||
if !strings.Contains(result, "format: uri") {
|
||||
t.Errorf("uri format hint should be added, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "format: email") {
|
||||
t.Errorf("email format hint should be added, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "format: date-time") {
|
||||
t.Errorf("date-time format hint should be added, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// GetThinkingText extracts the thinking text from a content part.
|
||||
// Handles various formats:
|
||||
// - Simple string: { "thinking": "text" } or { "text": "text" }
|
||||
// - Wrapped object: { "thinking": { "text": "text", "cache_control": {...} } }
|
||||
// - Gemini-style: { "thought": true, "text": "text" }
|
||||
// Returns the extracted text string.
|
||||
func GetThinkingText(part gjson.Result) string {
|
||||
// Try direct text field first (Gemini-style)
|
||||
if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
|
||||
return text.String()
|
||||
}
|
||||
|
||||
// Try thinking field
|
||||
thinkingField := part.Get("thinking")
|
||||
if !thinkingField.Exists() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// thinking is a string
|
||||
if thinkingField.Type == gjson.String {
|
||||
return thinkingField.String()
|
||||
}
|
||||
|
||||
// thinking is an object with inner text/thinking
|
||||
if thinkingField.IsObject() {
|
||||
if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
|
||||
return inner.String()
|
||||
}
|
||||
if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
|
||||
return inner.String()
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetThinkingTextFromJSON extracts thinking text from a raw JSON string.
|
||||
func GetThinkingTextFromJSON(jsonStr string) string {
|
||||
return GetThinkingText(gjson.Parse(jsonStr))
|
||||
}
|
||||
|
||||
// SanitizeThinkingPart normalizes a thinking part to a canonical form.
|
||||
// Strips cache_control and other non-essential fields.
|
||||
// Returns the sanitized part as JSON string.
|
||||
func SanitizeThinkingPart(part gjson.Result) string {
|
||||
// Gemini-style: { thought: true, text, thoughtSignature }
|
||||
if part.Get("thought").Bool() {
|
||||
result := `{"thought":true}`
|
||||
if text := GetThinkingText(part); text != "" {
|
||||
result, _ = sjson.Set(result, "text", text)
|
||||
}
|
||||
if sig := part.Get("thoughtSignature"); sig.Exists() && sig.Type == gjson.String {
|
||||
result, _ = sjson.Set(result, "thoughtSignature", sig.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Anthropic-style: { type: "thinking", thinking, signature }
|
||||
if part.Get("type").String() == "thinking" || part.Get("thinking").Exists() {
|
||||
result := `{"type":"thinking"}`
|
||||
if text := GetThinkingText(part); text != "" {
|
||||
result, _ = sjson.Set(result, "thinking", text)
|
||||
}
|
||||
if sig := part.Get("signature"); sig.Exists() && sig.Type == gjson.String {
|
||||
result, _ = sjson.Set(result, "signature", sig.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Not a thinking part, return as-is but strip cache_control
|
||||
return StripCacheControl(part.Raw)
|
||||
}
|
||||
|
||||
// StripCacheControl removes cache_control and providerOptions from a JSON object.
|
||||
func StripCacheControl(jsonStr string) string {
|
||||
result := jsonStr
|
||||
result, _ = sjson.Delete(result, "cache_control")
|
||||
result, _ = sjson.Delete(result, "providerOptions")
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user