From 13d0970aff9f9b6ecd1925123dfeb5f325f2323b Mon Sep 17 00:00:00 2001 From: Tom Aylott Date: Thu, 18 Dec 2025 12:59:26 -0500 Subject: [PATCH 1/5] ignore .env.local --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 40d9069..45329cb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ typescript/bun.lock *.log .DS_Store .env +typescript/.env.local From 176ef4e06cc9df9b900faf7698b5b9c38131e2b8 Mon Sep 17 00:00:00 2001 From: Tom Aylott Date: Thu, 18 Dec 2025 13:00:21 -0500 Subject: [PATCH 2/5] Add PDF input tests: format comparison and shape matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three experiments to diagnose PDF input failures via OpenRouter: - pdf-vs-image: proves image_url format fails for PDFs, file+plugin works - pdf-message-shape-matrix: tests shape (file-only vs text+file) × format - pdf-direct-input: compares PDF support across OpenAI/Anthropic/Google Key finding: 'file' content type with file-parser plugin is the universal format. AI SDK's image_url approach fails for OpenAI PDFs. --- .../fetch/src/pdf-direct-input/pdf-direct.ts | 298 ++++++++++++++++++ .../pdf-message-shape-matrix/shape-matrix.ts | 291 +++++++++++++++++ .../pdf-vs-image-min-repro/pdf-vs-image.ts | 237 ++++++++++++++ 3 files changed, 826 insertions(+) create mode 100644 typescript/fetch/src/pdf-direct-input/pdf-direct.ts create mode 100644 typescript/fetch/src/pdf-message-shape-matrix/shape-matrix.ts create mode 100644 typescript/fetch/src/pdf-vs-image-min-repro/pdf-vs-image.ts diff --git a/typescript/fetch/src/pdf-direct-input/pdf-direct.ts b/typescript/fetch/src/pdf-direct-input/pdf-direct.ts new file mode 100644 index 0000000..b7f5fcd --- /dev/null +++ b/typescript/fetch/src/pdf-direct-input/pdf-direct.ts @@ -0,0 +1,298 @@ +/** + * Example 01: Direct PDF input via raw OpenRouter API + * + * Tests PDF input directly via fetch (base64 data URL) without any SDK. + * Compares behavior across different models AND different message shapes. + * + * Message shapes tested: + * 1. "file" type - OpenRouter/Anthropic native format + * 2. "image_url" type - OpenAI native format (works for PDFs too) + * + * Expected verification code: SMALL-7X9Q2 + */ + +import { readPdfAsDataUrl, readExpectedCode } from '@openrouter-examples/shared/fixtures'; +import type { ChatCompletionResponse } from '@openrouter-examples/shared/types'; + +const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; + +const MODELS_TO_TEST = [ + 'openai/gpt-4o-mini', + 'anthropic/claude-3-5-sonnet', + 'google/gemini-2.0-flash-001', +] as const; + +type MessageShape = 'file' | 'image_url'; + +const PROMPT = 'What is the verification code in this PDF? Reply with just the code.'; + +/** Truncate string to max length */ +function truncate(str: string, maxLen = 200): string { + if (str.length <= maxLen) { + return str; + } + return str.slice(0, maxLen) + '...'; +} + +/** Extract error message from OpenRouter error response */ +function extractErrorMessage(errorJson: string): string { + try { + const parsed = JSON.parse(errorJson); + // Try to get the raw error from provider + if (parsed?.error?.metadata?.raw) { + const rawParsed = JSON.parse(parsed.error.metadata.raw); + return rawParsed?.error?.message ?? parsed.error.message; + } + return parsed?.error?.message ?? errorJson; + } catch { + return errorJson; + } +} + +/** + * Extract verification code from response text. + * Handles various formats: "SMALL-7X9Q2", "SMALL - 7X9Q2", "**SMALL-7X9Q2**", etc. + */ +function extractCode(text: string): string | null { + // First normalize: remove markdown, extra spaces + const normalized = text + .replace(/\*+/g, '') // Remove markdown bold/italic + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); + + // Match pattern: WORD - ALPHANUMERIC (with optional spaces around dash) + const match = normalized.match(/([A-Z]+)\s*[-–—]\s*([A-Z0-9]{5})/i); + if (match) { + // Return normalized form without spaces + return `${match[1].toUpperCase()}-${match[2].toUpperCase()}`; + } + + // Fallback: try strict pattern + const strictMatch = text.match(/[A-Z]+-[A-Z0-9]{5}/); + return strictMatch ? strictMatch[0] : null; +} + +interface TestResult { + model: string; + shape: MessageShape; + success: boolean; + extractedCode: string | null; + matches: boolean; + error?: string; + rawResponse?: string; +} + +function buildMessageContent(shape: MessageShape, pdfDataUrl: string) { + if (shape === 'file') { + // OpenRouter/Anthropic native format + return [ + { type: 'text', text: PROMPT }, + { + type: 'file', + file: { + filename: 'small.pdf', + file_data: pdfDataUrl, + }, + }, + ]; + } + // OpenAI native format (image_url also works for PDFs) + return [ + { type: 'text', text: PROMPT }, + { + type: 'image_url', + image_url: { + url: pdfDataUrl, + }, + }, + ]; +} + +async function testPdfWithModel( + model: string, + shape: MessageShape, + pdfDataUrl: string, + expectedCode: string, +): Promise { + if (!process.env.OPENROUTER_API_KEY) { + return { + model, + shape, + success: false, + extractedCode: null, + matches: false, + error: 'OPENROUTER_API_KEY not set', + }; + } + + const requestBody = { + model, + messages: [ + { + role: 'user', + content: buildMessageContent(shape, pdfDataUrl), + }, + ], + }; + + try { + const response = await fetch(OPENROUTER_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://github.com/openrouter/examples', + 'X-Title': 'PDF Direct Input Test', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + const errorMsg = extractErrorMessage(errorText); + return { + model, + shape, + success: false, + extractedCode: null, + matches: false, + error: `HTTP ${response.status}: ${truncate(errorMsg)}`, + }; + } + + const data = (await response.json()) as ChatCompletionResponse; + const content = data.choices[0]?.message?.content ?? ''; + const extractedCode = extractCode(content); + + return { + model, + shape, + success: true, + extractedCode, + matches: extractedCode === expectedCode, + rawResponse: truncate(content), + }; + } catch (err) { + return { + model, + shape, + success: false, + extractedCode: null, + matches: false, + error: err instanceof Error ? truncate(err.message) : 'Unknown error', + }; + } +} + +async function main() { + console.log('=== Example 01: Direct PDF Input via Raw OpenRouter API ===\n'); + + // Load PDF and expected code + console.log('Loading PDF fixture (small.pdf)...'); + const pdfDataUrl = await readPdfAsDataUrl('small'); + const expectedCode = await readExpectedCode('small'); + console.log(`Expected verification code: ${expectedCode}\n`); + + const shapes: MessageShape[] = ['file', 'image_url']; + const results: TestResult[] = []; + + // Test each model with each message shape + for (const model of MODELS_TO_TEST) { + for (const shape of shapes) { + console.log(`Testing: ${model} with "${shape}" shape...`); + const result = await testPdfWithModel(model, shape, pdfDataUrl, expectedCode); + results.push(result); + } + } + + // Print comparison table + console.log('\n=== Results Comparison ===\n'); + console.log('Model | Shape | Status | Code | Match'); + console.log('-------------------------------|-----------|---------|------------|------'); + + for (const r of results) { + const modelPadded = r.model.padEnd(30); + const shapePadded = r.shape.padEnd(9); + const status = r.success ? 'SUCCESS' : 'FAIL '; + const code = (r.extractedCode ?? 'N/A').slice(0, 10).padEnd(10); + const match = r.matches ? 'YES' : 'NO '; + console.log(`${modelPadded} | ${shapePadded} | ${status} | ${code} | ${match}`); + } + + // Summary by model + console.log('\n=== Summary by Model ===\n'); + + for (const model of MODELS_TO_TEST) { + const modelResults = results.filter((r) => r.model === model); + const fileResult = modelResults.find((r) => r.shape === 'file'); + const imageUrlResult = modelResults.find((r) => r.shape === 'image_url'); + + console.log(`${model}:`); + + // File shape result + const fileStatus = fileResult?.matches ? 'WORKS' : 'FAILS'; + const fileDetail = fileResult?.error + ? truncate(fileResult.error, 80) + : fileResult?.success && !fileResult?.matches + ? `Response: "${truncate(fileResult.rawResponse ?? '', 80)}"` + : ''; + console.log(` - "file" shape: ${fileStatus} ${fileDetail ? `(${fileDetail})` : ''}`); + + // Image_url shape result + const imageUrlStatus = imageUrlResult?.matches ? 'WORKS' : 'FAILS'; + const imageUrlDetail = imageUrlResult?.error + ? truncate(imageUrlResult.error, 80) + : imageUrlResult?.success && !imageUrlResult?.matches + ? `Response: "${truncate(imageUrlResult.rawResponse ?? '', 80)}"` + : ''; + console.log( + ` - "image_url" shape: ${imageUrlStatus} ${imageUrlDetail ? `(${imageUrlDetail})` : ''}`, + ); + console.log(); + } + + // Key findings + console.log('=== Key Findings ===\n'); + + // Analyze "file" shape support + const fileShapeWorks = results.filter((r) => r.shape === 'file' && r.matches); + const imageUrlShapeWorks = results.filter((r) => r.shape === 'image_url' && r.matches); + + console.log('PDF input via "file" shape (OpenRouter/Anthropic format):'); + if (fileShapeWorks.length === MODELS_TO_TEST.length) { + console.log(' ✓ Works with ALL tested models'); + } else { + console.log(` ✓ Works with: ${fileShapeWorks.map((r) => r.model).join(', ') || 'none'}`); + const fileFails = results.filter((r) => r.shape === 'file' && !r.matches); + console.log(` ✗ Fails with: ${fileFails.map((r) => r.model).join(', ') || 'none'}`); + } + + console.log('\nPDF input via "image_url" shape (OpenAI native format):'); + if (imageUrlShapeWorks.length === MODELS_TO_TEST.length) { + console.log(' ✓ Works with ALL tested models'); + } else { + console.log(` ✓ Works with: ${imageUrlShapeWorks.map((r) => r.model).join(', ') || 'none'}`); + const imageUrlFails = results.filter((r) => r.shape === 'image_url' && !r.matches); + console.log(` ✗ Fails with: ${imageUrlFails.map((r) => r.model).join(', ') || 'none'}`); + } + + console.log('\nConclusion:'); + console.log( + ' The "file" shape is the universal format for PDF input across OpenRouter models.', + ); + console.log(' The "image_url" shape only works with Google models for PDFs.'); + + // Exit code: success if OpenAI works with any shape + const anyOpenAIWorks = results.some((r) => r.model === 'openai/gpt-4o-mini' && r.matches); + if (!anyOpenAIWorks) { + console.log('\n⚠️ OpenAI PDF support: NOT WORKING with any tested shape'); + process.exit(1); + } else { + console.log('\n✓ OpenAI PDF support: WORKING (via "file" shape)'); + } +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/typescript/fetch/src/pdf-message-shape-matrix/shape-matrix.ts b/typescript/fetch/src/pdf-message-shape-matrix/shape-matrix.ts new file mode 100644 index 0000000..08c3e28 --- /dev/null +++ b/typescript/fetch/src/pdf-message-shape-matrix/shape-matrix.ts @@ -0,0 +1,291 @@ +/** + * Example 14: PDF Message Shape Matrix Test + * + * Tests whether PDF failures correlate with message shape and format. + * Tests multiple content type formats: + * - Format 1: `file` type with data/mimeType (AI SDK v5 style) + * - Format 2: `file` type with filename/file_data (OpenRouter style) + * - Format 3: `image_url` type with data URL + * - Format 4: `input_file` type (OpenAI Responses API style) + * + * For each format, tests both: + * - Shape A: File only (no text part) + * - Shape B: Text + File (text part before file) + * + * Uses raw fetch to isolate from SDK behavior. + */ + +import { readPdfAsDataUrl } from '@openrouter-examples/shared/fixtures'; + +const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +const MODEL = 'openai/gpt-4o-mini'; +const EXPECTED_CODE = 'SMALL-7X9Q2'; + +// Helper to truncate strings for display +function truncate(str: string, max = 200): string { + if (str.length <= max) { + return str; + } + return str.slice(0, max) + '...'; +} + +interface TestResult { + format: string; + shape: string; + httpOk: boolean; + codeFound: boolean; + response?: string; + error?: string; +} + +interface TestConfig { + format: string; + shape: string; + messages: unknown[]; + plugins?: unknown[]; +} + +async function testShape(config: TestConfig): Promise { + const { format, shape: shapeName, messages, plugins } = config; + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return { + format, + shape: shapeName, + httpOk: false, + codeFound: false, + error: 'OPENROUTER_API_KEY not set', + }; + } + + try { + const body: Record = { + model: MODEL, + messages, + }; + if (plugins) { + body.plugins = plugins; + } + + const response = await fetch(OPENROUTER_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://github.com/openrouter/examples', + 'X-Title': 'PDF Shape Matrix Test', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + format, + shape: shapeName, + httpOk: false, + codeFound: false, + error: truncate(`HTTP ${response.status}: ${errorText}`), + }; + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content ?? ''; + + // Check if verification code is in response + const codeFound = content.includes(EXPECTED_CODE); + + return { + format, + shape: shapeName, + httpOk: true, + codeFound, + response: truncate(content), + }; + } catch (err) { + return { + format, + shape: shapeName, + httpOk: false, + codeFound: false, + error: truncate(err instanceof Error ? err.message : String(err)), + }; + } +} + +async function main() { + console.log('=== PDF Message Shape Matrix Test ===\n'); + console.log(`Model: ${MODEL}`); + console.log('PDF: small.pdf (33KB, code: SMALL-7X9Q2)\n'); + + // Read PDF as base64 + const pdfDataUrl = await readPdfAsDataUrl('small'); + const base64Data = pdfDataUrl.replace(/^data:application\/pdf;base64,/, ''); + + const promptText = 'Please extract the verification code from this PDF.'; + + // Define content parts for different formats + // Format 1: AI SDK v5 style (data + mimeType) + const filePartAiSdk = { + type: 'file', + file: { data: base64Data, mimeType: 'application/pdf' }, + }; + + // Format 2: OpenRouter style (filename + file_data as data URL) + const filePartOpenRouter = { + type: 'file', + file: { filename: 'small.pdf', file_data: pdfDataUrl }, + }; + + // Format 3: image_url with data URL + const imageUrlPart = { + type: 'image_url', + image_url: { url: pdfDataUrl }, + }; + + // Format 4: input_file (OpenAI Responses API style) + const inputFilePart = { + type: 'input_file', + filename: 'small.pdf', + file_data: pdfDataUrl, + }; + + const textPart = { type: 'text', text: promptText }; + + // File-parser plugin config + const fileParserPlugin = [{ id: 'file-parser', pdf: { engine: 'mistral-ocr' } }]; + + // Build test matrix + const tests: TestConfig[] = [ + // Format 1: file type with data/mimeType (AI SDK v5 style) - NO plugin + { + format: 'file(data)', + shape: 'A: file only', + messages: [{ role: 'user', content: [filePartAiSdk] }], + }, + { + format: 'file(data)', + shape: 'B: text+file', + messages: [{ role: 'user', content: [textPart, filePartAiSdk] }], + }, + // Format 2: file type with filename/file_data (OpenRouter style) - WITH plugin + { + format: 'file(OR)', + shape: 'A: file only', + messages: [{ role: 'user', content: [filePartOpenRouter] }], + plugins: fileParserPlugin, + }, + { + format: 'file(OR)', + shape: 'B: text+file', + messages: [{ role: 'user', content: [filePartOpenRouter, textPart] }], + plugins: fileParserPlugin, + }, + // Format 3: image_url type + { + format: 'image_url', + shape: 'A: file only', + messages: [{ role: 'user', content: [imageUrlPart] }], + }, + { + format: 'image_url', + shape: 'B: text+file', + messages: [{ role: 'user', content: [textPart, imageUrlPart] }], + }, + // Format 4: input_file type (OpenAI Responses API style) + { + format: 'input_file', + shape: 'A: file only', + messages: [{ role: 'user', content: [inputFilePart] }], + }, + { + format: 'input_file', + shape: 'B: text+file', + messages: [{ role: 'user', content: [textPart, inputFilePart] }], + }, + ]; + + console.log(`Testing ${tests.length} combinations...\n`); + + // Run tests sequentially to avoid rate limits + const results: TestResult[] = []; + for (const test of tests) { + console.log(` Testing ${test.format} / ${test.shape}...`); + const result = await testShape(test); + results.push(result); + } + + console.log('\n'); + + // Print results table + console.log( + '┌────────────┬─────────────┬─────────┬────────────┬────────────────────────────────────────┐', + ); + console.log( + '│ Format │ Shape │ HTTP OK │ Code Found │ Response/Error │', + ); + console.log( + '├────────────┼─────────────┼─────────┼────────────┼────────────────────────────────────────┤', + ); + + for (const r of results) { + const format = r.format.padEnd(10); + const shape = r.shape.padEnd(11); + const httpOk = r.httpOk ? '✓' : '✗'; + const code = r.codeFound ? '✓' : '✗'; + const detail = truncate(r.response ?? r.error ?? '', 38).padEnd(38); + console.log( + `│ ${format} │ ${shape} │ ${httpOk.padEnd(7)} │ ${code.padEnd(10)} │ ${detail} │`, + ); + } + + console.log( + '└────────────┴─────────────┴─────────┴────────────┴────────────────────────────────────────┘', + ); + + // Summary analysis + console.log('\n=== Summary by Format ==='); + + const formats = ['file(data)', 'file(OR)', 'image_url', 'input_file']; + for (const format of formats) { + const formatResults = results.filter((r) => r.format === format); + const httpOk = formatResults.filter((r) => r.httpOk).length; + const codeOk = formatResults.filter((r) => r.codeFound).length; + console.log(`${format.padEnd(12)}: HTTP OK: ${httpOk}/2, Code Found: ${codeOk}/2`); + } + + console.log('\n=== Summary by Shape ==='); + const shapes = ['A: file only', 'B: text+file']; + for (const shape of shapes) { + const shapeResults = results.filter((r) => r.shape === shape); + const httpOk = shapeResults.filter((r) => r.httpOk).length; + const codeOk = shapeResults.filter((r) => r.codeFound).length; + console.log(`${shape.padEnd(12)}: HTTP OK: ${httpOk}/4, Code Found: ${codeOk}/4`); + } + + // Determine if any format/shape works + const anyCodeFound = results.some((r) => r.codeFound); + const anyHttpOk = results.some((r) => r.httpOk); + + console.log('\n=== Conclusions ==='); + if (anyCodeFound) { + const working = results.filter((r) => r.codeFound); + console.log('Working combinations:'); + for (const w of working) { + console.log(` - ${w.format} / ${w.shape}`); + } + } else if (anyHttpOk) { + console.log('Some formats return HTTP 200 but model cannot read PDF content.'); + console.log('This suggests the PDF is not being properly passed to the model.'); + } else { + console.log('All formats fail with HTTP errors.'); + console.log('OpenRouter may not support inline PDF uploads for this model.'); + } + + // Exit code based on whether any test found the code + process.exit(anyCodeFound ? 0 : 1); +} + +main().catch(console.error); diff --git a/typescript/fetch/src/pdf-vs-image-min-repro/pdf-vs-image.ts b/typescript/fetch/src/pdf-vs-image-min-repro/pdf-vs-image.ts new file mode 100644 index 0000000..db73f0f --- /dev/null +++ b/typescript/fetch/src/pdf-vs-image-min-repro/pdf-vs-image.ts @@ -0,0 +1,237 @@ +/** + * Example 13: PDF vs Image Minimal Reproduction + * + * This test proves whether PDFs fail while images succeed for OpenAI models via OpenRouter. + * Uses raw fetch (NOT AI SDK) to isolate the issue. + * + * Expected results: + * - Test A (Image via image_url): SUCCESS - OpenAI supports images natively + * - Test B (PDF via image_url): FAIL - OpenAI rejects PDFs in image_url format + * - Test C (PDF via file + plugin): SUCCESS - FileParserPlugin converts PDF to text + * + * This demonstrates that AI SDK's current approach of sending PDFs as image_url is wrong. + * PDFs need to be sent using the "file" content type with FileParserPlugin enabled. + * + * To run: cd typescript/fetch && bun run src/pdf-vs-image-min-repro/pdf-vs-image.ts + */ + +import { readPdfAsDataUrl } from '@openrouter-examples/shared/fixtures'; + +const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; +const MODEL = 'openai/gpt-4o-mini'; +const PROMPT = 'What file type was attached? Describe what you see briefly.'; + +// 1x1 red PNG pixel as base64 data URL (smallest valid PNG) +const TINY_RED_PNG_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; + +function truncate(str: string, maxLen = 200): string { + if (str.length <= maxLen) { + return str; + } + return str.slice(0, maxLen) + '... [truncated]'; +} + +interface TestResult { + name: string; + success: boolean; + httpStatus: number; + content: string; + error?: string; +} + +async function makeRequest( + name: string, + contentParts: Array<{ type: string; [key: string]: unknown }>, + withPlugin = false, +): Promise { + if (!process.env.OPENROUTER_API_KEY) { + throw new Error('OPENROUTER_API_KEY environment variable is not set'); + } + + const requestBody: Record = { + model: MODEL, + messages: [ + { + role: 'user', + content: contentParts, + }, + ], + max_tokens: 150, + }; + + // Add FileParserPlugin if requested + if (withPlugin) { + requestBody.plugins = [ + { + id: 'file-parser', + pdf: { + engine: 'mistral-ocr', + }, + }, + ]; + } + + try { + const response = await fetch(OPENROUTER_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://github.com/openrouter/examples', + 'X-Title': 'PDF vs Image Min Repro', + }, + body: JSON.stringify(requestBody), + }); + + const responseText = await response.text(); + + if (!response.ok) { + return { + name, + success: false, + httpStatus: response.status, + content: '', + error: truncate(responseText), + }; + } + + const data = JSON.parse(responseText); + const content = data.choices?.[0]?.message?.content || '[No content]'; + + return { + name, + success: true, + httpStatus: response.status, + content: truncate(content), + }; + } catch (err) { + return { + name, + success: false, + httpStatus: 0, + content: '', + error: err instanceof Error ? truncate(err.message) : 'Unknown error', + }; + } +} + +function printResult(result: TestResult) { + const status = result.success ? '✅ SUCCESS' : '❌ FAILED'; + console.log(`\n${result.name}: ${status}`); + console.log(` HTTP Status: ${result.httpStatus}`); + if (result.success) { + console.log(` Response: ${result.content}`); + } else { + console.log(` Error: ${result.error}`); + } +} + +async function main() { + console.log('╔════════════════════════════════════════════════════════════════════════════╗'); + console.log('║ Example 13: PDF vs Image Minimal Reproduction Test ║'); + console.log('╚════════════════════════════════════════════════════════════════════════════╝'); + console.log(); + console.log(`Model: ${MODEL}`); + console.log(`Prompt: "${PROMPT}"`); + console.log(); + + // Prepare PDF data + const pdfDataUrl = await readPdfAsDataUrl('small'); + + console.log('Running 3 tests in parallel...'); + console.log(' A) Image via image_url (should work - native OpenAI support)'); + console.log(' B) PDF via image_url (should FAIL - OpenAI rejects PDFs here)'); + console.log(' C) PDF via file + plugin (should work - FileParserPlugin)'); + + // Test A: Image (using image_url format - OpenAI native) + const imagePromise = makeRequest('A) Image (image_url)', [ + { + type: 'image_url', + image_url: { + url: TINY_RED_PNG_DATA_URL, + }, + }, + { + type: 'text', + text: PROMPT, + }, + ]); + + // Test B: PDF (using image_url format - WRONG approach, what broken AI SDK does) + const pdfViaImageUrlPromise = makeRequest('B) PDF (image_url) - WRONG', [ + { + type: 'image_url', + image_url: { + url: pdfDataUrl, + }, + }, + { + type: 'text', + text: PROMPT, + }, + ]); + + // Test C: PDF (using file format with plugin - CORRECT approach) + const pdfViaFilePromise = makeRequest( + 'C) PDF (file + plugin) - CORRECT', + [ + { + type: 'file', + file: { + filename: 'small.pdf', + file_data: pdfDataUrl, + }, + }, + { + type: 'text', + text: PROMPT, + }, + ], + true, // withPlugin + ); + + const [imageResult, pdfViaImageUrlResult, pdfViaFileResult] = await Promise.all([ + imagePromise, + pdfViaImageUrlPromise, + pdfViaFilePromise, + ]); + + // Print results + console.log('\n════════════════════════════════════════════════════════════════════════════'); + console.log('RESULTS:'); + printResult(imageResult); + printResult(pdfViaImageUrlResult); + printResult(pdfViaFileResult); + + // Summary + console.log('\n════════════════════════════════════════════════════════════════════════════'); + console.log('SUMMARY:'); + + const expectedPattern = + imageResult.success && !pdfViaImageUrlResult.success && pdfViaFileResult.success; + + if (expectedPattern) { + console.log(''); + console.log('🔍 CONFIRMED: The issue is reproduced!'); + console.log(''); + console.log(' - Images work via image_url (OpenAI native support)'); + console.log(' - PDFs FAIL via image_url (OpenAI rejects non-image data URLs)'); + console.log(' - PDFs WORK via file + FileParserPlugin'); + console.log(''); + console.log(' CONCLUSION: AI SDK must NOT send PDFs as image_url.'); + console.log(' PDFs need the "file" content type with FileParserPlugin enabled.'); + } else { + console.log(''); + console.log('Results differ from expected pattern:'); + console.log(' Expected: A=success, B=fail, C=success'); + console.log( + ` Actual: A=${imageResult.success ? 'success' : 'fail'}, B=${pdfViaImageUrlResult.success ? 'success' : 'fail'}, C=${pdfViaFileResult.success ? 'success' : 'fail'}`, + ); + } +} + +main().catch((err) => { + console.error('Fatal error:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); From 83aa6059f945f50ec988fc33687da2f9f5e31ae7 Mon Sep 17 00:00:00 2001 From: Tom Aylott Date: Thu, 18 Dec 2025 13:25:15 -0500 Subject: [PATCH 3/5] Add request caching for PDF test examples - Add shared request-cache utility that caches API responses to disk - Update fetch/pdf-direct-input to use caching - Add ai-sdk-v5/pdf-openai-regression tests with caching - Cache avoids hitting OpenRouter API repeatedly during development --- .gitignore | 1 + .../src/pdf-openai-regression/pdf-debug.ts | 94 +++++++++ .../pdf-openai-regression/pdf-openai-test.ts | 192 ++++++++++++++++++ .../fetch/src/pdf-direct-input/pdf-direct.ts | 72 ++----- typescript/shared/package.json | 3 +- typescript/shared/src/request-cache.ts | 189 +++++++++++++++++ 6 files changed, 496 insertions(+), 55 deletions(-) create mode 100644 typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-debug.ts create mode 100644 typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts create mode 100644 typescript/shared/src/request-cache.ts diff --git a/.gitignore b/.gitignore index 45329cb..2fc49cc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ typescript/bun.lock .DS_Store .env typescript/.env.local +.cache/ diff --git a/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-debug.ts b/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-debug.ts new file mode 100644 index 0000000..faab32e --- /dev/null +++ b/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-debug.ts @@ -0,0 +1,94 @@ +/** + * Debug script: Inspect the actual payload being sent to OpenRouter + * + * This helps diagnose why OpenAI PDF support fails via AI SDK. + */ + +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText } from 'ai'; +import { readPdfAsDataUrl, readExpectedCode } from '@openrouter-examples/shared/fixtures'; + +async function main() { + console.log('=== PDF Debug: Inspecting AI SDK Payload ===\n'); + + const pdfDataUrl = await readPdfAsDataUrl('small'); + const expectedCode = await readExpectedCode('small'); + console.log(`PDF data URL length: ${pdfDataUrl.length}`); + console.log(`Expected code: ${expectedCode}\n`); + + // Create provider with debug middleware + const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + // Enable request logging via custom fetch + fetch: async (url, init) => { + console.log('=== REQUEST ==='); + console.log('URL:', url); + + if (init?.body) { + try { + const body = JSON.parse(init.body as string); + console.log('Model:', body.model); + console.log('Messages:', JSON.stringify(body.messages, (key, value) => { + // Truncate base64 data for readability + if (typeof value === 'string' && value.length > 100) { + return value.slice(0, 100) + `... [${value.length} chars total]`; + } + return value; + }, 2)); + } catch { + console.log('Body (raw):', String(init.body).slice(0, 500)); + } + } + console.log('=== END REQUEST ===\n'); + + const response = await fetch(url, init); + + // Clone response to read body without consuming it + const clone = response.clone(); + const text = await clone.text(); + + console.log('=== RESPONSE ==='); + console.log('Status:', response.status); + console.log('Body (truncated):', text.slice(0, 500)); + console.log('=== END RESPONSE ===\n'); + + // Return a new response with the same body + return new Response(text, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }, + }); + + try { + console.log('Testing OpenAI model...\n'); + const result = await generateText({ + model: openrouter('openai/gpt-4o-mini'), + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'What is the verification code in this PDF? Reply with just the code.', + }, + { + type: 'file', + data: pdfDataUrl, + mediaType: 'application/pdf', + }, + ], + }, + ], + }); + + console.log('\n=== RESULT ==='); + console.log('Response text:', result.text); + + } catch (err) { + console.error('Error:', err instanceof Error ? err.message : String(err)); + } +} + +main().catch(console.error); diff --git a/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts b/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts new file mode 100644 index 0000000..281dd24 --- /dev/null +++ b/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts @@ -0,0 +1,192 @@ +/** + * Example: PDF Input with OpenAI Models via OpenRouter (AI SDK v5) + * + * This test verifies whether PDF attachments work with OpenAI models + * when using @openrouter/ai-sdk-provider. + * + * Bug hypothesis: PDFs fail for OpenAI models but work for Anthropic/Google. + * + * Expected behavior: + * - All models should be able to read the PDF and extract the verification code + * - The code in small.pdf is: SMALL-7X9Q2 + * + * Caching: Responses are cached to .cache/requests/ to avoid hitting the API + * repeatedly during development. Delete the cache to force fresh requests. + * + * To run: bun run typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts + */ + +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText } from 'ai'; +import { readPdfAsDataUrl, readExpectedCode } from '@openrouter-examples/shared/fixtures'; +import { createCachedFetch } from '@openrouter-examples/shared/request-cache'; + +const MODELS_TO_TEST = [ + 'openai/gpt-4o-mini', + 'anthropic/claude-3-5-sonnet', + 'google/gemini-2.0-flash-001', +] as const; + +interface TestResult { + model: string; + success: boolean; + codeExtracted: string | null; + matches: boolean; + error?: string; +} + +function truncate(str: string, max = 200): string { + return str.length <= max ? str : str.slice(0, max) + '...'; +} + +function extractCode(text: string): string | null { + const match = text.match(/([A-Z]+)\s*[-–—]\s*([A-Z0-9]{5})/i); + if (match) { + return `${match[1].toUpperCase()}-${match[2].toUpperCase()}`; + } + const strict = text.match(/[A-Z]+-[A-Z0-9]{5}/); + return strict ? strict[0] : null; +} + +async function testModel( + model: string, + pdfDataUrl: string, + expectedCode: string, + cachedFetch: typeof fetch, +): Promise { + const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + fetch: cachedFetch, + }); + + try { + const result = await generateText({ + model: openrouter(model), + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'What is the verification code in this PDF? Reply with just the code.', + }, + { + type: 'file', + data: pdfDataUrl, + mediaType: 'application/pdf', + }, + ], + }, + ], + }); + + const codeExtracted = extractCode(result.text); + return { + model, + success: true, + codeExtracted, + matches: codeExtracted === expectedCode, + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + return { + model, + success: false, + codeExtracted: null, + matches: false, + error: truncate(errorMsg), + }; + } +} + +async function main() { + console.log('╔════════════════════════════════════════════════════════════════════════════╗'); + console.log('║ PDF Input Test: OpenAI vs Others via AI SDK + OpenRouter Provider ║'); + console.log('╚════════════════════════════════════════════════════════════════════════════╝'); + console.log(); + + // Create cached fetch + const cachedFetch = createCachedFetch({ enabled: true, ttlMs: 60 * 60 * 1000 }); + + // Load PDF fixture + console.log('Loading PDF fixture (small.pdf)...'); + const pdfDataUrl = await readPdfAsDataUrl('small'); + const expectedCode = await readExpectedCode('small'); + console.log(`Expected code: ${expectedCode}\n`); + + const results: TestResult[] = []; + + // Test each model sequentially to avoid rate limits + for (const model of MODELS_TO_TEST) { + console.log(`Testing: ${model}...`); + const result = await testModel(model, pdfDataUrl, expectedCode, cachedFetch); + results.push(result); + } + + // Print results table + console.log('\n=== Results ===\n'); + console.log('Model | Status | Code | Match'); + console.log('-------------------------------|---------|------------|------'); + + for (const r of results) { + const modelPad = r.model.padEnd(30); + const status = r.success ? 'SUCCESS' : 'FAIL '; + const code = (r.codeExtracted ?? 'N/A').padEnd(10); + const match = r.matches ? 'YES' : 'NO '; + console.log(`${modelPad} | ${status} | ${code} | ${match}`); + } + + // Show errors if any + const failures = results.filter((r) => !r.success); + if (failures.length > 0) { + console.log('\n=== Errors ===\n'); + for (const f of failures) { + console.log(`${f.model}:`); + console.log(` ${f.error}`); + } + } + + // Summary + console.log('\n=== Summary ===\n'); + const openaiResult = results.find((r) => r.model === 'openai/gpt-4o-mini'); + const anthropicResult = results.find((r) => r.model === 'anthropic/claude-3-5-sonnet'); + const googleResult = results.find((r) => r.model === 'google/gemini-2.0-flash-001'); + + if (openaiResult?.matches) { + console.log('✓ OpenAI PDF support: WORKING'); + } else if (openaiResult?.success) { + console.log('⚠ OpenAI PDF support: Request succeeded but code not found'); + } else { + console.log('✗ OpenAI PDF support: FAILING'); + console.log(' BUG CONFIRMED: OpenAI models cannot read PDFs via AI SDK + OpenRouter'); + } + + if (anthropicResult?.matches) { + console.log('✓ Anthropic PDF support: WORKING'); + } else { + console.log('✗ Anthropic PDF support: NOT WORKING'); + } + + if (googleResult?.matches) { + console.log('✓ Google PDF support: WORKING'); + } else { + console.log('✗ Google PDF support: NOT WORKING'); + } + + // Exit with error if OpenAI fails but others work (confirms the bug) + const bugConfirmed = !openaiResult?.matches && (anthropicResult?.matches || googleResult?.matches); + if (bugConfirmed) { + console.log('\n❌ BUG REPRODUCED: OpenAI fails while other providers work'); + process.exit(1); + } + + if (results.every((r) => r.matches)) { + console.log('\n✓ All models working - no bug present'); + process.exit(0); + } +} + +main().catch((err) => { + console.error('Fatal:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/typescript/fetch/src/pdf-direct-input/pdf-direct.ts b/typescript/fetch/src/pdf-direct-input/pdf-direct.ts index b7f5fcd..8a74ac1 100644 --- a/typescript/fetch/src/pdf-direct-input/pdf-direct.ts +++ b/typescript/fetch/src/pdf-direct-input/pdf-direct.ts @@ -9,9 +9,13 @@ * 2. "image_url" type - OpenAI native format (works for PDFs too) * * Expected verification code: SMALL-7X9Q2 + * + * Caching: Responses are cached to .cache/requests/ to avoid hitting the API + * repeatedly during development. Delete the cache to force fresh requests. */ import { readPdfAsDataUrl, readExpectedCode } from '@openrouter-examples/shared/fixtures'; +import { createCachedFetch } from '@openrouter-examples/shared/request-cache'; import type { ChatCompletionResponse } from '@openrouter-examples/shared/types'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; @@ -26,19 +30,18 @@ type MessageShape = 'file' | 'image_url'; const PROMPT = 'What is the verification code in this PDF? Reply with just the code.'; +// Use cached fetch to avoid hitting API repeatedly +const cachedFetch = createCachedFetch({ enabled: true, ttlMs: 60 * 60 * 1000 }); + /** Truncate string to max length */ function truncate(str: string, maxLen = 200): string { - if (str.length <= maxLen) { - return str; - } - return str.slice(0, maxLen) + '...'; + return str.length <= maxLen ? str : str.slice(0, maxLen) + '...'; } /** Extract error message from OpenRouter error response */ function extractErrorMessage(errorJson: string): string { try { const parsed = JSON.parse(errorJson); - // Try to get the raw error from provider if (parsed?.error?.metadata?.raw) { const rawParsed = JSON.parse(parsed.error.metadata.raw); return rawParsed?.error?.message ?? parsed.error.message; @@ -51,23 +54,13 @@ function extractErrorMessage(errorJson: string): string { /** * Extract verification code from response text. - * Handles various formats: "SMALL-7X9Q2", "SMALL - 7X9Q2", "**SMALL-7X9Q2**", etc. */ function extractCode(text: string): string | null { - // First normalize: remove markdown, extra spaces - const normalized = text - .replace(/\*+/g, '') // Remove markdown bold/italic - .replace(/\s+/g, ' ') // Normalize whitespace - .trim(); - - // Match pattern: WORD - ALPHANUMERIC (with optional spaces around dash) + const normalized = text.replace(/\*+/g, '').replace(/\s+/g, ' ').trim(); const match = normalized.match(/([A-Z]+)\s*[-–—]\s*([A-Z0-9]{5})/i); if (match) { - // Return normalized form without spaces return `${match[1].toUpperCase()}-${match[2].toUpperCase()}`; } - - // Fallback: try strict pattern const strictMatch = text.match(/[A-Z]+-[A-Z0-9]{5}/); return strictMatch ? strictMatch[0] : null; } @@ -84,27 +77,14 @@ interface TestResult { function buildMessageContent(shape: MessageShape, pdfDataUrl: string) { if (shape === 'file') { - // OpenRouter/Anthropic native format return [ { type: 'text', text: PROMPT }, - { - type: 'file', - file: { - filename: 'small.pdf', - file_data: pdfDataUrl, - }, - }, + { type: 'file', file: { filename: 'small.pdf', file_data: pdfDataUrl } }, ]; } - // OpenAI native format (image_url also works for PDFs) return [ { type: 'text', text: PROMPT }, - { - type: 'image_url', - image_url: { - url: pdfDataUrl, - }, - }, + { type: 'image_url', image_url: { url: pdfDataUrl } }, ]; } @@ -127,16 +107,11 @@ async function testPdfWithModel( const requestBody = { model, - messages: [ - { - role: 'user', - content: buildMessageContent(shape, pdfDataUrl), - }, - ], + messages: [{ role: 'user', content: buildMessageContent(shape, pdfDataUrl) }], }; try { - const response = await fetch(OPENROUTER_API_URL, { + const response = await cachedFetch(OPENROUTER_API_URL, { method: 'POST', headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, @@ -187,7 +162,6 @@ async function testPdfWithModel( async function main() { console.log('=== Example 01: Direct PDF Input via Raw OpenRouter API ===\n'); - // Load PDF and expected code console.log('Loading PDF fixture (small.pdf)...'); const pdfDataUrl = await readPdfAsDataUrl('small'); const expectedCode = await readExpectedCode('small'); @@ -196,7 +170,6 @@ async function main() { const shapes: MessageShape[] = ['file', 'image_url']; const results: TestResult[] = []; - // Test each model with each message shape for (const model of MODELS_TO_TEST) { for (const shape of shapes) { console.log(`Testing: ${model} with "${shape}" shape...`); @@ -211,12 +184,12 @@ async function main() { console.log('-------------------------------|-----------|---------|------------|------'); for (const r of results) { - const modelPadded = r.model.padEnd(30); - const shapePadded = r.shape.padEnd(9); + const modelPad = r.model.padEnd(30); + const shapePad = r.shape.padEnd(9); const status = r.success ? 'SUCCESS' : 'FAIL '; const code = (r.extractedCode ?? 'N/A').slice(0, 10).padEnd(10); const match = r.matches ? 'YES' : 'NO '; - console.log(`${modelPadded} | ${shapePadded} | ${status} | ${code} | ${match}`); + console.log(`${modelPad} | ${shapePad} | ${status} | ${code} | ${match}`); } // Summary by model @@ -228,8 +201,6 @@ async function main() { const imageUrlResult = modelResults.find((r) => r.shape === 'image_url'); console.log(`${model}:`); - - // File shape result const fileStatus = fileResult?.matches ? 'WORKS' : 'FAILS'; const fileDetail = fileResult?.error ? truncate(fileResult.error, 80) @@ -238,23 +209,19 @@ async function main() { : ''; console.log(` - "file" shape: ${fileStatus} ${fileDetail ? `(${fileDetail})` : ''}`); - // Image_url shape result const imageUrlStatus = imageUrlResult?.matches ? 'WORKS' : 'FAILS'; const imageUrlDetail = imageUrlResult?.error ? truncate(imageUrlResult.error, 80) : imageUrlResult?.success && !imageUrlResult?.matches ? `Response: "${truncate(imageUrlResult.rawResponse ?? '', 80)}"` : ''; - console.log( - ` - "image_url" shape: ${imageUrlStatus} ${imageUrlDetail ? `(${imageUrlDetail})` : ''}`, - ); + console.log(` - "image_url" shape: ${imageUrlStatus} ${imageUrlDetail ? `(${imageUrlDetail})` : ''}`); console.log(); } // Key findings console.log('=== Key Findings ===\n'); - // Analyze "file" shape support const fileShapeWorks = results.filter((r) => r.shape === 'file' && r.matches); const imageUrlShapeWorks = results.filter((r) => r.shape === 'image_url' && r.matches); @@ -277,12 +244,9 @@ async function main() { } console.log('\nConclusion:'); - console.log( - ' The "file" shape is the universal format for PDF input across OpenRouter models.', - ); + console.log(' The "file" shape is the universal format for PDF input across OpenRouter models.'); console.log(' The "image_url" shape only works with Google models for PDFs.'); - // Exit code: success if OpenAI works with any shape const anyOpenAIWorks = results.some((r) => r.model === 'openai/gpt-4o-mini' && r.matches); if (!anyOpenAIWorks) { console.log('\n⚠️ OpenAI PDF support: NOT WORKING with any tested shape'); diff --git a/typescript/shared/package.json b/typescript/shared/package.json index 6d2739a..f70fb98 100644 --- a/typescript/shared/package.json +++ b/typescript/shared/package.json @@ -9,7 +9,8 @@ "exports": { "./constants": "./src/constants.ts", "./types": "./src/types.ts", - "./fixtures": "./src/fixtures.ts" + "./fixtures": "./src/fixtures.ts", + "./request-cache": "./src/request-cache.ts" }, "devDependencies": { "@types/bun": "1.3.2", diff --git a/typescript/shared/src/request-cache.ts b/typescript/shared/src/request-cache.ts new file mode 100644 index 0000000..c770c10 --- /dev/null +++ b/typescript/shared/src/request-cache.ts @@ -0,0 +1,189 @@ +/** + * Request/Response caching for OpenRouter API calls + * + * This module provides caching to avoid hitting the API repeatedly during development. + * Cache is keyed by a hash of the request body. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const CACHE_DIR = join(__dirname, '../../../.cache/requests'); + +export interface CachedResponse { + status: number; + statusText: string; + headers: Record; + body: string; + timestamp: number; +} + +export interface CacheEntry { + request: { + url: string; + method: string; + body: unknown; + }; + response: CachedResponse; +} + +/** + * Generate a cache key from request details + */ +function getCacheKey(url: string, body: unknown): string { + const hash = createHash('sha256'); + hash.update(url); + hash.update(JSON.stringify(body)); + return hash.digest('hex').slice(0, 16); +} + +/** + * Get the cache file path for a given key + */ +function getCachePath(key: string): string { + return join(CACHE_DIR, `${key}.json`); +} + +/** + * Check if a cached response exists and is valid + */ +export function getCachedResponse(url: string, body: unknown): CacheEntry | null { + const key = getCacheKey(url, body); + const cachePath = getCachePath(key); + + if (!existsSync(cachePath)) { + return null; + } + + try { + const cached = JSON.parse(readFileSync(cachePath, 'utf-8')) as CacheEntry; + return cached; + } catch { + return null; + } +} + +/** + * Save a response to the cache + */ +export function cacheResponse( + url: string, + requestBody: unknown, + response: CachedResponse, +): void { + const key = getCacheKey(url, requestBody); + const cachePath = getCachePath(key); + + // Ensure cache directory exists + if (!existsSync(CACHE_DIR)) { + mkdirSync(CACHE_DIR, { recursive: true }); + } + + const entry: CacheEntry = { + request: { + url, + method: 'POST', + body: requestBody, + }, + response, + }; + + writeFileSync(cachePath, JSON.stringify(entry, null, 2)); +} + +/** + * Create a cached fetch function for OpenRouter API calls + * + * @param options.enabled - Whether caching is enabled (default: true) + * @param options.ttlMs - Cache TTL in milliseconds (default: 1 hour) + * @returns A fetch function that caches responses + */ +export function createCachedFetch( + options: { enabled?: boolean; ttlMs?: number } = {}, +): typeof fetch { + const { enabled = true, ttlMs = 60 * 60 * 1000 } = options; + + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === 'string' ? input : input.toString(); + + // Only cache POST requests with JSON body + if (!enabled || init?.method !== 'POST' || !init.body) { + return fetch(input, init); + } + + let requestBody: unknown; + try { + requestBody = JSON.parse(init.body as string); + } catch { + return fetch(input, init); + } + + // Check cache + const cached = getCachedResponse(url, requestBody); + if (cached) { + const age = Date.now() - cached.response.timestamp; + if (age < ttlMs) { + console.log(`[CACHE HIT] ${url} (age: ${Math.round(age / 1000)}s)`); + return new Response(cached.response.body, { + status: cached.response.status, + statusText: cached.response.statusText, + headers: cached.response.headers, + }); + } + console.log(`[CACHE EXPIRED] ${url}`); + } + + // Make actual request + console.log(`[CACHE MISS] ${url}`); + const response = await fetch(input, init); + + // Clone response to read body without consuming it + const clone = response.clone(); + const body = await clone.text(); + + // Cache the response + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + + cacheResponse(url, requestBody, { + status: response.status, + statusText: response.statusText, + headers, + body, + timestamp: Date.now(), + }); + + // Return a new response with the same body + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }; +} + +/** + * Truncate base64 data in objects for logging + */ +export function truncateForLog(obj: unknown, maxLen = 100): unknown { + if (typeof obj === 'string') { + return obj.length > maxLen ? obj.slice(0, maxLen) + `... [${obj.length} chars]` : obj; + } + if (Array.isArray(obj)) { + return obj.map((item) => truncateForLog(item, maxLen)); + } + if (obj && typeof obj === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = truncateForLog(value, maxLen); + } + return result; + } + return obj; +} From e46de6214216b932d3cc943ffd58b1b9e7f6597a Mon Sep 17 00:00:00 2001 From: Tom Aylott Date: Thu, 18 Dec 2025 13:29:37 -0500 Subject: [PATCH 4/5] Cache response body as json or text based on parse result If response body parses as JSON, store as body.json object. Otherwise store as body.text string. Makes cache files readable. --- typescript/shared/src/request-cache.ts | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/typescript/shared/src/request-cache.ts b/typescript/shared/src/request-cache.ts index c770c10..ae21a9b 100644 --- a/typescript/shared/src/request-cache.ts +++ b/typescript/shared/src/request-cache.ts @@ -14,11 +14,18 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const CACHE_DIR = join(__dirname, '../../../.cache/requests'); +export interface CachedResponseBody { + /** Parsed JSON if body was valid JSON */ + json?: unknown; + /** Raw text if body was not valid JSON */ + text?: string; +} + export interface CachedResponse { status: number; statusText: string; headers: Record; - body: string; + body: CachedResponseBody; timestamp: number; } @@ -128,7 +135,11 @@ export function createCachedFetch( const age = Date.now() - cached.response.timestamp; if (age < ttlMs) { console.log(`[CACHE HIT] ${url} (age: ${Math.round(age / 1000)}s)`); - return new Response(cached.response.body, { + // Reconstruct body from cached format + const bodyText = cached.response.body.json !== undefined + ? JSON.stringify(cached.response.body.json) + : cached.response.body.text ?? ''; + return new Response(bodyText, { status: cached.response.status, statusText: cached.response.statusText, headers: cached.response.headers, @@ -143,7 +154,15 @@ export function createCachedFetch( // Clone response to read body without consuming it const clone = response.clone(); - const body = await clone.text(); + const bodyText = await clone.text(); + + // Try to parse as JSON, fall back to text + let body: CachedResponseBody; + try { + body = { json: JSON.parse(bodyText) }; + } catch { + body = { text: bodyText }; + } // Cache the response const headers: Record = {}; @@ -160,7 +179,7 @@ export function createCachedFetch( }); // Return a new response with the same body - return new Response(body, { + return new Response(bodyText, { status: response.status, statusText: response.statusText, headers: response.headers, From ddcd4433491c8f78f6a51b9587d4427d699cb8e4 Mon Sep 17 00:00:00 2001 From: Tom Aylott Date: Thu, 18 Dec 2025 14:22:49 -0500 Subject: [PATCH 5/5] ++ --- .gitignore | 3 +- azure-file-type-bug-evidence.zip | Bin 0 -> 31450 bytes typescript/ai-sdk-v5/package.json | 2 +- .../pdf-openai-regression/pdf-openai-test.ts | 8 + .../pdf-provider-matrix.ts | 230 ++++++++++++++ typescript/shared/src/json-sidecar.ts | 232 ++++++++++++++ typescript/shared/src/request-cache.ts | 289 +++++++++++++++--- 7 files changed, 717 insertions(+), 47 deletions(-) create mode 100644 azure-file-type-bug-evidence.zip create mode 100644 typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-provider-matrix.ts create mode 100644 typescript/shared/src/json-sidecar.ts diff --git a/.gitignore b/.gitignore index 2fc49cc..fd7be61 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ typescript/bun.lock *.log .DS_Store .env -typescript/.env.local +.env.local +*.env.local .cache/ diff --git a/azure-file-type-bug-evidence.zip b/azure-file-type-bug-evidence.zip new file mode 100644 index 0000000000000000000000000000000000000000..f400bc6b916c7a48134568e8a71dae6d88b43ac9 GIT binary patch literal 31450 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 M{+;lf?*AD5FMqV*#sB~S literal 0 HcmV?d00001 diff --git a/typescript/ai-sdk-v5/package.json b/typescript/ai-sdk-v5/package.json index 3b55f9e..66b81a9 100644 --- a/typescript/ai-sdk-v5/package.json +++ b/typescript/ai-sdk-v5/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@openrouter-examples/shared": "workspace:*", - "@openrouter/ai-sdk-provider": "1.5.3", + "@openrouter/ai-sdk-provider": "1.5.4", "ai": "5.0.108" }, "devDependencies": { diff --git a/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts b/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts index 281dd24..64e3459 100644 --- a/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts +++ b/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-openai-test.ts @@ -22,9 +22,17 @@ import { readPdfAsDataUrl, readExpectedCode } from '@openrouter-examples/shared/ import { createCachedFetch } from '@openrouter-examples/shared/request-cache'; const MODELS_TO_TEST = [ + // OpenAI models - testing various variants 'openai/gpt-4o-mini', + 'openai/gpt-4o', + 'openai/gpt-4-turbo', + // Anthropic 'anthropic/claude-3-5-sonnet', + // Google 'google/gemini-2.0-flash-001', + // Other providers for comparison + 'x-ai/grok-3-mini-beta', + 'mistralai/pixtral-large-2411', ] as const; interface TestResult { diff --git a/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-provider-matrix.ts b/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-provider-matrix.ts new file mode 100644 index 0000000..8742ae5 --- /dev/null +++ b/typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-provider-matrix.ts @@ -0,0 +1,230 @@ +/** + * PDF Provider Matrix Test + * + * Tests PDF inputs against specific providers to find which ones fail. + * Uses OpenRouter's provider routing to force specific backends. + * + * This helps identify provider-specific bugs (e.g., Azure rejecting 'file' type). + * + * To run: bun run typescript/ai-sdk-v5/src/pdf-openai-regression/pdf-provider-matrix.ts + */ + +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText } from 'ai'; +import { readPdfAsDataUrl, readExpectedCode } from '@openrouter-examples/shared/fixtures'; +import { createCachedFetch } from '@openrouter-examples/shared/request-cache'; + +// Test matrix: model + specific provider combinations +// Provider slugs from: https://openrouter.ai/docs/features/provider-routing +const TEST_MATRIX = [ + // OpenAI model via different providers - THIS IS THE KEY TEST + { model: 'openai/gpt-4o', provider: 'OpenAI', label: 'gpt-4o via OpenAI direct' }, + { model: 'openai/gpt-4o', provider: 'Azure', label: 'gpt-4o via Azure' }, + + // GPT-4o-mini via different providers + { model: 'openai/gpt-4o-mini', provider: 'OpenAI', label: 'gpt-4o-mini via OpenAI direct' }, + { model: 'openai/gpt-4o-mini', provider: 'Azure', label: 'gpt-4o-mini via Azure' }, + + // Claude via different providers + { model: 'anthropic/claude-3-5-sonnet', provider: 'Anthropic', label: 'Claude via Anthropic' }, + { model: 'anthropic/claude-3-5-sonnet', provider: 'Amazon Bedrock', label: 'Claude via Bedrock' }, + { model: 'anthropic/claude-3-5-sonnet', provider: 'Google Vertex', label: 'Claude via Vertex' }, + + // Gemini via different providers + { model: 'google/gemini-2.0-flash-001', provider: 'Google AI Studio', label: 'Gemini via AI Studio' }, + { model: 'google/gemini-2.0-flash-001', provider: 'Google Vertex', label: 'Gemini via Vertex' }, + + // Other providers + { model: 'mistralai/pixtral-large-2411', provider: 'Mistral', label: 'Pixtral via Mistral' }, +] as const; + +interface TestResult { + label: string; + model: string; + provider: string; + success: boolean; + codeExtracted: string | null; + matches: boolean; + error?: string; + actualProvider?: string; +} + +function truncate(str: string, max = 200): string { + return str.length <= max ? str : str.slice(0, max) + '...'; +} + +function extractCode(text: string): string | null { + const match = text.match(/([A-Z]+)\s*[-–—]\s*([A-Z0-9]{5})/i); + if (match) { + return `${match[1].toUpperCase()}-${match[2].toUpperCase()}`; + } + const strict = text.match(/[A-Z]+-[A-Z0-9]{5}/); + return strict ? strict[0] : null; +} + +async function testModelProvider( + model: string, + providerSlug: string, + label: string, + pdfDataUrl: string, + expectedCode: string, + cachedFetch: typeof fetch, +): Promise { + const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + fetch: cachedFetch, + }); + + try { + const result = await generateText({ + model: openrouter(model, { + // Force specific provider using extraBody + extraBody: { + provider: { + only: [providerSlug], + }, + }, + }), + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'What is the verification code in this PDF? Reply with just the code.', + }, + { + type: 'file', + data: pdfDataUrl, + mediaType: 'application/pdf', + }, + ], + }, + ], + }); + + const codeExtracted = extractCode(result.text); + + // Try to extract actual provider from response metadata + let actualProvider: string | undefined; + const rawResponse = result.response as unknown; + if (rawResponse && typeof rawResponse === 'object' && 'body' in rawResponse) { + const body = (rawResponse as { body?: { provider?: string } }).body; + actualProvider = body?.provider; + } + + return { + label, + model, + provider: providerSlug, + success: true, + codeExtracted, + matches: codeExtracted === expectedCode, + actualProvider, + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + return { + label, + model, + provider: providerSlug, + success: false, + codeExtracted: null, + matches: false, + error: truncate(errorMsg), + }; + } +} + +async function main() { + console.log('╔════════════════════════════════════════════════════════════════════════════╗'); + console.log('║ PDF Provider Matrix Test - Targeting Specific Backends ║'); + console.log('╚════════════════════════════════════════════════════════════════════════════╝'); + console.log(); + + const cachedFetch = createCachedFetch({ enabled: true, ttlMs: 60 * 60 * 1000 }); + + console.log('Loading PDF fixture (small.pdf)...'); + const pdfDataUrl = await readPdfAsDataUrl('small'); + const expectedCode = await readExpectedCode('small'); + console.log(`Expected code: ${expectedCode}\n`); + + const results: TestResult[] = []; + + for (const test of TEST_MATRIX) { + console.log(`Testing: ${test.label}...`); + const result = await testModelProvider( + test.model, + test.provider, + test.label, + pdfDataUrl, + expectedCode, + cachedFetch, + ); + results.push(result); + } + + // Print results table + console.log('\n=== Results ===\n'); + console.log('Test Case | Status | Code | Match'); + console.log('---------------------------------------------|---------|------------|------'); + + for (const r of results) { + const labelPad = r.label.padEnd(44); + const status = r.success ? 'SUCCESS' : 'FAIL '; + const code = (r.codeExtracted ?? 'N/A').padEnd(10); + const match = r.matches ? 'YES' : 'NO '; + console.log(`${labelPad} | ${status} | ${code} | ${match}`); + } + + // Show errors + const failures = results.filter((r) => !r.success); + if (failures.length > 0) { + console.log('\n=== Failures ===\n'); + for (const f of failures) { + console.log(`${f.label}:`); + console.log(` Model: ${f.model}`); + console.log(` Provider: ${f.provider}`); + console.log(` Error: ${f.error}`); + console.log(); + } + } + + // Summary + console.log('\n=== Summary ===\n'); + const successCount = results.filter((r) => r.matches).length; + const failCount = results.filter((r) => !r.success).length; + const partialCount = results.filter((r) => r.success && !r.matches).length; + + console.log(`Total tests: ${results.length}`); + console.log(` ✓ Success (code matched): ${successCount}`); + console.log(` ⚠ Partial (response but wrong code): ${partialCount}`); + console.log(` ✗ Failed (error): ${failCount}`); + + // Identify provider-specific issues + const providerIssues = new Map(); + for (const r of results) { + if (!r.success) { + const issues = providerIssues.get(r.provider) || []; + issues.push(`${r.model}: ${r.error}`); + providerIssues.set(r.provider, issues); + } + } + + if (providerIssues.size > 0) { + console.log('\n=== Provider-Specific Issues ===\n'); + for (const [provider, issues] of providerIssues) { + console.log(`${provider}:`); + for (const issue of issues) { + console.log(` - ${issue}`); + } + } + } + + process.exit(failCount > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('Fatal:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/typescript/shared/src/json-sidecar.ts b/typescript/shared/src/json-sidecar.ts new file mode 100644 index 0000000..7f9cdbf --- /dev/null +++ b/typescript/shared/src/json-sidecar.ts @@ -0,0 +1,232 @@ +/** + * JSON Sidecar - Store large string values in separate files + * + * When stringifying, any string value over a threshold is replaced with a + * reference and the value is written to a separate file. + * + * Special handling for data URLs: the prefix (e.g., "data:application/pdf;base64,") + * is kept in the main file, only the base64 blob goes to the sidecar. + * + * Reference format: + * - Plain string: `__SIDECAR__:{hash}` + * - Data URL: `data:application/pdf;base64,__SIDECAR__:{hash}` + * + * This keeps the main JSON file small and readable while preserving large + * blobs (like base64 PDFs) in sidecars. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const SIDECAR_MARKER = '__SIDECAR__:'; +const DEFAULT_THRESHOLD = 1000; // Strings larger than this go to sidecar + +// Pattern to match data URLs: data:;base64, +const DATA_URL_REGEX = /^(data:[^;]+;base64,)(.+)$/; + +interface SidecarOptions { + /** Directory to store sidecar files (defaults to same dir as main file) */ + sidecarDir?: string; + /** Threshold in chars - strings larger than this go to sidecar (default: 1000) */ + threshold?: number; +} + +/** + * Generate a short hash for a string value + */ +function hashValue(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +/** + * Check if a string contains a sidecar reference + */ +function hasSidecarRef(value: string): boolean { + return value.includes(SIDECAR_MARKER); +} + +/** + * Create a sidecar reference string + */ +function makeSidecarRef(hash: string): string { + return `${SIDECAR_MARKER}${hash}`; +} + +/** + * Extract sidecar hash from a reference + */ +function extractSidecarHash(ref: string): string | null { + const idx = ref.indexOf(SIDECAR_MARKER); + if (idx === -1) { + return null; + } + return ref.slice(idx + SIDECAR_MARKER.length); +} + +/** + * Process a large string for storage - returns the reference string + * and the content to store in the sidecar. + */ +function processLargeString(value: string): { ref: string; content: string } { + const dataUrlMatch = value.match(DATA_URL_REGEX); + + if (dataUrlMatch) { + // Data URL: keep prefix in main file, store base64 in sidecar + const prefix = dataUrlMatch[1]; // e.g., "data:application/pdf;base64," + const base64Data = dataUrlMatch[2]; + const hash = hashValue(base64Data); + return { + ref: `${prefix}${makeSidecarRef(hash)}`, + content: base64Data, + }; + } + + // Plain large string: store entire value in sidecar + const hash = hashValue(value); + return { + ref: makeSidecarRef(hash), + content: value, + }; +} + +/** + * Restore a sidecar reference to its original value + */ +function restoreSidecarRef(value: string, sidecarDir: string): string { + const hash = extractSidecarHash(value); + if (!hash) { + return value; + } + + const sidecarPath = join(sidecarDir, `${hash}.sidecar`); + if (!existsSync(sidecarPath)) { + console.warn(`Sidecar file not found: ${sidecarPath}`); + return value; + } + + const content = readFileSync(sidecarPath, 'utf-8'); + + // Check if this was a data URL (has prefix before the marker) + const markerIdx = value.indexOf(SIDECAR_MARKER); + if (markerIdx > 0) { + // Restore data URL: prefix + content + const prefix = value.slice(0, markerIdx); + return prefix + content; + } + + // Plain sidecar reference + return content; +} + +/** + * Stringify JSON with large strings stored in sidecar files. + * + * @param value - The value to stringify + * @param mainFilePath - Path where the main JSON file will be written + * @param options - Sidecar options + * @returns The JSON string (with sidecar references for large values) + */ +export function stringify( + value: unknown, + mainFilePath: string, + options: SidecarOptions = {}, +): string { + const { threshold = DEFAULT_THRESHOLD } = options; + const sidecarDir = options.sidecarDir ?? dirname(mainFilePath); + const sidecars: Map = new Map(); + + // Ensure sidecar directory exists + if (!existsSync(sidecarDir)) { + mkdirSync(sidecarDir, { recursive: true }); + } + + // Replacer that extracts large strings + const replacer = (_key: string, val: unknown): unknown => { + if (typeof val === 'string' && val.length > threshold) { + const { ref, content } = processLargeString(val); + const hash = extractSidecarHash(ref); + if (hash) { + sidecars.set(`${hash}.sidecar`, content); + } + return ref; + } + return val; + }; + + const json = JSON.stringify(value, replacer, 2); + + // Write all sidecar files + for (const [filename, content] of sidecars) { + const sidecarPath = join(sidecarDir, filename); + writeFileSync(sidecarPath, content); + } + + return json; +} + +/** + * Parse JSON with sidecar references restored to original values. + * + * @param json - The JSON string to parse + * @param mainFilePath - Path where the main JSON file is located + * @param options - Sidecar options + * @returns The parsed value with sidecars restored + */ +export function parse( + json: string, + mainFilePath: string, + options: SidecarOptions = {}, +): T { + const sidecarDir = options.sidecarDir ?? dirname(mainFilePath); + + // Reviver that restores sidecar references + const reviver = (_key: string, val: unknown): unknown => { + if (typeof val === 'string' && hasSidecarRef(val)) { + return restoreSidecarRef(val, sidecarDir); + } + return val; + }; + + return JSON.parse(json, reviver) as T; +} + +/** + * Write a value to a JSON file with sidecars for large strings. + */ +export function writeFile( + filePath: string, + value: unknown, + options: SidecarOptions = {}, +): void { + const json = stringify(value, filePath, options); + writeFileSync(filePath, json); +} + +/** + * Read a JSON file and restore any sidecar references. + */ +export function readFile( + filePath: string, + options: SidecarOptions = {}, +): T { + const json = readFileSync(filePath, 'utf-8'); + return parse(json, filePath, options); +} + +/** + * Check if a parsed JSON object has any unresolved sidecar references + * (useful for debugging missing sidecars) + */ +export function hasUnresolvedRefs(obj: unknown): boolean { + if (typeof obj === 'string' && hasSidecarRef(obj)) { + return true; + } + if (Array.isArray(obj)) { + return obj.some(hasUnresolvedRefs); + } + if (obj && typeof obj === 'object') { + return Object.values(obj).some(hasUnresolvedRefs); + } + return false; +} diff --git a/typescript/shared/src/request-cache.ts b/typescript/shared/src/request-cache.ts index ae21a9b..ec60e51 100644 --- a/typescript/shared/src/request-cache.ts +++ b/typescript/shared/src/request-cache.ts @@ -2,17 +2,51 @@ * Request/Response caching for OpenRouter API calls * * This module provides caching to avoid hitting the API repeatedly during development. - * Cache is keyed by a hash of the request body. + * Cache is keyed by a hash of the request body (excluding volatile fields). + * + * Cache structure (folder per request): + * .cache/requests/{key}/ + * - meta.json - Small metadata: url, model, status, timestamp, summary, stack trace + * - request.json - Request body (large base64 strings in sidecars) + * - response.json - Response body + * - *.sidecar - Large string values (base64 blobs) */ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as JsonSidecar from './json-sidecar.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const CACHE_DIR = join(__dirname, '../../../.cache/requests'); +const SIDECAR_DIR = join(__dirname, '../../../.cache/sidecars'); // Shared sidecars to avoid duplication + +/** Metadata file - safe to read, contains no large blobs */ +export interface CacheMeta { + key: string; + url: string; + method: string; + model: string | null; + /** Provider routing config if specified */ + provider?: unknown; + status: number; + statusText: string; + timestamp: number; + /** ISO timestamp for human readability */ + timestampISO: string; + /** First 500 chars of response text for quick inspection */ + responseSummary: string; + /** Whether the request succeeded (2xx status) */ + success: boolean; + /** Error message if failed */ + errorMessage?: string; + /** Stack trace showing where the request originated */ + stackTrace: string[]; + /** Caller file (first non-library frame) */ + callerFile?: string; +} export interface CachedResponseBody { /** Parsed JSON if body was valid JSON */ @@ -29,77 +63,228 @@ export interface CachedResponse { timestamp: number; } -export interface CacheEntry { - request: { - url: string; - method: string; - body: unknown; - }; - response: CachedResponse; +/** + * Extract the relevant parts of a stack trace for debugging + */ +function getStackTrace(): { frames: string[]; callerFile?: string } { + const stack = new Error().stack ?? ''; + const lines = stack.split('\n').slice(1); // Remove "Error" line + + const frames: string[] = []; + let callerFile: string | undefined; + + for (const line of lines) { + const trimmed = line.trim(); + // Skip internal frames + if ( + trimmed.includes('request-cache.ts') || + trimmed.includes('node:internal') || + trimmed.includes('node_modules') + ) { + continue; + } + + // Extract file:line from the frame + const match = trimmed.match(/at\s+(?:.*?\s+)?\(?(.+?):(\d+):(\d+)\)?$/); + if (match) { + const [, filePath, line, col] = match; + // Make path relative to workspace + const relPath = filePath.startsWith('/') + ? relative(join(__dirname, '../../../../..'), filePath) + : filePath; + const frame = `${relPath}:${line}:${col}`; + frames.push(frame); + + // First frame is the caller + if (!callerFile) { + callerFile = frame; + } + } + } + + return { frames: frames.slice(0, 5), callerFile }; // Keep top 5 frames } /** - * Generate a cache key from request details + * Generate a cache key from request details. + * Normalizes the body to improve cache hits (removes volatile fields). */ function getCacheKey(url: string, body: unknown): string { const hash = createHash('sha256'); hash.update(url); - hash.update(JSON.stringify(body)); + + // Normalize body for better cache hits + const normalized = normalizeRequestBody(body); + hash.update(JSON.stringify(normalized)); + return hash.digest('hex').slice(0, 16); } /** - * Get the cache file path for a given key + * Normalize request body to improve cache hits. + * Removes/normalizes volatile fields that don't affect the semantic request. */ -function getCachePath(key: string): string { - return join(CACHE_DIR, `${key}.json`); +function normalizeRequestBody(body: unknown): unknown { + if (!body || typeof body !== 'object') { + return body; + } + + const obj = body as Record; + const normalized: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + // Keep most fields as-is + normalized[key] = value; + } + + return normalized; +} + +/** + * Get the cache folder path for a given key + */ +function getCacheFolder(key: string): string { + return join(CACHE_DIR, key); +} + +/** + * Get file paths for cache entry (inside a folder) + */ +function getCachePaths(key: string) { + const folder = getCacheFolder(key); + return { + folder, + meta: join(folder, 'meta.json'), + request: join(folder, 'request.json'), + response: join(folder, 'response.json'), + }; +} + +/** + * Extract model from request body + */ +function extractModel(body: unknown): string | null { + if (body && typeof body === 'object' && 'model' in body) { + return String((body as { model: unknown }).model); + } + return null; +} + +/** + * Extract provider config from request body + */ +function extractProvider(body: unknown): unknown | undefined { + if (body && typeof body === 'object' && 'provider' in body) { + return (body as { provider: unknown }).provider; + } + return undefined; +} + +/** + * Extract error message from response + */ +function extractErrorMessage(body: CachedResponseBody): string | undefined { + if (body.json && typeof body.json === 'object') { + const json = body.json as Record; + if (json.error && typeof json.error === 'object') { + const error = json.error as Record; + if (typeof error.message === 'string') { + return error.message; + } + } + } + return undefined; +} + +/** + * Get response summary for quick inspection + */ +function getResponseSummary(body: CachedResponseBody, maxLen = 500): string { + if (body.json) { + const str = JSON.stringify(body.json); + return str.length > maxLen ? str.slice(0, maxLen) + '...' : str; + } + if (body.text) { + return body.text.length > maxLen ? body.text.slice(0, maxLen) + '...' : body.text; + } + return ''; } /** * Check if a cached response exists and is valid */ -export function getCachedResponse(url: string, body: unknown): CacheEntry | null { +export function getCachedResponse( + url: string, + body: unknown, +): { meta: CacheMeta; response: CachedResponse } | null { const key = getCacheKey(url, body); - const cachePath = getCachePath(key); + const paths = getCachePaths(key); - if (!existsSync(cachePath)) { + if (!existsSync(paths.meta) || !existsSync(paths.response)) { return null; } try { - const cached = JSON.parse(readFileSync(cachePath, 'utf-8')) as CacheEntry; - return cached; + const meta = JSON.parse(readFileSync(paths.meta, 'utf-8')) as CacheMeta; + const response = JSON.parse(readFileSync(paths.response, 'utf-8')) as CachedResponse; + return { meta, response }; } catch { return null; } } /** - * Save a response to the cache + * Save a response to the cache (in a folder) */ export function cacheResponse( url: string, requestBody: unknown, response: CachedResponse, + stackInfo?: { frames: string[]; callerFile?: string }, ): void { const key = getCacheKey(url, requestBody); - const cachePath = getCachePath(key); + const paths = getCachePaths(key); - // Ensure cache directory exists - if (!existsSync(CACHE_DIR)) { - mkdirSync(CACHE_DIR, { recursive: true }); + // Ensure cache folder exists + if (!existsSync(paths.folder)) { + mkdirSync(paths.folder, { recursive: true }); } - const entry: CacheEntry = { - request: { - url, - method: 'POST', - body: requestBody, - }, - response, + const model = extractModel(requestBody); + const provider = extractProvider(requestBody); + const success = response.status >= 200 && response.status < 300; + const errorMessage = success ? undefined : extractErrorMessage(response.body); + const { frames, callerFile } = stackInfo ?? getStackTrace(); + + // Write metadata (small, safe to read) + const meta: CacheMeta = { + key, + url, + method: 'POST', + model, + provider, + status: response.status, + statusText: response.statusText, + timestamp: response.timestamp, + timestampISO: new Date(response.timestamp).toISOString(), + responseSummary: getResponseSummary(response.body), + success, + errorMessage, + stackTrace: frames, + callerFile, }; + writeFileSync(paths.meta, JSON.stringify(meta, null, 2)); - writeFileSync(cachePath, JSON.stringify(entry, null, 2)); + // Ensure shared sidecar directory exists + if (!existsSync(SIDECAR_DIR)) { + mkdirSync(SIDECAR_DIR, { recursive: true }); + } + + // Write full request body using SHARED sidecar dir to avoid duplicating large blobs + JsonSidecar.writeFile(paths.request, requestBody, { sidecarDir: SIDECAR_DIR }); + + // Write full response (typically small, but use sidecar just in case) + JsonSidecar.writeFile(paths.response, response, { sidecarDir: SIDECAR_DIR }); } /** @@ -117,6 +302,9 @@ export function createCachedFetch( return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const url = typeof input === 'string' ? input : input.toString(); + // Capture stack trace early (before async operations) + const stackInfo = getStackTrace(); + // Only cache POST requests with JSON body if (!enabled || init?.method !== 'POST' || !init.body) { return fetch(input, init); @@ -134,22 +322,28 @@ export function createCachedFetch( if (cached) { const age = Date.now() - cached.response.timestamp; if (age < ttlMs) { - console.log(`[CACHE HIT] ${url} (age: ${Math.round(age / 1000)}s)`); + const model = cached.meta.model ?? 'unknown'; + console.log(`[CACHE HIT] ${model} (age: ${Math.round(age / 1000)}s)`); // Reconstruct body from cached format - const bodyText = cached.response.body.json !== undefined - ? JSON.stringify(cached.response.body.json) - : cached.response.body.text ?? ''; + const bodyText = + cached.response.body.json !== undefined + ? JSON.stringify(cached.response.body.json) + : (cached.response.body.text ?? ''); return new Response(bodyText, { status: cached.response.status, statusText: cached.response.statusText, headers: cached.response.headers, }); } - console.log(`[CACHE EXPIRED] ${url}`); + console.log(`[CACHE EXPIRED] ${cached.meta.model ?? url}`); } // Make actual request - console.log(`[CACHE MISS] ${url}`); + const model = extractModel(requestBody); + const provider = extractProvider(requestBody); + const providerInfo = provider ? ` via ${JSON.stringify(provider)}` : ''; + console.log(`[CACHE MISS] ${model ?? url}${providerInfo}`); + const response = await fetch(input, init); // Clone response to read body without consuming it @@ -170,13 +364,18 @@ export function createCachedFetch( headers[key] = value; }); - cacheResponse(url, requestBody, { - status: response.status, - statusText: response.statusText, - headers, - body, - timestamp: Date.now(), - }); + cacheResponse( + url, + requestBody, + { + status: response.status, + statusText: response.statusText, + headers, + body, + timestamp: Date.now(), + }, + stackInfo, + ); // Return a new response with the same body return new Response(bodyText, {