use std::collections::BTreeMap;
use std::fmt;
use std::time::Duration;
use async_trait::async_trait;
use rig_core::completion::request::Cost;
use rig_core::driver::Model;
use rig_core::error::{EncodeError, ProviderError};
use rig_core::operation::Whole;
use rig_core::wire::{
Call, Descriptor, Encoded, Free, Json, Mode, Operation, Wire, WireFrame, document,
};
use rig_typesafeai::{DynamicQuery, JevConfig, Query};
use serde::Deserialize;
use super::{
Answer, ApiKey, Decisions, JudgmentError, JudgmentErrorKind, JudgmentModel, LlmConfig,
Question, Usage,
};
const BODY_LOG_CHARS: usize = 200;
struct Evaluation;
impl Operation for Evaluation {
type Request = rig_typesafeai::types::Request;
type Event = std::convert::Infallible;
type End = Reply;
type Response = Reply;
type Fold = Whole<Self>;
type Emit = Free;
fn fold(_request: &Self::Request, _call: &mut Call<'_>) -> Whole<Self> {
Whole::<Self>::new()
}
}
#[derive(Clone)]
struct JevWire(JevConfig);
impl Wire for JevWire {
type Op = Evaluation;
type Payload = Encoded;
type Frame = WireFrame;
type Decoder<'id> = Json;
type Reassembler = document::Unreassembled;
fn describe(&self) -> Descriptor<'_> {
self.0.describe()
}
fn encode(
&self,
request: rig_typesafeai::types::Request,
mode: Mode,
) -> Result<Encoded, EncodeError> {
self.0.encode(request, mode)
}
fn decoder<'id>(&self) -> Self::Decoder<'id> {
Json
}
}
#[derive(Debug, Deserialize)]
struct Reply {
model: String,
answers: UniqueAnswers,
#[serde(default)]
usage: Option<serde_json::Value>,
}
#[derive(Debug)]
struct UniqueAnswers(BTreeMap<String, Answer>);
impl<'de> Deserialize<'de> for UniqueAnswers {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visit;
impl<'de> serde::de::Visitor<'de> for Visit {
type Value = UniqueAnswers;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("an object keyed by unique question IDs")
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<UniqueAnswers, M::Error> {
let mut answers = BTreeMap::new();
while let Some((id, answer)) = map.next_entry::<String, Answer>()? {
if id.is_empty() || answers.insert(id, answer).is_some() {
return Err(serde::de::Error::custom(
"question IDs must be nonempty and unique",
));
}
}
Ok(UniqueAnswers(answers))
}
}
deserializer.deserialize_map(Visit)
}
}
const USAGE_COUNTERS: [&str; 7] = [
"input_tokens",
"output_tokens",
"total_tokens",
"cached_input_tokens",
"cache_creation_input_tokens",
"tool_use_prompt_tokens",
"reasoning_tokens",
];
fn lenient_usage(raw: Option<&serde_json::Value>) -> (Option<Usage>, bool) {
use serde_json::Value;
let mut usage = match raw {
None | Some(Value::Null) => return (None, false),
Some(Value::Object(usage)) => usage.clone(),
Some(_) => return (None, true),
};
let mut dropped = false;
let cost = match usage.remove("cost") {
None | Some(Value::Null) => None,
Some(Value::Number(total)) => total.as_f64().map(Cost::from_total),
Some(other) => serde_json::from_value::<Cost>(other).ok(),
};
dropped |= cost.is_none() && raw.is_some_and(|u| !u["cost"].is_null());
usage.retain(|key, value| {
let bad = USAGE_COUNTERS.contains(&key.as_str()) && !value.is_u64() && !value.is_null();
dropped |= bad;
!bad
});
match serde_json::from_value::<Usage>(Value::Object(usage)) {
Ok(usage) => (Some(usage.cost(cost)), dropped),
Err(_) => (cost.map(|cost| Usage::new().cost(cost)), true),
}
}
fn state_problem(state: &serde_json::Value) -> Option<&'static str> {
match state {
serde_json::Value::String(_)
| serde_json::Value::Object(_)
| serde_json::Value::Array(_) => None,
serde_json::Value::Null => Some("state cannot be null"),
_ => Some("question content must be a string, object, array, or null"),
}
}
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<JevWire, rig_reqwest::ReqwestClient>,
model_id: String,
endpoint: String,
timeout: Duration,
key: ApiKey,
}
impl JevJudge {
pub fn new(config: &LlmConfig) -> Self {
let wire = JevConfig::new(config.api_key().expose())
.model(config.jev_model())
.with_endpoint(config.jev_endpoint());
Self {
model: Model::new(JevWire(wire), own_http_client()),
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: serde_json::Value,
questions: BTreeMap<String, Question>,
) -> Result<Decisions, JudgmentError> {
let query = DynamicQuery::new(questions).map_err(|e| self.classify(&e))?;
if let Some(reason) = state_problem(&state) {
return Err(self.classify(&ProviderError::request(reason)));
}
let request = rig_typesafeai::types::Request {
state,
questions: serde_json::value::to_raw_value(&query)
.map_err(|e| self.classify(&e.into()))?,
};
let reply = tokio::time::timeout(self.timeout, self.model.call(request))
.await
.map_err(|_| {
self.error(
JudgmentErrorKind::Timeout,
format!("no reply within {} ms", self.timeout.as_millis()),
)
})?
.map_err(|e| self.classify(&e))?;
let (usage, dropped) = lenient_usage(reply.usage.as_ref());
if dropped && let Some(raw) = &reply.usage {
tracing::debug!(
usage = ?self.log_excerpt(&raw.to_string()),
"jev usage not understood; dropped"
);
}
let answers = query
.decode(reply.answers.0)
.map_err(|e| self.classify(&e))?;
Ok(Decisions {
answers,
model: self
.key
.excerpt(&reply.model, super::MAX_REPORTED_MODEL_CHARS),
usage,
})
}
fn endpoint_host(&self) -> Option<String> {
JevJudge::endpoint_host(self)
}
fn model_id(&self) -> Option<String> {
Some(self.model_id.clone())
}
}