diff --git a/lib/ai-providers/openai-adapter.ts b/lib/ai-providers/openai-adapter.ts new file mode 100644 index 0000000..de3dd25 --- /dev/null +++ b/lib/ai-providers/openai-adapter.ts @@ -0,0 +1,239 @@ +import OpenAI, { AzureOpenAI } from 'openai'; +import { z } from 'zod'; +import type { + ProviderAdapter, + ProviderGenerateParams, + ProviderGenerateResult +} from './types'; + +const OPENAI_PROVIDER_NAME = 'openai'; +const AZURE_PROVIDER_NAME = 'azure-openai'; +const OPENAI_DEFAULT_MODEL = 'gpt-4.1'; + +/** + * Detect reasoning models that don't support temperature/topP parameters. + */ +function isReasoningModel(model: string): boolean { + const reasoningModels = ['o1-preview', 'o1-mini', 'o1', 'o3-mini']; + return reasoningModels.some(m => model.includes(m)); +} + +/** + * Azure OpenAI requires root schema to be type: "object", not "array". + * Wrap array schemas in an object wrapper. + */ +function wrapArraySchema(jsonSchema: Record): { + wrappedSchema: Record; + wasWrapped: boolean; +} { + if (jsonSchema.type === 'array') { + return { + wrappedSchema: { + type: 'object', + properties: { items: jsonSchema }, + required: ['items'], + additionalProperties: false + }, + wasWrapped: true + }; + } + return { wrappedSchema: jsonSchema, wasWrapped: false }; +} + +/** + * Unwrap array response if the schema was wrapped. + * Azure returns: {"items": [...]} -> extract just [...] + */ +function unwrapArrayResponse(content: string, wasWrapped: boolean): string { + if (!wasWrapped) return content; + try { + const parsed = JSON.parse(content); + if (parsed && typeof parsed === 'object' && 'items' in parsed) { + return JSON.stringify(parsed.items); + } + } catch { + // Keep original if parsing fails + } + return content; +} + +/** + * Normalize usage metadata to our standard format. + */ +function normalizeUsage( + usage: OpenAI.CompletionUsage | undefined, + latencyMs: number +) { + return { + promptTokens: usage?.prompt_tokens, + completionTokens: usage?.completion_tokens, + totalTokens: usage?.total_tokens, + latencyMs + }; +} + +/** + * Shared generate implementation for both OpenAI and Azure OpenAI. + */ +async function generateWithClient( + client: OpenAI | AzureOpenAI, + params: ProviderGenerateParams, + providerName: string, + defaultModel: string, + isAzure: boolean +): Promise { + const requestStartedAt = Date.now(); + const model = params.model ?? defaultModel; + + // Build request parameters + const requestParams: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming = { + model, + messages: [{ role: 'user', content: params.prompt }] + }; + + // Temperature and topP - skip for reasoning models + if (!isReasoningModel(model)) { + if (typeof params.temperature === 'number') { + requestParams.temperature = params.temperature; + } + if (typeof params.topP === 'number') { + requestParams.top_p = params.topP; + } + } + + // Max tokens - Azure uses max_completion_tokens, OpenAI uses max_tokens + if (typeof params.maxOutputTokens === 'number') { + if (isAzure) { + requestParams.max_completion_tokens = params.maxOutputTokens; + } else { + requestParams.max_tokens = params.maxOutputTokens; + } + } + + // Structured output with JSON schema + let wasArrayWrapped = false; + if (params.zodSchema) { + let jsonSchema = z.toJSONSchema(params.zodSchema) as Record; + + // Azure requires object root type - wrap arrays + if (isAzure) { + const { wrappedSchema, wasWrapped } = wrapArraySchema(jsonSchema); + jsonSchema = wrappedSchema; + wasArrayWrapped = wasWrapped; + } + + requestParams.response_format = { + type: 'json_schema', + json_schema: { + name: params.schemaName?.trim() || 'ResponseSchema', + schema: jsonSchema + } + }; + } + + try { + const response = await client.chat.completions.create( + requestParams, + params.timeoutMs ? { timeout: params.timeoutMs } : undefined + ); + + const latencyMs = Date.now() - requestStartedAt; + const choice = response.choices[0]; + let content = choice?.message?.content ?? ''; + + if (!content) { + throw new Error(`${providerName} returned an empty response.`); + } + + // Unwrap array response if schema was wrapped for Azure + if (isAzure && wasArrayWrapped) { + content = unwrapArrayResponse(content, true); + } + + return { + content, + rawResponse: response, + provider: providerName, + model: response.model ?? model, + usage: normalizeUsage(response.usage, latencyMs) + }; + } catch (error) { + if (error instanceof OpenAI.APIError) { + throw new Error( + `${providerName} API error (${error.status}): ${error.message}` + ); + } + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`${providerName} request timed out.`); + } + throw error; + } +} + +/** + * Create an OpenAI adapter using the official SDK. + */ +export function createOpenAIAdapter(): ProviderAdapter { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error( + 'OPENAI_API_KEY is required to use the OpenAI provider.' + ); + } + + const client = new OpenAI({ + apiKey, + baseURL: process.env.OPENAI_BASE_URL, + organization: process.env.OPENAI_ORG_ID + }); + + return { + name: OPENAI_PROVIDER_NAME, + defaultModel: OPENAI_DEFAULT_MODEL, + generate: (params) => + generateWithClient( + client, + params, + OPENAI_PROVIDER_NAME, + OPENAI_DEFAULT_MODEL, + false + ) + }; +} + +/** + * Create an Azure OpenAI adapter using the official SDK. + */ +export function createAzureOpenAIAdapter(): ProviderAdapter { + const apiKey = process.env.AZURE_OPENAI_API_KEY; + const endpoint = process.env.AZURE_OPENAI_ENDPOINT?.replace(/\/$/, ''); + const deployment = process.env.AZURE_OPENAI_DEPLOYMENT; + const apiVersion = + process.env.AZURE_OPENAI_API_VERSION || '2024-10-01-preview'; + + if (!apiKey || !endpoint || !deployment) { + throw new Error( + 'Azure OpenAI requires AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, and AZURE_OPENAI_DEPLOYMENT.' + ); + } + + const client = new AzureOpenAI({ + apiKey, + endpoint, + deployment, + apiVersion + }); + + return { + name: AZURE_PROVIDER_NAME, + defaultModel: deployment, + generate: (params) => + generateWithClient( + client, + params, + AZURE_PROVIDER_NAME, + deployment, + true + ) + }; +} diff --git a/lib/ai-providers/registry.ts b/lib/ai-providers/registry.ts index 1af25a4..07fd998 100644 --- a/lib/ai-providers/registry.ts +++ b/lib/ai-providers/registry.ts @@ -1,19 +1,28 @@ import { createGeminiAdapter } from './gemini-adapter'; import { createGrokAdapter } from './grok-adapter'; -import type { ProviderAdapter, ProviderGenerateParams, ProviderGenerateResult } from './types'; +import { createOpenAIAdapter, createAzureOpenAIAdapter } from './openai-adapter'; +import type { + ProviderAdapter, + ProviderGenerateParams, + ProviderGenerateResult +} from './types'; -type ProviderKey = 'grok' | 'gemini'; +type ProviderKey = 'grok' | 'gemini' | 'openai' | 'azure-openai'; type ProviderFactory = () => ProviderAdapter; const providerFactories: Record = { grok: createGrokAdapter, gemini: createGeminiAdapter, + openai: createOpenAIAdapter, + 'azure-openai': createAzureOpenAIAdapter }; const providerEnvGuards: Record string | undefined> = { grok: () => process.env.XAI_API_KEY, gemini: () => process.env.GEMINI_API_KEY, + openai: () => process.env.OPENAI_API_KEY, + 'azure-openai': () => process.env.AZURE_OPENAI_API_KEY }; const providerCache: Partial> = {}; @@ -28,12 +37,10 @@ function resolveProviderKey(preferred?: string): ProviderKey { return envPreference as ProviderKey; } - if (providerEnvGuards.grok()) { - return 'grok'; - } - if (providerEnvGuards.gemini()) { - return 'gemini'; - } + if (providerEnvGuards.grok()) return 'grok'; + if (providerEnvGuards.gemini()) return 'gemini'; + if (providerEnvGuards.openai()) return 'openai'; + if (providerEnvGuards['azure-openai']()) return 'azure-openai'; return 'grok'; } diff --git a/package-lock.json b/package-lock.json index 623e389..6dd29f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,9 +34,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.2.7", + "dotenv": "^17.2.3", "jsdom": "^27.0.0", "lucide-react": "^0.542.0", - "next": "15.5.7", + "next": "^15.5.9", + "openai": "^6.14.0", "postmark": "^4.0.5", "react": "19.1.2", "react-dom": "19.1.2", @@ -48,6 +50,7 @@ "zod": "^4.1.9" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.3", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -188,7 +191,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -233,7 +235,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1573,9 +1574,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", - "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2898,7 +2899,6 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.4.tgz", "integrity": "sha512-LcbTzFhHYdwfQ7TRPfol0z04rLEyHabpGYANME6wkQ/kLtKNmI+Vy+WEM8HxeOZAtByUFxoUTTLwhXmrh+CcVw==", "license": "MIT", - "peer": true, "dependencies": { "@supabase/auth-js": "2.71.1", "@supabase/functions-js": "2.4.6", @@ -3324,7 +3324,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3335,7 +3334,6 @@ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3413,7 +3411,6 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -3975,7 +3972,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5045,6 +5041,18 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5369,7 +5377,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5544,7 +5551,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8788,13 +8794,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", - "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", - "peer": true, "dependencies": { - "@next/env": "15.5.7", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -9046,6 +9051,27 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.14.0.tgz", + "integrity": "sha512-ZPD9MG5/sPpyGZ0idRoDK0P5MWEMuXe0Max/S55vuvoxqyEVkN94m9jSpE3YgNgz3WoESFvozs57dxWqAco31w==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9266,7 +9292,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9409,7 +9434,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.2.tgz", "integrity": "sha512-MdWVitvLbQULD+4DP8GYjZUrepGW7d+GQkNVqJEzNxE+e9WIa4egVFE/RDfVb1u9u/Jw7dNMmPB4IqxzbFYJ0w==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9419,7 +9443,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-dEoydsCp50i7kS1xHOmPXq4zQYoGWedUsvqv9H6zdif2r7yLHygyfP9qou71TulRN0d6ng9EbRVsQhSqfUc19g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10660,7 +10683,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10896,7 +10918,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 8d305fe..6a3feb9 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "dotenv": "^17.2.3", "jsdom": "^27.0.0", "lucide-react": "^0.542.0", - "next": "15.5.7", + "next": "^15.5.9", + "openai": "^6.14.0", "postmark": "^4.0.5", "react": "19.1.2", "react-dom": "19.1.2", diff --git a/scripts/test-azure-openai.ts b/scripts/test-azure-openai.ts new file mode 100644 index 0000000..c4827ab --- /dev/null +++ b/scripts/test-azure-openai.ts @@ -0,0 +1,194 @@ +#!/usr/bin/env tsx +/** + * Test script for Azure OpenAI Adapter + * + * Run with: npx tsx scripts/test-azure-openai.ts + * + * Required environment variables: + * AZURE_OPENAI_API_KEY + * AZURE_OPENAI_ENDPOINT + * AZURE_OPENAI_DEPLOYMENT + * AZURE_OPENAI_API_VERSION (optional, defaults to 2024-02-15-preview) + */ + +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { z } from 'zod'; + +// Load environment variables from .env.local +config({ path: resolve(process.cwd(), '.env.local') }); + +// Import the adapter after loading env vars +import { createAzureOpenAIAdapter } from '../lib/ai-providers/openai-adapter'; + +async function runTests() { + console.log('๐Ÿงช Testing Azure OpenAI Adapter\n'); + console.log('=' .repeat(50)); + + // Check environment variables + const requiredVars = [ + 'AZURE_OPENAI_API_KEY', + 'AZURE_OPENAI_ENDPOINT', + 'AZURE_OPENAI_DEPLOYMENT' + ]; + + const missingVars = requiredVars.filter(v => !process.env[v]); + if (missingVars.length > 0) { + console.error('โŒ Missing required environment variables:'); + missingVars.forEach(v => console.error(` - ${v}`)); + console.error('\nMake sure these are set in your .env.local file'); + process.exit(1); + } + + console.log('๐Ÿ“‹ Configuration:'); + console.log(` Endpoint: ${process.env.AZURE_OPENAI_ENDPOINT}`); + console.log(` Deployment: ${process.env.AZURE_OPENAI_DEPLOYMENT}`); + console.log(` API Version: ${process.env.AZURE_OPENAI_API_VERSION || '2024-02-15-preview'}`); + console.log(''); + + let adapter; + try { + adapter = createAzureOpenAIAdapter(); + console.log('โœ… Adapter created successfully\n'); + } catch (error) { + console.error('โŒ Failed to create adapter:', error); + process.exit(1); + } + + let passed = 0; + let failed = 0; + + // Test 1: Simple text generation + console.log('=' .repeat(50)); + console.log('Test 1: Simple text generation (no schema)'); + console.log('=' .repeat(50)); + try { + const result = await adapter.generate({ + prompt: 'Say "Hello, Azure OpenAI is working!" and nothing else.', + maxOutputTokens: 50 + }); + console.log('โœ… PASSED'); + console.log(` Response: ${result.content.substring(0, 100)}${result.content.length > 100 ? '...' : ''}`); + console.log(` Model: ${result.model}`); + console.log(` Latency: ${result.usage?.latencyMs}ms`); + passed++; + } catch (error) { + console.log('โŒ FAILED'); + console.log(` Error: ${error instanceof Error ? error.message : String(error)}`); + failed++; + } + console.log(''); + + // Test 2: Object schema (should work directly) + console.log('=' .repeat(50)); + console.log('Test 2: Structured output with object schema'); + console.log('=' .repeat(50)); + try { + const personSchema = z.object({ + name: z.string(), + age: z.number(), + city: z.string() + }); + + const result = await adapter.generate({ + prompt: 'Generate a fictional person with name, age, and city. Return valid JSON.', + zodSchema: personSchema, + schemaName: 'Person', + maxOutputTokens: 100 + }); + + const parsed = JSON.parse(result.content); + console.log('โœ… PASSED'); + console.log(` Response: ${JSON.stringify(parsed)}`); + console.log(` Validated: name=${parsed.name}, age=${parsed.age}, city=${parsed.city}`); + passed++; + } catch (error) { + console.log('โŒ FAILED'); + console.log(` Error: ${error instanceof Error ? error.message : String(error)}`); + failed++; + } + console.log(''); + + // Test 3: Array schema (tests the wrapping fix) + console.log('=' .repeat(50)); + console.log('Test 3: Structured output with ARRAY schema (tests wrapping fix)'); + console.log('=' .repeat(50)); + try { + const colorsSchema = z.array(z.string()); + + const result = await adapter.generate({ + prompt: 'List exactly 3 colors as a JSON array of strings. Example: ["red", "blue", "green"]', + zodSchema: colorsSchema, + schemaName: 'Colors', + maxOutputTokens: 50 + }); + + const parsed = JSON.parse(result.content); + console.log('โœ… PASSED'); + console.log(` Response: ${JSON.stringify(parsed)}`); + console.log(` Is Array: ${Array.isArray(parsed)}`); + console.log(` Length: ${parsed.length}`); + passed++; + } catch (error) { + console.log('โŒ FAILED'); + console.log(` Error: ${error instanceof Error ? error.message : String(error)}`); + failed++; + } + console.log(''); + + // Test 4: Complex array schema (similar to summaryTakeawaysSchema) + console.log('=' .repeat(50)); + console.log('Test 4: Complex array schema (like summaryTakeawaysSchema)'); + console.log('=' .repeat(50)); + try { + const takeawaySchema = z.array( + z.object({ + label: z.string(), + insight: z.string() + }) + ); + + const result = await adapter.generate({ + prompt: 'Generate 2 takeaways about learning programming. Each should have a "label" (short title) and "insight" (explanation). Return as JSON array.', + zodSchema: takeawaySchema, + schemaName: 'Takeaways', + maxOutputTokens: 300 + }); + + const parsed = JSON.parse(result.content); + console.log('โœ… PASSED'); + console.log(` Response: ${JSON.stringify(parsed).substring(0, 200)}...`); + console.log(` Is Array: ${Array.isArray(parsed)}`); + console.log(` Items: ${parsed.length}`); + if (parsed[0]) { + console.log(` First item keys: ${Object.keys(parsed[0]).join(', ')}`); + } + passed++; + } catch (error) { + console.log('โŒ FAILED'); + console.log(` Error: ${error instanceof Error ? error.message : String(error)}`); + failed++; + } + console.log(''); + + // Summary + console.log('=' .repeat(50)); + console.log('๐Ÿ“Š Test Summary'); + console.log('=' .repeat(50)); + console.log(` Passed: ${passed}`); + console.log(` Failed: ${failed}`); + console.log(` Total: ${passed + failed}`); + console.log(''); + + if (failed === 0) { + console.log('๐ŸŽ‰ All tests passed! Azure OpenAI adapter is working correctly.'); + } else { + console.log('โš ๏ธ Some tests failed. Check the errors above.'); + process.exit(1); + } +} + +runTests().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +});