aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/commands/ai.rs18
-rw-r--r--src/config.rs63
-rw-r--r--src/events/mod.rs18
-rw-r--r--src/lang.rs4
-rw-r--r--src/openrouter/mod.rs88
5 files changed, 117 insertions, 74 deletions
diff --git a/src/commands/ai.rs b/src/commands/ai.rs
index a75f164..5ab077e 100644
--- a/src/commands/ai.rs
+++ b/src/commands/ai.rs
@@ -4,7 +4,7 @@ use crate::lang;
4 4
5type Context<'a> = poise::Context<'a, Data, Error>; 5type Context<'a> = poise::Context<'a, Data, Error>;
6 6
7/// Query the AI senator via OpenRouter 7/// Query the AI senator via OpenRouter or local LLM
8#[poise::command(slash_command)] 8#[poise::command(slash_command)]
9pub async fn senator( 9pub async fn senator(
10 ctx: Context<'_>, 10 ctx: Context<'_>,
@@ -13,20 +13,8 @@ pub async fn senator(
13) -> crate::error::Result<()> { 13) -> crate::error::Result<()> {
14 ctx.defer().await?; 14 ctx.defer().await?;
15 15
16 let api_key = ctx 16 let (api_url, api_key, model) = ctx.data().config.resolve_llm();
17 .data() 17 let client = crate::openrouter::OpenRouterClient::new(api_url, api_key, model);
18 .config
19 .openrouter_api_key
20 .as_ref()
21 .ok_or_else(|| Error::Custom(lang::L_OPENROUTER_NO_KEY.into()))?;
22 let model = ctx
23 .data()
24 .config
25 .openrouter_model
26 .as_deref()
27 .unwrap_or("cognitivecomputations/dolphin-mistral-24b-venice-edition:free");
28
29 let client = crate::openrouter::OpenRouterClient::new(api_key.clone(), model.to_string());
30 let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?; 18 let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?;
31 19
32 if tts_vc.unwrap_or(false) { 20 if tts_vc.unwrap_or(false) {
diff --git a/src/config.rs b/src/config.rs
index 96a6849..989a060 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -10,8 +10,13 @@ pub struct Config {
10 pub copyparty_url: String, 10 pub copyparty_url: String,
11 pub copyparty_url_upload: String, 11 pub copyparty_url_upload: String,
12 pub copyparty_password: Option<String>, 12 pub copyparty_password: Option<String>,
13 /// Deprecated – use llm_api_key instead
13 pub openrouter_api_key: Option<String>, 14 pub openrouter_api_key: Option<String>,
15 /// Deprecated – use llm_model instead
14 pub openrouter_model: Option<String>, 16 pub openrouter_model: Option<String>,
17 pub llm_api_key: Option<String>,
18 pub llm_model: Option<String>,
19 pub llm_api_url: String,
15 pub database_path: String, 20 pub database_path: String,
16} 21}
17 22
@@ -72,6 +77,24 @@ const ENV_VARS: &[EnvVar] = &[
72 description: "OpenRouter model", 77 description: "OpenRouter model",
73 }, 78 },
74 EnvVar { 79 EnvVar {
80 name: "LLM_API_KEY",
81 required: false,
82 default: None,
83 description: "LLM API key (takes priority over OPENROUTER_API_KEY)",
84 },
85 EnvVar {
86 name: "LLM_MODEL",
87 required: false,
88 default: None,
89 description: "LLM model name (takes priority over OPENROUTER_MODEL)",
90 },
91 EnvVar {
92 name: "LLM_API_URL",
93 required: false,
94 default: Some("https://openrouter.ai/api/v1/chat/completions"),
95 description: "LLM API full URL including /chat/completions path",
96 },
97 EnvVar {
75 name: "SENATOR_DB_PATH", 98 name: "SENATOR_DB_PATH",
76 required: false, 99 required: false,
77 default: Some("data/senator.db"), 100 default: Some("data/senator.db"),
@@ -126,6 +149,19 @@ pub fn check_env_vars() {
126 149
127impl Config { 150impl Config {
128 pub fn from_env() -> Self { 151 pub fn from_env() -> Self {
152 let openrouter_api_key = env::var("OPENROUTER_API_KEY").ok();
153 let openrouter_model = env::var("OPENROUTER_MODEL").ok();
154 let llm_api_key = env::var("LLM_API_KEY").ok();
155 let llm_model = env::var("LLM_MODEL").ok();
156
157 // Warn about deprecated env vars (mirrors Python's BACKWARD_COMPAT_MSG)
158 if openrouter_api_key.is_some() && llm_api_key.is_none() {
159 tracing::warn!("OPENROUTER_API_KEY is deprecated. Use LLM_API_KEY instead.");
160 }
161 if openrouter_model.is_some() && llm_model.is_none() {
162 tracing::warn!("OPENROUTER_MODEL is deprecated. Use LLM_MODEL instead.");
163 }
164
129 Self { 165 Self {
130 token: env::var("TOKEN").expect("Missing TOKEN"), 166 token: env::var("TOKEN").expect("Missing TOKEN"),
131 status_channel_id: env::var("STATUS_CHANNEL_ID") 167 status_channel_id: env::var("STATUS_CHANNEL_ID")
@@ -137,9 +173,32 @@ impl Config {
137 copyparty_url_upload: env::var("COPYPARTY_URL_UPLOAD") 173 copyparty_url_upload: env::var("COPYPARTY_URL_UPLOAD")
138 .unwrap_or_else(|_| "http://localhost:3939".into()), 174 .unwrap_or_else(|_| "http://localhost:3939".into()),
139 copyparty_password: env::var("COPYPARTY_PASSWORD").ok(), 175 copyparty_password: env::var("COPYPARTY_PASSWORD").ok(),
140 openrouter_api_key: env::var("OPENROUTER_API_KEY").ok(), 176 openrouter_api_key,
141 openrouter_model: env::var("OPENROUTER_MODEL").ok(), 177 openrouter_model,
178 llm_api_key,
179 llm_model,
180 llm_api_url: env::var("LLM_API_URL")
181 .unwrap_or_else(|_| "https://openrouter.ai/api/v1/chat/completions".into()),
142 database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()), 182 database_path: env::var("SENATOR_DB_PATH").unwrap_or_else(|_| "data/senator.db".into()),
143 } 183 }
144 } 184 }
185
186 /// Resolve the effective LLM config: (api_url, api_key, model).
187 /// LLM_* vars take priority; falls back to OPENROUTER_* for backward compat.
188 pub fn resolve_llm(&self) -> (String, String, String) {
189 let api_url = self.llm_api_url.clone();
190 let api_key = self
191 .llm_api_key
192 .clone()
193 .or_else(|| self.openrouter_api_key.clone())
194 .unwrap_or_default();
195 let model = self
196 .llm_model
197 .clone()
198 .or_else(|| self.openrouter_model.clone())
199 .unwrap_or_else(|| {
200 "cognitivecomputations/dolphin-mistral-24b-venice-edition:free".into()
201 });
202 (api_url, api_key, model)
203 }
145} 204}
diff --git a/src/events/mod.rs b/src/events/mod.rs
index 457ebcd..9a193d0 100644
--- a/src/events/mod.rs
+++ b/src/events/mod.rs
@@ -335,20 +335,22 @@ pub async fn handle_message(
335 let is_mention = new_message.mentions_user_id(ctx.cache.current_user().id); 335 let is_mention = new_message.mentions_user_id(ctx.cache.current_user().id);
336 let has_keyword = content_lower.contains(&data.config.assistant_keyword); 336 let has_keyword = content_lower.contains(&data.config.assistant_keyword);
337 337
338 if (is_mention || has_keyword) && data.config.openrouter_api_key.is_some() { 338 if is_mention || has_keyword {
339 let api_key = data.config.openrouter_api_key.clone().unwrap(); 339 let (api_url, api_key, model) = data.config.resolve_llm();
340 let model = data.config.openrouter_model.clone().unwrap_or_else(|| { 340 let client = crate::openrouter::OpenRouterClient::new(api_url, api_key, model);
341 "cognitivecomputations/dolphin-mistral-24b-venice-edition:free".into()
342 });
343
344 let client = crate::openrouter::OpenRouterClient::new(api_key, model);
345 let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT; 341 let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT;
346 342
343 let bot_id = ctx.cache.current_user().id;
347 let mut messages = Vec::new(); 344 let mut messages = Vec::new();
348 let mut current_msg = Some(new_message.clone()); 345 let mut current_msg = Some(new_message.clone());
349 for _ in 0..5 { 346 for _ in 0..5 {
350 if let Some(msg) = current_msg { 347 if let Some(msg) = current_msg {
351 messages.push(("user".into(), msg.content.clone())); 348 let role = if msg.author.id == bot_id {
349 "assistant"
350 } else {
351 "user"
352 };
353 messages.push((role.into(), msg.content.clone()));
352 current_msg = match msg.message_reference.and_then(|r| r.message_id) { 354 current_msg = match msg.message_reference.and_then(|r| r.message_id) {
353 Some(ref_id) => msg.channel_id.message(ctx, ref_id).await.ok(), 355 Some(ref_id) => msg.channel_id.message(ctx, ref_id).await.ok(),
354 None => None, 356 None => None,
diff --git a/src/lang.rs b/src/lang.rs
index 626f387..b1ed088 100644
--- a/src/lang.rs
+++ b/src/lang.rs
@@ -312,8 +312,8 @@ pub fn fmt_cp_invalid_json(e: &dyn std::fmt::Display, snippet: &str) -> String {
312 312
313// ── OpenRouter ──────────────────────────────────────────────────────── 313// ── OpenRouter ────────────────────────────────────────────────────────
314 314
315pub fn fmt_openrouter_err(status: &dyn std::fmt::Display, text: &str) -> String { 315pub fn fmt_llm_err(endpoint: &str, status: &dyn std::fmt::Display, text: &str) -> String {
316 format!("Błąd API OpenRouter. {status}: {text}") 316 format!("Błąd API LLM ({endpoint}). {status}: {text}")
317} 317}
318 318
319// ── Voice / audio decoding ──────────────────────────────────────────── 319// ── Voice / audio decoding ────────────────────────────────────────────
diff --git a/src/openrouter/mod.rs b/src/openrouter/mod.rs
index 31cee85..1d3b443 100644
--- a/src/openrouter/mod.rs
+++ b/src/openrouter/mod.rs
@@ -1,3 +1,5 @@
1use tracing::info;
2
1use reqwest::Client; 3use reqwest::Client;
2use serde::{Deserialize, Serialize}; 4use serde::{Deserialize, Serialize};
3 5
@@ -7,6 +9,7 @@ use crate::lang;
7pub struct OpenRouterClient { 9pub struct OpenRouterClient {
8 api_key: String, 10 api_key: String,
9 model: String, 11 model: String,
12 api_url: String,
10 client: Client, 13 client: Client,
11} 14}
12 15
@@ -39,14 +42,49 @@ struct MessageContent {
39} 42}
40 43
41impl OpenRouterClient { 44impl OpenRouterClient {
42 pub fn new(api_key: String, model: String) -> Self { 45 /// `api_url` is the full URL including `/chat/completions` path.
46 pub fn new(api_url: String, api_key: String, model: String) -> Self {
47 info!("LLM client: url={api_url}, model={model}");
43 Self { 48 Self {
44 api_key, 49 api_key,
45 model, 50 model,
51 api_url,
46 client: Client::new(), 52 client: Client::new(),
47 } 53 }
48 } 54 }
49 55
56 async fn do_request(&self, body: &ChatRequest) -> Result<String> {
57 let mut req = self
58 .client
59 .post(&self.api_url)
60 .header("Content-Type", "application/json")
61 .json(body);
62
63 if !self.api_key.is_empty() {
64 req = req.header("Authorization", format!("Bearer {}", self.api_key));
65 }
66
67 let resp = req.send().await?;
68
69 if !resp.status().is_success() {
70 let status = resp.status();
71 let text = resp.text().await.unwrap_or_default();
72 return Err(crate::error::Error::Custom(lang::fmt_llm_err(
73 &self.api_url,
74 &status,
75 &text,
76 )));
77 }
78
79 let chat_resp: ChatResponse = resp.json().await?;
80 Ok(chat_resp
81 .choices
82 .into_iter()
83 .next()
84 .map(|c| c.message.content)
85 .unwrap_or_default())
86 }
87
50 pub async fn chat(&self, system_prompt: &str, user_message: &str) -> Result<String> { 88 pub async fn chat(&self, system_prompt: &str, user_message: &str) -> Result<String> {
51 let body = ChatRequest { 89 let body = ChatRequest {
52 model: self.model.clone(), 90 model: self.model.clone(),
@@ -63,29 +101,7 @@ impl OpenRouterClient {
63 max_tokens: 500, 101 max_tokens: 500,
64 }; 102 };
65 103
66 let resp = self 104 self.do_request(&body).await
67 .client
68 .post("https://openrouter.ai/api/v1/chat/completions")
69 .header("Authorization", format!("Bearer {}", self.api_key))
70 .json(&body)
71 .send()
72 .await?;
73
74 if !resp.status().is_success() {
75 let status = resp.status();
76 let text = resp.text().await.unwrap_or_default();
77 return Err(crate::error::Error::Custom(lang::fmt_openrouter_err(
78 &status, &text,
79 )));
80 }
81
82 let chat_resp: ChatResponse = resp.json().await?;
83 Ok(chat_resp
84 .choices
85 .into_iter()
86 .next()
87 .map(|c| c.message.content)
88 .unwrap_or_default())
89 } 105 }
90 106
91 pub async fn chat_with_history( 107 pub async fn chat_with_history(
@@ -107,28 +123,6 @@ impl OpenRouterClient {
107 max_tokens: 500, 123 max_tokens: 500,
108 }; 124 };
109 125
110 let resp = self 126 self.do_request(&body).await
111 .client
112 .post("https://openrouter.ai/api/v1/chat/completions")
113 .header("Authorization", format!("Bearer {}", self.api_key))
114 .json(&body)
115 .send()
116 .await?;
117
118 if !resp.status().is_success() {
119 let status = resp.status();
120 let text = resp.text().await.unwrap_or_default();
121 return Err(crate::error::Error::Custom(lang::fmt_openrouter_err(
122 &status, &text,
123 )));
124 }
125
126 let chat_resp: ChatResponse = resp.json().await?;
127 Ok(chat_resp
128 .choices
129 .into_iter()
130 .next()
131 .map(|c| c.message.content)
132 .unwrap_or_default())
133 } 127 }
134} 128}