use async_trait::async_trait;
use serde_json::Value;
#[cfg(test)]
use nexo_tool_meta::admin::llm::LlmUsage;
use nexo_tool_meta::admin::llm::{LlmCompleteInput, LlmCompleteResponse};
use super::super::dispatcher::{AdminRpcError, AdminRpcResult};
#[async_trait]
pub trait LlmCompleter: Send + Sync {
async fn complete(&self, input: LlmCompleteInput)
-> Result<LlmCompleteResponse, AdminRpcError>;
}
pub async fn complete(completer: &dyn LlmCompleter, raw_params: Value) -> AdminRpcResult {
let input: LlmCompleteInput = match serde_json::from_value(raw_params) {
Ok(i) => i,
Err(e) => {
return AdminRpcResult::err(AdminRpcError::InvalidParams(format!(
"llm/complete params: {e}"
)));
}
};
if input.provider.trim().is_empty() {
return AdminRpcResult::err(AdminRpcError::InvalidParams("provider is empty".into()));
}
if input.model.trim().is_empty() {
return AdminRpcResult::err(AdminRpcError::InvalidParams("model is empty".into()));
}
if input.messages.is_empty() && input.system_prompt.is_none() {
return AdminRpcResult::err(AdminRpcError::InvalidParams(
"messages cannot be empty when system_prompt is also missing".into(),
));
}
for (idx, m) in input.messages.iter().enumerate() {
if !matches!(m.role.as_str(), "system" | "user" | "assistant" | "tool") {
return AdminRpcResult::err(AdminRpcError::InvalidParams(format!(
"messages[{idx}].role must be one of system|user|assistant|tool, got `{}`",
m.role
)));
}
}
if let Some(t) = input.temperature {
if !(0.0..=2.0).contains(&t) || t.is_nan() {
return AdminRpcResult::err(AdminRpcError::InvalidParams(
"temperature must be in [0, 2]".into(),
));
}
}
match completer.complete(input).await {
Ok(r) => AdminRpcResult::ok(serde_json::to_value(r).unwrap_or(Value::Null)),
Err(e) => AdminRpcResult::err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use nexo_tool_meta::admin::llm::LlmChatMessage;
use std::sync::Arc;
struct MockCompleter {
captured: Arc<tokio::sync::Mutex<Option<LlmCompleteInput>>>,
reply: String,
}
#[async_trait]
impl LlmCompleter for MockCompleter {
async fn complete(
&self,
input: LlmCompleteInput,
) -> Result<LlmCompleteResponse, AdminRpcError> {
*self.captured.lock().await = Some(input.clone());
Ok(LlmCompleteResponse {
content: self.reply.clone(),
model: input.model,
usage: LlmUsage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
})
}
}
fn ok_input() -> Value {
serde_json::json!({
"provider": "deepseek",
"model": "deepseek-v4-flash",
"messages": [
{ "role": "user", "content": "hola" }
]
})
}
#[tokio::test]
async fn rejects_empty_provider() {
let mock = MockCompleter {
captured: Arc::new(Default::default()),
reply: "x".into(),
};
let mut p = ok_input();
p["provider"] = serde_json::json!("");
let r = complete(&mock, p).await;
assert!(r.error.is_some());
}
#[tokio::test]
async fn rejects_unknown_role() {
let mock = MockCompleter {
captured: Arc::new(Default::default()),
reply: "x".into(),
};
let mut p = ok_input();
p["messages"][0]["role"] = serde_json::json!("nope");
let r = complete(&mock, p).await;
assert!(r.error.is_some());
}
#[tokio::test]
async fn rejects_temperature_out_of_range() {
let mock = MockCompleter {
captured: Arc::new(Default::default()),
reply: "x".into(),
};
let mut p = ok_input();
p["temperature"] = serde_json::json!(5.0);
let r = complete(&mock, p).await;
assert!(r.error.is_some());
}
#[tokio::test]
async fn forwards_to_completer_on_happy_path() {
let captured = Arc::new(tokio::sync::Mutex::new(None));
let mock = MockCompleter {
captured: Arc::clone(&captured),
reply: "respuesta".into(),
};
let r = complete(&mock, ok_input()).await;
let v = r.result.expect("expected ok result");
assert_eq!(v["content"], "respuesta");
assert_eq!(v["model"], "deepseek-v4-flash");
let cap = captured.lock().await.clone().unwrap();
assert_eq!(cap.provider, "deepseek");
assert_eq!(cap.messages.len(), 1);
assert_eq!(cap.messages[0].role, "user");
assert_eq!(cap.messages[0].content, "hola");
}
#[tokio::test]
async fn allows_system_only_when_messages_empty() {
let mock = MockCompleter {
captured: Arc::new(Default::default()),
reply: "y".into(),
};
let p = serde_json::json!({
"provider": "deepseek",
"model": "x",
"messages": [],
"system_prompt": "you are a helpful assistant"
});
let r = complete(&mock, p).await;
assert!(r.result.is_some());
}
#[test]
fn _msg_type_is_publicly_accessible() {
let _ = LlmChatMessage {
role: "user".into(),
content: "ping".into(),
};
}
}