diff --git a/typescript/effect-ai/run-examples.ts b/typescript/effect-ai/run-examples.ts index 67429a3..3a1e843 100755 --- a/typescript/effect-ai/run-examples.ts +++ b/typescript/effect-ai/run-examples.ts @@ -1,57 +1,322 @@ #!/usr/bin/env bun + +/** + * Run all Effect-AI examples in parallel using Effect + * + * This script demonstrates Effect patterns for: + * - Parallel execution with concurrency control + * - Structured error handling + * - Resource management (file system, processes) + * - Type-safe results tracking + */ + +// TODO: use @effect/platform instead of node.js APIs + +import { Effect, Console, Exit } from "effect"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; + +// ============================================================================ +// Types +// ============================================================================ + +interface ExampleResult { + readonly example: string; + readonly exitCode: number; + readonly duration: number; + readonly success: boolean; + readonly startTime: Date; + readonly endTime: Date; +} + +// ============================================================================ +// Error Types +// ============================================================================ + +class ExampleNotFoundError { + readonly _tag = "ExampleNotFoundError"; + constructor(readonly example: string) {} +} + +class ExampleExecutionError { + readonly _tag = "ExampleExecutionError"; + constructor( + readonly example: string, + readonly exitCode: number, + readonly message: string + ) {} +} + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Recursively find all .ts files in a directory + */ +const findExamples = (dir: string): Effect.Effect => + Effect.gen(function* () { + const entries = yield* Effect.sync(() => fs.readdirSync(dir)); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry); + const stat = yield* Effect.sync(() => fs.statSync(fullPath)); + + if (stat.isDirectory()) { + const subFiles = yield* findExamples(fullPath); + files.push(...subFiles); + } else if (entry.endsWith('.ts')) { + files.push(fullPath); + } + } + + return files.sort(); + }); + /** - * Run all example files in the src/ directory - * Each example is run in a separate process to handle process.exit() calls + * Check if example file exists */ +const checkExampleExists = (example: string) => + Effect.gen(function* () { + const exists = yield* Effect.sync(() => fs.existsSync(example)); -import { readdirSync, statSync } from 'fs'; -import { join } from 'path'; -import { $ } from 'bun'; - -const srcDir = join(import.meta.dir, 'src'); - -// Recursively find all .ts files in src/ -function findExamples(dir: string): string[] { - const entries = readdirSync(dir); - const files: string[] = []; - - for (const entry of entries) { - const fullPath = join(dir, entry); - const stat = statSync(fullPath); - - if (stat.isDirectory()) { - files.push(...findExamples(fullPath)); - } else if (entry.endsWith('.ts')) { - files.push(fullPath); + if (!exists) { + return yield* Effect.fail(new ExampleNotFoundError(example)); } + + return example; + }); + +/** + * Run a single example and capture output + */ +const runExample = (example: string, baseDir: string) => + Effect.gen(function* () { + const startTime = new Date(); + const relativePath = example.replace(baseDir + '/', ''); + + // Run the example using bun + const exitCode = yield* Effect.async( + (resume) => { + const proc = spawn("bun", ["run", example], { + stdio: ["ignore", "inherit", "inherit"], + env: process.env, + }); + + proc.on("close", (code) => { + resume(Effect.succeed(code ?? 0)); + }); + + proc.on("error", (err) => { + resume( + Effect.fail( + new ExampleExecutionError( + example, + -1, + `Failed to spawn process: ${err.message}` + ) + ) + ); + }); + } + ); + + const endTime = new Date(); + const duration = endTime.getTime() - startTime.getTime(); + + const result: ExampleResult = { + example: relativePath, + exitCode, + duration, + success: exitCode === 0, + startTime, + endTime, + }; + + return result; + }); + +/** + * Format duration in human-readable format + */ +const formatDuration = (ms: number): string => { + const seconds = Math.floor(ms / 1000); + const milliseconds = ms % 1000; + + if (seconds > 0) { + return `${seconds}.${Math.floor(milliseconds / 100)}s`; } - - return files.sort(); -} + return `${milliseconds}ms`; +}; + +// ============================================================================ +// Main Program +// ============================================================================ + +const program = Effect.gen(function* () { + const baseDir = import.meta.dir; + const srcDir = path.join(baseDir, 'src'); + + // Print header + yield* Console.log("=".repeat(80)); + yield* Console.log("Effect-AI Examples Runner"); + yield* Console.log("=".repeat(80)); + yield* Console.log(""); -const examples = findExamples(srcDir); -console.log(`Found ${examples.length} example(s)\n`); - -let failed = 0; -for (const example of examples) { - const relativePath = example.replace(import.meta.dir + '/', ''); - console.log(`\n${'='.repeat(80)}`); - console.log(`Running: ${relativePath}`); - console.log('='.repeat(80)); - - try { - await $`bun run ${example}`.quiet(); - console.log(`āœ… ${relativePath} completed successfully`); - } catch (error) { - console.error(`āŒ ${relativePath} failed`); - failed++; + // Find all examples + yield* Console.log("šŸ” Searching for examples..."); + const examples = yield* findExamples(srcDir); + yield* Console.log(`āœ“ Found ${examples.length} example(s)`); + yield* Console.log(""); + + // Check all examples exist + yield* Effect.all( + examples.map((example) => checkExampleExists(example)), + { concurrency: "unbounded" } + ); + + // Launch all examples + yield* Console.log(`šŸš€ Launching ${examples.length} examples in parallel...`); + yield* Console.log(""); + + // Print all examples being launched + for (const example of examples) { + const relativePath = example.replace(baseDir + '/', ''); + yield* Console.log(`ā³ Launching: ${relativePath}`); } -} -console.log(`\n${'='.repeat(80)}`); -console.log(`Results: ${examples.length - failed}/${examples.length} passed`); -console.log('='.repeat(80)); + yield* Console.log(""); + yield* Console.log(`šŸ“Š All ${examples.length} examples launched!`); + yield* Console.log(" Waiting for completion..."); + yield* Console.log(""); + + // Create tasks to run + const exampleTasks = examples.map((example) => runExample(example, baseDir)); + + // Run all examples in parallel and collect results + const results = yield* Effect.all( + exampleTasks.map((task) => Effect.exit(task)), + { concurrency: "unbounded" } + ); + + // Process results + const successfulResults: ExampleResult[] = []; + const failedResults: Array<{ example: string; error: unknown }> = []; + + for (let index = 0; index < results.length; index++) { + const exit = results[index]; + const example = examples[index]; + + if (!exit || !example) continue; + + if (Exit.isSuccess(exit)) { + const result = exit.value; + successfulResults.push(result); + + yield* Console.log( + `āœ… Success: ${result.example} (${formatDuration(result.duration)})` + ); + } else if (Exit.isFailure(exit)) { + const cause = exit.cause; + const relativePath = example.replace(baseDir + '/', ''); + failedResults.push({ example: relativePath, error: cause }); + + yield* Console.log(`āŒ Failed: ${relativePath}`); + } + } + + // Print summary + yield* Console.log(""); + yield* Console.log("=".repeat(80)); + yield* Console.log("Summary"); + yield* Console.log("=".repeat(80)); + yield* Console.log(`Total examples: ${examples.length}`); + yield* Console.log(`āœ… Successful: ${successfulResults.length}`); + yield* Console.log(`āŒ Failed: ${failedResults.length}`); + yield* Console.log(""); + + // Show successful examples with details + if (successfulResults.length > 0) { + yield* Console.log("āœ… Successful examples:"); + for (const result of successfulResults) { + yield* Console.log( + ` ${result.example} - ${formatDuration(result.duration)}` + ); + } + yield* Console.log(""); + } + + // Show failed examples with details + if (failedResults.length > 0) { + yield* Console.log("āŒ Failed examples:"); + for (const failure of failedResults) { + yield* Console.log(` ${failure.example}`); + + // Try to extract error message + const errorMsg = failure.error instanceof Error + ? failure.error.message + : String(failure.error); -if (failed > 0) { + if (errorMsg) { + yield* Console.log(` Error: ${errorMsg}`); + } + } + yield* Console.log(""); + } + + // Final status + yield* Console.log("=".repeat(80)); + if (failedResults.length > 0) { + yield* Console.log(`Results: ${successfulResults.length}/${examples.length} passed`); + yield* Console.log("=".repeat(80)); + + return { success: false, results: successfulResults, failures: failedResults }; + } else { + yield* Console.log("All examples completed successfully! āœ“"); + yield* Console.log("=".repeat(80)); + + return { success: true, results: successfulResults, failures: [] }; + } +}); + +// ============================================================================ +// Error Handling +// ============================================================================ + +const handleError = (error: unknown) => { + if (error instanceof ExampleNotFoundError) { + console.error(`\nāŒ Example not found: ${error.example}\n`); + process.exit(1); + } + + console.error("\nāŒ Unexpected error:", error); process.exit(1); -} +}; + +// ============================================================================ +// Main Execution +// ============================================================================ + +const main = async () => { + const exit = await Effect.runPromiseExit(program); + + if (Exit.isSuccess(exit)) { + const { success } = exit.value; + process.exit(success ? 0 : 1); + } else { + const cause = exit.cause; + + // Extract the first failure from the cause + const failure = cause._tag === "Fail" ? cause.error : cause; + + handleError(failure); + } +}; + +// Run the program +main().catch((error) => { + console.error("\nāŒ Fatal error:", error); + process.exit(1); +}); diff --git a/typescript/effect-ai/src/plugin-file-parser/README.md b/typescript/effect-ai/src/plugin-file-parser/README.md new file mode 100644 index 0000000..8787e49 --- /dev/null +++ b/typescript/effect-ai/src/plugin-file-parser/README.md @@ -0,0 +1,22 @@ +# OpenRouter FileParserPlugin Examples (Effect AI) + +Examples demonstrating OpenRouter's FileParserPlugin with @effect/ai. + +## Overview + +The FileParserPlugin integrates with Effect AI's type-safe, composable architecture to provide: + +- Server-side PDF parsing with OpenRouter's file parser +- Effect-based error handling and composition +- Layer-based dependency injection for configuration +- Concurrent processing with Effect.all + +## Examples + +- `file-parser-all-sizes.ts` - Tests PDF processing across multiple file sizes with Effect patterns + +## Running + +```bash +bun run typescript/effect-ai/src/plugin-file-parser/file-parser-all-sizes.ts +``` diff --git a/typescript/effect-ai/src/plugin-file-parser/file-parser-all-sizes.ts b/typescript/effect-ai/src/plugin-file-parser/file-parser-all-sizes.ts new file mode 100644 index 0000000..812eecb --- /dev/null +++ b/typescript/effect-ai/src/plugin-file-parser/file-parser-all-sizes.ts @@ -0,0 +1,176 @@ +/** + * Example: OpenRouter FileParserPlugin with @effect/ai + * + * This example demonstrates how to use OpenRouter's FileParserPlugin with + * Effect AI, combining idiomatic Effect patterns with PDF file processing: + * - Effect.gen for generator-style effect composition + * - Layer-based dependency injection + * - Type-safe error handling with Effect + * - File processing with the FileParserPlugin + * - Uses shared fixtures module with absolute paths + * + * To run: bun run typescript/effect-ai/src/plugin-file-parser/file-parser-all-sizes.ts + */ + +import * as OpenRouterClient from '@effect/ai-openrouter/OpenRouterClient'; +import * as OpenRouterLanguageModel from '@effect/ai-openrouter/OpenRouterLanguageModel'; +import * as LanguageModel from '@effect/ai/LanguageModel'; +import * as Prompt from '@effect/ai/Prompt'; +import { FetchHttpClient } from '@effect/platform'; +import * as BunContext from '@effect/platform-bun/BunContext'; +import { + type PdfSize, + PDF_SIZES, + extractCode, + formatSize, + getPdfSize, + readExpectedCode, + readPdfAsDataUrl, +} from '@openrouter-examples/shared/fixtures'; +import { Console, Effect, Layer, Redacted } from 'effect'; + +/** + * OpenRouter FileParserPlugin configuration + * This plugin enables server-side PDF parsing using OpenRouter's file parser + * instead of including PDF content in the message text. + */ +const fileParserConfig: OpenRouterLanguageModel.Config.Service = { + plugins: [ + { + id: 'file-parser', + pdf: { + engine: 'mistral-ocr', + }, + }, + ], +}; + +/** + * Process a single PDF file with logging and error handling + */ +const processPdf = (size: PdfSize, expectedCode: string) => + Effect.gen(function* () { + yield* Console.log(`\n=== ${size.toUpperCase()} PDF ===`); + + const sizeBytes = getPdfSize(size); + yield* Console.log(`Size: ${formatSize(sizeBytes)}`); + yield* Console.log(`Expected: ${expectedCode}`); + + const dataUrl = yield* Effect.promise(() => readPdfAsDataUrl(size)); + + /** + * Construct prompt with file attachment for file parser plugin + * + * IMPORTANT: The PDF is sent as a file attachment via Prompt.makePart("file", ...) + * and will be processed by OpenRouter's file parser plugin server-side. + * The PDF content is NOT included in the text content - only the user instruction + * is sent as text. The file parser plugin extracts the PDF content automatically. + */ + const prompt = Prompt.make([ + Prompt.makeMessage('user', { + content: [ + // PDF file attachment - processed by file parser plugin + Prompt.makePart('file', { + mediaType: 'application/pdf', + fileName: `${size}.pdf`, + data: dataUrl, + }), + // Text instruction only - NO PDF content included here + Prompt.makePart('text', { + text: 'Extract the verification code. Reply with ONLY the code.', + }), + ], + }), + ]); + + // Generate text with file parser plugin enabled + // The plugin processes the PDF file attachment server-side + const response = yield* LanguageModel.generateText({ + prompt, + }).pipe(OpenRouterLanguageModel.withConfigOverride(fileParserConfig)); + + const extracted = extractCode(response.text); + const success = extracted === expectedCode; + + yield* Console.log(`Extracted: ${extracted || '(none)'}`); + yield* Console.log(`Status: ${success ? 'āœ… PASS' : 'āŒ FAIL'}`); + + return { success, extracted, expected: expectedCode }; + }); + +/** + * Main program orchestrating all PDF runs + */ +const program = Effect.gen(function* () { + yield* Console.log('╔════════════════════════════════════════════════════════════════════════════╗'); + yield* Console.log('ā•‘ OpenRouter FileParserPlugin - Effect AI ā•‘'); + yield* Console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); + yield* Console.log(); + yield* Console.log('Testing PDF processing with verification code extraction'); + yield* Console.log(); + + const logFailure = + (label: string) => + (error: unknown) => + Effect.gen(function* () { + yield* Console.error(`Error processing ${label}:`, error); + return { + success: false, + extracted: null, + expected: '', + }; + }); + + const results = yield* Effect.all( + PDF_SIZES.map((size) => + Effect.gen(function* () { + const expectedCode = yield* Effect.promise(() => readExpectedCode(size)); + return yield* processPdf(size, expectedCode).pipe( + Effect.catchAll(logFailure(size)), + ); + }), + ), + { concurrency: 'unbounded' }, + ); + + yield* Console.log('\n' + '='.repeat(80)); + + const passed = results.filter((r) => r.success).length; + const total = results.length; + + yield* Console.log(`Results: ${passed}/${total} passed`); + yield* Console.log('='.repeat(80)); + + if (passed === total) { + yield* Console.log('\nāœ… All PDF sizes processed successfully!'); + return 0; + } + yield* Console.log('\nāŒ Some PDF tests failed'); + return 1; +}); + +/** + * Layer composition for dependency injection + */ +const OpenRouterClientLayer = OpenRouterClient.layer({ + apiKey: Redacted.make(process.env.OPENROUTER_API_KEY!), +}).pipe(Layer.provide(FetchHttpClient.layer)); + +const OpenRouterModelLayer = OpenRouterLanguageModel.layer({ + model: 'openai/gpt-4o-mini', + config: { + temperature: 0.7, + max_tokens: 500, + }, +}).pipe(Layer.provide(OpenRouterClientLayer)); + +/** + * Run the program with dependency injection + */ +const exitCode = await program.pipe( + Effect.provide(OpenRouterModelLayer), + Effect.provide(BunContext.layer), + Effect.runPromise, +); + +process.exit(exitCode);