discourse-ai/assets/javascripts/discourse/components/ai-llm-editor-form.gjs
Keegan George d26c7ac48d
FEATURE: Add spending metrics to AI usage (#1268)
This update adds metrics for estimated spending in AI usage. To make use of it, admins must add cost details to the LLM config page (input, output, and cached input costs per 1M tokens). After doing so, the metrics will appear in the AI usage dashboard as the AI plugin is used.
2025-04-17 15:09:48 -07:00

630 lines
18 KiB
Text

import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { concat, fn, get } from "@ember/helper";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { service } from "@ember/service";
import { eq, gt } from "truth-helpers";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import Form from "discourse/components/form";
import Avatar from "discourse/helpers/bound-avatar-template";
import icon from "discourse/helpers/d-icon";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
import DurationSelector from "./ai-quota-duration-selector";
import AiLlmQuotaModal from "./modal/ai-llm-quota-modal";
export default class AiLlmEditorForm extends Component {
@service toasts;
@service router;
@service dialog;
@service modal;
@tracked isSaving = false;
@tracked testRunning = false;
@tracked testResult = null;
@tracked testError = null;
@cached
get formData() {
if (this.args.llmTemplate) {
let [id, modelName] = this.args.llmTemplate.split(/-(.*)/);
if (id === "none") {
return { provider_params: {} };
}
const info = this.args.llms.resultSetMeta.presets.findBy("id", id);
const modelInfo = info.models.findBy("name", modelName);
return {
max_prompt_tokens: modelInfo.tokens,
max_output_tokens: modelInfo.max_output_tokens,
tokenizer: info.tokenizer,
url: modelInfo.endpoint || info.endpoint,
display_name: modelInfo.display_name,
name: modelInfo.name,
provider: info.provider,
provider_params: this.computeProviderParams(info.provider),
input_cost: modelInfo.input_cost,
output_cost: modelInfo.output_cost,
cached_input_cost: modelInfo.cached_input_cost,
};
}
const { model } = this.args;
return {
max_prompt_tokens: model.max_prompt_tokens,
max_output_tokens: model.max_output_tokens,
api_key: model.api_key,
tokenizer: model.tokenizer,
url: model.url,
display_name: model.display_name,
name: model.name,
provider: model.provider,
enabled_chat_bot: model.enabled_chat_bot,
vision_enabled: model.vision_enabled,
input_cost: model.input_cost,
output_cost: model.output_cost,
cached_input_cost: model.cached_input_cost,
provider_params: this.computeProviderParams(
model.provider,
model.provider_params
),
llm_quotas: model.llm_quotas,
};
}
get selectedProviders() {
const t = (provName) => {
return i18n(`discourse_ai.llms.providers.${provName}`);
};
return this.args.llms.resultSetMeta.providers
.map((prov) => {
return { id: prov, name: t(prov) };
})
.sort((a, b) => a.name.localeCompare(b.name));
}
get tokenizers() {
return this.args.llms.resultSetMeta.tokenizers.sort((a, b) =>
a.name.localeCompare(b.name)
);
}
get adminUser() {
return AdminUser.create(this.args.model?.user);
}
get testErrorMessage() {
return i18n("discourse_ai.llms.tests.failure", { error: this.testError });
}
get displayTestResult() {
return this.testRunning || this.testResult !== null;
}
get modulesUsingModel() {
const usedBy = this.args.model.used_by?.filter((m) => m.type !== "ai_bot");
if (!usedBy || usedBy.length === 0) {
return null;
}
const localized = usedBy.map((m) => {
return i18n(`discourse_ai.llms.usage.${m.type}`, {
persona: m.name,
});
});
// TODO: this is not perfectly localized
return localized.join(", ");
}
get inUseWarning() {
return i18n("discourse_ai.llms.in_use_warning", {
settings: this.modulesUsingModel,
count: this.args.model.used_by.length,
});
}
get showAddQuotaButton() {
return !this.args.model.isNew;
}
computeProviderParams(provider, currentParams = {}) {
const params = this.args.llms.resultSetMeta.provider_params[provider] ?? {};
return Object.fromEntries(
Object.entries(params).map(([k, v]) => [
k,
currentParams[k] ?? (v?.type === "enum" ? v.default : null),
])
);
}
@action
canEditURL(provider) {
return provider !== "aws_bedrock";
}
@action
openAddQuotaModal(addItemToCollection) {
this.modal.show(AiLlmQuotaModal, {
model: { llm: this.args.model, addItemToCollection },
});
}
@action
metaProviderParams(provider) {
const params = this.args.llms.resultSetMeta.provider_params[provider] || {};
return Object.entries(params).reduce((acc, [field, value]) => {
if (typeof value === "string") {
acc[field] = { type: value };
} else if (typeof value === "object") {
if (value.values) {
value = { ...value };
value.values = value.values.map((v) => ({ id: v, name: v }));
}
acc[field] = {
type: value.type || "text",
values: value.values || [],
default: value.default ?? undefined,
};
} else {
acc[field] = { type: "text" }; // fallback
}
return acc;
}, {});
}
@action
async save(data) {
this.isSaving = true;
const isNew = this.args.model.isNew;
const updatedData = {
...data,
};
// If max_prompt_tokens input is cleared,
// we want the db to store null
if (!data.max_output_tokens) {
updatedData.max_output_tokens = null;
}
try {
await this.args.model.save(updatedData);
if (isNew) {
this.args.llms.addObject(this.args.model);
this.router.transitionTo("adminPlugins.show.discourse-ai-llms.index");
} else {
this.toasts.success({
data: { message: i18n("discourse_ai.llms.saved") },
duration: 2000,
});
}
} catch (e) {
popupAjaxError(e);
} finally {
later(() => {
this.isSaving = false;
}, 1000);
}
}
@action
async test(data) {
this.testRunning = true;
try {
const configTestResult = await this.args.model.testConfig(data);
this.testResult = configTestResult.success;
if (this.testResult) {
this.testError = null;
} else {
this.testError = configTestResult.error;
}
} catch (e) {
popupAjaxError(e);
} finally {
later(() => {
this.testRunning = false;
}, 1000);
}
}
@action
setProvider(provider, { set }) {
set("provider_params", this.computeProviderParams(provider));
set("provider", provider);
}
@action
delete() {
return this.dialog.confirm({
message: i18n("discourse_ai.llms.confirm_delete"),
didConfirm: () => {
return this.args.model
.destroyRecord()
.then(() => {
this.args.llms.removeObject(this.args.model);
this.router.transitionTo(
"adminPlugins.show.discourse-ai-llms.index"
);
})
.catch(popupAjaxError);
},
});
}
@action
providerParamsKeys(providerParams) {
return providerParams ? Object.keys(providerParams) : [];
}
<template>
<Form
@onSubmit={{this.save}}
@data={{this.formData}}
class="ai-llm-editor"
as |form data|
>
{{#if this.modulesUsingModel}}
<form.Alert @icon="circle-info">
{{this.inUseWarning}}
</form.Alert>
{{/if}}
<form.Field
@name="display_name"
@title={{i18n "discourse_ai.llms.display_name"}}
@validation="required|length:1,100"
@format="large"
@tooltip={{i18n "discourse_ai.llms.hints.display_name"}}
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="name"
@title={{i18n "discourse_ai.llms.name"}}
@tooltip={{i18n "discourse_ai.llms.hints.name"}}
@validation="required"
@format="large"
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="provider"
@title={{i18n "discourse_ai.llms.provider"}}
@format="large"
@validation="required"
@onSet={{this.setProvider}}
as |field|
>
<field.Select as |select|>
{{#each this.selectedProviders as |provider|}}
<select.Option
@value={{provider.id}}
>{{provider.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
{{#if (this.canEditURL data.provider)}}
<form.Field
@name="url"
@title={{i18n "discourse_ai.llms.url"}}
@validation="required"
@format="large"
as |field|
>
<field.Input />
</form.Field>
{{/if}}
<form.Field
@name="api_key"
@title={{i18n "discourse_ai.llms.api_key"}}
@validation="required"
@format="large"
as |field|
>
<field.Password />
</form.Field>
<form.Object @name="provider_params" as |object providerParamsData|>
{{#each (this.providerParamsKeys providerParamsData) as |name|}}
{{#let
(get (this.metaProviderParams data.provider) name)
as |params|
}}
<object.Field
@name={{name}}
@title={{i18n (concat "discourse_ai.llms.provider_fields." name)}}
@format="large"
as |field|
>
{{#if (eq params.type "enum")}}
<field.Select @includeNone={{false}} as |select|>
{{#each params.values as |option|}}
<select.Option
@value={{option.id}}
>{{option.name}}</select.Option>
{{/each}}
</field.Select>
{{else if (eq params.type "checkbox")}}
<field.Checkbox />
{{else}}
<field.Input @type={{params.type}} />
{{/if}}
</object.Field>
{{/let}}
{{/each}}
</form.Object>
<form.Field
@name="tokenizer"
@title={{i18n "discourse_ai.llms.tokenizer"}}
@format="large"
@validation="required"
as |field|
>
<field.Select as |select|>
{{#each this.tokenizers as |tokenizer|}}
<select.Option
@value={{tokenizer.id}}
>{{tokenizer.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
<form.Field
@name="max_prompt_tokens"
@title={{i18n "discourse_ai.llms.max_prompt_tokens"}}
@tooltip={{i18n "discourse_ai.llms.hints.max_prompt_tokens"}}
@validation="required"
@format="large"
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</form.Field>
<form.InputGroup as |inputGroup|>
<inputGroup.Field
@name="input_cost"
@title={{i18n "discourse_ai.llms.cost_input"}}
@tooltip={{i18n "discourse_ai.llms.hints.cost_input"}}
@helpText={{i18n "discourse_ai.llms.hints.cost_measure"}}
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</inputGroup.Field>
<inputGroup.Field
@name="cached_input_cost"
@title={{i18n "discourse_ai.llms.cost_cached_input"}}
@tooltip={{i18n "discourse_ai.llms.hints.cost_cached_input"}}
@helpText={{i18n "discourse_ai.llms.hints.cost_measure"}}
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</inputGroup.Field>
<inputGroup.Field
@name="output_cost"
@title={{i18n "discourse_ai.llms.cost_output"}}
@tooltip={{i18n "discourse_ai.llms.hints.cost_output"}}
@helpText={{i18n "discourse_ai.llms.hints.cost_measure"}}
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</inputGroup.Field>
</form.InputGroup>
<form.Field
@name="max_output_tokens"
@title={{i18n "discourse_ai.llms.max_output_tokens"}}
@tooltip={{i18n "discourse_ai.llms.hints.max_output_tokens"}}
@format="large"
as |field|
>
<field.Input @type="number" step="any" min="0" lang="en" />
</form.Field>
<form.Field
@name="vision_enabled"
@title={{i18n "discourse_ai.llms.vision_enabled"}}
@tooltip={{i18n "discourse_ai.llms.hints.vision_enabled"}}
@format="large"
as |field|
>
<field.Checkbox />
</form.Field>
<form.Field
@name="enabled_chat_bot"
@title={{i18n "discourse_ai.llms.enabled_chat_bot"}}
@tooltip={{i18n "discourse_ai.llms.hints.enabled_chat_bot"}}
@format="large"
as |field|
>
<field.Checkbox />
</form.Field>
{{#if @model.user}}
<form.Container @title={{i18n "discourse_ai.llms.ai_bot_user"}}>
<a
class="avatar"
href={{@model.user.path}}
data-user-card={{@model.user.username}}
>
{{Avatar @model.user.avatar_template "small"}}
</a>
<LinkTo @route="adminUser" @model={{this.adminUser}}>
{{@model.user.username}}
</LinkTo>
</form.Container>
{{/if}}
{{#if (gt data.llm_quotas.length 0)}}
<form.Container @title={{i18n "discourse_ai.llms.quotas.title"}}>
<table class="ai-llm-quotas__table">
<thead class="ai-llm-quotas__table-head">
<tr class="ai-llm-quotas__header-row">
<th class="ai-llm-quotas__header">{{i18n
"discourse_ai.llms.quotas.group"
}}</th>
<th class="ai-llm-quotas__header">{{i18n
"discourse_ai.llms.quotas.max_tokens"
}}</th>
<th class="ai-llm-quotas__header">{{i18n
"discourse_ai.llms.quotas.max_usages"
}}</th>
<th class="ai-llm-quotas__header">{{i18n
"discourse_ai.llms.quotas.duration"
}}</th>
<th
class="ai-llm-quotas__header ai-llm-quotas__header--actions"
></th>
<th></th>
</tr>
</thead>
<tbody class="ai-llm-quotas__table-body">
<form.Collection
@name="llm_quotas"
@tagName="tr"
class="ai-llm-quotas__row"
as |collection index collectionData|
>
<td
class="ai-llm-quotas__cell"
>{{collectionData.group_name}}</td>
<td class="ai-llm-quotas__cell">
<collection.Field
@name="max_tokens"
@title="max_tokens"
@showTitle={{false}}
as |field|
>
<field.Input
@type="number"
class="ai-llm-quotas__input"
min="1"
/>
</collection.Field>
</td>
<td class="ai-llm-quotas__cell">
<collection.Field
@name="max_usages"
@title="max_usages"
@showTitle={{false}}
as |field|
>
<field.Input
@type="number"
class="ai-llm-quotas__input"
min="1"
/>
</collection.Field>
</td>
<td class="ai-llm-quotas__cell">
<collection.Field
@name="duration_seconds"
@title="duration_seconds"
@showTitle={{false}}
as |field|
>
<field.Custom>
<DurationSelector
@value={{collectionData.duration_seconds}}
@onChange={{field.set}}
/>
</field.Custom>
</collection.Field>
</td>
<td>
<form.Button
@icon="trash-can"
@action={{fn collection.remove index}}
class="btn-danger ai-llm-quotas__delete-btn"
/>
</td>
</form.Collection>
</tbody>
</table>
</form.Container>
<form.Button
@action={{fn
this.openAddQuotaModal
(fn form.addItemToCollection "llm_quotas")
}}
@icon="plus"
@label="discourse_ai.llms.quotas.add"
class="ai-llm-editor__add-quota-btn"
/>
{{/if}}
<form.Actions>
<form.Button
@action={{fn this.test data}}
@disabled={{this.testRunning}}
@label="discourse_ai.llms.tests.title"
/>
<form.Submit />
{{#if (eq data.llm_quotas.length 0)}}
<form.Button
@action={{fn
this.openAddQuotaModal
(fn form.addItemToCollection "llm_quotas")
}}
@label="discourse_ai.llms.quotas.add"
class="ai-llm-editor__add-quota-btn"
/>
{{/if}}
{{#unless @model.isNew}}
<form.Button
@action={{this.delete}}
@label="discourse_ai.llms.delete"
class="btn-danger"
/>
{{/unless}}
</form.Actions>
{{#if this.displayTestResult}}
<form.Container @format="full">
<ConditionalLoadingSpinner
@size="small"
@condition={{this.testRunning}}
>
{{#if this.testResult}}
<div class="ai-llm-editor-tests__success">
{{icon "check"}}
{{i18n "discourse_ai.llms.tests.success"}}
</div>
{{else}}
<div class="ai-llm-editor-tests__failure">
{{icon "xmark"}}
{{this.testErrorMessage}}
</div>
{{/if}}
</ConditionalLoadingSpinner>
</form.Container>
{{/if}}
</Form>
</template>
}