FEATURE: LLM presets for model creation (#681)

* FEATURE: LLM presets for model creation

Previous to this users needed to look up complicated settings
when setting up models.

This introduces and extensible preset system with Google/OpenAI/Anthropic
presets.

This will cover all the most common LLMs, we can always add more as
we go.

Additionally:

- Proper support for Anthropic Claude Sonnet 3.5
- Stop blurring api keys when navigating away - this made it very complex to reuse keys
This commit is contained in:
Sam 2024-06-21 17:32:15 +10:00 committed by GitHub
parent 558574fa87
commit e04a7be122
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 464 additions and 278 deletions

View File

@ -16,6 +16,7 @@ module DiscourseAi
root: false,
).as_json,
meta: {
presets: DiscourseAi::Completions::Llm.presets,
providers: DiscourseAi::Completions::Llm.provider_names,
tokenizers:
DiscourseAi::Completions::Llm.tokenizer_names.map { |tn|

View File

@ -41,7 +41,7 @@ class LlmModel < ActiveRecord::Base
new_user =
User.new(
id: [FIRST_BOT_USER_ID, next_id].min,
email: "no_email_#{name.underscore}",
email: "no_email_#{SecureRandom.hex}",
name: name.titleize,
username: UserNameSuggester.suggest(name),
active: true,

View File

@ -0,0 +1,297 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { or } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import Avatar from "discourse/helpers/bound-avatar-template";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
import ComboBox from "select-kit/components/combo-box";
import DTooltip from "float-kit/components/d-tooltip";
export default class AiLlmEditorForm extends Component {
@service toasts;
@service router;
@service dialog;
@tracked isSaving = false;
@tracked testRunning = false;
@tracked testResult = null;
@tracked testError = null;
@tracked apiKeySecret = true;
get selectedProviders() {
const t = (provName) => {
return I18n.t(`discourse_ai.llms.providers.${provName}`);
};
return this.args.llms.resultSetMeta.providers.map((prov) => {
return { id: prov, name: t(prov) };
});
}
get adminUser() {
return AdminUser.create(this.args.model?.user);
}
@action
async save() {
this.isSaving = true;
const isNew = this.args.model.isNew;
try {
const result = await this.args.model.save();
this.args.model.setProperties(result.responseJson.ai_persona);
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.t("discourse_ai.llms.saved") },
duration: 2000,
});
}
} catch (e) {
popupAjaxError(e);
} finally {
later(() => {
this.isSaving = false;
}, 1000);
}
}
@action
async test() {
this.testRunning = true;
try {
const configTestResult = await this.args.model.testConfig();
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);
}
}
get testErrorMessage() {
return I18n.t("discourse_ai.llms.tests.failure", { error: this.testError });
}
get displayTestResult() {
return this.testRunning || this.testResult !== null;
}
@action
makeApiKeySecret() {
this.apiKeySecret = true;
}
@action
toggleApiKeySecret() {
this.apiKeySecret = !this.apiKeySecret;
}
@action
delete() {
return this.dialog.confirm({
message: I18n.t("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
async toggleEnabledChatBot() {
this.args.model.set("enabled_chat_bot", !this.args.model.enabled_chat_bot);
if (!this.args.model.isNew) {
try {
await this.args.model.update({
enabled_chat_bot: this.args.model.enabled_chat_bot,
});
} catch (e) {
popupAjaxError(e);
}
}
}
<template>
{{#unless (or @model.url_editable @model.isNew)}}
<div class="alert alert-info">
{{icon "exclamation-circle"}}
{{I18n.t "discourse_ai.llms.srv_warning"}}
</div>
{{/unless}}
<form class="form-horizontal ai-llm-editor">
<div class="control-group">
<label>{{i18n "discourse_ai.llms.display_name"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__display-name"
@type="text"
@value={{@model.display_name}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.name"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__name"
@type="text"
@value={{@model.name}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.llms.hints.name"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.provider"}}</label>
<ComboBox
@value={{@model.provider}}
@content={{this.selectedProviders}}
/>
</div>
{{#if (or @model.url_editable @model.isNew)}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.url"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__url"
@type="text"
@value={{@model.url}}
/>
</div>
{{/if}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.api_key"}}</label>
<div class="ai-llm-editor__secret-api-key-group">
<Input
@value={{@model.api_key}}
class="ai-llm-editor-input ai-llm-editor__api-key"
@type={{if this.apiKeySecret "password" "text"}}
{{on "focusout" this.makeApiKeySecret}}
/>
<DButton @action={{this.toggleApiKeySecret}} @icon="far-eye-slash" />
</div>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.tokenizer"}}</label>
<ComboBox
@value={{@model.tokenizer}}
@content={{@llms.resultSetMeta.tokenizers}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.max_prompt_tokens"}}</label>
<Input
@type="number"
class="ai-llm-editor-input ai-llm-editor__max-prompt-tokens"
step="any"
min="0"
lang="en"
@value={{@model.max_prompt_tokens}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.llms.hints.max_prompt_tokens"}}
/>
</div>
<div class="control-group">
<DToggleSwitch
class="ai-llm-editor__enabled-chat-bot"
@state={{@model.enabled_chat_bot}}
@label="discourse_ai.llms.enabled_chat_bot"
{{on "click" this.toggleEnabledChatBot}}
/>
</div>
{{#if @model.user}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.ai_bot_user"}}</label>
<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>
</div>
{{/if}}
<div class="control-group ai-llm-editor__action_panel">
<DButton
class="ai-llm-editor__test"
@action={{this.test}}
@disabled={{this.testRunning}}
>
{{I18n.t "discourse_ai.llms.tests.title"}}
</DButton>
<DButton
class="btn-primary ai-llm-editor__save"
@action={{this.save}}
@disabled={{this.isSaving}}
>
{{I18n.t "discourse_ai.llms.save"}}
</DButton>
{{#unless @model.isNew}}
<DButton
@action={{this.delete}}
class="btn-danger ai-llm-editor__delete"
>
{{I18n.t "discourse_ai.llms.delete"}}
</DButton>
{{/unless}}
</div>
<div class="control-group ai-llm-editor-tests">
{{#if this.displayTestResult}}
{{#if this.testRunning}}
<div class="spinner small"></div>
{{I18n.t "discourse_ai.llms.tests.running"}}
{{else}}
{{#if this.testResult}}
<div class="ai-llm-editor-tests__success">
{{icon "check"}}
{{I18n.t "discourse_ai.llms.tests.success"}}
</div>
{{else}}
<div class="ai-llm-editor-tests__failure">
{{icon "times"}}
{{this.testErrorMessage}}
</div>
{{/if}}
{{/if}}
{{/if}}
</div>
</form>
</template>
}

View File

@ -1,148 +1,64 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { or } from "truth-helpers";
import BackButton from "discourse/components/back-button";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import Avatar from "discourse/helpers/bound-avatar-template";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
import ComboBox from "select-kit/components/combo-box";
import DTooltip from "float-kit/components/d-tooltip";
import AiLlmEditorForm from "./ai-llm-editor-form";
export default class AiLlmEditor extends Component {
@service toasts;
@service router;
@service dialog;
@tracked presetConfigured = false;
presetId = "none";
@tracked isSaving = false;
@tracked testRunning = false;
@tracked testResult = null;
@tracked testError = null;
@tracked apiKeySecret = true;
get selectedProviders() {
const t = (provName) => {
return I18n.t(`discourse_ai.llms.providers.${provName}`);
};
return this.args.llms.resultSetMeta.providers.map((prov) => {
return { id: prov, name: t(prov) };
});
get showPresets() {
return (
this.args.model.isNew && !this.presetConfigured && !this.args.model.url
);
}
get adminUser() {
return AdminUser.create(this.args.model?.user);
}
@action
async save() {
this.isSaving = true;
const isNew = this.args.model.isNew;
try {
const result = await this.args.model.save();
this.args.model.setProperties(result.responseJson.ai_persona);
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.t("discourse_ai.llms.saved") },
duration: 2000,
});
}
} catch (e) {
popupAjaxError(e);
} finally {
later(() => {
this.isSaving = false;
}, 1000);
}
}
@action
async test() {
this.testRunning = true;
try {
const configTestResult = await this.args.model.testConfig();
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);
}
}
get testErrorMessage() {
return I18n.t("discourse_ai.llms.tests.failure", { error: this.testError });
}
get displayTestResult() {
return this.testRunning || this.testResult !== null;
}
@action
makeApiKeySecret() {
this.apiKeySecret = true;
}
@action
toggleApiKeySecret() {
this.apiKeySecret = !this.apiKeySecret;
}
@action
delete() {
return this.dialog.confirm({
message: I18n.t("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);
get preConfiguredLlms() {
let options = [
{
id: "none",
name: I18n.t(`discourse_ai.llms.preconfigured.none`),
},
];
this.args.llms.resultSetMeta.presets.forEach((llm) => {
if (llm.models) {
llm.models.forEach((model) => {
options.push({
id: `${llm.id}-${model.name}`,
name: model.display_name,
});
});
}
});
return options;
}
@action
async toggleEnabledChatBot() {
this.args.model.set("enabled_chat_bot", !this.args.model.enabled_chat_bot);
if (!this.args.model.isNew) {
try {
await this.args.model.update({
enabled_chat_bot: this.args.model.enabled_chat_bot,
});
} catch (e) {
popupAjaxError(e);
}
configurePreset() {
this.presetConfigured = true;
let [id, model] = this.presetId.split(/-(.*)/);
if (id === "none") {
return;
}
const info = this.args.llms.resultSetMeta.presets.findBy("id", id);
const modelInfo = info.models.findBy("name", model);
this.args.model.setProperties({
max_prompt_tokens: modelInfo.tokens,
tokenizer: info.tokenizer,
url: modelInfo.endpoint || info.endpoint,
display_name: modelInfo.display_name,
name: modelInfo.name,
provider: info.provider,
});
}
<template>
@ -150,157 +66,25 @@ export default class AiLlmEditor extends Component {
@route="adminPlugins.show.discourse-ai-llms"
@label="discourse_ai.llms.back"
/>
{{#unless (or @model.url_editable @model.isNew)}}
<div class="alert alert-info">
{{icon "exclamation-circle"}}
{{I18n.t "discourse_ai.llms.srv_warning"}}
</div>
{{/unless}}
<form class="form-horizontal ai-llm-editor">
<div class="control-group">
<label>{{i18n "discourse_ai.llms.display_name"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__display-name"
@type="text"
@value={{@model.display_name}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.name"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__name"
@type="text"
@value={{@model.name}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.llms.hints.name"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.provider"}}</label>
<ComboBox
@value={{@model.provider}}
@content={{this.selectedProviders}}
/>
</div>
{{#if (or @model.url_editable @model.isNew)}}
{{#if this.showPresets}}
<form class="form-horizontal ai-llm-editor">
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.url"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__url"
@type="text"
@value={{@model.url}}
<label>{{I18n.t "discourse_ai.llms.preconfigured_llms"}}</label>
<ComboBox
@value={{this.presetId}}
@content={{this.preConfiguredLlms}}
class="ai-llm-editor__presets"
/>
</div>
{{/if}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.api_key"}}</label>
<div class="ai-llm-editor__secret-api-key-group">
<Input
@value={{@model.api_key}}
class="ai-llm-editor-input ai-llm-editor__api-key"
@type={{if this.apiKeySecret "password" "text"}}
{{on "focusout" this.makeApiKeySecret}}
/>
<DButton
@action={{this.toggleApiKeySecret}}
@icon="far-eye-slash"
{{on "focusout" this.makeApiKeySecret}}
/>
</div>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.tokenizer"}}</label>
<ComboBox
@value={{@model.tokenizer}}
@content={{@llms.resultSetMeta.tokenizers}}
/>
</div>
<div class="control-group">
<label>{{i18n "discourse_ai.llms.max_prompt_tokens"}}</label>
<Input
@type="number"
class="ai-llm-editor-input ai-llm-editor__max-prompt-tokens"
step="any"
min="0"
lang="en"
@value={{@model.max_prompt_tokens}}
/>
<DTooltip
@icon="question-circle"
@content={{I18n.t "discourse_ai.llms.hints.max_prompt_tokens"}}
/>
</div>
<div class="control-group">
<DToggleSwitch
class="ai-llm-editor__enabled-chat-bot"
@state={{@model.enabled_chat_bot}}
@label="discourse_ai.llms.enabled_chat_bot"
{{on "click" this.toggleEnabledChatBot}}
/>
</div>
{{#if @model.user}}
<div class="control-group">
<label>{{i18n "discourse_ai.llms.ai_bot_user"}}</label>
<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>
</div>
{{/if}}
<div class="control-group ai-llm-editor__action_panel">
<DButton
class="ai-llm-editor__test"
@action={{this.test}}
@disabled={{this.testRunning}}
>
{{I18n.t "discourse_ai.llms.tests.title"}}
</DButton>
<DButton
class="btn-primary ai-llm-editor__save"
@action={{this.save}}
@disabled={{this.isSaving}}
>
{{I18n.t "discourse_ai.llms.save"}}
</DButton>
{{#unless @model.isNew}}
<DButton
@action={{this.delete}}
class="btn-danger ai-llm-editor__delete"
>
{{I18n.t "discourse_ai.llms.delete"}}
<div class="control-group ai-llm-editor__action_panel">
<DButton class="ai-llm-editor__next" @action={{this.configurePreset}}>
{{I18n.t "discourse_ai.llms.next.title"}}
</DButton>
{{/unless}}
</div>
<div class="control-group ai-llm-editor-tests">
{{#if this.displayTestResult}}
{{#if this.testRunning}}
<div class="spinner small"></div>
{{I18n.t "discourse_ai.llms.tests.running"}}
{{else}}
{{#if this.testResult}}
<div class="ai-llm-editor-tests__success">
{{icon "check"}}
{{I18n.t "discourse_ai.llms.tests.success"}}
</div>
{{else}}
<div class="ai-llm-editor-tests__failure">
{{icon "times"}}
{{this.testErrorMessage}}
</div>
{{/if}}
{{/if}}
{{/if}}
</div>
</form>
</div>
</form>
{{else}}
<AiLlmEditorForm @model={{@model}} @llms={{@llms}} />
{{/if}}
</template>
}

View File

@ -40,7 +40,7 @@ export default class AiLlmsListEditor extends Component {
{{#unless @currentLlm.isNew}}
<LinkTo
@route="adminPlugins.show.discourse-ai-llms.new"
class="btn btn-small btn-primary"
class="btn btn-small btn-primary ai-llms-list-editor__new"
>
{{icon "plus"}}
<span>{{I18n.t "discourse_ai.llms.new"}}</span>

View File

@ -204,6 +204,11 @@ en:
delete: Delete
srv_warning: This LLM points to an SRV record, and its URL is not editable. You have to update the hidden "ai_vllm_endpoint_srv" setting instead.
preconfigured_llms: "Select your LLM"
preconfigured:
none: "Configure manually..."
next:
title: "Next"
tests:
title: "Run Test"

View File

@ -28,7 +28,6 @@ module DiscourseAi
end
def default_options(dialect)
# skipping 2.0 support for now, since other models are better
mapped_model =
case model
when "claude-2"
@ -41,8 +40,10 @@ module DiscourseAi
"claude-3-sonnet-20240229"
when "claude-3-opus"
"claude-3-opus-20240229"
when "claude-3-5-sonnet"
"claude-3-5-sonnet-20240620"
else
raise "Unsupported model: #{model}"
model
end
options = { model: mapped_model, max_tokens: 3_000 }

View File

@ -78,6 +78,8 @@ module DiscourseAi
"anthropic.claude-instant-v1"
when "claude-3-opus"
"anthropic.claude-3-opus-20240229-v1:0"
when "claude-3-5-sonnet"
"anthropic.claude-3-5-sonnet-20240620-v1:0"
else
model
end

View File

@ -18,6 +18,63 @@ module DiscourseAi
UNKNOWN_MODEL = Class.new(StandardError)
class << self
def presets
# Sam: I am not sure if it makes sense to translate model names at all
@presets ||=
begin
[
{
id: "anthropic",
models: [
{
name: "claude-3-5-sonnet",
tokens: 200_000,
display_name: "Claude 3.5 Sonnet",
},
{ name: "claude-3-opus", tokens: 200_000, display_name: "Claude 3 Opus" },
{ name: "claude-3-sonnet", tokens: 200_000, display_name: "Claude 3 Sonnet" },
{ name: "claude-3-haiku", tokens: 200_000, display_name: "Claude 3 Haiku" },
],
tokenizer: DiscourseAi::Tokenizer::AnthropicTokenizer,
endpoint: "https://api.anthropic.com/v1/messages",
provider: "anthropic",
},
{
id: "google",
models: [
{
name: "gemini-1.5-pro",
tokens: 800_000,
endpoint:
"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro-latest",
display_name: "Gemini 1.5 Pro",
},
{
name: "gemini-1.5-flash",
tokens: 800_000,
endpoint:
"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest",
display_name: "Gemini 1.5 Flash",
},
],
tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer,
provider: "google",
},
{
id: "open_ai",
models: [
{ name: "gpt-4o", tokens: 131_072, display_name: "GPT-4 Omni" },
{ name: "gpt-4-turbo", tokens: 131_072, display_name: "GPT-4 Turbo" },
{ name: "gpt-3.5-turbo", tokens: 16_385, display_name: "GPT-3.5 Turbo" },
],
tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer,
endpoint: "https://api.openai.com/v1/chat/completions",
provider: "open_ai",
},
]
end
end
def provider_names
providers = %w[aws_bedrock anthropic vllm hugging_face cohere open_ai google azure]
if !Rails.env.production?

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
RSpec.describe "Admin dashboard", type: :system do
fab!(:admin)
it "correctly sets defaults" do
sign_in(admin)
visit "/admin/plugins/discourse-ai/ai-llms"
find(".ai-llms-list-editor__new").click()
select_kit = PageObjects::Components::SelectKit.new(".ai-llm-editor__presets")
select_kit.expand
select_kit.select_row_by_value("anthropic-claude-3-haiku")
find(".ai-llm-editor__next").click()
find("input.ai-llm-editor__api-key").fill_in(with: "abcd")
find(".ai-llm-editor__save").click()
expect(page).to have_current_path("/admin/plugins/discourse-ai/ai-llms")
llm = LlmModel.order(:id).last
expect(llm.api_key).to eq("abcd")
preset = DiscourseAi::Completions::Llm.presets.find { |p| p[:id] == "anthropic" }
model_preset = preset[:models].find { |m| m[:name] == "claude-3-haiku" }
expect(llm.name).to eq("claude-3-haiku")
expect(llm.url).to eq(preset[:endpoint])
expect(llm.tokenizer).to eq(preset[:tokenizer].to_s)
expect(llm.max_prompt_tokens.to_i).to eq(model_preset[:tokens])
expect(llm.provider).to eq("anthropic")
expect(llm.display_name).to eq(model_preset[:display_name])
end
end