/** * @node ollama-chat * @name Ollama Chat * @category ai * @version 1.0.0 * @description Send a prompt to an Ollama model, optionally with an image for vision models, and return the text or parsed JSON response * @icon ollama */ const configSchema = { type: 'object', properties: { baseUrl: { type: 'string', title: 'Ollama Base URL', description: 'Base URL of the Ollama instance. Use https://ollama.com to call the hosted API directly instead of proxying through a local daemon.', default: 'http://localhost:11434' }, authType: { type: 'string', title: 'Authentication', description: 'Hosted ollama.com requires a credential. A local daemon usually does not.', enum: ['none', 'credential'], enumLabels: ['None', 'Stored credential'], default: 'none' }, credentialId: { type: 'string', title: 'Credential', description: 'Stored Bearer credential holding the ollama.com API key', // Read as a header, so any header-shaped credential works. An IMAP // or database credential has no header to send and would only be // an option that fails. dynamicOptions: { source: 'credentials', filter: { type: ['basic', 'bearer', 'api_key', 'oauth2'] } }, default: '', showWhen: { field: 'authType', value: 'credential' } }, model: { type: 'string', title: 'Model', description: 'Model tag, for example llama3.2 or a vision model tag', default: 'llama3.2' }, systemPrompt: { type: 'string', title: 'System Prompt', description: 'Role and output contract for the model. When Response Format is JSON, mention the word JSON here so the model honours it.', format: 'textarea', default: '' }, userPrompt: { type: 'string', title: 'User Prompt', description: 'Message sent with the request. Supports {{variable}} interpolation.', format: 'textarea', default: 'Describe the input.' }, responseFormat: { type: 'string', title: 'Response Format', description: 'How to treat the reply. JSON parses the content and fails the attempt when it is not valid JSON.', enum: ['text', 'json'], enumLabels: ['Plain text', 'JSON object'], default: 'text' }, temperature: { type: 'number', title: 'Temperature', description: 'Sampling temperature. Use 0 for the most repeatable output.', default: 0 }, imageMode: { type: 'string', title: 'Image Input', description: 'Auto picks up binary or base64 image data from the previous node. Use None for text-only models.', enum: ['auto', 'none'], enumLabels: ['Auto detect', 'None'], default: 'auto' }, imageField: { valueKind: 'path', type: 'string', title: 'Image Field Override', description: 'Optional dot path to base64 image data, for example data.file.data', default: '', showWhen: { field: 'imageMode', value: 'auto' } }, passthroughImage: { type: 'boolean', title: 'Pass Image Through', description: 'Include the base64 image in the output so later nodes can store or reuse it', default: false }, retryCount: { type: 'number', title: 'Retries', description: 'Extra attempts after a failed or unparseable response', default: 0 }, retryDelayMs: { type: 'number', title: 'Retry Delay (ms)', description: 'Pause before the first retry. Doubles on each further attempt.', default: 2000 }, retryMaxDelayMs: { type: 'number', title: 'Max Retry Delay (ms)', description: 'Upper bound for the exponential backoff', default: 30000 }, skipOnError: { type: 'boolean', title: 'Skip On Error', description: 'Return success false instead of failing the workflow. Useful inside a loop.', default: false }, timeoutMs: { type: 'number', title: 'Timeout (ms)', description: 'Per-attempt request timeout', default: 120000 } }, required: ['baseUrl', 'model'] }; const inputSchema = { type: 'object', properties: { data: { type: 'any', description: 'Upstream output. Image data is detected here when Image Input is Auto.' }, file: { type: 'object', description: 'Binary file object with base64 data' }, base64: { type: 'string', description: 'Raw base64-encoded image' }, url: { type: 'string', description: 'Image URL, fetched when no binary data is present' } } }; const outputSchema = { type: 'object', properties: { success: { type: 'boolean', description: 'False when the call failed and Skip On Error is enabled' }, error: { type: 'string', description: 'Failure reason when success is false' }, content: { type: 'string', description: 'Raw text reply from the model' }, json: { type: 'any', description: 'Parsed reply when Response Format is JSON' }, model: { type: 'string', description: 'Model that answered' }, attempts: { type: 'number', description: 'How many attempts were made' }, hadImage: { type: 'boolean', description: 'Whether an image was sent' }, imageBase64: { type: 'string', description: 'Base64 image when Pass Image Through is enabled' }, mimeType: { type: 'string', description: 'Image MIME type when known' }, sourceUrl: { type: 'string', description: 'Originating image URL when known' } } }; function getPath(root, path) { if (!root || !path) { return undefined; } const parts = String(path).split('.'); let current = root; for (let i = 0; i < parts.length; i++) { if (current === null || typeof current !== 'object') { return undefined; } current = current[parts[i]]; } return current; } // A model asked for JSON that returns something almost-JSON is repaired rather // than retried. The retry existed for a model having a bad moment, but where // the fault is deterministic - a model that always omits the opening brace - // every extra attempt is another paid call for the same malformed answer. // What was repaired is logged, so a model that has started ignoring the schema // is visible rather than quietly patched over on every run. function parseModelJson(text, provider) { var repaired = smartbotic.utils.repairJson(text); if (!repaired.ok) { // Nothing salvageable - let the strict parser raise the real message. return JSON.parse(text); } if (repaired.repairs.length > 0) { smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' + repaired.repairs.join('; ') + '). The model is not honouring the requested format.'); } return repaired.value; } function stripFences(text) { const out = String(text || '').trim(); if (out.indexOf('```') === -1) { return out; } const first = out.indexOf('{'); const last = out.lastIndexOf('}'); if (first !== -1 && last !== -1 && last > first) { return out.substring(first, last + 1); } const firstArr = out.indexOf('['); const lastArr = out.lastIndexOf(']'); if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) { return out.substring(firstArr, lastArr + 1); } return out; } function findImage(input, override) { const result = { base64: '', mimeType: '', url: '' }; if (override) { const direct = getPath(input, override); if (typeof direct === 'string' && direct.length > 0) { result.base64 = direct; return result; } } // Collect candidate roots breadth-first: inside a loop, or when another node // sits between the fetch and this one, the file object is nested rather than // sitting at input or input.data. const roots = []; const queue = [input]; let guard = 0; while (queue.length > 0 && guard < 64) { guard++; const node = queue.shift(); if (!node || typeof node !== 'object') { continue; } roots.push(node); const keys = Object.keys(node); for (let k = 0; k < keys.length; k++) { const child = node[keys[k]]; if (child && typeof child === 'object' && keys[k] !== 'file') { queue.push(child); } } } for (let i = 0; i < roots.length; i++) { const root = roots[i]; if (!root || typeof root !== 'object') { continue; } if (!result.base64 && root.file && typeof root.file.data === 'string') { result.base64 = root.file.data; result.mimeType = root.file.mimeType || ''; } if (!result.base64 && typeof root.base64 === 'string') { result.base64 = root.base64; } if (!result.base64 && typeof root.imageBase64 === 'string') { result.base64 = root.imageBase64; } if (!result.url && typeof root.url === 'string') { result.url = root.url; } if (!result.url && typeof root.sourceUrl === 'string') { result.url = root.sourceUrl; } } return result; } function buildHeaders(config) { const headers = { 'Content-Type': 'application/json' }; if (config.authType === 'credential' && config.credentialId) { const auth = smartbotic.credentials.get(config.credentialId); if (!auth.success) { throw new Error('Failed to load credential: ' + auth.error); } headers[auth.headerName] = auth.headerValue; } return headers; } // Statuses no amount of retrying can get past. // // 429 is deliberately NOT here: a rate limit is exactly what backing off is // for. Neither are 500 and 502 - Ollama's cloud returns those transiently, and // spacing the retries out recovers from them. const PERMANENT_STATUSES = [ 400, // bad request - the same payload will be just as bad next time 401, // unauthorized - no key, or one the server will not accept 403, // refused: no subscription for this model, or the allowance is spent 404 // no such model ]; function callOllama(config, base64, headers) { const messages = []; if (config.systemPrompt && String(config.systemPrompt).trim().length > 0) { messages.push({ role: 'system', content: String(config.systemPrompt) }); } const userMessage = { role: 'user', content: config.userPrompt || 'Describe the input.' }; if (base64) { userMessage.images = [base64]; } messages.push(userMessage); const payload = { model: config.model, stream: false, options: { temperature: Number(config.temperature) || 0 }, messages: messages }; // Ask the server for JSON, rather than only checking afterwards whether we // got any. // // "JSON object" used to mean nothing more than "parse the reply and fail // the attempt if it does not parse". The model was never told, so nothing // stopped it emitting almost-JSON - and a reply missing one opening quote // // { "title_hu":Tuzfenyes ejszakai olelkezes", "title_en": "..." // // failed all three attempts and took the workflow down with it. Ollama's // format parameter constrains decoding so a malformed reply cannot be // produced in the first place; the parse below then only has to deal with // an empty or truncated response. if (config.responseFormat === 'json') { payload.format = 'json'; } const response = smartbotic.http.request({ method: 'POST', url: String(config.baseUrl).replace(/\/+$/, '') + '/api/chat', headers: headers, body: JSON.stringify(payload), timeout: Number(config.timeoutMs) || 120000 }); if (response.status < 200 || response.status >= 300) { const detail = typeof response.data === 'string' ? response.data : JSON.stringify(response.data); const error = new Error('Ollama HTTP ' + response.status + ': ' + detail); // Whether trying again could possibly help. // // Ollama documents 400, 404, 429, 500 and 502; 401 and 403 are not in // the documentation but both come back from the cloud endpoint - 401 // with no key, 403 when the key is fine and the account is refused, // which is what an exhausted weekly allowance looks like. // // Nothing in the permanent set changes because we ask again a few // seconds later: the request is malformed, the model does not exist, // the key is wrong, or the plan says no. Retrying those only delays // the report and spends more of whatever ran out. error.permanent = PERMANENT_STATUSES.indexOf(response.status) !== -1; error.status = response.status; throw error; } const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; if (body && body.error) { throw new Error('Ollama error: ' + body.error); } const content = body && body.message ? body.message.content : ''; if (!content) { throw new Error('Ollama returned an empty response'); } return content; } module.exports = { configSchema, inputSchema, outputSchema, async execute(config, input, context) { let image = { base64: '', mimeType: '', url: '' }; if (config.imageMode !== 'none') { image = findImage(input, config.imageField); if (!image.base64 && image.url) { smartbotic.log.info('ollama-chat: fetching image from ' + image.url); const dl = smartbotic.http.request({ method: 'GET', url: image.url, timeout: 60000 }); if (dl.status < 200 || dl.status >= 300) { throw new Error('Failed to fetch image: HTTP ' + dl.status); } image.base64 = typeof dl.data === 'string' ? smartbotic.utils.base64Encode(dl.data) : ''; } } // Resolved once, outside the retry loop: a missing or broken credential is // not transient, so retrying it with backoff only wastes time. const headers = buildHeaders(config); const wantJson = config.responseFormat === 'json'; const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0); let content = ''; let parsed = null; let lastError = ''; let used = 0; let permanent = false; for (let attempt = 1; attempt <= attempts; attempt++) { used = attempt; try { content = callOllama(config, image.base64, headers); if (wantJson) { parsed = parseModelJson(stripFences(content), 'Ollama'); } lastError = ''; break; } catch (err) { lastError = err && err.message ? err.message : String(err); parsed = null; if (err && err.permanent === true) { // Said once, and said as what it is. Reporting "failed after // 3 attempts" for a refusal invites the reader to wonder // what was flaky, when nothing was. smartbotic.log.warn('ollama-chat: ' + lastError); permanent = true; break; } smartbotic.log.warn('ollama-chat: attempt ' + attempt + ' of ' + attempts + ' failed: ' + lastError); if (attempt < attempts) { // Exponential backoff. Ollama's cloud tier returns transient 500s far // more often under rapid succession, so spacing retries out recovers // markedly better than hammering at a fixed interval. const base = Number(config.retryDelayMs) || 2000; const cap = Number(config.retryMaxDelayMs) || 30000; let delay = base * Math.pow(2, attempt - 1); if (delay > cap) { delay = cap; } smartbotic.log.info('ollama-chat: backing off ' + delay + 'ms before retry'); smartbotic.utils.sleep(delay); } } } const passImage = config.passthroughImage === true; if (lastError) { // A refusal is reported as a refusal. "Failed after 3 attempts" // reads as something flaky that might work next time, which sends // whoever gets the alert looking for a fault that is not there - // the answer to a spent allowance or a wrong key is not to run it // again. const summary = permanent ? 'ollama-chat was refused: ' + lastError : 'ollama-chat failed after ' + used + ' attempt(s): ' + lastError; if (config.skipOnError !== true) { throw new Error(summary); } smartbotic.log.warn('ollama-chat: skipping - ' + summary); return { success: false, error: lastError, content: content, json: null, model: config.model, attempts: used, hadImage: image.base64 ? true : false, imageBase64: passImage ? image.base64 : '', mimeType: image.mimeType, sourceUrl: image.url }; } return { success: true, error: '', content: content, json: parsed, model: config.model, attempts: used, hadImage: image.base64 ? true : false, imageBase64: passImage ? image.base64 : '', mimeType: image.mimeType, sourceUrl: image.url }; } };