diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3aa0a5821..8e99f86ce 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed `{}` (empty JSON Schema, the wire representation of `z.unknown()`) being passed verbatim to grammar-constrained samplers (llama.cpp, etc.) in `additionalProperties`, `items`, and other schema-valued positions. Grammar builders treat `{}` as "generate an empty object" rather than "any JSON value", causing open-typed fields (e.g. `extra.title` from `z.record(z.string(), z.unknown())`) to always emit `{}` instead of the intended string/number/etc. The Zod wire-schema post-processor (`zodToWireSchema`) now normalizes `{}` to boolean `true` (semantically identical per JSON Schema draft 2020-12 §4.3.1) in all schema-valued key positions. `normalizeOpenAIResponsesSchemaNode` applies the same normalization for TypeBox/MCP tools that don't pass through the Zod post-processor. ([#1179](https://github.com/can1357/oh-my-pi/issues/1179)) +- Fixed `{}` (empty JSON Schema, the wire representation of `z.unknown()`) being passed verbatim to grammar-constrained samplers (llama.cpp, etc.) in `additionalProperties`, `items`, and other schema-valued positions across **every provider** (OpenAI, Anthropic, Google, Ollama, Bedrock, Cursor). Grammar builders treat `{}` as "generate an empty object" rather than "any JSON value", causing open-typed fields (e.g. `extra.title` from `z.record(z.string(), z.unknown())`) to always emit `{}` instead of the intended string/number/etc. `toolWireSchema` now applies a new `normalizeEmptySchemas` pass (exported) to both the Zod and TypeBox/raw-JSON-Schema branches, converting `{}` → `true` (semantically identical per JSON Schema draft 2020-12 §4.3.1) in all schema-valued positions. Strict-mode opt-out is preserved across all providers: OpenAI's `hasUnrepresentableStrictObjectMap` hits the `=== true` branch instead of the `isJsonObject({})` branch (same result); Anthropic's `normalizeAnthropicStrictSchemaNode` opts out via `additionalProperties !== false` (still true for `true`); Google's `normalizeSchemaForGoogle` strips `additionalProperties` regardless (pre-existing). ([#1179](https://github.com/can1357/oh-my-pi/issues/1179)) ## [15.1.4] - 2026-05-19 ### Changed diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 833778399..90311e8ac 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -909,11 +909,11 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap): Record { delete schema.$schema; walk(schema); + normalizeEmptySchemas(schema); return schema; } @@ -137,8 +135,30 @@ function walk(node: unknown): void { } } - // Normalize {} (empty JSON Schema = z.unknown()) to boolean `true` so - // grammar-constrained samplers emit any JSON value, not just empty objects. + for (const k in obj) walk(obj[k]); +} + +/** + * Normalize `{}` (empty JSON Schema = `z.unknown()` / unconstrained value) to + * boolean `true` in every schema-valued position. JSON Schema draft 2020-12 + * §4.3.1: `{}` and `true` are semantically equivalent ("any JSON value"). + * Grammar-constrained samplers (llama.cpp, etc.) treat the object form as + * "generate an empty object" rather than "any JSON value", causing open-typed + * fields like `extra.title` (from `z.record(z.string(), z.unknown())`) to + * always emit `{}` instead of the intended string/number/etc. (issue #1179). + * + * Mutates in place. Provider-agnostic — applied to every tool wire schema so + * Anthropic, Google, OpenAI, Ollama, Bedrock, and Cursor all see the + * normalized form, regardless of whether the source was Zod or TypeBox. + */ +export function normalizeEmptySchemas(node: unknown): void { + if (Array.isArray(node)) { + for (const child of node) normalizeEmptySchemas(child); + return; + } + if (!node || typeof node !== "object") return; + const obj = node as Record; + for (const key of SCHEMA_VALUE_KEYS) { if (Object.hasOwn(obj, key) && isEmptyObject(obj[key])) obj[key] = true; } @@ -159,7 +179,7 @@ function walk(node: unknown): void { } } - for (const k in obj) walk(obj[k]); + for (const k in obj) normalizeEmptySchemas(obj[k]); } /** Convert a Zod schema into the JSON Schema shape providers consume. */ @@ -177,13 +197,17 @@ export function zodToWireSchema(schema: ZodType): Record { * Resolve a tool's parameters to a JSON Schema object suitable for sending * over the wire. Zod schemas are converted (and cached); legacy TypeBox / raw * JSON Schema parameters are upgraded to draft 2020-12 (and cached). + * + * Both branches finish with `normalizeEmptySchemas` so every provider — + * OpenAI, Anthropic, Google, Ollama, Bedrock, Cursor — sees `{}` normalized + * to `true` in schema-valued positions (issue #1179). */ export function toolWireSchema(tool: Tool): Record { const params: TSchema = tool.parameters; if (isZodSchema(params)) return zodToWireSchema(params); - return stamp( - params as Record, - kJsonWireSchema, - p => upgradeJsonSchemaTo202012(p) as Record, - ); + return stamp(params as Record, kJsonWireSchema, p => { + const upgraded = upgradeJsonSchemaTo202012(p) as Record; + normalizeEmptySchemas(upgraded); + return upgraded; + }); } diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts index d31894e5e..0d3b1c1dc 100644 --- a/packages/ai/test/schema-wire.test.ts +++ b/packages/ai/test/schema-wire.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "bun:test"; -import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { normalizeAnthropicToolSchema } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { Tool } from "@oh-my-pi/pi-ai/types"; +import { + isZodSchema, + normalizeEmptySchemas, + normalizeSchemaForCCA, + normalizeSchemaForGoogle, + toolWireSchema, + zodToWireSchema, +} from "@oh-my-pi/pi-ai/utils/schema"; import { z } from "zod/v4"; describe("isZodSchema", () => { @@ -79,3 +88,106 @@ describe("zodToWireSchema — empty-schema normalization", () => { expect(name.additionalProperties).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// normalizeEmptySchemas — provider-agnostic post-pipeline normalization +// --------------------------------------------------------------------------- + +describe("normalizeEmptySchemas", () => { + it("normalizes {} in additionalProperties / items / property values / combiner branches", () => { + const schema: Record = { + type: "object", + properties: { meta: {}, items: { type: "array", items: {} } }, + additionalProperties: {}, + anyOf: [{}, { type: "string" }], + }; + normalizeEmptySchemas(schema); + expect(schema).toEqual({ + type: "object", + properties: { meta: true, items: { type: "array", items: true } }, + additionalProperties: true, + anyOf: [true, { type: "string" }], + }); + }); + + it("leaves non-empty schemas and boolean values alone", () => { + const schema: Record = { + type: "object", + additionalProperties: { type: "string" }, + unevaluatedProperties: false, + }; + normalizeEmptySchemas(schema); + expect(schema).toEqual({ + type: "object", + additionalProperties: { type: "string" }, + unevaluatedProperties: false, + }); + }); +}); + +// --------------------------------------------------------------------------- +// toolWireSchema — covers both Zod and TypeBox paths (issue #1179) +// --------------------------------------------------------------------------- + +describe("toolWireSchema — empty-schema normalization across both paths", () => { + function zodTool(parameters: z.ZodType): Tool { + return { name: "t", description: "", parameters, async execute() {} } as unknown as Tool; + } + function jsonTool(parameters: Record): Tool { + return { name: "t", description: "", parameters, async execute() {} } as unknown as Tool; + } + + it("normalizes {} → true for Zod tools (z.record(z.string(), z.unknown()))", () => { + const wire = toolWireSchema(zodTool(z.object({ extra: z.record(z.string(), z.unknown()) }))); + const extra = (wire.properties as Record).extra as Record; + expect(extra.additionalProperties).toBe(true); + }); + + it("normalizes {} → true for TypeBox / raw JSON Schema tools", () => { + const wire = toolWireSchema( + jsonTool({ + type: "object", + properties: { extra: { type: "object", additionalProperties: {} } }, + required: [], + }), + ); + const extra = (wire.properties as Record).extra as Record; + expect(extra.additionalProperties).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Provider downstream behavior with normalized `additionalProperties: true` +// (issue #1179 — verify Google and Anthropic don't break) +// --------------------------------------------------------------------------- + +describe("provider normalizers on normalized open-record schemas", () => { + const wire = zodToWireSchema( + z.object({ + action: z.enum(["apply", "discard"]), + extra: z.record(z.string(), z.unknown()).optional(), + }), + ); + + it("Anthropic preserves additionalProperties: true so strict-mode opt-out still fires", () => { + // `normalizeAnthropicStrictSchemaNode` rejects nodes where additionalProperties !== false. + // With normalization, the value is `true` (was `{}`); still !== false, so strict opts out. + const out = normalizeAnthropicToolSchema(wire) as Record; + const extra = (out.properties as Record).extra as Record; + expect(extra.additionalProperties).toBe(true); + }); + + it("Google strips additionalProperties entirely (UNSUPPORTED_SCHEMA_FIELDS)", () => { + // Pre-existing behavior — Google never sees the open-record marker either way. + // `additionalProperties: true` is removed just like `additionalProperties: {}` was. + const out = normalizeSchemaForGoogle(wire) as Record; + const extra = (out.properties as Record).extra as Record; + expect(extra).not.toHaveProperty("additionalProperties"); + }); + + it("CCA (Claude on Cloud Code Assist) strips additionalProperties entirely", () => { + const out = normalizeSchemaForCCA(wire) as Record; + const extra = (out.properties as Record).extra as Record; + expect(extra).not.toHaveProperty("additionalProperties"); + }); +});