use std::collections::BTreeMap;
use std::fmt;
use std::time::Duration;
use async_trait::async_trait;
use rig_core::driver::Model;
use rig_core::error::ProviderError;
use rig_typesafeai::{DynamicQuery, Evaluate, JevConfig};
use serde_json::Value;
use super::{
ApiKey, Decisions, JudgmentError, JudgmentErrorKind, JudgmentModel, LlmConfig, Question,
};
const BODY_LOG_CHARS: usize = 200;
fn with_total_tokens(usage: rig_core::completion::Usage) -> rig_core::completion::Usage {
if usage.total_tokens.is_some()
|| (usage.input_tokens.is_none() && usage.output_tokens.is_none())
{
return usage;
}
let total = usage
.input_tokens
.unwrap_or(0)
.saturating_add(usage.output_tokens.unwrap_or(0));
usage.total_tokens(total)
}
fn own_http_client() -> rig_reqwest::ReqwestClient {
match rig_reqwest::reqwest::Client::builder().build() {
Ok(client) => rig_reqwest::ReqwestClient::from(client),
Err(_) => rig_reqwest::ReqwestClient::default(),
}
}
#[derive(Clone)]
pub struct JevJudge {
model: Model<JevConfig>,
model_id: String,
endpoint: String,
timeout: Duration,
key: ApiKey,
}
impl JevJudge {
pub fn new(config: &LlmConfig) -> Self {
let model = JevConfig::new(config.api_key().expose())
.model(config.jev_model())
.with_endpoint(config.jev_endpoint())
.connect(own_http_client())
.evaluation();
Self {
model,
model_id: config.jev_model().to_string(),
endpoint: config.jev_endpoint().to_string(),
timeout: config.timeout(),
key: config.api_key().clone(),
}
}
pub fn model_id(&self) -> &str {
&self.model_id
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn endpoint_host(&self) -> Option<String> {
super::endpoint_host(&self.endpoint)
}
fn error(&self, kind: JudgmentErrorKind, message: impl AsRef<str>) -> JudgmentError {
JudgmentError::scrubbed(kind, self.redact_endpoint(message.as_ref()), &self.key)
}
fn redact_endpoint(&self, text: &str) -> String {
let Ok(url) = url::Url::parse(&self.endpoint) else {
return text.to_string();
};
let mut out = text.to_string();
for part in [url.query(), url.fragment()].into_iter().flatten() {
if !part.is_empty() {
out = out.replace(part, super::REDACTED);
}
}
if let Some((_, rest)) = self.endpoint.split_once(['?', '#']) {
for part in rest.split(['?', '#']).filter(|p| !p.is_empty()) {
out = out.replace(part, super::REDACTED);
}
}
out
}
fn log_excerpt(&self, text: &str) -> String {
self.key
.excerpt(&self.redact_endpoint(text), BODY_LOG_CHARS)
}
fn reply_error(
&self,
kind: JudgmentErrorKind,
reply: &rig_core::ProviderResponseError,
) -> JudgmentError {
let status = reply.status.map(|s| s.as_u16());
tracing::debug!(
?status,
body = ?self.log_excerpt(reply.body.trim()),
"jev upstream error reply"
);
match status {
Some(code) => self.error(kind, format!("status {code}")),
None => self.error(kind, "provider error reply without an HTTP status"),
}
}
fn opaque_error(
&self,
kind: JudgmentErrorKind,
summary: &str,
error: &ProviderError,
) -> JudgmentError {
tracing::debug!(
detail = ?self.log_excerpt(&error.to_string()),
"jev provider failure"
);
self.error(kind, summary)
}
fn classify(&self, error: &ProviderError) -> JudgmentError {
use JudgmentErrorKind as K;
match error {
ProviderError::InvalidAuthentication(reply) => self.reply_error(K::Unauthorized, reply),
ProviderError::ProviderResponse(reply) => {
let kind = match reply.status.map(|s| s.as_u16()) {
Some(401 | 403) => K::Unauthorized,
Some(429) => K::RateLimited,
_ => K::Upstream,
};
self.reply_error(kind, reply)
}
ProviderError::CacheExpired { response, .. } => self.reply_error(K::Upstream, response),
ProviderError::Json(_)
| ProviderError::Response(_)
| ProviderError::Truncated
| ProviderError::MismatchedDimensions { .. } => {
self.error(K::Decode, error.to_string())
}
ProviderError::Http(_) => self.error(K::Transport, error.to_string()),
ProviderError::Url(_)
| ProviderError::Request(_)
| ProviderError::UnsupportedOption(_) => {
self.error(K::InvalidRequest, error.to_string())
}
ProviderError::Provider(_) | ProviderError::Relayed(_) => {
self.opaque_error(K::Upstream, "provider reported a failure", error)
}
_ => self.opaque_error(K::Upstream, "unclassified provider failure", error),
}
}
}
impl fmt::Debug for JevJudge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JevJudge")
.field("model", &self.model_id)
.field("endpoint", &self.endpoint)
.field("timeout", &self.timeout)
.field("key", &self.key)
.finish_non_exhaustive()
}
}
#[async_trait]
impl JudgmentModel for JevJudge {
async fn decide(
&self,
state: Value,
questions: BTreeMap<String, Question>,
) -> Result<Decisions, JudgmentError> {
let query = DynamicQuery::new(questions).map_err(|e| self.classify(&e))?;
let result = tokio::time::timeout(self.timeout, self.model.evaluate(&state, query))
.await
.map_err(|_| {
self.error(
JudgmentErrorKind::Timeout,
format!("no reply within {} ms", self.timeout.as_millis()),
)
})?
.map_err(|e| self.classify(&e))?;
Ok(Decisions {
answers: result.answers,
model: self
.key
.excerpt(&result.model, super::MAX_REPORTED_MODEL_CHARS),
usage: result.usage.map(with_total_tokens),
})
}
fn endpoint_host(&self) -> Option<String> {
JevJudge::endpoint_host(self)
}
fn model_id(&self) -> Option<String> {
Some(self.model_id.clone())
}
}