gen-openai-compatible-nodes.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. #!/usr/bin/env python3
  2. """
  3. Write one chat node per provider that speaks the OpenAI chat-completions API.
  4. OpenRouter, Together, Groq, DeepSeek and the rest all accept the same request
  5. and return the same response - the differences are the address, the name on the
  6. key, and which models are on offer. That is not enough to justify writing the
  7. same four hundred lines nine times, and a node cannot require another node's
  8. file, so the implementation lives here and each node is generated from it.
  9. Change the template or the provider table, run this, check the diff, commit.
  10. Nothing here runs at execution time.
  11. python3 scripts/gen-openai-compatible-nodes.py
  12. """
  13. import json
  14. from pathlib import Path
  15. PROVIDERS = [
  16. {
  17. 'id': 'openai', 'name': 'OpenAI',
  18. 'base': 'https://api.openai.com/v1',
  19. 'model': 'gpt-4o-mini',
  20. 'keys': 'platform.openai.com/api-keys',
  21. 'note': 'Also reaches anything else served behind an OpenAI-compatible address - point the base URL at it.',
  22. },
  23. {
  24. 'id': 'openrouter', 'name': 'OpenRouter',
  25. 'base': 'https://openrouter.ai/api/v1',
  26. 'model': 'openai/gpt-4o-mini',
  27. 'keys': 'openrouter.ai/keys',
  28. 'note': 'One key for models from many providers. Model names carry the provider, as in anthropic/claude-3.5-sonnet.',
  29. },
  30. {
  31. 'id': 'together', 'name': 'Together AI',
  32. 'base': 'https://api.together.xyz/v1',
  33. 'model': 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
  34. 'keys': 'api.together.ai/settings/api-keys',
  35. 'note': 'Open-weight models, hosted.',
  36. },
  37. {
  38. 'id': 'groq', 'name': 'Groq',
  39. 'base': 'https://api.groq.com/openai/v1',
  40. 'model': 'llama-3.3-70b-versatile',
  41. 'keys': 'console.groq.com/keys',
  42. 'note': 'Very fast, a small catalogue.',
  43. },
  44. {
  45. 'id': 'deepseek', 'name': 'DeepSeek',
  46. 'base': 'https://api.deepseek.com/v1',
  47. 'model': 'deepseek-chat',
  48. 'keys': 'platform.deepseek.com/api_keys',
  49. 'note': 'deepseek-chat for general work, deepseek-reasoner when the answer needs working out.',
  50. },
  51. {
  52. 'id': 'mistral', 'name': 'Mistral',
  53. 'base': 'https://api.mistral.ai/v1',
  54. 'model': 'mistral-large-latest',
  55. 'keys': 'console.mistral.ai/api-keys',
  56. 'note': '',
  57. },
  58. {
  59. 'id': 'xai', 'name': 'xAI',
  60. 'base': 'https://api.x.ai/v1',
  61. 'model': 'grok-2-latest',
  62. 'keys': 'console.x.ai',
  63. 'note': 'The Grok models.',
  64. },
  65. {
  66. 'id': 'fireworks', 'name': 'Fireworks AI',
  67. 'base': 'https://api.fireworks.ai/inference/v1',
  68. 'model': 'accounts/fireworks/models/llama-v3p3-70b-instruct',
  69. 'keys': 'fireworks.ai/account/api-keys',
  70. 'note': 'Model names are full account paths.',
  71. },
  72. {
  73. 'id': 'perplexity', 'name': 'Perplexity',
  74. 'base': 'https://api.perplexity.ai',
  75. 'model': 'sonar',
  76. 'keys': 'perplexity.ai/settings/api',
  77. 'note': 'Answers are searched for as well as generated. It publishes no model list, so type the name.',
  78. 'no_model_list': True,
  79. },
  80. {
  81. 'id': 'deepinfra', 'name': 'DeepInfra',
  82. 'base': 'https://api.deepinfra.com/v1/openai',
  83. 'model': 'meta-llama/Llama-3.3-70B-Instruct',
  84. 'keys': 'deepinfra.com/dash/api_keys',
  85. 'note': '',
  86. },
  87. ]
  88. TEMPLATE = r'''/**
  89. * @node {id}-chat
  90. * @name {name} Chat
  91. * @category ai
  92. * @version 1.0.0
  93. * @description Ask {name} a question, with an image if there is one, and optionally get JSON back
  94. * @icon message-square
  95. */
  96. // Generated by scripts/gen-openai-compatible-nodes.py from one implementation
  97. // shared with every other OpenAI-compatible provider. Edit the generator, not
  98. // this file.
  99. // The credential this node wants, named so it can be found and so a key for
  100. // this provider is not offered to a different one. It is stored as a plain
  101. // bearer credential - that is what decides how it is encrypted - and this only
  102. // says which bearer credential is the {name} one.
  103. const credentialTypes = [
  104. {{
  105. id: '{id}',
  106. label: '{name} API key',
  107. baseType: 'bearer',
  108. description: 'An API key from {keys}',
  109. tokenLabel: 'API key'
  110. }}
  111. ];
  112. const configSchema = {{
  113. type: 'object',
  114. uiGroups: [
  115. {{ title: 'Connection', fields: ['baseUrl', 'credentialId'] }},
  116. {{ title: 'Model', fields: ['model', 'systemPrompt', 'userPrompt'] }},
  117. {{ title: 'Answer', fields: ['responseFormat', 'jsonMode', 'temperature', 'maxTokens', 'topP', 'seed'] }},
  118. {{ title: 'Image', fields: ['imageMode', 'imageField', 'imageDetail', 'passthroughImage'] }},
  119. {{ title: 'When it goes wrong', fields: ['retryCount', 'retryDelayMs', 'retryMaxDelayMs', 'skipOnError', 'timeoutMs'] }},
  120. {{ title: 'Advanced', fields: ['extraHeaders', 'extraBody'] }}
  121. ],
  122. properties: {{
  123. baseUrl: {{
  124. type: 'string', title: 'Base URL',
  125. description: 'Where the OpenAI-compatible API lives. The default is {name}; change it to reach a proxy or a self-hosted gateway',
  126. default: '{base}'
  127. }},
  128. credentialId: {{
  129. type: 'string', title: 'Credential',
  130. description: 'The stored {name} API key',
  131. dynamicOptions: {{ source: 'credentials', filter: {{ type: ['{id}', 'bearer', 'api_key'] }} }}
  132. }},
  133. model: {{
  134. type: 'string', title: 'Model',
  135. description: 'Model name.{note_suffix}',
  136. default: '{model}'{model_options}
  137. }},
  138. systemPrompt: {{
  139. type: 'string', title: 'System Prompt', format: 'textarea',
  140. description: 'The role and the output contract. When Response Format is JSON, say the word JSON here - several providers refuse JSON mode without it'
  141. }},
  142. userPrompt: {{
  143. type: 'string', title: 'User Prompt', format: 'textarea',
  144. description: 'The message sent with the request. Supports {{{{variable}}}} interpolation',
  145. default: 'Describe the input.'
  146. }},
  147. responseFormat: {{
  148. type: 'string', title: 'Response Format',
  149. enum: ['text', 'json'],
  150. enumLabels: ['Plain text', 'JSON object'],
  151. default: 'text',
  152. description: 'JSON parses the reply and fails the attempt when it is not valid JSON, so a retry gets another go'
  153. }},
  154. jsonMode: {{
  155. type: 'boolean', title: 'Ask The Provider For JSON', default: true,
  156. showWhen: {{ field: 'responseFormat', value: 'json' }},
  157. description: 'Send response_format json_object, which makes the model return JSON rather than being asked nicely. Turn it off for a provider or model that rejects the field'
  158. }},
  159. temperature: {{
  160. type: 'number', title: 'Temperature', default: 0,
  161. description: 'Higher is more varied. 0 for the most repeatable output'
  162. }},
  163. maxTokens: {{
  164. type: 'number', title: 'Max Tokens',
  165. description: 'Upper bound on the reply. Leave empty for the provider default'
  166. }},
  167. topP: {{
  168. type: 'number', title: 'Top P',
  169. description: 'Nucleus sampling. Leave empty for the provider default'
  170. }},
  171. seed: {{
  172. type: 'number', title: 'Seed',
  173. description: 'Same seed and same input gives the same answer, where the provider supports it. Leave empty for none'
  174. }},
  175. imageMode: {{
  176. type: 'string', title: 'Image Input',
  177. enum: ['auto', 'none'],
  178. enumLabels: ['Send an image when the input has one', 'Text only'],
  179. default: 'none',
  180. description: 'Auto picks up a binary file, a base64 string or an image URL from the input. Needs a model that can see'
  181. }},
  182. imageField: {{
  183. valueKind: 'path',
  184. type: 'string', title: 'Image Field',
  185. showWhen: {{ field: 'imageMode', value: 'auto' }},
  186. description: 'Optional dotted path to the base64 image, such as data.file.data. Left empty, the input is searched for one'
  187. }},
  188. imageDetail: {{
  189. type: 'string', title: 'Image Detail',
  190. enum: ['', 'low', 'high', 'auto'],
  191. enumLabels: ['(provider default)', 'Low - cheaper, coarser', 'High - more tokens, more detail', 'Auto'],
  192. default: '',
  193. showWhen: {{ field: 'imageMode', value: 'auto' }},
  194. description: 'How closely to look at the image, where the provider supports it'
  195. }},
  196. passthroughImage: {{
  197. type: 'boolean', title: 'Pass The Image Through', default: false,
  198. showWhen: {{ field: 'imageMode', value: 'auto' }},
  199. description: 'Include the base64 image in the output so a later node can store or reuse it'
  200. }},
  201. retryCount: {{
  202. type: 'number', title: 'Retries', default: 0,
  203. description: 'Extra attempts after a failed call or an unparseable answer'
  204. }},
  205. retryDelayMs: {{
  206. type: 'number', title: 'Retry Delay (ms)', default: 2000,
  207. description: 'Wait before the first retry. Doubles on each further attempt'
  208. }},
  209. retryMaxDelayMs: {{
  210. type: 'number', title: 'Max Retry Delay (ms)', default: 30000,
  211. description: 'Upper bound for the backoff'
  212. }},
  213. skipOnError: {{
  214. type: 'boolean', title: 'Skip On Error', default: false,
  215. description: 'Return success false instead of failing the workflow. Useful inside a loop, where one bad item should not end the run'
  216. }},
  217. timeoutMs: {{
  218. type: 'number', title: 'Timeout (ms)', default: 120000,
  219. description: 'Per-attempt request timeout'
  220. }},
  221. extraHeaders: {{
  222. type: 'object', title: 'Extra Headers',
  223. additionalProperties: {{ type: 'string' }},
  224. description: 'Sent with the request. OpenRouter reads HTTP-Referer and X-Title to attribute usage'
  225. }},
  226. extraBody: {{
  227. type: 'object', title: 'Extra Body Fields',
  228. description: 'Merged into the request body, for anything this provider accepts that has no setting here'
  229. }}
  230. }},
  231. required: ['credentialId', 'model']
  232. }};
  233. const inputSchema = {{ type: 'object', properties: {{ data: {{ type: 'any' }} }} }};
  234. const outputSchema = {{
  235. type: 'object',
  236. properties: {{
  237. success: {{ type: 'boolean', description: 'False when the call failed and Skip On Error is on' }},
  238. error: {{ type: 'string', description: 'Why it failed, when it did' }},
  239. content: {{ type: 'string', description: 'The reply as text' }},
  240. json: {{ type: 'any', description: 'The parsed reply, when Response Format is JSON' }},
  241. model: {{ type: 'string', description: 'The model that answered, as the provider reported it' }},
  242. finishReason: {{ type: 'string', description: 'Why the model stopped - stop, length, content_filter' }},
  243. attempts: {{ type: 'number', description: 'How many attempts were made' }},
  244. usage: {{ type: 'object', description: 'Token counts, when the provider reports them' }},
  245. hadImage: {{ type: 'boolean' }},
  246. imageBase64: {{ type: 'string', description: 'The image sent, when Pass The Image Through is on' }},
  247. mimeType: {{ type: 'string' }},
  248. sourceUrl: {{ type: 'string', description: 'Where the image came from, when it came from a URL' }},
  249. // Only present when the node is asked for its model list rather than run.
  250. models: {{ type: 'array', description: 'The models this provider offers' }}
  251. }}
  252. }};
  253. const PROVIDER = '{name}';
  254. function baseOf(config) {{
  255. const value = String(config.baseUrl || '{base}').trim().replace(/\/+$/, '');
  256. if (!value) {{
  257. throw new Error(PROVIDER + ': a base URL is required, such as {base}');
  258. }}
  259. return value;
  260. }}
  261. function authHeaders(config) {{
  262. const headers = {{ 'Content-Type': 'application/json' }};
  263. const auth = smartbotic.credentials.get(config.credentialId);
  264. if (!auth || auth.success !== true) {{
  265. throw new Error(PROVIDER + ': could not read the credential: ' +
  266. ((auth && auth.error) || 'unknown error'));
  267. }}
  268. // Whatever shape the credential is stored as, it arrives as a ready-made
  269. // header - bearer and api_key both work without this node knowing which.
  270. headers[auth.headerName] = auth.headerValue;
  271. const extra = config.extraHeaders;
  272. if (extra && typeof extra === 'object') {{
  273. const keys = Object.keys(extra);
  274. for (let i = 0; i < keys.length; i++) {{
  275. if (extra[keys[i]] !== undefined && extra[keys[i]] !== null && extra[keys[i]] !== '') {{
  276. headers[keys[i]] = String(extra[keys[i]]);
  277. }}
  278. }}
  279. }}
  280. return headers;
  281. }}
  282. function request(options) {{
  283. const response = smartbotic.http.request(options);
  284. let body = response.data;
  285. if (typeof body === 'string' && body.length > 0) {{
  286. try {{
  287. body = JSON.parse(body);
  288. }} catch (e) {{
  289. if (response.status >= 200 && response.status < 300) {{
  290. throw new Error(PROVIDER + ': the reply was not JSON: ' +
  291. body.substring(0, 200).replace(/\s+/g, ' '));
  292. }}
  293. }}
  294. }}
  295. if (response.status < 200 || response.status >= 300) {{
  296. let detail = 'HTTP ' + response.status;
  297. if (body && body.error) {{
  298. detail = typeof body.error === 'string' ? body.error :
  299. (body.error.message || JSON.stringify(body.error));
  300. }} else if (typeof body === 'string' && body) {{
  301. detail = body.substring(0, 200).replace(/\s+/g, ' ');
  302. }}
  303. throw new Error(PROVIDER + ' ' + options.what + ' failed: ' + detail);
  304. }}
  305. return body || {{}};
  306. }}
  307. function getPath(root, path) {{
  308. if (!root || !path) {{
  309. return undefined;
  310. }}
  311. const parts = String(path).split('.');
  312. let current = root;
  313. for (let i = 0; i < parts.length; i++) {{
  314. if (current === null || typeof current !== 'object') {{
  315. return undefined;
  316. }}
  317. current = current[parts[i]];
  318. }}
  319. return current;
  320. }}
  321. // Models are asked for JSON and answer with a fenced code block often enough
  322. // that refusing it would mean retrying a perfectly good answer.
  323. function stripFences(text) {{
  324. const out = String(text || '').trim();
  325. if (out.indexOf('```') === -1) {{
  326. return out;
  327. }}
  328. const first = out.indexOf('{{');
  329. const last = out.lastIndexOf('}}');
  330. if (first !== -1 && last !== -1 && last > first) {{
  331. return out.substring(first, last + 1);
  332. }}
  333. const firstArr = out.indexOf('[');
  334. const lastArr = out.lastIndexOf(']');
  335. if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) {{
  336. return out.substring(firstArr, lastArr + 1);
  337. }}
  338. return out;
  339. }}
  340. // The image can be anywhere in the input: inside a loop, or with another node
  341. // between the download and this one, it is nested rather than sitting at the
  342. // top. So the input is searched breadth-first rather than guessed at.
  343. function findImage(input, override) {{
  344. const result = {{ base64: '', mimeType: '', url: '' }};
  345. if (override) {{
  346. const direct = getPath(input, override);
  347. if (typeof direct === 'string' && direct.length > 0) {{
  348. result.base64 = direct;
  349. return result;
  350. }}
  351. }}
  352. const roots = [];
  353. const queue = [input];
  354. let guard = 0;
  355. while (queue.length > 0 && guard < 64) {{
  356. guard++;
  357. const node = queue.shift();
  358. if (!node || typeof node !== 'object') {{
  359. continue;
  360. }}
  361. roots.push(node);
  362. const keys = Object.keys(node);
  363. for (let k = 0; k < keys.length; k++) {{
  364. const child = node[keys[k]];
  365. if (child && typeof child === 'object' && keys[k] !== 'file') {{
  366. queue.push(child);
  367. }}
  368. }}
  369. }}
  370. for (let i = 0; i < roots.length; i++) {{
  371. const root = roots[i];
  372. if (!root || typeof root !== 'object') {{
  373. continue;
  374. }}
  375. if (!result.base64 && root.file && typeof root.file.data === 'string') {{
  376. result.base64 = root.file.data;
  377. result.mimeType = root.file.mimeType || '';
  378. }}
  379. if (!result.base64 && typeof root.base64 === 'string') {{
  380. result.base64 = root.base64;
  381. }}
  382. if (!result.base64 && typeof root.imageBase64 === 'string') {{
  383. result.base64 = root.imageBase64;
  384. }}
  385. if (!result.url && typeof root.url === 'string') {{
  386. result.url = root.url;
  387. }}
  388. if (!result.url && typeof root.sourceUrl === 'string') {{
  389. result.url = root.sourceUrl;
  390. }}
  391. }}
  392. return result;
  393. }}
  394. function buildMessages(config, image) {{
  395. const messages = [];
  396. const system = String(config.systemPrompt || '').trim();
  397. if (system) {{
  398. messages.push({{ role: 'system', content: system }});
  399. }}
  400. const text = String(config.userPrompt || 'Describe the input.');
  401. if (!image.base64) {{
  402. messages.push({{ role: 'user', content: text }});
  403. return messages;
  404. }}
  405. // With an image the content becomes a list of parts, which is how every
  406. // OpenAI-compatible provider takes one. The base64 goes in as a data URI.
  407. const imagePart = {{
  408. type: 'image_url',
  409. image_url: {{ url: 'data:' + (image.mimeType || 'image/jpeg') + ';base64,' + image.base64 }}
  410. }};
  411. if (config.imageDetail) {{
  412. imagePart.image_url.detail = String(config.imageDetail);
  413. }}
  414. messages.push({{ role: 'user', content: [{{ type: 'text', text: text }}, imagePart] }});
  415. return messages;
  416. }}
  417. function putIfSet(target, key, value) {{
  418. if (value === undefined || value === null || value === '') {{
  419. return;
  420. }}
  421. target[key] = value;
  422. }}
  423. function chat(config, image, headers) {{
  424. const body = {{
  425. model: config.model,
  426. messages: buildMessages(config, image),
  427. stream: false
  428. }};
  429. // Temperature 0 is a real setting and must survive, so emptiness is the
  430. // test rather than truthiness.
  431. putIfSet(body, 'temperature', config.temperature);
  432. putIfSet(body, 'max_tokens', config.maxTokens);
  433. putIfSet(body, 'top_p', config.topP);
  434. putIfSet(body, 'seed', config.seed);
  435. if (config.responseFormat === 'json' && config.jsonMode !== false) {{
  436. body.response_format = {{ type: 'json_object' }};
  437. }}
  438. const extra = config.extraBody;
  439. if (extra && typeof extra === 'object') {{
  440. const keys = Object.keys(extra);
  441. for (let i = 0; i < keys.length; i++) {{
  442. body[keys[i]] = extra[keys[i]];
  443. }}
  444. }}
  445. const answer = request({{
  446. method: 'POST',
  447. url: baseOf(config) + '/chat/completions',
  448. headers: headers,
  449. body: JSON.stringify(body),
  450. timeout: Number(config.timeoutMs) || 120000,
  451. what: 'the chat request'
  452. }});
  453. const choice = (answer.choices && answer.choices[0]) || {{}};
  454. const message = choice.message || {{}};
  455. let content = message.content;
  456. // Some providers answer with the content already split into parts.
  457. if (content && typeof content !== 'string' && typeof content.length === 'number') {{
  458. let joined = '';
  459. for (let i = 0; i < content.length; i++) {{
  460. const part = content[i];
  461. if (part && typeof part.text === 'string') {{ joined += part.text; }}
  462. }}
  463. content = joined;
  464. }}
  465. if (!content) {{
  466. // A refusal is a documented field of its own, and reporting "empty
  467. // response" for one sends the reader looking in the wrong place.
  468. if (message.refusal) {{
  469. throw new Error(PROVIDER + ' declined to answer: ' + message.refusal);
  470. }}
  471. throw new Error(PROVIDER + ' returned an empty reply' +
  472. (choice.finish_reason ? ' (finished: ' + choice.finish_reason + ')' : ''));
  473. }}
  474. return {{
  475. content: String(content),
  476. model: String(answer.model || config.model),
  477. finishReason: String(choice.finish_reason || ''),
  478. usage: answer.usage || {{}}
  479. }};
  480. }}
  481. async function execute(config, input, context) {{
  482. // Asked for its model list by the editor rather than run. Answered before
  483. // anything else, because none of the generation settings apply.
  484. if (config.listOnly === true) {{
  485. {list_only}
  486. }}
  487. let image = {{ base64: '', mimeType: '', url: '' }};
  488. if (config.imageMode === 'auto') {{
  489. image = findImage(input, config.imageField);
  490. if (!image.base64 && image.url) {{
  491. smartbotic.log.info(PROVIDER + ': fetching the image from ' + image.url);
  492. const download = smartbotic.http.request({{
  493. method: 'GET', url: image.url, timeout: 60000
  494. }});
  495. if (download.status < 200 || download.status >= 300) {{
  496. throw new Error(PROVIDER + ': could not fetch the image: HTTP ' + download.status);
  497. }}
  498. image.base64 = typeof download.data === 'string'
  499. ? smartbotic.utils.base64Encode(download.data) : '';
  500. }}
  501. }}
  502. // Resolved once, outside the retry loop: a missing or broken credential is
  503. // not transient, and retrying it with backoff only wastes time.
  504. const headers = authHeaders(config);
  505. const wantJson = config.responseFormat === 'json';
  506. const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
  507. let answer = null;
  508. let parsed = null;
  509. let lastError = '';
  510. let used = 0;
  511. for (let attempt = 1; attempt <= attempts; attempt++) {{
  512. used = attempt;
  513. try {{
  514. answer = chat(config, image, headers);
  515. if (wantJson) {{
  516. parsed = JSON.parse(stripFences(answer.content));
  517. }}
  518. lastError = '';
  519. break;
  520. }} catch (err) {{
  521. lastError = err && err.message ? err.message : String(err);
  522. parsed = null;
  523. smartbotic.log.warn(PROVIDER + ': attempt ' + attempt + ' of ' + attempts +
  524. ' failed: ' + lastError);
  525. if (attempt < attempts) {{
  526. const base = Number(config.retryDelayMs) || 2000;
  527. const cap = Number(config.retryMaxDelayMs) || 30000;
  528. let delay = base * Math.pow(2, attempt - 1);
  529. if (delay > cap) {{ delay = cap; }}
  530. smartbotic.log.info(PROVIDER + ': waiting ' + delay + 'ms before the next attempt');
  531. smartbotic.utils.sleep(delay);
  532. }}
  533. }}
  534. }}
  535. const passImage = config.passthroughImage === true;
  536. const common = {{
  537. attempts: used,
  538. hadImage: image.base64 ? true : false,
  539. imageBase64: passImage ? image.base64 : '',
  540. mimeType: image.mimeType,
  541. sourceUrl: image.url
  542. }};
  543. if (lastError) {{
  544. if (config.skipOnError !== true) {{
  545. throw new Error(PROVIDER + ' failed after ' + used + ' attempt(s): ' + lastError);
  546. }}
  547. smartbotic.log.warn(PROVIDER + ': skipping after ' + used + ' attempt(s)');
  548. return Object.assign({{
  549. success: false, error: lastError, content: (answer && answer.content) || '',
  550. json: null, model: config.model, finishReason: '', usage: {{}}
  551. }}, common);
  552. }}
  553. return Object.assign({{
  554. success: true, error: '', content: answer.content, json: parsed,
  555. model: answer.model, finishReason: answer.finishReason, usage: answer.usage
  556. }}, common);
  557. }}
  558. module.exports = {{ credentialTypes, configSchema, inputSchema, outputSchema, execute }};
  559. '''
  560. LIST_ONLY = r'''const listing = request({
  561. method: 'GET',
  562. url: baseOf(config) + '/models',
  563. headers: authHeaders(config),
  564. timeout: Number(config.timeoutMs) || 30000,
  565. what: 'listing the models'
  566. });
  567. const rows = listing.data || listing.models || [];
  568. const models = [];
  569. for (let i = 0; i < rows.length; i++) {
  570. const row = rows[i] || {};
  571. const name = row.id || row.name || String(row);
  572. if (name) { models.push({ name: String(name) }); }
  573. }
  574. // Alphabetical: providers return these in whatever order they please,
  575. // and a list of hundreds is unusable without one.
  576. models.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
  577. smartbotic.log.info(PROVIDER + ': ' + models.length + ' model(s) on offer');
  578. return { models: models };'''
  579. NO_LIST = r'''// This provider publishes no model list, so there is nothing to
  580. // offer and saying so beats an empty dropdown with no explanation.
  581. return { models: [] };'''
  582. MODEL_OPTIONS = r''',
  583. dynamicOptions: {
  584. source: 'node',
  585. config: { listOnly: true },
  586. itemsPath: 'models',
  587. valueKey: 'name',
  588. labelKey: 'name',
  589. needs: ['baseUrl', 'credentialId']
  590. }'''
  591. def main():
  592. root = Path(__file__).resolve().parent.parent / 'nodes' / 'ai'
  593. for provider in PROVIDERS:
  594. note = provider.get('note', '')
  595. source = TEMPLATE.format(
  596. id=provider['id'],
  597. name=provider['name'],
  598. base=provider['base'],
  599. model=provider['model'],
  600. keys=provider['keys'],
  601. note_suffix=(' ' + note) if note else '',
  602. model_options='' if provider.get('no_model_list') else MODEL_OPTIONS,
  603. list_only=NO_LIST if provider.get('no_model_list') else LIST_ONLY,
  604. )
  605. path = root / f"{provider['id']}-chat.js"
  606. path.write_text(source)
  607. print(f"{provider['id'] + '-chat.js':24} {provider['base']}")
  608. if __name__ == '__main__':
  609. main()