use std::time::Duration;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use super::embedder::detect_hardware;
use crate::LlmInference;
use crate::{MemoryError, Result};
#[derive(Debug, Clone)]
pub struct KnownModel {
pub ollama_tag: &'static str,
pub ram_mb: u64,
pub description: &'static str,
}
pub const KNOWN_MODELS: &[KnownModel] = &[
KnownModel {
ollama_tag: "llama3.3:70b",
ram_mb: 45_600,
description: "Llama 3.3 70B — haut de gamme, qualité supérieure à 3.1:70b, GPU requis",
},
KnownModel {
ollama_tag: "gemma3:27b",
ram_mb: 22_500,
description: "Gemma 3 27B — top open-source multimodal Google 2026",
},
KnownModel {
ollama_tag: "qwen3:32b",
ram_mb: 22_200,
description: "Qwen 3 32B — raisonnement avancé, top open-source 2026",
},
KnownModel {
ollama_tag: "devstral:24b",
ram_mb: 14_400,
description: "Devstral 24B — Mistral, spécialisé agents et génération de code",
},
KnownModel {
ollama_tag: "gemma3:12b",
ram_mb: 12_400,
description: "Gemma 3 12B — excellent multilingue, code et instruction-following",
},
KnownModel {
ollama_tag: "qwen3:14b",
ram_mb: 10_700,
description: "Qwen 3 14B — excellent code + raisonnement, recommandé milieu de gamme",
},
KnownModel {
ollama_tag: "deepseek-r1:14b",
ram_mb: 9_500,
description: "DeepSeek-R1 14B distill — chain-of-thought, très précis en maths/code",
},
KnownModel {
ollama_tag: "phi4:14b",
ram_mb: 9_000,
description: "Phi-4 14B Microsoft — fort raisonnement dans un modèle compact",
},
KnownModel {
ollama_tag: "mistral-nemo:12b",
ram_mb: 7_200,
description: "Mistral Nemo 12B — généraliste, fenêtre de contexte 128k",
},
KnownModel {
ollama_tag: "llama3.3:8b",
ram_mb: 6_200,
description: "Llama 3.3 8B — qualité supérieure à 3.1:8b, même empreinte",
},
KnownModel {
ollama_tag: "llama3.1:8b",
ram_mb: 5_800,
description: "Llama 3.1 8B — très répandu, fenêtre 128k, toujours pertinent",
},
KnownModel {
ollama_tag: "qwen3:8b",
ram_mb: 5_100,
description: "Qwen 3 8B — excellent code et raisonnement, compact",
},
KnownModel {
ollama_tag: "deepseek-r1:7b",
ram_mb: 4_500,
description: "DeepSeek-R1 7B distill — raisonnement chain-of-thought compact",
},
KnownModel {
ollama_tag: "mistral:7b",
ram_mb: 4_100,
description: "Mistral 7B — référence CPU, très répandu, solide",
},
KnownModel {
ollama_tag: "qwen3:4b",
ram_mb: 3_600,
description: "Qwen 3 4B — excellent rapport qualité/RAM 2026, meilleur choix léger",
},
KnownModel {
ollama_tag: "gemma3:4b",
ram_mb: 3_000,
description: "Gemma 3 4B — multilingue, solide sur CPU",
},
KnownModel {
ollama_tag: "phi4-mini",
ram_mb: 2_800,
description: "Phi-4 Mini 3.8B — fort raisonnement sur CPU, successeur de phi3.5",
},
KnownModel {
ollama_tag: "llama3.2:3b",
ram_mb: 2_000,
description: "Llama 3.2 3B — basse mémoire, contexte multimodal",
},
KnownModel {
ollama_tag: "gemma2:2b",
ram_mb: 1_400,
description: "Gemma 2 2B — très léger, qualité correcte",
},
KnownModel {
ollama_tag: "llama3.2:1b",
ram_mb: 700,
description: "Llama 3.2 1B — minimaliste, CPU très contraint",
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum BackendKind {
Ollama,
LmStudio,
Jan,
Gpt4All,
KoboldCpp,
Vllm,
LocalAi,
AnythingLlm,
OpenAiCompat,
}
#[derive(Debug, Clone)]
pub struct LlmOption {
pub model_id: String,
pub server_url: String,
pub backend: BackendKind,
pub ram_mb: Option<u64>,
}
#[derive(Deserialize)]
struct OllamaTagsResponse {
models: Vec<OllamaModelEntry>,
}
#[derive(Deserialize)]
struct OllamaModelEntry {
name: String,
}
#[derive(Deserialize)]
struct OpenAiModelsResponse {
data: Vec<OpenAiModelEntry>,
}
#[derive(Deserialize)]
struct OpenAiModelEntry {
id: String,
}
const DETECT_TIMEOUT: Duration = Duration::from_secs(1);
pub async fn detect_llm_options() -> Vec<LlmOption> {
let client = Client::builder().timeout(DETECT_TIMEOUT).build().unwrap_or_default();
let mut out = Vec::new();
out.extend(probe_ollama(&client, "http://localhost:11434").await);
out.extend(probe_openai_compat(&client, "http://localhost:1234", BackendKind::LmStudio).await);
out.extend(probe_openai_compat(&client, "http://localhost:1337", BackendKind::Jan).await);
out.extend(probe_openai_compat(&client, "http://localhost:4891", BackendKind::Gpt4All).await);
out.extend(probe_openai_compat(&client, "http://localhost:5001", BackendKind::KoboldCpp).await);
out.extend(probe_openai_compat(&client, "http://localhost:8000", BackendKind::Vllm).await);
out.extend(probe_openai_compat(&client, "http://localhost:8080", BackendKind::LocalAi).await);
out.extend(probe_anythingllm(&client, "http://localhost:3001").await);
out
}
async fn probe_ollama(client: &Client, base_url: &str) -> Vec<LlmOption> {
let url = format!("{base_url}/api/tags");
let Ok(resp) = client.get(&url).send().await else {
return Vec::new();
};
let Ok(body) = resp.json::<OllamaTagsResponse>().await else {
return Vec::new();
};
body.models
.into_iter()
.map(|m| LlmOption {
ram_mb: ram_for(&m.name),
model_id: m.name,
server_url: base_url.to_string(),
backend: BackendKind::Ollama,
})
.collect()
}
async fn probe_openai_compat(client: &Client, base_url: &str, kind: BackendKind) -> Vec<LlmOption> {
let url = format!("{base_url}/v1/models");
let Ok(resp) = client.get(&url).send().await else {
return Vec::new();
};
let Ok(body) = resp.json::<OpenAiModelsResponse>().await else {
return Vec::new();
};
body.data
.into_iter()
.map(|m| LlmOption {
ram_mb: ram_for(&m.id),
model_id: m.id,
server_url: base_url.to_string(),
backend: kind.clone(),
})
.collect()
}
async fn probe_anythingllm(client: &Client, base_url: &str) -> Vec<LlmOption> {
let url = format!("{base_url}/api/ping");
let Ok(resp) = client.get(&url).send().await else {
return Vec::new();
};
if !resp.status().is_success() {
return Vec::new();
}
vec![LlmOption {
model_id: "anythingllm".to_string(),
server_url: base_url.to_string(),
backend: BackendKind::AnythingLlm,
ram_mb: None,
}]
}
fn ram_for(tag: &str) -> Option<u64> {
KNOWN_MODELS
.iter()
.find(|m| tag.starts_with(m.ollama_tag) || m.ollama_tag.starts_with(tag))
.map(|m| m.ram_mb)
}
#[must_use]
pub fn best_llm_option(options: &[LlmOption]) -> Option<&LlmOption> {
let hw = detect_hardware();
let budget_mb = hw.gpu_vram_mb.map(|v| v * 9 / 10).unwrap_or(hw.total_ram_mb * 6 / 10);
options
.iter()
.filter(|o| o.ram_mb.is_some_and(|r| r <= budget_mb))
.max_by_key(|o| o.ram_mb)
}
#[must_use]
pub fn propose_models_to_install(installed: &[LlmOption]) -> Vec<&'static KnownModel> {
let hw = detect_hardware();
let budget_mb = hw.gpu_vram_mb.map(|v| v * 9 / 10).unwrap_or(hw.total_ram_mb * 6 / 10);
let installed_ids: Vec<&str> = installed.iter().map(|o| o.model_id.as_str()).collect();
KNOWN_MODELS
.iter()
.filter(|m| m.ram_mb <= budget_mb && !installed_ids.iter().any(|id| id.starts_with(m.ollama_tag)))
.collect()
}
pub struct LlmProvision {
pub backend: Box<dyn LlmInference>,
pub model_id: String,
pub ram_mb: Option<u64>,
}
pub async fn choose_llm() -> Result<LlmProvision> {
let options = detect_llm_options().await;
if let Some(opt) = best_llm_option(&options) {
return Ok(LlmProvision {
backend: Box::new(OpenAiCompatBackend::new(&opt.server_url, &opt.model_id)),
model_id: opt.model_id.clone(),
ram_mb: opt.ram_mb,
});
}
if let Some(backend) = anythingllm_from_env() {
let model_id = backend.workspace_slug.clone();
return Ok(LlmProvision {
backend: Box::new(backend),
model_id,
ram_mb: None,
});
}
let has_anythingllm = options.iter().any(|o| o.backend == BackendKind::AnythingLlm);
let usable: Vec<_> = options.iter().filter(|o| o.ram_mb.is_some()).collect();
let hint = if usable.is_empty() {
if has_anythingllm {
"AnythingLLM détecté (port 3001). Pour l'utiliser comme backend d'inférence, \
définissez BASEMYAI_ANYTHINGLLM_KEY et BASEMYAI_ANYTHINGLLM_WORKSPACE. \
Alternativement, configurez Ollama ou LM Studio comme backend dans AnythingLLM."
.to_string()
} else {
let proposals = propose_models_to_install(&[]);
if proposals.is_empty() {
"Aucun serveur LLM local détecté. Installez Ollama (https://ollama.com) \
puis `ollama pull <modèle>`. Ou configurez BASEMYAI_ANYTHINGLLM_KEY + \
BASEMYAI_ANYTHINGLLM_WORKSPACE pour utiliser AnythingLLM."
.to_string()
} else {
let tags: Vec<_> = proposals
.iter()
.take(3)
.map(|m| format!("`ollama pull {}`", m.ollama_tag))
.collect();
format!(
"Aucun serveur LLM local détecté. Installez Ollama puis lancez : {}",
tags.join(" ou ")
)
}
}
} else {
let proposals = propose_models_to_install(&options);
if proposals.is_empty() {
"Aucun modèle installé ne tient dans la mémoire disponible.".to_string()
} else {
let tags: Vec<_> = proposals
.iter()
.take(3)
.map(|m| format!("`ollama pull {}` (~{} Mo) — {}", m.ollama_tag, m.ram_mb, m.description))
.collect();
format!("Modèles disponibles pour votre machine :\n{}", tags.join("\n"))
}
};
Err(MemoryError::Inference(hint))
}
const INFERENCE_TIMEOUT: Duration = Duration::from_secs(300);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
pub struct OpenAiCompatBackend {
client: Client,
base_url: String,
model: String,
timeout: Duration,
}
pub type OllamaBackend = OpenAiCompatBackend;
pub struct AnythingLlmBackend {
client: Client,
base_url: String,
workspace_slug: String,
api_key: String,
timeout: Duration,
}
impl AnythingLlmBackend {
#[must_use]
pub fn new(base_url: &str, workspace_slug: &str, api_key: &str) -> Self {
Self {
client: Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.build()
.unwrap_or_default(),
base_url: base_url.trim_end_matches('/').to_string(),
workspace_slug: workspace_slug.to_string(),
api_key: api_key.to_string(),
timeout: INFERENCE_TIMEOUT,
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
#[must_use]
pub fn anythingllm_from_env() -> Option<AnythingLlmBackend> {
let key = std::env::var("BASEMYAI_ANYTHINGLLM_KEY").ok()?;
let slug = std::env::var("BASEMYAI_ANYTHINGLLM_WORKSPACE").ok()?;
let url = std::env::var("BASEMYAI_ANYTHINGLLM_URL").unwrap_or_else(|_| "http://localhost:3001".to_string());
Some(AnythingLlmBackend::new(&url, &slug, &key))
}
#[derive(Serialize)]
struct AnythingLlmChatRequest<'a> {
message: &'a str,
mode: &'static str,
}
#[derive(Deserialize)]
struct AnythingLlmChatResponse {
#[serde(rename = "textResponse")]
text_response: Option<String>,
error: Option<String>,
}
#[async_trait::async_trait]
impl LlmInference for AnythingLlmBackend {
async fn complete(&self, prompt: &str) -> Result<String> {
let url = format!("{}/api/v1/workspace/{}/chat", self.base_url, self.workspace_slug);
let body = AnythingLlmChatRequest {
message: prompt,
mode: "chat",
};
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.timeout(self.timeout)
.json(&body)
.send()
.await
.map_err(|e| MemoryError::Inference(format!("AnythingLLM : requête échouée : {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(MemoryError::Inference(format!("AnythingLLM : HTTP {status} — {text}")));
}
let parsed: AnythingLlmChatResponse = resp
.json()
.await
.map_err(|e| MemoryError::Inference(format!("AnythingLLM : réponse illisible : {e}")))?;
if let Some(ref err) = parsed.error
&& !err.is_empty()
{
return Err(MemoryError::Inference(format!("AnythingLLM erreur : {err}")));
}
parsed
.text_response
.filter(|s| !s.is_empty())
.ok_or_else(|| MemoryError::Inference("AnythingLLM : réponse vide (textResponse null)".into()))
}
fn model_id(&self) -> &str {
&self.workspace_slug
}
}
impl OpenAiCompatBackend {
#[must_use]
pub fn new(base_url: &str, model: &str) -> Self {
Self {
client: Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.build()
.unwrap_or_default(),
base_url: base_url.trim_end_matches('/').to_string(),
model: model.to_string(),
timeout: INFERENCE_TIMEOUT,
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: [ChatMessage<'a>; 1],
stream: bool,
}
#[derive(Serialize)]
struct ChatMessage<'a> {
role: &'static str,
content: &'a str,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Deserialize)]
struct ChatChoice {
message: ChatChoiceMessage,
}
#[derive(Deserialize)]
struct ChatChoiceMessage {
content: String,
}
#[async_trait::async_trait]
impl LlmInference for OpenAiCompatBackend {
async fn complete(&self, prompt: &str) -> Result<String> {
let url = format!("{}/v1/chat/completions", self.base_url);
let body = ChatRequest {
model: &self.model,
messages: [ChatMessage {
role: "user",
content: prompt,
}],
stream: false,
};
let resp = self
.client
.post(&url)
.timeout(self.timeout)
.json(&body)
.send()
.await
.map_err(|e| MemoryError::Inference(format!("requête LLM échouée : {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(MemoryError::Inference(format!("serveur LLM : HTTP {status} — {text}")));
}
let parsed: ChatResponse = resp
.json()
.await
.map_err(|e| MemoryError::Inference(format!("réponse LLM illisible : {e}")))?;
parsed
.choices
.into_iter()
.next()
.map(|c| c.message.content)
.ok_or_else(|| MemoryError::Inference("réponse LLM vide (aucun choix)".into()))
}
fn model_id(&self) -> &str {
&self.model
}
}