tpmjs/OPENAI_SCHEMA_ERROR.md
Ajax Davis 635fc96cac feat: implement playground app with AI SDK v6 tool execution
- Create new Next.js app at apps/playground for testing TPMJS tools
- Implement AI SDK v6 patterns with DefaultChatTransport and UIMessage format
- Create template tool package at packages/tools/hello with hello-world and hello-name tools
- Use tool() and jsonSchema() helpers to avoid Zod 4 conversion issues with OpenAI
- Add static tool loading system with switch statement (Next.js/webpack compatible)
- Implement chat interface with tool call visualization showing inputs/outputs
- Support multi-step tool execution with stepCountIs(5)
- Stream responses with toUIMessageStreamResponse() for full tool support
- Add sidebar showing available tools (static list)
- Use parts-based message rendering for text and tool calls
- Integrate firecrawl-aisdk tools (scrape, crawl, search)
- Add theme toggle in header (defaults to light mode)
- Fix responsive layout with max-width for message bubbles
- Use biome-ignore comments for legitimate any types in tool loading

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 02:51:02 +10:00

14 KiB

OpenAI Schema Validation Error - AI SDK v6

RESOLVED

Solution: Use tool() and jsonSchema() from AI SDK instead of Zod for tool definitions.

Error Message

Error [AI_APICallError]: Invalid schema for function 'helloWorld': schema must be a JSON Schema of 'type: "object"', got 'type: "None"'.

Context

Building a Next.js playground app to test AI SDK v6 tool execution with OpenAI's GPT-4o-mini model. The error occurs when OpenAI validates the tool schema sent in the API request.

Root Cause

Zod 4.0.0 generates JSON Schema with allOf + $ref at the root level instead of a direct type: "object". OpenAI's API requires a JSON Schema with type: "object" at the root, so it rejects Zod 4 schemas with type: "None" error.

Environment

  • AI SDK Version: ai@6.0.0-beta.124
  • OpenAI Provider: @ai-sdk/openai@3.0.0-beta.74
  • OpenAI Library: openai@^6.9.1
  • Zod Version: zod@^4.0.0
  • Next.js Version: next@^16.0.4
  • Node.js: Latest
  • TypeScript: Strict mode enabled

Tool Definition

Located at: packages/tools/hello/src/index.ts

import { z } from 'zod';

/**
 * Hello World Tool
 * Returns a simple "Hello, World!" greeting
 *
 * This is a proper AI SDK v6 tool that can be used with streamText()
 */
export const helloWorldTool = {
  description: 'Returns a simple "Hello, World!" greeting message',
  parameters: z.object({
    // OpenAI requires at least one optional parameter, can't be completely empty
    includeTimestamp: z.boolean().optional().describe('Whether to include a timestamp in the response'),
  }),
  execute: async ({ includeTimestamp = true }: { includeTimestamp?: boolean }) => {
    const response: any = {
      message: 'Hello, World!',
    };

    if (includeTimestamp) {
      response.timestamp = new Date().toISOString();
    }

    return response;
  },
};

/**
 * Hello Name Tool
 * Returns a personalized greeting with the provided name
 *
 * This is a proper AI SDK v6 tool that can be used with streamText()
 */
export const helloNameTool = {
  description: 'Returns a personalized greeting with the provided name',
  parameters: z.object({
    name: z.string().describe('The name of the person to greet'),
  }),
  execute: async ({ name }: { name: string }) => {
    return {
      message: `Hello, ${name}!`,
      timestamp: new Date().toISOString(),
    };
  },
};

Tool Loading

Located at: apps/playground/src/lib/tool-loader.ts

// Static imports for tools (required for Next.js/webpack)
import { helloWorldTool, helloNameTool } from '@tpmjs/hello';
import { scrapeTool, crawlTool, searchTool } from 'firecrawl-aisdk';

/**
 * Load a specific TPMJS tool by package name
 */
export async function loadTpmjsTool(packageName: string): Promise<any> {
  try {
    // Map package names to their tool functions
    switch (packageName) {
      case '@tpmjs/hello':
        // Hello has multiple tools, return all of them
        return {
          helloWorld: helloWorldTool,
          helloName: helloNameTool,
        };

      case 'firecrawl-aisdk':
        // Firecrawl has multiple tools, return all of them
        return {
          scrapeTool,
          crawlTool,
          searchTool,
        };

      default:
        throw new Error(`Unknown tool package: ${packageName}`);
    }
  } catch (error) {
    if (error instanceof Error) {
      throw new Error(`Failed to load tool from package ${packageName}: ${error.message}`);
    }
    throw new Error(`Failed to load tool from package ${packageName}: Unknown error`);
  }
}

/**
 * Load all installed TPMJS tools
 */
export async function loadAllTools(): Promise<Record<string, any>> {
  const installedTools = ['@tpmjs/hello', 'firecrawl-aisdk'];

  const tools: Record<string, any> = {};

  for (const packageName of installedTools) {
    try {
      const tool = await loadTpmjsTool(packageName);

      // If the tool returns an object with multiple tools (like firecrawl), spread them
      if (tool && typeof tool === 'object' && !tool.description) {
        Object.assign(tools, tool);
      } else {
        // Single tool - use a cleaned name (remove hyphens, camelCase)
        const toolName = packageName.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()).replace(/-/g, '');
        tools[toolName] = tool;
      }
    } catch (error) {
      console.error(`Failed to load tool ${packageName}:`, error);
      // Continue loading other tools even if one fails
    }
  }

  return tools;
}

API Route

Located at: apps/playground/src/app/api/chat/route.ts

import { loadAllTools } from '~/lib/tool-loader';
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { NextRequest } from 'next/server';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { messages } = body;

    if (!messages || !Array.isArray(messages)) {
      return new Response(JSON.stringify({ error: 'Invalid request: messages array required' }), {
        status: 400,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    // Load all available tools
    const tools = await loadAllTools();

    console.log('Loaded tools:', Object.keys(tools));

    // Create system message
    const systemMessage = {
      role: 'system' as const,
      content: `You are a helpful AI assistant that can use TPMJS tools to help users.

Available tools:
${Object.entries(tools)
  .map(([name, tool]) => `- ${name}: ${tool.description}`)
  .join('\n')}

Call tools as needed to answer user questions. Execute tools directly.`,
    };

    // Stream the AI response with tools
    const result = streamText({
      model: openai('gpt-4o-mini'),
      messages: [systemMessage, ...messages],
      tools,
      maxSteps: 5,
    });

    return result.toTextStreamResponse();
  } catch (error) {
    console.error('Chat API error:', error);
    return new Response(
      JSON.stringify({
        error: error instanceof Error ? error.message : 'Unknown error occurred',
      }),
      {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      }
    );
  }
}

Package Configuration

Located at: packages/tools/hello/package.json

{
  "name": "@tpmjs/hello",
  "version": "0.0.1",
  "private": true,
  "description": "Example TPMJS tools - Hello World and Hello Name",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "clean": "rm -rf dist",
    "type-check": "tsc --noEmit"
  },
  "keywords": [
    "tpmjs-tool",
    "ai-sdk",
    "hello",
    "example"
  ],
  "tpmjs": {
    "category": "text-analysis",
    "description": "Simple greeting tools - Hello World and personalized Hello Name greetings"
  },
  "dependencies": {
    "ai": "6.0.0-beta.124",
    "zod": "^4.0.0"
  },
  "devDependencies": {
    "@tpmjs/tsconfig": "workspace:*",
    "typescript": "^5.9.3"
  },
  "files": [
    "dist",
    "README.md"
  ]
}

TypeScript Configuration

Located at: packages/tools/hello/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Compiled Output

Located at: packages/tools/hello/dist/index.js

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.helloNameTool = exports.helloWorldTool = void 0;
const zod_1 = require("zod");
/**
 * Hello World Tool
 * Returns a simple "Hello, World!" greeting
 *
 * This is a proper AI SDK v6 tool that can be used with streamText()
 */
exports.helloWorldTool = {
    description: 'Returns a simple "Hello, World!" greeting message',
    parameters: zod_1.z.object({
        // OpenAI requires at least one optional parameter, can't be completely empty
        includeTimestamp: zod_1.z.boolean().optional().describe('Whether to include a timestamp in the response'),
    }),
    execute: async ({ includeTimestamp = true }) => {
        const response = {
            message: 'Hello, World!',
        };
        if (includeTimestamp) {
            response.timestamp = new Date().toISOString();
        }
        return response;
    },
};
/**
 * Hello Name Tool
 * Returns a personalized greeting with the provided name
 *
 * This is a proper AI SDK v6 tool that can be used with streamTime()
 */
exports.helloNameTool = {
    description: 'Returns a personalized greeting with the provided name',
    parameters: zod_1.z.object({
        name: zod_1.z.string().describe('The name of the person to greet'),
    }),
    execute: async ({ name }) => {
        return {
            message: `Hello, ${name}!`,
            timestamp: new Date().toISOString(),
        };
    },
};

Full Error Response from OpenAI

{
  "error": {
    "message": "Invalid schema for function 'helloWorld': schema must be a JSON Schema of 'type: \"object\"', got 'type: \"None\"'.",
    "type": "invalid_request_error",
    "param": "tools[0].parameters",
    "code": "invalid_function_parameters"
  }
}

API endpoint: https://api.openai.com/v1/responses Status code: 400

Problem Analysis

  1. OpenAI expects JSON Schema format - The tools[0].parameters field must be a valid JSON Schema object with type: "object"

  2. AI SDK v6 should convert Zod to JSON Schema - The AI SDK is supposed to automatically convert Zod schemas to JSON Schema when sending to OpenAI, but it's producing type: "None" instead

  3. Potential causes:

    • Zod 4.0.0 compatibility issue with AI SDK v6 beta
    • AI SDK not properly converting the Zod schema
    • Issue with how the tool object is structured
    • Problem with how tools are passed to streamText()
  4. Already tried:

    • Added at least one parameter (even optional) to helloWorldTool
    • Used proper Zod schema with .describe() for descriptions
    • Followed AI SDK v6 tool definition format exactly
    • Built the package successfully (dist folder exists)

AI SDK v6 Tool Format Reference

According to AI SDK v6 documentation, a tool should be defined as:

{
  description: string,
  parameters: ZodSchema,
  execute: async (args) => Promise<any>
}

This matches our implementation exactly.

Questions for ChatGPT

  1. Is there a known compatibility issue between AI SDK v6 Beta (6.0.0-beta.124) and Zod 4.0.0?

  2. Does the AI SDK v6 require a specific tool registration format when passing to streamText()?

  3. Should tools be wrapped in a different structure (e.g., using tool() helper function)?

  4. Is there a way to manually convert Zod schema to JSON Schema that OpenAI accepts?

  5. Are there any known issues with using workspace packages (@tpmjs/hello) in Next.js API routes with dynamic imports?

  6. Should we downgrade to Zod 3.x instead of Zod 4.0.0?

  7. Is there a debug mode to see what JSON Schema is being sent to OpenAI?

Additional Context

  • The firecrawl-aisdk package works correctly with the same setup
  • Build process completes successfully with no TypeScript errors
  • The tool is being loaded and passed to streamText() correctly
  • Error only occurs when OpenAI validates the tool schema
  • This is a monorepo using pnpm workspaces and Turborepo
  • Tool definition: packages/tools/hello/src/index.ts
  • Tool loader: apps/playground/src/lib/tool-loader.ts
  • API route: apps/playground/src/app/api/chat/route.ts
  • Package config: packages/tools/hello/package.json
  • Compiled output: packages/tools/hello/dist/index.js

Expected Behavior

Tools should be automatically converted from Zod schema to JSON Schema by AI SDK v6 and accepted by OpenAI's API.

Actual Behavior

OpenAI rejects the tool schema with error: got 'type: "None"' instead of a valid JSON Schema object.


SOLUTION IMPLEMENTED

What We Changed

Instead of using Zod schemas with parameters, we now use AI SDK's tool() helper with jsonSchema() for the input schema. This bypasses Zod's JSON Schema conversion entirely.

Before (Broken with Zod 4)

import { z } from 'zod';

export const helloWorldTool = {
  description: 'Returns a simple "Hello, World!" greeting message',
  parameters: z.object({
    includeTimestamp: z.boolean().optional().describe('Whether to include a timestamp'),
  }),
  execute: async ({ includeTimestamp = true }) => {
    // ...
  },
};

After (Working with jsonSchema)

import { jsonSchema, tool } from 'ai';

type HelloWorldInput = {
  includeTimestamp?: boolean;
};

export const helloWorldTool = tool({
  description: 'Returns a simple "Hello, World!" greeting message',
  inputSchema: jsonSchema<HelloWorldInput>({
    type: 'object',
    properties: {
      includeTimestamp: {
        type: 'boolean',
        description: 'Whether to include a timestamp in the response',
      },
    },
    additionalProperties: false,
  }),
  async execute({ includeTimestamp = true }) {
    const response: any = {
      message: 'Hello, World!',
    };
    if (includeTimestamp) {
      response.timestamp = new Date().toISOString();
    }
    return response;
  },
});

Key Changes

  1. Import from ai: Added jsonSchema and tool imports
  2. Define TypeScript types: Created HelloWorldInput type for type safety
  3. Use tool() wrapper: Wraps the entire tool definition
  4. Use jsonSchema() for schema: Provides explicit JSON Schema with type: "object" at root
  5. Removed Zod dependency: No longer need zod in package.json

Benefits

  • Works with OpenAI's strict schema validation
  • Explicit control over JSON Schema structure
  • Full TypeScript type safety with generic types
  • No dependency on Zod (one less package to maintain)
  • Follows AI SDK v6 best practices
  • Guaranteed type: "object" at root level

Updated Package Dependencies

{
  "dependencies": {
    "ai": "6.0.0-beta.124"
  }
}

Zod is no longer needed in tool packages that use jsonSchema().

References