UNPKG

zod-stream

Version:

A client for node or the browser to generate and consume streaming json

759 lines (628 loc) 21.7 kB
<div align="center"> <h1>zod-stream</h1> </div> <br /> <p align="center"><i>> Type-safe structured extraction from LLM streams with progressive validation</i></p> <br /> <div align="center"> <a aria-label="NPM version" href="https://www.npmjs.com/package/zod-stream"> <img alt="zod-stream" src="https://img.shields.io/npm/v/zod-stream.svg?style=flat-square&logo=npm&labelColor=000000&label=zod-stream"> </a> <a aria-label="Island AI" href="https://github.com/hack-dance/island-ai"> <img alt="Island AI" src="https://img.shields.io/badge/Part of Island AI-000000.svg?style=flat-square&labelColor=000000&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMiIgZGF0YS1uYW1lPSJMYXllciAyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMTQuNjkgMjU5LjI0Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiAjZmZmOwogICAgICAgIHN0cm9rZS13aWR0aDogMHB4OwogICAgICB9CiAgICA8L3N0eWxlPgogIDwvZGVmcz4KICA8ZyBpZD0iTGF5ZXJfMS0yIiBkYXRhLW5hbWU9IkxheWVyIDEiPgogICAgPGc+CiAgICAgIDxnPgogICAgICAgIDxwYXRoIGNsYXNzPSJjbHMtMSIgZD0ibTEwMC42MSwxNzguNDVoMTMuOTd2LTE5LjYyaC0xMy45N3YxOS42MlptMC0xMDguOTZ2MjMuNzJoMTMuOTd2LTIzLjcyaC0xMy45N1ptLTIuNzksMTg5Ljc1aDE5LjU2bC0yLjc5LTI4LjkyaC0xMy45N2wtMi43OSwyOC45MlptMi43OS0xMzcuNjJoMTMuOTd2LTE5LjYyaC0xMy45N3YxOS42MlptMCwyOC40MWgxMy45N3YtMTkuNjJoLTEzLjk3djE5LjYyWiIvPgogICAgICAgIDxjaXJjbGUgY2xhc3M9ImNscy0xIiBjeD0iOTQuNSIgY3k9IjY5LjExIiByPSIxNC4yNCIvPgogICAgICAgIDxjaXJjbGUgY2xhc3M9ImNscy0xIiBjeD0iMTIwLjE5IiBjeT0iNjkuMTEiIHI9IjE0LjI0Ii8+CiAgICAgICAgPHBhdGggY2xhc3M9ImNscy0xIiBkPSJtMjE0LjI1LDYyLjU5Yy0uNzktLjc1LTE4Ljc1LTE3LjQ4LTQ5LjQ2LTE5LjA0bDE1Ljc1LTUuODhjLTEuNjctMi40Ni00LjAxLTQuMTgtNi4zNS02LS4yMy0uMTgtLjAzLS41OC4yMy0uNTcsMy40NS4xNyw2LjgyLDEuNzUsMTAuMTIsMi42OCwxLjA2LjMsMi4wOS43MiwzLjA4LDEuMjRsMTkuNDUtNy4yNmMuNTMtLjIuOS0uNzEuOTEtMS4yOHMtLjMyLTEuMDktLjg1LTEuMzJjLTEuMDQtLjQ0LTI1Ljk2LTEwLjc2LTU3LjM1Ljk2LTEuMTkuNDQtMi4zNy45MS0zLjU0LDEuNDFsMTMuNTEtMTMuMTNjLTIuMTgtLjY3LTQuNC0uOTUtNi42My0xLjQ0LS4zOC0uMDgtLjQxLS43NSwwLS44MSwzLjEyLS40NCw2LjU0LS45OCw5Ljg3LS45MWw5LjEzLTguODdjLjQxLS40LjUzLTEuMDEuMzItMS41My0uMjItLjUzLS44LS43OS0xLjMxLS44Ny0uOTYuMDEtMjMuNy40OS00My45NiwyMC4xOCwwLDAsMCwwLDAsMGwtMjAuMDcsMTkuNzYtMTkuNTgtMTkuNzZDNjcuMjUuNDksNDQuNTEuMDEsNDMuNTUsMGMtLjU2LjA1LTEuMDkuMzQtMS4zMS44Ny0uMjIuNTMtLjA5LDEuMTQuMzIsMS41M2w1LjY3LDUuNTFjNS4xLjIyLDEwLjE0LjcxLDE0LjQzLDQsLjQyLjMyLjIsMS4xMi0uMzkuOTMtMi41OC0uODYtNi4wMi0uODctOS4zOS0uNGwxNS41NiwxNS4xMmMtMS4xNy0uNS0yLjM2LS45Ny0zLjU0LTEuNDEtMzEuNC0xMS43Mi01Ni4zLTEuNDEtNTcuMzUtLjk2LS41Mi4yMi0uODYuNzUtLjg1LDEuMzJzLjM3LDEuMDguOTEsMS4yOGwxMS4wNiw0LjEzYzQuNDYtMS40OCw4LjctMi4zOSwxMC40Mi0yLjU1LjU3LS4wNS41Ni43My4xMi45MS0xLjg2Ljc0LTMuNjEsMi4yOS01LjI3LDMuNjFsMjUuOTQsOS42OEMxOS4xOCw0NS4xMSwxLjIyLDYxLjg0LjQzLDYyLjU5Yy0uNDEuMzktLjU1LDEtLjM0LDEuNTMuMjEuNTMuNzMuODgsMS4zLjg4aDEzLjljLjE1LS4wOS4zMS0uMTkuNDUtLjI4LDUuNzktMy41OCwxMS45NC02LjE5LDE4LjE4LTguODcuNjgtLjI5LDEuMjguNjQuNiwxLjAzLTMuNTQsMi4wMy02LjU0LDUuMS05LjQ5LDguMTNoMTQuNTljNC4yNy0zLjExLDguODItNS43LDEzLjE2LTguNy41OS0uNDEsMS4yMi40OS43NS45Ny0yLjM1LDIuMzgtNC40NCw1LjA2LTYuNTMsNy43NGgxMTYuODNjLS45OS0zLjE5LTIuMDItNi4zNS00LjEzLTkuMDQtLjMzLS40Mi4xOC0uOTYuNTktLjU5LDMuMzYsMy4wMSw3LjM3LDYuMTUsMTEuMDIsOS42M2gxNS4zNGMtMS4zOC0zLjUyLTMuMDUtNi44Mi01LjcxLTguNjctLjU0LS4zNy0uMDgtMS4xNS41MS0uODcsNC40LDIuMDgsOC4yNyw1Ljg2LDExLjY1LDkuNTRoMjAuMmMuNTcsMCwxLjA5LS4zNSwxLjMtLjg4LjIxLS41My4wOC0xLjE0LS4zNC0xLjUzWiIvPgogICAgICA8L2c+CiAgICAgIDxwYXRoIGNsYXNzPSJjbHMtMSIgZD0ibTEwMS4wNiwyMjEuMzNoMTMuOTd2LTMzLjZoLTEzLjk3djMzLjZaIi8+CiAgICA8L2c+CiAgPC9nPgo8L3N2Zz4="> </a> <a aria-label="Made by hack.dance" href="https://hack.dance"> <img alt="docs" src="https://img.shields.io/badge/MADE%20BY%20HACK.DANCE-000000.svg?style=flat-square&labelColor=000000"> </a> <a aria-label="Twitter" href="https://twitter.com/dimitrikennedy"> <img alt="follow" src="https://img.shields.io/twitter/follow/dimitrikennedy?style=social&labelColor=000000"> </a> </div> <br /> `zod-stream` adds structured output validation and streaming capabilities to LLM responses. Built on top of [`schema-stream`](https://www.npmjs.com/package/schema-stream), it enables type-safe extraction with progressive validation. ## Key Features - 🔄 Stream structured LLM outputs with validation - 🎯 Multiple response modes (TOOLS, FUNCTIONS, JSON, etc.) - 📝 OpenAI client integration - 🌳 Progressive validation with partial results - ⚡ Built on schema-stream - 🔍 Full TypeScript support ## Why zod-stream? `zod-stream` solves key challenges in handling streaming LLM responses: - **Dependency Management**: Process data as soon as dependencies are met, rather than waiting for complete responses ```typescript if (isPathComplete(['user', 'preferences'], chunk)) { // Start personalizing immediately, don't wait for content initializeUserExperience(chunk.user.preferences); } ``` - **Type-Safe LLM Integration**: Full TypeScript support for structured outputs from OpenAI and other providers ```typescript const params = withResponseModel({ response_model: { schema, name: "Extract" }, mode: "TOOLS" // or "FUNCTIONS", "JSON", etc. }); ``` - **Progressive Processing**: Built on `schema-stream` for immediate access to partial results ```typescript for await (const chunk of stream) { // Safely access partial data with full type inference chunk._meta._completedPaths.forEach(path => { processDependency(path, chunk); }); } ``` - **Provider Flexibility**: Consistent interface across different LLM response formats ```typescript // Works with various response modes const stream = OAIStream({ res: completion }); // OpenAI tools/functions const stream = JSONStream({ res: completion }); // Direct JSON ``` Think of it as a type-safe pipeline for handling streaming LLM data where you need to: - Start processing before the full response arrives - Ensure type safety throughout the stream - Handle complex data dependencies - Work with multiple LLM response formats ## Installation ```bash # npm npm install zod-stream zod openai # pnpm pnpm add zod-stream zod openai # bun bun add zod-stream zod openai ``` ## Core Concepts The `ZodStream` client provides real-time validation and metadata for streaming LLM responses: ```typescript import ZodStream from "zod-stream"; import { z } from "zod"; const client = new ZodStream({ debug: true // Enable debug logging }); // Define your extraction schema const schema = z.object({ content: z.string(), metadata: z.object({ confidence: z.number(), category: z.string() }) }); // Create streaming extraction const stream = await client.create({ completionPromise: async () => { const response = await fetch("/api/extract", { method: "POST", body: JSON.stringify({ prompt: "..." }) }); return response.body; }, response_model: { schema, name: "ContentExtraction" } }); // Process with validation metadata for await (const chunk of stream) { console.log({ data: chunk, // Partial extraction result isValid: chunk._meta._isValid, // Current validation state activePath: chunk._meta._activePath, // Currently processing path completedPaths: chunk._meta._completedPaths // Completed paths }); } ``` ## Progressive Processing `zod-stream` enables processing dependent data as soon as relevant paths complete, without waiting for the full response: ```typescript // Define schema for a complex analysis const schema = z.object({ user: z.object({ id: z.string(), preferences: z.object({ theme: z.string(), language: z.string() }) }), content: z.object({ title: z.string(), body: z.string(), metadata: z.object({ keywords: z.array(z.string()), category: z.string() }) }), recommendations: z.array(z.object({ id: z.string(), score: z.number(), reason: z.string() })) }); // Process data as it becomes available for await (const chunk of stream) { // Start personalizing UI as soon as user preferences are ready if (isPathComplete(['user', 'preferences'], chunk)) { applyUserTheme(chunk.user.preferences.theme); setLanguage(chunk.user.preferences.language); } // Begin content indexing once we have title and keywords if (isPathComplete(['content', 'metadata', 'keywords'], chunk) && isPathComplete(['content', 'title'], chunk)) { indexContent({ title: chunk.content.title, keywords: chunk.content.metadata.keywords }); } // Start fetching recommended content in parallel chunk._meta._completedPaths.forEach(path => { if (path[0] === 'recommendations' && path.length === 2) { const index = path[1] as number; const recommendation = chunk.recommendations[index]; if (recommendation?.id) { prefetchContent(recommendation.id); } } }); } ``` This approach enables: - Early UI updates based on user preferences - Parallel processing of independent data - Optimistic loading of related content - Better perceived performance - Resource optimization ## Stream Metadata Every streamed chunk includes metadata about validation state: ```typescript type CompletionMeta = { _isValid: boolean; // Schema validation status _activePath: (string | number)[]; // Current parsing path _completedPaths: (string | number)[][]; // All completed paths } // Example chunk { content: "partial content...", metadata: { confidence: 0.95 }, _meta: { _isValid: false, // Not valid yet _activePath: ["metadata", "category"], _completedPaths: [ ["content"], ["metadata", "confidence"] ] } } ``` ## Schema Stubs Get typed stub objects for initialization: ```typescript const schema = z.object({ users: z.array(z.object({ name: z.string(), age: z.number() })) }); const client = new ZodStream(); const stub = client.getSchemaStub({ schema, defaultData: { users: [{ name: "loading...", age: 0 }] } }); ``` ## Debug Logging Enable detailed logging for debugging: ```typescript const client = new ZodStream({ debug: true }); // Logs will include: // - Stream initialization // - Validation results // - Path completion // - Errors with full context ``` ### Using Response Models The `withResponseModel` helper configures OpenAI parameters based on your schema and chosen mode: ```typescript import { withResponseModel } from "zod-stream"; import { z } from "zod"; const schema = z.object({ sentiment: z.string(), keywords: z.array(z.string()), confidence: z.number() }); // Configure for OpenAI tools mode const params = withResponseModel({ response_model: { schema, name: "Analysis", description: "Extract sentiment and keywords" }, mode: "TOOLS", params: { messages: [{ role: "user", content: "Analyze this text..." }], model: "gpt-4" } }); const completion = await oai.chat.completions.create({ ...params, stream: true }); ``` ## Response Modes `zod-stream` supports multiple modes for structured LLM responses: ```typescript import { MODE } from "zod-stream"; const modes = { FUNCTIONS: "FUNCTIONS", // OpenAI function calling TOOLS: "TOOLS", // OpenAI tools API JSON: "JSON", // Direct JSON response MD_JSON: "MD_JSON", // JSON in markdown blocks JSON_SCHEMA: "JSON_SCHEMA", // JSON with schema validation THINKING_MD_JSON: "THINKING_MD_JSON" // JSON with thinking in markdown blocks (deepseek r1) } as const; ``` ### Mode-Specific Behaviors #### TOOLS Mode ```typescript // Results in OpenAI tool configuration { tool_choice: { type: "function", function: { name: "Analysis" } }, tools: [{ type: "function", function: { name: "Analysis", description: "Extract sentiment and keywords", parameters: {/* Generated from schema */} } }] } ``` #### FUNCTIONS Mode (Legacy) ```typescript // Results in OpenAI function configuration { function_call: { name: "Analysis" }, functions: [{ name: "Analysis", description: "Extract sentiment and keywords", parameters: {/* Generated from schema */} }] } ``` #### JSON Mode ```typescript // Results in direct JSON response configuration { response_format: { type: "json_object" }, messages: [ { role: "system", content: "Return JSON matching schema..." }, // ... user messages ] } ``` ### Response Parsing Built-in parsers handle different response formats: ```typescript import { OAIResponseParser, OAIResponseToolArgsParser, OAIResponseFnArgsParser, OAIResponseJSONParser, thinkingJsonParser } from "zod-stream"; // Automatic format detection const result = OAIResponseParser(response); // Format-specific parsing const toolArgs = OAIResponseToolArgsParser(response); const fnArgs = OAIResponseFnArgsParser(response); const jsonContent = OAIResponseJSONParser(response); const thinkingJson = thinkingJsonParser(response); ``` ### Streaming Utilities Handle streaming responses with built-in utilities: ```typescript import { OAIStream, readableStreamToAsyncGenerator } from "zod-stream"; // Create streaming response app.post("/api/stream", async (req, res) => { const completion = await oai.chat.completions.create({ ...params, stream: true }); return new Response( OAIStream({ res: completion }) ); }); // Convert stream to async generator const generator = readableStreamToAsyncGenerator(stream); for await (const chunk of generator) { console.log(chunk); } ``` ### Path Tracking Utilities Monitor completion status of specific paths: ```typescript import { isPathComplete } from "zod-stream"; const activePath = ["analysis", "sentiment"]; const isComplete = isPathComplete(activePath, { _meta: { _completedPaths: [["analysis", "sentiment"]], _activePath: ["analysis", "keywords"], _isValid: false } }); ``` ## Error Handling `zod-stream` provides error handling at multiple levels: ```typescript const stream = await client.create({ completionPromise: async () => response.body, response_model: { schema } }); let finalResult // Path tracking for progressive updates for await (const chunk of stream) { finalResult = chunk // Check which paths are complete console.log("Completed paths:", chunk._meta._completedPaths); console.log("Current path:", chunk._meta._activePath); } // Final validation happens after stream completes const isValid = finalResult._meta._isValid ``` ## Real-World Use Cases ### 1. Progressive Data Analysis ```typescript const analysisSchema = z.object({ marketData: z.object({ trends: z.array(z.object({ metric: z.string(), value: z.number() })), summary: z.string() }), competitors: z.array(z.object({ name: z.string(), strengths: z.array(z.string()), weaknesses: z.array(z.string()) })), recommendations: z.object({ immediate: z.array(z.string()), longTerm: z.array(z.string()), budget: z.number() }) }); for await (const chunk of stream) { // Start visualizing market trends immediately if (isPathComplete(['marketData', 'trends'], chunk)) { initializeCharts(chunk.marketData.trends); } // Begin competitor analysis in parallel chunk._meta._completedPaths.forEach(path => { if (path[0] === 'competitors' && path.length === 2) { const competitor = chunk.competitors[path[1] as number]; fetchCompetitorData(competitor.name); } }); // Start budget planning once we have immediate recommendations if (isPathComplete(['recommendations', 'immediate'], chunk) && isPathComplete(['recommendations', 'budget'], chunk)) { planBudgetAllocation({ actions: chunk.recommendations.immediate, budget: chunk.recommendations.budget }); } } ``` ### 2. Document Processing Pipeline ```typescript const documentSchema = z.object({ metadata: z.object({ title: z.string(), author: z.string(), topics: z.array(z.string()) }), sections: z.array(z.object({ heading: z.string(), content: z.string(), annotations: z.array(z.object({ type: z.string(), text: z.string(), confidence: z.number() })) })), summary: z.object({ abstract: z.string(), keyPoints: z.array(z.string()), readingTime: z.number() }) }); for await (const chunk of stream) { // Start document indexing as soon as metadata is available if (isPathComplete(['metadata'], chunk)) { indexDocument({ title: chunk.metadata.title, topics: chunk.metadata.topics }); } // Process sections as they complete chunk._meta._completedPaths.forEach(path => { if (path[0] === 'sections' && isPathComplete([...path, 'annotations'], chunk)) { const sectionIndex = path[1] as number; const section = chunk.sections[sectionIndex]; // Process annotations for each completed section processAnnotations({ heading: section.heading, annotations: section.annotations }); } }); // Generate preview once we have abstract and reading time if (isPathComplete(['summary', 'abstract'], chunk) && isPathComplete(['summary', 'readingTime'], chunk)) { generatePreview({ abstract: chunk.summary.abstract, readingTime: chunk.summary.readingTime }); } } ``` ### 3. E-commerce Product Enrichment ```typescript const productSchema = z.object({ basic: z.object({ id: z.string(), name: z.string(), category: z.string() }), pricing: z.object({ base: z.number(), discounts: z.array(z.object({ type: z.string(), amount: z.number() })), final: z.number() }), inventory: z.object({ status: z.string(), locations: z.array(z.object({ id: z.string(), quantity: z.number() })) }), enrichment: z.object({ seoDescription: z.string(), searchKeywords: z.array(z.string()), relatedProducts: z.array(z.string()) }) }); for await (const chunk of stream) { // Start inventory checks as soon as basic info is available if (isPathComplete(['basic'], chunk)) { initializeProductCard(chunk.basic); } // Update pricing as soon as final price is calculated if (isPathComplete(['pricing', 'final'], chunk)) { updatePriceDisplay(chunk.pricing.final); // If we also have inventory, update buy button if (isPathComplete(['inventory', 'status'], chunk)) { updateBuyButton({ price: chunk.pricing.final, status: chunk.inventory.status }); } } // Start SEO optimization in parallel if (isPathComplete(['enrichment', 'seoDescription'], chunk) && isPathComplete(['enrichment', 'searchKeywords'], chunk)) { optimizeProductSEO({ description: chunk.enrichment.seoDescription, keywords: chunk.enrichment.searchKeywords }); } // Prefetch related products as they're identified if (isPathComplete(['enrichment', 'relatedProducts'], chunk)) { prefetchRelatedProducts(chunk.enrichment.relatedProducts); } } ``` ### With Next.js API Routes ```typescript // pages/api/extract.ts import { withResponseModel, OAIStream } from "zod-stream"; import { z } from "zod"; const schema = z.object({ summary: z.string(), topics: z.array(z.string()), sentiment: z.object({ score: z.number(), label: z.string() }) }); export default async function handler(req, res) { const { content } = await req.json(); const params = withResponseModel({ response_model: { schema, name: "ContentAnalysis" }, mode: "TOOLS", params: { messages: [{ role: "user", content: `Analyze: ${content}` }], model: "gpt-4" } }); const stream = await oai.chat.completions.create({ ...params, stream: true }); return new Response(OAIStream({ res: stream })); } ``` ### With React and stream-hooks ```typescript import { useJsonStream } from "stream-hooks"; import { z } from "zod"; const schema = z.object({ summary: z.string(), topics: z.array(z.string()) }); function AnalysisComponent() { const [data, setData] = useState<z.infer<typeof schema>>(); const { loading, error, startStream } = useJsonStream({ schema, onReceive: (data) => { setData(data) } }); return ( <div> <button onClick={() => startStream({ url: "/api/extract", method: "POST", body: { content: "..." } })} disabled={loading} > Start Analysis </button> {loading && <LoadingState paths={data._meta._completedPaths} />} {error && <ErrorDisplay error={error} />} <ProgressiveDisplay data={data} isComplete={data._meta._completedPaths.length > 0} /> </div> ); } ``` ## Integration with Island AI Part of the Island AI toolkit: - [`zod-stream`](https://www.npmjs.com/package/zod-stream): Structured streaming - [`stream-hooks`](https://www.npmjs.com/package/stream-hooks): React streaming hooks - [`schema-stream`](https://www.npmjs.com/package/schema-stream): Streaming JSON parser - [`evalz`](https://www.npmjs.com/package/evalz): LLM evaluation - [`llm-polyglot`](https://www.npmjs.com/package/llm-polyglot): Universal LLM client - [`instructor`](https://www.npmjs.com/package/@instructor-ai/instructor): High-level extraction ## Contributing We welcome contributions! Check out: - [Island AI Documentation](https://island.hack.dance) - [GitHub Issues](https://github.com/hack-dance/island-ai/issues) - [Twitter](https://twitter.com/dimitrikennedy) ## License MIT © [hack.dance](https://hack.dance)