Blame Raw
Cohee · 51ad27fb · · 1293 lines (49.2 KB)
6 contributors
1import { DOMPurify, moment, sha256 } from '../lib.js';
2import { event_types, eventSource, getRequestHeaders, saveSettings } from '../script.js';
3import { t } from './i18n.js';
4import { chat_completion_sources } from './openai.js';
5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
6import { SlashCommand } from './slash-commands/SlashCommand.js';
7import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
8import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
9import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
10import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
11import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
12import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
13import { renderTemplateAsync } from './templates.js';
14import { textgen_types } from './textgen-settings.js';
15import { getCurrentUserHandle } from './user.js';
16import { copyText, isTrueBoolean, uuidv4 } from './utils.js';
17import { accountStorage } from './util/AccountStorage.js';
18
19export const SECRET_KEYS = {
20 HORDE: 'api_key_horde',
21 MANCER: 'api_key_mancer',
22 VLLM: 'api_key_vllm',
23 APHRODITE: 'api_key_aphrodite',
24 TABBY: 'api_key_tabby',
25 OPENAI: 'api_key_openai',
26 NOVEL: 'api_key_novel',
27 CLAUDE: 'api_key_claude',
28 DEEPL: 'deepl',
29 LIBRE: 'libre',
30 LIBRE_URL: 'libre_url',
31 LINGVA_URL: 'lingva_url',
32 OPENROUTER: 'api_key_openrouter',
33 AI21: 'api_key_ai21',
34 ONERING_URL: 'oneringtranslator_url',
35 DEEPLX_URL: 'deeplx_url',
36 MAKERSUITE: 'api_key_makersuite',
37 VERTEXAI: 'api_key_vertexai',
38 SERPAPI: 'api_key_serpapi',
39 MISTRALAI: 'api_key_mistralai',
40 TOGETHERAI: 'api_key_togetherai',
41 INFERMATICAI: 'api_key_infermaticai',
42 DREAMGEN: 'api_key_dreamgen',
43 CUSTOM: 'api_key_custom',
44 OOBA: 'api_key_ooba',
45 NOMICAI: 'api_key_nomicai',
46 KOBOLDCPP: 'api_key_koboldcpp',
47 LLAMACPP: 'api_key_llamacpp',
48 COHERE: 'api_key_cohere',
49 PERPLEXITY: 'api_key_perplexity',
50 GROQ: 'api_key_groq',
51 AZURE_TTS: 'api_key_azure_tts',
52 AZURE_OPENAI: 'api_key_azure_openai',
53 FEATHERLESS: 'api_key_featherless',
54 HUGGINGFACE: 'api_key_huggingface',
55 STABILITY: 'api_key_stability',
56 CUSTOM_OPENAI_TTS: 'api_key_custom_openai_tts',
57 CHUTES: 'api_key_chutes',
58 ELECTRONHUB: 'api_key_electronhub',
59 NANOGPT: 'api_key_nanogpt',
60 TAVILY: 'api_key_tavily',
61 BFL: 'api_key_bfl',
62 COMFY_RUNPOD: 'api_key_comfy_runpod',
63 GENERIC: 'api_key_generic',
64 DEEPSEEK: 'api_key_deepseek',
65 SERPER: 'api_key_serper',
66 AIMLAPI: 'api_key_aimlapi',
67 FALAI: 'api_key_falai',
68 XAI: 'api_key_xai',
69 FIREWORKS: 'api_key_fireworks',
70 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
71 MINIMAX: 'api_key_minimax',
72 MINIMAX_GROUP_ID: 'minimax_group_id',
73 MOONSHOT: 'api_key_moonshot',
74 COMETAPI: 'api_key_cometapi',
75 ZAI: 'api_key_zai',
76 SILICONFLOW: 'api_key_siliconflow',
77 ELEVENLABS: 'api_key_elevenlabs',
78 POLLINATIONS: 'api_key_pollinations',
79 VOLCENGINE_APP_ID: 'volcengine_app_id',
80 VOLCENGINE_ACCESS_KEY: 'volcengine_access_key',
81 WORKERS_AI: 'api_key_workers_ai',
82};
83
84const FRIENDLY_NAMES = {
85 [SECRET_KEYS.HORDE]: 'AI Horde',
86 [SECRET_KEYS.MANCER]: 'Mancer',
87 [SECRET_KEYS.OPENAI]: 'OpenAI',
88 [SECRET_KEYS.NOVEL]: 'NovelAI',
89 [SECRET_KEYS.CLAUDE]: 'Claude',
90 [SECRET_KEYS.OPENROUTER]: 'OpenRouter',
91 [SECRET_KEYS.AI21]: 'AI21',
92 [SECRET_KEYS.MAKERSUITE]: 'Google AI Studio',
93 [SECRET_KEYS.VERTEXAI]: 'Google Vertex AI (Express Mode)',
94 [SECRET_KEYS.VLLM]: 'vLLM',
95 [SECRET_KEYS.APHRODITE]: 'Aphrodite',
96 [SECRET_KEYS.TABBY]: 'TabbyAPI',
97 [SECRET_KEYS.MISTRALAI]: 'MistralAI',
98 [SECRET_KEYS.CUSTOM]: 'Custom (OpenAI-compatible)',
99 [SECRET_KEYS.TOGETHERAI]: 'TogetherAI',
100 [SECRET_KEYS.OOBA]: 'Text Generation WebUI',
101 [SECRET_KEYS.INFERMATICAI]: 'InfermaticAI',
102 [SECRET_KEYS.DREAMGEN]: 'DreamGen',
103 [SECRET_KEYS.NOMICAI]: 'NomicAI',
104 [SECRET_KEYS.KOBOLDCPP]: 'KoboldCpp',
105 [SECRET_KEYS.LLAMACPP]: 'llama.cpp',
106 [SECRET_KEYS.COHERE]: 'Cohere',
107 [SECRET_KEYS.PERPLEXITY]: 'Perplexity',
108 [SECRET_KEYS.GROQ]: 'Groq',
109 [SECRET_KEYS.FEATHERLESS]: 'Featherless',
110 [SECRET_KEYS.HUGGINGFACE]: 'HuggingFace',
111 [SECRET_KEYS.CHUTES]: 'Chutes',
112 [SECRET_KEYS.ELECTRONHUB]: 'Electron Hub',
113 [SECRET_KEYS.NANOGPT]: 'NanoGPT',
114 [SECRET_KEYS.GENERIC]: 'Generic (OpenAI-compatible)',
115 [SECRET_KEYS.DEEPSEEK]: 'DeepSeek',
116 [SECRET_KEYS.XAI]: 'xAI (Grok)',
117 [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: 'Google Vertex AI (Service Account)',
118 [SECRET_KEYS.STABILITY]: 'Stability AI',
119 [SECRET_KEYS.CUSTOM_OPENAI_TTS]: 'Custom OpenAI TTS',
120 [SECRET_KEYS.TAVILY]: 'Tavily',
121 [SECRET_KEYS.BFL]: 'Black Forest Labs',
122 [SECRET_KEYS.COMFY_RUNPOD]: 'ComfyUI RunPod',
123 [SECRET_KEYS.SERPAPI]: 'SerpApi',
124 [SECRET_KEYS.SERPER]: 'Serper',
125 [SECRET_KEYS.FALAI]: 'FAL.AI',
126 [SECRET_KEYS.AZURE_TTS]: 'Azure TTS',
127 [SECRET_KEYS.AIMLAPI]: 'AI/ML API',
128 [SECRET_KEYS.FIREWORKS]: 'Fireworks AI',
129 [SECRET_KEYS.DEEPL]: 'DeepL',
130 [SECRET_KEYS.LIBRE]: 'LibreTranslate',
131 [SECRET_KEYS.LIBRE_URL]: 'LibreTranslate Endpoint (e.g. http://127.0.0.1:5000/translate)',
132 [SECRET_KEYS.LINGVA_URL]: 'Lingva Endpoint (e.g. https://lingva.ml/api/v1)',
133 [SECRET_KEYS.ONERING_URL]: 'OneRingTranslator Endpoint (e.g. http://127.0.0.1:4990/translate)',
134 [SECRET_KEYS.DEEPLX_URL]: 'DeepLX Endpoint (e.g. http://127.0.0.1:1188/translate)',
135 [SECRET_KEYS.MINIMAX]: 'MiniMax',
136 [SECRET_KEYS.MINIMAX_GROUP_ID]: 'MiniMax Group ID',
137 [SECRET_KEYS.MOONSHOT]: 'Moonshot AI',
138 [SECRET_KEYS.COMETAPI]: 'CometAPI',
139 [SECRET_KEYS.AZURE_OPENAI]: 'Azure OpenAI',
140 [SECRET_KEYS.ZAI]: 'Z.AI',
141 [SECRET_KEYS.SILICONFLOW]: 'SiliconFlow',
142 [SECRET_KEYS.ELEVENLABS]: 'ElevenLabs TTS',
143 [SECRET_KEYS.POLLINATIONS]: 'Pollinations',
144 [SECRET_KEYS.VOLCENGINE_APP_ID]: 'Volcengine App ID',
145 [SECRET_KEYS.VOLCENGINE_ACCESS_KEY]: 'Volcengine Access Key',
146 [SECRET_KEYS.WORKERS_AI]: 'Cloudflare Workers AI',
147};
148
149const INPUT_MAP = {
150 [SECRET_KEYS.HORDE]: '#horde_api_key',
151 [SECRET_KEYS.MANCER]: '#api_key_mancer',
152 [SECRET_KEYS.OPENAI]: '#api_key_openai',
153 [SECRET_KEYS.NOVEL]: '#api_key_novel',
154 [SECRET_KEYS.CLAUDE]: '#api_key_claude',
155 [SECRET_KEYS.OPENROUTER]: '.api_key_openrouter',
156 [SECRET_KEYS.AI21]: '#api_key_ai21',
157 [SECRET_KEYS.MAKERSUITE]: '#api_key_makersuite',
158 [SECRET_KEYS.VERTEXAI]: '#api_key_vertexai',
159 [SECRET_KEYS.VLLM]: '#api_key_vllm',
160 [SECRET_KEYS.APHRODITE]: '#api_key_aphrodite',
161 [SECRET_KEYS.TABBY]: '#api_key_tabby',
162 [SECRET_KEYS.MISTRALAI]: '#api_key_mistralai',
163 [SECRET_KEYS.CUSTOM]: '#api_key_custom',
164 [SECRET_KEYS.TOGETHERAI]: '#api_key_togetherai',
165 [SECRET_KEYS.OOBA]: '#api_key_ooba',
166 [SECRET_KEYS.INFERMATICAI]: '#api_key_infermaticai',
167 [SECRET_KEYS.DREAMGEN]: '#api_key_dreamgen',
168 [SECRET_KEYS.KOBOLDCPP]: '#api_key_koboldcpp',
169 [SECRET_KEYS.LLAMACPP]: '#api_key_llamacpp',
170 [SECRET_KEYS.COHERE]: '#api_key_cohere',
171 [SECRET_KEYS.PERPLEXITY]: '#api_key_perplexity',
172 [SECRET_KEYS.GROQ]: '#api_key_groq',
173 [SECRET_KEYS.FEATHERLESS]: '#api_key_featherless',
174 [SECRET_KEYS.HUGGINGFACE]: '#api_key_huggingface',
175 [SECRET_KEYS.CHUTES]: '#api_key_chutes',
176 [SECRET_KEYS.ELECTRONHUB]: '#api_key_electronhub',
177 [SECRET_KEYS.NANOGPT]: '#api_key_nanogpt',
178 [SECRET_KEYS.GENERIC]: '#api_key_generic',
179 [SECRET_KEYS.DEEPSEEK]: '#api_key_deepseek',
180 [SECRET_KEYS.AIMLAPI]: '#api_key_aimlapi',
181 [SECRET_KEYS.XAI]: '#api_key_xai',
182 [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: '#vertexai_service_account_json',
183 [SECRET_KEYS.MOONSHOT]: '#api_key_moonshot',
184 [SECRET_KEYS.FIREWORKS]: '#api_key_fireworks',
185 [SECRET_KEYS.COMETAPI]: '#api_key_cometapi',
186 [SECRET_KEYS.AZURE_OPENAI]: '#api_key_azure_openai',
187 [SECRET_KEYS.ZAI]: '#api_key_zai',
188 [SECRET_KEYS.SILICONFLOW]: '#api_key_siliconflow',
189 [SECRET_KEYS.MINIMAX]: '#api_key_minimax',
190 [SECRET_KEYS.POLLINATIONS]: '#api_key_pollinations',
191 [SECRET_KEYS.WORKERS_AI]: '#api_key_workers_ai',
192};
193
194const getLabel = () => moment().format('L LT');
195
196/**
197 * Resolves the secret key based on the selected API, chat completion source, and text completion type.
198 * @returns {string|null} The secret key corresponding to the selected API, or null if no key is found.
199 */
200export function resolveSecretKey() {
201 const { mainApi, chatCompletionSettings, textCompletionSettings } = SillyTavern.getContext();
202 const chatCompletionSource = chatCompletionSettings.chat_completion_source;
203 const textCompletionType = textCompletionSettings.type;
204
205 if (mainApi === 'koboldhorde') {
206 return SECRET_KEYS.HORDE;
207 }
208
209 if (mainApi === 'novel') {
210 return SECRET_KEYS.NOVEL;
211 }
212
213 if (mainApi === 'textgenerationwebui') {
214 const [key] = Object.entries(textgen_types).find(([, value]) => value === textCompletionType) ?? [null];
215 if (key && SECRET_KEYS[key]) {
216 return SECRET_KEYS[key];
217 }
218 }
219
220 if (mainApi === 'openai') {
221 if (chatCompletionSource === chat_completion_sources.VERTEXAI) {
222 switch (chatCompletionSettings.vertexai_auth_mode) {
223 case 'express':
224 return SECRET_KEYS.VERTEXAI;
225 case 'full':
226 return SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT;
227 }
228 }
229
230 const [key] = Object.entries(chat_completion_sources).find(([, value]) => value === chatCompletionSource) ?? [null];
231 if (key && SECRET_KEYS[key]) {
232 return SECRET_KEYS[key];
233 }
234 }
235
236 return null;
237}
238
239/**
240 * Gets the label of a secret by its ID.
241 * @param {string} id The ID of the secret to find.
242 * @returns {string} The label of the secret with the given ID, or an empty string if not found.
243 */
244export function getSecretLabelById(id) {
245 for (const key of Object.values(SECRET_KEYS)) {
246 const secrets = secret_state[key];
247 if (!Array.isArray(secrets)) {
248 continue;
249 }
250 const secret = secrets.find(s => s.id === id);
251 if (secret) {
252 return `${secret.label} (${secret.value})`;
253 }
254 }
255 return '';
256}
257
258export function updateSecretDisplay() {
259 for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
260 const validSecret = !!secret_state[secret_key];
261 const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');
262 const label = getActiveSecretLabel(secret_key);
263 const placeholderWithLabel = label ? `${placeholder} (${label})` : placeholder;
264 $(input_selector).attr('placeholder', placeholderWithLabel);
265 }
266}
267
268/**
269 * Gets the active secret label for a given key.
270 * @param {string} key Gets the active secret label for a given key.
271 * @returns {string} The label of the active secret, or '[No label]' if none is active.
272 */
273function getActiveSecretLabel(key) {
274 const selectedSecret = secret_state[key];
275 if (Array.isArray(selectedSecret)) {
276 const activeSecret = selectedSecret.find(x => x.active);
277 if (!activeSecret) {
278 return '';
279 }
280 return activeSecret.label || activeSecret.value || t`[No label]`;
281 }
282 return '';
283}
284
285/**
286 * Checks if secrets can be viewed based on server configuration.
287 * @returns {Promise<boolean|null>} A boolean value, or null if the request fails.
288 */
289export async function canViewSecrets() {
290 try {
291 const response = await fetch('/api/secrets/settings', {
292 method: 'POST',
293 headers: getRequestHeaders({ omitContentType: true }),
294 });
295
296 if (!response.ok) {
297 return null;
298 }
299
300 const data = await response.json();
301 return data?.allowKeysExposure === true;
302 } catch (error) {
303 console.error('Error getting secrets settings:', error);
304 return null;
305 }
306}
307
308async function viewSecrets() {
309 const response = await fetch('/api/secrets/view', {
310 method: 'POST',
311 headers: getRequestHeaders({ omitContentType: true }),
312 });
313
314 if (response.status == 403) {
315 await Popup.show.text(t`Forbidden`, t`To view your API keys here, set the value of allowKeysExposure to true in config.yaml file and restart the SillyTavern server.`);
316 return;
317 }
318
319 if (!response.ok) {
320 return;
321 }
322
323 const data = await response.json();
324 const table = document.createElement('table');
325 table.classList.add('responsiveTable');
326 $(table).append('<thead><th>Key</th><th>Value</th></thead>');
327
328 for (const [key, value] of Object.entries(data)) {
329 $(table).append(`<tr><td>${DOMPurify.sanitize(key)}</td><td>${DOMPurify.sanitize(value)}</td></tr>`);
330 }
331
332 await callGenericPopup(table.outerHTML, POPUP_TYPE.TEXT, '', { wide: true, large: true, allowVerticalScrolling: true });
333}
334
335/**
336 * @type {import('../../src/endpoints/secrets.js').SecretStateMap}
337 */
338export let secret_state = {};
339
340/**
341 * Write a secret value to the server.
342 * @param {string} key Secret key
343 * @param {string} value Secret value to write
344 * @param {string} [label] (Optional) Label for the key. If not provided, generated automatically.
345 * @param {Object} [options] Additional options
346 * @param {boolean} [options.allowEmpty] Whether to allow writing empty values. If false and value is empty, the secret will be deleted.
347 * @return {Promise<string?>} The ID of the newly created secret key, or null if no value is provided.
348 */
349export async function writeSecret(key, value, label, { allowEmpty } = {}) {
350 try {
351 if (!value && !allowEmpty) {
352 console.warn(`No value provided for ${key} in writeSecret, redirecting to deleteSecret`);
353 await deleteSecret(key);
354 return null;
355 }
356
357 if (!label) {
358 label = getLabel();
359 }
360
361 const response = await fetch('/api/secrets/write', {
362 method: 'POST',
363 headers: getRequestHeaders(),
364 body: JSON.stringify({ key, value, label }),
365 });
366
367 if (!response.ok) {
368 return null;
369 }
370
371 const { id } = await response.json();
372 // Clear the input field
373 $(INPUT_MAP[key]).val('').trigger('input');
374 await readSecretState();
375 await eventSource.emit(event_types.SECRET_WRITTEN, key);
376 return id;
377 } catch (error) {
378 console.error(`Could not write secret value: ${key}`, error);
379 return null;
380 }
381}
382
383/**
384 * Deletes a secret value from the server.
385 * @param {string} key Secret key
386 * @param {string} [id] (Optional) ID of the secret key to delete. If not provided, deletes an active key.
387 */
388export async function deleteSecret(key, id) {
389 try {
390 const response = await fetch('/api/secrets/delete', {
391 method: 'POST',
392 headers: getRequestHeaders(),
393 body: JSON.stringify({ key, id }),
394 });
395
396 if (response.ok) {
397 await readSecretState();
398 // Force reconnection to the API with the new key
399 $('#main_api').trigger('change');
400 await eventSource.emit(event_types.SECRET_DELETED, key);
401 }
402 } catch (error) {
403 console.error(`Could not delete secret value: ${key}`, error);
404 }
405}
406
407/**
408 * Reads the current state of secrets from the server.
409 * @returns {Promise<void>}
410 */
411export async function readSecretState() {
412 try {
413 const response = await fetch('/api/secrets/read', {
414 method: 'POST',
415 headers: getRequestHeaders({ omitContentType: true }),
416 });
417
418 if (response.ok) {
419 secret_state = await response.json();
420 updateSecretDisplay();
421 updateInputDataLists();
422 }
423 } catch {
424 console.error('Could not read secrets file');
425 }
426}
427
428/**
429 * Finds a secret value by key.
430 * @param {string} key Secret key
431 * @param {string} [id] ID of the secret to find. If not provided, will return the active secret.
432 * @returns {Promise<string?>} Secret value, or null if keys are not exposed
433 */
434export async function findSecret(key, id) {
435 try {
436 const response = await fetch('/api/secrets/find', {
437 method: 'POST',
438 headers: getRequestHeaders(),
439 body: JSON.stringify({ key, id }),
440 });
441
442 if (!response.ok) {
443 return null;
444 }
445
446 const data = await response.json();
447 return data.value;
448 } catch {
449 console.error('Could not find secret value: ', key);
450 return null;
451 }
452}
453
454/**
455 * Changes the active value for a given secret key.
456 * @param {string} key Secret key to rotate
457 * @param {string} id ID of the secret to rotate
458 */
459export async function rotateSecret(key, id) {
460 try {
461 const response = await fetch('/api/secrets/rotate', {
462 method: 'POST',
463 headers: getRequestHeaders(),
464 body: JSON.stringify({ key, id }),
465 });
466
467 if (response.ok) {
468 await readSecretState();
469 // Force reconnection to the API with the new key
470 $('#main_api').trigger('change');
471 await eventSource.emit(event_types.SECRET_ROTATED, key);
472 }
473 } catch (error) {
474 console.error(`Could not rotate secret value: ${key}`, error);
475 }
476}
477
478/**
479 * Renames a secret value on the server.
480 * @param {string} key Secret key to rename
481 * @param {string} id ID of the secret to rename
482 * @param {string} label Label to rename the secret to
483 */
484export async function renameSecret(key, id, label) {
485 try {
486 const response = await fetch('/api/secrets/rename', {
487 method: 'POST',
488 headers: getRequestHeaders(),
489 body: JSON.stringify({ key, id, label }),
490 });
491
492 if (response.ok) {
493 await readSecretState();
494 await eventSource.emit(event_types.SECRET_EDITED, key);
495 }
496 } catch (error) {
497 console.error(`Could not rename secret value: ${key}`, error);
498 }
499}
500
501/**
502 * Generates a storage key for the PKCE code verifier for a given source.
503 * @param {string} source Source for which to generate the storage key (e.g. 'openrouter')
504 * @returns {string} The storage key for the PKCE code verifier for a given source.
505 */
506const getVerifierKey = (source) => `${getCurrentUserHandle()}_${source}_code_verifier`;
507
508/**
509 * Generates a code challenge for PKCE authentication flows.
510 * @param {string} input Input secret string to generate the code challenge from.
511 * @returns {string} S256 code challenge generated from the input string, encoded in base64url format.
512 */
513const generateChallenge = (input) => {
514 const encoder = new TextEncoder();
515 const data = encoder.encode(input);
516 const hashBytes = sha256.array(data);
517 return btoa(String.fromCharCode(...hashBytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
518};
519
520/**
521 * Redirects the user to authorize OpenRouter.
522 */
523async function authorizeOpenRouter() {
524 if (secret_state[SECRET_KEYS.OPENROUTER]) {
525 const confirmed = await Popup.show.confirm(t`OpenRouter API key already exists`, t`Do you really wish to create a new OpenRouter key? Your existing key will not be deleted.`);
526 if (!confirmed) {
527 return;
528 }
529 }
530
531 // Generate a PKCE code verifier and code challenge
532 const codeVerifier = uuidv4() + uuidv4();
533 const codeChallenge = generateChallenge(codeVerifier);
534 accountStorage.setItem(getVerifierKey('openrouter'), codeVerifier);
535 await saveSettings();
536
537 // Redirect to OpenRouter authorization URL with the code challenge and callback URL
538 const redirectUrl = new URL('/callback/openrouter', window.location.origin);
539 const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
540 location.href = openRouterUrl;
541}
542
543/**
544 * Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key.
545 * @returns {Promise<void>}
546 */
547export async function checkOpenRouterAuth() {
548 const params = new URLSearchParams(location.search);
549 const source = params.get('source');
550 if (source === 'openrouter') {
551 const query = new URLSearchParams(params.get('query'));
552 try {
553 const code = query.get('code');
554 if (!code) {
555 throw new Error('OpenRouter authorization code not found in URL');
556 }
557
558 const codeVerifier = accountStorage.getItem(getVerifierKey('openrouter'));
559 if (!codeVerifier) {
560 throw new Error('OpenRouter code verifier not found in accountStorage');
561 }
562
563 const response = await fetch('https://openrouter.ai/api/v1/auth/keys', {
564 method: 'POST',
565 headers: {
566 'Content-Type': 'application/json',
567 },
568 body: JSON.stringify({
569 code: code,
570 code_verifier: codeVerifier,
571 code_challenge_method: 'S256',
572 }),
573 });
574
575 if (!response.ok) {
576 throw new Error('OpenRouter exchange error');
577 }
578
579 const data = await response.json();
580 if (!data || !data.key) {
581 throw new Error('OpenRouter invalid response');
582 }
583
584 await writeSecret(SECRET_KEYS.OPENROUTER, data.key);
585
586 if (secret_state[SECRET_KEYS.OPENROUTER]) {
587 toastr.success('OpenRouter token saved');
588 } else {
589 throw new Error('OpenRouter token not saved');
590 }
591 } catch (err) {
592 toastr.error('Could not verify OpenRouter token. Please try again.');
593 console.error('OpenRouter OAuth error:', err);
594 } finally {
595 // Remove the code from the URL
596 const currentUrl = window.location.href;
597 const urlWithoutSearchParams = currentUrl.split('?')[0];
598 window.history.pushState({}, '', urlWithoutSearchParams);
599 }
600 }
601
602 // Clean-up any code verifiers that might be left in accountStorage from abandoned auth flows
603 accountStorage.removeItem(getVerifierKey('openrouter'));
604}
605
606/**
607 * Updates the input data lists for secret keys for autocomplete functionality.
608 */
609function updateInputDataLists() {
610 let container = document.getElementById('secrets_datalists');
611 if (!container) {
612 container = document.createElement('div');
613 container.id = 'secrets_datalists';
614 container.style.display = 'none';
615 document.body.appendChild(container);
616 }
617
618 for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
619 const inputElements = document.querySelectorAll(inputSelector);
620 if (inputElements.length === 0) {
621 console.warn(`No input elements found for key: ${key}`);
622 continue;
623 }
624
625 const dataListId = `${key}_datalist`;
626 let dataList = document.getElementById(dataListId);
627 if (!dataList) {
628 dataList = document.createElement('datalist');
629 dataList.id = dataListId;
630 container.appendChild(dataList);
631 }
632
633 // Clear existing options
634 dataList.innerHTML = '';
635
636 const secrets = secret_state[key];
637 if (!Array.isArray(secrets)) {
638 continue;
639 }
640
641 for (const secret of secrets) {
642 const option = document.createElement('option');
643 option.value = secret.id;
644 option.textContent = `${secret.label} (${secret.value})`;
645 dataList.appendChild(option);
646 }
647
648 // Set the input element to use the datalist
649 inputElements.forEach(element => {
650 element.setAttribute('list', dataListId);
651 });
652 }
653}
654
655/**
656 * Opens the key manager dialog for a specific key.
657 * @param {string} key Key for which to open the key manager dialog.
658 */
659async function openKeyManagerDialog(key) {
660 const name = FRIENDLY_NAMES[key] || key;
661 const template = $(await renderTemplateAsync('secretKeyManager', { name, key }));
662 template.find('button[data-action="add-secret"]').on('click', async function () {
663 let label = '';
664 let result = POPUP_RESULT.CANCELLED;
665 const value = await Popup.show.input(t`Add Secret`, t`Secret value (can be empty):`, '', {
666 customInputs: [{
667 id: 'newSecretLabel',
668 type: 'text',
669 label: t`Label (optional):`,
670 }],
671 onClose: popup => {
672 if (popup.result) {
673 label = popup.inputResults.get('newSecretLabel').toString().trim();
674 result = popup.result;
675 }
676 },
677 });
678 if (!value) {
679 if (result !== POPUP_RESULT.AFFIRMATIVE) {
680 return;
681 }
682 const allowEmpty = await Popup.show.confirm(t`No value entered`, t`No value was entered for the secret. Do you want to add an empty secret?`);
683 if (!allowEmpty) {
684 return;
685 }
686 }
687 await writeSecret(key, value, label, { allowEmpty: true });
688 await renderSecretsList();
689 });
690
691 await renderSecretsList();
692 await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, onOpen: scrollToActive });
693
694 async function renderSecretsList() {
695 const secrets = secret_state[key] ?? [];
696 const list = template.find('.secretKeyManagerList');
697 const previousScrollTop = list.scrollTop();
698
699 const emptyMessage = template.find('.secretKeyManagerListEmpty');
700 emptyMessage.toggle(secrets.length === 0);
701
702 const itemBlocks = [];
703 for (const secret of secrets) {
704 const itemTemplate = $(await renderTemplateAsync('secretKeyManagerListItem', secret));
705 itemTemplate.find('[data-action="copy-id"]').on('click', async function () {
706 await copyText(secret.id);
707 toastr.info(t`Secret ID copied to clipboard.`);
708 });
709 itemTemplate.find('button[data-action="rotate-secret"]').on('click', async function () {
710 await rotateSecret(key, secret.id);
711 await renderSecretsList();
712 });
713 itemTemplate.find('button[data-action="copy-secret"]').on('click', async function () {
714 const secretValue = await findSecret(key, secret.id);
715 if (secretValue === null) {
716 toastr.error(t`The key exposure might be disabled by the server config.`, t`Failed to copy secret value`);
717 return;
718 }
719 await copyText(secretValue);
720 toastr.info(t`Secret value copied to clipboard.`);
721 });
722 itemTemplate.find('button[data-action="rename-secret"]').on('click', async function () {
723 const label = await Popup.show.input(t`Rename Secret`, t`Enter new label for the secret:`, secret?.label || getLabel());
724 if (!label) {
725 return;
726 }
727 await renameSecret(key, secret.id, label);
728 await renderSecretsList();
729 });
730 itemTemplate.find('button[data-action="delete-secret"]').on('click', async function () {
731 const confirm = await Popup.show.confirm(t`Delete Secret: ${secret?.label}`, t`Are you sure you want to delete this secret? This action cannot be undone.`);
732 if (!confirm) {
733 return;
734 }
735 await deleteSecret(key, secret.id);
736 await renderSecretsList();
737 });
738 itemBlocks.push(itemTemplate);
739 }
740
741 list.empty().append(itemBlocks).scrollTop(previousScrollTop);
742 }
743
744 function scrollToActive() {
745 const list = template.find('.secretKeyManagerList');
746 const activeKey = list.find('.active');
747 if (activeKey.length > 0) {
748 const activeKeyScrollTop = activeKey.position().top + list.scrollTop() - list.height() / 2;
749 list.scrollTop(activeKeyScrollTop);
750 }
751 }
752}
753
754function registerSecretSlashCommands() {
755 const secretKeyEnumProvider = () => Object.values(SECRET_KEYS).map(key => new SlashCommandEnumValue(key, FRIENDLY_NAMES[key] || key, enumTypes.name, enumIcons.key));
756 const secretIdEnumProvider = (/** @type {SlashCommandExecutor} */ executor, /** @type {SlashCommandScope} */ _scope) => {
757 const key = executor?.namedArgumentList?.find(x => x.name === 'key')?.value?.toString() || resolveSecretKey();
758 if (!key || !secret_state[key] || !Array.isArray(secret_state[key]) || secret_state[key].length === 0) {
759 return [];
760 }
761
762 return secret_state[key].map(secret => {
763 return new SlashCommandEnumValue(secret.id, `${secret.label} (${secret.value})`, enumTypes.name, enumIcons.key);
764 });
765 };
766
767 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
768 name: 'secret-id',
769 aliases: ['secret-rotate'],
770 helpString: t`Sets the ID of a currently active secret key. Gets the ID of the secret key if no value is provided.`,
771 returns: t`The ID of the secret key that is now active.`,
772 namedArgumentList: [
773 SlashCommandNamedArgument.fromProps({
774 name: 'quiet',
775 description: t`Suppress toast message notifications.`,
776 isRequired: false,
777 defaultValue: String(false),
778 typeList: [ARGUMENT_TYPE.BOOLEAN],
779 }),
780 SlashCommandNamedArgument.fromProps({
781 name: 'key',
782 description: t`The key to get the secret ID for. If not provided, will use the currently active API secrets.`,
783 isRequired: false,
784 typeList: [ARGUMENT_TYPE.STRING],
785 enumProvider: secretKeyEnumProvider,
786 }),
787 ],
788 unnamedArgumentList: [
789 SlashCommandArgument.fromProps({
790 description: t`The ID or a label of the secret key to set as active. If not provided, will return the currently active secret ID.`,
791 isRequired: true,
792 typeList: [ARGUMENT_TYPE.STRING],
793 enumProvider: secretIdEnumProvider,
794 }),
795 ],
796 callback: async (args, value) => {
797 const quiet = isTrueBoolean(args?.quiet?.toString());
798 const id = value?.toString()?.trim();
799 const key = args?.key?.toString()?.trim() || resolveSecretKey();
800
801 if (!key) {
802 if (!quiet) {
803 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
804 }
805 return '';
806 }
807
808 const secrets = secret_state[key];
809 if (!Array.isArray(secrets) || secrets.length === 0) {
810 if (!quiet) {
811 toastr.error(t`No saved secrets found for the key: ${key}`);
812 }
813 return '';
814 }
815
816 if (!id) {
817 const activeSecret = secrets.find(s => s.active);
818 if (!activeSecret) {
819 if (!quiet) {
820 toastr.error(t`No active secret found for the key: ${key}`);
821 }
822 return '';
823 }
824 return activeSecret.id;
825 }
826
827 const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id);
828 if (!savedSecret) {
829 if (!quiet) {
830 toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
831 }
832 return '';
833 }
834
835 // Set the secret as active
836 await rotateSecret(key, savedSecret.id);
837 if (!quiet) {
838 toastr.success(t`Secret with ID: ${id} is now active for the key: ${key}`);
839 }
840
841 return savedSecret.id;
842 },
843 }));
844
845 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
846 name: 'secret-delete',
847 helpString: t`Deletes a secret key by ID.`,
848 namedArgumentList: [
849 SlashCommandNamedArgument.fromProps({
850 name: 'quiet',
851 description: t`Suppress toast message notifications.`,
852 isRequired: false,
853 defaultValue: String(false),
854 typeList: [ARGUMENT_TYPE.BOOLEAN],
855 }),
856 SlashCommandNamedArgument.fromProps({
857 name: 'key',
858 description: t`The key to delete the secret from. If not provided, will use the currently active API secrets.`,
859 isRequired: false,
860 typeList: [ARGUMENT_TYPE.STRING],
861 enumProvider: secretKeyEnumProvider,
862 }),
863 ],
864 unnamedArgumentList: [
865 SlashCommandArgument.fromProps({
866 description: t`The ID or a label of the secret key to delete. If not provided, will delete the active secret.`,
867 isRequired: true,
868 typeList: [ARGUMENT_TYPE.STRING],
869 enumProvider: secretIdEnumProvider,
870 }),
871 ],
872 callback: async (args, value) => {
873 const quiet = isTrueBoolean(args?.quiet?.toString());
874 const id = value?.toString()?.trim();
875 const key = args?.key?.toString()?.trim() || resolveSecretKey();
876
877 if (!key) {
878 if (!quiet) {
879 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
880 }
881 return '';
882 }
883
884 const secrets = secret_state[key];
885 if (!Array.isArray(secrets) || secrets.length === 0) {
886 if (!quiet) {
887 toastr.error(t`No saved secrets found for the key: ${key}`);
888 }
889 return '';
890 }
891
892 const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
893 if (!savedSecret) {
894 if (!quiet) {
895 toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
896 }
897 return '';
898 }
899
900 // Delete the secret
901 await deleteSecret(key, savedSecret.id);
902 if (!quiet) {
903 toastr.success(t`Secret with ID: ${id} has been deleted for the key: ${key}`);
904 }
905
906 return savedSecret.id;
907 },
908 }));
909
910 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
911 name: 'secret-write',
912 helpString: t`Writes a secret key with a value and an optional label.`,
913 returns: t`The ID of the newly created secret key.`,
914 namedArgumentList: [
915 SlashCommandNamedArgument.fromProps({
916 name: 'quiet',
917 description: t`Suppress toast message notifications.`,
918 isRequired: false,
919 defaultValue: String(false),
920 typeList: [ARGUMENT_TYPE.BOOLEAN],
921 }),
922 SlashCommandNamedArgument.fromProps({
923 name: 'key',
924 description: t`The key to write the secret to. If not provided, will use the currently active API secrets.`,
925 isRequired: false,
926 typeList: [ARGUMENT_TYPE.STRING],
927 enumProvider: secretKeyEnumProvider,
928 }),
929 SlashCommandNamedArgument.fromProps({
930 name: 'label',
931 description: t`The label for the secret key. If not provided, will use the current date and time.`,
932 isRequired: false,
933 typeList: [ARGUMENT_TYPE.STRING],
934 }),
935 SlashCommandNamedArgument.fromProps({
936 name: 'empty',
937 description: t`Whether to allow empty values.`,
938 isRequired: false,
939 typeList: [ARGUMENT_TYPE.BOOLEAN],
940 defaultValue: String(false),
941 }),
942 ],
943 unnamedArgumentList: [
944 SlashCommandArgument.fromProps({
945 description: t`The value of the secret key to write.`,
946 isRequired: true,
947 typeList: [ARGUMENT_TYPE.STRING],
948 }),
949 ],
950 callback: async (args, value) => {
951 const quiet = isTrueBoolean(args?.quiet?.toString());
952 const allowEmpty = isTrueBoolean(args?.empty?.toString());
953 const key = args?.key?.toString()?.trim() || resolveSecretKey();
954
955 if (!key) {
956 if (!quiet) {
957 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
958 }
959 return '';
960 }
961
962 const secrets = secret_state[key];
963 if (!Array.isArray(secrets) || secrets.length === 0) {
964 if (!quiet) {
965 toastr.error(t`No saved secrets found for the key: ${key}`);
966 }
967 return '';
968 }
969
970 const valueStr = value?.toString()?.trim();
971 if (!valueStr && !allowEmpty) {
972 if (!quiet) {
973 toastr.error(t`No value provided for the secret key: ${key}`);
974 }
975 return '';
976 }
977
978 const label = args?.label?.toString()?.trim() || getLabel();
979 const id = await writeSecret(key, valueStr, label, { allowEmpty });
980
981 if (!quiet) {
982 toastr.success(t`Secret has been written for the key: ${key}`);
983 }
984
985 return id || '';
986 },
987 }));
988
989 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
990 name: 'secret-rename',
991 helpString: t`Renames a secret key by ID.`,
992 namedArgumentList: [
993 SlashCommandNamedArgument.fromProps({
994 name: 'quiet',
995 description: t`Suppress toast message notifications.`,
996 isRequired: false,
997 defaultValue: String(false),
998 typeList: [ARGUMENT_TYPE.BOOLEAN],
999 }),
1000 SlashCommandNamedArgument.fromProps({
1001 name: 'key',
1002 description: t`The key to rename the secret in. If not provided, will use the currently active API secrets.`,
1003 isRequired: false,
1004 typeList: [ARGUMENT_TYPE.STRING],
1005 enumProvider: secretKeyEnumProvider,
1006 }),
1007 SlashCommandNamedArgument.fromProps({
1008 name: 'id',
1009 description: t`The ID of the secret to rename. If not provided, will rename the active secret.`,
1010 isRequired: true,
1011 typeList: [ARGUMENT_TYPE.STRING],
1012 }),
1013 ],
1014 unnamedArgumentList: [
1015 SlashCommandArgument.fromProps({
1016 description: t`The new label for the secret key.`,
1017 isRequired: true,
1018 typeList: [ARGUMENT_TYPE.STRING],
1019 }),
1020 ],
1021 callback: async (args, value) => {
1022 const quiet = isTrueBoolean(args?.quiet?.toString());
1023 const key = args?.key?.toString()?.trim() || resolveSecretKey();
1024 const id = args?.id?.toString()?.trim();
1025
1026 if (!key) {
1027 if (!quiet) {
1028 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
1029 }
1030 return '';
1031 }
1032
1033 const secrets = secret_state[key];
1034 if (!Array.isArray(secrets) || secrets.length === 0) {
1035 if (!quiet) {
1036 toastr.error(t`No saved secrets found for the key: ${key}`);
1037 }
1038 return '';
1039 }
1040
1041 const newLabel = value?.toString()?.trim();
1042 if (!newLabel) {
1043 if (!quiet) {
1044 toastr.error(t`No new label provided for the secret key: ${key}`);
1045 }
1046 return '';
1047 }
1048
1049 const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
1050 if (!savedSecret) {
1051 if (!quiet) {
1052 toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
1053 }
1054 return '';
1055 }
1056
1057 // Rename the secret
1058 await renameSecret(key, savedSecret.id, newLabel);
1059 if (!quiet) {
1060 toastr.success(t`Secret with ID: ${id} has been renamed to "${newLabel}" for the key: ${key}`);
1061 }
1062
1063 return savedSecret.id;
1064 },
1065 }));
1066
1067 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1068 name: 'secret-read',
1069 aliases: ['secret-find', 'secret-get'],
1070 helpString: t`Reads a secret key by ID. If key exposure is disabled, this command will not work!`,
1071 returns: t`The value of the secret key.`,
1072 namedArgumentList: [
1073 SlashCommandNamedArgument.fromProps({
1074 name: 'quiet',
1075 description: t`Suppress toast message notifications.`,
1076 isRequired: false,
1077 defaultValue: String(false),
1078 typeList: [ARGUMENT_TYPE.BOOLEAN],
1079 }),
1080 SlashCommandNamedArgument.fromProps({
1081 name: 'key',
1082 description: t`The key to read the secret from. If not provided, will use the currently active API secrets.`,
1083 isRequired: false,
1084 typeList: [ARGUMENT_TYPE.STRING],
1085 enumProvider: secretKeyEnumProvider,
1086 }),
1087 ],
1088 unnamedArgumentList: [
1089 SlashCommandArgument.fromProps({
1090 description: t`The ID or a label of the secret key to read. If not provided, will return the currently active secret value.`,
1091 isRequired: true,
1092 typeList: [ARGUMENT_TYPE.STRING],
1093 enumProvider: secretIdEnumProvider,
1094 }),
1095 ],
1096 callback: async (args, value) => {
1097 const quiet = isTrueBoolean(args?.quiet?.toString());
1098 const key = args?.key?.toString()?.trim() || resolveSecretKey();
1099 const id = value?.toString()?.trim();
1100
1101 if (!key) {
1102 if (!quiet) {
1103 toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
1104 }
1105 return '';
1106 }
1107
1108 const secrets = secret_state[key];
1109 if (!Array.isArray(secrets) || secrets.length === 0) {
1110 if (!quiet) {
1111 toastr.error(t`No saved secrets found for the key: ${key}`);
1112 }
1113 return '';
1114 }
1115
1116 const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
1117 if (!savedSecret) {
1118 if (!quiet) {
1119 toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
1120 }
1121 return '';
1122 }
1123
1124 const secretValue = await findSecret(key, savedSecret.id);
1125 if (secretValue === null) {
1126 if (!quiet) {
1127 toastr.error(t`Could not retrieve the secret value for key: ${key}. Key exposure might be disabled.`);
1128 }
1129 return '';
1130 }
1131
1132 return secretValue;
1133 },
1134 }));
1135}
1136
1137export async function initSecrets() {
1138 $('#viewSecrets').on('click', viewSecrets);
1139 $(document).on('click', '.manage-api-keys', async function () {
1140 const key = $(this).data('key');
1141 if (!key || !Object.values(SECRET_KEYS).includes(key)) {
1142 console.error('Invalid key for manage-api-keys:', key);
1143 return;
1144 }
1145 await openKeyManagerDialog(key);
1146 });
1147 $(document).on('input', Object.values(INPUT_MAP).join(','), function () {
1148 const id = $(this).attr('id');
1149 const value = $(this).val();
1150
1151 // Find the key based on the entered value
1152 for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
1153 if (!value || !this.matches(inputSelector)) {
1154 continue;
1155 }
1156 const secrets = secret_state[key];
1157 if (!Array.isArray(secrets)) {
1158 continue;
1159 }
1160 const secretMatch = secrets.find(secret => secret.id === value);
1161 if (secretMatch) {
1162 $(this).val('');
1163 return rotateSecret(key, secretMatch.id);
1164 }
1165 }
1166
1167 const warningElement = $(`[data-for="${id}"]`);
1168 warningElement.toggle(value.length > 0);
1169 });
1170 $('.openrouter_authorize').on('click', authorizeOpenRouter);
1171 $(document).on('click', '.openrouter_view_credits', async function (event) {
1172 event.preventDefault();
1173 const display = $(this).siblings('.openrouter_credits_display').first();
1174 display.text(t`Loading…`);
1175 try {
1176 const response = await fetch('/api/openrouter/credits', {
1177 method: 'POST',
1178 headers: getRequestHeaders(),
1179 });
1180 if (!response.ok) {
1181 throw new Error(`HTTP ${response.status}`);
1182 }
1183 const data = await response.json();
1184 if (typeof data.remaining !== 'number') {
1185 throw new Error('Invalid response');
1186 }
1187 display.text(`$${data.remaining.toFixed(2)}`);
1188 } catch (error) {
1189 console.error('Failed to fetch OpenRouter credits:', error);
1190 display.text('');
1191 toastr.error(t`Could not fetch OpenRouter credits. Please try again.`);
1192 }
1193 });
1194
1195 const formatNanoGptNumber = (num, decimals = null) => {
1196 const number = Number(num);
1197 if (!Number.isFinite(number)) return decimals === null ? '0' : (0).toFixed(decimals);
1198 if (decimals !== null) return number.toFixed(decimals);
1199 if (number >= 1000000) return (number / 1000000).toFixed(1) + 'M';
1200 if (number >= 1000) return (number / 1000).toFixed(1) + 'K';
1201 return number.toString();
1202 };
1203
1204 const createNanoGptCreditsPopup = (credits) => {
1205 const root = $('<div class="nanogpt-credits-popup"></div>');
1206 root.append($('<h3></h3>').text(t`NanoGPT Credits & Usage`));
1207
1208 const rows = [
1209 [t`USD`, `$${formatNanoGptNumber(credits.usdBalance, 2)}`],
1210 [t`NANO`, formatNanoGptNumber(credits.nanoBalance, 3)],
1211 ];
1212
1213 const addUsage = (label, usage, limit) => {
1214 if (usage) {
1215 rows.push([label, t`${formatNanoGptNumber(usage.used)} / ${formatNanoGptNumber(limit)} (${formatNanoGptNumber(usage.remaining)} left)`]);
1216 }
1217 };
1218
1219 if (credits.subscription?.active) {
1220 const sub = credits.subscription;
1221 const subEndDate = sub.period?.currentPeriodEnd ? moment(sub.period.currentPeriodEnd).format('LL') : t`Unknown`;
1222 rows.push([t`Sub`, t`Active (until ${subEndDate})`]);
1223 addUsage(t`Tokens/wk`, sub.weekly_tokens, sub.limits?.weeklyInputTokens);
1224 addUsage(t`Tokens/day`, sub.daily_tokens, sub.limits?.dailyInputTokens);
1225 addUsage(t`Images/day`, sub.daily_images, sub.limits?.dailyImages);
1226 }
1227
1228 for (const [label, value] of rows) {
1229 root.append($('<div></div>').text(label));
1230 root.append($('<div></div>').text(value));
1231 }
1232
1233 return root;
1234 };
1235
1236 $(document).on('click', '.nanogpt_view_credits', async function (event) {
1237 event.preventDefault();
1238 const display = $(this).siblings('.nanogpt_credits_display').first();
1239 display.empty().text(t`Loading…`);
1240
1241 try {
1242 const response = await fetch('/api/nanogpt/credits', {
1243 method: 'POST',
1244 headers: getRequestHeaders(),
1245 });
1246
1247 if (!response.ok) {
1248 throw new Error(`HTTP ${response.status}`);
1249 }
1250
1251 const data = await response.json();
1252
1253 const usdBalance = Number(data.usd_balance);
1254 const nanoBalance = Number(data.nano_balance);
1255 if (!Number.isFinite(usdBalance) || !Number.isFinite(nanoBalance)) {
1256 throw new Error('Invalid response');
1257 }
1258
1259 let balances = [`$${formatNanoGptNumber(usdBalance, 2)}`];
1260 if (nanoBalance > 0) {
1261 balances.push(`${formatNanoGptNumber(nanoBalance, 3)} NANO`);
1262 }
1263 let shortInlineText = balances.join(' | ');
1264
1265 if (data.subscription?.active) {
1266 shortInlineText += ` | ${t`Sub Active`}`;
1267 }
1268
1269 display.empty().text(shortInlineText + ' ');
1270
1271 const infoBtn = $('<i class="fa-solid fa-circle-info cursor-pointer nanogpt_info_btn"></i>');
1272 infoBtn.attr('title', t`View details`);
1273 infoBtn.data('credits', {
1274 usdBalance,
1275 nanoBalance,
1276 subscription: data.subscription,
1277 });
1278 display.append(infoBtn);
1279 } catch (error) {
1280 console.error('Failed to fetch NanoGPT credits:', error);
1281 display.empty().text('');
1282 toastr.error(t`Could not fetch NanoGPT credits. Please try again.`);
1283 }
1284 });
1285
1286 $(document).on('click', '.nanogpt_info_btn', async function () {
1287 const credits = $(this).data('credits');
1288 if (credits) {
1289 await callGenericPopup(createNanoGptCreditsPopup(credits), POPUP_TYPE.TEXT);
1290 }
1291 });
1292 registerSecretSlashCommands();
1293}