| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- /**
- * @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';
- // Statuses this provider documents as answers that will not change.
- //
- // xAI publishes no error-code table. This is the conservative common set every OpenAI-compatible provider agrees on; nothing provider-specific is assumed.
- //
- // 429 is treated as transient unless a code below says otherwise - backing off
- // is exactly what a rate limit asks for. 5xx is transient unless listed.
- const PERMANENT_STATUSES = [400, 401, 403, 404];
- const PERMANENT_ERROR_CODES = [];
- 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;
- let code = '';
- if (body && body.error) {
- detail = typeof body.error === 'string' ? body.error :
- (body.error.message || JSON.stringify(body.error));
- code = (body.error && body.error.code) || (body.error && body.error.type) || '';
- } else if (typeof body === 'string' && body) {
- detail = body.substring(0, 200).replace(/\s+/g, ' ');
- }
- const error = new Error(PROVIDER + ' ' + options.what + ' failed: ' + detail);
- error.status = response.status;
- // Whether asking again could ever give a different answer. Retrying a
- // refusal does not just waste time - where the refusal is a spent
- // allowance or a billing stop, it spends more of whatever ran out.
- error.permanent = PERMANENT_STATUSES.indexOf(response.status) !== -1 ||
- (PERMANENT_ERROR_CODES.length > 0 && code &&
- PERMANENT_ERROR_CODES.indexOf(String(code)) !== -1);
- throw error;
- }
- return body || {};
- }
- function getPath(root, path) {
- if (!root || !path) {
- return undefined;
- }
- const parts = String(path).split('.');
- let current = root;
- for (let i = 0; i < parts.length; i++) {
- if (current === null || typeof current !== 'object') {
- return undefined;
- }
- current = current[parts[i]];
- }
- return current;
- }
- // Models are asked for JSON and answer with a fenced code block often enough
- // that refusing it would mean retrying a perfectly good answer.
- // A model asked for JSON that returns something almost-JSON is repaired rather
- // than retried. The retry existed for a model having a bad moment, but where
- // the fault is deterministic - a model that always omits the opening brace -
- // every extra attempt is another paid call for the same malformed answer.
- // What was repaired is logged, so a model that has started ignoring the schema
- // is visible rather than quietly patched over on every run.
- function parseModelJson(text, provider) {
- var repaired = smartbotic.utils.repairJson(text);
- if (!repaired.ok) {
- // Nothing salvageable - let the strict parser raise the real message.
- return JSON.parse(text);
- }
- if (repaired.repairs.length > 0) {
- smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
- repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
- }
- return repaired.value;
- }
- function stripFences(text) {
- const out = String(text || '').trim();
- if (out.indexOf('```') === -1) {
- return out;
- }
- const first = out.indexOf('{');
- const last = out.lastIndexOf('}');
- if (first !== -1 && last !== -1 && last > first) {
- return out.substring(first, last + 1);
- }
- const firstArr = out.indexOf('[');
- const lastArr = out.lastIndexOf(']');
- if (firstArr !== -1 && lastArr !== -1 && lastArr > firstArr) {
- return out.substring(firstArr, lastArr + 1);
- }
- return out;
- }
- // The image can be anywhere in the input: inside a loop, or with another node
- // between the download and this one, it is nested rather than sitting at the
- // top. So the input is searched breadth-first rather than guessed at.
- function findImage(input, override) {
- const result = { base64: '', mimeType: '', url: '' };
- if (override) {
- const direct = getPath(input, override);
- if (typeof direct === 'string' && direct.length > 0) {
- result.base64 = direct;
- return result;
- }
- }
- const roots = [];
- const queue = [input];
- let guard = 0;
- while (queue.length > 0 && guard < 64) {
- guard++;
- const node = queue.shift();
- if (!node || typeof node !== 'object') {
- continue;
- }
- roots.push(node);
- const keys = Object.keys(node);
- for (let k = 0; k < keys.length; k++) {
- const child = node[keys[k]];
- if (child && typeof child === 'object' && keys[k] !== 'file') {
- queue.push(child);
- }
- }
- }
- for (let i = 0; i < roots.length; i++) {
- const root = roots[i];
- if (!root || typeof root !== 'object') {
- continue;
- }
- if (!result.base64 && root.file && typeof root.file.data === 'string') {
- result.base64 = root.file.data;
- result.mimeType = root.file.mimeType || '';
- }
- if (!result.base64 && typeof root.base64 === 'string') {
- result.base64 = root.base64;
- }
- if (!result.base64 && typeof root.imageBase64 === 'string') {
- result.base64 = root.imageBase64;
- }
- if (!result.url && typeof root.url === 'string') {
- result.url = root.url;
- }
- if (!result.url && typeof root.sourceUrl === 'string') {
- result.url = root.sourceUrl;
- }
- }
- return result;
- }
- function buildMessages(config, image) {
- const messages = [];
- const system = String(config.systemPrompt || '').trim();
- if (system) {
- messages.push({ role: 'system', content: system });
- }
- const text = String(config.userPrompt || 'Describe the input.');
- if (!image.base64) {
- messages.push({ role: 'user', content: text });
- return messages;
- }
- // With an image the content becomes a list of parts, which is how every
- // OpenAI-compatible provider takes one. The base64 goes in as a data URI.
- const imagePart = {
- type: 'image_url',
- image_url: { url: 'data:' + (image.mimeType || 'image/jpeg') + ';base64,' + image.base64 }
- };
- if (config.imageDetail) {
- imagePart.image_url.detail = String(config.imageDetail);
- }
- messages.push({ role: 'user', content: [{ type: 'text', text: text }, imagePart] });
- return messages;
- }
- function putIfSet(target, key, value) {
- if (value === undefined || value === null || value === '') {
- return;
- }
- target[key] = value;
- }
- function chat(config, image, headers) {
- const body = {
- model: config.model,
- messages: buildMessages(config, image),
- stream: false
- };
- // Temperature 0 is a real setting and must survive, so emptiness is the
- // test rather than truthiness.
- putIfSet(body, 'temperature', config.temperature);
- putIfSet(body, 'max_tokens', config.maxTokens);
- putIfSet(body, 'top_p', config.topP);
- putIfSet(body, 'seed', config.seed);
- if (config.responseFormat === 'json' && config.jsonMode !== false) {
- body.response_format = { type: 'json_object' };
- }
- const extra = config.extraBody;
- if (extra && typeof extra === 'object') {
- const keys = Object.keys(extra);
- for (let i = 0; i < keys.length; i++) {
- body[keys[i]] = extra[keys[i]];
- }
- }
- const answer = request({
- method: 'POST',
- url: baseOf(config) + '/chat/completions',
- headers: headers,
- body: JSON.stringify(body),
- timeout: Number(config.timeoutMs) || 120000,
- what: 'the chat request'
- });
- const choice = (answer.choices && answer.choices[0]) || {};
- const message = choice.message || {};
- let content = message.content;
- // Some providers answer with the content already split into parts.
- if (content && typeof content !== 'string' && typeof content.length === 'number') {
- let joined = '';
- for (let i = 0; i < content.length; i++) {
- const part = content[i];
- if (part && typeof part.text === 'string') { joined += part.text; }
- }
- content = joined;
- }
- if (!content) {
- // A refusal is a documented field of its own, and reporting "empty
- // response" for one sends the reader looking in the wrong place.
- if (message.refusal) {
- throw new Error(PROVIDER + ' declined to answer: ' + message.refusal);
- }
- throw new Error(PROVIDER + ' returned an empty reply' +
- (choice.finish_reason ? ' (finished: ' + choice.finish_reason + ')' : ''));
- }
- return {
- content: String(content),
- model: String(answer.model || config.model),
- finishReason: String(choice.finish_reason || ''),
- usage: answer.usage || {}
- };
- }
- async function execute(config, input, context) {
- // Asked for its model list by the editor rather than run. Answered before
- // anything else, because none of the generation settings apply.
- if (config.listOnly === true) {
- const listing = request({
- method: 'GET',
- url: baseOf(config) + '/models',
- headers: authHeaders(config),
- timeout: Number(config.timeoutMs) || 30000,
- what: 'listing the models'
- });
- const rows = listing.data || listing.models || [];
- const models = [];
- for (let i = 0; i < rows.length; i++) {
- const row = rows[i] || {};
- const name = row.id || row.name || String(row);
- if (name) { models.push({ name: String(name) }); }
- }
- // Alphabetical: providers return these in whatever order they please,
- // and a list of hundreds is unusable without one.
- models.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
- smartbotic.log.info(PROVIDER + ': ' + models.length + ' model(s) on offer');
- return { models: models };
- }
- let image = { base64: '', mimeType: '', url: '' };
- if (config.imageMode === 'auto') {
- image = findImage(input, config.imageField);
- if (!image.base64 && image.url) {
- smartbotic.log.info(PROVIDER + ': fetching the image from ' + image.url);
- const download = smartbotic.http.request({
- method: 'GET', url: image.url, timeout: 60000
- });
- if (download.status < 200 || download.status >= 300) {
- throw new Error(PROVIDER + ': could not fetch the image: HTTP ' + download.status);
- }
- image.base64 = typeof download.data === 'string'
- ? smartbotic.utils.base64Encode(download.data) : '';
- }
- }
- // Resolved once, outside the retry loop: a missing or broken credential is
- // not transient, and retrying it with backoff only wastes time.
- const headers = authHeaders(config);
- const wantJson = config.responseFormat === 'json';
- const attempts = 1 + (Number(config.retryCount) > 0 ? Number(config.retryCount) : 0);
- let answer = null;
- let parsed = null;
- let lastError = '';
- let used = 0;
- for (let attempt = 1; attempt <= attempts; attempt++) {
- used = attempt;
- try {
- answer = chat(config, image, headers);
- if (wantJson) {
- parsed = parseModelJson(stripFences(answer.content), PROVIDER);
- }
- lastError = '';
- break;
- } catch (err) {
- lastError = err && err.message ? err.message : String(err);
- parsed = null;
- smartbotic.log.warn(PROVIDER + ': attempt ' + attempt + ' of ' + attempts +
- ' failed: ' + lastError);
- if (err && err.permanent) {
- smartbotic.log.warn(PROVIDER + ': the request was refused (HTTP ' +
- err.status + '), so the remaining attempts were not made');
- break;
- }
- if (attempt < attempts) {
- const base = Number(config.retryDelayMs) || 2000;
- const cap = Number(config.retryMaxDelayMs) || 30000;
- let delay = base * Math.pow(2, attempt - 1);
- if (delay > cap) { delay = cap; }
- smartbotic.log.info(PROVIDER + ': waiting ' + delay + 'ms before the next attempt');
- smartbotic.utils.sleep(delay);
- }
- }
- }
- const passImage = config.passthroughImage === true;
- const common = {
- attempts: used,
- hadImage: image.base64 ? true : false,
- imageBase64: passImage ? image.base64 : '',
- mimeType: image.mimeType,
- sourceUrl: image.url
- };
- if (lastError) {
- if (config.skipOnError !== true) {
- throw new Error(PROVIDER + ' failed after ' + used + ' attempt(s): ' + lastError);
- }
- smartbotic.log.warn(PROVIDER + ': skipping after ' + used + ' attempt(s)');
- return Object.assign({
- success: false, error: lastError, content: (answer && answer.content) || '',
- json: null, model: config.model, finishReason: '', usage: {}
- }, common);
- }
- return Object.assign({
- success: true, error: '', content: answer.content, json: parsed,
- model: answer.model, finishReason: answer.finishReason, usage: answer.usage
- }, common);
- }
- module.exports = { credentialTypes, configSchema, inputSchema, outputSchema, execute };
|