pub mod jev;
pub mod openrouter;
use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use std::time::Duration;
use async_trait::async_trait;
pub use rig_typesafeai::types::{Answer, Question, Usage};
pub const OPENROUTER_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
pub const OPENROUTER_KEY_FILE_ENV: &str = "CPM_OPENROUTER_KEY_FILE";
pub const JEV_MODEL_ENV: &str = "CPM_JEV_MODEL";
pub const JEV_ENDPOINT_ENV: &str = "CPM_JEV_ENDPOINT";
pub const LLM_TIMEOUT_ENV: &str = "CPM_LLM_TIMEOUT_SECS";
pub const LLM_MODEL_ENV: &str = "CPM_LLM_MODEL";
pub const DEFAULT_JEV_MODEL: &str = "typesafe/jev-1.13";
pub const DEFAULT_JEV_ENDPOINT: &str = "https://openrouter.ai/api/v1/systemone";
pub const MAX_MODEL_ID_CHARS: usize = 128;
pub const MAX_REPORTED_MODEL_CHARS: usize = 64;
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
pub const TIMEOUT_SECS_RANGE: std::ops::RangeInclusive<u64> = 1..=300;
pub const DEFAULT_LLM_MODEL: &str = "openai/gpt-5-mini";
pub const REDACTED: &str = "[redacted]";
pub const MAX_ERROR_MESSAGE_CHARS: usize = 300;
pub const MAX_KEY_FILE_BYTES: u64 = 4096;
const MIN_KEY_FRAGMENT_CHARS: usize = 8;
#[derive(Clone, PartialEq, Eq)]
pub struct ApiKey(String);
impl ApiKey {
pub fn expose(&self) -> &str {
&self.0
}
pub fn scrub(&self, text: &str) -> String {
if self.0.is_empty() {
text.to_string()
} else {
text.replace(&self.0, REDACTED)
}
}
pub fn excerpt(&self, text: &str, max_chars: usize) -> String {
let scrubbed = self.scrub(text);
let mut out: Vec<char> = scrubbed.chars().take(max_chars).collect();
let key: Vec<char> = self.0.chars().collect();
let longest = key.len().min(out.len());
if let Some(len) = (MIN_KEY_FRAGMENT_CHARS..=longest)
.rev()
.find(|&len| out[out.len() - len..] == key[..len])
{
out.truncate(out.len() - len);
}
out.into_iter().collect()
}
}
impl fmt::Debug for ApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(REDACTED)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("{LLM_TIMEOUT_ENV} must be an integer number of seconds in 1..=300")]
InvalidTimeout,
#[error(
"{JEV_ENDPOINT_ENV} must be an https:// URL (http:// only for a loopback host) \
without credentials: {reason}"
)]
InvalidEndpoint { reason: String },
#[error("{JEV_MODEL_ENV} {reason}")]
InvalidModel { reason: &'static str },
#[error("{OPENROUTER_KEY_FILE_ENV}: key file cannot be read: {reason}")]
KeyFile { reason: String },
#[error("{OPENROUTER_KEY_FILE_ENV}: key file ignored: {reason}")]
KeyFileIgnored { reason: KeyFileIgnoredReason },
}
impl ConfigError {
pub fn review_reason(&self) -> String {
match self {
Self::InvalidTimeout => format!(
"{LLM_TIMEOUT_ENV} invalid: must be an integer number of seconds in 1..=300"
),
Self::InvalidEndpoint { reason } => format!("{JEV_ENDPOINT_ENV} invalid: {reason}"),
Self::InvalidModel { reason } => format!("{JEV_MODEL_ENV} invalid: {reason}"),
Self::KeyFile { reason } => {
format!("key file unreadable: {reason} ({OPENROUTER_KEY_FILE_ENV})")
}
Self::KeyFileIgnored { reason } => {
format!("key file ignored: {reason} ({OPENROUTER_KEY_FILE_ENV})")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyFileIgnoredReason {
WorldReadable,
Empty,
}
impl fmt::Display for KeyFileIgnoredReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::WorldReadable => "world-readable",
Self::Empty => "empty",
})
}
}
#[derive(Clone, Debug)]
pub struct LlmConfig {
api_key: ApiKey,
jev_model: String,
jev_endpoint: String,
timeout: Duration,
llm_model: String,
}
impl LlmConfig {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: ApiKey(api_key.into()),
jev_model: DEFAULT_JEV_MODEL.to_string(),
jev_endpoint: DEFAULT_JEV_ENDPOINT.to_string(),
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
llm_model: DEFAULT_LLM_MODEL.to_string(),
}
}
pub fn from_env() -> Result<Option<Self>, ConfigError> {
Self::from_lookup(|name| std::env::var(name).ok())
}
pub fn from_lookup(
lookup: impl Fn(&str) -> Option<String>,
) -> Result<Option<Self>, ConfigError> {
let non_blank = |name: &str| lookup(name).filter(|v| !v.trim().is_empty());
let key = match non_blank(OPENROUTER_API_KEY_ENV) {
Some(key) => Some(key.trim().to_string()),
None => match non_blank(OPENROUTER_KEY_FILE_ENV) {
Some(path) => read_key_file(Path::new(path.trim()))?,
None => None,
},
};
let timeout = match non_blank(LLM_TIMEOUT_ENV) {
Some(raw) => parse_timeout(&raw)?,
None => Duration::from_secs(DEFAULT_TIMEOUT_SECS),
};
let jev_endpoint = match non_blank(JEV_ENDPOINT_ENV) {
Some(raw) => parse_endpoint(raw.trim())?,
None => DEFAULT_JEV_ENDPOINT.to_string(),
};
let Some(key) = key else {
return Ok(None);
};
let mut config = Self::new(key)
.with_jev_endpoint(jev_endpoint)
.with_timeout(timeout);
if let Some(model) = non_blank(JEV_MODEL_ENV) {
config.jev_model = parse_model(model.trim(), &config.api_key)?;
}
if let Some(model) = non_blank(LLM_MODEL_ENV) {
config.llm_model = model.trim().to_string();
}
Ok(Some(config))
}
pub fn with_jev_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.jev_endpoint = endpoint.into();
self
}
pub fn with_jev_model(mut self, model: impl Into<String>) -> Self {
self.jev_model = model.into();
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn api_key(&self) -> &ApiKey {
&self.api_key
}
pub fn jev_model(&self) -> &str {
&self.jev_model
}
pub fn jev_endpoint(&self) -> &str {
&self.jev_endpoint
}
pub fn jev_endpoint_host(&self) -> Option<String> {
endpoint_host(&self.jev_endpoint)
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn llm_model(&self) -> &str {
&self.llm_model
}
}
fn parse_timeout(raw: &str) -> Result<Duration, ConfigError> {
raw.trim()
.parse::<u64>()
.ok()
.filter(|secs| TIMEOUT_SECS_RANGE.contains(secs))
.map(Duration::from_secs)
.ok_or(ConfigError::InvalidTimeout)
}
fn parse_model(raw: &str, key: &ApiKey) -> Result<String, ConfigError> {
let invalid = |reason| Err(ConfigError::InvalidModel { reason });
if raw.chars().count() > MAX_MODEL_ID_CHARS {
return invalid("must be at most 128 characters");
}
if !raw
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '-'))
{
return invalid("may only contain A-Z a-z 0-9 . _ : / -");
}
if !key.expose().is_empty() && raw.contains(key.expose()) {
return invalid("must not contain the OpenRouter key");
}
Ok(raw.to_string())
}
fn parse_endpoint(raw: &str) -> Result<String, ConfigError> {
let invalid = |reason: &str| ConfigError::InvalidEndpoint {
reason: reason.to_string(),
};
let url = url::Url::parse(raw).map_err(|e| invalid(&e.to_string()))?;
if !url.username().is_empty() || url.password().is_some() {
return Err(invalid("credentials in the URL are not allowed"));
}
let loopback = match url.host() {
Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => return Err(invalid("missing host")),
};
match url.scheme() {
"https" => Ok(raw.to_string()),
"http" if loopback => Ok(raw.to_string()),
"http" => Err(invalid("http:// is only allowed for a loopback host")),
_ => Err(invalid("unsupported scheme")),
}
}
fn read_key_file(path: &Path) -> Result<Option<String>, ConfigError> {
use std::io::Read;
let key_file_error = |reason: String| ConfigError::KeyFile { reason };
let io_error = |e: std::io::Error| key_file_error(e.kind().to_string());
let ignored = |reason: KeyFileIgnoredReason| ConfigError::KeyFileIgnored { reason };
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK);
let file = options.open(path).map_err(io_error)?;
let metadata = file.metadata().map_err(io_error)?;
if !metadata.is_file() {
return Err(key_file_error("not a regular file".to_string()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o004 != 0 {
return Err(ignored(KeyFileIgnoredReason::WorldReadable));
}
}
let mut bytes = Vec::new();
file.take(MAX_KEY_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(io_error)?;
if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
return Err(key_file_error(format!(
"larger than {MAX_KEY_FILE_BYTES} bytes"
)));
}
let contents =
String::from_utf8(bytes).map_err(|_| key_file_error("not valid UTF-8".to_string()))?;
let key = contents.trim();
if key.is_empty() {
return Err(ignored(KeyFileIgnoredReason::Empty));
}
Ok(Some(key.to_string()))
}
#[derive(Debug, Clone)]
pub struct Decisions {
pub answers: BTreeMap<String, Answer>,
pub model: String,
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum JudgmentErrorKind {
Unauthorized,
RateLimited,
Timeout,
Upstream,
Decode,
Transport,
InvalidRequest,
}
impl JudgmentErrorKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Unauthorized => "unauthorized",
Self::RateLimited => "rate_limited",
Self::Timeout => "timeout",
Self::Upstream => "upstream",
Self::Decode => "decode",
Self::Transport => "transport",
Self::InvalidRequest => "invalid_request",
}
}
}
impl fmt::Display for JudgmentErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{kind}: {message}")]
pub struct JudgmentError {
kind: JudgmentErrorKind,
message: String,
}
impl JudgmentError {
pub fn scrubbed(kind: JudgmentErrorKind, message: impl AsRef<str>, key: &ApiKey) -> Self {
Self {
kind,
message: key.excerpt(message.as_ref(), MAX_ERROR_MESSAGE_CHARS),
}
}
pub fn kind(&self) -> JudgmentErrorKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
}
#[async_trait]
pub trait JudgmentModel: Send + Sync {
async fn decide(
&self,
state: serde_json::Value,
questions: BTreeMap<String, Question>,
) -> Result<Decisions, JudgmentError>;
fn endpoint_host(&self) -> Option<String> {
None
}
fn model_id(&self) -> Option<String> {
None
}
}
pub fn endpoint_host(endpoint: &str) -> Option<String> {
url::Url::parse(endpoint)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
}