use crate::agent::backend::LlmBackend;
use crate::agent::{LLMResponse, Message, Role, TokenCallback, TokenUsage};
use crate::tools::ToolDefinition;
use crate::{PawanError, Result};
use async_trait::async_trait;
use lancor::{ChatCompletionRequest, LlamaCppClient, Message as LancorMessage};
pub struct LancorBackend {
client: LlamaCppClient,
model: String,
temperature: Option<f32>,
max_tokens: Option<u32>,
}
impl LancorBackend {
pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Result<Self> {
let client = LlamaCppClient::new(base_url)
.map_err(|e| PawanError::Llm(format!("lancor client init failed: {e}")))?;
Ok(Self {
client,
model: model.into(),
temperature: None,
max_tokens: None,
})
}
pub fn with_api_key(
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Result<Self> {
let client = LlamaCppClient::with_api_key(base_url, api_key)
.map_err(|e| PawanError::Llm(format!("lancor client init failed: {e}")))?;
Ok(Self {
client,
model: model.into(),
temperature: None,
max_tokens: None,
})
}
pub fn temperature(mut self, t: f32) -> Self {
self.temperature = Some(t);
self
}
pub fn max_tokens(mut self, n: u32) -> Self {
self.max_tokens = Some(n);
self
}
fn to_lancor_messages(messages: &[Message]) -> Vec<LancorMessage> {
messages
.iter()
.map(|m| match m.role {
Role::System => LancorMessage::system(&m.content),
Role::User => LancorMessage::user(&m.content),
Role::Assistant => LancorMessage::assistant(&m.content),
Role::Tool => {
let body = m
.tool_result
.as_ref()
.map(|t| format!("[tool result] {}", t.content))
.unwrap_or_else(|| m.content.clone());
LancorMessage::user(body)
}
})
.collect()
}
}
#[async_trait]
impl LlmBackend for LancorBackend {
async fn generate(
&self,
messages: &[Message],
_tools: &[ToolDefinition],
_on_token: Option<&TokenCallback>,
) -> Result<LLMResponse> {
let mut req = ChatCompletionRequest::new(&self.model)
.messages(Self::to_lancor_messages(messages));
if let Some(t) = self.temperature {
req = req.temperature(t);
}
if let Some(n) = self.max_tokens {
req = req.max_tokens(n);
}
let resp = self
.client
.chat_completion(req)
.await
.map_err(|e| PawanError::Llm(format!("lancor chat_completion: {e}")))?;
let content = resp
.choices
.first()
.map(|c| c.message.content.clone())
.unwrap_or_default();
let finish_reason = resp
.choices
.first()
.and_then(|c| c.finish_reason.clone())
.unwrap_or_else(|| "stop".to_string());
let usage = TokenUsage {
prompt_tokens: resp.usage.prompt_tokens as u64,
completion_tokens: resp.usage.completion_tokens.unwrap_or(0) as u64,
total_tokens: resp.usage.total_tokens as u64,
reasoning_tokens: 0,
action_tokens: resp.usage.completion_tokens.unwrap_or(0) as u64,
};
Ok(LLMResponse {
content,
reasoning: None,
tool_calls: vec![],
finish_reason,
usage: Some(usage),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::ToolResultMessage;
use serde_json::json;
#[test]
fn test_lancor_backend_constructor_succeeds() {
let backend = LancorBackend::new("http://localhost:8080", "qwen3.5").unwrap();
assert_eq!(backend.model, "qwen3.5");
assert!(backend.temperature.is_none());
assert!(backend.max_tokens.is_none());
}
#[test]
fn test_lancor_backend_with_api_key_constructor() {
let backend =
LancorBackend::with_api_key("https://example.com", "secret", "model-x").unwrap();
assert_eq!(backend.model, "model-x");
}
#[test]
fn test_lancor_backend_builder_methods_chain() {
let backend = LancorBackend::new("http://localhost:8080", "m")
.unwrap()
.temperature(0.7)
.max_tokens(512);
assert_eq!(backend.temperature, Some(0.7));
assert_eq!(backend.max_tokens, Some(512));
}
#[test]
fn test_to_lancor_messages_maps_roles_correctly() {
let messages = vec![
Message {
role: Role::System,
content: "you are an assistant".into(),
tool_calls: vec![],
tool_result: None,
},
Message {
role: Role::User,
content: "hello".into(),
tool_calls: vec![],
tool_result: None,
},
Message {
role: Role::Assistant,
content: "hi there".into(),
tool_calls: vec![],
tool_result: None,
},
];
let lm = LancorBackend::to_lancor_messages(&messages);
assert_eq!(lm.len(), 3);
assert_eq!(lm[0].role, "system");
assert_eq!(lm[0].content, "you are an assistant");
assert_eq!(lm[1].role, "user");
assert_eq!(lm[1].content, "hello");
assert_eq!(lm[2].role, "assistant");
assert_eq!(lm[2].content, "hi there");
}
#[test]
fn test_to_lancor_messages_flattens_tool_role_to_user() {
let messages = vec![Message {
role: Role::Tool,
content: "raw content (ignored)".into(),
tool_calls: vec![],
tool_result: Some(ToolResultMessage {
tool_call_id: "call_1".into(),
content: json!({"files": ["a.rs", "b.rs"]}),
success: true,
}),
}];
let lm = LancorBackend::to_lancor_messages(&messages);
assert_eq!(lm.len(), 1);
assert_eq!(lm[0].role, "user", "Tool role must flatten to user");
assert!(lm[0].content.contains("[tool result]"));
assert!(lm[0].content.contains("a.rs"));
}
#[test]
fn test_to_lancor_messages_empty_input_yields_empty_output() {
let lm = LancorBackend::to_lancor_messages(&[]);
assert!(lm.is_empty());
}
#[test]
fn test_to_lancor_messages_tool_role_without_tool_result_falls_back_to_content() {
let messages = vec![Message {
role: Role::Tool,
content: "fallback text".into(),
tool_calls: vec![],
tool_result: None,
}];
let lm = LancorBackend::to_lancor_messages(&messages);
assert_eq!(lm.len(), 1);
assert_eq!(lm[0].role, "user", "Tool role must still flatten to user");
assert_eq!(
lm[0].content, "fallback text",
"content must fall back to m.content when tool_result is None"
);
}
#[test]
fn test_temperature_zero_is_stored_not_dropped() {
let backend = LancorBackend::new("http://localhost:8080", "m")
.unwrap()
.temperature(0.0);
assert_eq!(
backend.temperature,
Some(0.0),
"temperature(0.0) must set Some(0.0), not None"
);
}
#[test]
fn test_max_tokens_zero_is_stored_not_dropped() {
let backend = LancorBackend::new("http://localhost:8080", "m")
.unwrap()
.max_tokens(0);
assert_eq!(
backend.max_tokens,
Some(0),
"max_tokens(0) must set Some(0), not None"
);
}
#[test]
fn test_to_lancor_messages_preserves_order_across_all_four_roles() {
let messages = vec![
Message { role: Role::System, content: "sys".into(), tool_calls: vec![], tool_result: None },
Message { role: Role::User, content: "usr".into(), tool_calls: vec![], tool_result: None },
Message { role: Role::Assistant, content: "asst".into(), tool_calls: vec![], tool_result: None },
Message {
role: Role::Tool,
content: "raw".into(),
tool_calls: vec![],
tool_result: Some(ToolResultMessage {
tool_call_id: "id".into(),
content: json!({"k": "v"}),
success: true,
}),
},
];
let lm = LancorBackend::to_lancor_messages(&messages);
assert_eq!(lm.len(), 4);
assert_eq!(lm[0].role, "system");
assert_eq!(lm[1].role, "user");
assert_eq!(lm[2].role, "assistant");
assert_eq!(lm[3].role, "user", "Tool must become user");
assert!(lm[3].content.contains("[tool result]"));
}
}