Skip to main content

task_board/
llm.rs

1//! Optional: let a model phrase the reply.
2//!
3//! Off unless `serve --llm` is asked for *and* a key is in the environment.
4//! The board itself never goes near the model — ids, counts and state
5//! transitions are computed in [`crate::agent`] and the model is handed the
6//! finished sentence to rewrite. That is deliberate: a dogfood app whose
7//! assertions depend on a model is a dogfood app that cannot be tested.
8//!
9//! Same wire format and the same environment variables as `e2e/src/llm.rs`, and
10//! the same point: an OpenAI-compatible `/chat/completions` endpoint reached
11//! with `reqwest` and two `serde` structs. There is no LLM crate here either.
12
13use std::fmt;
14use std::time::Duration;
15
16use serde::Deserialize;
17use serde_json::json;
18
19/// The environment variable holding the API key.
20pub const API_KEY_ENV: &str = "AG_UI_LLM_API_KEY";
21/// Read when [`API_KEY_ENV`] is unset — the default endpoint is Gemini's.
22pub const FALLBACK_API_KEY_ENV: &str = "GEMINI_API_KEY";
23/// The environment variable holding the base URL.
24pub const BASE_URL_ENV: &str = "AG_UI_LLM_BASE_URL";
25/// The environment variable holding the model id.
26pub const MODEL_ENV: &str = "AG_UI_LLM_MODEL";
27/// Qwen Cloud's OpenAI-compatible mode, read when [`BASE_URL_ENV`] is unset:
28/// the base URL, its key, and its model, in that order.
29pub const QWEN_BASE_URL_ENV: &str = "QWEN_BASE_URL";
30/// The key that goes with [`QWEN_BASE_URL_ENV`].
31pub const QWEN_API_KEY_ENV: &str = "QWEN_API_KEY";
32/// The model that goes with [`QWEN_BASE_URL_ENV`], when [`MODEL_ENV`] is unset.
33pub const QWEN_MODEL_ENV: &str = "QWEN_MODEL";
34/// The Qwen model used when [`QWEN_MODEL_ENV`] is unset. Pinned, like the default.
35pub const QWEN_DEFAULT_MODEL: &str = "qwen-plus";
36
37/// Where requests go unless [`BASE_URL_ENV`] says otherwise.
38pub const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai";
39
40/// The model this talks to by default. Pinned, never a `*-latest` alias.
41pub const DEFAULT_MODEL: &str = "gemini-2.5-flash-lite";
42
43/// How the model is told to behave. Short on purpose: it is rewriting one
44/// sentence, not running the board.
45const 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/// [`Voice::from_env`] found no API key, and the endpoint needs one.
51#[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
66/// A model that rephrases the agent's replies.
67pub struct Voice {
68    client: reqwest::Client,
69    base_url: String,
70    model: String,
71    /// Absent for a local server that wants no credential.
72    api_key: Option<String>,
73}
74
75// Hand-written so the key cannot reach a log through `{:?}`.
76impl 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    /// Reads the endpoint, model and key from the environment.
88    ///
89    /// A custom [`BASE_URL_ENV`] is taken to mean a server the caller runs, so
90    /// a missing key there is not an error.
91    ///
92    /// # Errors
93    ///
94    /// [`MissingApiKey`] when the default endpoint would be used without one.
95    pub fn from_env() -> Result<Self, MissingApiKey> {
96        // AG_UI_LLM_BASE_URL wins outright; QWEN_BASE_URL picks Qwen Cloud with
97        // its own key and model; the default endpoint needs a key.
98        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    /// The endpoint this is pointed at. Carries no credential — the key is a
145    /// header.
146    pub fn endpoint(&self) -> &str {
147        &self.base_url
148    }
149
150    /// The model this is pointed at.
151    pub fn model(&self) -> &str {
152        &self.model
153    }
154
155    /// Rewrites `scripted` in the model's own words.
156    ///
157    /// The error is a plain string because the only caller formats it into a
158    /// `REASONING_*` block and carries on with the scripted sentence — there is
159    /// nothing to match on.
160    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
197/// A set, non-blank environment variable.
198fn 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/// Only the two fields this needs; everything else on the response is ignored.
206#[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}