together-chat.js 22 KB

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