UNPKG

@aj-archipelago/cortex

Version:

Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.

546 lines (476 loc) 19.8 kB
import Claude3VertexPlugin from "./claude3VertexPlugin.js"; import logger from "../../lib/logger.js"; import axios from 'axios'; import { sanitizeBase64 } from "../../lib/util.js"; // Claude 4 default maximum file size limit (30MB) for both images and PDFs const CLAUDE4_DEFAULT_MAX_FILE_SIZE = 30 * 1024 * 1024; // 30MB // Helper function to detect file type from URL or content function detectFileType(url, contentType) { const lowerUrl = url.toLowerCase(); // Check for data URLs first and extract media type if (lowerUrl.startsWith('data:')) { const match = lowerUrl.match(/data:([^;,]+)/); if (match) { const mediaType = match[1]; if (mediaType.includes('pdf')) return 'pdf'; if (mediaType.includes('text') || mediaType.includes('markdown')) return 'text'; } } // Check URL extension - extract path before query string/fragment and check if it ends with extension // Remove query string and fragment for more accurate extension detection const urlPath = lowerUrl.split('?')[0].split('#')[0]; if (urlPath.endsWith('.pdf')) return 'pdf'; if (urlPath.endsWith('.txt') || urlPath.endsWith('.text')) return 'text'; if (urlPath.endsWith('.md') || urlPath.endsWith('.markdown')) return 'text'; // Check content type parameter if (contentType) { if (contentType.includes('pdf')) return 'pdf'; if (contentType.includes('text/plain') || contentType.includes('text/markdown')) return 'text'; } return null; } // Fetch image and convert to base64 data URL (copy from parent) async function fetchImageAsDataURL(imageUrl) { try { const dataResponse = await axios.get(imageUrl, { timeout: 30000, responseType: 'arraybuffer', maxRedirects: 5 }); const contentType = dataResponse.headers['content-type']; const base64Image = Buffer.from(dataResponse.data).toString('base64'); return `data:${contentType};base64,${base64Image}`; } catch (e) { logger.error(`Failed to fetch image: ${imageUrl}. ${e}`); throw e; } } // Fetch file and convert to base64 async function fetchFileAsDataURL(fileUrl, fileType) { try { const dataResponse = await axios.get(fileUrl, { timeout: 30000, responseType: 'arraybuffer', maxRedirects: 5 }); const contentType = dataResponse.headers['content-type']; const base64Data = Buffer.from(dataResponse.data).toString('base64'); // Return appropriate data URL format if (fileType === 'pdf') { return `data:application/pdf;base64,${base64Data}`; } else if (fileType === 'text') { return `data:text/plain;base64,${base64Data}`; } return `data:${contentType};base64,${base64Data}`; } catch (e) { logger.error(`Failed to fetch file: ${fileUrl}. ${e}`); throw e; } } // Fetch text file and return as plain text async function fetchTextFileAsString(fileUrl) { try { const dataResponse = await axios.get(fileUrl, { timeout: 30000, responseType: 'text', maxRedirects: 5 }); return dataResponse.data; } catch (e) { logger.error(`Failed to fetch text file: ${fileUrl}. ${e}`); throw e; } } // Extended convertContentItem function that handles PDFs and text files async function convertContentItemClaude4(item, maxImageSize, plugin) { try { switch (typeof item) { case "string": return item ? { type: "text", text: item } : null; case "object": switch (item.type) { case "text": // Handle text content, but also check if it's stringified JSON containing documents if (!item.text) return null; // Try to parse stringified JSON to check if it's actually a document let parsedText = item.text; if (typeof item.text === 'string' && item.text.startsWith('{')) { try { const parsed = JSON.parse(item.text); // If this is stringified JSON for an image_url or document, process it accordingly if (parsed.type === 'image_url') { return await convertContentItemClaude4(parsed, maxImageSize, plugin); } else if (parsed.type === 'document') { return await convertContentItemClaude4(parsed, maxImageSize, plugin); } } catch (e) { // Not valid JSON, treat as plain text } } return { type: "text", text: parsedText }; case "tool_use": return { type: "tool_use", id: item.id, name: item.name, input: typeof item.input === 'string' ? { query: item.input } : item.input }; case "tool_result": return { type: "tool_result", tool_use_id: item.tool_use_id, content: item.content }; case "image_url": // Handle images and documents coming as image_url type // May include: image_url.url, url, originalFilename // Note: gcs URLs are for Google models only, Claude uses the main url // Handle both: { image_url: "string" } and { image_url: { url: "string" } } let imageUrl = item.url || item.image_url?.url; if (typeof item.image_url === 'string') { imageUrl = item.image_url; } const originalFilename = item.originalFilename || ''; if (!imageUrl) { logger.warn("Could not parse image URL from content - skipping image content."); return null; } // Check if this is actually a PDF document (by filename or URL extension) // Do this BEFORE image validation since PDFs are not images const isPDF = originalFilename.toLowerCase().endsWith('.pdf') || detectFileType(imageUrl) === 'pdf'; const isTxt = originalFilename.toLowerCase().endsWith('.txt') || originalFilename.toLowerCase().endsWith('.md'); // Handle PDF documents if (isPDF) { try { // Fetch the PDF from the URL (Azure Blob Storage or http/https) const pdfData = await fetchFileAsDataURL(imageUrl, 'pdf'); const base64Pdf = pdfData.split(",")[1]; const pdfSize = Buffer.from(base64Pdf, 'base64').length; if (pdfSize > maxImageSize) { logger.warn(`PDF size ${pdfSize} bytes exceeds maximum allowed size ${maxImageSize} - skipping PDF content.`); return null; } return { type: "document", source: { type: "base64", media_type: "application/pdf", data: base64Pdf } }; } catch (error) { logger.error(`Failed to fetch PDF from image_url field: ${error.message}`); return null; } } // Handle text documents if (isTxt) { try { const textContent = await fetchTextFileAsString(imageUrl); return { type: "text", text: textContent }; } catch (error) { logger.error(`Failed to fetch text file from image_url field: ${error.message}`); return null; } } // Only validate and handle as image if not a document try { if (!await plugin.validateImageUrl(imageUrl)) { return null; } const urlData = imageUrl.startsWith("data:") ? imageUrl : await fetchImageAsDataURL(imageUrl); if (!urlData) { return null; } const base64Image = urlData.split(",")[1]; const base64Size = Buffer.from(base64Image, 'base64').length; if (base64Size > maxImageSize) { logger.warn(`Image size ${base64Size} bytes exceeds maximum allowed size ${maxImageSize} - skipping image content.`); return null; } const [, mimeType = "image/jpeg"] = urlData.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/) || []; return { type: "image", source: { type: "base64", media_type: mimeType, data: base64Image, }, }; } catch (error) { logger.error(`Failed to process image: ${error.message}`); return null; } case "document": // Handle Claude document blocks (PDFs and text files) const documentUrl = item.url || item.source?.url; const documentData = item.data || item.source?.data; const documentFileId = item.file_id || item.source?.file_id; if (documentFileId) { // Use file_id reference return { type: "document", source: { type: "file", file_id: documentFileId } }; } else if (documentUrl) { // Determine file type const fileType = detectFileType(documentUrl); if (fileType === 'pdf') { // Fetch PDF and convert to base64 const pdfData = await fetchFileAsDataURL(documentUrl, 'pdf'); const base64Pdf = pdfData.split(",")[1]; const pdfSize = Buffer.from(base64Pdf, 'base64').length; if (pdfSize > maxImageSize) { logger.warn(`PDF size ${pdfSize} bytes exceeds maximum allowed size ${maxImageSize} - skipping PDF content.`); return null; } return { type: "document", source: { type: "base64", media_type: "application/pdf", data: base64Pdf } }; } else if (fileType === 'text') { // For text files, we can send as plain text or base64 // Using plain text is more efficient const textContent = await fetchTextFileAsString(documentUrl); return { type: "text", text: textContent }; } else { logger.warn(`Unsupported document type for URL: ${documentUrl}`); return null; } } else if (documentData) { // Already have base64 data const mediaType = item.media_type || item.source?.media_type || "application/pdf"; if (mediaType.includes('pdf')) { const pdfSize = Buffer.from(documentData, 'base64').length; if (pdfSize > maxImageSize) { logger.warn(`PDF size ${pdfSize} bytes exceeds maximum allowed size ${maxImageSize} - skipping PDF content.`); return null; } return { type: "document", source: { type: "base64", media_type: "application/pdf", data: documentData } }; } else if (mediaType.includes('text')) { // Decode base64 text data const textContent = Buffer.from(documentData, 'base64').toString('utf-8'); return { type: "text", text: textContent }; } } logger.warn("Could not parse document content - skipping document."); return null; default: return null; } default: return null; } } catch (e) { logger.warn(`Error converting content item: ${e}`); return null; } } class Claude4VertexPlugin extends Claude3VertexPlugin { constructor(pathway, model) { super(pathway, model); this.isMultiModal = true; } // Override to use 30MB default for Claude 4 (instead of 20MB) getModelMaxImageSize() { return (this.promptParameters.maxImageSize ?? this.model.maxImageSize ?? CLAUDE4_DEFAULT_MAX_FILE_SIZE); } // Override convertMessagesToClaudeVertex to use the extended content conversion async convertMessagesToClaudeVertex(messages) { // Create a deep copy of the input messages const messagesCopy = JSON.parse(JSON.stringify(messages)); let system = ""; let imageCount = 0; const maxImages = 20; // Claude allows up to 20 images per request // Extract system messages const systemMessages = messagesCopy.filter(message => message.role === "system"); if (systemMessages.length > 0) { system = systemMessages.map(message => { if (Array.isArray(message.content)) { return message.content .filter(item => item.type === 'text') .map(item => item.text) .join("\n"); } return message.content; }).join("\n"); } // Filter out system messages and empty messages let modifiedMessages = messagesCopy .filter(message => message.role !== "system") .map(message => { if (message.tool_calls) { return { role: message.role, content: message.tool_calls.map(toolCall => ({ type: "tool_use", id: toolCall.id, name: toolCall.function.name, input: JSON.parse(toolCall.function.arguments) })) }; } if (message.role === "tool") { return { role: "user", content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }] }; } return { ...message }; }) .filter(message => { if (!message.content) return false; if (Array.isArray(message.content) && message.content.length === 0) return false; return true; }); // Combine consecutive messages from the same author const combinedMessages = modifiedMessages.reduce((acc, message) => { if (acc.length === 0 || message.role !== acc[acc.length - 1].role) { acc.push({ ...message }); } else { const lastMessage = acc[acc.length - 1]; if (Array.isArray(lastMessage.content) && Array.isArray(message.content)) { lastMessage.content = [...lastMessage.content, ...message.content]; } else if (Array.isArray(lastMessage.content)) { lastMessage.content.push({ type: 'text', text: message.content }); } else if (Array.isArray(message.content)) { lastMessage.content = [{ type: 'text', text: lastMessage.content }, ...message.content]; } else { lastMessage.content += "\n" + message.content; } } return acc; }, []); // Ensure an odd number of messages const finalMessages = combinedMessages.length % 2 === 0 ? combinedMessages.slice(1) : combinedMessages; // Convert content items using the extended conversion function const claude4Messages = await Promise.all( finalMessages.map(async (message) => { const contentArray = Array.isArray(message.content) ? message.content : [message.content]; const claude4Content = await Promise.all(contentArray.map(async item => { const convertedItem = await convertContentItemClaude4(item, this.getModelMaxImageSize(), this); // Track image count if (convertedItem?.type === 'image') { imageCount++; if (imageCount > maxImages) { logger.warn(`Maximum number of images (${maxImages}) exceeded - skipping additional images.`); return null; } } return convertedItem; })); return { role: message.role, content: claude4Content.filter(Boolean), }; }) ); return { system, modifiedMessages: claude4Messages, }; } // Override logging to handle document blocks logRequestData(data, responseData, prompt) { const { stream, messages, system } = data; if (system) { const { length, units } = this.getLength(system); logger.info(`[system messages sent containing ${length} ${units}]`); logger.verbose(`${this.shortenContent(system)}`); } if (messages && messages.length > 1) { logger.info(`[chat request sent containing ${messages.length} messages]`); let totalLength = 0; let totalUnits; messages.forEach((message, index) => { let content; if (Array.isArray(message.content)) { // Only stringify objects, not strings (which may already be JSON strings) content = message.content.map((item) => { if (item.type === 'document') { return `{type: document, source: ${JSON.stringify(sanitizeBase64(item.source))}}`; } const sanitized = sanitizeBase64(item); return typeof sanitized === 'string' ? sanitized : JSON.stringify(sanitized); }).join(", "); } else { content = message.content; } const { length, units } = this.getLength(content); const preview = this.shortenContent(content); logger.verbose( `message ${index + 1}: role: ${ message.role }, ${units}: ${length}, content: "${preview}"` ); totalLength += length; totalUnits = units; }); logger.info(`[chat request contained ${totalLength} ${totalUnits}]`); } else { const message = messages[0]; let content; if (Array.isArray(message.content)) { // Only stringify objects, not strings (which may already be JSON strings) content = message.content.map((item) => { if (item.type === 'document') { return `{type: document, source: ${JSON.stringify(sanitizeBase64(item.source))}}`; } const sanitized = sanitizeBase64(item); return typeof sanitized === 'string' ? sanitized : JSON.stringify(sanitized); }).join(", "); } else { content = message.content; } const { length, units } = this.getLength(content); logger.info(`[request sent containing ${length} ${units}]`); logger.verbose(`${this.shortenContent(content)}`); } if (stream) { logger.info(`[response received as an SSE stream]`); } else { const parsedResponse = this.parseResponse(responseData); if (typeof parsedResponse === 'string') { const { length, units } = this.getLength(parsedResponse); logger.info(`[response received containing ${length} ${units}]`); logger.verbose(`${this.shortenContent(parsedResponse)}`); } else { logger.info(`[response received containing object]`); logger.verbose(`${JSON.stringify(parsedResponse)}`); } } prompt && prompt.debugInfo && (prompt.debugInfo += `\n${JSON.stringify(data)}`); } } export default Claude4VertexPlugin;