ollama-chat.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. /**
  2. * @node ollama-chat
  3. * @name Ollama Chat
  4. * @category ai
  5. * @version 1.0.0
  6. * @description Send a prompt to an Ollama model, optionally with an image for vision models, and return the text or parsed JSON response
  7. * @icon ollama
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. baseUrl: {
  13. type: 'string',
  14. title: 'Ollama Base URL',
  15. description: 'Base URL of the Ollama instance. Use https://ollama.com to call the hosted API directly instead of proxying through a local daemon.',
  16. default: 'http://localhost:11434'
  17. },
  18. authType: {
  19. type: 'string',
  20. title: 'Authentication',
  21. description: 'Hosted ollama.com requires a credential. A local daemon usually does not.',
  22. enum: ['none', 'credential'],
  23. enumLabels: ['None', 'Stored credential'],
  24. default: 'none'
  25. },
  26. credentialId: {
  27. type: 'string',
  28. title: 'Credential',
  29. description: 'Stored Bearer credential holding the ollama.com API key',
  30. // Read as a header, so any header-shaped credential works. An IMAP
  31. // or database credential has no header to send and would only be
  32. // an option that fails.
  33. dynamicOptions: {
  34. source: 'credentials',
  35. filter: { type: ['basic', 'bearer', 'api_key', 'oauth2'] }
  36. },
  37. default: '',
  38. showWhen: { field: 'authType', value: 'credential' }
  39. },
  40. model: {
  41. type: 'string',
  42. title: 'Model',
  43. description: 'Model tag, for example llama3.2 or a vision model tag',
  44. default: 'llama3.2'
  45. },
  46. systemPrompt: {
  47. type: 'string',
  48. title: 'System Prompt',
  49. description: 'Role and output contract for the model. When Response Format is JSON, mention the word JSON here so the model honours it.',
  50. format: 'textarea',
  51. default: ''
  52. },
  53. userPrompt: {
  54. type: 'string',
  55. title: 'User Prompt',
  56. description: 'Message sent with the request. Supports {{variable}} interpolation.',
  57. format: 'textarea',
  58. default: 'Describe the input.'
  59. },
  60. responseFormat: {
  61. type: 'string',
  62. title: 'Response Format',
  63. description: 'How to treat the reply. JSON parses the content and fails the attempt when it is not valid JSON.',
  64. enum: ['text', 'json'],
  65. enumLabels: ['Plain text', 'JSON object'],
  66. default: 'text'
  67. },
  68. temperature: {
  69. type: 'number',
  70. title: 'Temperature',
  71. description: 'Sampling temperature. Use 0 for the most repeatable output.',
  72. default: 0
  73. },
  74. imageMode: {
  75. type: 'string',
  76. title: 'Image Input',
  77. description: 'Auto picks up binary or base64 image data from the previous node. Use None for text-only models.',
  78. enum: ['auto', 'none'],
  79. enumLabels: ['Auto detect', 'None'],
  80. default: 'auto'
  81. },
  82. imageField: {
  83. valueKind: 'path',
  84. type: 'string',
  85. title: 'Image Field Override',
  86. description: 'Optional dot path to base64 image data, for example data.file.data',
  87. default: '',
  88. showWhen: { field: 'imageMode', value: 'auto' }
  89. },
  90. passthroughImage: {
  91. type: 'boolean',
  92. title: 'Pass Image Through',
  93. description: 'Include the base64 image in the output so later nodes can store or reuse it',
  94. default: false
  95. },
  96. retryCount: {
  97. type: 'number',
  98. title: 'Retries',
  99. description: 'Extra attempts after a failed or unparseable response',
  100. default: 0
  101. },
  102. retryDelayMs: {
  103. type: 'number',
  104. title: 'Retry Delay (ms)',
  105. description: 'Pause before the first retry. Doubles on each further attempt.',
  106. default: 2000
  107. },
  108. retryMaxDelayMs: {
  109. type: 'number',
  110. title: 'Max Retry Delay (ms)',
  111. description: 'Upper bound for the exponential backoff',
  112. default: 30000
  113. },
  114. skipOnError: {
  115. type: 'boolean',
  116. title: 'Skip On Error',
  117. description: 'Return success false instead of failing the workflow. Useful inside a loop.',
  118. default: false
  119. },
  120. timeoutMs: {
  121. type: 'number',
  122. title: 'Timeout (ms)',
  123. description: 'Per-attempt request timeout',
  124. default: 120000
  125. }
  126. },
  127. required: ['baseUrl', 'model']
  128. };
  129. const inputSchema = {
  130. type: 'object',
  131. properties: {
  132. data: {
  133. type: 'any',
  134. description: 'Upstream output. Image data is detected here when Image Input is Auto.'
  135. },
  136. file: {
  137. type: 'object',
  138. description: 'Binary file object with base64 data'
  139. },
  140. base64: {
  141. type: 'string',
  142. description: 'Raw base64-encoded image'
  143. },
  144. url: {
  145. type: 'string',
  146. description: 'Image URL, fetched when no binary data is present'
  147. }
  148. }
  149. };
  150. const outputSchema = {
  151. type: 'object',
  152. properties: {
  153. success: { type: 'boolean', description: 'False when the call failed and Skip On Error is enabled' },
  154. error: { type: 'string', description: 'Failure reason when success is false' },
  155. content: { type: 'string', description: 'Raw text reply from the model' },
  156. json: { type: 'any', description: 'Parsed reply when Response Format is JSON' },
  157. model: { type: 'string', description: 'Model that answered' },
  158. attempts: { type: 'number', description: 'How many attempts were made' },
  159. hadImage: { type: 'boolean', description: 'Whether an image was sent' },
  160. imageBase64: { type: 'string', description: 'Base64 image when Pass Image Through is enabled' },
  161. mimeType: { type: 'string', description: 'Image MIME type when known' },
  162. sourceUrl: { type: 'string', description: 'Originating image URL when known' }
  163. }
  164. };
  165. function getPath(root, path) {
  166. if (!root || !path) {
  167. return undefined;
  168. }
  169. const parts = String(path).split('.');
  170. let current = root;
  171. for (let i = 0; i < parts.length; i++) {
  172. if (current === null || typeof current !== 'object') {
  173. return undefined;
  174. }
  175. current = current[parts[i]];
  176. }
  177. return current;
  178. }
  179. // A model asked for JSON that returns something almost-JSON is repaired rather
  180. // than retried. The retry existed for a model having a bad moment, but where
  181. // the fault is deterministic - a model that always omits the opening brace -
  182. // every extra attempt is another paid call for the same malformed answer.
  183. // What was repaired is logged, so a model that has started ignoring the schema
  184. // is visible rather than quietly patched over on every run.
  185. function parseModelJson(text, provider) {
  186. var repaired = smartbotic.utils.repairJson(text);
  187. if (!repaired.ok) {
  188. // Nothing salvageable - let the strict parser raise the real message.
  189. return JSON.parse(text);
  190. }
  191. if (repaired.repairs.length > 0) {
  192. smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
  193. repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
  194. }
  195. return repaired.value;
  196. }
  197. function stripFences(text) {
  198. const out = String(text || '').trim();
  199. if (out.indexOf('```') === -1) {
  200. return out;
  201. }
  202. const first = out.indexOf('{');
  203. const last = out.lastIndexOf('}');
  204. if (first !== -1 && last !== -1 && last > first) {
  205. return out.substring(first, last + 1);
  206. }
  207. const firstArr = out.indexOf('[');
  208. const lastArr = out.lastIndexOf(']');
  209. if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) {
  210. return out.substring(firstArr, lastArr + 1);
  211. }
  212. return out;
  213. }
  214. function findImage(input, override) {
  215. const result = { base64: '', mimeType: '', url: '' };
  216. if (override) {
  217. const direct = getPath(input, override);
  218. if (typeof direct === 'string' && direct.length > 0) {
  219. result.base64 = direct;
  220. return result;
  221. }
  222. }
  223. // Collect candidate roots breadth-first: inside a loop, or when another node
  224. // sits between the fetch and this one, the file object is nested rather than
  225. // sitting at input or input.data.
  226. const roots = [];
  227. const queue = [input];
  228. let guard = 0;
  229. while (queue.length > 0 && guard < 64) {
  230. guard++;
  231. const node = queue.shift();
  232. if (!node || typeof node !== 'object') {
  233. continue;
  234. }
  235. roots.push(node);
  236. const keys = Object.keys(node);
  237. for (let k = 0; k < keys.length; k++) {
  238. const child = node[keys[k]];
  239. if (child && typeof child === 'object' && keys[k] !== 'file') {
  240. queue.push(child);
  241. }
  242. }
  243. }
  244. for (let i = 0; i < roots.length; i++) {
  245. const root = roots[i];
  246. if (!root || typeof root !== 'object') {
  247. continue;
  248. }
  249. if (!result.base64 && root.file && typeof root.file.data === 'string') {
  250. result.base64 = root.file.data;
  251. result.mimeType = root.file.mimeType || '';
  252. }
  253. if (!result.base64 && typeof root.base64 === 'string') {
  254. result.base64 = root.base64;
  255. }
  256. if (!result.base64 && typeof root.imageBase64 === 'string') {
  257. result.base64 = root.imageBase64;
  258. }
  259. if (!result.url && typeof root.url === 'string') {
  260. result.url = root.url;
  261. }
  262. if (!result.url && typeof root.sourceUrl === 'string') {
  263. result.url = root.sourceUrl;
  264. }
  265. }
  266. return result;
  267. }
  268. function buildHeaders(config) {
  269. const headers = { 'Content-Type': 'application/json' };
  270. if (config.authType === 'credential' && config.credentialId) {
  271. const auth = smartbotic.credentials.get(config.credentialId);
  272. if (!auth.success) {
  273. throw new Error('Failed to load credential: ' + auth.error);
  274. }
  275. headers[auth.headerName] = auth.headerValue;
  276. }
  277. return headers;
  278. }
  279. // Statuses no amount of retrying can get past.
  280. //
  281. // 429 is deliberately NOT here: a rate limit is exactly what backing off is
  282. // for. Neither are 500 and 502 - Ollama's cloud returns those transiently, and
  283. // spacing the retries out recovers from them.
  284. const PERMANENT_STATUSES = [
  285. 400, // bad request - the same payload will be just as bad next time
  286. 401, // unauthorized - no key, or one the server will not accept
  287. 403, // refused: no subscription for this model, or the allowance is spent
  288. 404 // no such model
  289. ];
  290. function callOllama(config, base64, headers) {
  291. const messages = [];
  292. if (config.systemPrompt && String(config.systemPrompt).trim().length > 0) {
  293. messages.push({ role: 'system', content: String(config.systemPrompt) });
  294. }
  295. const userMessage = { role: 'user', content: config.userPrompt || 'Describe the input.' };
  296. if (base64) {
  297. userMessage.images = [base64];
  298. }
  299. messages.push(userMessage);
  300. const payload = {
  301. model: config.model,
  302. stream: false,
  303. options: { temperature: Number(config.temperature) || 0 },
  304. messages: messages
  305. };
  306. // Ask the server for JSON, rather than only checking afterwards whether we
  307. // got any.
  308. //
  309. // "JSON object" used to mean nothing more than "parse the reply and fail
  310. // the attempt if it does not parse". The model was never told, so nothing
  311. // stopped it emitting almost-JSON - and a reply missing one opening quote
  312. //
  313. // { "title_hu":Tuzfenyes ejszakai olelkezes", "title_en": "..."
  314. //
  315. // failed all three attempts and took the workflow down with it. Ollama's
  316. // format parameter constrains decoding so a malformed reply cannot be
  317. // produced in the first place; the parse below then only has to deal with
  318. // an empty or truncated response.
  319. if (config.responseFormat === 'json') {
  320. payload.format = 'json';
  321. }
  322. const response = smartbotic.http.request({
  323. method: 'POST',
  324. url: String(config.baseUrl).replace(/\/+$/, '') + '/api/chat',
  325. headers: headers,
  326. body: JSON.stringify(payload),
  327. timeout: Number(config.timeoutMs) || 120000
  328. });
  329. if (response.status < 200 || response.status >= 300) {
  330. const detail = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
  331. const error = new Error('Ollama HTTP ' + response.status + ': ' + detail);
  332. // Whether trying again could possibly help.
  333. //
  334. // Ollama documents 400, 404, 429, 500 and 502; 401 and 403 are not in
  335. // the documentation but both come back from the cloud endpoint - 401
  336. // with no key, 403 when the key is fine and the account is refused,
  337. // which is what an exhausted weekly allowance looks like.
  338. //
  339. // Nothing in the permanent set changes because we ask again a few
  340. // seconds later: the request is malformed, the model does not exist,
  341. // the key is wrong, or the plan says no. Retrying those only delays
  342. // the report and spends more of whatever ran out.
  343. error.permanent = PERMANENT_STATUSES.indexOf(response.status) !== -1;
  344. error.status = response.status;
  345. throw error;
  346. }
  347. const body = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
  348. if (body && body.error) {
  349. throw new Error('Ollama error: ' + body.error);
  350. }
  351. const content = body && body.message ? body.message.content : '';
  352. if (!content) {
  353. throw new Error('Ollama returned an empty response');
  354. }
  355. return content;
  356. }
  357. module.exports = {
  358. configSchema,
  359. inputSchema,
  360. outputSchema,
  361. async execute(config, input, context) {
  362. let image = { base64: '', mimeType: '', url: '' };
  363. if (config.imageMode !== 'none') {
  364. image = findImage(input, config.imageField);
  365. if (!image.base64 && image.url) {
  366. smartbotic.log.info('ollama-chat: fetching image from ' + image.url);
  367. const dl = smartbotic.http.request({ method: 'GET', url: image.url, timeout: 60000 });
  368. if (dl.status < 200 || dl.status >= 300) {
  369. throw new Error('Failed to fetch image: HTTP ' + dl.status);
  370. }
  371. image.base64 = typeof dl.data === 'string' ? smartbotic.utils.base64Encode(dl.data) : '';
  372. }
  373. }
  374. // Resolved once, outside the retry loop: a missing or broken credential is
  375. // not transient, so retrying it with backoff only wastes time.
  376. const headers = buildHeaders(config);
  377. const wantJson = config.responseFormat === 'json';
  378. const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
  379. let content = '';
  380. let parsed = null;
  381. let lastError = '';
  382. let used = 0;
  383. let permanent = false;
  384. for (let attempt = 1; attempt <= attempts; attempt++) {
  385. used = attempt;
  386. try {
  387. content = callOllama(config, image.base64, headers);
  388. if (wantJson) {
  389. parsed = parseModelJson(stripFences(content), 'Ollama');
  390. }
  391. lastError = '';
  392. break;
  393. } catch (err) {
  394. lastError = err && err.message ? err.message : String(err);
  395. parsed = null;
  396. if (err && err.permanent === true) {
  397. // Said once, and said as what it is. Reporting "failed after
  398. // 3 attempts" for a refusal invites the reader to wonder
  399. // what was flaky, when nothing was.
  400. smartbotic.log.warn('ollama-chat: ' + lastError);
  401. permanent = true;
  402. break;
  403. }
  404. smartbotic.log.warn('ollama-chat: attempt ' + attempt + ' of ' + attempts + ' failed: ' + lastError);
  405. if (attempt < attempts) {
  406. // Exponential backoff. Ollama's cloud tier returns transient 500s far
  407. // more often under rapid succession, so spacing retries out recovers
  408. // markedly better than hammering at a fixed interval.
  409. const base = Number(config.retryDelayMs) || 2000;
  410. const cap = Number(config.retryMaxDelayMs) || 30000;
  411. let delay = base * Math.pow(2, attempt - 1);
  412. if (delay > cap) { delay = cap; }
  413. smartbotic.log.info('ollama-chat: backing off ' + delay + 'ms before retry');
  414. smartbotic.utils.sleep(delay);
  415. }
  416. }
  417. }
  418. const passImage = config.passthroughImage === true;
  419. if (lastError) {
  420. // A refusal is reported as a refusal. "Failed after 3 attempts"
  421. // reads as something flaky that might work next time, which sends
  422. // whoever gets the alert looking for a fault that is not there -
  423. // the answer to a spent allowance or a wrong key is not to run it
  424. // again.
  425. const summary = permanent
  426. ? 'ollama-chat was refused: ' + lastError
  427. : 'ollama-chat failed after ' + used + ' attempt(s): ' + lastError;
  428. if (config.skipOnError !== true) {
  429. throw new Error(summary);
  430. }
  431. smartbotic.log.warn('ollama-chat: skipping - ' + summary);
  432. return {
  433. success: false,
  434. error: lastError,
  435. content: content,
  436. json: null,
  437. model: config.model,
  438. attempts: used,
  439. hadImage: image.base64 ? true : false,
  440. imageBase64: passImage ? image.base64 : '',
  441. mimeType: image.mimeType,
  442. sourceUrl: image.url
  443. };
  444. }
  445. return {
  446. success: true,
  447. error: '',
  448. content: content,
  449. json: parsed,
  450. model: config.model,
  451. attempts: used,
  452. hadImage: image.base64 ? true : false,
  453. imageBase64: passImage ? image.base64 : '',
  454. mimeType: image.mimeType,
  455. sourceUrl: image.url
  456. };
  457. }
  458. };