diff --git a/internal/runtime/executor/antigravity_executor_request.go b/internal/runtime/executor/antigravity_executor_request.go index ae0f51a4..451571f2 100644 --- a/internal/runtime/executor/antigravity_executor_request.go +++ b/internal/runtime/executor/antigravity_executor_request.go @@ -173,24 +173,33 @@ func sanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySchema b payloadStr = renamed } - clean := util.CleanJSONSchemaForGemini + toolSchemaCleaner := util.CleanJSONSchemaForGemini if useAntigravitySchema { - clean = util.CleanJSONSchemaForAntigravity + toolSchemaCleaner = util.CleanJSONSchemaForAntigravity } + responseSchemaCleaner := util.CleanJSONSchemaForAntigravityResponse - for _, schemaPath := range antigravitySchemaPaths(payloadStr) { + cleanNestedToolSchema := func(schemaRaw string) string { + return cleanNestedSchema(toolSchemaCleaner, schemaRaw) + } + payloadStr = cleanAntigravitySchemasAtPaths(payloadStr, antigravityDeclarationSchemaPaths(payloadStr), cleanNestedToolSchema) + payloadStr = cleanAntigravitySchemasAtPaths(payloadStr, antigravityGenerationSchemaPaths(payloadStr), responseSchemaCleaner) + return payloadStr +} + +func cleanAntigravitySchemasAtPaths(payloadStr string, schemaPaths []string, clean func(string) string) string { + for _, schemaPath := range schemaPaths { schema := gjson.Get(payloadStr, schemaPath) if !schema.Exists() { continue } - updated, errSet := sjson.SetRawBytes([]byte(payloadStr), schemaPath, []byte(cleanNestedSchema(clean, schema.Raw))) + updated, errSet := sjson.SetRawBytes([]byte(payloadStr), schemaPath, []byte(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 } @@ -241,7 +250,12 @@ func antigravityFunctionDeclarationPaths(payloadStr string) []string { // 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) + paths := antigravityDeclarationSchemaPaths(payloadStr) + return append(paths, antigravityGenerationSchemaPaths(payloadStr)...) +} + +func antigravityDeclarationSchemaPaths(payloadStr string) []string { + paths := make([]string, 0, 8) for _, base := range antigravityFunctionDeclarationPaths(payloadStr) { for _, key := range antigravityDeclarationSchemaKeys { if gjson.Get(payloadStr, base+"."+key).IsObject() { @@ -249,11 +263,16 @@ func antigravitySchemaPaths(payloadStr string) []string { } } } + return paths +} + +func antigravityGenerationSchemaPaths(payloadStr string) []string { + paths := make([]string, 0, len(antigravityGenerationConfigContainers)*len(antigravityGenerationSchemaKeys)) for _, container := range antigravityGenerationConfigContainers { for _, key := range antigravityGenerationSchemaKeys { - p := container + "." + key - if gjson.Get(payloadStr, p).IsObject() { - paths = append(paths, p) + path := container + "." + key + if gjson.Get(payloadStr, path).IsObject() { + paths = append(paths, path) } } } diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go index bd038932..6151ae4c 100644 --- a/internal/runtime/executor/antigravity_schema_sanitize_test.go +++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go @@ -243,6 +243,85 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing } } +func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t *testing.T) { + payload := `{"request":{ + "tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{"value":{"type":"string"}}}}]}], + "generationConfig":{"responseSchema":{"type":"object","properties":{ + "empty":{"type":"object"}, + "optional":{"type":"object","properties":{"value":{"type":"string"}}} + }}} + }}` + + got := sanitizeAntigravityRequestSchemas(payload, true) + toolSchema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters") + if required := toolSchema.Get("required.0").String(); required != "_" { + t.Fatalf("tool schema lost VALIDATED placeholder, required[0] = %q: %s", required, got) + } + + responseSchema := gjson.Get(got, "request.generationConfig.responseSchema") + for _, path := range []string{ + "required", + "properties._", + "properties.reason", + "properties.empty.required", + "properties.empty.properties.reason", + "properties.optional.required", + "properties.optional.properties._", + } { + if responseSchema.Get(path).Exists() { + t.Errorf("response schema gained tool-only field %s: %s", path, responseSchema.Raw) + } + } +} + +func TestAntigravityBuildRequestKeepsJSONObjectSchemaPlaceholderFree(t *testing.T) { + input := []byte(`{"model":"gemini-3.1-pro-low","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}`) + translated := antigravitychat.ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", input, false) + body := buildRequestBodyFromRawPayload(t, "gemini-3.1-pro-low", translated) + encoded, errMarshal := json.Marshal(body) + if errMarshal != nil { + t.Fatal(errMarshal) + } + + schema := gjson.GetBytes(encoded, "request.generationConfig.responseSchema") + if got := schema.Get("type").String(); got != "object" { + t.Fatalf("responseSchema.type = %q, want object: %s", got, encoded) + } + if schema.Get("properties.reason").Exists() || schema.Get("required").Exists() { + t.Fatalf("json_object schema gained tool placeholders: %s", schema.Raw) + } +} + +func TestAntigravityBuildRequestPreservesGenerationResponseSchemaMetadata(t *testing.T) { + payload := []byte(`{"request":{"generationConfig":{"responseSchema":{ + "type":"object", + "nullable":true, + "properties":{"_":{"type":"string","nullable":true}}, + "required":["_"] + }}}}`) + + for _, modelName := range []string{"gemini-3.6-flash-high", "gemini-3.1-pro-low"} { + t.Run(modelName, func(t *testing.T) { + body := buildRequestBodyFromRawPayload(t, modelName, payload) + encoded, errMarshal := json.Marshal(body) + if errMarshal != nil { + t.Fatal(errMarshal) + } + + schema := gjson.GetBytes(encoded, "request.generationConfig.responseSchema") + if !schema.Get("nullable").Bool() || !schema.Get("properties._.nullable").Bool() { + t.Fatalf("response schema nullable metadata was removed: %s", schema.Raw) + } + if !schema.Get("properties._").Exists() { + t.Fatalf("legitimate underscore property was removed: %s", schema.Raw) + } + if required := schema.Get("required.0").String(); required != "_" { + t.Fatalf("required[0] = %q, want underscore: %s", required, schema.Raw) + } + }) + } +} + func TestAntigravityBuildRequestSanitizesSnakeCaseGenerationResponseSchemas(t *testing.T) { for _, testCase := range []struct { alias string diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index af0afa5d..c0a953e5 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -74,6 +74,22 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", maxTok.Num) } + // Map OpenAI response_format to Antigravity structured output settings. + if responseFormat := gjson.GetBytes(rawJSON, "response_format"); responseFormat.Exists() { + switch responseFormatType := strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())); responseFormatType { + case "json_object", "json_schema": + for _, schemaKey := range []string{"responseSchema", "responseJsonSchema", "response_schema", "response_json_schema"} { + out, _ = sjson.DeleteBytes(out, "request.generationConfig."+schemaKey) + } + out, _ = sjson.SetBytes(out, "request.generationConfig.responseMimeType", "application/json") + if responseFormatType == "json_object" { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(`{"type":"object"}`)) + } else if schema := responseFormat.Get("json_schema.schema"); schema.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(schema.Raw)) + } + } + } + // Candidate count (OpenAI 'n' parameter) if n := gjson.GetBytes(rawJSON, "n"); n.Exists() && n.Type == gjson.Number { if val := n.Int(); val > 1 { diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go index 0bf1a0fd..845e7b63 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -314,3 +314,79 @@ func TestConvertOpenAIRequestToAntigravityMapsToolChoiceModes(t *testing.T) { }) } } + +func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONObject(t *testing.T) { + inputJSON := []byte(`{ + "model":"gemini-3.6-flash-high", + "messages":[{"role":"user","content":"hi"}], + "generationConfig":{ + "responseSchema":{"type":"string","description":"stale"}, + "responseJsonSchema":{"type":"string"}, + "response_schema":{"type":"string"}, + "response_json_schema":{"type":"string"} + }, + "response_format":{"type":"json_object"} + }`) + + out := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false) + if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out) + } + schema := gjson.GetBytes(out, "request.generationConfig.responseSchema") + if got := schema.Get("type").String(); got != "object" { + t.Fatalf("responseSchema.type = %q, want object. Output: %s", got, out) + } + if schema.Get("description").Exists() { + t.Fatalf("stale responseSchema survived. Output: %s", out) + } + assertNoResponseSchemaAliases(t, out) +} + +func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONSchema(t *testing.T) { + inputJSON := []byte(`{ + "model":"gemini-3.6-flash-high", + "messages":[{"role":"user","content":"hi"}], + "generationConfig":{ + "responseSchema":{"type":"string","description":"stale"}, + "responseJsonSchema":{"type":"string"}, + "response_schema":{"type":"string"}, + "response_json_schema":{"type":"string"} + }, + "response_format":{ + "type":"json_schema", + "json_schema":{ + "name":"verdict", + "schema":{ + "type":"object", + "properties":{"score":{"type":"integer"}}, + "required":["score"] + } + } + } + }`) + + out := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false) + if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out) + } + schema := gjson.GetBytes(out, "request.generationConfig.responseSchema") + if !schema.Exists() { + t.Fatalf("responseSchema missing. Output: %s", out) + } + if got := schema.Get("properties.score.type").String(); got != "integer" { + t.Fatalf("responseSchema.properties.score.type = %q, want integer. Output: %s", got, out) + } + if schema.Get("description").Exists() { + t.Fatalf("stale responseSchema survived. Output: %s", out) + } + assertNoResponseSchemaAliases(t, out) +} + +func assertNoResponseSchemaAliases(t *testing.T, out []byte) { + t.Helper() + for _, schemaKey := range []string{"responseJsonSchema", "response_schema", "response_json_schema"} { + if gjson.GetBytes(out, "request.generationConfig."+schemaKey).Exists() { + t.Errorf("stale %s survived response_format mapping. Output: %s", schemaKey, out) + } + } +} diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 467bc134..51a414f7 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -24,21 +24,27 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t // 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. +// CleanJSONSchemaForAntigravity transforms a tool schema to be compatible with Antigravity API. // It handles unsupported keywords, type flattening, and schema simplification while preserving -// semantic information as description hints. +// semantic information as description hints and adding placeholders required by VALIDATED mode. func CleanJSONSchemaForAntigravity(jsonStr string) string { - return cleanJSONSchema(jsonStr, true) + return cleanJSONSchema(jsonStr, true, false) +} + +// CleanJSONSchemaForAntigravityResponse transforms a response schema without adding tool-only +// placeholders that would alter the client's structured output contract. +func CleanJSONSchemaForAntigravityResponse(jsonStr string) string { + return cleanJSONSchema(jsonStr, false, false) } // CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini tool calling. // It removes unsupported keywords and simplifies schemas, without adding empty-schema placeholders. func CleanJSONSchemaForGemini(jsonStr string) string { - return cleanJSONSchema(jsonStr, false) + return cleanJSONSchema(jsonStr, false, true) } // cleanJSONSchema performs the core cleaning operations on the JSON schema. -func cleanJSONSchema(jsonStr string, addPlaceholder bool) string { +func cleanJSONSchema(jsonStr string, addPlaceholder, removeGeminiMetadata bool) string { // Phase 1: Convert and add hints jsonStr = convertRefsToHints(jsonStr) jsonStr = convertConstToEnum(jsonStr) @@ -54,7 +60,7 @@ func cleanJSONSchema(jsonStr string, addPlaceholder bool) string { // Phase 3: Cleanup jsonStr = removeUnsupportedKeywords(jsonStr) - if !addPlaceholder { + if removeGeminiMetadata { // Gemini schema cleanup: remove nullable/title and placeholder-only fields. jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"}) jsonStr = removePlaceholderFields(jsonStr) diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index bb581cdc..20d10b4d 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -733,6 +733,37 @@ func TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T) } } +func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *testing.T) { + bare := gjson.Parse(CleanJSONSchemaForAntigravityResponse(`{"type":"object"}`)) + if bare.Get("properties.reason").Exists() || bare.Get("required").Exists() { + t.Fatalf("bare response schema gained tool placeholders: %s", bare.Raw) + } + + input := `{ + "type":"object", + "title":"Response", + "nullable":true, + "properties":{ + "empty":{"type":"object"}, + "optional":{"type":"object","properties":{"value":{"type":"string"}}} + } + }` + result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input)) + for _, path := range []string{ + "properties.empty.properties.reason", + "properties.empty.required", + "properties.optional.properties._", + "properties.optional.required", + } { + if result.Get(path).Exists() { + t.Errorf("response schema gained tool-only field %s: %s", path, result.Raw) + } + } + if result.Get("title").String() != "Response" || !result.Get("nullable").Bool() { + t.Errorf("Antigravity response metadata was removed: %s", result.Raw) + } +} + // ============================================================================ // Format field handling (ad-hoc patch removal) // ============================================================================