From e04a7be1225997ee1aaec0a46b53f1697631ba02 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 21 Jun 2024 17:32:15 +1000 Subject: [PATCH] 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 --- .../discourse_ai/admin/ai_llms_controller.rb | 1 + app/models/llm_model.rb | 2 +- .../components/ai-llm-editor-form.gjs | 297 ++++++++++++++++ .../discourse/components/ai-llm-editor.gjs | 332 +++--------------- .../components/ai-llms-list-editor.gjs | 2 +- config/locales/client.en.yml | 5 + lib/completions/endpoints/anthropic.rb | 5 +- lib/completions/endpoints/aws_bedrock.rb | 2 + lib/completions/llm.rb | 57 +++ spec/system/llms/ai_llm_spec.rb | 39 ++ 10 files changed, 464 insertions(+), 278 deletions(-) create mode 100644 assets/javascripts/discourse/components/ai-llm-editor-form.gjs create mode 100644 spec/system/llms/ai_llm_spec.rb diff --git a/app/controllers/discourse_ai/admin/ai_llms_controller.rb b/app/controllers/discourse_ai/admin/ai_llms_controller.rb index 79c283f3..a98b6803 100644 --- a/app/controllers/discourse_ai/admin/ai_llms_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_llms_controller.rb @@ -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| diff --git a/app/models/llm_model.rb b/app/models/llm_model.rb index 07c60789..609849f9 100644 --- a/app/models/llm_model.rb +++ b/app/models/llm_model.rb @@ -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, diff --git a/assets/javascripts/discourse/components/ai-llm-editor-form.gjs b/assets/javascripts/discourse/components/ai-llm-editor-form.gjs new file mode 100644 index 00000000..0fc46ec2 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-llm-editor-form.gjs @@ -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); + } + } + } + + +} diff --git a/assets/javascripts/discourse/components/ai-llm-editor.gjs b/assets/javascripts/discourse/components/ai-llm-editor.gjs index cfc5a4a3..af9b4ff6 100644 --- a/assets/javascripts/discourse/components/ai-llm-editor.gjs +++ b/assets/javascripts/discourse/components/ai-llm-editor.gjs @@ -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, + }); } } diff --git a/assets/javascripts/discourse/components/ai-llms-list-editor.gjs b/assets/javascripts/discourse/components/ai-llms-list-editor.gjs index 03f74b93..b8bb1319 100644 --- a/assets/javascripts/discourse/components/ai-llms-list-editor.gjs +++ b/assets/javascripts/discourse/components/ai-llms-list-editor.gjs @@ -40,7 +40,7 @@ export default class AiLlmsListEditor extends Component { {{#unless @currentLlm.isNew}} {{icon "plus"}} {{I18n.t "discourse_ai.llms.new"}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 21e6c91d..ca87d003 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/lib/completions/endpoints/anthropic.rb b/lib/completions/endpoints/anthropic.rb index 2739b02d..1fbbe620 100644 --- a/lib/completions/endpoints/anthropic.rb +++ b/lib/completions/endpoints/anthropic.rb @@ -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 } diff --git a/lib/completions/endpoints/aws_bedrock.rb b/lib/completions/endpoints/aws_bedrock.rb index 0087a981..48a333d9 100644 --- a/lib/completions/endpoints/aws_bedrock.rb +++ b/lib/completions/endpoints/aws_bedrock.rb @@ -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 diff --git a/lib/completions/llm.rb b/lib/completions/llm.rb index 3dca0c25..741ae653 100644 --- a/lib/completions/llm.rb +++ b/lib/completions/llm.rb @@ -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? diff --git a/spec/system/llms/ai_llm_spec.rb b/spec/system/llms/ai_llm_spec.rb new file mode 100644 index 00000000..371e9977 --- /dev/null +++ b/spec/system/llms/ai_llm_spec.rb @@ -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