/** * @node openrouter-chat * @name OpenRouter Chat * @category ai * @version 1.0.0 * @description Ask OpenRouter a question, with an image if there is one, and optionally get JSON back * @icon openrouter */ // 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 OpenRouter one. const credentialTypes = [ { id: 'openrouter', label: 'OpenRouter API key', baseType: 'bearer', description: 'An API key from openrouter.ai/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 OpenRouter; change it to reach a proxy or a self-hosted gateway', default: 'https://openrouter.ai/api/v1' }, credentialId: { type: 'string', title: 'Credential', description: 'The stored OpenRouter API key', dynamicOptions: { source: 'credentials', filter: { type: ['openrouter', 'bearer', 'api_key'] } } }, model: { type: 'string', title: 'Model', description: 'Model name. One key for models from many providers. Model names carry the provider, as in anthropic/claude-3.5-sonnet.', default: 'openai/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 = 'OpenRouter'; function baseOf(config) { const value = String(config.baseUrl || 'https://openrouter.ai/api/v1').trim().replace(/\/+$/, ''); if (!value) { throw new Error(PROVIDER + ': a base URL is required, such as https://openrouter.ai/api/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; if (body && body.error) { detail = typeof body.error === 'string' ? body.error : (body.error.message || JSON.stringify(body.error)); } else if (typeof body === 'string' && body) { detail = body.substring(0, 200).replace(/\s+/g, ' '); } throw new Error(PROVIDER + ' ' + options.what + ' failed: ' + detail); } 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. 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 = JSON.parse(stripFences(answer.content)); } 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 (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 };