use std::future::Future;
use serde_json::{Value, json};
#[derive(Debug, Clone, PartialEq)]
pub struct Endpoint {
pub base_url: String,
pub model: String,
pub credential: String,
}
#[expect(
clippy::struct_field_names,
reason = "`question` is what the field is called wherever an ask travels"
)]
#[derive(Debug, Clone, PartialEq)]
pub struct Question {
pub question: String,
pub describes: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Answer {
pub text: String,
pub tokens: Option<i64>,
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct AskFailed(pub String);
#[derive(Debug, Clone)]
pub struct SendRequest {
pub headers: Vec<(String, String)>,
pub body: String,
}
pub trait Sender: Send + Sync {
fn send(
&self,
url: String,
request: SendRequest,
) -> impl Future<Output = Result<(u16, Option<Value>), String>> + Send;
}
const INSTRUCTION: &str = "Answer the question about the material below, using only what is in \
it. Quote exactly when quoting: transcribe identifiers, paths, and messages character for \
character. Say plainly when the material does not answer the question. Do not suggest what \
to do about it.";
pub async fn ask(
endpoint: &Endpoint,
asked: &Question,
cancel: impl Future<Output = ()> + Send + Unpin,
send: &impl Sender,
) -> Result<Answer, AskFailed> {
let body = json!({
"model": endpoint.model,
"messages": [{
"role": "user",
"content": format!(
"{INSTRUCTION}\n\nQuestion: {}\n\n{}:\n{}",
asked.question, asked.describes, asked.content
),
}],
});
let base = endpoint.base_url.trim_end_matches('/');
let outcome = tokio::select! {
() = cancel => Err("it did not answer in time".to_owned()),
answer = send.send(
format!("{base}/chat/completions"),
SendRequest {
headers: vec![
(
"Authorization".to_owned(),
format!("Bearer {}", endpoint.credential),
),
("Content-Type".to_owned(), "application/json".to_owned()),
],
body: body.to_string(),
},
) => answer,
};
let (status, parsed) = match outcome {
Err(error) => {
return Err(AskFailed(format!("it could not be reached: {error}")));
}
Ok(answer) => answer,
};
if !(200..300).contains(&status) {
return Err(AskFailed(format!(
"{} refused the question: {status}",
endpoint.model
)));
}
let text = parsed.as_ref().and_then(content_of).unwrap_or_default();
if text.trim().is_empty() {
return Err(AskFailed(format!("{} returned no answer", endpoint.model)));
}
let tokens = parsed.as_ref().and_then(tokens_of);
Ok(Answer {
text: text.trim().to_owned(),
tokens,
})
}
fn content_of(body: &Value) -> Option<String> {
body.get("choices")?
.as_array()?
.first()?
.get("message")?
.get("content")?
.as_str()
.map(str::to_owned)
}
fn tokens_of(body: &Value) -> Option<i64> {
body.get("usage")?
.get("total_tokens")
.and_then(Value::as_i64)
}
pub struct HttpSender;
impl Sender for HttpSender {
async fn send(
&self,
url: String,
request: SendRequest,
) -> Result<(u16, Option<Value>), String> {
let client = reqwest::Client::new();
let mut sent = client.post(url);
for (name, value) in &request.headers {
sent = sent.header(name.as_str(), value.as_str());
}
let answer = sent
.header("Content-Type", "application/json")
.body(request.body)
.send()
.await
.map_err(|error| error.to_string())?;
let status = answer.status().as_u16();
let body = answer.json::<Value>().await.unwrap_or(Value::Null);
Ok((status, Some(body)))
}
}