Browse Source

feat: add strict mode to jsonSchema() for LLM structured outputs (#193)

* feat: add strict mode to jsonSchema() for LLM structured outputs

This PR adds support for generating strict JSON schemas compatible with LLM structured output APIs (OpenAI, Google Gemini, Anthropic, etc.).

## Problem
`catalog.jsonSchema()` was generating schemas that OpenAI's strict mode rejected due to:
- Use of `propertyNames` keyword (not permitted)
- Missing `additionalProperties: false` on nested objects
- Optional properties not being handled correctly for strict validation

## Changes
- Added `JsonSchemaOptions` interface with `strict` boolean flag
- Updated `catalog.jsonSchema()` to accept optional `JsonSchemaOptions` parameter
- When `strict: true`, the schema generator:
  - Sets `additionalProperties: false` on all object types
  - Removes `propertyNames` constraints
  - Lists all properties (including optional ones) in `required` arrays, using nullable types for optionals
  - Converts record types to fixed-key objects without additional property schemas
- Fixed Zod 4 compatibility issue in type name resolution
- Added comprehensive test suite covering all strict mode requirements
- Exported `JsonSchemaOptions` type from core package

## Usage
```typescript
// Default behavior (unchanged)
const schema = catalog.jsonSchema();

// Strict mode for LLM APIs
const strictSchema = catalog.jsonSchema({ strict: true });
```

The default behavior remains unchanged to maintain backward compatibility.

Fixes #180

* fix: document record type limitation and add anyOf nullable test

- Add clear JSDoc documenting that record/map types become opaque objects
  in strict mode (LLM strict schemas require additionalProperties: false)
- Improve inline comment on the record case in zodToJsonSchema
- Add test exercising the anyOf nullable wrapping for optional properties
  using a non-record schema so the path is directly verified

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
Chris Tate 4 months ago
parent
commit
c180f529a0
3 changed files with 335 additions and 33 deletions
  1. 1 0
      packages/core/src/index.ts
  2. 188 0
      packages/core/src/schema.test.ts
  3. 146 33
      packages/core/src/schema.ts

+ 1 - 0
packages/core/src/index.ts

@@ -160,6 +160,7 @@ export { defineSchema } from "./schema";
 // Catalog — defines the vocabulary (what components and actions are available)
 export type {
   Catalog,
+  JsonSchemaOptions,
   PromptOptions,
   PromptContext,
   SpecValidationResult,

+ 188 - 0
packages/core/src/schema.test.ts

@@ -592,6 +592,194 @@ describe("catalog.jsonSchema", () => {
     expect(jsonSchema).not.toBeNull();
     expect(typeof jsonSchema).toBe("object");
   });
+
+  describe("strict mode (LLM structured output compatible)", () => {
+    function hasNoPropertyNames(obj: unknown): boolean {
+      if (typeof obj !== "object" || obj === null) return true;
+      if ("propertyNames" in obj) return false;
+      return Object.values(obj).every(hasNoPropertyNames);
+    }
+
+    function allObjectsHaveAdditionalPropertiesFalse(obj: unknown): boolean {
+      if (typeof obj !== "object" || obj === null) return true;
+      const record = obj as Record<string, unknown>;
+      if (record.type === "object") {
+        if (record.additionalProperties !== false) return false;
+      }
+      return Object.values(record).every(
+        allObjectsHaveAdditionalPropertiesFalse,
+      );
+    }
+
+    function allObjectPropertiesRequired(obj: unknown): boolean {
+      if (typeof obj !== "object" || obj === null) return true;
+      const record = obj as Record<string, unknown>;
+      if (
+        record.type === "object" &&
+        record.properties &&
+        typeof record.properties === "object"
+      ) {
+        const propKeys = Object.keys(record.properties);
+        const required = (record.required as string[]) ?? [];
+        if (!propKeys.every((k) => required.includes(k))) return false;
+      }
+      return Object.values(record).every(allObjectPropertiesRequired);
+    }
+
+    it("sets additionalProperties: false on all nested objects", () => {
+      const catalog = defineCatalog(testSchema, {
+        components: {
+          Card: {
+            props: z.object({
+              title: z.string(),
+              subtitle: z.string().optional(),
+            }),
+            description: "",
+            slots: [],
+          },
+        },
+        actions: {},
+      });
+      const schema = catalog.jsonSchema({ strict: true });
+      expect(allObjectsHaveAdditionalPropertiesFalse(schema)).toBe(true);
+    });
+
+    it("does not emit propertyNames", () => {
+      const catalog = defineCatalog(testSchema, {
+        components: {
+          Text: {
+            props: z.object({ content: z.string() }),
+            description: "",
+            slots: [],
+          },
+        },
+        actions: {},
+      });
+      const schema = catalog.jsonSchema({ strict: true });
+      expect(hasNoPropertyNames(schema)).toBe(true);
+    });
+
+    it("lists all properties in required (optional uses nullable)", () => {
+      const catalog = defineCatalog(testSchema, {
+        components: {
+          Card: {
+            props: z.object({
+              title: z.string(),
+              subtitle: z.string().optional(),
+            }),
+            description: "",
+            slots: [],
+          },
+        },
+        actions: {},
+      });
+      const schema = catalog.jsonSchema({ strict: true });
+      expect(allObjectPropertiesRequired(schema)).toBe(true);
+    });
+
+    it("converts record types without additionalProperties schema value", () => {
+      const catalog = defineCatalog(testSchema, {
+        components: {
+          Text: {
+            props: z.object({ content: z.string() }),
+            description: "",
+            slots: [],
+          },
+        },
+        actions: {},
+      });
+      const schema = catalog.jsonSchema({
+        strict: true,
+      }) as Record<string, unknown>;
+
+      // Walk the schema and ensure no additionalProperties is set to a non-false value
+      function noAdditionalPropertiesSchema(obj: unknown): boolean {
+        if (typeof obj !== "object" || obj === null) return true;
+        const rec = obj as Record<string, unknown>;
+        if (
+          "additionalProperties" in rec &&
+          rec.additionalProperties !== false
+        ) {
+          return false;
+        }
+        return Object.values(rec).every(noAdditionalPropertiesSchema);
+      }
+      expect(noAdditionalPropertiesSchema(schema)).toBe(true);
+    });
+
+    it("wraps optional properties with anyOf nullable", () => {
+      // Use a schema where propsOf resolves to a single component's props
+      // (no record wrapper around the props) so the optional anyOf handling
+      // is directly visible in the JSON Schema output.
+      const flatSchema = defineSchema((s) => ({
+        spec: s.object({
+          component: s.object({
+            type: s.ref("catalog.components"),
+            props: s.propsOf("catalog.components"),
+          }),
+        }),
+        catalog: s.object({
+          components: s.map({
+            props: s.zod(),
+            description: s.string(),
+          }),
+        }),
+      }));
+
+      const catalog = defineCatalog(flatSchema, {
+        components: {
+          Card: {
+            props: z.object({
+              heading: z.string(),
+              caption: z.string().optional(),
+            }),
+            description: "",
+          },
+        },
+      });
+
+      const schema = catalog.jsonSchema({ strict: true }) as {
+        properties: {
+          component: {
+            properties: { props: Record<string, unknown> };
+          };
+        };
+      };
+
+      const propsSchema = schema.properties.component.properties.props;
+
+      // caption is optional – in strict mode it must be in `required`
+      // and wrapped in anyOf with null
+      const captionSchema = (
+        propsSchema as {
+          properties: { caption: Record<string, unknown> };
+        }
+      ).properties.caption;
+      expect(captionSchema).toEqual({
+        anyOf: [{ type: "string" }, { type: "null" }],
+      });
+
+      const propsRequired = (propsSchema as { required: string[] }).required;
+      expect(propsRequired).toContain("heading");
+      expect(propsRequired).toContain("caption");
+    });
+
+    it("does not affect default (non-strict) output", () => {
+      const catalog = defineCatalog(testSchema, {
+        components: {
+          Text: {
+            props: z.object({ content: z.string() }),
+            description: "",
+            slots: [],
+          },
+        },
+        actions: {},
+      });
+      const defaultSchema = catalog.jsonSchema();
+      const defaultSchema2 = catalog.jsonSchema({ strict: false });
+      expect(defaultSchema).toEqual(defaultSchema2);
+    });
+  });
 });
 
 // =============================================================================

+ 146 - 33
packages/core/src/schema.ts

@@ -92,7 +92,7 @@ export interface Catalog<
   /** Generate system prompt for AI */
   prompt(options?: PromptOptions): string;
   /** Export as JSON Schema for structured outputs */
-  jsonSchema(): object;
+  jsonSchema(options?: JsonSchemaOptions): object;
   /** Validate a spec against this catalog */
   validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>>;
   /** Get the Zod schema for the spec */
@@ -101,6 +101,28 @@ export interface Catalog<
   readonly _specType: InferSpec<TDef, TCatalog>;
 }
 
+/**
+ * Options for JSON Schema export
+ */
+export interface JsonSchemaOptions {
+  /**
+   * When true, produces a strict JSON Schema subset compatible with
+   * LLM structured output APIs (OpenAI, Google Gemini, Anthropic, etc.).
+   * This ensures:
+   * - `additionalProperties: false` on every object
+   * - All object properties listed in `required` (optionals use nullable types)
+   * - Record/map types converted to fixed-key objects
+   *
+   * **Limitation:** Record types (dynamic-key maps) cannot be represented in
+   * strict JSON Schema because `additionalProperties` must be `false`. They
+   * are emitted as `{ type: "object", properties: {}, additionalProperties: false }`.
+   * The LLM prompt (via `catalog.prompt()`) still describes the full structure,
+   * so the model can produce valid output even though the JSON Schema for
+   * record entries is opaque.
+   */
+  strict?: boolean;
+}
+
 /**
  * Prompt generation options
  */
@@ -414,8 +436,8 @@ function createCatalogFromSchema<TDef extends SchemaDefinition, TCatalog>(
       return generatePrompt(this, options);
     },
 
-    jsonSchema(): object {
-      return zodToJsonSchema(zodSchema);
+    jsonSchema(options: JsonSchemaOptions = {}): object {
+      return zodToJsonSchema(zodSchema, options.strict ?? false);
     },
 
     validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>> {
@@ -1319,44 +1341,111 @@ function formatZodType(schema: z.ZodType): string {
 }
 
 /**
- * Convert Zod schema to JSON Schema
+ * Resolve the Zod type name from a schema's internal definition.
+ * Supports both Zod 3 (`_def.typeName`) and Zod 4 (`_def.type`).
+ */
+function zodTypeName(def: Record<string, unknown>): string {
+  // Zod 4 uses _def.type as a plain string (e.g. "string", "object")
+  if (typeof def.type === "string") return def.type;
+  // Zod 3 uses _def.typeName (e.g. "ZodString", "ZodObject")
+  if (typeof def.typeName === "string") return def.typeName;
+  return "";
+}
+
+/**
+ * Normalise a Zod type name to a canonical lowercase form.
+ * Handles both Zod 3 ("ZodString") and Zod 4 ("string") conventions.
+ */
+function normalizeTypeName(raw: string): string {
+  // Zod 3 names start with "Zod", e.g. "ZodString" → "string"
+  if (raw.startsWith("Zod")) {
+    return raw.slice(3).toLowerCase();
+  }
+  return raw.toLowerCase();
+}
+
+/**
+ * Convert Zod schema to JSON Schema.
+ *
+ * When `strict` is true the output conforms to the JSON Schema subset required
+ * by LLM structured output APIs (no `propertyNames`, `additionalProperties: false`
+ * everywhere, all properties listed in `required`).
  */
-function zodToJsonSchema(schema: z.ZodType): object {
-  // Simplified JSON Schema conversion
+function zodToJsonSchema(schema: z.ZodType, strict = false): object {
   const def = schema._def as unknown as Record<string, unknown>;
-  const typeName = (def.typeName as string) ?? "";
+  const kind = normalizeTypeName(zodTypeName(def));
 
-  switch (typeName) {
-    case "ZodString":
+  switch (kind) {
+    case "string":
       return { type: "string" };
-    case "ZodNumber":
+    case "number":
       return { type: "number" };
-    case "ZodBoolean":
+    case "boolean":
       return { type: "boolean" };
-    case "ZodLiteral":
-      return { const: def.value };
-    case "ZodEnum":
-      return { enum: def.values };
-    case "ZodArray": {
-      const inner = def.type as z.ZodType | undefined;
+    case "literal": {
+      // Zod 4: _def.values (array), Zod 3: _def.value (single)
+      const values = def.values as unknown[] | undefined;
+      const value = values ? values[0] : def.value;
+      return { const: value };
+    }
+    case "enum": {
+      // Zod 4: _def.entries (object { a:"a", b:"b" }), Zod 3: _def.values (string[])
+      const entries = def.entries as Record<string, string> | undefined;
+      const values = entries
+        ? Object.values(entries)
+        : (def.values as string[] | undefined);
+      return { enum: values ?? [] };
+    }
+    case "array": {
+      // Zod 4: _def.element, Zod 3: _def.type
+      const inner = (def.element ?? def.type) as z.ZodType | undefined;
       return {
         type: "array",
-        items: inner ? zodToJsonSchema(inner) : {},
+        items: inner ? zodToJsonSchema(inner, strict) : {},
       };
     }
-    case "ZodObject": {
-      const shape = (def.shape as () => Record<string, z.ZodType>)?.();
-      if (!shape) return { type: "object" };
+    case "object": {
+      // Zod 4: _def.shape is an object, Zod 3: _def.shape is a function
+      const rawShape = def.shape;
+      const shape: Record<string, z.ZodType> | undefined =
+        typeof rawShape === "function"
+          ? (rawShape as () => Record<string, z.ZodType>)()
+          : (rawShape as Record<string, z.ZodType> | undefined);
+
+      if (!shape) {
+        if (strict) {
+          return {
+            type: "object",
+            properties: {},
+            required: [],
+            additionalProperties: false,
+          };
+        }
+        return { type: "object" };
+      }
+
       const properties: Record<string, object> = {};
       const required: string[] = [];
       for (const [key, value] of Object.entries(shape)) {
-        properties[key] = zodToJsonSchema(value);
         const innerDef = value._def as unknown as Record<string, unknown>;
-        if (
-          innerDef.typeName !== "ZodOptional" &&
-          innerDef.typeName !== "ZodNullable"
-        ) {
+        const innerKind = normalizeTypeName(zodTypeName(innerDef));
+        const isOptional = innerKind === "optional" || innerKind === "nullable";
+
+        if (strict) {
+          // In strict mode, all properties must be in required.
+          // Optional properties are represented as nullable types.
           required.push(key);
+          if (isOptional) {
+            const unwrapped = zodToJsonSchema(value, strict);
+            properties[key] = { anyOf: [unwrapped, { type: "null" }] };
+          } else {
+            properties[key] = zodToJsonSchema(value, strict);
+          }
+        } else {
+          properties[key] = zodToJsonSchema(value);
+          if (!isOptional) {
+            required.push(key);
+          }
         }
       }
       return {
@@ -1366,23 +1455,47 @@ function zodToJsonSchema(schema: z.ZodType): object {
         additionalProperties: false,
       };
     }
-    case "ZodRecord": {
+    case "record": {
       const valueType = def.valueType as z.ZodType | undefined;
+      if (strict) {
+        // LLM strict schemas require `additionalProperties: false` and do not
+        // permit a schema value for `additionalProperties`. Since record types
+        // have dynamic keys that cannot be enumerated at schema-generation time,
+        // we emit an opaque object. The LLM prompt still describes the expected
+        // structure so the model can produce valid output.
+        return {
+          type: "object",
+          properties: {},
+          required: [],
+          additionalProperties: false,
+        };
+      }
       return {
         type: "object",
         additionalProperties: valueType ? zodToJsonSchema(valueType) : true,
       };
     }
-    case "ZodOptional":
-    case "ZodNullable": {
+    case "optional":
+    case "nullable": {
       const inner = def.innerType as z.ZodType | undefined;
-      return inner ? zodToJsonSchema(inner) : {};
+      return inner ? zodToJsonSchema(inner, strict) : {};
     }
-    case "ZodUnion": {
+    case "union": {
       const options = def.options as z.ZodType[] | undefined;
-      return options ? { anyOf: options.map(zodToJsonSchema) } : {};
+      return options
+        ? { anyOf: options.map((o) => zodToJsonSchema(o, strict)) }
+        : {};
     }
-    case "ZodAny":
+    case "any":
+    case "unknown":
+      if (strict) {
+        return {
+          type: "object",
+          properties: {},
+          required: [],
+          additionalProperties: false,
+        };
+      }
       return {};
     default:
       return {};