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 -1Showing whitespace changes
public/index.html+13 -0
@@ -3648,6 +3648,19 @@
3648 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>3648 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
3649 </select>3649 </select>
3650 </div>3650 </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>
3651 </div>3664 </div>
3652 <div id="workers_ai_form" data-source="workers_ai">3665 <div id="workers_ai_form" data-source="workers_ai">
3653 <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>3666 <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';
80import { ToolManager } from './tool-calling.js';80import { ToolManager } from './tool-calling.js';
81import { accountStorage } from './util/AccountStorage.js';81import { accountStorage } from './util/AccountStorage.js';
82import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL, MEDIA_DISPLAY, MEDIA_TYPE } from './constants.js';82import { COMETAPI_IGNORE_PATTERNS, IGNORE_SYMBOL, MEDIA_DISPLAY, MEDIA_TYPE } from './constants.js';
83import { syncOpenRouterProvidersForModel, updateOpenRouterProvidersWarning } from './textgen-models.js';83import { syncNanoGptProvidersForModel, syncOpenRouterProvidersForModel, updateNanoGptProvidersWarning, updateOpenRouterProvidersWarning } from './textgen-models.js';
8484
85export {85export {
86 openai_messages_count,86 openai_messages_count,
@@ -329,6 +329,8 @@ export const settingsToUpdate = {
329 minimax_endpoint: ['#minimax_endpoint', 'minimax_endpoint', false, true],329 minimax_endpoint: ['#minimax_endpoint', 'minimax_endpoint', false, true],
330 electronhub_model: ['#model_electronhub_select', 'electronhub_model', false, true],330 electronhub_model: ['#model_electronhub_select', 'electronhub_model', false, true],
331 nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],331 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],
332 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],334 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],
333 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],335 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],
334 xai_model: ['#model_xai_select', 'xai_model', false, true],336 xai_model: ['#model_xai_select', 'xai_model', false, true],
@@ -443,6 +445,8 @@ const default_settings = {
443 minimax_endpoint: MINIMAX_ENDPOINT.GLOBAL,445 minimax_endpoint: MINIMAX_ENDPOINT.GLOBAL,
444 electronhub_model: 'gpt-4o-mini',446 electronhub_model: 'gpt-4o-mini',
445 nanogpt_model: 'gpt-4o-mini',447 nanogpt_model: 'gpt-4o-mini',
448 nanogpt_provider: '',
449 nanogpt_payg_override: false,
446 deepseek_model: 'deepseek-v4-flash',450 deepseek_model: 'deepseek-v4-flash',
447 aimlapi_model: 'chatgpt-4o-latest',451 aimlapi_model: 'chatgpt-4o-latest',
448 xai_model: 'grok-3-beta',452 xai_model: 'grok-3-beta',
@@ -2828,6 +2832,11 @@ export async function createGenerationParameters(settings, model, type, messages
2828 generate_data.middleout = settings.openrouter_middleout;2832 generate_data.middleout = settings.openrouter_middleout;
2829 }2833 }
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
2831 if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(settings.chat_completion_source)) {2840 if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(settings.chat_completion_source)) {
2832 const stopStringsLimit = 5;2841 const stopStringsLimit = 5;
2833 generate_data.top_k = Number(settings.top_k_openai);2842 generate_data.top_k = Number(settings.top_k_openai);
@@ -4290,6 +4299,7 @@ function loadOpenAISettings(data, settings) {
42904299
4291 $('#openrouter_providers_chat').trigger('change');4300 $('#openrouter_providers_chat').trigger('change');
4292 $('#openrouter_quantizations_chat').trigger('change');4301 $('#openrouter_quantizations_chat').trigger('change');
4302 $('#nanogpt_provider').trigger('change');
4293 $('#chat_completion_source').trigger('change');4303 $('#chat_completion_source').trigger('change');
4294}4304}
42954305
@@ -4937,6 +4947,7 @@ function onSettingsPresetChange() {
4937 $('#chat_completion_source').trigger('change');4947 $('#chat_completion_source').trigger('change');
4938 $('#openrouter_providers_chat').trigger('change');4948 $('#openrouter_providers_chat').trigger('change');
4939 $('#openrouter_quantizations_chat').trigger('change');4949 $('#openrouter_quantizations_chat').trigger('change');
4950 $('#nanogpt_provider').trigger('change');
4940 }4951 }
49414952
4942 $('#openai_logit_bias_preset').trigger('change');4953 $('#openai_logit_bias_preset').trigger('change');
@@ -5464,6 +5475,7 @@ async function onModelChange() {
54645475
5465 console.log('NanoGPT model changed to', value);5476 console.log('NanoGPT model changed to', value);
5466 oai_settings.nanogpt_model = value;5477 oai_settings.nanogpt_model = value;
5478 syncNanoGptProvidersForModel(value, '#nanogpt_provider');
5467 }5479 }
54685480
5469 if ($(this).is('#model_deepseek_select')) {5481 if ($(this).is('#model_deepseek_select')) {
@@ -7134,6 +7146,17 @@ export function initOpenAI() {
7134 saveSettingsDebounced();7146 saveSettingsDebounced();
7135 });7147 });
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
7137 $('#bind_preset_to_connection').on('input', function () {7160 $('#bind_preset_to_connection').on('input', function () {
7138 oai_settings.bind_preset_to_connection = !!$(this).prop('checked');7161 oai_settings.bind_preset_to_connection = !!$(this).prop('checked');
7139 saveSettingsDebounced();7162 saveSettingsDebounced();
public/scripts/textgen-models.js+290 -0
@@ -108,6 +108,214 @@ const OPENROUTER_PROVIDERS = [
108 'Z.AI',108 'Z.AI',
109];109];
110110
111/**
112 * List of NanoGPT providers.
113 * Providers endpoint: https://nano-gpt.com/api/models/providers
114 * @type {{id: string, label: string}[]}
115 */
116const 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
111const OPENROUTER_PROVIDER_WARNING_SELECTORS = {319const OPENROUTER_PROVIDER_WARNING_SELECTORS = {
112 '#openrouter_providers_text': {320 '#openrouter_providers_text': {
113 fallbackSelector: '#openrouter_allow_fallbacks_textgenerationwebui',321 fallbackSelector: '#openrouter_allow_fallbacks_textgenerationwebui',
@@ -187,6 +395,72 @@ export async function syncOpenRouterProvidersForModel(modelId, providersSelector
187 }395 }
188}396}
189397
398export 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
450export 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
190export async function loadOllamaModels(data) {464export async function loadOllamaModels(data) {
191 if (!Array.isArray(data)) {465 if (!Array.isArray(data)) {
192 console.error('Invalid Ollama models data', data);466 console.error('Invalid Ollama models data', data);
@@ -1084,6 +1358,14 @@ export function initTextGenModels() {
1084 }));1358 }));
1085 }1359 }
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
1087 if (!isMobile()) {1369 if (!isMobile()) {
1088 $('#mancer_model').select2({1370 $('#mancer_model').select2({
1089 placeholder: t`Select a model`,1371 placeholder: t`Select a model`,
@@ -1178,5 +1460,13 @@ export function initTextGenModels() {
1178 $(this).append($element);1460 $(this).append($element);
1179 $(this).trigger('change');1461 $(this).trigger('change');
1180 });1462 });
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 });
1181 }1471 }
1182}1472}
src/endpoints/backends/chat-completions.js+7 -0
@@ -2382,6 +2382,13 @@ router.post('/generate', async function (request, response) {
2382 apiKey = readSecret(request.user.directories, SECRET_KEYS.NANOGPT, request.body.secret_id);2382 apiKey = readSecret(request.user.directories, SECRET_KEYS.NANOGPT, request.body.secret_id);
2383 headers = {};2383 headers = {};
2384 bodyParams = {};2384 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 }
2385 if (request.body.enable_web_search && !/:online$/.test(request.body.model)) {2392 if (request.body.enable_web_search && !/:online$/.test(request.body.model)) {
2386 request.body.model = `${request.body.model}:online`;2393 request.body.model = `${request.body.model}:online`;
2387 }2394 }
src/endpoints/nanogpt.js+36 -0
@@ -100,3 +100,39 @@ router.post('/credits', async (req, res) => {
100 return res.sendStatus(500);100 return res.sendStatus(500);
101 }101 }
102});102});
103
104router.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});