ソースを参照

feat: chat nodes for ten OpenAI-compatible providers

OpenRouter, Together, Groq, DeepSeek, Mistral, xAI, Fireworks, Perplexity,
DeepInfra and OpenAI all accept the same chat request and return the same
reply. The differences are the address, the name on the key and the catalogue
- not enough to justify writing four hundred lines ten times.

A node cannot require another node's file, so the implementation lives in
scripts/gen-openai-compatible-nodes.py and each node is generated from it.
Adding a provider is a row in a table: id, name, base URL, default model,
where the keys come from. Nothing generated runs at execution time.

Each registers a credential type of its own, so an OpenRouter key is never
offered to Groq - while any of them still accepts a plain bearer credential,
because the shape is identical. The base URL is a setting, so these also
reach a proxy or a self-hosted gateway speaking the same API.

What each one does: system and user prompts, a model dropdown read from the
provider's own /models, JSON mode with a defensive parse for models that
answer in a code fence anyway, images sent as content parts with a data URI,
temperature, max tokens, top p, seed, retries with exponential backoff,
skip-on-error for use inside a loop, and token usage in the output. Empty
means absent throughout, so temperature 0 survives and an unset max_tokens
leaves the provider's own default alone. Perplexity publishes no model list,
so its model field is a plain box rather than an empty dropdown that never
fills.

Two failure modes are named rather than lumped together: a refusal is
reported as a refusal instead of "empty response", and a credential is read
once outside the retry loop because a broken key is not transient.

Verified end to end against a live OpenAI-compatible endpoint: the model list
came back with 18 entries, a plain-text call answered with usage counts, a
JSON-mode call parsed into an object, and an image sent as a data URI was
described correctly with hadImage true. 60/60.
fszontagh 1 ヶ月 前
親
コミット
48e81268be

+ 21 - 0
docs/nodes.md

@@ -736,3 +736,24 @@ Trigger nodes start workflow executions. Add `@trigger` to the JSDoc:
 ```
 
 Triggers typically don't have input schemas (they generate initial data).
+
+## Chat nodes for OpenAI-compatible providers
+
+OpenRouter, Together, Groq, DeepSeek, Mistral, xAI, Fireworks, Perplexity,
+DeepInfra and OpenAI itself all accept the same chat request and return the
+same reply. One implementation covers all of them, in
+`scripts/gen-openai-compatible-nodes.py`, which writes one node per provider.
+A node cannot require another node's file, so the code is generated rather
+than shared at runtime - edit the generator, run it, commit the diff.
+
+Adding a provider is a row in the `PROVIDERS` table: its id, display name,
+base URL, a sensible default model, and where its keys come from. Set
+`no_model_list` when the provider publishes no `/models` endpoint, and the
+model field becomes a plain box rather than an empty dropdown.
+
+Each node registers a credential type of its own, so a key for one provider
+is never offered to another - and any of them will still take a plain bearer
+credential, because the shape is the same.
+
+The base URL is a setting, so any of these nodes also reaches a proxy, a
+gateway or a self-hosted service that speaks the same API.

+ 546 - 0
nodes/ai/deepinfra-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @node deepinfra-chat
+ * @name DeepInfra Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask DeepInfra 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 DeepInfra one.
+const credentialTypes = [
+    {
+        id: 'deepinfra',
+        label: 'DeepInfra API key',
+        baseType: 'bearer',
+        description: 'An API key from deepinfra.com/dash/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 DeepInfra; change it to reach a proxy or a self-hosted gateway',
+            default: 'https://api.deepinfra.com/v1/openai'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'The stored DeepInfra API key',
+            dynamicOptions: { source: 'credentials', filter: { type: ['deepinfra', 'bearer', 'api_key'] } }
+        },
+        model: {
+            type: 'string', title: 'Model',
+            description: 'Model name.',
+            default: 'meta-llama/Llama-3.3-70B-Instruct',
+            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 = 'DeepInfra';
+
+function baseOf(config) {
+    const value = String(config.baseUrl || 'https://api.deepinfra.com/v1/openai').trim().replace(/\/+$/, '');
+    if (!value) {
+        throw new Error(PROVIDER + ': a base URL is required, such as https://api.deepinfra.com/v1/openai');
+    }
+    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 };

+ 546 - 0
nodes/ai/deepseek-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @node deepseek-chat
+ * @name DeepSeek Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask DeepSeek 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 DeepSeek one.
+const credentialTypes = [
+    {
+        id: 'deepseek',
+        label: 'DeepSeek API key',
+        baseType: 'bearer',
+        description: 'An API key from platform.deepseek.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 DeepSeek; change it to reach a proxy or a self-hosted gateway',
+            default: 'https://api.deepseek.com/v1'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'The stored DeepSeek API key',
+            dynamicOptions: { source: 'credentials', filter: { type: ['deepseek', 'bearer', 'api_key'] } }
+        },
+        model: {
+            type: 'string', title: 'Model',
+            description: 'Model name. deepseek-chat for general work, deepseek-reasoner when the answer needs working out.',
+            default: 'deepseek-chat',
+            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 = 'DeepSeek';
+
+function baseOf(config) {
+    const value = String(config.baseUrl || 'https://api.deepseek.com/v1').trim().replace(/\/+$/, '');
+    if (!value) {
+        throw new Error(PROVIDER + ': a base URL is required, such as https://api.deepseek.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;
+        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 };

+ 546 - 0
nodes/ai/fireworks-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @node fireworks-chat
+ * @name Fireworks AI Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask Fireworks AI 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 Fireworks AI one.
+const credentialTypes = [
+    {
+        id: 'fireworks',
+        label: 'Fireworks AI API key',
+        baseType: 'bearer',
+        description: 'An API key from fireworks.ai/account/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 Fireworks AI; change it to reach a proxy or a self-hosted gateway',
+            default: 'https://api.fireworks.ai/inference/v1'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'The stored Fireworks AI API key',
+            dynamicOptions: { source: 'credentials', filter: { type: ['fireworks', 'bearer', 'api_key'] } }
+        },
+        model: {
+            type: 'string', title: 'Model',
+            description: 'Model name. Model names are full account paths.',
+            default: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
+            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 = 'Fireworks AI';
+
+function baseOf(config) {
+    const value = String(config.baseUrl || 'https://api.fireworks.ai/inference/v1').trim().replace(/\/+$/, '');
+    if (!value) {
+        throw new Error(PROVIDER + ': a base URL is required, such as https://api.fireworks.ai/inference/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 };

+ 546 - 0
nodes/ai/groq-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @node groq-chat
+ * @name Groq Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask Groq 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 Groq one.
+const credentialTypes = [
+    {
+        id: 'groq',
+        label: 'Groq API key',
+        baseType: 'bearer',
+        description: 'An API key from console.groq.com/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 Groq; change it to reach a proxy or a self-hosted gateway',
+            default: 'https://api.groq.com/openai/v1'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'The stored Groq API key',
+            dynamicOptions: { source: 'credentials', filter: { type: ['groq', 'bearer', 'api_key'] } }
+        },
+        model: {
+            type: 'string', title: 'Model',
+            description: 'Model name. Very fast, a small catalogue.',
+            default: 'llama-3.3-70b-versatile',
+            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 = 'Groq';
+
+function baseOf(config) {
+    const value = String(config.baseUrl || 'https://api.groq.com/openai/v1').trim().replace(/\/+$/, '');
+    if (!value) {
+        throw new Error(PROVIDER + ': a base URL is required, such as https://api.groq.com/openai/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 };

+ 546 - 0
nodes/ai/mistral-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @node mistral-chat
+ * @name Mistral Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask Mistral 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 Mistral one.
+const credentialTypes = [
+    {
+        id: 'mistral',
+        label: 'Mistral API key',
+        baseType: 'bearer',
+        description: 'An API key from console.mistral.ai/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 Mistral; change it to reach a proxy or a self-hosted gateway',
+            default: 'https://api.mistral.ai/v1'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'The stored Mistral API key',
+            dynamicOptions: { source: 'credentials', filter: { type: ['mistral', 'bearer', 'api_key'] } }
+        },
+        model: {
+            type: 'string', title: 'Model',
+            description: 'Model name.',
+            default: 'mistral-large-latest',
+            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 = 'Mistral';
+
+function baseOf(config) {
+    const value = String(config.baseUrl || 'https://api.mistral.ai/v1').trim().replace(/\/+$/, '');
+    if (!value) {
+        throw new Error(PROVIDER + ': a base URL is required, such as https://api.mistral.ai/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 };

+ 546 - 0
nodes/ai/openai-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @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';
+
+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;
+        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 };

+ 546 - 0
nodes/ai/openrouter-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @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 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 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 };

+ 520 - 0
nodes/ai/perplexity-chat.js

@@ -0,0 +1,520 @@
+/**
+ * @node perplexity-chat
+ * @name Perplexity Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask Perplexity 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 Perplexity one.
+const credentialTypes = [
+    {
+        id: 'perplexity',
+        label: 'Perplexity API key',
+        baseType: 'bearer',
+        description: 'An API key from perplexity.ai/settings/api',
+        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 Perplexity; change it to reach a proxy or a self-hosted gateway',
+            default: 'https://api.perplexity.ai'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'The stored Perplexity API key',
+            dynamicOptions: { source: 'credentials', filter: { type: ['perplexity', 'bearer', 'api_key'] } }
+        },
+        model: {
+            type: 'string', title: 'Model',
+            description: 'Model name. Answers are searched for as well as generated. It publishes no model list, so type the name.',
+            default: 'sonar'
+        },
+        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 = 'Perplexity';
+
+function baseOf(config) {
+    const value = String(config.baseUrl || 'https://api.perplexity.ai').trim().replace(/\/+$/, '');
+    if (!value) {
+        throw new Error(PROVIDER + ': a base URL is required, such as https://api.perplexity.ai');
+    }
+    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) {
+        // This provider publishes no model list, so there is nothing to
+        // offer and saying so beats an empty dropdown with no explanation.
+        return { 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 };

+ 546 - 0
nodes/ai/together-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @node together-chat
+ * @name Together AI Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask Together AI 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 Together AI one.
+const credentialTypes = [
+    {
+        id: 'together',
+        label: 'Together AI API key',
+        baseType: 'bearer',
+        description: 'An API key from api.together.ai/settings/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 Together AI; change it to reach a proxy or a self-hosted gateway',
+            default: 'https://api.together.xyz/v1'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'The stored Together AI API key',
+            dynamicOptions: { source: 'credentials', filter: { type: ['together', 'bearer', 'api_key'] } }
+        },
+        model: {
+            type: 'string', title: 'Model',
+            description: 'Model name. Open-weight models, hosted.',
+            default: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
+            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 = 'Together AI';
+
+function baseOf(config) {
+    const value = String(config.baseUrl || 'https://api.together.xyz/v1').trim().replace(/\/+$/, '');
+    if (!value) {
+        throw new Error(PROVIDER + ': a base URL is required, such as https://api.together.xyz/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 };

+ 546 - 0
nodes/ai/xai-chat.js

@@ -0,0 +1,546 @@
+/**
+ * @node xai-chat
+ * @name xAI Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask xAI 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 xAI one.
+const credentialTypes = [
+    {
+        id: 'xai',
+        label: 'xAI API key',
+        baseType: 'bearer',
+        description: 'An API key from console.x.ai',
+        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 xAI; change it to reach a proxy or a self-hosted gateway',
+            default: 'https://api.x.ai/v1'
+        },
+        credentialId: {
+            type: 'string', title: 'Credential',
+            description: 'The stored xAI API key',
+            dynamicOptions: { source: 'credentials', filter: { type: ['xai', 'bearer', 'api_key'] } }
+        },
+        model: {
+            type: 'string', title: 'Model',
+            description: 'Model name. The Grok models.',
+            default: 'grok-2-latest',
+            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 = 'xAI';
+
+function baseOf(config) {
+    const value = String(config.baseUrl || 'https://api.x.ai/v1').trim().replace(/\/+$/, '');
+    if (!value) {
+        throw new Error(PROVIDER + ': a base URL is required, such as https://api.x.ai/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 };

+ 671 - 0
scripts/gen-openai-compatible-nodes.py

@@ -0,0 +1,671 @@
+#!/usr/bin/env python3
+"""
+Write one chat node per provider that speaks the OpenAI chat-completions API.
+
+OpenRouter, Together, Groq, DeepSeek and the rest all accept the same request
+and return the same response - the differences are the address, the name on the
+key, and which models are on offer. That is not enough to justify writing the
+same four hundred lines nine times, and a node cannot require another node's
+file, so the implementation lives here and each node is generated from it.
+
+Change the template or the provider table, run this, check the diff, commit.
+Nothing here runs at execution time.
+
+  python3 scripts/gen-openai-compatible-nodes.py
+"""
+
+import json
+from pathlib import Path
+
+PROVIDERS = [
+    {
+        'id': 'openai', 'name': 'OpenAI',
+        'base': 'https://api.openai.com/v1',
+        'model': 'gpt-4o-mini',
+        'keys': 'platform.openai.com/api-keys',
+        'note': 'Also reaches anything else served behind an OpenAI-compatible address - point the base URL at it.',
+    },
+    {
+        'id': 'openrouter', 'name': 'OpenRouter',
+        'base': 'https://openrouter.ai/api/v1',
+        'model': 'openai/gpt-4o-mini',
+        'keys': 'openrouter.ai/keys',
+        'note': 'One key for models from many providers. Model names carry the provider, as in anthropic/claude-3.5-sonnet.',
+    },
+    {
+        'id': 'together', 'name': 'Together AI',
+        'base': 'https://api.together.xyz/v1',
+        'model': 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
+        'keys': 'api.together.ai/settings/api-keys',
+        'note': 'Open-weight models, hosted.',
+    },
+    {
+        'id': 'groq', 'name': 'Groq',
+        'base': 'https://api.groq.com/openai/v1',
+        'model': 'llama-3.3-70b-versatile',
+        'keys': 'console.groq.com/keys',
+        'note': 'Very fast, a small catalogue.',
+    },
+    {
+        'id': 'deepseek', 'name': 'DeepSeek',
+        'base': 'https://api.deepseek.com/v1',
+        'model': 'deepseek-chat',
+        'keys': 'platform.deepseek.com/api_keys',
+        'note': 'deepseek-chat for general work, deepseek-reasoner when the answer needs working out.',
+    },
+    {
+        'id': 'mistral', 'name': 'Mistral',
+        'base': 'https://api.mistral.ai/v1',
+        'model': 'mistral-large-latest',
+        'keys': 'console.mistral.ai/api-keys',
+        'note': '',
+    },
+    {
+        'id': 'xai', 'name': 'xAI',
+        'base': 'https://api.x.ai/v1',
+        'model': 'grok-2-latest',
+        'keys': 'console.x.ai',
+        'note': 'The Grok models.',
+    },
+    {
+        'id': 'fireworks', 'name': 'Fireworks AI',
+        'base': 'https://api.fireworks.ai/inference/v1',
+        'model': 'accounts/fireworks/models/llama-v3p3-70b-instruct',
+        'keys': 'fireworks.ai/account/api-keys',
+        'note': 'Model names are full account paths.',
+    },
+    {
+        'id': 'perplexity', 'name': 'Perplexity',
+        'base': 'https://api.perplexity.ai',
+        'model': 'sonar',
+        'keys': 'perplexity.ai/settings/api',
+        'note': 'Answers are searched for as well as generated. It publishes no model list, so type the name.',
+        'no_model_list': True,
+    },
+    {
+        'id': 'deepinfra', 'name': 'DeepInfra',
+        'base': 'https://api.deepinfra.com/v1/openai',
+        'model': 'meta-llama/Llama-3.3-70B-Instruct',
+        'keys': 'deepinfra.com/dash/api_keys',
+        'note': '',
+    },
+]
+
+TEMPLATE = r'''/**
+ * @node {id}-chat
+ * @name {name} Chat
+ * @category ai
+ * @version 1.0.0
+ * @description Ask {name} 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 {name} one.
+const credentialTypes = [
+    {{
+        id: '{id}',
+        label: '{name} API key',
+        baseType: 'bearer',
+        description: 'An API key from {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 {name}; change it to reach a proxy or a self-hosted gateway',
+            default: '{base}'
+        }},
+        credentialId: {{
+            type: 'string', title: 'Credential',
+            description: 'The stored {name} API key',
+            dynamicOptions: {{ source: 'credentials', filter: {{ type: ['{id}', 'bearer', 'api_key'] }} }}
+        }},
+        model: {{
+            type: 'string', title: 'Model',
+            description: 'Model name.{note_suffix}',
+            default: '{model}'{model_options}
+        }},
+        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 = '{name}';
+
+function baseOf(config) {{
+    const value = String(config.baseUrl || '{base}').trim().replace(/\/+$/, '');
+    if (!value) {{
+        throw new Error(PROVIDER + ': a base URL is required, such as {base}');
+    }}
+    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) {{
+        {list_only}
+    }}
+
+    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 }};
+'''
+
+LIST_ONLY = r'''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 };'''
+
+NO_LIST = r'''// This provider publishes no model list, so there is nothing to
+        // offer and saying so beats an empty dropdown with no explanation.
+        return { models: [] };'''
+
+MODEL_OPTIONS = r''',
+            dynamicOptions: {
+                source: 'node',
+                config: { listOnly: true },
+                itemsPath: 'models',
+                valueKey: 'name',
+                labelKey: 'name',
+                needs: ['baseUrl', 'credentialId']
+            }'''
+
+
+def main():
+    root = Path(__file__).resolve().parent.parent / 'nodes' / 'ai'
+    for provider in PROVIDERS:
+        note = provider.get('note', '')
+        source = TEMPLATE.format(
+            id=provider['id'],
+            name=provider['name'],
+            base=provider['base'],
+            model=provider['model'],
+            keys=provider['keys'],
+            note_suffix=(' ' + note) if note else '',
+            model_options='' if provider.get('no_model_list') else MODEL_OPTIONS,
+            list_only=NO_LIST if provider.get('no_model_list') else LIST_ONLY,
+        )
+        path = root / f"{provider['id']}-chat.js"
+        path.write_text(source)
+        print(f"{provider['id'] + '-chat.js':24} {provider['base']}")
+
+
+if __name__ == '__main__':
+    main()