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
19pub 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#[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
54fn 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
82pub 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
151pub 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), _ => (
175 LLMBackend::Google,
176 "Google",
177 "GOOGLE_API_KEY",
178 DEFAULT_GOOGLE_MODEL,
179 ),
180 };
181
182 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 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 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 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 let mut messages = vec![ChatMessage::user().content(&system_prompt).build()];
276
277 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 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}