/** * @node openai-chat * @name OpenAI Chat * @category ai * @version 1.0.0 * @description Ask OpenAI a question, with an image if there is one, and optionally get JSON back * @icon message-square */ // Generated by scripts/gen-openai-compatible-nodes.py from one implementation // shared with every other OpenAI-compatible provider. Edit the generator, not // this file. // The credential this node wants, named so it can be found and so a key for // this provider is not offered to a different one. It is stored as a plain // bearer credential - that is what decides how it is encrypted - and this only // says which bearer credential is the OpenAI one. const credentialTypes = [ { id: 'openai', label: 'OpenAI API key', baseType: 'bearer', description: 'An API key from platform.openai.com/api-keys', tokenLabel: 'API key' } ]; const configSchema = { type: 'object', uiGroups: [ { title: 'Connection', fields: ['baseUrl', 'credentialId'] }, { title: 'Model', fields: ['model', 'systemPrompt', 'userPrompt'] }, { title: 'Answer', fields: ['responseFormat', 'jsonMode', 'temperature', 'maxTokens', 'topP', 'seed'] }, { title: 'Image', fields: ['imageMode', 'imageField', 'imageDetail', 'passthroughImage'] }, { title: 'When it goes wrong', fields: ['retryCount', 'retryDelayMs', 'retryMaxDelayMs', 'skipOnError', 'timeoutMs'] }, { title: 'Advanced', fields: ['extraHeaders', 'extraBody'] } ], properties: { baseUrl: { type: 'string', title: 'Base URL', description: 'Where the OpenAI-compatible API lives. The default is OpenAI; change it to reach a proxy or a self-hosted gateway', default: 'https://api.openai.com/v1' }, credentialId: { type: 'string', title: 'Credential', description: 'The stored OpenAI API key', dynamicOptions: { source: 'credentials', filter: { type: ['openai', 'bearer', 'api_key'] } } }, model: { type: 'string', title: 'Model', description: 'Model name. Also reaches anything else served behind an OpenAI-compatible address - point the base URL at it.', default: 'gpt-4o-mini', dynamicOptions: { source: 'node', config: { listOnly: true }, itemsPath: 'models', valueKey: 'name', labelKey: 'name', needs: ['baseUrl', 'credentialId'] } }, systemPrompt: { type: 'string', title: 'System Prompt', format: 'textarea', description: 'The role and the output contract. When Response Format is JSON, say the word JSON here - several providers refuse JSON mode without it' }, userPrompt: { type: 'string', title: 'User Prompt', format: 'textarea', description: 'The message sent with the request. Supports {{variable}} interpolation', default: 'Describe the input.' }, responseFormat: { type: 'string', title: 'Response Format', enum: ['text', 'json'], enumLabels: ['Plain text', 'JSON object'], default: 'text', description: 'JSON parses the reply and fails the attempt when it is not valid JSON, so a retry gets another go' }, jsonMode: { type: 'boolean', title: 'Ask The Provider For JSON', default: true, showWhen: { field: 'responseFormat', value: 'json' }, 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' }, temperature: { type: 'number', title: 'Temperature', default: 0, description: 'Higher is more varied. 0 for the most repeatable output' }, maxTokens: { type: 'number', title: 'Max Tokens', description: 'Upper bound on the reply. Leave empty for the provider default' }, topP: { type: 'number', title: 'Top P', description: 'Nucleus sampling. Leave empty for the provider default' }, seed: { type: 'number', title: 'Seed', description: 'Same seed and same input gives the same answer, where the provider supports it. Leave empty for none' }, imageMode: { type: 'string', title: 'Image Input', enum: ['auto', 'none'], enumLabels: ['Send an image when the input has one', 'Text only'], default: 'none', description: 'Auto picks up a binary file, a base64 string or an image URL from the input. Needs a model that can see' }, imageField: { valueKind: 'path', type: 'string', title: 'Image Field', showWhen: { field: 'imageMode', value: 'auto' }, description: 'Optional dotted path to the base64 image, such as data.file.data. Left empty, the input is searched for one' }, imageDetail: { type: 'string', title: 'Image Detail', enum: ['', 'low', 'high', 'auto'], enumLabels: ['(provider default)', 'Low - cheaper, coarser', 'High - more tokens, more detail', 'Auto'], default: '', showWhen: { field: 'imageMode', value: 'auto' }, description: 'How closely to look at the image, where the provider supports it' }, passthroughImage: { type: 'boolean', title: 'Pass The Image Through', default: false, showWhen: { field: 'imageMode', value: 'auto' }, description: 'Include the base64 image in the output so a later node can store or reuse it' }, retryCount: { type: 'number', title: 'Retries', default: 0, description: 'Extra attempts after a failed call or an unparseable answer' }, retryDelayMs: { type: 'number', title: 'Retry Delay (ms)', default: 2000, description: 'Wait before the first retry. Doubles on each further attempt' }, retryMaxDelayMs: { type: 'number', title: 'Max Retry Delay (ms)', default: 30000, description: 'Upper bound for the backoff' }, skipOnError: { type: 'boolean', title: 'Skip On Error', default: false, description: 'Return success false instead of failing the workflow. Useful inside a loop, where one bad item should not end the run' }, timeoutMs: { type: 'number', title: 'Timeout (ms)', default: 120000, description: 'Per-attempt request timeout' }, extraHeaders: { type: 'object', title: 'Extra Headers', additionalProperties: { type: 'string' }, description: 'Sent with the request. OpenRouter reads HTTP-Referer and X-Title to attribute usage' }, extraBody: { type: 'object', title: 'Extra Body Fields', description: 'Merged into the request body, for anything this provider accepts that has no setting here' } }, required: ['credentialId', 'model'] }; const inputSchema = { type: 'object', properties: { data: { type: 'any' } } }; const outputSchema = { type: 'object', properties: { success: { type: 'boolean', description: 'False when the call failed and Skip On Error is on' }, error: { type: 'string', description: 'Why it failed, when it did' }, content: { type: 'string', description: 'The reply as text' }, json: { type: 'any', description: 'The parsed reply, when Response Format is JSON' }, model: { type: 'string', description: 'The model that answered, as the provider reported it' }, finishReason: { type: 'string', description: 'Why the model stopped - stop, length, content_filter' }, attempts: { type: 'number', description: 'How many attempts were made' }, usage: { type: 'object', description: 'Token counts, when the provider reports them' }, hadImage: { type: 'boolean' }, imageBase64: { type: 'string', description: 'The image sent, when Pass The Image Through is on' }, mimeType: { type: 'string' }, sourceUrl: { type: 'string', description: 'Where the image came from, when it came from a URL' }, // Only present when the node is asked for its model list rather than run. models: { type: 'array', description: 'The models this provider offers' } } }; const PROVIDER = 'OpenAI'; // Statuses this provider documents as answers that will not change. // // OpenAI splits 429: "rate limit reached" is transient, but an exhausted credit balance, a project spend limit and an organisation usage limit all arrive as 429 too and no amount of backing off clears them. The error code tells them apart, which is why permanentErrorCodes exists. // // 429 is treated as transient unless a code below says otherwise - backing off // is exactly what a rate limit asks for. 5xx is transient unless listed. const PERMANENT_STATUSES = [400, 401, 403]; const PERMANENT_ERROR_CODES = ['insufficient_quota', 'billing_hard_limit_reached', 'access_terminated']; function baseOf(config) { const value = String(config.baseUrl || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); if (!value) { throw new Error(PROVIDER + ': a base URL is required, such as https://api.openai.com/v1'); } return value; } function authHeaders(config) { const headers = { 'Content-Type': 'application/json' }; const auth = smartbotic.credentials.get(config.credentialId); if (!auth || auth.success !== true) { throw new Error(PROVIDER + ': could not read the credential: ' + ((auth && auth.error) || 'unknown error')); } // Whatever shape the credential is stored as, it arrives as a ready-made // header - bearer and api_key both work without this node knowing which. headers[auth.headerName] = auth.headerValue; const extra = config.extraHeaders; if (extra && typeof extra === 'object') { const keys = Object.keys(extra); for (let i = 0; i < keys.length; i++) { if (extra[keys[i]] !== undefined && extra[keys[i]] !== null && extra[keys[i]] !== '') { headers[keys[i]] = String(extra[keys[i]]); } } } return headers; } function request(options) { const response = smartbotic.http.request(options); let body = response.data; if (typeof body === 'string' && body.length > 0) { try { body = JSON.parse(body); } catch (e) { if (response.status >= 200 && response.status < 300) { throw new Error(PROVIDER + ': the reply was not JSON: ' + body.substring(0, 200).replace(/\s+/g, ' ')); } } } if (response.status < 200 || response.status >= 300) { let detail = 'HTTP ' + response.status; let code = ''; if (body && body.error) { detail = typeof body.error === 'string' ? body.error : (body.error.message || JSON.stringify(body.error)); code = (body.error && body.error.code) || (body.error && body.error.type) || ''; } else if (typeof body === 'string' && body) { detail = body.substring(0, 200).replace(/\s+/g, ' '); } const error = new Error(PROVIDER + ' ' + options.what + ' failed: ' + detail); error.status = response.status; // Whether asking again could ever give a different answer. Retrying a // refusal does not just waste time - where the refusal is a spent // allowance or a billing stop, it spends more of whatever ran out. error.permanent = PERMANENT_STATUSES.indexOf(response.status) !== -1 || (PERMANENT_ERROR_CODES.length > 0 && code && PERMANENT_ERROR_CODES.indexOf(String(code)) !== -1); throw error; } return body || {}; } 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; } // Models are asked for JSON and answer with a fenced code block often enough // that refusing it would mean retrying a perfectly good answer. // 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; } // The image can be anywhere in the input: inside a loop, or with another node // between the download and this one, it is nested rather than sitting at the // top. So the input is searched breadth-first rather than guessed at. 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; } } 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 buildMessages(config, image) { const messages = []; const system = String(config.systemPrompt || '').trim(); if (system) { messages.push({ role: 'system', content: system }); } const text = String(config.userPrompt || 'Describe the input.'); if (!image.base64) { messages.push({ role: 'user', content: text }); return messages; } // With an image the content becomes a list of parts, which is how every // OpenAI-compatible provider takes one. The base64 goes in as a data URI. const imagePart = { type: 'image_url', image_url: { url: 'data:' + (image.mimeType || 'image/jpeg') + ';base64,' + image.base64 } }; if (config.imageDetail) { imagePart.image_url.detail = String(config.imageDetail); } messages.push({ role: 'user', content: [{ type: 'text', text: text }, imagePart] }); return messages; } function putIfSet(target, key, value) { if (value === undefined || value === null || value === '') { return; } target[key] = value; } function chat(config, image, headers) { const body = { model: config.model, messages: buildMessages(config, image), stream: false }; // Temperature 0 is a real setting and must survive, so emptiness is the // test rather than truthiness. putIfSet(body, 'temperature', config.temperature); putIfSet(body, 'max_tokens', config.maxTokens); putIfSet(body, 'top_p', config.topP); putIfSet(body, 'seed', config.seed); if (config.responseFormat === 'json' && config.jsonMode !== false) { body.response_format = { type: 'json_object' }; } const extra = config.extraBody; if (extra && typeof extra === 'object') { const keys = Object.keys(extra); for (let i = 0; i < keys.length; i++) { body[keys[i]] = extra[keys[i]]; } } const answer = request({ method: 'POST', url: baseOf(config) + '/chat/completions', headers: headers, body: JSON.stringify(body), timeout: Number(config.timeoutMs) || 120000, what: 'the chat request' }); const choice = (answer.choices && answer.choices[0]) || {}; const message = choice.message || {}; let content = message.content; // Some providers answer with the content already split into parts. if (content && typeof content !== 'string' && typeof content.length === 'number') { let joined = ''; for (let i = 0; i < content.length; i++) { const part = content[i]; if (part && typeof part.text === 'string') { joined += part.text; } } content = joined; } if (!content) { // A refusal is a documented field of its own, and reporting "empty // response" for one sends the reader looking in the wrong place. if (message.refusal) { throw new Error(PROVIDER + ' declined to answer: ' + message.refusal); } throw new Error(PROVIDER + ' returned an empty reply' + (choice.finish_reason ? ' (finished: ' + choice.finish_reason + ')' : '')); } return { content: String(content), model: String(answer.model || config.model), finishReason: String(choice.finish_reason || ''), usage: answer.usage || {} }; } async function execute(config, input, context) { // Asked for its model list by the editor rather than run. Answered before // anything else, because none of the generation settings apply. if (config.listOnly === true) { const listing = request({ method: 'GET', url: baseOf(config) + '/models', headers: authHeaders(config), timeout: Number(config.timeoutMs) || 30000, what: 'listing the models' }); const rows = listing.data || listing.models || []; const models = []; for (let i = 0; i < rows.length; i++) { const row = rows[i] || {}; const name = row.id || row.name || String(row); if (name) { models.push({ name: String(name) }); } } // Alphabetical: providers return these in whatever order they please, // and a list of hundreds is unusable without one. models.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; }); smartbotic.log.info(PROVIDER + ': ' + models.length + ' model(s) on offer'); return { models: models }; } let image = { base64: '', mimeType: '', url: '' }; if (config.imageMode === 'auto') { image = findImage(input, config.imageField); if (!image.base64 && image.url) { smartbotic.log.info(PROVIDER + ': fetching the image from ' + image.url); const download = smartbotic.http.request({ method: 'GET', url: image.url, timeout: 60000 }); if (download.status < 200 || download.status >= 300) { throw new Error(PROVIDER + ': could not fetch the image: HTTP ' + download.status); } image.base64 = typeof download.data === 'string' ? smartbotic.utils.base64Encode(download.data) : ''; } } // Resolved once, outside the retry loop: a missing or broken credential is // not transient, and retrying it with backoff only wastes time. const headers = authHeaders(config); const wantJson = config.responseFormat === 'json'; const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0); let answer = null; let parsed = null; let lastError = ''; let used = 0; for (let attempt = 1; attempt <= attempts; attempt++) { used = attempt; try { answer = chat(config, image, headers); if (wantJson) { parsed = parseModelJson(stripFences(answer.content), PROVIDER); } lastError = ''; break; } catch (err) { lastError = err && err.message ? err.message : String(err); parsed = null; smartbotic.log.warn(PROVIDER + ': attempt ' + attempt + ' of ' + attempts + ' failed: ' + lastError); if (err && err.permanent) { smartbotic.log.warn(PROVIDER + ': the request was refused (HTTP ' + err.status + '), so the remaining attempts were not made'); break; } if (attempt < attempts) { 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(PROVIDER + ': waiting ' + delay + 'ms before the next attempt'); smartbotic.utils.sleep(delay); } } } const passImage = config.passthroughImage === true; const common = { attempts: used, hadImage: image.base64 ? true : false, imageBase64: passImage ? image.base64 : '', mimeType: image.mimeType, sourceUrl: image.url }; if (lastError) { if (config.skipOnError !== true) { throw new Error(PROVIDER + ' failed after ' + used + ' attempt(s): ' + lastError); } smartbotic.log.warn(PROVIDER + ': skipping after ' + used + ' attempt(s)'); return Object.assign({ success: false, error: lastError, content: (answer && answer.content) || '', json: null, model: config.model, finishReason: '', usage: {} }, common); } return Object.assign({ success: true, error: '', content: answer.content, json: parsed, model: answer.model, finishReason: answer.finishReason, usage: answer.usage }, common); } module.exports = { credentialTypes, configSchema, inputSchema, outputSchema, execute };