mirror of
https://ghfast.top/https://github.com/discourse/discourse-ai.git
synced 2026-07-16 11:36:23 +08:00
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.
630 lines
18 KiB
Text
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>
|
|
}
|