use async_trait::async_trait;
use polyc_crypto::sensitive::Sensitive;
use polyc_judgment::{JudgmentError, JudgmentProvider, JudgmentRequest, JudgmentResponse};
use serde::Serialize;
pub const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
#[derive(Debug, Clone)]
pub struct SystemOneConfig {
pub base_url: String,
pub api_key: Sensitive<String>,
pub model: String,
}
const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub struct SystemOneJudgment {
http: reqwest::Client,
config: SystemOneConfig,
}
impl std::fmt::Debug for SystemOneJudgment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SystemOneJudgment")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl SystemOneJudgment {
pub fn new(config: SystemOneConfig) -> Result<Self, JudgmentError> {
let http = reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|err| JudgmentError::Transport(Box::new(err)))?;
Ok(Self { http, config })
}
fn endpoint(&self) -> String {
format!("{}/systemone", self.config.base_url.trim_end_matches('/'))
}
}
#[derive(Serialize)]
struct WireRequest<'a> {
model: &'a str,
#[serde(flatten)]
request: &'a JudgmentRequest,
}
fn error_for_status(status: u16, retry_after: Option<std::time::Duration>) -> JudgmentError {
match status {
401 | 403 => JudgmentError::Unauthorized,
402 => JudgmentError::Exhausted,
429 => JudgmentError::RateLimited { retry_after },
400..=499 => JudgmentError::Invalid(format!("status {status}")),
_ => JudgmentError::Unavailable { status },
}
}
fn parse_retry_after(value: &str) -> Option<std::time::Duration> {
let value = value.trim();
if let Ok(secs) = value.parse::<u64>() {
return Some(std::time::Duration::from_secs(secs));
}
let target = httpdate::parse_http_date(value).ok()?;
Some(
target
.duration_since(std::time::SystemTime::now())
.unwrap_or(std::time::Duration::ZERO),
)
}
#[async_trait]
impl JudgmentProvider for SystemOneJudgment {
type Error = JudgmentError;
async fn judge(&self, request: JudgmentRequest) -> Result<JudgmentResponse, Self::Error> {
let body = WireRequest {
model: &self.config.model,
request: &request,
};
tracing::debug!(
questions = request.questions.len(),
model = %self.config.model,
"judgment request"
);
let resp = self
.http
.post(self.endpoint())
.bearer_auth(self.config.api_key.expose())
.json(&body)
.send()
.await
.map_err(|err| JudgmentError::Transport(Box::new(err)))?;
let status = resp.status();
if !status.is_success() {
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(parse_retry_after);
return Err(error_for_status(status.as_u16(), retry_after));
}
let bytes = resp
.bytes()
.await
.map_err(|err| JudgmentError::Transport(Box::new(err)))?;
serde_json::from_slice(&bytes).map_err(|err| JudgmentError::Malformed(err.to_string()))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use std::collections::BTreeMap;
use polyc_judgment::{Answer, Question};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_partial_json, header, method, path},
};
use super::*;
fn provider(base_url: String) -> SystemOneJudgment {
SystemOneJudgment::new(SystemOneConfig {
base_url,
api_key: Sensitive::new("gw-test-key".to_owned()),
model: "judge-test-model".to_owned(),
})
.expect("client builds")
}
fn request() -> JudgmentRequest {
let mut questions = BTreeMap::new();
questions.insert(
"urgent".to_owned(),
Question::Noul {
instructions: "Is it urgent?".to_owned(),
criteria: None,
},
);
JudgmentRequest {
state: serde_json::json!({"message": "Help now"}),
questions,
}
}
#[tokio::test]
async fn posts_the_wire_shape_with_bearer_and_decodes_answers() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/systemone"))
.and(header("authorization", "Bearer gw-test-key"))
.and(body_partial_json(serde_json::json!({
"model": "judge-test-model",
"state": {"message": "Help now"},
"questions": {"urgent": {"type": "noul", "instructions": "Is it urgent?"}}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model": "judge-test-model-2",
"answers": {"urgent": {"type": "noul", "noul": 0.93}},
"usage": {"input_tokens": 312, "output_tokens": 48}
})))
.expect(1)
.mount(&server)
.await;
let response = provider(server.uri())
.judge(request())
.await
.expect("judged");
assert_eq!(response.model, "judge-test-model-2");
assert_eq!(response.noul("urgent"), Some(0.93));
assert_eq!(response.usage.input_tokens, 312);
assert!(matches!(
response.answers.get("urgent"),
Some(Answer::Noul { noul }) if (*noul - 0.93).abs() < f64::EPSILON
));
}
#[tokio::test]
async fn extra_gateway_fields_are_ignored_not_required() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/systemone"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "gen-01JAR3X9K2",
"provider": "routed-provider",
"model": "judge-test-model-2",
"answers": {"urgent": {"type": "noul", "noul": 0.2}},
"usage": {"input_tokens": 100, "output_tokens": 10, "cost": 0.0004}
})))
.expect(1)
.mount(&server)
.await;
let response = provider(server.uri())
.judge(request())
.await
.expect("judged despite extra fields");
assert_eq!(response.noul("urgent"), Some(0.2));
}
#[tokio::test]
async fn unauthorized_maps_without_leaking_the_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(401).set_body_string("bad key gw-test-key"))
.mount(&server)
.await;
let err = provider(server.uri())
.judge(request())
.await
.expect_err("refused");
assert!(matches!(err, JudgmentError::Unauthorized), "{err:?}");
assert!(!err.to_string().contains("gw-test-key"));
}
#[tokio::test]
async fn rate_limit_carries_retry_after() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "7"))
.mount(&server)
.await;
let err = provider(server.uri())
.judge(request())
.await
.expect_err("limited");
assert!(
matches!(
err,
JudgmentError::RateLimited {
retry_after: Some(d)
} if d == std::time::Duration::from_secs(7)
),
"{err:?}"
);
}
#[tokio::test]
async fn validation_failure_is_invalid_and_overload_is_unavailable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_partial_json(
serde_json::json!({"model": "judge-test-model"}),
))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"detail": [{"loc": ["body", "questions"], "msg": "bad"}]
})))
.up_to_n_times(1)
.mount(&server)
.await;
let err = provider(server.uri())
.judge(request())
.await
.expect_err("invalid");
assert!(matches!(err, JudgmentError::Invalid(_)), "{err:?}");
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(529))
.mount(&server)
.await;
let err = provider(server.uri())
.judge(request())
.await
.expect_err("overloaded");
assert!(
matches!(err, JudgmentError::Unavailable { status: 529 }),
"{err:?}"
);
}
#[tokio::test]
async fn malformed_success_body_is_malformed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let err = provider(server.uri())
.judge(request())
.await
.expect_err("malformed");
assert!(matches!(err, JudgmentError::Malformed(_)), "{err:?}");
}
#[test]
fn debug_output_redacts_the_key() {
let debug = format!("{:?}", provider("http://localhost:1".to_owned()));
assert!(!debug.contains("gw-test-key"), "{debug}");
assert!(debug.contains("Sensitive(<redacted>)"), "{debug}");
}
#[test]
fn endpoint_trims_a_trailing_slash() {
let p = provider("http://localhost:1/".to_owned());
assert_eq!(p.endpoint(), "http://localhost:1/systemone");
}
#[tokio::test]
async fn payment_required_is_exhausted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(402))
.mount(&server)
.await;
let err = provider(server.uri())
.judge(request())
.await
.expect_err("payment required");
assert!(matches!(err, JudgmentError::Exhausted), "{err:?}");
}
#[tokio::test]
async fn conflict_is_invalid() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409))
.mount(&server)
.await;
let err = provider(server.uri())
.judge(request())
.await
.expect_err("conflict");
assert!(matches!(err, JudgmentError::Invalid(_)), "{err:?}");
}
#[test]
fn retry_after_parses_a_delta_seconds_value() {
assert_eq!(
parse_retry_after("120"),
Some(std::time::Duration::from_secs(120))
);
}
#[test]
fn retry_after_parses_an_http_date_value() {
let duration = parse_retry_after("Thu, 01 Jan 2099 00:00:00 GMT").expect("parses");
assert!(duration.as_secs() > 0, "{duration:?}");
}
#[test]
fn retry_after_rejects_an_unrecognized_value() {
assert_eq!(parse_retry_after("not a duration"), None);
}
}