1use std::fmt;
14use std::time::Duration;
15
16use serde::Deserialize;
17use serde_json::json;
18
19pub const API_KEY_ENV: &str = "AG_UI_LLM_API_KEY";
21pub const FALLBACK_API_KEY_ENV: &str = "GEMINI_API_KEY";
23pub const BASE_URL_ENV: &str = "AG_UI_LLM_BASE_URL";
25pub const MODEL_ENV: &str = "AG_UI_LLM_MODEL";
27pub const QWEN_BASE_URL_ENV: &str = "QWEN_BASE_URL";
30pub const QWEN_API_KEY_ENV: &str = "QWEN_API_KEY";
32pub const QWEN_MODEL_ENV: &str = "QWEN_MODEL";
34pub const QWEN_DEFAULT_MODEL: &str = "qwen-plus";
36
37pub const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai";
39
40pub const DEFAULT_MODEL: &str = "gemini-2.5-flash-lite";
42
43const SYSTEM: &str = "You are a workshop assistant reporting on a task board. \
46Rewrite the given answer as one friendly sentence. Keep every id, number and \
47task title exactly as they appear. Do not add tasks, invent state, or ask \
48questions. Reply with the sentence and nothing else.";
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct MissingApiKey;
53
54impl fmt::Display for MissingApiKey {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(
57 f,
58 "neither {API_KEY_ENV} nor {FALLBACK_API_KEY_ENV} is set, and {DEFAULT_BASE_URL} needs a key \
59 (set {BASE_URL_ENV} to a local server such as http://localhost:11434/v1 to run without one)"
60 )
61 }
62}
63
64impl std::error::Error for MissingApiKey {}
65
66pub struct Voice {
68 client: reqwest::Client,
69 base_url: String,
70 model: String,
71 api_key: Option<String>,
73}
74
75impl fmt::Debug for Voice {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.debug_struct("Voice")
79 .field("base_url", &self.base_url)
80 .field("model", &self.model)
81 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
82 .finish()
83 }
84}
85
86impl Voice {
87 pub fn from_env() -> Result<Self, MissingApiKey> {
96 let generic_key = var(API_KEY_ENV);
99 let (base_url, model, api_key) = match (var(BASE_URL_ENV), var(QWEN_BASE_URL_ENV)) {
100 (Some(base_url), _) => (
101 base_url,
102 var(MODEL_ENV).unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
103 generic_key
104 .or_else(|| var(FALLBACK_API_KEY_ENV))
105 .or_else(|| var(QWEN_API_KEY_ENV)),
106 ),
107 (None, Some(base_url)) => {
108 let api_key = generic_key.or_else(|| var(QWEN_API_KEY_ENV));
109 if api_key.is_none() {
110 return Err(MissingApiKey);
111 }
112 (
113 base_url,
114 var(MODEL_ENV)
115 .or_else(|| var(QWEN_MODEL_ENV))
116 .unwrap_or_else(|| QWEN_DEFAULT_MODEL.to_owned()),
117 api_key,
118 )
119 }
120 (None, None) => {
121 let api_key = generic_key.or_else(|| var(FALLBACK_API_KEY_ENV));
122 if api_key.is_none() {
123 return Err(MissingApiKey);
124 }
125 (
126 DEFAULT_BASE_URL.to_owned(),
127 var(MODEL_ENV).unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
128 api_key,
129 )
130 }
131 };
132 let base_url = base_url.trim_end_matches('/').to_owned();
133 Ok(Self {
134 client: reqwest::Client::builder()
135 .timeout(Duration::from_secs(30))
136 .build()
137 .unwrap_or_default(),
138 base_url,
139 model,
140 api_key,
141 })
142 }
143
144 pub fn endpoint(&self) -> &str {
147 &self.base_url
148 }
149
150 pub fn model(&self) -> &str {
152 &self.model
153 }
154
155 pub async fn phrase(&self, said: &str, scripted: &str) -> Result<String, String> {
161 let body = json!({
162 "model": self.model,
163 "messages": [
164 {"role": "system", "content": SYSTEM},
165 {"role": "user", "content": format!(
166 "The user typed: {said}\nThe board's answer: {scripted}"
167 )},
168 ],
169 "temperature": 0,
170 });
171
172 let mut request = self
173 .client
174 .post(format!("{}/chat/completions", self.base_url))
175 .json(&body);
176 if let Some(key) = &self.api_key {
177 request = request.bearer_auth(key);
178 }
179
180 let response = request.send().await.map_err(|error| error.to_string())?;
181 let status = response.status();
182 if !status.is_success() {
183 let body = response.text().await.unwrap_or_default();
184 return Err(format!("HTTP {}: {}", status.as_u16(), body.trim()));
185 }
186
187 let completion: Completion = response.json().await.map_err(|error| error.to_string())?;
188 completion
189 .choices
190 .into_iter()
191 .next()
192 .map(|choice| choice.message.content.trim().to_owned())
193 .ok_or_else(|| "the model returned no choices".to_owned())
194 }
195}
196
197fn var(name: &str) -> Option<String> {
199 std::env::var(name)
200 .ok()
201 .map(|value| value.trim().to_owned())
202 .filter(|value| !value.is_empty())
203}
204
205#[derive(Debug, Deserialize)]
207struct Completion {
208 #[serde(default)]
209 choices: Vec<Choice>,
210}
211
212#[derive(Debug, Deserialize)]
213struct Choice {
214 message: ChatMessage,
215}
216
217#[derive(Debug, Deserialize)]
218struct ChatMessage {
219 #[serde(default)]
220 content: String,
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn the_debug_rendering_carries_no_key() {
229 let voice = Voice {
230 client: reqwest::Client::new(),
231 base_url: "http://localhost:11434/v1".to_owned(),
232 model: "qwen3:4b".to_owned(),
233 api_key: Some("sk-do-not-print-me".to_owned()),
234 };
235 let rendered = format!("{voice:?}");
236 assert!(!rendered.contains("sk-do-not-print-me"), "{rendered}");
237 assert!(rendered.contains("<redacted>"), "{rendered}");
238 }
239
240 #[test]
241 fn a_response_with_no_choices_is_not_a_panic() {
242 let completion: Completion = serde_json::from_str("{}").expect("an empty object");
243 assert!(completion.choices.is_empty());
244 }
245}