|
|
@@ -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()
|