| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671 |
- #!/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()
|