From 9b8c3c035f47cff1c3b71f67db6de80edca32200 Mon Sep 17 00:00:00 2001 From: sunss Date: Thu, 22 Oct 2026 14:32:56 +0800 Subject: [PATCH] fix(ai-providers): harden CCA schema projection with safe literal handling --- .../ai-providers/antigravity/provider.ts | 179 +++++++++--- .../ai-providers/antigravity.test.ts | 267 +++++++++++++++++- 2 files changed, 405 insertions(+), 41 deletions(-) diff --git a/extensions/ai-providers/antigravity/provider.ts b/extensions/ai-providers/antigravity/provider.ts index fb2a7054..6596fe57 100644 --- a/extensions/ai-providers/antigravity/provider.ts +++ b/extensions/ai-providers/antigravity/provider.ts @@ -33,7 +33,6 @@ import { emptyUsage } from "../usage.ts"; import { decodeApiKey } from "./credentials.ts"; import { convertMessages, - convertTools, isThinkingPart, mapStopReasonString, retainThoughtSignature, @@ -77,12 +76,13 @@ interface AntigravitySessionState { const sessionStates = new Map(); const MAX_SESSION_STATES = 64; -// Cloud Code Assist 400s on these JSON Schema keywords (reference: omp -// packages/ai/src/utils/schema/fields.ts — union of UNSUPPORTED_SCHEMA_FIELDS -// and LIFTABLE_TO_DESCRIPTION_FIELDS). Constraints that remain useful to the -// model are serialized into the sibling description before being removed. +// Conservative legacy projection, based on omp's Google schema fields.ts +// denylist. This is not a complete list of what the CCA endpoint rejects. +// Useful constraints are serialized into description before being removed. const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record = { $schema: true, + $id: true, + $anchor: true, $ref: true, $defs: true, $dynamicRef: true, @@ -97,6 +97,11 @@ const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record = { propertyNames: true, minItems: true, maxItems: true, + uniqueItems: true, + contains: true, + minContains: true, + maxContains: true, + additionalItems: true, minLength: true, maxLength: true, minProperties: true, @@ -106,9 +111,11 @@ const CCA_UNSUPPORTED_SCHEMA_FIELDS: Record = { exclusiveMinimum: true, exclusiveMaximum: true, multipleOf: true, - uniqueItems: true, pattern: true, format: true, + contentEncoding: true, + contentMediaType: true, + contentSchema: true, default: true, deprecated: true, readOnly: true, @@ -139,6 +146,28 @@ const CCA_LIFTABLE_TO_DESCRIPTION: Record = { examples: true, }; +// Keep the existing CCA parameters surface. Unknown keywords are omitted; +// this lossy projection does not replace Pi's validation of the original schema. +const CCA_WIRE_ALLOWED_KEYS: Record = { + type: true, + title: true, + description: true, + enum: true, + items: true, + properties: true, + required: true, + anyOf: true, + oneOf: true, + allOf: true, +}; + +function inferSchemaTypeFromValue(value: unknown) { + if (typeof value === "string") return "string"; + if (typeof value === "number") return "number"; + if (typeof value === "boolean") return "boolean"; + return undefined; +} + function sanitizeSchemaForCcaValue( value: unknown, insidePropertiesMap: boolean, @@ -147,28 +176,69 @@ function sanitizeSchemaForCcaValue( return value.map((entry) => sanitizeSchemaForCcaValue(entry, false)); } if (value === null || typeof value !== "object") return value; + if (insidePropertiesMap) { + // fromEntries preserves arbitrary parameter names, including __proto__. + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + sanitizeSchemaForCcaValue(entry, false), + ]), + ); + } + const source = value as Record; + let collapsedEnum: unknown[] | undefined; + let collapsedType: string | undefined; + const variants = source.anyOf; + // Only plain, homogeneous literal alternatives are equivalent to a flat + // enum. Keep conjunctions, exclusive choices, annotations and additional + // branch constraints intact rather than silently widening their meaning. + if ( + !Object.hasOwn(source, "const") && + !Object.hasOwn(source, "enum") && + Array.isArray(variants) && + variants.length > 0 && + variants.every((variant) => { + if ( + variant === null || + typeof variant !== "object" || + Array.isArray(variant) + ) + return false; + const entry = variant as Record; + const inferred = inferSchemaTypeFromValue(entry.const); + return ( + Object.hasOwn(entry, "const") && + inferred !== undefined && + (entry.type === undefined || entry.type === inferred) && + Object.keys(entry).every((key) => key === "type" || key === "const") + ); + }) + ) { + const values = variants.map( + (variant) => (variant as Record).const, + ); + const types = new Set(values.map(inferSchemaTypeFromValue)); + const inferred = inferSchemaTypeFromValue(values[0]); + if ( + types.size === 1 && + (source.type === undefined || source.type === inferred) + ) { + collapsedEnum = [...new Set(values)]; + collapsedType = inferred; + } + } + const out: Record = {}; const spill: Array<[string, unknown]> = []; let constEntry: unknown; let hasConst = false; - for (const [key, entry] of Object.entries(value)) { - // Keys below `properties` are user-defined parameter names, not JSON - // Schema keywords. A tool parameter named `pattern`, for example, must be - // preserved while its schema value is sanitized normally. - if (insidePropertiesMap) { - out[key] = sanitizeSchemaForCcaValue(entry, false); - continue; - } + for (const [key, entry] of Object.entries(source)) { + if (key === "anyOf" && collapsedEnum) continue; if (key === "const") { hasConst = true; constEntry = entry; continue; } - // Enum entries are instance data, including objects, not subschemas. - if (key === "enum") { - out.enum = entry; - continue; - } if (Object.hasOwn(CCA_UNSUPPORTED_SCHEMA_FIELDS, key)) { if ( entry !== undefined && @@ -178,13 +248,20 @@ function sanitizeSchemaForCcaValue( } continue; } - out[key] = sanitizeSchemaForCcaValue(entry, key === "properties"); + if (!Object.hasOwn(CCA_WIRE_ALLOWED_KEYS, key)) continue; + // Walk schema positions only. Enum objects and other literal payloads + // must never be interpreted as schemas (omp classifySchemaChild). + const isSchema = + key === "properties" || + key === "items" || + key === "anyOf" || + key === "oneOf" || + key === "allOf"; + out[key] = isSchema + ? sanitizeSchemaForCcaValue(entry, key === "properties") + : entry; } if (hasConst) { - // OpenAPI 3.0 — the schema dialect CCA validates against — has no - // "const"; TypeBox literal unions (`anyOf` of `{type, const}`) 400 the - // whole request. Rewrite the literal as a single-value enum, which CCA - // accepts (enum-carrying parameters in the same request pass). if ( Array.isArray(out.enum) && !out.enum.some((entry) => isDeepStrictEqual(entry, constEntry)) @@ -192,16 +269,46 @@ function sanitizeSchemaForCcaValue( throw new Error("Antigravity tool schema has conflicting const and enum"); } out.enum = [constEntry]; + const inferred = inferSchemaTypeFromValue(constEntry); + if (out.type === undefined && inferred !== undefined) out.type = inferred; + } + if (collapsedEnum) { + out.enum = collapsedEnum; + if (out.type === undefined) out.type = collapsedType; } if (spill.length > 0) { const formatted = `{${spill.map(([key, entry]) => `${key}: ${JSON.stringify(entry)}`).join(", ")}}`; const existing = typeof out.description === "string" ? out.description : ""; out.description = existing ? `${existing}\n\n${formatted}` : formatted; } + if (Array.isArray(out.type)) { + const types = [ + ...new Set( + out.type.filter( + (entry) => typeof entry === "string" && entry !== "null", + ), + ), + ]; + delete out.type; + // Distribute sibling constraints over each alternative; overwriting an + // existing anyOf or selecting only the first type loses valid values. + const branches = types.map((type) => { + const branch: Record = { ...out, type }; + // JSON Schema ignores these fields on other types; CCA rejects them. + if (type !== "array") delete branch.items; + if (type !== "object") { + delete branch.properties; + delete branch.required; + } + return branch; + }); + if (branches.length === 1) return branches[0]; + if (branches.length > 1) return { anyOf: branches }; + } return out; } -/** Recursively drop schema keywords Cloud Code Assist rejects. */ +/** Project tool schemas onto CCA parameters without rewriting literal data. */ export function sanitizeSchemaForCca(value: unknown): unknown { return sanitizeSchemaForCcaValue(value, false); } @@ -297,17 +404,17 @@ function buildTools( ): Record[] | undefined { if (toolChoice === "none") return undefined; if (!tools || tools.length === 0) return undefined; - const converted = convertTools([...tools], true) as - | { functionDeclarations: Record[] }[] - | undefined; - if (!converted) return undefined; - return converted.map((group) => ({ - ...group, - functionDeclarations: group.functionDeclarations.map((declaration) => ({ - ...declaration, - parameters: sanitizeSchemaForCca(declaration.parameters), - })), - })); + // Sanitize the original schema once. The generic Google converter walks + // instance objects too, losing literal fields before CCA can preserve them. + return [ + { + functionDeclarations: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: sanitizeSchemaForCca(tool.parameters), + })), + }, + ]; } export function buildRequestBody( diff --git a/tests/extensions/ai-providers/antigravity.test.ts b/tests/extensions/ai-providers/antigravity.test.ts index a5486d0d..f7b3c3df 100644 --- a/tests/extensions/ai-providers/antigravity.test.ts +++ b/tests/extensions/ai-providers/antigravity.test.ts @@ -555,9 +555,11 @@ test("sanitizeSchemaForCca preserves property names that match schema keywords", ); }); -test("sanitizeSchemaForCca rewrites const literals to enum inside anyOf branches", () => { +test("sanitizeSchemaForCca collapses only unconstrained same-type literal anyOf", () => { // TypeBox serializes Type.Union([Type.Literal(...)]) this way; CCA 400s the - // whole request on "const" while accepting enum in the same payload. + // whole request on "const" while accepting enum in the same payload. The + // plain alternatives collapse so the model sees one enum instead of + // anyOf-of-singleton-enums, without changing the allowed value set. assert.deepEqual( sanitizeSchemaForCca({ type: "object", @@ -577,10 +579,103 @@ test("sanitizeSchemaForCca rewrites const literals to enum inside anyOf branches properties: { evidence: { description: 'Evidence to read: "transcript", "final", "structured".', + type: "string", + enum: ["transcript", "final", "structured"], + }, + }, + }, + ); + // Literals without an explicit type still collapse, inferring "string". + assert.deepEqual( + sanitizeSchemaForCca({ + anyOf: [{ const: "a" }, { const: "b" }], + }), + { type: "string", enum: ["a", "b"] }, + ); + // Conjunction and exclusive choice must not become a union of values. + for (const combiner of ["allOf", "oneOf"]) { + assert.deepEqual( + sanitizeSchemaForCca({ + [combiner]: [ + { type: "string", const: "a" }, + { type: "string", const: "b" }, + ], + }), + { + [combiner]: [ + { type: "string", enum: ["a"] }, + { type: "string", enum: ["b"] }, + ], + }, + ); + } + assert.deepEqual( + sanitizeSchemaForCca({ oneOf: [{ const: "a" }, { const: "a" }] }), + { + oneOf: [ + { type: "string", enum: ["a"] }, + { type: "string", enum: ["a"] }, + ], + }, + ); + assert.deepEqual( + sanitizeSchemaForCca({ + anyOf: [{ type: "string", const: "a" }, { const: 1 }], + }), + { + anyOf: [ + { type: "string", enum: ["a"] }, + { type: "number", enum: [1] }, + ], + }, + ); + assert.deepEqual( + sanitizeSchemaForCca({ + anyOf: [{ const: "a", description: "First choice" }, { const: "b" }], + enum: ["a"], + }), + { + anyOf: [ + { type: "string", enum: ["a"], description: "First choice" }, + { type: "string", enum: ["b"] }, + ], + enum: ["a"], + }, + ); + assert.deepEqual( + sanitizeSchemaForCca({ + anyOf: [{ const: "a", enum: ["a"] }, { const: "b" }], + }), + { + anyOf: [ + { type: "string", enum: ["a"] }, + { type: "string", enum: ["b"] }, + ], + }, + ); +}); + +test("sanitizeSchemaForCca keeps mixed-type combinators as anyOf", () => { + // Not every variant carries `const` — the union must stay a union. + assert.deepEqual( + sanitizeSchemaForCca({ + type: "object", + properties: { + exclude: { + anyOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + }, + }), + { + type: "object", + properties: { + exclude: { anyOf: [ - { type: "string", enum: ["transcript"] }, - { type: "string", enum: ["final"] }, - { type: "string", enum: ["structured"] }, + { type: "string" }, + { type: "array", items: { type: "string" } }, ], }, }, @@ -596,6 +691,11 @@ test("sanitizeSchemaForCca intersects const with a sibling enum", () => { enum: ["chromium"], }, ); + // A bare const infers its type when the node has none. + assert.deepEqual(sanitizeSchemaForCca({ const: "chromium" }), { + type: "string", + enum: ["chromium"], + }); assert.deepEqual( sanitizeSchemaForCca({ type: "string", @@ -635,6 +735,111 @@ test("sanitizeSchemaForCca preserves a property named const", () => { ); }); +test("sanitizeSchemaForCca strips omp-synced keywords (contains, additionalItems, $id)", () => { + assert.deepEqual( + sanitizeSchemaForCca({ + type: "array", + items: { type: "string" }, + contains: { type: "string" }, + minContains: 1, + additionalItems: { type: "string" }, + contentEncoding: "base64", + $id: "https://example.com/schema", + }), + { type: "array", items: { type: "string" } }, + ); +}); + +test("sanitizeSchemaForCca drops unknown keywords as a fail-soft final gate", () => { + // Keep existing metadata and schema structure while omitting keywords + // outside this projection. This gate is not a complete CCA validator. + assert.deepEqual( + sanitizeSchemaForCca({ + type: "object", + title: "Files to write", + if: { type: "object" }, + // biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword under test + then: { type: "object" }, + properties: { + path: { type: "string", title: "Path", nullable: true }, + }, + required: ["path"], + }), + { + type: "object", + title: "Files to write", + properties: { + path: { type: "string", title: "Path" }, + }, + required: ["path"], + }, + ); +}); + +test("sanitizeSchemaForCca keeps every non-null type in type arrays", () => { + assert.deepEqual(sanitizeSchemaForCca({ type: ["string", "null"] }), { + type: "string", + }); + assert.deepEqual( + sanitizeSchemaForCca({ + type: ["string", "number"], + description: "Either type", + anyOf: [{ enum: ["a"] }, { enum: [1] }], + }), + { + anyOf: [ + { + type: "string", + description: "Either type", + anyOf: [{ enum: ["a"] }, { enum: [1] }], + }, + { + type: "number", + description: "Either type", + anyOf: [{ enum: ["a"] }, { enum: [1] }], + }, + ], + }, + ); + assert.deepEqual(sanitizeSchemaForCca({ type: ["null"] }), {}); + assert.deepEqual( + sanitizeSchemaForCca({ + type: ["string", "array", "object"], + items: { type: "string" }, + properties: { value: { type: "string" } }, + required: ["value"], + }), + { + anyOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + ], + }, + ); +}); + +test("sanitizeSchemaForCca preserves a property named anyOf", () => { + assert.deepEqual( + sanitizeSchemaForCca({ + type: "object", + properties: { + anyOf: { type: "string", description: "Coin flip" }, + }, + }), + { + type: "object", + properties: { + anyOf: { type: "string", description: "Coin flip" }, + }, + }, + ); +}); + test("buildRequestBody strips CCA-rejected keywords and spills constraints into description", () => { const contextWithTools = { ...SIMPLE_CONTEXT, @@ -654,6 +859,15 @@ test("buildRequestBody strips CCA-rejected keywords and spills constraints into mode: { type: "string", readOnly: false }, pattern: { type: "string", description: "Search pattern" }, $id: { type: "string", description: "User-defined property" }, + // TypeBox Type.Union([Type.Literal(...)]) shape: must reach the + // wire as a flat enum, never as anyOf branches carrying const. + evidence: { + description: 'Evidence to read: "transcript", "final".', + anyOf: [ + { type: "string", const: "transcript" }, + { type: "string", const: "final" }, + ], + }, }, }, }, @@ -689,10 +903,53 @@ test("buildRequestBody strips CCA-rejected keywords and spills constraints into type: "string", description: "User-defined property", }); + assert.deepEqual(parameters.properties.evidence, { + type: "string", + description: 'Evidence to read: "transcript", "final".', + enum: ["transcript", "final"], + }); }); // --- request envelope ------------------------------------------------------ +test("buildRequestBody preserves raw literal data and special property names on both routes", () => { + const literal = { type: "data", const: "keep", pattern: "data", $id: "data" }; + const context = { + ...SIMPLE_CONTEXT, + tools: [ + { + name: "choose", + description: "d", + parameters: { + type: "object", + properties: { + ["__proto__"]: { type: "string" }, + choice: { enum: [literal] }, + }, + required: ["__proto__", "choice"], + }, + }, + ], + }; + const original = structuredClone(context); + for (const model of [CLAUDE_MODEL, GEMINI_MODEL]) { + const body = buildRequestBody(model, context as never, undefined, "p") as { + request: { + tools: { + functionDeclarations: { + parameters: unknown; + parametersJsonSchema?: unknown; + }[]; + }[]; + }; + }; + const declaration = body.request.tools[0]!.functionDeclarations[0]!; + assert.deepEqual(declaration.parameters, original.tools[0]!.parameters); + assert.equal(Object.hasOwn(declaration, "parametersJsonSchema"), false); + } + assert.deepEqual(context, original); +}); + test("buildRequestBody produces the Antigravity envelope", () => { const body = buildRequestBody( CLAUDE_MODEL,