From 1b8e538a7791ba553ac5f6f39eab88769e652cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Wed, 17 Dec 2025 17:10:53 +0900 Subject: [PATCH 1/3] feature: Improves Gemini JSON schema compatibility Enhances compatibility with the Gemini API by implementing a schema cleaning process. This includes: - Centralizing schema cleaning logic for Gemini in a dedicated utility function. - Converting unsupported schema keywords to hints within the description field. - Flattening complex schema structures like `anyOf`, `oneOf`, and type arrays to simplify the schema. - Handling streaming responses with empty tool names, which can occur in subsequent chunks after the initial tool use. --- .../runtime/executor/antigravity_executor.go | 24 +- .../gemini/claude/gemini_claude_response.go | 16 +- internal/util/gemini_schema.go | 404 +++++++++++++++ internal/util/gemini_schema_test.go | 468 ++++++++++++++++++ internal/util/translator.go | 13 +- 5 files changed, 901 insertions(+), 24 deletions(-) create mode 100644 internal/util/gemini_schema.go create mode 100644 internal/util/gemini_schema_test.go diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 5884f705..9f61c91b 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -545,27 +545,9 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") } - strJSON = util.DeleteKey(strJSON, "$schema") - strJSON = util.DeleteKey(strJSON, "maxItems") - strJSON = util.DeleteKey(strJSON, "minItems") - strJSON = util.DeleteKey(strJSON, "minLength") - strJSON = util.DeleteKey(strJSON, "maxLength") - strJSON = util.DeleteKey(strJSON, "exclusiveMinimum") - strJSON = util.DeleteKey(strJSON, "exclusiveMaximum") - strJSON = util.DeleteKey(strJSON, "$ref") - strJSON = util.DeleteKey(strJSON, "$defs") - - paths = make([]string, 0) - util.Walk(gjson.Parse(strJSON), "", "anyOf", &paths) - for _, p := range paths { - anyOf := gjson.Get(strJSON, p) - if anyOf.IsArray() { - anyOfItems := anyOf.Array() - if len(anyOfItems) > 0 { - strJSON, _ = sjson.SetRaw(strJSON, p[:len(p)-len(".anyOf")], anyOfItems[0].Raw) - } - } - } + // Use the centralized schema cleaner to handle unsupported keywords, + // const->enum conversion, and flattening of types/anyOf. + strJSON = util.CleanJSONSchemaForGemini(strJSON) payload = []byte(strJSON) } diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index 7767c365..90e1c501 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -25,7 +25,8 @@ type Params struct { HasFirstResponse bool ResponseType int ResponseIndex int - HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output + HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output + CurrentToolName string // Tracks the current function name for streaming limits } // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. @@ -179,6 +180,18 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR usedTool = true fcName := functionCallResult.Get("name").String() + // FIX: Handle streaming split/delta where name might be empty in subsequent chunks. + // If we are already in tool use mode and name is empty, treat as continuation (delta). + if (*param).(*Params).ResponseType == 3 && fcName == "" { + if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { + output = output + "event: content_block_delta\n" + data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex), "delta.partial_json", fcArgsResult.Raw) + output = output + fmt.Sprintf("data: %s\n\n\n", data) + } + // Continue to next part without closing/opening logic + continue + } + // Handle state transitions when switching to function calls // Close any existing function call block first if (*param).(*Params).ResponseType == 3 { @@ -221,6 +234,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR } (*param).(*Params).ResponseType = 3 (*param).(*Params).HasContent = true + (*param).(*Params).CurrentToolName = fcName } } } diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go new file mode 100644 index 00000000..8b7b0372 --- /dev/null +++ b/internal/util/gemini_schema.go @@ -0,0 +1,404 @@ +// Package util provides utility functions for the CLI Proxy API server. +package util + +import ( + "fmt" + "sort" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini/Antigravity API. +// It handles unsupported keywords, type flattening, and schema simplification while preserving +// semantic information as description hints. +func CleanJSONSchemaForGemini(jsonStr string) string { + // Phase 1: Convert and add hints + jsonStr = convertRefsToHints(jsonStr) + jsonStr = convertConstToEnum(jsonStr) + jsonStr = addEnumHints(jsonStr) + jsonStr = addAdditionalPropertiesHints(jsonStr) + jsonStr = moveConstraintsToDescription(jsonStr) + + // Phase 2: Flatten complex structures + jsonStr = mergeAllOf(jsonStr) + jsonStr = flattenAnyOfOneOf(jsonStr) + jsonStr = flattenTypeArrays(jsonStr) + + // Phase 3: Cleanup + jsonStr = removeUnsupportedKeywords(jsonStr) + jsonStr = cleanupRequiredFields(jsonStr) + + return jsonStr +} + +// convertRefsToHints converts $ref to description hints (Lazy Hint strategy). +func convertRefsToHints(jsonStr string) string { + paths := findPaths(jsonStr, "$ref") + sortByDepth(paths) + + for _, p := range paths { + refVal := gjson.Get(jsonStr, p).String() + defName := refVal + if idx := strings.LastIndex(refVal, "/"); idx >= 0 { + defName = refVal[idx+1:] + } + + parentPath := trimSuffix(p, ".$ref") + hint := fmt.Sprintf("See: %s", defName) + if existing := gjson.Get(jsonStr, parentPath+".description").String(); existing != "" { + hint = fmt.Sprintf("%s (%s)", existing, hint) + } + + replacement := fmt.Sprintf(`{"type":"object","description":"%s"}`, hint) + jsonStr = setRawAt(jsonStr, parentPath, replacement) + } + return jsonStr +} + +func convertConstToEnum(jsonStr string) string { + for _, p := range findPaths(jsonStr, "const") { + val := gjson.Get(jsonStr, p) + if !val.Exists() { + continue + } + enumPath := trimSuffix(p, ".const") + ".enum" + if !gjson.Get(jsonStr, enumPath).Exists() { + jsonStr, _ = sjson.Set(jsonStr, enumPath, []interface{}{val.Value()}) + } + } + return jsonStr +} + +func addEnumHints(jsonStr string) string { + for _, p := range findPaths(jsonStr, "enum") { + arr := gjson.Get(jsonStr, p) + if !arr.IsArray() { + continue + } + items := arr.Array() + if len(items) <= 1 || len(items) > 10 { + continue + } + + var vals []string + for _, item := range items { + vals = append(vals, item.String()) + } + jsonStr = appendHint(jsonStr, trimSuffix(p, ".enum"), "Allowed: "+strings.Join(vals, ", ")) + } + return jsonStr +} + +func addAdditionalPropertiesHints(jsonStr string) string { + for _, p := range findPaths(jsonStr, "additionalProperties") { + if gjson.Get(jsonStr, p).Type == gjson.False { + jsonStr = appendHint(jsonStr, trimSuffix(p, ".additionalProperties"), "No extra properties allowed") + } + } + return jsonStr +} + +var unsupportedConstraints = []string{ + "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", + "pattern", "minItems", "maxItems", +} + +func moveConstraintsToDescription(jsonStr string) string { + for _, key := range unsupportedConstraints { + for _, p := range findPaths(jsonStr, key) { + val := gjson.Get(jsonStr, p) + if !val.Exists() || val.IsObject() || val.IsArray() { + continue + } + parentPath := trimSuffix(p, "."+key) + if isPropertyDefinition(parentPath) { + continue + } + jsonStr = appendHint(jsonStr, parentPath, fmt.Sprintf("%s: %s", key, val.String())) + } + } + return jsonStr +} + +func mergeAllOf(jsonStr string) string { + paths := findPaths(jsonStr, "allOf") + sortByDepth(paths) + + for _, p := range paths { + allOf := gjson.Get(jsonStr, p) + if !allOf.IsArray() { + continue + } + parentPath := trimSuffix(p, ".allOf") + + for _, item := range allOf.Array() { + if props := item.Get("properties"); props.IsObject() { + props.ForEach(func(key, value gjson.Result) bool { + destPath := joinPath(parentPath, "properties."+key.String()) + jsonStr, _ = sjson.SetRaw(jsonStr, destPath, value.Raw) + return true + }) + } + if req := item.Get("required"); req.IsArray() { + reqPath := joinPath(parentPath, "required") + current := getStrings(jsonStr, reqPath) + for _, r := range req.Array() { + if s := r.String(); !contains(current, s) { + current = append(current, s) + } + } + jsonStr, _ = sjson.Set(jsonStr, reqPath, current) + } + } + jsonStr, _ = sjson.Delete(jsonStr, p) + } + return jsonStr +} + +func flattenAnyOfOneOf(jsonStr string) string { + for _, key := range []string{"anyOf", "oneOf"} { + paths := findPaths(jsonStr, key) + sortByDepth(paths) + + for _, p := range paths { + arr := gjson.Get(jsonStr, p) + if !arr.IsArray() || len(arr.Array()) == 0 { + continue + } + + items := arr.Array() + bestIdx, allTypes := selectBest(items) + selected := items[bestIdx].Raw + + if len(allTypes) > 1 { + hint := "Accepts: " + strings.Join(allTypes, " | ") + selected = appendHintRaw(selected, hint) + } + + jsonStr = setRawAt(jsonStr, trimSuffix(p, "."+key), selected) + } + } + return jsonStr +} + +func selectBest(items []gjson.Result) (bestIdx int, types []string) { + bestScore := -1 + for i, item := range items { + t := item.Get("type").String() + score := 0 + + switch { + case t == "object" || item.Get("properties").Exists(): + score, t = 3, orDefault(t, "object") + case t == "array" || item.Get("items").Exists(): + score, t = 2, orDefault(t, "array") + case t != "" && t != "null": + score = 1 + default: + t = orDefault(t, "null") + } + + if t != "" { + types = append(types, t) + } + if score > bestScore { + bestScore, bestIdx = score, i + } + } + return +} + +func flattenTypeArrays(jsonStr string) string { + paths := findPaths(jsonStr, "type") + sortByDepth(paths) + + nullableFields := make(map[string][]string) + + for _, p := range paths { + res := gjson.Get(jsonStr, p) + if !res.IsArray() || len(res.Array()) == 0 { + continue + } + + hasNull, firstType := false, "" + for _, item := range res.Array() { + s := item.String() + if s == "null" { + hasNull = true + } else if firstType == "" { + firstType = s + } + } + if firstType == "" { + firstType = "string" + } + + jsonStr, _ = sjson.Set(jsonStr, p, firstType) + + if hasNull { + parts := strings.Split(p, ".") + if len(parts) >= 3 && parts[len(parts)-3] == "properties" { + fieldName := parts[len(parts)-2] + objectPath := strings.Join(parts[:len(parts)-3], ".") + nullableFields[objectPath] = append(nullableFields[objectPath], fieldName) + + propPath := joinPath(objectPath, "properties."+fieldName) + jsonStr = appendHint(jsonStr, propPath, "(nullable)") + } + } + } + + for objectPath, fields := range nullableFields { + reqPath := joinPath(objectPath, "required") + req := gjson.Get(jsonStr, reqPath) + if !req.IsArray() { + continue + } + + var filtered []string + for _, r := range req.Array() { + if !contains(fields, r.String()) { + filtered = append(filtered, r.String()) + } + } + + if len(filtered) == 0 { + jsonStr, _ = sjson.Delete(jsonStr, reqPath) + } else { + jsonStr, _ = sjson.Set(jsonStr, reqPath, filtered) + } + } + return jsonStr +} + +func removeUnsupportedKeywords(jsonStr string) string { + keywords := append(unsupportedConstraints, + "$schema", "$defs", "definitions", "const", "$ref", "additionalProperties", + ) + for _, key := range keywords { + for _, p := range findPaths(jsonStr, key) { + if isPropertyDefinition(trimSuffix(p, "."+key)) { + continue + } + jsonStr, _ = sjson.Delete(jsonStr, p) + } + } + return jsonStr +} + +func cleanupRequiredFields(jsonStr string) string { + for _, p := range findPaths(jsonStr, "required") { + parentPath := trimSuffix(p, ".required") + propsPath := joinPath(parentPath, "properties") + + req := gjson.Get(jsonStr, p) + props := gjson.Get(jsonStr, propsPath) + if !req.IsArray() || !props.IsObject() { + continue + } + + var valid []string + for _, r := range req.Array() { + if props.Get(r.String()).Exists() { + valid = append(valid, r.String()) + } + } + + if len(valid) != len(req.Array()) { + if len(valid) == 0 { + jsonStr, _ = sjson.Delete(jsonStr, p) + } else { + jsonStr, _ = sjson.Set(jsonStr, p, valid) + } + } + } + return jsonStr +} + +// --- Helpers --- + +func findPaths(jsonStr, field string) []string { + var paths []string + Walk(gjson.Parse(jsonStr), "", field, &paths) + return paths +} + +func sortByDepth(paths []string) { + sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) +} + +func trimSuffix(path, suffix string) string { + if path == strings.TrimPrefix(suffix, ".") { + return "" + } + return strings.TrimSuffix(path, suffix) +} + +func joinPath(base, suffix string) string { + if base == "" { + return suffix + } + return base + "." + suffix +} + +func setRawAt(jsonStr, path, value string) string { + if path == "" { + return value + } + result, _ := sjson.SetRaw(jsonStr, path, value) + return result +} + +func isPropertyDefinition(path string) bool { + return path == "properties" || strings.HasSuffix(path, ".properties") +} + +func appendHint(jsonStr, parentPath, hint string) string { + descPath := parentPath + ".description" + if parentPath == "" || parentPath == "@this" { + descPath = "description" + } + existing := gjson.Get(jsonStr, descPath).String() + if existing != "" { + hint = fmt.Sprintf("%s (%s)", existing, hint) + } + jsonStr, _ = sjson.Set(jsonStr, descPath, hint) + return jsonStr +} + +func appendHintRaw(jsonRaw, hint string) string { + existing := gjson.Get(jsonRaw, "description").String() + if existing != "" { + hint = fmt.Sprintf("%s (%s)", existing, hint) + } + jsonRaw, _ = sjson.Set(jsonRaw, "description", hint) + return jsonRaw +} + +func getStrings(jsonStr, path string) []string { + var result []string + if arr := gjson.Get(jsonStr, path); arr.IsArray() { + for _, r := range arr.Array() { + result = append(result, r.String()) + } + } + return result +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func orDefault(val, def string) string { + if val == "" { + return def + } + return val +} diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go new file mode 100644 index 00000000..3baa48ea --- /dev/null +++ b/internal/util/gemini_schema_test.go @@ -0,0 +1,468 @@ +package util + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestCleanJSONSchemaForGemini_ConstToEnum(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "InsightVizNode" + } + } + }` + + expected := `{ + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["InsightVizNode"] + } + } + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) +} + +func TestCleanJSONSchemaForGemini_TypeFlattening_Nullable(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "name": { + "type": ["string", "null"] + }, + "other": { + "type": "string" + } + }, + "required": ["name", "other"] + }` + + expected := `{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "(nullable)" + }, + "other": { + "type": "string" + } + }, + "required": ["other"] + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) +} + +func TestCleanJSONSchemaForGemini_ConstraintsToDescription(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "description": "List of tags", + "minItems": 1 + }, + "name": { + "type": "string", + "description": "User name", + "minLength": 3 + } + } + }` + + result := CleanJSONSchemaForGemini(input) + + // minItems should be REMOVED and moved to description + if strings.Contains(result, `"minItems"`) { + t.Errorf("minItems keyword should be removed") + } + if !strings.Contains(result, "minItems: 1") { + t.Errorf("minItems hint missing in description") + } + + // minLength should be moved to description + if !strings.Contains(result, "minLength: 3") { + t.Errorf("minLength hint missing in description") + } + if strings.Contains(result, `"minLength":`) || strings.Contains(result, `"minLength" :`) { + t.Errorf("minLength keyword should be removed") + } +} + +func TestCleanJSONSchemaForGemini_AnyOfFlattening_SmartSelection(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "query": { + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "properties": { + "kind": { "type": "string" } + } + } + ] + } + } + }` + + expected := `{ + "type": "object", + "properties": { + "query": { + "type": "object", + "description": "Accepts: null | object", + "properties": { + "kind": { "type": "string" } + } + } + } + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) +} + +func TestCleanJSONSchemaForGemini_OneOfFlattening(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "config": { + "oneOf": [ + { "type": "string" }, + { "type": "integer" } + ] + } + } + }` + + expected := `{ + "type": "object", + "properties": { + "config": { + "type": "string", + "description": "Accepts: string | integer" + } + } + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) +} + +func TestCleanJSONSchemaForGemini_AllOfMerging(t *testing.T) { + input := `{ + "type": "object", + "allOf": [ + { + "properties": { + "a": { "type": "string" } + }, + "required": ["a"] + }, + { + "properties": { + "b": { "type": "integer" } + }, + "required": ["b"] + } + ] + }` + + expected := `{ + "type": "object", + "properties": { + "a": { "type": "string" }, + "b": { "type": "integer" } + }, + "required": ["a", "b"] + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) +} + +func TestCleanJSONSchemaForGemini_RefHandling(t *testing.T) { + input := `{ + "definitions": { + "User": { + "type": "object", + "properties": { + "name": { "type": "string" } + } + } + }, + "type": "object", + "properties": { + "customer": { "$ref": "#/definitions/User" } + } + }` + + expected := `{ + "type": "object", + "properties": { + "customer": { + "type": "object", + "description": "See: User" + } + } + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) +} + +func TestCleanJSONSchemaForGemini_CyclicRefDefaults(t *testing.T) { + input := `{ + "definitions": { + "Node": { + "type": "object", + "properties": { + "child": { "$ref": "#/definitions/Node" } + } + } + }, + "$ref": "#/definitions/Node" + }` + + result := CleanJSONSchemaForGemini(input) + + var resMap map[string]interface{} + json.Unmarshal([]byte(result), &resMap) + + if resMap["type"] != "object" { + t.Errorf("Expected type: object, got: %v", resMap["type"]) + } + + desc, ok := resMap["description"].(string) + if !ok || !strings.Contains(desc, "Node") { + t.Errorf("Expected description hint containing 'Node', got: %v", resMap["description"]) + } +} + +func TestCleanJSONSchemaForGemini_RequiredCleanup(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"} + }, + "required": ["a", "b", "c"] + }` + + expected := `{ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"} + }, + "required": ["a", "b"] + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) +} + +func TestCleanJSONSchemaForGemini_PropertyNameCollision(t *testing.T) { + // A tool has an argument named "pattern" - should NOT be treated as a constraint + input := `{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The regex pattern" + } + }, + "required": ["pattern"] + }` + + expected := `{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The regex pattern" + } + }, + "required": ["pattern"] + }` + + result := CleanJSONSchemaForGemini(input) + compareJSON(t, expected, result) + + var resMap map[string]interface{} + json.Unmarshal([]byte(result), &resMap) + props, _ := resMap["properties"].(map[string]interface{}) + if _, ok := props["description"]; ok { + t.Errorf("Invalid 'description' property injected into properties map") + } +} + +func TestCleanJSONSchemaForGemini_DotKeys(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "my.param": { + "type": "string", + "$ref": "#/definitions/MyType" + } + }, + "definitions": { + "MyType": { "type": "string" } + } + }` + + result := CleanJSONSchemaForGemini(input) + + var resMap map[string]interface{} + if err := json.Unmarshal([]byte(result), &resMap); err != nil { + t.Fatalf("Failed to unmarshal result: %v", err) + } + + props, ok := resMap["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("properties missing") + } + + if val, ok := props["my.param"]; !ok { + t.Fatalf("Key 'my.param' is missing. Result: %s", result) + } else { + valMap, _ := val.(map[string]interface{}) + if _, hasRef := valMap["$ref"]; hasRef { + t.Errorf("Key 'my.param' still contains $ref") + } + if _, ok := props["my"]; ok { + t.Errorf("Artifact key 'my' created by sjson splitting") + } + } +} + +func TestCleanJSONSchemaForGemini_AnyOfAlternativeHints(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "value": { + "anyOf": [ + { "type": "string" }, + { "type": "integer" }, + { "type": "null" } + ] + } + } + }` + + result := CleanJSONSchemaForGemini(input) + + if !strings.Contains(result, "Accepts:") { + t.Errorf("Expected alternative types hint, got: %s", result) + } + if !strings.Contains(result, "string") || !strings.Contains(result, "integer") { + t.Errorf("Expected all alternative types in hint, got: %s", result) + } +} + +func TestCleanJSONSchemaForGemini_NullableHint(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "name": { + "type": ["string", "null"], + "description": "User name" + } + }, + "required": ["name"] + }` + + result := CleanJSONSchemaForGemini(input) + + if !strings.Contains(result, "(nullable)") { + t.Errorf("Expected nullable hint, got: %s", result) + } + if !strings.Contains(result, "User name") { + t.Errorf("Expected original description to be preserved, got: %s", result) + } +} + +func TestCleanJSONSchemaForGemini_EnumHint(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"], + "description": "Current status" + } + } + }` + + result := CleanJSONSchemaForGemini(input) + + if !strings.Contains(result, "Allowed:") { + t.Errorf("Expected enum values hint, got: %s", result) + } + if !strings.Contains(result, "active") || !strings.Contains(result, "inactive") { + t.Errorf("Expected enum values in hint, got: %s", result) + } +} + +func TestCleanJSONSchemaForGemini_AdditionalPropertiesHint(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "additionalProperties": false + }` + + result := CleanJSONSchemaForGemini(input) + + if !strings.Contains(result, "No extra properties allowed") { + t.Errorf("Expected additionalProperties hint, got: %s", result) + } +} + +func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["fixed"] + } + } + }` + + result := CleanJSONSchemaForGemini(input) + + if strings.Contains(result, "Allowed:") { + t.Errorf("Single value enum should not add Allowed hint, got: %s", result) + } +} + +func compareJSON(t *testing.T, expectedJSON, actualJSON string) { + var expMap, actMap map[string]interface{} + errExp := json.Unmarshal([]byte(expectedJSON), &expMap) + errAct := json.Unmarshal([]byte(actualJSON), &actMap) + + if errExp != nil || errAct != nil { + t.Fatalf("JSON Unmarshal error. Exp: %v, Act: %v", errExp, errAct) + } + + if !reflect.DeepEqual(expMap, actMap) { + expBytes, _ := json.MarshalIndent(expMap, "", " ") + actBytes, _ := json.MarshalIndent(actMap, "", " ") + t.Errorf("JSON mismatch:\nExpected:\n%s\n\nActual:\n%s", string(expBytes), string(actBytes)) + } +} diff --git a/internal/util/translator.go b/internal/util/translator.go index 5a4bb7e8..21ba7c7d 100644 --- a/internal/util/translator.go +++ b/internal/util/translator.go @@ -6,6 +6,7 @@ package util import ( "bytes" "fmt" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -28,10 +29,18 @@ func Walk(value gjson.Result, path, field string, paths *[]string) { // For JSON objects and arrays, iterate through each child value.ForEach(func(key, val gjson.Result) bool { var childPath string + // Escape special characters for gjson/sjson path syntax + // . -> \. + // * -> \* + // ? -> \? + safeKey := strings.ReplaceAll(key.String(), ".", "\\.") + safeKey = strings.ReplaceAll(safeKey, "*", "\\*") + safeKey = strings.ReplaceAll(safeKey, "?", "\\?") + if path == "" { - childPath = key.String() + childPath = safeKey } else { - childPath = path + "." + key.String() + childPath = path + "." + safeKey } if key.String() == field { *paths = append(*paths, childPath) From 27734a23b18b1534b06cb2889c5e15f3b42cceae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Wed, 17 Dec 2025 17:15:11 +0900 Subject: [PATCH 2/3] Update internal/util/translator.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- internal/util/translator.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/util/translator.go b/internal/util/translator.go index 21ba7c7d..eca38a30 100644 --- a/internal/util/translator.go +++ b/internal/util/translator.go @@ -33,9 +33,8 @@ func Walk(value gjson.Result, path, field string, paths *[]string) { // . -> \. // * -> \* // ? -> \? - safeKey := strings.ReplaceAll(key.String(), ".", "\\.") - safeKey = strings.ReplaceAll(safeKey, "*", "\\*") - safeKey = strings.ReplaceAll(safeKey, "?", "\\?") + var keyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?") + safeKey := keyReplacer.Replace(key.String()) if path == "" { childPath = safeKey From aea337cfe2a812120031f25e3500cdd203c725e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Wed, 17 Dec 2025 17:30:23 +0900 Subject: [PATCH 3/3] feature: Improves schema flattening and tool use handling Updates schema flattening logic to handle multiple non-null types, providing a more descriptive "Accepts" hint. Removes redundant tracking of the current tool name in `Params` as it's no longer needed for streaming limits, simplifying the structure. --- .../gemini/claude/gemini_claude_response.go | 4 +--- internal/util/gemini_schema.go | 19 +++++++++++++----- internal/util/gemini_schema_test.go | 20 +++++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index 90e1c501..379f137c 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -25,8 +25,7 @@ type Params struct { HasFirstResponse bool ResponseType int ResponseIndex int - HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output - CurrentToolName string // Tracks the current function name for streaming limits + HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output } // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. @@ -234,7 +233,6 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR } (*param).(*Params).ResponseType = 3 (*param).(*Params).HasContent = true - (*param).(*Params).CurrentToolName = fcName } } } diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 8b7b0372..dd8d1c67 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -222,21 +222,30 @@ func flattenTypeArrays(jsonStr string) string { continue } - hasNull, firstType := false, "" + hasNull := false + var nonNullTypes []string for _, item := range res.Array() { s := item.String() if s == "null" { hasNull = true - } else if firstType == "" { - firstType = s + } else if s != "" { + nonNullTypes = append(nonNullTypes, s) } } - if firstType == "" { - firstType = "string" + + firstType := "string" + if len(nonNullTypes) > 0 { + firstType = nonNullTypes[0] } jsonStr, _ = sjson.Set(jsonStr, p, firstType) + parentPath := trimSuffix(p, ".type") + if len(nonNullTypes) > 1 { + hint := "Accepts: " + strings.Join(nonNullTypes, " | ") + jsonStr = appendHint(jsonStr, parentPath, hint) + } + if hasNull { parts := strings.Split(p, ".") if len(parts) >= 3 && parts[len(parts)-3] == "properties" { diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 3baa48ea..a17cfb86 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -451,6 +451,26 @@ func TestCleanJSONSchemaForGemini_SingleEnumNoHint(t *testing.T) { } } +func TestCleanJSONSchemaForGemini_MultipleNonNullTypes(t *testing.T) { + input := `{ + "type": "object", + "properties": { + "value": { + "type": ["string", "integer", "boolean"] + } + } + }` + + result := CleanJSONSchemaForGemini(input) + + if !strings.Contains(result, "Accepts:") { + t.Errorf("Expected multiple types hint, got: %s", result) + } + if !strings.Contains(result, "string") || !strings.Contains(result, "integer") || !strings.Contains(result, "boolean") { + t.Errorf("Expected all types in hint, got: %s", result) + } +} + func compareJSON(t *testing.T, expectedJSON, actualJSON string) { var expMap, actMap map[string]interface{} errExp := json.Unmarshal([]byte(expectedJSON), &expMap)