feat: add nanogpt provider selection (#5544) * add nanogpt provider selection * update payg text * fix: resync providers from endpoint --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

4ca9863f3809be18e39fc7439be234035f54abeb

DeathStalker471 <thomasnealson85@gmail.com>

Signed
5 files changed, +370 -1Ignore whitespace
public/index.html+13 -0
@@ -3648,6 +3648,19 @@
36483648 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
36493649 </select>
36503650 </div>
3651+ <div>
3652+ <h4>
3653+ <span data-i18n="Model Providers">Model Providers</span>
3654+ <i id="nanogpt_provider_warning" class="fa-solid fa-circle-exclamation displayNone" title="Deselect inapplicable provider(s) or select an applicable provider to avoid a 404 error."></i>
3655+ </h4>
3656+ <select id="nanogpt_provider">
3657+ <option value="" data-i18n="Auto">Auto</option>
3658+ </select>
3659+ <label class="checkbox_label marginTopBot5" for="nanogpt_payg_override" data-i18n="[title]Force NanoGPT pay-as-you-go billing for this request." title="Force NanoGPT pay-as-you-go billing for this request.">
3660+ <input id="nanogpt_payg_override" type="checkbox" />
3661+ <span data-i18n="Use pay-as-you-go billing">Use pay-as-you-go billing</span>
3662+ </label>
3663+ </div>
36513664 </div>
36523665 <div id="workers_ai_form" data-source="workers_ai">
36533666 <h4><a href="https://dash.cloudflare.com/?to=/:account/ai/workers-ai/api-quick-start" target="_blank" rel="noopener noreferrer" data-i18n="Cloudflare Workers AI API Key">Cloudflare Workers AI API Key</a></h4>
public/scripts/openai.js+24 -1
@@ -80,7 +80,7 @@ import { t } from './i18n.js';
8080import { ToolManager } from './tool-calling.js';
8181import { accountStorage } from './util/AccountStorage.js';
8282import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL, MEDIA_DISPLAY, MEDIA_TYPE } from './constants.js';
8383import { syncNanoGptProvidersForModel, syncOpenRouterProvidersForModel, updateNanoGptProvidersWarning, updateOpenRouterProvidersWarning } from './textgen-models.js';
8484
8585export {
8686 openai_messages_count,
@@ -329,6 +329,8 @@ export const settingsToUpdate = {
329329 minimax_endpoint: ['#minimax_endpoint', 'minimax_endpoint', false, true],
330330 electronhub_model: ['#model_electronhub_select', 'electronhub_model', false, true],
331331 nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],
332+ nanogpt_provider: ['#nanogpt_provider', 'nanogpt_provider', false, true],
333+ nanogpt_payg_override: ['#nanogpt_payg_override', 'nanogpt_payg_override', true, true],
332334 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],
333335 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],
334336 xai_model: ['#model_xai_select', 'xai_model', false, true],
@@ -443,6 +445,8 @@ const default_settings = {
443445 minimax_endpoint: MINIMAX_ENDPOINT.GLOBAL,
444446 electronhub_model: 'gpt-4o-mini',
445447 nanogpt_model: 'gpt-4o-mini',
448+ nanogpt_provider: '',
449+ nanogpt_payg_override: false,
446450 deepseek_model: 'deepseek-v4-flash',
447451 aimlapi_model: 'chatgpt-4o-latest',
448452 xai_model: 'grok-3-beta',
@@ -2828,6 +2832,11 @@ export async function createGenerationParameters(settings, model, type, messages
28282832 generate_data.middleout = settings.openrouter_middleout;
28292833 }
28302834
2835+ if (settings.chat_completion_source === chat_completion_sources.NANOGPT) {
2836+ generate_data.nanogpt_provider = settings.nanogpt_provider;
2837+ generate_data.nanogpt_payg_override = settings.nanogpt_payg_override;
2838+ }
2839+
28312840 if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(settings.chat_completion_source)) {
28322841 const stopStringsLimit = 5;
28332842 generate_data.top_k = Number(settings.top_k_openai);
@@ -4290,6 +4299,7 @@ function loadOpenAISettings(data, settings) {
42904299
42914300 $('#openrouter_providers_chat').trigger('change');
42924301 $('#openrouter_quantizations_chat').trigger('change');
4302+ $('#nanogpt_provider').trigger('change');
42934303 $('#chat_completion_source').trigger('change');
42944304}
42954305
@@ -4937,6 +4947,7 @@ function onSettingsPresetChange() {
49374947 $('#chat_completion_source').trigger('change');
49384948 $('#openrouter_providers_chat').trigger('change');
49394949 $('#openrouter_quantizations_chat').trigger('change');
4950+ $('#nanogpt_provider').trigger('change');
49404951 }
49414952
49424953 $('#openai_logit_bias_preset').trigger('change');
@@ -5464,6 +5475,7 @@ async function onModelChange() {
54645475
54655476 console.log('NanoGPT model changed to', value);
54665477 oai_settings.nanogpt_model = value;
5478+ syncNanoGptProvidersForModel(value, '#nanogpt_provider');
54675479 }
54685480
54695481 if ($(this).is('#model_deepseek_select')) {
@@ -7134,6 +7146,17 @@ export function initOpenAI() {
71347146 saveSettingsDebounced();
71357147 });
71367148
7149+ $('#nanogpt_provider').on('change', function () {
7150+ oai_settings.nanogpt_provider = String($(this).val() || '');
7151+ updateNanoGptProvidersWarning('#nanogpt_provider');
7152+ saveSettingsDebounced();
7153+ });
7154+
7155+ $('#nanogpt_payg_override').on('input', function () {
7156+ oai_settings.nanogpt_payg_override = !!$(this).prop('checked');
7157+ saveSettingsDebounced();
7158+ });
7159+
71377160 $('#bind_preset_to_connection').on('input', function () {
71387161 oai_settings.bind_preset_to_connection = !!$(this).prop('checked');
71397162 saveSettingsDebounced();
public/scripts/textgen-models.js+290 -0
@@ -108,6 +108,214 @@ const OPENROUTER_PROVIDERS = [
108108 'Z.AI',
109109];
110110
111+/**
112+ * List of NanoGPT providers.
113+ * Providers endpoint: https://nano-gpt.com/api/models/providers
114+ * @type {{id: string, label: string}[]}
115+ */
116+const NANOGPT_PROVIDERS = [
117+ {
118+ 'id': 'akash',
119+ 'label': 'Akash',
120+ },
121+ {
122+ 'id': 'alibaba',
123+ 'label': 'Alibaba',
124+ },
125+ {
126+ 'id': 'ambient',
127+ 'label': 'Ambient',
128+ },
129+ {
130+ 'id': 'arliai',
131+ 'label': 'ArliAI',
132+ },
133+ {
134+ 'id': 'atlascloud',
135+ 'label': 'AtlasCloud',
136+ },
137+ {
138+ 'id': 'azure',
139+ 'label': 'Azure',
140+ },
141+ {
142+ 'id': 'awsbedrock',
143+ 'label': 'Amazon Bedrock',
144+ },
145+ {
146+ 'id': 'baidu',
147+ 'label': 'Baidu',
148+ },
149+ {
150+ 'id': 'baseten',
151+ 'label': 'BaseTen',
152+ },
153+ {
154+ 'id': 'cerebras',
155+ 'label': 'Cerebras',
156+ },
157+ {
158+ 'id': 'chutes',
159+ 'label': 'Chutes',
160+ },
161+ {
162+ 'id': 'clarifai',
163+ 'label': 'Clarifai',
164+ },
165+ {
166+ 'id': 'cloudflare',
167+ 'label': 'Cloudflare',
168+ },
169+ {
170+ 'id': 'crusoe',
171+ 'label': 'Crusoe',
172+ },
173+ {
174+ 'id': 'dekallm',
175+ 'label': 'DekaLLM',
176+ },
177+ {
178+ 'id': 'deepinfra',
179+ 'label': 'DeepInfra',
180+ },
181+ {
182+ 'id': 'deepseek',
183+ 'label': 'DeepSeek',
184+ },
185+ {
186+ 'id': 'fireworks',
187+ 'label': 'Fireworks',
188+ },
189+ {
190+ 'id': 'friendli',
191+ 'label': 'Friendli',
192+ },
193+ {
194+ 'id': 'gmicloud',
195+ 'label': 'GMICloud',
196+ },
197+ {
198+ 'id': 'lilac',
199+ 'label': 'Lilac',
200+ },
201+ {
202+ 'id': 'google',
203+ 'label': 'Google',
204+ },
205+ {
206+ 'id': 'groq',
207+ 'label': 'Groq',
208+ },
209+ {
210+ 'id': 'hyperbolic',
211+ 'label': 'Hyperbolic',
212+ },
213+ {
214+ 'id': 'ionet',
215+ 'label': 'Io Net',
216+ },
217+ {
218+ 'id': 'inceptron',
219+ 'label': 'Inceptron',
220+ },
221+ {
222+ 'id': 'mancer',
223+ 'label': 'Mancer',
224+ },
225+ {
226+ 'id': 'mara',
227+ 'label': 'Mara',
228+ },
229+ {
230+ 'id': 'meganova',
231+ 'label': 'MegaNova',
232+ },
233+ {
234+ 'id': 'minimax',
235+ 'label': 'MiniMax',
236+ },
237+ {
238+ 'id': 'modelrun',
239+ 'label': 'ModelRun',
240+ },
241+ {
242+ 'id': 'moonshot',
243+ 'label': 'Moonshot',
244+ },
245+ {
246+ 'id': 'morph',
247+ 'label': 'Morph',
248+ },
249+ {
250+ 'id': 'ncompass',
251+ 'label': 'NCompass',
252+ },
253+ {
254+ 'id': 'nebius',
255+ 'label': 'Nebius',
256+ },
257+ {
258+ 'id': 'neuralwatt',
259+ 'label': 'Neuralwatt',
260+ },
261+ {
262+ 'id': 'nextbit',
263+ 'label': 'NextBit',
264+ },
265+ {
266+ 'id': 'novita',
267+ 'label': 'Novita',
268+ },
269+ {
270+ 'id': 'parasail',
271+ 'label': 'Parasail',
272+ },
273+ {
274+ 'id': 'phala',
275+ 'label': 'Phala',
276+ },
277+ {
278+ 'id': 'redpill',
279+ 'label': 'Redpill',
280+ },
281+ {
282+ 'id': 'sambanova',
283+ 'label': 'SambaNova',
284+ },
285+ {
286+ 'id': 'sambanova-high-throughput',
287+ 'label': 'SambaNova (High Throughput)',
288+ },
289+ {
290+ 'id': 'siliconflow',
291+ 'label': 'SiliconFlow',
292+ },
293+ {
294+ 'id': 'streamlake',
295+ 'label': 'StreamLake',
296+ },
297+ {
298+ 'id': 'tinfoil',
299+ 'label': 'Tinfoil',
300+ },
301+ {
302+ 'id': 'together',
303+ 'label': 'Together',
304+ },
305+ {
306+ 'id': 'venice',
307+ 'label': 'Venice',
308+ },
309+ {
310+ 'id': 'wandb',
311+ 'label': 'Weights & Biases',
312+ },
313+ {
314+ 'id': 'zai',
315+ 'label': 'Z.AI',
316+ },
317+];
318+
111319const OPENROUTER_PROVIDER_WARNING_SELECTORS = {
112320 '#openrouter_providers_text': {
113321 fallbackSelector: '#openrouter_allow_fallbacks_textgenerationwebui',
@@ -187,6 +395,72 @@ export async function syncOpenRouterProvidersForModel(modelId, providersSelector
187395 }
188396}
189397
398+export async function syncNanoGptProvidersForModel(modelId, providersSelector) {
399+ const $providers = $(providersSelector);
400+
401+ const refreshWarningState = () => {
402+ updateNanoGptProvidersWarning(providersSelector);
403+ };
404+
405+ if (!modelId) {
406+ $providers.find('option').prop('disabled', false);
407+ $providers.trigger('change.select2');
408+ refreshWarningState();
409+ return;
410+ }
411+
412+ try {
413+ const response = await fetch('/api/nanogpt/models/providers', {
414+ method: 'POST',
415+ headers: getRequestHeaders(),
416+ body: JSON.stringify({ model: modelId }),
417+ });
418+
419+ if (!response.ok) {
420+ refreshWarningState();
421+ return;
422+ }
423+
424+ const data = await response.json();
425+ const providerIds = Array.isArray(data?.providers) ? data.providers : [];
426+
427+ if (!data?.supportsProviderSelection || providerIds.length === 0) {
428+ $providers.find('option').each(function () {
429+ $(this).prop('disabled', Boolean($(this).val()));
430+ });
431+ $providers.trigger('change').trigger('change.select2');
432+ refreshWarningState();
433+ return;
434+ }
435+
436+ $providers.find('option').each(function () {
437+ const value = $(this).val();
438+ const isAvailable = !value || providerIds.includes(value);
439+ $(this).prop('disabled', !isAvailable);
440+ });
441+
442+ $providers.trigger('change.select2');
443+ refreshWarningState();
444+ } catch (error) {
445+ console.error('Failed to fetch NanoGPT providers for model', error);
446+ refreshWarningState();
447+ }
448+}
449+
450+export function updateNanoGptProvidersWarning(providersSelector) {
451+ const $providers = $(providersSelector);
452+
453+ if ($providers.length === 0) {
454+ return;
455+ }
456+
457+ const selectedCount = $providers.find('option:selected').length;
458+ const applicableSelectedCount = $providers.find('option:selected:not(:disabled)').length;
459+ const showWarning = selectedCount > 0 && applicableSelectedCount === 0;
460+
461+ $('#nanogpt_provider_warning').toggleClass('displayNone', !showWarning);
462+}
463+
190464export async function loadOllamaModels(data) {
191465 if (!Array.isArray(data)) {
192466 console.error('Invalid Ollama models data', data);
@@ -1084,6 +1358,14 @@ export function initTextGenModels() {
10841358 }));
10851359 }
10861360
1361+ const nanoGptProvidersSelect = $('#nanogpt_provider');
1362+ for (const provider of NANOGPT_PROVIDERS) {
1363+ nanoGptProvidersSelect.append($('<option>', {
1364+ value: provider.id,
1365+ text: provider.label,
1366+ }));
1367+ }
1368+
10871369 if (!isMobile()) {
10881370 $('#mancer_model').select2({
10891371 placeholder: t`Select a model`,
@@ -1178,5 +1460,13 @@ export function initTextGenModels() {
11781460 $(this).append($element);
11791461 $(this).trigger('change');
11801462 });
1463+ nanoGptProvidersSelect.select2({
1464+ sorter: data => data.sort((a, b) => a.text.localeCompare(b.text)),
1465+ placeholder: t`Select providers. No selection = all providers.`,
1466+ searchInputPlaceholder: t`Search providers...`,
1467+ searchInputCssClass: 'text_pole',
1468+ width: '100%',
1469+ allowClear: true,
1470+ });
11811471 }
11821472}
src/endpoints/backends/chat-completions.js+7 -0
@@ -2382,6 +2382,13 @@ router.post('/generate', async function (request, response) {
23822382 apiKey = readSecret(request.user.directories, SECRET_KEYS.NANOGPT, request.body.secret_id);
23832383 headers = {};
23842384 bodyParams = {};
2385+ if (request.body.nanogpt_provider) {
2386+ headers['X-Provider'] = request.body.nanogpt_provider;
2387+ }
2388+ if (request.body.nanogpt_payg_override) {
2389+ headers['X-Billing-Mode'] = 'paygo';
2390+ bodyParams['billing_mode'] = 'paygo';
2391+ }
23852392 if (request.body.enable_web_search && !/:online$/.test(request.body.model)) {
23862393 request.body.model = `${request.body.model}:online`;
23872394 }
src/endpoints/nanogpt.js+36 -0
@@ -100,3 +100,39 @@ router.post('/credits', async (req, res) => {
100100 return res.sendStatus(500);
101101 }
102102});
103+
104+router.post('/models/providers', async (req, res) => {
105+ try {
106+ const { model } = req.body;
107+
108+ if (!model) {
109+ return res.status(400).json({ supportsProviderSelection: false, providers: [] });
110+ }
111+
112+ const encodedModel = encodeURIComponent(model);
113+ const response = await fetch(`${API_NANOGPT}/models/${encodedModel}/providers`, {
114+ method: 'GET',
115+ headers: {
116+ 'Accept': 'application/json',
117+ },
118+ });
119+
120+ if (!response.ok) {
121+ return res.json({ supportsProviderSelection: false, providers: [] });
122+ }
123+
124+ /** @type {any} */
125+ const data = await response.json();
126+ const providers = Array.isArray(data?.providers)
127+ ? data.providers.filter(p => p?.available !== false).map(p => p.provider).filter(Boolean)
128+ : [];
129+
130+ return res.json({
131+ supportsProviderSelection: Boolean(data?.supportsProviderSelection),
132+ providers,
133+ });
134+ } catch (error) {
135+ console.error(error);
136+ return res.sendStatus(500);
137+ }
138+});