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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user