Skip to content

Instantly share code, notes, and snippets.

@avimar
Last active September 26, 2024 07:53
Show Gist options
  • Save avimar/1649504125b87e913bf41147029800a8 to your computer and use it in GitHub Desktop.
Save avimar/1649504125b87e913bf41147029800a8 to your computer and use it in GitHub Desktop.
LiberChat Token Count
//Via: Austin @ https://discord.com/channels/1086345563026489514/1086345563026489517/1239210064783474688
//Small updates via https://gist.github.com/insilications/a3ce3add092df165e5521eb2be0e73be for tokenizer, stats box.
//Edits: - Include prompt field, version 2
// - estimate for GPT 4o/sonnet & Opus at 2024-06-02 pricing
// ==UserScript==
// @name OpenAI Token Counter for LibreChat
// @namespace http://tampermonkey.net/
// @version 1.3
// @description Automatically count tokens of chat content on LibreChat
// @author ChatGPT 4
// @match https://YOUR DOMAIN*
// @grant none
// @require https://unpkg.com/gpt-tokenizer/dist/cl100k_base.js
// ==/UserScript==
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
(function() {
'use strict';
const prices={
gpt_4o: 2.5
,claude_sonnet: 3
,claude_opus: 15
};
const popup = document.createElement('div');
popup.style.position = 'fixed';
popup.style.right = '20px';
popup.style.bottom = '80px';
popup.style.backgroundColor = '#333';
popup.style.color = '#fff';
popup.style.padding = '10px';
popup.style.borderRadius = '5px';
popup.style.zIndex = '1001';
popup.id= 'tokenStats';
document.body.appendChild(popup);
// Debounce function to limit the rate of function calls
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
var cacheTimeSonnet = {};
const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
var tokenCountCompletions = 0;
var tokenCountPrompt = 0;
function updateTokenCountCompletions(mutations){
//if(mutations) console.log(mutations);
if (mutations && mutations.length === 1 &&
(mutations[0].target.id === 'tokenStats' || mutations[0].target.id === 'timer')
) {
return; // only mutation is secondary token stats update or timer, quit early
}
//console.log(observer);
console.log('calculating completions token count...');
const preWrapElements = document.querySelectorAll('.markdown');
//const codeElements = document.querySelectorAll('code');//pretty sure this is included in the markdown
let totalTokens = 0;
preWrapElements.forEach(element => {
const text = element.textContent;
const tokens = GPTTokenizer_cl100k_base.encode(text);
totalTokens += tokens.length;
});
if(tokenCountCompletions!=totalTokens){
tokenCountCompletions=totalTokens;
writeTotalCount();
}
}
let previousLength = 0;
function updateTokenCountPrompt(){
const promptBox = document.getElementById('prompt-textarea');
let promptBoxText = promptBox && promptBox.value;
//console.log('Current text length:', promptBoxText ? promptBoxText.length : 'value not found');
//console.log('Previous length:', previousLength);
if(promptBoxText) {
if (Math.abs(promptBoxText.length-previousLength)<5) {
console.log("prompt token count::less than 5 characters, skipping.");
return; // don't bother recalculating for less than 5 characters
}
console.log('prompt token count::updating');
previousLength = promptBoxText.length;
let promptBoxTokens = GPTTokenizer_cl100k_base.encode(promptBoxText);
var totalTokens = promptBoxTokens.length;
}
else totalTokens = 0;
if(tokenCountPrompt!=totalTokens){
tokenCountPrompt=totalTokens;
writeTotalCount();
}
}
function writeTotalCount(){
const now = new Date().getTime();
const uuid = getUUID();
var timeLeft=-1;
if(cacheTimeSonnet[uuid]) {
const startTime = new Date(cacheTimeSonnet[uuid]).getTime();
timeLeft = startTime + fiveMinutes - now;
}
const textarea = document.getElementById('prompt-textarea');
const isClaude = (textarea && textarea.placeholder === "Message Claude");
const isCachedClaude = timeLeft>0 && isClaude;
console.log('isCachedClaude?',isCachedClaude,'timeLeft',timeLeft,'isClaude',isClaude);
const totalTokens = tokenCountCompletions+tokenCountPrompt;
const cached_cost = tokenCountCompletions/1000000 * prices.claude_sonnet * (isCachedClaude?0.1:1.25); //already cached
const uncached_cost = tokenCountPrompt/1000000 * prices.claude_sonnet * 1.25; //pay extra to cache
const result = `Tokens: ${tokenCountCompletions}${isCachedClaude?'*':''} + ${tokenCountPrompt}
Sonnet input: ${(cached_cost+uncached_cost).toFixed(3)}`;
//GPT-4o ${(totalTokens/1000000*prices.gpt_4o).toFixed(3)}`;
//Claude-Opus: ${(totalTokens/1000000*prices.claude_opus).toFixed(3)}`;//no spaces which will be trunacted to avoid mutation cycles
if(!popup.innerText || popup.innerText!=result) {
console.log('updating stats text...');
//console.log(result==popup.innerText,[popup.innerText, result]);
popup.innerText=result;
}
}
const debouncedUpdateTokenCount = debounce(updateTokenCountCompletions, 100);
const debouncedUpdateTokenCountPrompt = debounce(updateTokenCountPrompt, 50);
function observeDOMChanges() {
const observer = new MutationObserver(debouncedUpdateTokenCount);
observer.observe(document.body, { childList: true, subtree: true });
}
async function observePromptBox() {
//console.log('waiting for prompt box');
const textarea = await waitForElement('#prompt-textarea');
console.log('attached to prompt box');
textarea.onchange= () => {//when the system clears it after submit, onkeyup isn't triggering, and it can be a long wait until we get a response, leading to double counting tokens.
debouncedUpdateTokenCountPrompt();
};
textarea.onkeyup = () => {
debouncedUpdateTokenCountPrompt();
};
}
async function observeSubmit(){
//const submitButton = await waitForElement('#send-button');
const form = await waitForElement('form');
form.addEventListener('submit', async (event) => {
//console.log(event);
//submitButton.addEventListener('click', () => {
console.log('CAUGHT SUBMIT');
const textarea = document.getElementById('prompt-textarea');
if (textarea && textarea.placeholder === "Message Claude") {
console.log("Timer::is claude, resetting timer");
const now = new Date();
//might need to wait for a new message to get the uuid.
let uuid = getUUID();
if(!uuid) {
await sleep(3000);
uuid = getUUID();
}
if(!uuid) return;
cacheTimeSonnet[uuid] = now;//cached time from before waiting for UUID.
updateTimer();
} else {
console.log("Timer::not claude, removing timer.");
if (timerIntervalId) {
clearInterval(timerIntervalId);
timerIntervalId=false;
let timerSpan = document.getElementById('timer');
timerSpan.textContent = 'n/a';
}
}
});
/*document.body.addEventListener('click', (event) => {
console.log('caught click for ',event);
if (event.target.id === 'send-button') { // Or another way to identify the button
console.log('CAUGHT SUBMIT, resetting timer');
cacheTimeSonnet = new Date();
updateTimer();
}
});*/
}
let timerIntervalId = null;
async function updateTimer() {
const contentInfo = document.querySelector('[role="contentinfo"]');
let timerSpan = document.getElementById('timer');
console.log('calculating timer...');
if (!timerSpan) {
timerSpan = document.createElement('span');
timerSpan.id = 'timer';
timerSpan.style.order = '2';
timerSpan.style.marginLeft = 'auto';
timerSpan.style.fontWeight = 'bold';
contentInfo.appendChild(timerSpan);
// Modify the parent container
contentInfo.style.display = 'flex';
contentInfo.style.justifyContent = 'space-between';
contentInfo.style.alignItems = 'center';
}
// Clear any existing interval
if (timerIntervalId) {
clearInterval(timerIntervalId);
timerIntervalId=false;
}
function updateDisplay() {
const uuid = getUUID();
if(!cacheTimeSonnet[uuid]) {
timerSpan.textContent = '';
return;
}
const startTime = new Date(cacheTimeSonnet[uuid]).getTime();
const now = new Date().getTime();
const timeLeft = startTime + fiveMinutes - now;
if(timeLeft > 0 && timeLeft%5000 < 1000) console.log("updating timer, 5 second reminder to check for dups...");
if (timeLeft < -60000) { // More than 1 minute past expiration
timerSpan.textContent = 'Expired';
timerSpan.style.color = 'red';
//timerSpan.style.animation = 'pulse-subtle 1.5s ease-in-out infinite';
if (timerIntervalId) {
clearInterval(timerIntervalId);
timerIntervalId=false;
}
} else {
const minutes = Math.floor((Math.abs(timeLeft) % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((Math.abs(timeLeft) % (1000 * 60)) / 1000);
timerSpan.textContent = `${timeLeft<0?'-':''}${minutes}:${seconds.toString().padStart(2, '0')}`;
if (timeLeft > 90000) { // More than 1 minutes left
timerSpan.style.color = 'green';
timerSpan.style.animation = '';
} else if (timeLeft > 5000) { // Less than 90 seconds left
timerSpan.style.color = 'orange';
timerSpan.style.animation = 'pulse-subtle 1.5s ease-in-out infinite';
} else { // Less than 1 minute left or negative
timerSpan.style.color = 'red';
//timerSpan.style.animation = 'pulse-subtle 1.5s ease-in-out infinite';
}
}
}
// Initial call to set the initial state immediately
updateDisplay();
timerIntervalId = setInterval(updateDisplay, 1000);
}
function getUUID() {
const url = new URL(window.location.href);
const pathSegments = url.pathname.split('/');
const uuid = pathSegments[pathSegments.length - 1];
// Validate that it's a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (uuidRegex.test(uuid)) {
return uuid;
} else {
console.log('No valid UUID found in the URL');
return null;
}
}
async function waitForElement(selector) {
let element;
while (!element) {
element = document.querySelector(selector);
if (!element) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
return element;
}
updateTokenCountCompletions();
observeDOMChanges();
observePromptBox();
observeSubmit();
const style = document.createElement('style');
style.textContent = `
@keyframes pulse-subtle {
0% { transform: scale(1); }
50% { transform: scale(1.10); }
100% { transform: scale(1); }
}
.pulse-subtle {
animation: pulse-subtle 1.5s ease-in-out infinite;
}
`;
document.head.appendChild(style);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment