Last active
September 26, 2024 07:53
-
-
Save avimar/1649504125b87e913bf41147029800a8 to your computer and use it in GitHub Desktop.
LiberChat Token Count
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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