diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 924ddbe6f..2848a1dbd 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -2425,18 +2425,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau payloadLog []byte ) if antigravityRequestNeedsSchemaSanitization(payload) { - payloadStr := string(payload) - paths := make([]string, 0) - util.Walk(gjson.Parse(payloadStr), "", "parametersJsonSchema", &paths) - for _, p := range paths { - payloadStr, _ = util.RenameKey(payloadStr, p, p[:len(p)-len("parametersJsonSchema")]+"parameters") - } - - if useAntigravitySchema { - payloadStr = util.CleanJSONSchemaForAntigravity(payloadStr) - } else { - payloadStr = util.CleanJSONSchemaForGemini(payloadStr) - } + payloadStr := sanitizeAntigravityRequestSchemas(string(payload), useAntigravitySchema) if strings.Contains(modelName, "claude") { updated, _ := sjson.SetBytes([]byte(payloadStr), "request.toolConfig.functionCallingConfig.mode", "VALIDATED") @@ -2515,15 +2504,142 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau return httpReq, nil } +// sanitizeAntigravityRequestSchemas cleans the JSON schemas carried by an Antigravity request. +// +// Cleaning is applied only to the payload locations that actually hold a JSON schema. The schema +// cleaner rewrites keys such as "title", "format", "default" and "const", which are also ordinary +// data keys inside functionCall arguments replayed from conversation history. Running it over the +// whole document silently mutated that history, so tools lost required argument fields and the +// model imitated the corrupted examples on later turns. +func sanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySchema bool) string { + for _, base := range antigravityFunctionDeclarationPaths(payloadStr) { + oldPath := base + ".parametersJsonSchema" + if !gjson.Get(payloadStr, oldPath).Exists() { + continue + } + renamed, errRename := util.RenameKey(payloadStr, oldPath, base+".parameters") + if errRename != nil { + log.Debugf("antigravity: failed to rename %s: %v", oldPath, errRename) + continue + } + payloadStr = renamed + } + + clean := util.CleanJSONSchemaForGemini + if useAntigravitySchema { + clean = util.CleanJSONSchemaForAntigravity + } + + for _, schemaPath := range antigravitySchemaPaths(payloadStr) { + schema := gjson.Get(payloadStr, schemaPath) + if !schema.Exists() { + continue + } + updated, errSet := sjson.SetRawBytes([]byte(payloadStr), schemaPath, []byte(cleanNestedSchema(clean, schema.Raw))) + if errSet != nil { + log.Debugf("antigravity: failed to write cleaned schema at %s: %v", schemaPath, errSet) + continue + } + payloadStr = string(updated) + } + + return payloadStr +} + +// antigravitySchemaWrapperKey nests a schema during cleaning. It is never sent upstream. +const antigravitySchemaWrapperKey = "schema" + +// cleanNestedSchema cleans a schema with it nested one level down, then unwraps it. +// +// The cleaner deliberately skips placeholder insertion for a top-level schema, but Claude's +// VALIDATED mode needs every tool schema to declare at least one required property. Whole-payload +// cleaning always saw tool schemas nested inside the request, so nesting is reproduced here to keep +// the emitted schema byte-identical to the previous behaviour. +func cleanNestedSchema(clean func(string) string, schemaRaw string) string { + wrapped, errWrap := sjson.SetRaw("{}", antigravitySchemaWrapperKey, schemaRaw) + if errWrap != nil { + return clean(schemaRaw) + } + if unwrapped := gjson.Get(clean(wrapped), antigravitySchemaWrapperKey); unwrapped.Exists() { + return unwrapped.Raw + } + return clean(schemaRaw) +} + +// antigravityFunctionDeclarationPaths returns the path of every function declaration in the request. +// Both the camelCase and snake_case spellings are accepted because callers reach this executor +// through different translators. +func antigravityFunctionDeclarationPaths(payloadStr string) []string { + tools := gjson.Get(payloadStr, "request.tools") + if !tools.IsArray() { + return nil + } + paths := make([]string, 0, len(tools.Array())) + for i, tool := range tools.Array() { + for _, declKey := range []string{"functionDeclarations", "function_declarations"} { + decls := tool.Get(declKey) + if !decls.IsArray() { + continue + } + for j := range decls.Array() { + paths = append(paths, fmt.Sprintf("request.tools.%d.%s.%d", i, declKey, j)) + } + } + } + return paths +} + +// antigravitySchemaPaths returns every payload path that holds a JSON schema document. +// A function declaration may carry a schema for its parameters and for its result, so all of +// them must be cleaned; anything omitted here reaches the upstream API uncleaned. +func antigravitySchemaPaths(payloadStr string) []string { + paths := make([]string, 0, 12) + for _, base := range antigravityFunctionDeclarationPaths(payloadStr) { + for _, key := range antigravityDeclarationSchemaKeys { + if gjson.Get(payloadStr, base+"."+key).IsObject() { + paths = append(paths, base+"."+key) + } + } + } + for _, container := range antigravityGenerationConfigContainers { + for _, key := range antigravityGenerationSchemaKeys { + p := container + "." + key + if gjson.Get(payloadStr, p).IsObject() { + paths = append(paths, p) + } + } + } + return paths +} + +// The upstream API is proto-JSON and accepts either spelling, and the Gemini translator forwards +// whichever one the client sent. Both are therefore cleaned where they sit rather than renamed: +// renaming would alter the body the client asked for, and only the unsupported keywords inside a +// schema cause upstream errors. The one exception is parametersJsonSchema, renamed onto parameters +// above because whole-payload cleaning did the same. +var ( + antigravityDeclarationSchemaKeys = []string{ + "parameters", "parametersJsonSchema", "parameters_json_schema", + "response", "responseJsonSchema", "response_json_schema", + } + antigravityGenerationConfigContainers = []string{ + "request.generationConfig", "request.generation_config", + } + antigravityGenerationSchemaKeys = []string{ + "responseSchema", "responseJsonSchema", "response_schema", "response_json_schema", + } +) + func antigravityRequestNeedsSchemaSanitization(payload []byte) bool { if gjson.GetBytes(payload, "request.tools.0").Exists() { return true } - if gjson.GetBytes(payload, "request.generationConfig.responseJsonSchema").Exists() { - return true - } - if gjson.GetBytes(payload, "request.generationConfig.responseSchema").Exists() { - return true + for _, container := range antigravityGenerationConfigContainers { + for _, key := range antigravityGenerationSchemaKeys { + if gjson.GetBytes(payload, container+"."+key).Exists() { + return true + } + } } return false } diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go new file mode 100644 index 000000000..bd038932c --- /dev/null +++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go @@ -0,0 +1,339 @@ +package executor + +import ( + "encoding/json" + "strings" + "testing" + + antigravitychat "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/chat-completions" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/tidwall/gjson" +) + +const sanitizeTestPayload = `{ + "request": { + "contents": [ + {"role": "model", "parts": [{"functionCall": {"name": "manage_todo_list", "args": { + "operation": "write", + "todoList": [ + {"id": 1, "title": "output 1", "description": "d1", "status": "not-started"}, + {"id": 2, "title": "output 2", "description": "d2", "status": "not-started"} + ]}}}]}, + {"role": "model", "parts": [{"functionCall": {"name": "write_file", "args": { + "path": "a.md", "format": "markdown", "default": "x", "pattern": "p", + "const": "c", "deprecated": false, "nullable": "n", "examples": "e", + "additionalProperties": "ap", "x-custom": "keepme" + }}}]} + ], + "tools": [{"functionDeclarations": [{ + "name": "manage_todo_list", + "parametersJsonSchema": { + "type": "object", + "required": ["todoList"], + "properties": {"todoList": {"type": "array", "items": { + "type": "object", + "required": ["id", "title"], + "title": "TodoItem", + "properties": {"id": {"type": "number"}, "title": {"type": "string", "minLength": 3}} + }}} + } + }]}] + } +}` + +// TestSanitizeAntigravityRequestSchemasPreservesHistory guards against the schema cleaner being +// applied to the whole payload, which silently stripped keys such as "title" from functionCall +// arguments replayed from conversation history. +func TestSanitizeAntigravityRequestSchemasPreservesHistory(t *testing.T) { + for _, tc := range []struct { + name string + useAntigravitySchema bool + }{ + {"gemini", false}, + {"antigravity", true}, + } { + t.Run(tc.name, func(t *testing.T) { + got := sanitizeAntigravityRequestSchemas(sanitizeTestPayload, tc.useAntigravitySchema) + + before := gjson.Get(sanitizeTestPayload, "request.contents") + after := gjson.Get(got, "request.contents") + if before.Raw != after.Raw { + t.Errorf("conversation history was mutated.\nbefore: %s\nafter: %s", before.Raw, after.Raw) + } + + todo := gjson.Get(got, `request.contents.0.parts.0.functionCall.args.todoList`) + for i, item := range todo.Array() { + if !item.Get("title").Exists() { + t.Errorf("todoList[%d] lost its title: %s", i, item.Raw) + } + } + + args := gjson.Get(got, `request.contents.1.parts.0.functionCall.args`) + for _, key := range []string{"format", "default", "pattern", "const", "deprecated", "examples", "additionalProperties", "x-custom"} { + if !args.Get(gjson.Escape(key)).Exists() { + t.Errorf("argument key %q was stripped from history: %s", key, args.Raw) + } + } + if args.Get("enum").Exists() { + t.Errorf("cleaner fabricated an enum key in history args: %s", args.Raw) + } + }) + } +} + +// TestSanitizeAntigravityRequestSchemasStillCleansSchemas verifies the schema itself is still +// renamed and cleaned, so scoping the cleaner did not disable it. +func TestSanitizeAntigravityRequestSchemasStillCleansSchemas(t *testing.T) { + got := sanitizeAntigravityRequestSchemas(sanitizeTestPayload, false) + + decl := "request.tools.0.functionDeclarations.0" + if gjson.Get(got, decl+".parametersJsonSchema").Exists() { + t.Errorf("parametersJsonSchema was not renamed: %s", gjson.Get(got, decl).Raw) + } + schema := gjson.Get(got, decl+".parameters") + if !schema.Exists() { + t.Fatalf("parameters missing after sanitization: %s", gjson.Get(got, decl).Raw) + } + + items := schema.Get("properties.todoList.items") + if items.Get("title").Exists() { + t.Errorf("schema keyword title was not removed: %s", items.Raw) + } + if items.Get("properties.title.minLength").Exists() { + t.Errorf("unsupported keyword minLength was not removed: %s", items.Raw) + } + if !items.Get("properties.title").Exists() { + t.Errorf("schema property named title must be preserved: %s", items.Raw) + } + if req := items.Get("required").Array(); len(req) != 2 { + t.Errorf("required list should keep id and title, got: %s", items.Get("required").Raw) + } +} + +// TestSanitizeAntigravityRequestSchemasCleansResultSchemas covers the schemas a function +// declaration can carry besides its parameters. Missing one sends it upstream uncleaned. +func TestSanitizeAntigravityRequestSchemasCleansResultSchemas(t *testing.T) { + payload := `{"request": {"tools": [{"functionDeclarations": [{ + "name": "t", + "parameters": {"type": "object", "$id": "drop-a", "properties": {"a": {"type": "string"}}}, + "response": {"type": "object", "$comment": "drop-b", "properties": {"b": {"type": "string"}}}, + "responseJsonSchema": {"type": "object", "$id": "drop-c", "properties": {"c": {"type": "string"}}} + }]}]}}` + + got := sanitizeAntigravityRequestSchemas(payload, false) + decl := gjson.Get(got, "request.tools.0.functionDeclarations.0") + + for _, unsupported := range []string{`parameters.\$id`, `response.\$comment`, `responseJsonSchema.\$id`} { + if decl.Get(unsupported).Exists() { + t.Errorf("unsupported keyword %s survived cleaning: %s", unsupported, decl.Raw) + } + } + for _, kept := range []string{"parameters.properties.a", "response.properties.b", "responseJsonSchema.properties.c"} { + if !decl.Get(kept).Exists() { + t.Errorf("%s should be preserved: %s", kept, decl.Raw) + } + } +} + +// TestAntigravitySchemaPathsCoverEverySchemaLocation pins the set of payload locations that get +// cleaned. Scoping the cleaner traded "clean everything" for an explicit list, so a schema at a +// location missing from that list now reaches upstream uncleaned and is rejected — four such gaps +// were found this way, one per location that had been overlooked. +// +// The declaration keys must stay in step with allowedToolKeys in +// internal/translator/antigravity/claude/antigravity_claude_request.go, which is the authoritative +// list of what a function declaration may carry. Add a schema-bearing key there and it must be +// added here too; this test only fails once the key is listed below, so treat the pairing as +// something to check whenever that list changes. +func TestAntigravitySchemaPathsCoverEverySchemaLocation(t *testing.T) { + const schema = `{"type":"object","$id":"drop","properties":{"a":{"type":"string"}}}` + + // Both spellings of the declarations container are exercised: the Gemini translator forwards + // snake_case untouched, so covering only camelCase leaves those requests uncleaned. + for _, declContainer := range []string{"functionDeclarations", "function_declarations"} { + for _, genContainer := range antigravityGenerationConfigContainers { + t.Run(declContainer+"_"+strings.TrimPrefix(genContainer, "request."), func(t *testing.T) { + decl := `"name":"t"` + for _, k := range antigravityDeclarationSchemaKeys { + decl += `,"` + k + `":` + schema + } + gen := "" + for i, k := range antigravityGenerationSchemaKeys { + if i > 0 { + gen += "," + } + gen += `"` + k + `":` + schema + } + payload := `{"request":{"tools":[{"` + declContainer + `":[{` + decl + `}]}],"` + + strings.TrimPrefix(genContainer, "request.") + `":{` + gen + `}}}` + + if !antigravityRequestNeedsSchemaSanitization([]byte(payload)) { + t.Fatal("sanitization must trigger for a payload carrying schemas") + } + got := sanitizeAntigravityRequestSchemas(payload, false) + + check := func(path string) { + t.Helper() + node := gjson.Get(got, path) + if !node.Exists() { + t.Errorf("%s disappeared: %s", path, got) + return + } + if node.Get(`\$id`).Exists() { + t.Errorf("%s was never cleaned, $id reaches upstream: %s", path, node.Raw) + } + } + base := "request.tools.0." + declContainer + ".0." + for _, k := range antigravityDeclarationSchemaKeys { + // Only the camelCase alias is renamed onto parameters, matching whole-payload + // cleaning. Every other spelling is cleaned where the client put it. + if k == "parametersJsonSchema" { + if gjson.Get(got, base+k).Exists() { + t.Errorf("%s should have been renamed onto parameters: %s", k, got) + } + continue + } + check(base + k) + } + for _, k := range antigravityGenerationSchemaKeys { + check(genContainer + "." + k) + } + }) + } + } +} + +// TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning pins the emitted schema to what +// whole-payload cleaning produced. Narrowing the scope must change which nodes are cleaned, never +// the result for a schema node — in particular the Claude VALIDATED placeholder, which the cleaner +// only adds when the schema is not top-level. +func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing.T) { + shapes := map[string]string{ + "optionalOnly": `{"type":"object","properties":{"flag":{"type":"string"}}}`, + "emptyProps": `{"type":"object","properties":{}}`, + "noProps": `{"type":"object"}`, + "withRequired": `{"type":"object","required":["a"],"properties":{"a":{"type":"string","minLength":2}}}`, + "nestedArray": `{"type":"object","properties":{"list":{"type":"array","items":{"type":"object","title":"X","required":["id","title"],"properties":{"id":{"type":"number"},"title":{"type":"string"}}}}}}`, + "enumAndRemoved": `{"type":"object","$comment":"c","properties":{"m":{"type":"string","enum":["a","b"],` + + `"deprecated":true}}}`, + } + const schemaPath = "request.tools.0.functionDeclarations.0.parameters" + + for _, useAntigravitySchema := range []bool{false, true} { + for name, schema := range shapes { + doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}` + whole := util.CleanJSONSchemaForGemini(doc) + if useAntigravitySchema { + whole = util.CleanJSONSchemaForAntigravity(doc) + } + want := gjson.Get(whole, schemaPath).Raw + got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw + if want != got { + t.Errorf("%s (antigravity=%v) diverged from whole-payload cleaning.\nwant: %s\ngot: %s", + name, useAntigravitySchema, want, got) + } + } + } + + // Explicitly pin the placeholder, so the equivalence above cannot pass by both sides dropping it. + doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + shapes["optionalOnly"] + `}]}]}}` + got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, true), schemaPath) + if req := got.Get("required").Array(); len(req) != 1 || req[0].String() != "_" { + t.Errorf("Claude VALIDATED placeholder missing for an optional-only schema: %s", got.Raw) + } +} + +func TestAntigravityBuildRequestSanitizesSnakeCaseGenerationResponseSchemas(t *testing.T) { + for _, testCase := range []struct { + alias string + canonical string + }{ + {alias: "response_schema", canonical: "responseSchema"}, + {alias: "response_json_schema", canonical: "responseJsonSchema"}, + } { + t.Run(testCase.alias, func(t *testing.T) { + input := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"user","content":"hi"}],"generation_config":{"` + testCase.alias + `":{"type":"object","$id":"drop-me","properties":{"title":{"type":"string"}}}}}`) + translated := antigravitychat.ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", input, false) + body := buildRequestBodyFromRawPayload(t, "gemini-3.6-flash-high", translated) + encoded, errMarshal := json.Marshal(body) + if errMarshal != nil { + t.Fatal(errMarshal) + } + + base := "request.generationConfig." + // The upstream API accepts either spelling, so the field must stay where the client put + // it. Only the unsupported keywords inside it are what upstream rejects. + schema := gjson.GetBytes(encoded, base+testCase.alias) + if !schema.Exists() { + t.Fatalf("snake_case response schema was renamed or dropped: %s", encoded) + } + if gjson.GetBytes(encoded, base+testCase.canonical).Exists() { + t.Fatalf("cleaning must not add a second spelling: %s", encoded) + } + if schema.Get(`\$id`).Exists() { + t.Fatalf("unsupported $id survived cleaning: %s", schema.Raw) + } + if !schema.Get("properties.title").Exists() { + t.Fatalf("schema property named title was removed: %s", schema.Raw) + } + }) + } +} + +// TestSanitizeAntigravityRequestSchemasCleansBothSpellingsInPlace covers a payload carrying both +// spellings: each is cleaned where it sits, and neither is silently dropped. +func TestSanitizeAntigravityRequestSchemasCleansBothSpellingsInPlace(t *testing.T) { + payload := `{"request":{"generationConfig":{` + + `"responseSchema":{"type":"object","$id":"drop-a","properties":{"canonical":{"type":"string"}}},` + + `"response_schema":{"type":"object","$id":"drop-b","properties":{"alias":{"type":"string"}}}}}}` + + got := sanitizeAntigravityRequestSchemas(payload, false) + + for path, prop := range map[string]string{ + "request.generationConfig.responseSchema": "canonical", + "request.generationConfig.response_schema": "alias", + } { + schema := gjson.Get(got, path) + if !schema.Exists() { + t.Errorf("%s was dropped: %s", path, got) + continue + } + if schema.Get(`\$id`).Exists() { + t.Errorf("%s kept unsupported $id: %s", path, schema.Raw) + } + if !schema.Get("properties." + prop).Exists() { + t.Errorf("%s lost its property: %s", path, schema.Raw) + } + } +} + +// TestSanitizeAntigravityRequestSchemasIsIdempotent guards the hint duplication seen in +// production, where a schema cleaned by a translator was cleaned again by this executor. +func TestSanitizeAntigravityRequestSchemasIsIdempotent(t *testing.T) { + // "withDesc" already has a description, so the hint is parenthesised; "bare" has none, so the + // hint is stored on its own. Both spellings must survive a second cleaning pass unchanged. + // "compound" has no description and two hints, so the first pass stores the enum hint bare and + // appends the constraint after it — the second pass must recognise that leading bare form. + payload := `{"request": {"tools": [{"functionDeclarations": [{ + "name": "manage_todo_list", + "parameters": {"type": "object", "properties": { + "withDesc": {"type": "string", "enum": ["write", "read"], "description": "pick one"}, + "bare": {"type": "string", "enum": ["not-started", "in-progress", "completed"]}, + "compound": {"type": "string", "enum": ["a", "b"], "minLength": 1}}} + }]}]}}` + + once := sanitizeAntigravityRequestSchemas(payload, false) + twice := sanitizeAntigravityRequestSchemas(once, false) + + base := "request.tools.0.functionDeclarations.0.parameters.properties." + for _, prop := range []string{"withDesc", "bare", "compound"} { + descPath := base + prop + ".description" + first, second := gjson.Get(once, descPath).String(), gjson.Get(twice, descPath).String() + if first != second { + t.Errorf("%s: cleaning is not idempotent.\nonce: %s\ntwice: %s", prop, first, second) + } + if strings.Count(second, "Allowed:") != 1 { + t.Errorf("%s: hint duplicated: %s", prop, second) + } + } +} diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 010669a81..467bc1341 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -15,6 +15,15 @@ var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\? const placeholderReasonDescription = "Brief explanation of why you are calling this tool" +// Pass a single JSON schema to the functions below — never a whole request document. +// +// Cleaning walks every node and rewrites keys by name, and schema keywords such as "title", +// "format", "default" and "const" are also ordinary data keys. Handing these functions a request +// silently rewrites tool-call arguments inside the conversation history: the guard that protects +// a key under ".properties" does not apply to argument values, so the keys are deleted outright +// and replacements such as "enum" and "type" are fabricated. That regression reached production +// once already; scope every call site to the schema itself. + // 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. @@ -685,26 +694,37 @@ func descriptionPath(parentPath string) string { return parentPath + ".description" } +// mergeHint combines an existing description with a hint. Cleaning is not always a single pass: +// a schema may be cleaned by a translator and again by an executor, so an already-present hint is +// kept as-is instead of being appended a second time. +func mergeHint(existing, hint string) string { + if existing == "" { + return hint + } + // A hint added to an empty description is stored bare and later hints are appended after it, so + // the bare form may sit alone, lead the description, or appear parenthesised further along. + if existing == hint || + strings.HasPrefix(existing, hint+" (") || + strings.Contains(existing, fmt.Sprintf("(%s)", hint)) { + return existing + } + return fmt.Sprintf("%s (%s)", existing, hint) +} + 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) - } - updated, _ := sjson.SetBytes([]byte(jsonStr), descPath, hint) + merged := mergeHint(gjson.Get(jsonStr, descPath).String(), hint) + updated, _ := sjson.SetBytes([]byte(jsonStr), descPath, merged) jsonStr = string(updated) return jsonStr } func appendHintRaw(jsonRaw, hint string) string { - existing := gjson.Get(jsonRaw, "description").String() - if existing != "" { - hint = fmt.Sprintf("%s (%s)", existing, hint) - } - updated, _ := sjson.SetBytes([]byte(jsonRaw), "description", hint) + merged := mergeHint(gjson.Get(jsonRaw, "description").String(), hint) + updated, _ := sjson.SetBytes([]byte(jsonRaw), "description", merged) jsonRaw = string(updated) return jsonRaw }