Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 7ca21cc

Browse files
authored
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
1 parent 51e0a96 commit 7ca21cc

File tree

15 files changed

+192
-44
lines changed

15 files changed

+192
-44
lines changed

app/models/ai_api_audit_log.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module Provider
1515
Ollama = 7
1616
SambaNova = 8
1717
Mistral = 9
18+
OpenRouter = 10
1819
end
1920

2021
def next_log_id

app/models/llm_model.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ def self.provider_params
4747
disable_system_prompt: :checkbox,
4848
enable_native_tool: :checkbox,
4949
},
50+
open_router: {
51+
disable_native_tools: :checkbox,
52+
provider_order: :text,
53+
provider_quantizations: :text,
54+
},
5055
}
5156
end
5257

assets/javascripts/discourse/components/ai-artifact.gjs

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import Component from "@glimmer/component";
22
import { tracked } from "@glimmer/tracking";
3-
import { on } from "@ember/modifier";
43
import { action } from "@ember/object";
54
import { service } from "@ember/service";
65
import DButton from "discourse/components/d-button";
76
import htmlClass from "discourse/helpers/html-class";
87
import getURL from "discourse-common/lib/get-url";
98

9+
// note the panel for artifact full screen can not be at position 0,0
10+
// otherwise this hack will not activate.
11+
// https://github.com/discourse/discourse/blob/b8325f2190a8c0a9022405c219faeac6f0f98ca5/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js#L77-L77
12+
// this will cause post stream to navigate to a different post
13+
1014
export default class AiArtifactComponent extends Component {
1115
@service siteSettings;
1216
@tracked expanded = false;
@@ -15,17 +19,29 @@ export default class AiArtifactComponent extends Component {
1519
constructor() {
1620
super(...arguments);
1721
this.keydownHandler = this.handleKeydown.bind(this);
22+
this.popStateHandler = this.handlePopState.bind(this);
23+
window.addEventListener("popstate", this.popStateHandler);
1824
}
1925

2026
willDestroy() {
2127
super.willDestroy(...arguments);
2228
window.removeEventListener("keydown", this.keydownHandler);
29+
window.removeEventListener("popstate", this.popStateHandler);
2330
}
2431

2532
@action
2633
handleKeydown(event) {
2734
if (event.key === "Escape" || event.key === "Esc") {
28-
this.expanded = false;
35+
history.back();
36+
}
37+
}
38+
39+
@action
40+
handlePopState(event) {
41+
const state = event.state;
42+
this.expanded = state?.artifactId === this.args.artifactId;
43+
if (!this.expanded) {
44+
window.removeEventListener("keydown", this.keydownHandler);
2945
}
3046
}
3147

@@ -52,12 +68,17 @@ export default class AiArtifactComponent extends Component {
5268

5369
@action
5470
toggleView() {
55-
this.expanded = !this.expanded;
56-
if (this.expanded) {
71+
if (!this.expanded) {
72+
window.history.pushState(
73+
{ artifactId: this.args.artifactId },
74+
"",
75+
window.location.href + "#artifact-fullscreen"
76+
);
5777
window.addEventListener("keydown", this.keydownHandler);
5878
} else {
59-
window.removeEventListener("keydown", this.keydownHandler);
79+
history.back();
6080
}
81+
this.expanded = !this.expanded;
6182
}
6283

6384
get wrapperClasses() {
@@ -66,25 +87,12 @@ export default class AiArtifactComponent extends Component {
6687
}`;
6788
}
6889

69-
@action
70-
artifactPanelHover() {
71-
// retrrigger animation
72-
const panel = document.querySelector(".ai-artifact__panel");
73-
panel.style.animation = "none"; // Stop the animation
74-
setTimeout(() => {
75-
panel.style.animation = ""; // Re-trigger the animation by removing the none style
76-
}, 0);
77-
}
78-
7990
<template>
8091
{{#if this.expanded}}
8192
{{htmlClass "ai-artifact-expanded"}}
8293
{{/if}}
8394
<div class={{this.wrapperClasses}}>
84-
<div
85-
class="ai-artifact__panel--wrapper"
86-
{{on "mouseleave" this.artifactPanelHover}}
87-
>
95+
<div class="ai-artifact__panel--wrapper">
8896
<div class="ai-artifact__panel">
8997
<DButton
9098
class="btn-flat btn-icon-text"

assets/stylesheets/modules/ai-bot/common/ai-artifact.scss

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,27 +37,21 @@ html.ai-artifact-expanded {
3737
}
3838

3939
.ai-artifact__panel--wrapper {
40-
display: block;
4140
position: fixed;
4241
top: 0;
43-
left: 0;
44-
right: 0;
45-
height: 4em;
42+
left: 2em;
43+
right: 2em;
44+
height: 2em;
4645
z-index: 1000000;
47-
&:hover {
48-
.ai-artifact__panel {
49-
transform: translateY(0) !important;
50-
animation: none;
51-
}
52-
}
46+
animation: vanishing 0.5s 3s forwards;
5347
}
5448

5549
.ai-artifact__panel {
5650
display: block;
5751
position: fixed;
5852
top: 0;
59-
left: 0;
60-
right: 0;
53+
left: 2em;
54+
right: 2em;
6155
height: 2em;
6256
transition: transform 0.5s ease-in-out;
6357
animation: slideUp 0.5s 3s forwards;
@@ -75,7 +69,6 @@ html.ai-artifact-expanded {
7569
.d-icon {
7670
color: var(--secondary-high);
7771
}
78-
//color: var(--secondary-vary-low);
7972
}
8073
}
8174
}
@@ -85,6 +78,12 @@ html.ai-artifact-expanded {
8578
}
8679
}
8780

81+
@keyframes vanishing {
82+
to {
83+
display: none;
84+
}
85+
}
86+
8887
iframe {
8988
position: fixed;
9089
top: 0;

config/locales/client.en.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ en:
351351
CDCK: "CDCK"
352352
samba_nova: "SambaNova"
353353
mistral: "Mistral"
354+
open_router: "OpenRouter"
354355
fake: "Custom"
355356

356357
provider_fields:
@@ -360,6 +361,8 @@ en:
360361
disable_system_prompt: "Disable system message in prompts"
361362
enable_native_tool: "Enable native tool support"
362363
disable_native_tools: "Disable native tool support (use XML based tools)"
364+
provider_order: "Provider order (comma delimited list)"
365+
provider_quantizations: "Order of provider quantizations (comma delimited list eg: fp16,fp8)"
363366

364367
related_topics:
365368
title: "Related topics"
@@ -436,7 +439,7 @@ en:
436439

437440
ai_artifact:
438441
expand_view_label: "Expand view"
439-
collapse_view_label: "Exit Fullscreen (ESC)"
442+
collapse_view_label: "Exit Fullscreen (ESC or Back button)"
440443
click_to_run_label: "Run Artifact"
441444

442445
ai_bot:

config/locales/server.en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ en:
252252
failed_to_share: "Failed to share the conversation"
253253
conversation_deleted: "Conversation share deleted successfully"
254254
ai_bot:
255+
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]"
255256
default_pm_prefix: "[Untitled AI bot PM]"
256257
personas:
257258
default_llm_required: "Default LLM model is required prior to enabling Chat"

lib/ai_bot/playground.rb

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -533,14 +533,26 @@ def reply_to(post, custom_instructions: nil, &blk)
533533
reply_post.post_custom_prompt.update!(custom_prompt: prompt)
534534
end
535535

536+
reply_post
537+
rescue => e
538+
if reply_post
539+
details = e.message.to_s
540+
reply = "#{reply}\n\n#{I18n.t("discourse_ai.ai_bot.reply_error", details: details)}"
541+
reply_post.revise(
542+
bot.bot_user,
543+
{ raw: reply },
544+
skip_validations: true,
545+
skip_revision: true,
546+
)
547+
end
548+
raise e
549+
ensure
536550
# since we are skipping validations and jobs we
537551
# may need to fix participant count
538-
if reply_post.topic.private_message? && reply_post.topic.participant_count < 2
552+
if reply_post && reply_post.topic && reply_post.topic.private_message? &&
553+
reply_post.topic.participant_count < 2
539554
reply_post.topic.update!(participant_count: 2)
540555
end
541-
542-
reply_post
543-
ensure
544556
post_streamer&.finish(skip_callback: true)
545557
publish_final_update(reply_post) if stream_reply
546558
if reply_post && post.post_number == 1 && post.topic.private_message?

lib/completions/dialects/chat_gpt.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ module Dialects
66
class ChatGpt < Dialect
77
class << self
88
def can_translate?(llm_model)
9-
llm_model.provider == "open_ai" || llm_model.provider == "azure"
9+
llm_model.provider == "open_router" || llm_model.provider == "open_ai" ||
10+
llm_model.provider == "azure"
1011
end
1112
end
1213

lib/completions/endpoints/base.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def endpoint_for(provider_name)
2121
DiscourseAi::Completions::Endpoints::Cohere,
2222
DiscourseAi::Completions::Endpoints::SambaNova,
2323
DiscourseAi::Completions::Endpoints::Mistral,
24+
DiscourseAi::Completions::Endpoints::OpenRouter,
2425
]
2526

2627
endpoints << DiscourseAi::Completions::Endpoints::Ollama if Rails.env.development?
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module Completions
5+
module Endpoints
6+
class OpenRouter < OpenAi
7+
def self.can_contact?(model_provider)
8+
%w[open_router].include?(model_provider)
9+
end
10+
11+
def prepare_request(payload)
12+
headers = { "Content-Type" => "application/json" }
13+
api_key = llm_model.api_key
14+
15+
headers["Authorization"] = "Bearer #{api_key}"
16+
headers["X-Title"] = "Discourse AI"
17+
headers["HTTP-Referer"] = "https://www.discourse.org/ai"
18+
19+
Net::HTTP::Post.new(model_uri, headers).tap { |r| r.body = payload }
20+
end
21+
22+
def prepare_payload(prompt, model_params, dialect)
23+
payload = super
24+
25+
if quantizations = llm_model.provider_params["provider_quantizations"].presence
26+
options = quantizations.split(",").map(&:strip)
27+
28+
payload[:provider] = { quantizations: options }
29+
end
30+
31+
if order = llm_model.provider_params["provider_order"].presence
32+
options = order.split(",").map(&:strip)
33+
payload[:provider] ||= {}
34+
payload[:provider][:order] = options
35+
end
36+
37+
payload
38+
end
39+
end
40+
end
41+
end
42+
end

0 commit comments

Comments
 (0)