UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

1,227 lines (1,082 loc) 38.4 kB
/** * AIPromise - Promise pipelining for AI functions * * This enables: * - Property access tracking for dynamic schema inference * - Promise pipelining without await * - Magical .map() for batch processing * - Dependency graph resolution * * @example * ```ts * // Dynamic schema from destructuring * const { summary, keyPoints, conclusion } = ai`write about ${topic}` * * // Pipeline without await * const isValid = is`${conclusion} is solid given ${keyPoints}` * * // Batch process with map * const ideas = list`startup ideas` * const evaluated = await ideas.map(idea => ({ * idea, * viable: is`${idea} is viable`, * market: ai`market size for ${idea}`, * })) * * // Only await at the end * if (await isValid) { ... } * ``` * * @packageDocumentation */ import { generateObject, streamObject, streamText } from './generate.js' import type { SimpleSchema } from './schema.js' import type { FunctionOptions } from './template.js' import { isInRecordingMode, getCurrentItemPlaceholder, captureOperation, createBatchMap, BatchMapPromise, } from './batch-map.js' import { getModel } from './context.js' // ============================================================================ // Streaming Types // ============================================================================ /** * Options for streaming */ export interface StreamOptions { /** Abort signal for cancellation */ abortSignal?: AbortSignal } /** * Streaming result wrapper that provides both AsyncIterable interface * and access to the final result */ export interface StreamingAIPromise<T> extends AsyncIterable<T extends string ? string : Partial<T>> { /** Stream of text chunks (for text generation) */ textStream: AsyncIterable<string> /** Stream of partial objects (for object generation) */ partialObjectStream: AsyncIterable<Partial<T>> /** Promise that resolves to the final complete result */ result: Promise<T> /** Promise interface - then() */ then<TResult1 = T, TResult2 = never>( onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null ): Promise<TResult1 | TResult2> } // ============================================================================ // Types // ============================================================================ /** Symbol to identify AIPromise instances */ export const AI_PROMISE_SYMBOL = Symbol.for('ai-promise') /** Symbol to get the raw AIPromise from a proxy */ export const RAW_PROMISE_SYMBOL = Symbol.for('ai-promise-raw') /** Recording mode for map() */ export const RECORDING_MODE = Symbol.for('ai-promise-recording') /** Dependency tracking */ interface Dependency { promise: AIPromise<unknown> path: string[] } /** Map callback recording */ interface MapRecording { operations: Array<{ type: 'ai' | 'is' | 'list' | 'lists' | 'extract' | 'other' prompt: string dependencies: Dependency[] }> capturedStubs: AIPromise<unknown>[] } /** Options for AIPromise creation */ export interface AIPromiseOptions extends FunctionOptions { /** The type of generation */ type?: 'text' | 'object' | 'list' | 'lists' | 'boolean' | 'extract' /** Base schema (can be extended by property access) */ baseSchema?: SimpleSchema /** Parent promise this was derived from */ parent?: AIPromise<unknown> /** Property path from parent */ propertyPath?: string[] } // ============================================================================ // Global State // ============================================================================ /** Current recording context for map() */ let currentRecording: MapRecording | null = null /** Pending promises for batch resolution */ const pendingPromises = new Set<AIPromise<unknown>>() /** Promise resolution queue */ let resolutionScheduled = false // ============================================================================ // AIPromise Implementation // ============================================================================ /** * AIPromise - Promise wrapper for AI functions * * Acts as both a Promise AND a stub that: * - Tracks property accesses for dynamic schema inference * - Records dependencies for promise pipelining * - Supports .map() for batch processing */ export class AIPromise<T> implements PromiseLike<T> { /** Marker to identify AIPromise instances */ readonly [AI_PROMISE_SYMBOL] = true /** The prompt that will generate this value */ private _prompt: string /** Options for generation */ private _options: AIPromiseOptions /** Properties accessed on this promise (for schema inference) */ private _accessedProps = new Set<string>() /** Property path from parent (for nested access) */ private _propertyPath: string[] /** Parent promise (if this is a property access) */ private _parent: AIPromise<unknown> | null /** Dependencies (other AIPromises used in our prompt) */ private _dependencies: Dependency[] = [] /** Cached resolver promise */ private _resolver: Promise<T> | null = null /** Resolved value (cached after first resolution) */ private _resolvedValue: T | undefined /** Whether this promise has been resolved */ private _isResolved = false /** Whether we're in recording mode */ private _isRecording = false constructor(prompt: string, options: AIPromiseOptions = {}) { this._prompt = prompt this._options = options this._propertyPath = options.propertyPath || [] this._parent = options.parent || null // Track this promise for batch resolution pendingPromises.add(this) // Return a proxy that intercepts property access return new Proxy(this, PROXY_HANDLERS) as AIPromise<T> } /** Get the prompt */ get prompt(): string { return this._prompt } /** Get the property path */ get path(): string[] { return this._propertyPath } /** Check if resolved */ get isResolved(): boolean { return this._isResolved } /** Get accessed properties */ get accessedProps(): Set<string> { return this._accessedProps } /** * Add a dependency (another AIPromise used in this one's prompt) */ addDependency(promise: AIPromise<unknown>, path: string[] = []): void { this._dependencies.push({ promise, path }) } /** * Resolve this promise */ async resolve(): Promise<T> { if (this._isResolved) { return this._resolvedValue as T } // If this is a property access on a parent, resolve the parent first if (this._parent) { const parentValue = await this._parent.resolve() const value = getNestedValue(parentValue, this._propertyPath) this._resolvedValue = value as T this._isResolved = true return this._resolvedValue } // Resolve dependencies first const resolvedDeps: Record<string, unknown> = {} for (const dep of this._dependencies) { const value = await dep.promise.resolve() const key = dep.path.length > 0 ? dep.path.join('.') : `dep_${this._dependencies.indexOf(dep)}` resolvedDeps[key] = value } // Substitute resolved dependencies into prompt let finalPrompt = this._prompt for (const [key, value] of Object.entries(resolvedDeps)) { finalPrompt = finalPrompt.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), String(value)) } // Build schema from accessed properties const schema = this._buildSchema() // Generate the result const result = await generateObject({ model: this._options.model || 'sonnet', schema, prompt: finalPrompt, ...(this._options.system !== undefined && { system: this._options.system }), ...(this._options.temperature !== undefined && { temperature: this._options.temperature }), ...(this._options.maxTokens !== undefined && { maxTokens: this._options.maxTokens }), }) // Extract the value based on type // Type assertions here are safe because: // 1. Runtime type checking validates the response structure // 2. The type parameter T corresponds to the expected output type for each mode let value = result.object as T if ( this._options.type === 'text' && typeof value === 'object' && value !== null && 'text' in value ) { value = (value as { text: T }).text } else if ( this._options.type === 'boolean' && typeof value === 'object' && value !== null && 'answer' in value ) { const answer = (value as { answer: string | boolean }).answer // When type === 'boolean', T is constrained to boolean at the call site. // TypeScript can't express this dependent relationship, so we use a simple cast. // Runtime validation: answer is verified to be 'true', 'false', or boolean. const booleanValue = answer === 'true' || answer === true value = booleanValue as T } else if ( (this._options.type === 'list' || this._options.type === 'extract') && typeof value === 'object' && value !== null && 'items' in value ) { value = (value as { items: T }).items } this._resolvedValue = value this._isResolved = true pendingPromises.delete(this) return this._resolvedValue } /** * Build schema from accessed properties and base schema */ private _buildSchema(): SimpleSchema { const baseSchema = this._options.baseSchema || {} // If no properties accessed, use base schema or infer from type if (this._accessedProps.size === 0) { if (typeof baseSchema === 'object' && Object.keys(baseSchema).length > 0) { return baseSchema } // Infer from type switch (this._options.type) { case 'list': return { items: ['List items'] } case 'extract': return { items: [ 'Array of extracted items as strings - extract ALL matching items from the text', ], } case 'lists': return { categories: ['Category names'], data: 'JSON object with categorized lists' } case 'boolean': return { answer: 'true | false' } case 'text': return { text: 'The generated text' } default: return { result: 'The result' } } } // Build schema from accessed properties const schema: { [key: string]: SimpleSchema } = {} for (const prop of this._accessedProps) { // Check if base schema has this property if (typeof baseSchema === 'object' && !Array.isArray(baseSchema) && prop in baseSchema) { const propSchema = (baseSchema as { [key: string]: SimpleSchema })[prop] if (propSchema !== undefined) { schema[prop] = propSchema continue } } // Infer type from property name patterns const lowerProp = prop.toLowerCase() if ( lowerProp.endsWith('s') || lowerProp.includes('list') || lowerProp.includes('items') || lowerProp.includes('array') ) { schema[prop] = [`List of ${prop}`] } else if ( lowerProp.includes('is') || lowerProp.includes('has') || lowerProp.includes('can') || lowerProp.includes('should') ) { schema[prop] = `Whether ${prop} (true/false)` } else if ( lowerProp.includes('count') || lowerProp.includes('number') || lowerProp.includes('total') || lowerProp.includes('amount') ) { schema[prop] = `The ${prop} (number)` } else { schema[prop] = `The ${prop}` } } return schema } /** * Map over array results - automatically batches operations! * * When you map over a list, the operations are captured and * automatically batched when resolved. Uses provider batch APIs * for cost savings (50% discount) when beneficial. * * @example * ```ts * // Simple map - each title becomes a blog post * const titles = await list`10 blog post titles` * const posts = titles.map(title => write`blog post: # ${title}`) * console.log(await posts) // 10 blog posts via batch API * * // Complex map - multiple operations per item * const ideas = await list`startup ideas` * const evaluated = await ideas.map(idea => ({ * idea, * viable: is`${idea} is viable`, * market: ai`market size for ${idea}`, * })) * ``` */ map<U>(callback: (item: T extends (infer I)[] ? I : T, index: number) => U): BatchMapPromise<U> { // Create a wrapper that resolves this promise first, then maps const mapPromise = new BatchMapPromise<U>([], [], {}) // Override the resolve to first get the list items // Type assertion: BatchMapPromise.resolve is a public method that we're replacing // with a compatible async function returning Promise<U[]> const self = this Object.defineProperty(mapPromise, 'resolve', { value: async function (): Promise<U[]> { // First, resolve the list const items = await self.resolve() if (!Array.isArray(items)) { throw new Error('Cannot map over non-array result') } // Now create the actual batch map with the resolved items const actualBatchMap = createBatchMap(items as (T extends (infer I)[] ? I : T)[], callback) return actualBatchMap.resolve() }, writable: true, configurable: true, }) return mapPromise } /** * Map with explicit batch options * * @example * ```ts * // Force immediate execution (no batch API) * const posts = titles.mapImmediate(title => write`blog post: ${title}`) * * // Force batch API (even for small lists) * const posts = titles.mapDeferred(title => write`blog post: ${title}`) * ``` */ mapImmediate<U>( callback: (item: T extends (infer I)[] ? I : T, index: number) => U ): BatchMapPromise<U> { const mapPromise = new BatchMapPromise<U>([], [], { immediate: true }) const self = this Object.defineProperty(mapPromise, 'resolve', { value: async function (): Promise<U[]> { const items = await self.resolve() if (!Array.isArray(items)) { throw new Error('Cannot map over non-array result') } const actualBatchMap = createBatchMap( items as (T extends (infer I)[] ? I : T)[], callback, { immediate: true } ) return actualBatchMap.resolve() }, writable: true, configurable: true, }) return mapPromise } mapDeferred<U>( callback: (item: T extends (infer I)[] ? I : T, index: number) => U ): BatchMapPromise<U> { const mapPromise = new BatchMapPromise<U>([], [], { deferred: true }) const self = this Object.defineProperty(mapPromise, 'resolve', { value: async function (): Promise<U[]> { const items = await self.resolve() if (!Array.isArray(items)) { throw new Error('Cannot map over non-array result') } const actualBatchMap = createBatchMap( items as (T extends (infer I)[] ? I : T)[], callback, { deferred: true } ) return actualBatchMap.resolve() }, writable: true, configurable: true, }) return mapPromise } /** * ForEach with automatic batching * * @example * ```ts * await list`startup ideas`.forEach(async idea => { * console.log(await is`${idea} is viable`) * }) * ``` */ async forEach( callback: (item: T extends (infer I)[] ? I : T, index: number) => void | Promise<void> ): Promise<void> { const items = await this.resolve() if (Array.isArray(items)) { for (let i = 0; i < items.length; i++) { await callback(items[i], i) } } else { // When T is not an array, the conditional type T extends (infer I)[] ? I : T resolves to T await callback(items as T extends (infer I)[] ? I : T, 0) } } /** * Async iterator support with smart batching */ async *[Symbol.asyncIterator](): AsyncIterator<T extends (infer I)[] ? I : T> { const items = await this.resolve() if (Array.isArray(items)) { for (const item of items) { // Each array item is the inferred element type I when T extends I[] yield item as T extends (infer I)[] ? I : T } } else { // When T is not an array, the item type is T itself yield items as T extends (infer I)[] ? I : T } } /** * Stream the AI generation - returns chunks as they arrive * * For text generation, yields string chunks. * For object generation, yields partial objects as they build up. * For list generation, yields items as they're generated. * * @example * ```ts * // Text streaming * const stream = write`Write a story`.stream() * for await (const chunk of stream.textStream) { * process.stdout.write(chunk) * } * * // Object streaming with partial updates * const stream = ai`Generate a recipe`.stream() * for await (const partial of stream.partialObjectStream) { * console.log('Building:', partial) * } * * // Get final result after streaming * const finalResult = await stream.result * ``` */ stream(options?: StreamOptions): StreamingAIPromise<T> { return createStreamingAIPromise(this, options) } /** * Promise interface - then() */ then<TResult1 = T, TResult2 = never>( onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null ): Promise<TResult1 | TResult2> { if (!this._resolver) { // Schedule batch resolution on next microtask this._resolver = new Promise<T>((resolve, reject) => { queueMicrotask(async () => { try { const value = await this.resolve() resolve(value) } catch (error) { reject(error) } }) }) } return this._resolver.then(onfulfilled, onrejected) } /** * Promise interface - catch() */ catch<TResult = never>( onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null ): Promise<T | TResult> { return this.then(null, onrejected) } /** * Promise interface - finally() */ finally(onfinally?: (() => void) | null): Promise<T> { return this.then( (value) => { onfinally?.() return value }, (reason) => { onfinally?.() throw reason } ) } } // ============================================================================ // Proxy Handlers // ============================================================================ const PROXY_HANDLERS: ProxyHandler<AIPromise<unknown>> = { get(target, prop: string | symbol, _receiver) { // Handle symbols if (typeof prop === 'symbol') { if (prop === AI_PROMISE_SYMBOL) return true if (prop === RAW_PROMISE_SYMBOL) return target if (prop === Symbol.asyncIterator) return target[Symbol.asyncIterator].bind(target) return (target as unknown as Record<symbol, unknown>)[prop] } // Handle promise methods if (prop === 'then' || prop === 'catch' || prop === 'finally') { const method = (target as unknown as Record<string, (...args: unknown[]) => unknown>)[prop] return method?.bind(target) } // Handle AIPromise methods if ( prop === 'map' || prop === 'forEach' || prop === 'resolve' || prop === 'stream' || prop === 'addDependency' || prop === 'mapImmediate' || prop === 'mapDeferred' ) { const method = (target as unknown as Record<string, (...args: unknown[]) => unknown>)[prop] return method?.bind(target) } // Handle internal properties if ( prop.startsWith('_') || prop === 'prompt' || prop === 'path' || prop === 'isResolved' || prop === 'accessedProps' ) { return (target as unknown as Record<string, unknown>)[prop] } // Track property access for schema inference target.accessedProps.add(prop) // If we're in recording mode, record this access if (currentRecording) { // Just track the access, don't create new promise } // Return a new AIPromise for the property path return new AIPromise<unknown>(target.prompt, { ...target['_options'], parent: target, propertyPath: [...target.path, prop], }) }, // Prevent mutation set() { throw new Error('AIPromise properties are read-only') }, deleteProperty() { throw new Error('AIPromise properties cannot be deleted') }, // Handle function calls (for chained methods) apply(target, _thisArg, args) { // If the target is callable (e.g., from a template function), call it const call = (target as unknown as Record<string, unknown>)['_call'] if (typeof call === 'function') { return (call as (...args: unknown[]) => unknown)(...args) } throw new Error('AIPromise is not callable') }, } // ============================================================================ // Helper Functions // ============================================================================ /** * Get a nested value from an object by path */ function getNestedValue(obj: unknown, path: string[]): unknown { let current = obj for (const key of path) { if (current === null || current === undefined) return undefined if (key === '__item__') continue // Skip internal markers current = (current as Record<string, unknown>)[key] } return current } /** * Analyze the result of a map callback to build batch schema */ function analyzeRecordingResult(result: unknown, recording: MapRecording): SimpleSchema { if (result === null || result === undefined) { return { result: 'The result' } } if (typeof result !== 'object') { return { result: 'The result' } } // Build schema from the result structure const schema: Record<string, SimpleSchema> = {} for (const [key, value] of Object.entries(result)) { if (isAIPromise(value)) { // This is a reference to an AI operation const aiPromise = getRawPromise(value as AIPromise<unknown>) // Infer schema from the promise's accessed properties or type if (aiPromise.accessedProps.size > 0) { schema[key] = Object.fromEntries( Array.from(aiPromise.accessedProps).map((p) => [p, `The ${p}`]) ) } else { // Access private _options through type-safe assertion const options = (aiPromise as unknown as { _options: AIPromiseOptions })._options const type = options?.type if (type === 'boolean') { schema[key] = 'true | false' } else if (type === 'list') { schema[key] = ['List items'] } else { schema[key] = `The ${key}` } } } else if (typeof value === 'object' && value !== null) { // Recursively analyze nested objects schema[key] = analyzeRecordingResult(value, recording) as SimpleSchema } else { // Literal value - include as-is schema[key] = `Value: ${JSON.stringify(value)}` } } return schema } /** * Check if a value is an AIPromise */ export function isAIPromise(value: unknown): value is AIPromise<unknown> { return ( value !== null && typeof value === 'object' && AI_PROMISE_SYMBOL in value && (value as Record<symbol, unknown>)[AI_PROMISE_SYMBOL] === true ) } /** * Get the raw AIPromise from a proxied value */ export function getRawPromise<T>(value: AIPromise<T>): AIPromise<T> { const raw = (value as unknown as Record<symbol, AIPromise<T> | undefined>)[RAW_PROMISE_SYMBOL] return raw ?? value } // ============================================================================ // Factory Functions // ============================================================================ /** * Create an AIPromise for text generation */ export function createTextPromise(prompt: string, options?: FunctionOptions): AIPromise<string> { return new AIPromise<string>(prompt, { ...options, type: 'text' }) } /** * Create an AIPromise for object generation with dynamic schema */ export function createObjectPromise<T = unknown>( prompt: string, options?: FunctionOptions ): AIPromise<T> { return new AIPromise<T>(prompt, { ...options, type: 'object' }) } /** * Create an AIPromise for list generation */ export function createListPromise(prompt: string, options?: FunctionOptions): AIPromise<string[]> { return new AIPromise<string[]>(prompt, { ...options, type: 'list' }) } /** * Create an AIPromise for multiple lists generation */ export function createListsPromise( prompt: string, options?: FunctionOptions ): AIPromise<Record<string, string[]>> { return new AIPromise<Record<string, string[]>>(prompt, { ...options, type: 'lists' }) } /** * Create an AIPromise for boolean/is check */ export function createBooleanPromise( prompt: string, options?: FunctionOptions ): AIPromise<boolean> { return new AIPromise<boolean>(prompt, { ...options, type: 'boolean' }) } /** * Create an AIPromise for extraction */ export function createExtractPromise<T = unknown>( prompt: string, options?: FunctionOptions ): AIPromise<T[]> { return new AIPromise<T[]>(prompt, { ...options, type: 'extract' }) } // ============================================================================ // Template Tag Helpers // ============================================================================ /** * Parse template literals and track AIPromise dependencies */ export function parseTemplateWithDependencies( strings: TemplateStringsArray, ...values: unknown[] ): { prompt: string; dependencies: Dependency[] } { const dependencies: Dependency[] = [] let prompt = '' for (let i = 0; i < strings.length; i++) { prompt += strings[i] if (i < values.length) { const value = values[i] if (isAIPromise(value)) { // Track as dependency const rawPromise = getRawPromise(value) const depKey = `dep_${dependencies.length}` dependencies.push({ promise: rawPromise, path: rawPromise.path }) prompt += `\${${depKey}}` } else { // Inline the value prompt += String(value) } } } return { prompt, dependencies } } /** * Create a template function that returns AIPromise */ export function createAITemplateFunction<T>( type: AIPromiseOptions['type'], baseOptions?: FunctionOptions ): ((strings: TemplateStringsArray, ...values: unknown[]) => AIPromise<T>) & ((prompt: string, options?: FunctionOptions) => AIPromise<T>) { function templateFn( promptOrStrings: string | TemplateStringsArray, ...args: unknown[] ): AIPromise<T> { let prompt: string let dependencies: Dependency[] = [] let options: FunctionOptions & { baseSchema?: SimpleSchema } = { ...baseOptions } if (Array.isArray(promptOrStrings) && 'raw' in promptOrStrings) { // Tagged template literal const parsed = parseTemplateWithDependencies(promptOrStrings, ...args) prompt = parsed.prompt dependencies = parsed.dependencies } else { // Regular function call prompt = promptOrStrings as string if (args.length > 0 && typeof args[0] === 'object') { options = { ...options, ...(args[0] as FunctionOptions) } } } // If we're in recording mode (inside a .map() callback), capture this operation if (isInRecordingMode()) { const batchType = type === 'text' ? 'text' : type === 'boolean' ? 'boolean' : type === 'list' ? 'list' : 'object' captureOperation(prompt, batchType, options.baseSchema, options.system) } const promise = new AIPromise<T>(prompt, { ...options, ...(type !== undefined && { type }), }) // Add dependencies for (const dep of dependencies) { promise.addDependency(dep.promise, dep.path) } return promise } // Return type matches the declared intersection type return templateFn as ((strings: TemplateStringsArray, ...values: unknown[]) => AIPromise<T>) & ((prompt: string, options?: FunctionOptions) => AIPromise<T>) } // ============================================================================ // Streaming Implementation // ============================================================================ /** * Create a streaming wrapper for an AIPromise * * This function creates a StreamingAIPromise that: * - Resolves dependencies before streaming * - Streams text or partial objects based on the promise type * - Collects the final result as stream is consumed * - Supports cancellation via AbortSignal */ function createStreamingAIPromise<T>( promise: AIPromise<T>, options?: StreamOptions ): StreamingAIPromise<T> { const rawPromise = getRawPromise(promise) const promiseOptions = (rawPromise as unknown as { _options: AIPromiseOptions })._options const dependencies = (rawPromise as unknown as { _dependencies: Dependency[] })._dependencies // Result promise state let resultResolve: (value: T) => void let resultReject: (error: unknown) => void const resultPromise = new Promise<T>((resolve, reject) => { resultResolve = resolve resultReject = reject }) // Shared state to prevent multiple API calls let streamStarted = false let cachedTextChunks: string[] | null = null let cachedPartialObjects: Partial<T>[] | null = null let streamError: unknown = null let finalValue: T | undefined // Resolve dependencies and prepare the final prompt const preparePrompt = async (): Promise<string> => { const resolvedDeps: Record<string, unknown> = {} for (const dep of dependencies) { const value = await dep.promise.resolve() const key = dep.path.length > 0 ? dep.path.join('.') : `dep_${dependencies.indexOf(dep)}` resolvedDeps[key] = value } let finalPrompt = rawPromise.prompt for (const [key, value] of Object.entries(resolvedDeps)) { finalPrompt = finalPrompt.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), String(value)) } return finalPrompt } // Build schema from accessed properties const buildSchema = (): SimpleSchema => { return (rawPromise as unknown as { _buildSchema: () => SimpleSchema })._buildSchema() } // Extract value based on type (same logic as resolve()) // Type assertions here are safe because: // 1. Runtime type checking validates the response structure // 2. The type parameter T corresponds to the expected output type for each mode const extractFinalValue = (obj: unknown): T => { let value = obj as T if ( promiseOptions.type === 'text' && typeof value === 'object' && value !== null && 'text' in value ) { value = (value as { text: T }).text } else if ( promiseOptions.type === 'boolean' && typeof value === 'object' && value !== null && 'answer' in value ) { const answer = (value as { answer: string | boolean }).answer // When type === 'boolean', T is constrained to boolean at the call site. // TypeScript can't express this dependent relationship, so we use a simple cast. // Runtime validation: answer is verified to be 'true', 'false', or boolean. const booleanValue = answer === 'true' || answer === true value = booleanValue as T } else if ( (promiseOptions.type === 'list' || promiseOptions.type === 'extract') && typeof value === 'object' && value !== null && 'items' in value ) { value = (value as { items: T }).items } return value } // Create text stream that collects chunks for result async function* createTextStream(): AsyncGenerator<string> { if (cachedTextChunks !== null) { // Return cached chunks if we already streamed for (const chunk of cachedTextChunks) { yield chunk } return } if (streamStarted && streamError) { throw streamError } streamStarted = true cachedTextChunks = [] try { const finalPrompt = await preparePrompt() const result = await streamText({ model: promiseOptions.model || 'sonnet', prompt: finalPrompt, ...(promiseOptions.system !== undefined && { system: promiseOptions.system }), ...(promiseOptions.temperature !== undefined && { temperature: promiseOptions.temperature, }), ...(promiseOptions.maxTokens !== undefined && { maxTokens: promiseOptions.maxTokens }), ...(options?.abortSignal !== undefined && { abortSignal: options.abortSignal }), }) let fullText = '' for await (const chunk of result.textStream) { cachedTextChunks!.push(chunk) fullText += chunk yield chunk } finalValue = fullText as T resultResolve!(finalValue) } catch (error) { streamError = error resultReject!(error) throw error } } // Create partial object stream that collects objects for result async function* createPartialObjectStream(): AsyncGenerator<Partial<T>> { if (cachedPartialObjects !== null) { // Return cached partials if we already streamed for (const partial of cachedPartialObjects) { yield partial } return } if (streamStarted && streamError) { throw streamError } streamStarted = true cachedPartialObjects = [] try { const finalPrompt = await preparePrompt() const schema = buildSchema() const result = await streamObject({ model: promiseOptions.model || 'sonnet', schema, prompt: finalPrompt, ...(promiseOptions.system !== undefined && { system: promiseOptions.system }), ...(promiseOptions.temperature !== undefined && { temperature: promiseOptions.temperature, }), ...(promiseOptions.maxTokens !== undefined && { maxTokens: promiseOptions.maxTokens }), ...(options?.abortSignal !== undefined && { abortSignal: options.abortSignal }), }) let lastPartial: Partial<T> = {} as Partial<T> for await (const partial of result.partialObjectStream) { cachedPartialObjects!.push(partial as Partial<T>) lastPartial = partial as Partial<T> yield partial as Partial<T> } finalValue = extractFinalValue(lastPartial) resultResolve!(finalValue) } catch (error) { streamError = error resultReject!(error) throw error } } // Create main stream based on type async function* createMainStream(): AsyncGenerator<T extends string ? string : Partial<T>> { if (promiseOptions.type === 'text') { for await (const chunk of createTextStream()) { // When type is 'text', T is string, so the conditional type resolves to string yield chunk as T extends string ? string : Partial<T> } } else if (promiseOptions.type === 'list') { // For lists, yield new items as they appear let lastLength = 0 for await (const partial of createPartialObjectStream()) { const items = (partial as { items?: string[] }).items || [] for (let i = lastLength; i < items.length; i++) { // List items are strings, cast to the conditional return type yield items[i] as T extends string ? string : Partial<T> } lastLength = items.length } } else { for await (const partial of createPartialObjectStream()) { // For object types, T is not string, so conditional type resolves to Partial<T> yield partial as T extends string ? string : Partial<T> } } } // Start the stream collection in background if result is awaited const ensureStreamStarted = (): void => { if (!streamStarted) { // Start consuming the appropriate stream to populate result if (promiseOptions.type === 'text') { ;(async () => { try { for await (const _ of createTextStream()) { // consume } } catch { // Error already handled in stream } })() } else { ;(async () => { try { for await (const _ of createPartialObjectStream()) { // consume } } catch { // Error already handled in stream } })() } } } // Create a lazy result promise that starts streaming when accessed const lazyResult: Promise<T> & { _started?: boolean } = Object.assign({ then<TResult1 = T, TResult2 = never>( onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null ): Promise<TResult1 | TResult2> { ensureStreamStarted() return resultPromise.then(onfulfilled, onrejected) }, catch<TResult = never>( onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null ): Promise<T | TResult> { ensureStreamStarted() return resultPromise.catch(onrejected) }, finally(onfinally?: (() => void) | null): Promise<T> { ensureStreamStarted() return resultPromise.finally(onfinally) }, [Symbol.toStringTag]: 'Promise' as const, }) as Promise<T> & { _started?: boolean } // Create the streaming object const streamingPromise: StreamingAIPromise<T> = { textStream: { [Symbol.asyncIterator]: createTextStream, }, partialObjectStream: { [Symbol.asyncIterator]: createPartialObjectStream, }, result: lazyResult, [Symbol.asyncIterator]: createMainStream, then<TResult1 = T, TResult2 = never>( onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null ): Promise<TResult1 | TResult2> { // If result is awaited before stream consumption, start the stream ensureStreamStarted() return resultPromise.then(onfulfilled, onrejected) }, } return streamingPromise }