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:
parent
51e0a96e51
commit
7ca21cc329
|
@ -15,6 +15,7 @@ class AiApiAuditLog < ActiveRecord::Base
|
|||
Ollama = 7
|
||||
SambaNova = 8
|
||||
Mistral = 9
|
||||
OpenRouter = 10
|
||||
end
|
||||
|
||||
def next_log_id
|
||||
|
|
|
@ -47,6 +47,11 @@ class LlmModel < ActiveRecord::Base
|
|||
disable_system_prompt: :checkbox,
|
||||
enable_native_tool: :checkbox,
|
||||
},
|
||||
open_router: {
|
||||
disable_native_tools: :checkbox,
|
||||
provider_order: :text,
|
||||
provider_quantizations: :text,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import htmlClass from "discourse/helpers/html-class";
|
||||
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 {
|
||||
@service siteSettings;
|
||||
@tracked expanded = false;
|
||||
|
@ -15,17 +19,29 @@ export default class AiArtifactComponent extends Component {
|
|||
constructor() {
|
||||
super(...arguments);
|
||||
this.keydownHandler = this.handleKeydown.bind(this);
|
||||
this.popStateHandler = this.handlePopState.bind(this);
|
||||
window.addEventListener("popstate", this.popStateHandler);
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
window.removeEventListener("keydown", this.keydownHandler);
|
||||
window.removeEventListener("popstate", this.popStateHandler);
|
||||
}
|
||||
|
||||
@action
|
||||
handleKeydown(event) {
|
||||
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
|
||||
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);
|
||||
} else {
|
||||
window.removeEventListener("keydown", this.keydownHandler);
|
||||
history.back();
|
||||
}
|
||||
this.expanded = !this.expanded;
|
||||
}
|
||||
|
||||
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>
|
||||
{{#if this.expanded}}
|
||||
{{htmlClass "ai-artifact-expanded"}}
|
||||
{{/if}}
|
||||
<div class={{this.wrapperClasses}}>
|
||||
<div
|
||||
class="ai-artifact__panel--wrapper"
|
||||
{{on "mouseleave" this.artifactPanelHover}}
|
||||
>
|
||||
<div class="ai-artifact__panel--wrapper">
|
||||
<div class="ai-artifact__panel">
|
||||
<DButton
|
||||
class="btn-flat btn-icon-text"
|
||||
|
|
|
@ -37,27 +37,21 @@ html.ai-artifact-expanded {
|
|||
}
|
||||
|
||||
.ai-artifact__panel--wrapper {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4em;
|
||||
left: 2em;
|
||||
right: 2em;
|
||||
height: 2em;
|
||||
z-index: 1000000;
|
||||
&:hover {
|
||||
.ai-artifact__panel {
|
||||
transform: translateY(0) !important;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
animation: vanishing 0.5s 3s forwards;
|
||||
}
|
||||
|
||||
.ai-artifact__panel {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
left: 2em;
|
||||
right: 2em;
|
||||
height: 2em;
|
||||
transition: transform 0.5s ease-in-out;
|
||||
animation: slideUp 0.5s 3s forwards;
|
||||
|
@ -75,7 +69,6 @@ html.ai-artifact-expanded {
|
|||
.d-icon {
|
||||
color: var(--secondary-high);
|
||||
}
|
||||
//color: var(--secondary-vary-low);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -85,6 +78,12 @@ html.ai-artifact-expanded {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes vanishing {
|
||||
to {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
iframe {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
|
|
@ -351,6 +351,7 @@ en:
|
|||
CDCK: "CDCK"
|
||||
samba_nova: "SambaNova"
|
||||
mistral: "Mistral"
|
||||
open_router: "OpenRouter"
|
||||
fake: "Custom"
|
||||
|
||||
provider_fields:
|
||||
|
@ -360,6 +361,8 @@ en:
|
|||
disable_system_prompt: "Disable system message in prompts"
|
||||
enable_native_tool: "Enable native tool support"
|
||||
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:
|
||||
title: "Related topics"
|
||||
|
@ -436,7 +439,7 @@ en:
|
|||
|
||||
ai_artifact:
|
||||
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"
|
||||
|
||||
ai_bot:
|
||||
|
|
|
@ -252,6 +252,7 @@ en:
|
|||
failed_to_share: "Failed to share the conversation"
|
||||
conversation_deleted: "Conversation share deleted successfully"
|
||||
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]"
|
||||
personas:
|
||||
default_llm_required: "Default LLM model is required prior to enabling Chat"
|
||||
|
|
|
@ -533,14 +533,26 @@ module DiscourseAi
|
|||
reply_post.post_custom_prompt.update!(custom_prompt: prompt)
|
||||
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
|
||||
# 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)
|
||||
end
|
||||
|
||||
reply_post
|
||||
ensure
|
||||
post_streamer&.finish(skip_callback: true)
|
||||
publish_final_update(reply_post) if stream_reply
|
||||
if reply_post && post.post_number == 1 && post.topic.private_message?
|
||||
|
|
|
@ -6,7 +6,8 @@ module DiscourseAi
|
|||
class ChatGpt < Dialect
|
||||
class << self
|
||||
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
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ module DiscourseAi
|
|||
DiscourseAi::Completions::Endpoints::Cohere,
|
||||
DiscourseAi::Completions::Endpoints::SambaNova,
|
||||
DiscourseAi::Completions::Endpoints::Mistral,
|
||||
DiscourseAi::Completions::Endpoints::OpenRouter,
|
||||
]
|
||||
|
||||
endpoints << DiscourseAi::Completions::Endpoints::Ollama if Rails.env.development?
|
||||
|
|
|
@ -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
|
|
@ -108,6 +108,24 @@ module DiscourseAi
|
|||
endpoint: "https://api.mistral.ai/v1/chat/completions",
|
||||
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
|
||||
|
@ -124,6 +142,7 @@ module DiscourseAi
|
|||
azure
|
||||
samba_nova
|
||||
mistral
|
||||
open_router
|
||||
]
|
||||
if !Rails.env.production?
|
||||
providers << "fake"
|
||||
|
|
|
@ -64,7 +64,7 @@ module DiscourseAi
|
|||
.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }
|
||||
.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.
|
||||
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.
|
||||
|
@ -103,7 +103,7 @@ module DiscourseAi
|
|||
statements =
|
||||
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,
|
||||
single-sentence summary that conveys the main topic and current developments to someone with no prior context.
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ module DiscourseAi
|
|||
input =
|
||||
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.
|
||||
|
||||
### Guidelines:
|
||||
|
@ -76,7 +76,7 @@ module DiscourseAi
|
|||
input =
|
||||
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.
|
||||
|
||||
- Only include the summary, without any additional commentary.
|
||||
|
|
|
@ -28,6 +28,15 @@ Fabricator(:hf_model, from: :llm_model) do
|
|||
provider "hugging_face"
|
||||
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
|
||||
display_name "Llama 3.1 vLLM"
|
||||
name "meta-llama/Meta-Llama-3.1-70B-Instruct"
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue