Skip to main content

chaiss_core/
llm.rs

1use flume::Sender;
2use futures::StreamExt;
3use llm::{
4    builder::{LLMBackend, LLMBuilder},
5    chat::{ChatMessage, ReasoningEffort},
6};
7
8#[derive(Clone, Debug)]
9pub struct LlmPromptPayload {
10    pub prompt: String,
11    pub current_fen: String,
12    pub ascii_board: String,
13    pub algebraic_history: Vec<String>,
14    pub chat_history: Vec<(String, String)>,
15    pub predictive_matrix_hotspots: Vec<String>,
16    pub system_role: String,
17}
18
19/// Default model per backend when `LLM_MODEL` is not set.
20pub const DEFAULT_GOOGLE_MODEL: &str = "gemini-3.7-flash";
21pub const DEFAULT_OPENAI_MODEL: &str = "gpt-4-turbo";
22pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-3-opus-20240229";
23pub const DEFAULT_OLLAMA_MODEL: &str = "llama3";
24
25/// An LLM failure split into what the user should read and what a developer
26/// needs for troubleshooting.
27///
28/// `user_message` is short, free of protocol jargon, and safe to render in the
29/// chat panel. `detail` carries the raw backend error (status, JSON body, ...)
30/// and is meant for the console log only.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct LlmError {
33    pub user_message: String,
34    pub detail: String,
35}
36
37impl std::fmt::Display for LlmError {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.write_str(&self.user_message)
40    }
41}
42
43impl std::error::Error for LlmError {}
44
45impl LlmError {
46    fn new(user_message: impl Into<String>, detail: impl Into<String>) -> Self {
47        Self {
48            user_message: user_message.into(),
49            detail: detail.into(),
50        }
51    }
52}
53
54/// Extract the first HTTP status code mentioned in a raw backend error, e.g.
55/// `"... returned error status: 503 Service Unavailable ..."` or a JSON body
56/// containing `"code": 503`.
57fn extract_http_status(raw: &str) -> Option<u16> {
58    let lower = raw.to_ascii_lowercase();
59    for marker in [
60        "error status: ",
61        "\"code\": ",
62        "\"code\":",
63        "status code ",
64        "status: ",
65    ] {
66        if let Some(pos) = lower.find(marker) {
67            let digits: String = lower[pos + marker.len()..]
68                .trim_start()
69                .chars()
70                .take_while(|c| c.is_ascii_digit())
71                .collect();
72            if digits.len() == 3 {
73                if let Ok(code) = digits.parse::<u16>() {
74                    return Some(code);
75                }
76            }
77        }
78    }
79    None
80}
81
82/// Translate a raw backend/transport error into an [`LlmError`] whose
83/// `user_message` explains what happened in plain language while `detail`
84/// preserves the original text for the console.
85pub fn classify_backend_error(provider: &str, model: &str, raw: impl Into<String>) -> LlmError {
86    let raw = raw.into();
87    let lower = raw.to_ascii_lowercase();
88    let target = format!("{model} ({provider})");
89    let status = extract_http_status(&raw);
90
91    let overloaded = status == Some(503)
92        || lower.contains("unavailable")
93        || lower.contains("high demand")
94        || lower.contains("overloaded");
95    let rate_limited = status == Some(429)
96        || lower.contains("resource_exhausted")
97        || lower.contains("rate limit")
98        || lower.contains("quota");
99    let unauthorized = matches!(status, Some(401) | Some(403))
100        || lower.contains("api key not valid")
101        || lower.contains("permission_denied")
102        || lower.contains("unauthenticated");
103    let not_found = status == Some(404) || lower.contains("not_found");
104    let server_error =
105        matches!(status, Some(500) | Some(502) | Some(504)) || lower.contains("internal error");
106    let network = lower.contains("timed out")
107        || lower.contains("timeout")
108        || lower.contains("connection")
109        || lower.contains("dns")
110        || lower.contains("network");
111
112    let user_message = if overloaded {
113        format!(
114            "The AI backend {target} is temporarily unavailable due to high demand. \
115             Spikes are usually short-lived — please try again in a moment."
116        )
117    } else if rate_limited {
118        format!(
119            "The AI backend {target} is rate-limiting requests or your quota is exhausted. \
120             Please wait a little before retrying, or check your plan and billing."
121        )
122    } else if unauthorized {
123        format!(
124            "The AI backend {target} rejected the API key. \
125             Please check the key in your `.env` file and restart the application."
126        )
127    } else if not_found {
128        format!(
129            "The model {target} was not found. \
130             Please check `LLM_MODEL` in your `.env` file — the model may have been renamed or retired."
131        )
132    } else if server_error {
133        format!(
134            "The AI backend {target} reported an internal error. \
135             This is on the provider's side — please try again shortly."
136        )
137    } else if network {
138        format!(
139            "Could not reach the AI backend {target}. \
140             Please check your network connection and try again."
141        )
142    } else {
143        format!(
144            "The AI backend {target} returned an unexpected error. See the console for details."
145        )
146    };
147
148    LlmError::new(user_message, raw)
149}
150
151// Orchestrate non-blocking HTTP REST streaming against the configured backend (Gemini by default).
152pub async fn stream_llm_response(
153    payload: LlmPromptPayload,
154    tx: Sender<String>,
155) -> Result<(), LlmError> {
156    let llm_backend_str = std::env::var("LLM_BACKEND")
157        .unwrap_or_else(|_| "google".to_string())
158        .to_lowercase();
159
160    let (backend_enum, provider_name, api_key_env, default_model) = match llm_backend_str.as_str() {
161        "openai" => (
162            LLMBackend::OpenAI,
163            "OpenAI",
164            "OPENAI_API_KEY",
165            DEFAULT_OPENAI_MODEL,
166        ),
167        "anthropic" => (
168            LLMBackend::Anthropic,
169            "Anthropic",
170            "ANTHROPIC_API_KEY",
171            DEFAULT_ANTHROPIC_MODEL,
172        ),
173        "ollama" => (LLMBackend::Ollama, "Ollama", "", DEFAULT_OLLAMA_MODEL), // Local testing fallback
174        _ => (
175            LLMBackend::Google,
176            "Google",
177            "GOOGLE_API_KEY",
178            DEFAULT_GOOGLE_MODEL,
179        ),
180    };
181
182    // `LLM_MODEL` overrides the per-backend default without a rebuild.
183    let model = std::env::var("LLM_MODEL")
184        .ok()
185        .map(|m| m.trim().to_string())
186        .filter(|m| !m.is_empty())
187        .unwrap_or_else(|| default_model.to_string());
188
189    // Google publishes the key as GEMINI_API_KEY in most of its docs; accept both spellings.
190    let api_key = std::env::var(api_key_env)
191        .or_else(|_| {
192            if backend_enum == LLMBackend::Google {
193                std::env::var("GEMINI_API_KEY")
194            } else {
195                Err(std::env::VarError::NotPresent)
196            }
197        })
198        .unwrap_or_else(|_| "TESTKEY".to_string());
199
200    // Validate we actually have a key mapped, else error gracefully without crashing!
201    // Skip key check for Ollama / Local backends
202    if api_key == "TESTKEY" && backend_enum != LLMBackend::Ollama {
203        return Err(LlmError::new(
204            format!(
205                "No API key found for the {provider_name} backend. \
206                 Please set `{api_key_env}` in your `.env` file and restart the application."
207            ),
208            format!("environment variable {api_key_env} is not set (backend: {llm_backend_str})"),
209        ));
210    }
211
212    let mut builder = LLMBuilder::new()
213        .api_key(api_key.clone())
214        .model(&model)
215        .max_tokens(8000)
216        .temperature(0.7);
217
218    if backend_enum == LLMBackend::Google {
219        builder = builder
220            .backend(LLMBackend::OpenAI)
221            .base_url("https://generativelanguage.googleapis.com/v1beta/openai/")
222            .reasoning_effort(ReasoningEffort::High);
223    } else {
224        builder = builder.backend(backend_enum);
225    }
226
227    let llm = builder
228        .build()
229        .map_err(|e| {
230            LlmError::new(
231                format!("Could not initialise the {provider_name} client for model {model}. See the console for details."),
232                format!("Failed LLM Build: {e:?}"),
233            )
234        })?;
235
236    let fen_parts: Vec<&str> = payload.current_fen.split_whitespace().collect();
237    let is_white_turn = fen_parts.get(1).is_none_or(|&p| p == "w");
238    let active_color = if is_white_turn { "WHITE" } else { "BLACK" };
239
240    // 1. Build context mathematically formatting history and explicit ASCII layouts securely
241    let formatted_history: String = payload
242        .algebraic_history
243        .iter()
244        .enumerate()
245        .map(|(i, mov)| format!("{}. {}", i, mov))
246        .collect::<Vec<_>>()
247        .join("\n");
248    let mut futuristic_foresight = String::new();
249    if !payload.predictive_matrix_hotspots.is_empty() {
250        futuristic_foresight = format!(
251            "\n\nCRITICAL CONTEXT INJECTION:\nThe Rust Engine's 2nd-Order Predictive Matrix natively resolved that the following squares will become the MOST densely contested structural targets 1-ply into the future: {}\nIncorporate this absolute mathematical foresight organically into your conceptual strategic analysis!",
252            payload.predictive_matrix_hotspots.join(", ")
253        );
254    }
255
256    let system_prompt = format!(
257        "You are Chaiss, an advanced Chess {} mathematically bound to geometrical analysis.\n\n\
258Current FEN String:\n{}\n\n\
259Structural ASCII Board Matrix:\n{}\n\n\
260Full Explicit Match Algebraic Sequence:\n{}\n\n\
261The geometry currently dictates it is {}'s turn to move. \
262Critically evaluate physical piece interactions natively, recognize structural blunders explicitly, and predict future hostile pressure correctly. Focus your analysis purely geometrically tracking explicit pawn structure and piece coordination sequentially over time. The user provides algebraic prompts.{}\n\n\
263FORMATTING CONSTRAINT: Respond in plain Markdown only. NEVER use LaTeX or math notation — no $ or $$ delimiters and no backslash commands such as \\text or \\quad. Write chess moves as plain standard algebraic notation text.\n\n\
264CRITICALLY BINDING REQUIREMENT: At the mathematical conclusion of your analysis, you MUST provide exactly one hypothesized continuation line up to 4 plies deep recursively, formatted distinctly exactly on a single line like this:\n\
265### PREDICTIVE MATRIX: e4, e5, Nf3, Nc6",
266        payload.system_role,
267        payload.current_fen,
268        payload.ascii_board,
269        formatted_history,
270        active_color,
271        futuristic_foresight
272    );
273
274    // 2. Synthesize Context Matrix iteratively mimicking continuous API session strings cleanly
275    let mut messages = vec![ChatMessage::user().content(&system_prompt).build()];
276
277    // Inject a dummy acknowledgment so Gemini mathematically anchors the System constraints before our formal chat!
278    messages.push(ChatMessage::assistant().content("System Context Acknowledged. I am mathematically bound to the supplied FEN bounds.").build());
279
280    for (role, content) in payload.chat_history {
281        if role == "User" {
282            messages.push(ChatMessage::user().content(&content).build());
283        } else {
284            messages.push(ChatMessage::assistant().content(&content).build());
285        }
286    }
287
288    // Inject the final active mathematical Prompt
289    messages.push(ChatMessage::user().content(&payload.prompt).build());
290
291    let mut stream = llm.chat_stream(&messages).await.map_err(|e| {
292        classify_backend_error(provider_name, &model, format!("Chat Stream err: {e}"))
293    })?;
294
295    while let Some(result) = stream.next().await {
296        match result {
297            Ok(token) => {
298                let _ = tx.send_async(token).await;
299            }
300            Err(e) => {
301                return Err(classify_backend_error(
302                    provider_name,
303                    &model,
304                    format!("Network Stream Disconnected Abruptly: {e}"),
305                ));
306            }
307        }
308    }
309
310    Ok(())
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    const GEMINI_503: &str = "Chat Stream err: Response Format Error: OpenAI API returned error status: 503 Service Unavailable. Raw response: [{ \"error\": { \"code\": 503, \"message\": \"This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.\", \"status\": \"UNAVAILABLE\" } } ]";
318
319    #[test]
320    fn extracts_status_from_crate_error_text() {
321        assert_eq!(extract_http_status(GEMINI_503), Some(503));
322        assert_eq!(
323            extract_http_status("OpenAI API returned error status: 429 Too Many Requests"),
324            Some(429)
325        );
326        assert_eq!(extract_http_status("{ \"code\": 404 }"), Some(404));
327        assert_eq!(extract_http_status("connection reset by peer"), None);
328    }
329
330    #[test]
331    fn gemini_high_demand_maps_to_friendly_message_and_keeps_detail() {
332        let err = classify_backend_error("Google", "gemini-3.7-flash", GEMINI_503);
333        assert!(err.user_message.contains("temporarily unavailable"));
334        assert!(err.user_message.contains("gemini-3.7-flash (Google)"));
335        assert!(!err.user_message.contains("503"));
336        assert_eq!(err.detail, GEMINI_503);
337        assert_eq!(err.to_string(), err.user_message);
338    }
339
340    #[test]
341    fn classifies_other_statuses() {
342        let m = |raw: &str| classify_backend_error("Google", "m", raw).user_message;
343        assert!(m("error status: 429 Too Many Requests").contains("rate-limiting"));
344        assert!(m("error status: 401 Unauthorized").contains("rejected the API key"));
345        assert!(m("error status: 403 Forbidden").contains("rejected the API key"));
346        assert!(m("error status: 404 Not Found").contains("was not found"));
347        assert!(m("error status: 500 Internal Server Error").contains("internal error"));
348        assert!(m("error status: 502 Bad Gateway").contains("internal error"));
349        assert!(m("request timed out").contains("Could not reach"));
350        assert!(m("something entirely different").contains("unexpected error"));
351    }
352}