use flume::Sender;
use futures::StreamExt;
use llm::{
builder::{LLMBackend, LLMBuilder},
chat::{ChatMessage, ReasoningEffort},
};
#[derive(Clone, Debug)]
pub struct LlmPromptPayload {
pub prompt: String,
pub current_fen: String,
pub ascii_board: String,
pub algebraic_history: Vec<String>,
pub chat_history: Vec<(String, String)>,
pub predictive_matrix_hotspots: Vec<String>,
pub system_role: String,
}
pub const DEFAULT_GOOGLE_MODEL: &str = "gemini-3.7-flash";
pub const DEFAULT_OPENAI_MODEL: &str = "gpt-4-turbo";
pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-3-opus-20240229";
pub const DEFAULT_OLLAMA_MODEL: &str = "llama3";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LlmError {
pub user_message: String,
pub detail: String,
}
impl std::fmt::Display for LlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.user_message)
}
}
impl std::error::Error for LlmError {}
impl LlmError {
fn new(user_message: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
user_message: user_message.into(),
detail: detail.into(),
}
}
}
fn extract_http_status(raw: &str) -> Option<u16> {
let lower = raw.to_ascii_lowercase();
for marker in [
"error status: ",
"\"code\": ",
"\"code\":",
"status code ",
"status: ",
] {
if let Some(pos) = lower.find(marker) {
let digits: String = lower[pos + marker.len()..]
.trim_start()
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if digits.len() == 3 {
if let Ok(code) = digits.parse::<u16>() {
return Some(code);
}
}
}
}
None
}
pub fn classify_backend_error(provider: &str, model: &str, raw: impl Into<String>) -> LlmError {
let raw = raw.into();
let lower = raw.to_ascii_lowercase();
let target = format!("{model} ({provider})");
let status = extract_http_status(&raw);
let overloaded = status == Some(503)
|| lower.contains("unavailable")
|| lower.contains("high demand")
|| lower.contains("overloaded");
let rate_limited = status == Some(429)
|| lower.contains("resource_exhausted")
|| lower.contains("rate limit")
|| lower.contains("quota");
let unauthorized = matches!(status, Some(401) | Some(403))
|| lower.contains("api key not valid")
|| lower.contains("permission_denied")
|| lower.contains("unauthenticated");
let not_found = status == Some(404) || lower.contains("not_found");
let server_error =
matches!(status, Some(500) | Some(502) | Some(504)) || lower.contains("internal error");
let network = lower.contains("timed out")
|| lower.contains("timeout")
|| lower.contains("connection")
|| lower.contains("dns")
|| lower.contains("network");
let user_message = if overloaded {
format!(
"The AI backend {target} is temporarily unavailable due to high demand. \
Spikes are usually short-lived — please try again in a moment."
)
} else if rate_limited {
format!(
"The AI backend {target} is rate-limiting requests or your quota is exhausted. \
Please wait a little before retrying, or check your plan and billing."
)
} else if unauthorized {
format!(
"The AI backend {target} rejected the API key. \
Please check the key in your `.env` file and restart the application."
)
} else if not_found {
format!(
"The model {target} was not found. \
Please check `LLM_MODEL` in your `.env` file — the model may have been renamed or retired."
)
} else if server_error {
format!(
"The AI backend {target} reported an internal error. \
This is on the provider's side — please try again shortly."
)
} else if network {
format!(
"Could not reach the AI backend {target}. \
Please check your network connection and try again."
)
} else {
format!(
"The AI backend {target} returned an unexpected error. See the console for details."
)
};
LlmError::new(user_message, raw)
}
pub async fn stream_llm_response(
payload: LlmPromptPayload,
tx: Sender<String>,
) -> Result<(), LlmError> {
let llm_backend_str = std::env::var("LLM_BACKEND")
.unwrap_or_else(|_| "google".to_string())
.to_lowercase();
let (backend_enum, provider_name, api_key_env, default_model) = match llm_backend_str.as_str() {
"openai" => (
LLMBackend::OpenAI,
"OpenAI",
"OPENAI_API_KEY",
DEFAULT_OPENAI_MODEL,
),
"anthropic" => (
LLMBackend::Anthropic,
"Anthropic",
"ANTHROPIC_API_KEY",
DEFAULT_ANTHROPIC_MODEL,
),
"ollama" => (LLMBackend::Ollama, "Ollama", "", DEFAULT_OLLAMA_MODEL), _ => (
LLMBackend::Google,
"Google",
"GOOGLE_API_KEY",
DEFAULT_GOOGLE_MODEL,
),
};
let model = std::env::var("LLM_MODEL")
.ok()
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
.unwrap_or_else(|| default_model.to_string());
let api_key = std::env::var(api_key_env)
.or_else(|_| {
if backend_enum == LLMBackend::Google {
std::env::var("GEMINI_API_KEY")
} else {
Err(std::env::VarError::NotPresent)
}
})
.unwrap_or_else(|_| "TESTKEY".to_string());
if api_key == "TESTKEY" && backend_enum != LLMBackend::Ollama {
return Err(LlmError::new(
format!(
"No API key found for the {provider_name} backend. \
Please set `{api_key_env}` in your `.env` file and restart the application."
),
format!("environment variable {api_key_env} is not set (backend: {llm_backend_str})"),
));
}
let mut builder = LLMBuilder::new()
.api_key(api_key.clone())
.model(&model)
.max_tokens(8000)
.temperature(0.7);
if backend_enum == LLMBackend::Google {
builder = builder
.backend(LLMBackend::OpenAI)
.base_url("https://generativelanguage.googleapis.com/v1beta/openai/")
.reasoning_effort(ReasoningEffort::High);
} else {
builder = builder.backend(backend_enum);
}
let llm = builder
.build()
.map_err(|e| {
LlmError::new(
format!("Could not initialise the {provider_name} client for model {model}. See the console for details."),
format!("Failed LLM Build: {e:?}"),
)
})?;
let fen_parts: Vec<&str> = payload.current_fen.split_whitespace().collect();
let is_white_turn = fen_parts.get(1).is_none_or(|&p| p == "w");
let active_color = if is_white_turn { "WHITE" } else { "BLACK" };
let formatted_history: String = payload
.algebraic_history
.iter()
.enumerate()
.map(|(i, mov)| format!("{}. {}", i, mov))
.collect::<Vec<_>>()
.join("\n");
let mut futuristic_foresight = String::new();
if !payload.predictive_matrix_hotspots.is_empty() {
futuristic_foresight = format!(
"\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!",
payload.predictive_matrix_hotspots.join(", ")
);
}
let system_prompt = format!(
"You are Chaiss, an advanced Chess {} mathematically bound to geometrical analysis.\n\n\
Current FEN String:\n{}\n\n\
Structural ASCII Board Matrix:\n{}\n\n\
Full Explicit Match Algebraic Sequence:\n{}\n\n\
The geometry currently dictates it is {}'s turn to move. \
Critically 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\
FORMATTING 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\
CRITICALLY 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\
### PREDICTIVE MATRIX: e4, e5, Nf3, Nc6",
payload.system_role,
payload.current_fen,
payload.ascii_board,
formatted_history,
active_color,
futuristic_foresight
);
let mut messages = vec![ChatMessage::user().content(&system_prompt).build()];
messages.push(ChatMessage::assistant().content("System Context Acknowledged. I am mathematically bound to the supplied FEN bounds.").build());
for (role, content) in payload.chat_history {
if role == "User" {
messages.push(ChatMessage::user().content(&content).build());
} else {
messages.push(ChatMessage::assistant().content(&content).build());
}
}
messages.push(ChatMessage::user().content(&payload.prompt).build());
let mut stream = llm.chat_stream(&messages).await.map_err(|e| {
classify_backend_error(provider_name, &model, format!("Chat Stream err: {e}"))
})?;
while let Some(result) = stream.next().await {
match result {
Ok(token) => {
let _ = tx.send_async(token).await;
}
Err(e) => {
return Err(classify_backend_error(
provider_name,
&model,
format!("Network Stream Disconnected Abruptly: {e}"),
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
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\" } } ]";
#[test]
fn extracts_status_from_crate_error_text() {
assert_eq!(extract_http_status(GEMINI_503), Some(503));
assert_eq!(
extract_http_status("OpenAI API returned error status: 429 Too Many Requests"),
Some(429)
);
assert_eq!(extract_http_status("{ \"code\": 404 }"), Some(404));
assert_eq!(extract_http_status("connection reset by peer"), None);
}
#[test]
fn gemini_high_demand_maps_to_friendly_message_and_keeps_detail() {
let err = classify_backend_error("Google", "gemini-3.7-flash", GEMINI_503);
assert!(err.user_message.contains("temporarily unavailable"));
assert!(err.user_message.contains("gemini-3.7-flash (Google)"));
assert!(!err.user_message.contains("503"));
assert_eq!(err.detail, GEMINI_503);
assert_eq!(err.to_string(), err.user_message);
}
#[test]
fn classifies_other_statuses() {
let m = |raw: &str| classify_backend_error("Google", "m", raw).user_message;
assert!(m("error status: 429 Too Many Requests").contains("rate-limiting"));
assert!(m("error status: 401 Unauthorized").contains("rejected the API key"));
assert!(m("error status: 403 Forbidden").contains("rejected the API key"));
assert!(m("error status: 404 Not Found").contains("was not found"));
assert!(m("error status: 500 Internal Server Error").contains("internal error"));
assert!(m("error status: 502 Bad Gateway").contains("internal error"));
assert!(m("request timed out").contains("Could not reach"));
assert!(m("something entirely different").contains("unexpected error"));
}
}