diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 33df61f9..38d3773e 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -390,6 +390,11 @@ func addEmptySchemaPlaceholder(jsonStr string) string { // If schema has properties but none are required, add a minimal placeholder. if propsVal.IsObject() && !hasRequiredProperties { + // DO NOT add placeholder if it's a top-level schema (parentPath is empty) + // or if we've already added a placeholder reason above. + if parentPath == "" { + continue + } placeholderPath := joinPath(propsPath, "_") if !gjson.Get(jsonStr, placeholderPath).Exists() { jsonStr, _ = sjson.Set(jsonStr, placeholderPath+".type", "boolean") diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 69adbcdb..60335f22 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -127,8 +127,10 @@ func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_SmartSelection(t *testing "type": "object", "description": "Accepts: null | object", "properties": { + "_": { "type": "boolean" }, "kind": { "type": "string" } - } + }, + "required": ["_"] } } }` diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go new file mode 100644 index 00000000..4ff8454b --- /dev/null +++ b/internal/util/sanitize_test.go @@ -0,0 +1,56 @@ +package util + +import ( + "testing" +) + +func TestSanitizeFunctionName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Normal", "valid_name", "valid_name"}, + {"With Dots", "name.with.dots", "name.with.dots"}, + {"With Colons", "name:with:colons", "name:with:colons"}, + {"With Dashes", "name-with-dashes", "name-with-dashes"}, + {"Mixed Allowed", "name.with_dots:colons-dashes", "name.with_dots:colons-dashes"}, + {"Invalid Characters", "name!with@invalid#chars", "name_with_invalid_chars"}, + {"Spaces", "name with spaces", "name_with_spaces"}, + {"Non-ASCII", "name_with_你好_chars", "name_with____chars"}, + {"Starts with digit", "123name", "_123name"}, + {"Starts with dot", ".name", "_.name"}, + {"Starts with colon", ":name", "_:name"}, + {"Starts with dash", "-name", "_-name"}, + {"Starts with invalid char", "!name", "_name"}, + {"Exactly 64 chars", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, + {"Too long (65 chars)", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charactX", "this_is_a_very_long_name_that_exactly_reaches_sixty_four_charact"}, + {"Very long", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_limit_for_function_names", "this_is_a_very_long_name_that_exceeds_the_sixty_four_character_l"}, + {"Starts with digit (64 chars total)", "1234567890123456789012345678901234567890123456789012345678901234", "_123456789012345678901234567890123456789012345678901234567890123"}, + {"Starts with invalid char (64 chars total)", "!234567890123456789012345678901234567890123456789012345678901234", "_234567890123456789012345678901234567890123456789012345678901234"}, + {"Empty", "", ""}, + {"Single character invalid", "@", "_"}, + {"Single character valid", "a", "a"}, + {"Single character digit", "1", "_1"}, + {"Single character underscore", "_", "_"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SanitizeFunctionName(tt.input) + if got != tt.expected { + t.Errorf("SanitizeFunctionName(%q) = %v, want %v", tt.input, got, tt.expected) + } + // Verify Gemini compliance + if len(got) > 64 { + t.Errorf("SanitizeFunctionName(%q) result too long: %d", tt.input, len(got)) + } + if len(got) > 0 { + first := got[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + t.Errorf("SanitizeFunctionName(%q) result starts with invalid char: %c", tt.input, first) + } + } + }) + } +} diff --git a/internal/util/util.go b/internal/util/util.go index 17536ac1..4e846306 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -8,12 +8,52 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" log "github.com/sirupsen/logrus" ) +var functionNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_.:-]`) + +// SanitizeFunctionName ensures a function name matches the requirements for Gemini/Vertex AI. +// It replaces invalid characters with underscores, ensures it starts with a letter or underscore, +// and truncates it to 64 characters if necessary. +// Regex Rule: [^a-zA-Z0-9_.:-] replaced with _. +func SanitizeFunctionName(name string) string { + if name == "" { + return "" + } + + // Replace invalid characters with underscore + sanitized := functionNameSanitizer.ReplaceAllString(name, "_") + + // Ensure it starts with a letter or underscore + // Re-reading requirements: Must start with a letter or an underscore. + if len(sanitized) > 0 { + first := sanitized[0] + if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') { + // If it starts with an allowed character but not allowed at the beginning (digit, dot, colon, dash), + // we must prepend an underscore. + + // To stay within the 64-character limit while prepending, we must truncate first. + if len(sanitized) >= 64 { + sanitized = sanitized[:63] + } + sanitized = "_" + sanitized + } + } else { + sanitized = "_" + } + + // Truncate to 64 characters + if len(sanitized) > 64 { + sanitized = sanitized[:64] + } + return sanitized +} + // SetLogLevel configures the logrus log level based on the configuration. // It sets the log level to DebugLevel if debug mode is enabled, otherwise to InfoLevel. func SetLogLevel(cfg *config.Config) {