use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use rig_core::client::{CompletionClient, Nothing};
use rig_core::completion::Prompt;
use rig_core::providers::deepseek;
use rig_core::providers::ollama;
use std::fmt;
use std::sync::{Arc, Mutex};
use dyn_clone::DynClone;
use std::time::Duration;
use crate::types::GenerationParams;
#[async_trait]
pub trait Generator: Send + Sync + fmt::Debug + DynClone {
async fn generate_stream(&self, prompt: &str, on_token: &(dyn Fn(String) + Sync))
-> Result<()>;
async fn generate(&self, prompt: &str) -> Result<String> {
let acc = std::sync::Mutex::new(String::new());
self.generate_stream(prompt, &|token| {
acc.lock().unwrap().push_str(&token);
})
.await?;
Ok(acc.into_inner().unwrap())
}
async fn clear_memory(&self) -> Result<()> {
Ok(())
}
fn backend_name(&self) -> &'static str;
fn model_name(&self) -> &str;
}
dyn_clone::clone_trait_object!(Generator);
#[derive(Clone, Debug)]
pub struct OllamaGenerator {
model: String,
params: GenerationParams,
request_timeout_secs: Option<u64>,
}
impl OllamaGenerator {
pub fn new(model: String, params: GenerationParams, request_timeout_secs: Option<u64>) -> Self {
Self {
model,
params,
request_timeout_secs,
}
}
}
#[async_trait]
impl Generator for OllamaGenerator {
async fn generate_stream(
&self,
prompt: &str,
on_token: &(dyn Fn(String) + Sync),
) -> Result<()> {
let client = if let Some(secs) = self.request_timeout_secs {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(secs))
.build()
.context("failed to build HTTP client with timeout")?;
ollama::Client::builder()
.http_client(http)
.api_key(Nothing)
.build()
.map_err(|e| {
anyhow!(crate::RagrigError::OllamaUnreachable {
context: format!("create chat client: {e}"),
})
})?
} else {
ollama::Client::new(Nothing).map_err(|e| {
anyhow!(crate::RagrigError::OllamaUnreachable {
context: format!("create chat client: {e}"),
})
})?
};
let mut builder = client.agent(&self.model);
if let Some(t) = self.params.temperature {
builder = builder.temperature(t);
}
if let Some(n) = self.params.max_tokens {
builder = builder.max_tokens(n as u64);
}
if let Some(extra) = self.params.additional_json() {
builder = builder.additional_params(extra);
}
let agent = builder.build();
let response = match agent.prompt(prompt).await {
Ok(r) => r,
Err(e) => {
let msg = e.to_string();
if msg.contains("exceeds the available context size")
|| msg.contains("exceed_context_size_error")
{
let current = extract_ollama_token_count(&msg, "n_prompt_tokens").unwrap_or(0);
let max = extract_ollama_token_count(&msg, "n_ctx").unwrap_or(4096);
return Err(anyhow!(crate::RagrigError::ContextSizeExceeded {
current,
max
}));
}
return Err(anyhow!(crate::RagrigError::GenerationFailed {
backend: "ollama".into(),
model: self.model.clone(),
detail: msg,
}));
}
};
on_token(response);
Ok(())
}
fn backend_name(&self) -> &'static str {
"Ollama"
}
fn model_name(&self) -> &str {
&self.model
}
}
fn extract_ollama_token_count(msg: &str, key: &str) -> Option<usize> {
for needle in &[format!("\"{}\":", key), format!("{}:", key)] {
if let Some(pos) = msg.find(needle.as_str()) {
let rest = msg[pos + needle.len()..].trim_start();
let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(n) = num.parse::<usize>() {
return Some(n);
}
}
}
None
}
#[derive(Clone)]
pub struct DeepSeekGenerator {
model: String,
api_key: String,
params: GenerationParams,
request_timeout_secs: Option<u64>,
}
impl std::fmt::Debug for DeepSeekGenerator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeepSeekGenerator")
.field("model", &self.model)
.field("api_key", &"[redacted]")
.field("params", &self.params)
.field("request_timeout_secs", &self.request_timeout_secs)
.finish()
}
}
impl DeepSeekGenerator {
pub fn new(
model: String,
api_key: String,
params: GenerationParams,
request_timeout_secs: Option<u64>,
) -> Self {
Self {
model,
api_key,
params,
request_timeout_secs,
}
}
}
#[async_trait]
impl Generator for DeepSeekGenerator {
async fn generate_stream(
&self,
prompt: &str,
on_token: &(dyn Fn(String) + Sync),
) -> Result<()> {
let client = if let Some(secs) = self.request_timeout_secs {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(secs))
.build()
.context("failed to build HTTP client with timeout")?;
deepseek::Client::builder()
.api_key(&self.api_key)
.http_client(http)
.build()
.map_err(|e| {
anyhow!(crate::RagrigError::GenerationFailed {
backend: "deepseek".into(),
model: self.model.clone(),
detail: format!("failed to create client: {e}"),
})
})?
} else {
deepseek::Client::new(&self.api_key).map_err(|e| {
anyhow!(crate::RagrigError::GenerationFailed {
backend: "deepseek".into(),
model: self.model.clone(),
detail: format!("failed to create client: {e}"),
})
})?
};
let mut builder = client.agent(&self.model);
if let Some(t) = self.params.temperature {
builder = builder.temperature(t);
}
if let Some(n) = self.params.max_tokens {
builder = builder.max_tokens(n as u64);
}
if let Some(extra) = self.params.additional_json() {
builder = builder.additional_params(extra);
}
let agent = builder.build();
let response = agent.prompt(prompt).await.map_err(|e| {
anyhow!(crate::RagrigError::GenerationFailed {
backend: "deepseek".into(),
model: self.model.clone(),
detail: e.to_string(),
})
})?;
on_token(response);
Ok(())
}
fn backend_name(&self) -> &'static str {
"DeepSeek"
}
fn model_name(&self) -> &str {
&self.model
}
}
pub trait SimpleGenerator: Send + Sync + fmt::Debug + Clone + 'static {
fn respond(&mut self, prompt: &str) -> String;
fn backend_name(&self) -> &'static str;
fn model_name(&self) -> String;
}
pub struct MutexGenerator<T: SimpleGenerator> {
inner: Arc<Mutex<T>>,
backend: &'static str,
model: String,
}
impl<T: SimpleGenerator> MutexGenerator<T> {
pub fn new(inner: T) -> Self {
let backend = inner.backend_name();
let model = inner.model_name();
MutexGenerator {
inner: Arc::new(Mutex::new(inner)),
backend,
model,
}
}
}
impl<T: SimpleGenerator> Clone for MutexGenerator<T> {
fn clone(&self) -> Self {
MutexGenerator {
inner: Arc::clone(&self.inner),
backend: self.backend,
model: self.model.clone(),
}
}
}
impl<T: SimpleGenerator> fmt::Debug for MutexGenerator<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MutexGenerator")
.field("backend", &self.backend)
.field("model", &self.model)
.finish()
}
}
#[async_trait]
impl<T: SimpleGenerator> Generator for MutexGenerator<T> {
async fn generate_stream(
&self,
prompt: &str,
on_token: &(dyn Fn(String) + Sync),
) -> Result<()> {
let response = self.inner.lock().unwrap().respond(prompt);
on_token(response);
Ok(())
}
fn backend_name(&self) -> &'static str {
self.backend
}
fn model_name(&self) -> &str {
&self.model
}
}
#[derive(Clone, Debug)]
pub enum ChatAgentSpec {
Ollama {
model: String,
params: GenerationParams,
request_timeout_secs: Option<u64>,
},
DeepSeek {
model: String,
api_key: Option<String>,
params: GenerationParams,
request_timeout_secs: Option<u64>,
},
#[cfg(feature = "internal-generate")]
Candle {
model_path: String,
tokenizer_path: Option<String>,
},
}
impl ChatAgentSpec {
pub fn ollama(
model: impl Into<String>,
params: impl Into<GenerationParams>,
request_timeout_secs: Option<u64>,
) -> Self {
Self::Ollama {
model: model.into(),
params: params.into(),
request_timeout_secs,
}
}
pub fn deepseek(
model: impl Into<String>,
api_key: impl Into<Option<String>>,
params: impl Into<GenerationParams>,
request_timeout_secs: Option<u64>,
) -> Self {
Self::DeepSeek {
model: model.into(),
api_key: api_key.into(),
params: params.into(),
request_timeout_secs,
}
}
pub fn parse(
backend: &str,
model: Option<&str>,
api_key: Option<&str>,
params: Option<GenerationParams>,
) -> Result<Self> {
let params = params.unwrap_or_default();
match backend.to_lowercase().as_str() {
"ollama" => {
let model = model.unwrap_or("gemma2:latest").to_string();
Ok(Self::Ollama {
model,
params,
request_timeout_secs: None,
})
}
"deepseek" => {
let api_key = api_key.map(|s| s.to_string());
let model = model.unwrap_or("deepseek-chat").to_string();
Ok(Self::DeepSeek {
model,
api_key,
params,
request_timeout_secs: None,
})
}
#[cfg(feature = "internal-generate")]
"candle" => {
let model_path = model
.ok_or_else(|| anyhow!("candle requires a model path"))?
.to_string();
let tokenizer_path = api_key.map(|s| s.to_string());
Ok(Self::Candle {
model_path,
tokenizer_path,
})
}
other => Err(anyhow!(
"Unknown chat backend: '{}'. Available: {}",
other,
Self::available_backends().join(", ")
)),
}
}
pub fn available_backends() -> &'static [&'static str] {
&[
"ollama",
"deepseek",
#[cfg(feature = "internal-generate")]
"candle",
]
}
pub fn build(&self) -> Result<Box<dyn Generator>> {
match self {
Self::Ollama {
model,
params,
request_timeout_secs,
} => Ok(Box::new(OllamaGenerator::new(
model.clone(),
params.clone(),
*request_timeout_secs,
))),
Self::DeepSeek {
model,
api_key,
params,
request_timeout_secs,
} => {
let key = api_key
.clone()
.or_else(|| std::env::var("DEEPSEEK_API_KEY").ok())
.ok_or_else(|| {
anyhow!(
"DeepSeek requires an API key \
(set DEEPSEEK_API_KEY env var or pass as argument)"
)
})?;
Ok(Box::new(DeepSeekGenerator::new(
model.clone(),
key,
params.clone(),
*request_timeout_secs,
)))
}
#[cfg(feature = "internal-generate")]
Self::Candle {
model_path,
tokenizer_path,
} => {
use crate::generate::CandleGenerator;
use crate::generate::Device;
let generator = if let Some(tp) = tokenizer_path {
CandleGenerator::new(model_path, tp, Device::Cpu)
} else {
CandleGenerator::from_gguf(model_path, Device::Cpu)
};
Ok(Box::new(generator))
}
}
}
}
impl TryFrom<ChatAgentSpec> for Box<dyn Generator> {
type Error = anyhow::Error;
fn try_from(spec: ChatAgentSpec) -> Result<Self, Self::Error> {
spec.build()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ollama_default_model() {
let spec = ChatAgentSpec::parse("ollama", None, None, None).unwrap();
match spec {
ChatAgentSpec::Ollama { model, .. } => assert_eq!(model, "gemma2:latest"),
_ => panic!("expected Ollama variant"),
}
}
#[test]
fn parse_ollama_custom_model() {
let spec = ChatAgentSpec::parse("ollama", Some("qwen2.5:14b"), None, None).unwrap();
match spec {
ChatAgentSpec::Ollama { model, .. } => assert_eq!(model, "qwen2.5:14b"),
_ => panic!("expected Ollama variant"),
}
}
#[test]
fn parse_ollama_case_insensitive() {
let spec = ChatAgentSpec::parse("OLLAMA", None, None, None).unwrap();
assert!(matches!(spec, ChatAgentSpec::Ollama { .. }));
}
#[test]
fn parse_deepseek_default_model() {
let spec = ChatAgentSpec::parse("deepseek", None, Some("sk-test"), None).unwrap();
match spec {
ChatAgentSpec::DeepSeek { model, api_key, .. } => {
assert_eq!(model, "deepseek-chat");
assert_eq!(api_key, Some("sk-test".to_string()));
}
_ => panic!("expected DeepSeek variant"),
}
}
#[test]
fn parse_deepseek_no_key_still_parses() {
let spec = ChatAgentSpec::parse("deepseek", None, None, None).unwrap();
match spec {
ChatAgentSpec::DeepSeek { api_key, .. } => assert!(api_key.is_none()),
_ => panic!("expected DeepSeek variant"),
}
}
#[test]
fn parse_unknown_backend_is_error() {
let err = ChatAgentSpec::parse("openai", None, None, None).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown chat backend"));
assert!(msg.contains("openai"));
}
#[test]
fn build_ollama_succeeds() {
let spec = ChatAgentSpec::Ollama {
model: "gemma2:latest".into(),
params: GenerationParams::default(),
request_timeout_secs: None,
};
let agent = spec.build().unwrap();
assert_eq!(agent.backend_name(), "Ollama");
assert_eq!(agent.model_name(), "gemma2:latest");
}
#[test]
fn build_deepseek_no_key_is_error() {
if std::env::var("DEEPSEEK_API_KEY").is_ok() {
return;
}
let spec = ChatAgentSpec::DeepSeek {
model: "deepseek-chat".into(),
api_key: None,
params: GenerationParams::default(),
request_timeout_secs: None,
};
assert!(spec.build().is_err());
}
#[test]
fn build_deepseek_with_inline_key_succeeds() {
let spec = ChatAgentSpec::DeepSeek {
model: "deepseek-chat".into(),
api_key: Some("sk-test".into()),
params: GenerationParams::default(),
request_timeout_secs: None,
};
let agent = spec.build().unwrap();
assert_eq!(agent.backend_name(), "DeepSeek");
assert_eq!(agent.model_name(), "deepseek-chat");
}
#[test]
fn ollama_generator_identity() {
let g = OllamaGenerator::new("gemma2:latest".into(), GenerationParams::default(), None);
assert_eq!(g.backend_name(), "Ollama");
assert_eq!(g.model_name(), "gemma2:latest");
}
#[test]
fn deepseek_generator_identity() {
let g = DeepSeekGenerator::new(
"deepseek-chat".into(),
"sk-test".into(),
GenerationParams::default(),
None,
);
assert_eq!(g.backend_name(), "DeepSeek");
assert_eq!(g.model_name(), "deepseek-chat");
}
#[tokio::test]
async fn clear_memory_default_is_noop() {
let g = OllamaGenerator::new("gemma2:latest".into(), GenerationParams::default(), None);
assert!(g.clear_memory().await.is_ok());
}
#[tokio::test]
#[ignore]
async fn generate_delegates_to_stream() {
let g = OllamaGenerator::new("gemma2:latest".into(), GenerationParams::default(), None);
let _ = g.generate("hello").await;
}
#[test]
fn try_from_ollama_spec_succeeds() {
use std::convert::TryFrom;
let spec = ChatAgentSpec::Ollama {
model: "gemma2:latest".into(),
params: GenerationParams::default(),
request_timeout_secs: None,
};
let agent = Box::<dyn Generator>::try_from(spec).unwrap();
assert_eq!(agent.backend_name(), "Ollama");
assert_eq!(agent.model_name(), "gemma2:latest");
}
}