FEATURE: first class support for OpenRouter (#1011)

* FEATURE: first class support for OpenRouter

This new implementation supports picking quantization and provider pref

Also:

- Improve logging for summary generation
- Improve error message when contacting LLMs fails

* Better support for full screen artifacts on iPad

Support back button to close full screen
This commit is contained in:
Sam 2024-12-10 05:59:19 +11:00 committed by GitHub
parent 51e0a96e51
commit 7ca21cc329
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 192 additions and 44 deletions

View File

@ -15,6 +15,7 @@ class AiApiAuditLog < ActiveRecord::Base
Ollama = 7 Ollama = 7
SambaNova = 8 SambaNova = 8
Mistral = 9 Mistral = 9
OpenRouter = 10
end end
def next_log_id def next_log_id

View File

@ -47,6 +47,11 @@ class LlmModel < ActiveRecord::Base
disable_system_prompt: :checkbox, disable_system_prompt: :checkbox,
enable_native_tool: :checkbox, enable_native_tool: :checkbox,
}, },
open_router: {
disable_native_tools: :checkbox,
provider_order: :text,
provider_quantizations: :text,
},
} }
end end

View File

@ -1,12 +1,16 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { service } from "@ember/service"; import { service } from "@ember/service";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import htmlClass from "discourse/helpers/html-class"; import htmlClass from "discourse/helpers/html-class";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
// note the panel for artifact full screen can not be at position 0,0
// otherwise this hack will not activate.
// https://github.com/discourse/discourse/blob/b8325f2190a8c0a9022405c219faeac6f0f98ca5/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js#L77-L77
// this will cause post stream to navigate to a different post
export default class AiArtifactComponent extends Component { export default class AiArtifactComponent extends Component {
@service siteSettings; @service siteSettings;
@tracked expanded = false; @tracked expanded = false;
@ -15,17 +19,29 @@ export default class AiArtifactComponent extends Component {
constructor() { constructor() {
super(...arguments); super(...arguments);
this.keydownHandler = this.handleKeydown.bind(this); this.keydownHandler = this.handleKeydown.bind(this);
this.popStateHandler = this.handlePopState.bind(this);
window.addEventListener("popstate", this.popStateHandler);
} }
willDestroy() { willDestroy() {
super.willDestroy(...arguments); super.willDestroy(...arguments);
window.removeEventListener("keydown", this.keydownHandler); window.removeEventListener("keydown", this.keydownHandler);
window.removeEventListener("popstate", this.popStateHandler);
} }
@action @action
handleKeydown(event) { handleKeydown(event) {
if (event.key === "Escape" || event.key === "Esc") { if (event.key === "Escape" || event.key === "Esc") {
this.expanded = false; history.back();
}
}
@action
handlePopState(event) {
const state = event.state;
this.expanded = state?.artifactId === this.args.artifactId;
if (!this.expanded) {
window.removeEventListener("keydown", this.keydownHandler);
} }
} }
@ -52,12 +68,17 @@ export default class AiArtifactComponent extends Component {
@action @action
toggleView() { toggleView() {
this.expanded = !this.expanded; if (!this.expanded) {
if (this.expanded) { window.history.pushState(
{ artifactId: this.args.artifactId },
"",
window.location.href + "#artifact-fullscreen"
);
window.addEventListener("keydown", this.keydownHandler); window.addEventListener("keydown", this.keydownHandler);
} else { } else {
window.removeEventListener("keydown", this.keydownHandler); history.back();
} }
this.expanded = !this.expanded;
} }
get wrapperClasses() { get wrapperClasses() {
@ -66,25 +87,12 @@ export default class AiArtifactComponent extends Component {
}`; }`;
} }
@action
artifactPanelHover() {
// retrrigger animation
const panel = document.querySelector(".ai-artifact__panel");
panel.style.animation = "none"; // Stop the animation
setTimeout(() => {
panel.style.animation = ""; // Re-trigger the animation by removing the none style
}, 0);
}
<template> <template>
{{#if this.expanded}} {{#if this.expanded}}
{{htmlClass "ai-artifact-expanded"}} {{htmlClass "ai-artifact-expanded"}}
{{/if}} {{/if}}
<div class={{this.wrapperClasses}}> <div class={{this.wrapperClasses}}>
<div <div class="ai-artifact__panel--wrapper">
class="ai-artifact__panel--wrapper"
{{on "mouseleave" this.artifactPanelHover}}
>
<div class="ai-artifact__panel"> <div class="ai-artifact__panel">
<DButton <DButton
class="btn-flat btn-icon-text" class="btn-flat btn-icon-text"

View File

@ -37,27 +37,21 @@ html.ai-artifact-expanded {
} }
.ai-artifact__panel--wrapper { .ai-artifact__panel--wrapper {
display: block;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 2em;
right: 0; right: 2em;
height: 4em; height: 2em;
z-index: 1000000; z-index: 1000000;
&:hover { animation: vanishing 0.5s 3s forwards;
.ai-artifact__panel {
transform: translateY(0) !important;
animation: none;
}
}
} }
.ai-artifact__panel { .ai-artifact__panel {
display: block; display: block;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 2em;
right: 0; right: 2em;
height: 2em; height: 2em;
transition: transform 0.5s ease-in-out; transition: transform 0.5s ease-in-out;
animation: slideUp 0.5s 3s forwards; animation: slideUp 0.5s 3s forwards;
@ -75,7 +69,6 @@ html.ai-artifact-expanded {
.d-icon { .d-icon {
color: var(--secondary-high); color: var(--secondary-high);
} }
//color: var(--secondary-vary-low);
} }
} }
} }
@ -85,6 +78,12 @@ html.ai-artifact-expanded {
} }
} }
@keyframes vanishing {
to {
display: none;
}
}
iframe { iframe {
position: fixed; position: fixed;
top: 0; top: 0;

View File

@ -351,6 +351,7 @@ en:
CDCK: "CDCK" CDCK: "CDCK"
samba_nova: "SambaNova" samba_nova: "SambaNova"
mistral: "Mistral" mistral: "Mistral"
open_router: "OpenRouter"
fake: "Custom" fake: "Custom"
provider_fields: provider_fields:
@ -360,6 +361,8 @@ en:
disable_system_prompt: "Disable system message in prompts" disable_system_prompt: "Disable system message in prompts"
enable_native_tool: "Enable native tool support" enable_native_tool: "Enable native tool support"
disable_native_tools: "Disable native tool support (use XML based tools)" disable_native_tools: "Disable native tool support (use XML based tools)"
provider_order: "Provider order (comma delimited list)"
provider_quantizations: "Order of provider quantizations (comma delimited list eg: fp16,fp8)"
related_topics: related_topics:
title: "Related topics" title: "Related topics"
@ -436,7 +439,7 @@ en:
ai_artifact: ai_artifact:
expand_view_label: "Expand view" expand_view_label: "Expand view"
collapse_view_label: "Exit Fullscreen (ESC)" collapse_view_label: "Exit Fullscreen (ESC or Back button)"
click_to_run_label: "Run Artifact" click_to_run_label: "Run Artifact"
ai_bot: ai_bot:

View File

@ -252,6 +252,7 @@ en:
failed_to_share: "Failed to share the conversation" failed_to_share: "Failed to share the conversation"
conversation_deleted: "Conversation share deleted successfully" conversation_deleted: "Conversation share deleted successfully"
ai_bot: ai_bot:
reply_error: "Sorry, it looks like our system encountered an unexpected issue while trying to reply.\n\n[details='Error details']\n%{details}\n[/details]"
default_pm_prefix: "[Untitled AI bot PM]" default_pm_prefix: "[Untitled AI bot PM]"
personas: personas:
default_llm_required: "Default LLM model is required prior to enabling Chat" default_llm_required: "Default LLM model is required prior to enabling Chat"

View File

@ -533,14 +533,26 @@ module DiscourseAi
reply_post.post_custom_prompt.update!(custom_prompt: prompt) reply_post.post_custom_prompt.update!(custom_prompt: prompt)
end end
reply_post
rescue => e
if reply_post
details = e.message.to_s
reply = "#{reply}\n\n#{I18n.t("discourse_ai.ai_bot.reply_error", details: details)}"
reply_post.revise(
bot.bot_user,
{ raw: reply },
skip_validations: true,
skip_revision: true,
)
end
raise e
ensure
# since we are skipping validations and jobs we # since we are skipping validations and jobs we
# may need to fix participant count # may need to fix participant count
if reply_post.topic.private_message? && reply_post.topic.participant_count < 2 if reply_post && reply_post.topic && reply_post.topic.private_message? &&
reply_post.topic.participant_count < 2
reply_post.topic.update!(participant_count: 2) reply_post.topic.update!(participant_count: 2)
end end
reply_post
ensure
post_streamer&.finish(skip_callback: true) post_streamer&.finish(skip_callback: true)
publish_final_update(reply_post) if stream_reply publish_final_update(reply_post) if stream_reply
if reply_post && post.post_number == 1 && post.topic.private_message? if reply_post && post.post_number == 1 && post.topic.private_message?

View File

@ -6,7 +6,8 @@ module DiscourseAi
class ChatGpt < Dialect class ChatGpt < Dialect
class << self class << self
def can_translate?(llm_model) def can_translate?(llm_model)
llm_model.provider == "open_ai" || llm_model.provider == "azure" llm_model.provider == "open_router" || llm_model.provider == "open_ai" ||
llm_model.provider == "azure"
end end
end end

View File

@ -21,6 +21,7 @@ module DiscourseAi
DiscourseAi::Completions::Endpoints::Cohere, DiscourseAi::Completions::Endpoints::Cohere,
DiscourseAi::Completions::Endpoints::SambaNova, DiscourseAi::Completions::Endpoints::SambaNova,
DiscourseAi::Completions::Endpoints::Mistral, DiscourseAi::Completions::Endpoints::Mistral,
DiscourseAi::Completions::Endpoints::OpenRouter,
] ]
endpoints << DiscourseAi::Completions::Endpoints::Ollama if Rails.env.development? endpoints << DiscourseAi::Completions::Endpoints::Ollama if Rails.env.development?

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
module DiscourseAi
module Completions
module Endpoints
class OpenRouter < OpenAi
def self.can_contact?(model_provider)
%w[open_router].include?(model_provider)
end
def prepare_request(payload)
headers = { "Content-Type" => "application/json" }
api_key = llm_model.api_key
headers["Authorization"] = "Bearer #{api_key}"
headers["X-Title"] = "Discourse AI"
headers["HTTP-Referer"] = "https://www.discourse.org/ai"
Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload }
end
def prepare_payload(prompt, model_params, dialect)
payload = super
if quantizations = llm_model.provider_params["provider_quantizations"].presence
options = quantizations.split(",").map(&:strip)
payload[:provider] = { quantizations: options }
end
if order = llm_model.provider_params["provider_order"].presence
options = order.split(",").map(&:strip)
payload[:provider] ||= {}
payload[:provider][:order] = options
end
payload
end
end
end
end
end

View File

@ -108,6 +108,24 @@ module DiscourseAi
endpoint: "https://api.mistral.ai/v1/chat/completions", endpoint: "https://api.mistral.ai/v1/chat/completions",
provider: "mistral", provider: "mistral",
}, },
{
id: "open_router",
models: [
{
name: "meta-llama/llama-3.3-70b-instruct",
tokens: 128_000,
display_name: "Llama 3.3 70B",
},
{
name: "google/gemini-flash-1.5-exp",
tokens: 1_000_000,
display_name: "Gemini Flash 1.5 Exp",
},
],
tokenizer: DiscourseAi::Tokenizer::OpenAiTokenizer,
endpoint: "https://openrouter.ai/api/v1/chat/completions",
provider: "open_router",
},
] ]
end end
end end
@ -124,6 +142,7 @@ module DiscourseAi
azure azure
samba_nova samba_nova
mistral mistral
open_router
] ]
if !Rails.env.production? if !Rails.env.production?
providers << "fake" providers << "fake"

View File

@ -64,7 +64,7 @@ module DiscourseAi
.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " } .map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }
.join("\n") .join("\n")
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip) prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip, topic_id: target.id)
You are an advanced summarization bot. Your task is to update an existing single-sentence summary by integrating new developments from a conversation. You are an advanced summarization bot. Your task is to update an existing single-sentence summary by integrating new developments from a conversation.
Analyze the most recent messages to identify key updates or shifts in the main topic and reflect these in the updated summary. Analyze the most recent messages to identify key updates or shifts in the main topic and reflect these in the updated summary.
Emphasize new significant information or developments within the context of the initial conversation theme. Emphasize new significant information or developments within the context of the initial conversation theme.
@ -103,7 +103,7 @@ module DiscourseAi
statements = statements =
contents.to_a.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " } contents.to_a.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip) prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip, topic_id: target.id)
You are an advanced summarization bot. Analyze a given conversation and produce a concise, You are an advanced summarization bot. Analyze a given conversation and produce a concise,
single-sentence summary that conveys the main topic and current developments to someone with no prior context. single-sentence summary that conveys the main topic and current developments to someone with no prior context.
@ -124,9 +124,9 @@ module DiscourseAi
### Context: ### Context:
#{content_title.present? ? "The discussion title is: " + content_title + ". (DO NOT REPEAT THIS IN THE SUMMARY)\n" : ""} #{content_title.present? ? "The discussion title is: " + content_title + ". (DO NOT REPEAT THIS IN THE SUMMARY)\n" : ""}
The conversation began with the following statement: The conversation began with the following statement:
#{statements.shift}\n #{statements.shift}\n
TEXT TEXT

View File

@ -33,7 +33,7 @@ module DiscourseAi
input = input =
contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]})" }.join contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]})" }.join
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT) prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT, topic_id: target.id)
You are an advanced summarization bot tasked with enhancing an existing summary by incorporating additional posts. You are an advanced summarization bot tasked with enhancing an existing summary by incorporating additional posts.
### Guidelines: ### Guidelines:
@ -76,7 +76,7 @@ module DiscourseAi
input = input =
contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join
prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip) prompt = DiscourseAi::Completions::Prompt.new(<<~TEXT.strip, topic_id: target.id)
You are an advanced summarization bot that generates concise, coherent summaries of provided text. You are an advanced summarization bot that generates concise, coherent summaries of provided text.
- Only include the summary, without any additional commentary. - Only include the summary, without any additional commentary.

View File

@ -28,6 +28,15 @@ Fabricator(:hf_model, from: :llm_model) do
provider "hugging_face" provider "hugging_face"
end end
Fabricator(:open_router_model, from: :llm_model) do
display_name "OpenRouter"
name "openrouter-1.0"
provider "open_router"
tokenizer "DiscourseAi::Tokenizer::OpenAiTokenizer"
max_prompt_tokens 64_000
url "https://openrouter.ai/api/v1/chat/completions"
end
Fabricator(:vllm_model, from: :llm_model) do Fabricator(:vllm_model, from: :llm_model) do
display_name "Llama 3.1 vLLM" display_name "Llama 3.1 vLLM"
name "meta-llama/Meta-Llama-3.1-70B-Instruct" name "meta-llama/Meta-Llama-3.1-70B-Instruct"

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::Completions::Endpoints::OpenRouter do
fab!(:user)
fab!(:open_router_model)
subject(:endpoint) { described_class.new(open_router_model) }
it "supports provider quantization and order selection" do
open_router_model.provider_params["provider_quantizations"] = "int8,int16"
open_router_model.provider_params["provider_order"] = "Google, Amazon Bedrock"
open_router_model.save!
parsed_body = nil
stub_request(:post, open_router_model.url).with(
body: proc { |body| parsed_body = JSON.parse(body, symbolize_names: true) },
headers: {
"Content-Type" => "application/json",
"X-Title" => "Discourse AI",
"HTTP-Referer" => "https://www.discourse.org/ai",
"Authorization" => "Bearer 123",
},
).to_return(
status: 200,
body: { "choices" => [message: { role: "assistant", content: "world" }] }.to_json,
)
proxy = DiscourseAi::Completions::Llm.proxy("custom:#{open_router_model.id}")
result = proxy.generate("hello", user: user)
expect(result).to eq("world")
expected = {
model: "openrouter-1.0",
messages: [
{ role: "system", content: "You are a helpful bot" },
{ role: "user", content: "hello" },
],
provider: {
quantizations: %w[int8 int16],
order: ["Google", "Amazon Bedrock"],
},
}
expect(parsed_body).to eq(expected)
end
end