ai-functions
Version:
Core AI primitives for building intelligent applications
582 lines (501 loc) • 17.2 kB
text/typescript
/**
* Batch Map - Automatic batch detection for .map() operations
*
* When you call .map() on a list result, the individual operations
* are captured and automatically batched when resolved.
*
* @example
* ```ts
* // This automatically batches the write operations
* const titles = await list`10 blog post titles`
* const posts = titles.map(title => write`blog post: # ${title}`)
*
* // When awaited, posts are generated via batch API
* console.log(await posts) // 10 blog posts
* ```
*
* @packageDocumentation
*/
// Note: We avoid importing AIPromise here to prevent circular dependencies
// The AI promise module imports from this file for recording mode
import {
getContext,
getExecutionTier,
getProvider,
getModel,
isFlexAvailable,
type ExecutionTier,
} from './context.js'
import { getLogger } from './logger.js'
import { createBatch, getBatchAdapter, type BatchItem, type BatchResult } from './batch-queue.js'
import { generateObject, generateText } from './generate.js'
import type { SimpleSchema } from './schema.js'
// ============================================================================
// Types
// ============================================================================
/**
* Symbol to identify BatchMapPromise instances
*
* Used internally to detect BatchMapPromise objects for proper handling.
*/
export const BATCH_MAP_SYMBOL = Symbol.for('ai-batch-map')
/**
* A captured operation from the map callback
*
* When recording mode is active, AI operations are captured instead of executed.
* This allows batch processing of multiple operations in a single API call.
*/
export interface CapturedOperation {
/** Unique ID for this operation */
id: string
/** The prompt template */
prompt: string
/** The item value that will be substituted */
itemPlaceholder: string
/** Schema for structured output */
schema?: SimpleSchema
/** Generation type */
type: 'text' | 'object' | 'boolean' | 'list'
/** System prompt */
system?: string
}
/**
* Options for batch map
*
* Control how batch map operations are executed.
*/
export interface BatchMapOptions {
/** Force immediate execution (no batching) */
immediate?: boolean
/** Force batch API (even for small batches) */
deferred?: boolean
}
// ============================================================================
// BatchMapPromise
// ============================================================================
/**
* A promise that represents a batch of mapped operations.
* Resolves by either:
* - Executing via batch API (for large batches or when deferred)
* - Executing concurrently (for small batches or when immediate)
*/
export class BatchMapPromise<T> implements PromiseLike<T[]> {
readonly [BATCH_MAP_SYMBOL] = true
/** The source list items */
private _items: unknown[]
/** The captured operations (one per item) */
private _operations: CapturedOperation[][]
/** Options for batch execution */
private _options: BatchMapOptions
/** Cached resolver promise */
private _resolver: Promise<T[]> | null = null
constructor(items: unknown[], operations: CapturedOperation[][], options: BatchMapOptions = {}) {
this._items = items
this._operations = operations
this._options = options
}
/**
* Get the number of items in the batch
*/
get size(): number {
return this._items.length
}
/**
* Resolve the batch
*/
async resolve(): Promise<T[]> {
const totalOperations = this._operations.reduce((sum, ops) => sum + ops.length, 0)
// Determine execution tier
let tier: ExecutionTier
if (this._options.deferred) {
tier = 'batch'
} else if (this._options.immediate) {
tier = 'immediate'
} else {
tier = getExecutionTier(totalOperations)
}
// Execute based on tier
switch (tier) {
case 'immediate':
return this._resolveImmediately()
case 'flex':
// Use flex processing if available, otherwise fall back to immediate
if (isFlexAvailable()) {
return this._resolveViaFlex()
}
getLogger().warn(
`Flex processing not available for ${getProvider()}, using immediate execution`
)
return this._resolveImmediately()
case 'batch':
return this._resolveViaBatchAPI()
default:
return this._resolveImmediately()
}
}
/**
* Execute via flex processing (faster than batch, ~50% discount)
* Available for OpenAI and AWS Bedrock
*/
private async _resolveViaFlex(): Promise<T[]> {
const provider = getProvider()
const model = getModel()
// Try to get the flex adapter
try {
const { getFlexAdapter } = await import('./batch-queue.js')
const adapter = getFlexAdapter(provider)
// Build batch items
const batchItems: BatchItem[] = []
const itemOperationMap: Map<string, { itemIndex: number; opIndex: number }> = new Map()
for (let itemIndex = 0; itemIndex < this._items.length; itemIndex++) {
const item = this._items[itemIndex]
const operations = this._operations[itemIndex] || []
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
const op = operations[opIndex]
if (!op) continue
const id = `item_${itemIndex}_op_${opIndex}`
const prompt = op.prompt.replace(op.itemPlaceholder, String(item))
batchItems.push({
id,
prompt,
...(op.schema !== undefined && { schema: op.schema }),
options: Object.assign({ model }, op.system !== undefined ? { system: op.system } : {}),
status: 'pending',
})
itemOperationMap.set(id, { itemIndex, opIndex })
}
}
// Submit via flex adapter
const results = await adapter.submitFlex(batchItems, { model })
return this._reconstructResults(results, itemOperationMap)
} catch {
// Flex adapter not available, fall back to batch or immediate
getLogger().warn(`Flex adapter not available, falling back to batch API`)
return this._resolveViaBatchAPI()
}
}
/**
* Execute via provider batch API (deferred, 50% discount)
*/
private async _resolveViaBatchAPI(): Promise<T[]> {
const ctx = getContext()
const provider = getProvider()
const model = getModel()
// Try to get the batch adapter
let adapter
try {
adapter = getBatchAdapter(provider)
} catch {
// Adapter not registered, fall back to immediate execution
getLogger().warn(
`Batch adapter for ${provider} not available, falling back to immediate execution`
)
return this._resolveImmediately()
}
// Flatten all operations into a single batch
const batchItems: BatchItem[] = []
const itemOperationMap: Map<string, { itemIndex: number; opIndex: number }> = new Map()
for (let itemIndex = 0; itemIndex < this._items.length; itemIndex++) {
const item = this._items[itemIndex]
const operations = this._operations[itemIndex] || []
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
const op = operations[opIndex]
if (!op) continue
const id = `item_${itemIndex}_op_${opIndex}`
// Substitute the actual item value into the prompt
const prompt = op.prompt.replace(op.itemPlaceholder, String(item))
batchItems.push({
id,
prompt,
...(op.schema !== undefined && { schema: op.schema }),
options: Object.assign({ model }, op.system !== undefined ? { system: op.system } : {}),
status: 'pending',
})
itemOperationMap.set(id, { itemIndex, opIndex })
}
}
// Submit batch
const batch = createBatch({
provider,
model,
...(ctx.webhookUrl !== undefined && { webhookUrl: ctx.webhookUrl }),
...(ctx.metadata !== undefined && { metadata: ctx.metadata }),
})
for (const item of batchItems) {
batch.add(item.prompt, {
...(item.schema !== undefined && { schema: item.schema }),
...(item.options !== undefined && { options: item.options }),
...(item.id !== undefined && { customId: item.id }),
})
}
const { completion } = await batch.submit()
const results = await completion
// Reconstruct the results array
return this._reconstructResults(results, itemOperationMap)
}
/**
* Execute immediately (concurrent requests)
*/
private async _resolveImmediately(): Promise<T[]> {
const model = getModel()
const results: T[] = []
// Process each item
for (let itemIndex = 0; itemIndex < this._items.length; itemIndex++) {
const item = this._items[itemIndex]
const operations = this._operations[itemIndex] || []
// If there's only one operation per item, resolve it directly
if (operations.length === 1) {
const op = operations[0]
if (op) {
const prompt = op.prompt.replace(op.itemPlaceholder, String(item))
const result = await this._executeOperation(op, prompt, model)
results.push(result as T)
}
} else if (operations.length > 0) {
// Multiple operations per item - resolve as object
const opResults: Record<string, unknown> = {}
await Promise.all(
operations.map(async (op, opIndex) => {
if (!op) return
const prompt = op.prompt.replace(op.itemPlaceholder, String(item))
opResults[`op_${opIndex}`] = await this._executeOperation(op, prompt, model)
})
)
results.push(opResults as T)
}
}
return results
}
/**
* Execute a single operation
*/
private async _executeOperation(
op: CapturedOperation,
prompt: string,
model: string
): Promise<unknown> {
switch (op.type) {
case 'text':
const textResult = await generateText({
model,
prompt,
...(op.system !== undefined && { system: op.system }),
})
return textResult.text
case 'boolean':
const boolResult = await generateObject({
model,
schema: { answer: 'true | false' },
prompt,
system: op.system || 'Answer with true or false.',
})
return (boolResult.object as { answer: string }).answer === 'true'
case 'list':
const listResult = await generateObject({
model,
schema: { items: ['List items'] },
prompt,
...(op.system !== undefined && { system: op.system }),
})
return (listResult.object as { items: string[] }).items
case 'object':
default:
const objResult = await generateObject({
model,
schema: op.schema || { result: 'The result' },
prompt,
...(op.system !== undefined && { system: op.system }),
})
return objResult.object
}
}
/**
* Reconstruct results from batch response
*/
private _reconstructResults(
batchResults: BatchResult[],
itemOperationMap: Map<string, { itemIndex: number; opIndex: number }>
): T[] {
const results: (T | Record<string, unknown>)[] = new Array(this._items.length)
// Initialize results array
for (let i = 0; i < this._items.length; i++) {
const operations = this._operations[i] || []
if (operations.length === 1) {
results[i] = undefined as T
} else {
results[i] = {}
}
}
// Fill in results
for (const batchResult of batchResults) {
const mapping = itemOperationMap.get(batchResult.id)
if (!mapping) continue
const { itemIndex, opIndex } = mapping
const operations = this._operations[itemIndex] || []
if (operations.length === 1) {
results[itemIndex] = batchResult.result as T
} else {
;(results[itemIndex] as Record<string, unknown>)[`op_${opIndex}`] = batchResult.result
}
}
return results as 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> {
if (!this._resolver) {
this._resolver = this.resolve()
}
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
}
)
}
}
// ============================================================================
// Recording Context
// ============================================================================
/** Current item value being recorded */
let currentRecordingItem: unknown = null
/** Current item placeholder string */
let currentItemPlaceholder: string = ''
/** Captured operations during recording */
let capturedOperations: CapturedOperation[] = []
/** Recording mode flag */
let isRecording = false
/** Operation counter for unique IDs */
let operationCounter = 0
/**
* Check if we're in recording mode
*
* Recording mode is active during batch map callback execution.
* When true, AI operations are captured instead of executed.
*
* @returns true if currently recording operations for batch processing
*/
export function isInRecordingMode(): boolean {
return isRecording
}
/**
* Get the current item placeholder for template substitution
*
* During recording mode, this returns a placeholder string that will be
* replaced with the actual item value when the batch is executed.
*
* @returns The placeholder string if in recording mode, null otherwise
*/
export function getCurrentItemPlaceholder(): string | null {
return isRecording ? currentItemPlaceholder : null
}
/**
* Capture an operation during recording
*
* Called by AI template functions when in recording mode to capture
* operations for later batch execution.
*
* @param prompt - The prompt template
* @param type - The operation type (text, object, boolean, list)
* @param schema - Optional schema for structured output
* @param system - Optional system prompt
*/
export function captureOperation(
prompt: string,
type: CapturedOperation['type'],
schema?: SimpleSchema,
system?: string
): void {
if (!isRecording) return
capturedOperations.push({
id: `op_${++operationCounter}`,
prompt,
itemPlaceholder: currentItemPlaceholder,
...(schema !== undefined && { schema }),
type,
...(system !== undefined && { system }),
})
}
// ============================================================================
// Batch Map Factory
// ============================================================================
/**
* Create a batch map from an array and a callback
*
* This is called internally by AIPromise.map() to enable automatic
* batch processing of mapped operations.
*
* @typeParam T - The type of items in the source array
* @typeParam U - The return type of the callback
* @param items - Array of items to map over
* @param callback - Function called for each item (operations are captured, not executed)
* @param options - Batch map options
* @returns A BatchMapPromise that resolves to an array of results
*/
export function createBatchMap<T, U>(
items: T[],
callback: (item: T, index: number) => U,
options: BatchMapOptions = {}
): BatchMapPromise<U> {
const allOperations: CapturedOperation[][] = []
for (let i = 0; i < items.length; i++) {
const item = items[i] as T
// Enter recording mode
isRecording = true
currentRecordingItem = item
currentItemPlaceholder = `__BATCH_ITEM_${i}__`
capturedOperations = []
try {
// Execute the callback to capture operations
callback(item, i)
// Operations should have been captured via captureOperation()
allOperations.push([...capturedOperations])
} finally {
// Exit recording mode
isRecording = false
currentRecordingItem = null
currentItemPlaceholder = ''
capturedOperations = []
}
}
return new BatchMapPromise<U>(items, allOperations, options)
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Check if a value is a BatchMapPromise
*
* @param value - Value to check
* @returns true if value is a BatchMapPromise instance
*/
export function isBatchMapPromise(value: unknown): value is BatchMapPromise<unknown> {
return (
value !== null &&
typeof value === 'object' &&
BATCH_MAP_SYMBOL in value &&
(value as Record<symbol, unknown>)[BATCH_MAP_SYMBOL] === true
)
}