Sam d07cf51653
FEATURE: llm quotas (#1047)
Adds a comprehensive quota management system for LLM models that allows:

- Setting per-group (applied per user in the group) token and usage limits with configurable durations
- Tracking and enforcing token/usage limits across user groups
- Quota reset periods (hourly, daily, weekly, or custom)
-  Admin UI for managing quotas with real-time updates

This system provides granular control over LLM API usage by allowing admins
to define limits on both total tokens and number of requests per group.
Supports multiple concurrent quotas per model and automatically handles
quota resets.


Co-authored-by: Keegan George <kgeorge13@gmail.com>
2025-01-14 15:54:09 +11:00

250 lines
7.7 KiB
JavaScript

import { ajax } from "discourse/lib/ajax";
import { extractError, popupAjaxError } from "discourse/lib/ajax-error";
import { apiInitializer } from "discourse/lib/api";
import {
getUploadMarkdown,
IMAGE_MARKDOWN_REGEX,
isImage,
} from "discourse/lib/uploads";
import { i18n } from "discourse-i18n";
export default apiInitializer("1.25.0", (api) => {
const buttonAttrs = {
label: i18n("discourse_ai.ai_helper.image_caption.button_label"),
icon: "discourse-sparkles",
class: "generate-caption",
};
const settings = api.container.lookup("service:site-settings");
const currentUser = api.getCurrentUser();
if (
!settings.ai_helper_enabled_features.includes("image_caption") ||
!currentUser?.can_use_assistant
) {
return;
}
api.addSaveableUserOptionField("auto_image_caption");
api.addComposerImageWrapperButton(
buttonAttrs.label,
buttonAttrs.class,
buttonAttrs.icon,
(event) => {
const imageCaptionPopup = api.container.lookup(
"service:imageCaptionPopup"
);
imageCaptionPopup.popupTrigger = event.target;
if (
imageCaptionPopup.popupTrigger.classList.contains("generate-caption")
) {
const buttonWrapper = event.target.closest(".button-wrapper");
const imageIndex = parseInt(
buttonWrapper.getAttribute("data-image-index"),
10
);
const imageSrc = event.target
.closest(".image-wrapper")
.querySelector("img")
.getAttribute("src");
imageCaptionPopup.toggleLoadingState(true);
const site = api.container.lookup("service:site");
if (!site.mobileView) {
imageCaptionPopup.showPopup = !imageCaptionPopup.showPopup;
}
imageCaptionPopup._request = ajax(
`/discourse-ai/ai-helper/caption_image`,
{
method: "POST",
data: {
image_url: imageSrc,
image_url_type: "long_url",
},
}
);
imageCaptionPopup._request
.then(({ caption }) => {
imageCaptionPopup.imageSrc = imageSrc;
imageCaptionPopup.imageIndex = imageIndex;
imageCaptionPopup.newCaption = caption;
if (site.mobileView) {
// Auto-saves caption on mobile view
imageCaptionPopup.updateCaption();
}
})
.catch(popupAjaxError)
.finally(() => {
imageCaptionPopup.toggleLoadingState(false);
});
}
}
);
// Checks if image is small (≤ 0.1 MP)
function isSmallImage(width, height) {
const megapixels = (width * height) / 1000000;
return megapixels <= 0.1;
}
function needsImprovedCaption(caption) {
return caption.length < 20 || caption.split(" ").length === 1;
}
function getUploadUrlFromMarkdown(markdown) {
const regex = /\(upload:\/\/([^)]+)\)/;
const match = markdown.match(regex);
return match ? `upload://${match[1]}` : null;
}
async function fetchImageCaption(imageUrl, urlType) {
try {
const response = await ajax(`/discourse-ai/ai-helper/caption_image`, {
method: "POST",
data: {
image_url: imageUrl,
image_url_type: urlType,
},
});
return response.caption;
} catch (error) {
toasts.error({
class: "ai-image-caption-error-toast",
duration: 3000,
data: {
message: extractError(error),
},
});
}
}
const autoCaptionAllowedGroups =
settings?.ai_auto_image_caption_allowed_groups
.split("|")
.map((id) => parseInt(id, 10));
const currentUserGroups = currentUser.groups.map((g) => g.id);
if (
!currentUserGroups.some((groupId) =>
autoCaptionAllowedGroups.includes(groupId)
)
) {
return;
}
const toasts = api.container.lookup("service:toasts");
// Automatically caption uploaded images
api.addComposerUploadMarkdownResolver(async (upload) => {
const autoCaptionEnabled = currentUser.get(
"user_option.auto_image_caption"
);
if (
!autoCaptionEnabled ||
!isImage(upload.url) ||
!needsImprovedCaption(upload.original_filename) ||
isSmallImage(upload.width, upload.height)
) {
return getUploadMarkdown(upload);
}
const caption = await fetchImageCaption(upload.url, "long_url");
if (!caption) {
return getUploadMarkdown(upload);
}
return `![${caption}|${upload.thumbnail_width}x${upload.thumbnail_height}](${upload.short_url})`;
});
// Conditionally show dialog to auto image caption
api.composerBeforeSave(() => {
return new Promise((resolve, reject) => {
const dialog = api.container.lookup("service:dialog");
const composer = api.container.lookup("service:composer");
const localePrefix =
"discourse_ai.ai_helper.image_caption.automatic_caption_dialog";
const autoCaptionEnabled = currentUser.get(
"user_option.auto_image_caption"
);
const imageUploads = composer.model.reply.match(IMAGE_MARKDOWN_REGEX);
const hasImageUploads = imageUploads?.length > 0;
if (!hasImageUploads) {
resolve();
}
const imagesToCaption = imageUploads.filter((image) => {
const caption = image
.substring(image.indexOf("[") + 1, image.indexOf("]"))
.split("|")[0];
// We don't check if the image is small to show the prompt here
// because the width/height are the thumbnail sizes so the mp count
// is incorrect. It doesn't matter because the auto caption won't
// happen anyways if its small because that uses the actual upload dimensions
return needsImprovedCaption(caption);
});
const needsBetterCaptions = imagesToCaption?.length > 0;
const keyValueStore = api.container.lookup("service:key-value-store");
const imageCaptionPopup = api.container.lookup(
"service:imageCaptionPopup"
);
const autoCaptionPromptKey = "ai-auto-caption-seen";
const seenAutoCaptionPrompt = keyValueStore.getItem(autoCaptionPromptKey);
if (autoCaptionEnabled || !needsBetterCaptions || seenAutoCaptionPrompt) {
return resolve();
}
keyValueStore.setItem(autoCaptionPromptKey, true);
dialog.confirm({
message: i18n(`${localePrefix}.prompt`),
confirmButtonLabel: `${localePrefix}.confirm`,
cancelButtonLabel: `${localePrefix}.cancel`,
class: "ai-image-caption-prompt-dialog",
didConfirm: async () => {
try {
currentUser.set("user_option.auto_image_caption", true);
await currentUser.save(["auto_image_caption"]);
imagesToCaption.forEach(async (imageMarkdown) => {
const uploadUrl = getUploadUrlFromMarkdown(imageMarkdown);
imageCaptionPopup.showAutoCaptionLoader = true;
const caption = await fetchImageCaption(uploadUrl, "short_url");
// Find and replace the caption in the reply
const regex = new RegExp(
`(!\\[)[^|]+(\\|[^\\]]+\\]\\(${uploadUrl}\\))`
);
const newReply = composer.model.reply.replace(
regex,
`$1${caption}$2`
);
composer.model.set("reply", newReply);
imageCaptionPopup.showAutoCaptionLoader = false;
resolve();
});
} catch (error) {
// Reject the promise if an error occurs
// Show an error saying unable to generate captions
reject(error);
}
},
didCancel: () => {
// Don't enable auto captions and continue with the save
resolve();
},
});
});
});
});