fix(schema): apply empty-schema normalization to all providers, not just openai

Move {}→true normalization out of zodToWireSchema/postProcess (Zod-only)
into a dedicated, exported normalizeEmptySchemas helper that toolWireSchema
calls for both the Zod and TypeBox/raw-JSON-Schema branches. Every provider
that calls toolWireSchema (OpenAI, Anthropic, Google, Ollama, Bedrock,
Cursor) now receives the normalized schema regardless of how the tool was
authored.

Verified strict-mode opt-out across providers:
- OpenAI: hasUnrepresentableStrictObjectMap hits === true branch (was
  isJsonObject({}) branch), same result -> strict: false.
- Anthropic: normalizeAnthropicStrictSchemaNode opts out via
  additionalProperties !== false (still true for true) -> strict: false.
- Google: normalizeSchemaForGoogle strips additionalProperties regardless
  (UNSUPPORTED_SCHEMA_FIELDS, pre-existing behavior).

The normalizeOpenAIResponsesSchemaNode guard stays as a safety net for
callers that invoke sanitizeSchemaForOpenAIResponses directly on schemas
that bypass the wire-schema pipeline (e.g. fixtures, debug paths).

Adds cross-provider tests covering both Zod and TypeBox inputs and verifying
downstream provider behavior with the normalized form.

Fixes #1179
This commit is contained in:
roboomp
2026-05-19 04:58:19 +00:00
parent bcf05e3ea6
commit ff9e007e1e
4 changed files with 157 additions and 21 deletions
+1 -1
View File
@@ -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
+5 -5
View File
@@ -909,11 +909,11 @@ function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonO
// `{}` (empty JSON Schema) ≡ `true` (JSON Schema draft 2020-12 §4.3.1).
// Grammar-constrained samplers (llama.cpp, etc.) treat the object form 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. (issue #1179).
// This also covers TypeBox / MCP tool schemas that arrive without going through
// the Zod wire-schema post-processor.
// "generate an empty object" rather than "any JSON value" (issue #1179).
// `toolWireSchema` already runs `normalizeEmptySchemas` upstream, but this
// guard remains as a safety net for callers that invoke
// `sanitizeSchemaForOpenAIResponses` directly on a schema that bypassed
// the wire-schema pipeline (e.g. provider-specific fixtures, debug paths).
if (isJsonObjectEmpty(value)) return true;
const cached = cache.get(value);
+38 -14
View File
@@ -62,16 +62,14 @@ const kJsonWireSchema = Symbol("pi.schema.json.wire");
* treat defaulted fields as optional; Zod inverts this and keeps them
* required at the input boundary, then materializes the default).
* - Strip the noisy safe-integer bounds Zod injects for `z.number().int()`.
* - Normalize `{}` (empty JSON Schema = `z.unknown()`) to `true` in every
* schema-valued position. JSON Schema draft 2020-12 §4.3.1: `{} ≡ true`.
* 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` to always emit `{}` instead of
* the intended string/number/etc. (issue #1179).
*
* The empty-schema normalization (`{}` → `true`, see `normalizeEmptySchemas`)
* runs separately from `toolWireSchema` so both Zod and TypeBox tools get it.
*/
function postProcess(schema: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>;
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<string, unknown> {
* 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<string, unknown> {
const params: TSchema = tool.parameters;
if (isZodSchema(params)) return zodToWireSchema(params);
return stamp(
params as Record<string, unknown>,
kJsonWireSchema,
p => upgradeJsonSchemaTo202012(p) as Record<string, unknown>,
);
return stamp(params as Record<string, unknown>, kJsonWireSchema, p => {
const upgraded = upgradeJsonSchemaTo202012(p) as Record<string, unknown>;
normalizeEmptySchemas(upgraded);
return upgraded;
});
}
+113 -1
View File
@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown>): 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<string, unknown>).extra as Record<string, unknown>;
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<string, unknown>).extra as Record<string, unknown>;
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<string, unknown>;
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
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<string, unknown>;
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
expect(extra).not.toHaveProperty("additionalProperties");
});
it("CCA (Claude on Cloud Code Assist) strips additionalProperties entirely", () => {
const out = normalizeSchemaForCCA(wire) as Record<string, unknown>;
const extra = (out.properties as Record<string, unknown>).extra as Record<string, unknown>;
expect(extra).not.toHaveProperty("additionalProperties");
});
});