diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8173320c..935d10357f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **antigravity-to-openai**: preserve the `required` array when translating Draft 2020-12 tool schemas (e.g. from OpenCode), stripping unsupported JSON Schema meta keywords while keeping mandatory arguments required so the model no longer calls tools without them. (thanks @anuragg-saxenaa) + - **cli(runtime)**: persist the lazily-installed native runtime deps (`better-sqlite3`, `systray2`) to the shared runtime `package.json` with `--save-exact` instead of `--no-save`, so installing one no longer prunes the other as "extraneous" — fixing a "No SQLite driver available" failure after a `--tray` install (thanks @omartuhintvs). - **pricing**: add the `MiniMax-M3` cost row (canonical + lowercase alias) so the new MiniMax default model gets accurate per-request cost accounting instead of falling back to a zero/default rate (thanks @octo-patch). diff --git a/open-sse/translator/request/antigravity-to-openai.ts b/open-sse/translator/request/antigravity-to-openai.ts index b993c37700..24d25d4b11 100644 --- a/open-sse/translator/request/antigravity-to-openai.ts +++ b/open-sse/translator/request/antigravity-to-openai.ts @@ -85,7 +85,7 @@ export function antigravityToOpenAIRequest(model, body, stream) { function: { name: func.name, description: func.description || "", - parameters: normalizeSchemaTypes(func.parameters) || { + parameters: cleanSchemaPreservingRequired(func.parameters) || { type: "object", properties: {}, }, @@ -100,22 +100,24 @@ export function antigravityToOpenAIRequest(model, body, stream) { } // Recursively convert Antigravity schema types (OBJECT, STRING, etc.) to lowercase -// and strip unsupported fields like enumDescriptions -function normalizeSchemaTypes(schema) { +// and strip unsupported fields like enumDescriptions. +function normalizeSchemaTypes(schema: unknown): unknown { if (!schema || typeof schema !== "object") return schema; - const result = Array.isArray(schema) ? [...schema] : { ...schema }; + const result: JsonRecord = Array.isArray(schema) + ? ([...(schema as unknown[])] as unknown as JsonRecord) + : { ...(schema as JsonRecord) }; if (typeof result.type === "string") { result.type = result.type.toLowerCase(); } - // Strip enumDescriptions — not supported by upstream OpenAI-compatible APIs + // Strip enumDescriptions — not supported by upstream APIs delete result.enumDescriptions; - if (result.properties) { - const normalized = {}; - for (const [key, val] of Object.entries(result.properties)) { + if (result.properties && typeof result.properties === "object") { + const normalized: JsonRecord = {}; + for (const [key, val] of Object.entries(result.properties as JsonRecord)) { normalized[key] = normalizeSchemaTypes(val); } result.properties = normalized; @@ -128,6 +130,83 @@ function normalizeSchemaTypes(schema) { return result; } +// Clean a JSON Schema for Antigravity while PRESERVING the `required` array at every level. +// Unlike the type-lowering pass alone, this strips JSON Schema Draft 2020-12 meta keywords +// ($schema, $defs, $ref, additionalProperties, patternProperties, title, x-*, ...) that the +// Antigravity upstream does not accept, yet keeps `required` so the model still treats +// mandatory tool arguments as mandatory. Clients such as OpenCode send full Draft 2020-12 +// tool schemas; dropping `required` lets the model call tools without their required args. +function cleanSchemaPreservingRequired(schema: unknown): unknown { + if (!schema || typeof schema !== "object") return schema; + + // Reuse the existing recursion to lowercase types + strip enumDescriptions, then + // remove draft-meta keywords and reconcile `required` against the surviving properties. + const normalized = normalizeSchemaTypes(structuredClone(schema)); + stripDraftMeta(normalized); + preserveRequired(normalized); + return normalized; +} + +// Draft 2020-12 / JSON Schema meta keywords the Antigravity upstream does not accept. +const DRAFT_META_KEYS = new Set([ + "$schema", + "$defs", + "definitions", + "$ref", + "$comment", + "const", + "additionalProperties", + "propertyNames", + "patternProperties", + "title", +]); + +function stripDraftMeta(obj: unknown): void { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) stripDraftMeta(item); + return; + } + const record = obj as JsonRecord; + for (const key of Object.keys(record)) { + if (DRAFT_META_KEYS.has(key) || key.startsWith("x-")) { + delete record[key]; + } + } + for (const value of Object.values(record)) { + if (value && typeof value === "object") stripDraftMeta(value); + } +} + +// Preserve `required` even when referenced fields were stripped from constraint blocks. +// At each node where both `required` and `properties` are present, keep only the entries +// that still exist in `properties`; drop `required` entirely if none survive. This avoids +// emitting a `required` array that references fields removed by stripDraftMeta. +function preserveRequired(obj: unknown): void { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) preserveRequired(item); + return; + } + const record = obj as JsonRecord; + if (Array.isArray(record.required) && record.properties && typeof record.properties === "object") { + const properties = record.properties as JsonRecord; + const valid = (record.required as unknown[]).filter( + (field) => + typeof field === "string" && + Object.prototype.hasOwnProperty.call(properties, field) + ); + if (valid.length === 0) { + delete record.required; + } else { + record.required = valid; + } + } + for (const value of Object.values(record)) { + if (value && typeof value === "object") preserveRequired(value); + } +} + // Convert Antigravity content to OpenAI message // Handles: text, thought, thoughtSignature, functionCall, functionResponse, inlineData function convertContent(content) { diff --git a/tests/unit/translator-antigravity-to-openai.test.ts b/tests/unit/translator-antigravity-to-openai.test.ts index e44c5c02cd..6012b0bb9a 100644 --- a/tests/unit/translator-antigravity-to-openai.test.ts +++ b/tests/unit/translator-antigravity-to-openai.test.ts @@ -250,3 +250,74 @@ test("Antigravity -> OpenAI strips enumDescriptions from tool schema (top-level }, }); }); + +test("Antigravity -> OpenAI preserves the required array on Draft 2020-12 tool schemas", () => { + const result = antigravityToOpenAIRequest( + "gpt-4o", + { + request: { + tools: [ + { + functionDeclarations: [ + { + name: "create_file", + parameters: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "OBJECT", + additionalProperties: false, + title: "CreateFileArgs", + $defs: { unused: { type: "STRING" } }, + properties: { + path: { type: "STRING", pattern: "^/" }, + contents: { type: "STRING" }, + }, + required: ["path", "contents"], + }, + }, + ], + }, + ], + }, + }, + false + ); + + const params = (result.tools[0].function as any).parameters; + // The required array must survive so the model treats mandatory args as mandatory. + assert.deepEqual(params.required, ["path", "contents"]); + // Types are still lowered and Draft 2020-12 meta keywords are stripped. + assert.equal(params.type, "object"); + assert.equal(params.properties.path.type, "string"); + assert.equal(params.$schema, undefined); + assert.equal(params.$defs, undefined); + assert.equal(params.additionalProperties, undefined); + assert.equal(params.title, undefined); +}); + +test("Antigravity -> OpenAI drops required entries that no longer exist in properties", () => { + const result = antigravityToOpenAIRequest( + "gpt-4o", + { + request: { + tools: [ + { + functionDeclarations: [ + { + name: "partial", + parameters: { + type: "OBJECT", + properties: { kept: { type: "STRING" } }, + required: ["kept", "ghost"], + }, + }, + ], + }, + ], + }, + }, + false + ); + + const params = (result.tools[0].function as any).parameters; + assert.deepEqual(params.required, ["kept"]); +});