mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix(antigravity): scope schema sanitization (#4571)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
339
internal/runtime/executor/antigravity_schema_sanitize_test.go
Normal file
339
internal/runtime/executor/antigravity_schema_sanitize_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user