1pub mod jev;
21pub mod openrouter;
22
23use std::collections::BTreeMap;
24use std::fmt;
25use std::path::Path;
26use std::time::Duration;
27
28use async_trait::async_trait;
29pub use rig_typesafeai::types::{Answer, Question, Usage};
30
31pub const OPENROUTER_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
33pub const OPENROUTER_KEY_FILE_ENV: &str = "CPM_OPENROUTER_KEY_FILE";
35pub const JEV_MODEL_ENV: &str = "CPM_JEV_MODEL";
37pub const JEV_ENDPOINT_ENV: &str = "CPM_JEV_ENDPOINT";
39pub const LLM_TIMEOUT_ENV: &str = "CPM_LLM_TIMEOUT_SECS";
41pub const LLM_MODEL_ENV: &str = "CPM_LLM_MODEL";
43
44pub const DEFAULT_JEV_MODEL: &str = "typesafe/jev-1.13";
46pub const DEFAULT_JEV_ENDPOINT: &str = "https://openrouter.ai/api/v1/systemone";
48pub const MAX_MODEL_ID_CHARS: usize = 128;
50pub const MAX_REPORTED_MODEL_CHARS: usize = 64;
52pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
54pub const TIMEOUT_SECS_RANGE: std::ops::RangeInclusive<u64> = 1..=300;
56pub const DEFAULT_LLM_MODEL: &str = "openai/gpt-5-mini";
58
59pub const REDACTED: &str = "[redacted]";
61pub const MAX_ERROR_MESSAGE_CHARS: usize = 300;
63pub const MAX_KEY_FILE_BYTES: u64 = 4096;
65const MIN_KEY_FRAGMENT_CHARS: usize = 8;
67
68#[derive(Clone, PartialEq, Eq)]
71pub struct ApiKey(String);
72
73impl ApiKey {
74 pub fn expose(&self) -> &str {
77 &self.0
78 }
79
80 pub fn scrub(&self, text: &str) -> String {
82 if self.0.is_empty() {
83 text.to_string()
84 } else {
85 text.replace(&self.0, REDACTED)
86 }
87 }
88
89 pub fn excerpt(&self, text: &str, max_chars: usize) -> String {
94 let scrubbed = self.scrub(text);
95 let mut out: Vec<char> = scrubbed.chars().take(max_chars).collect();
96 let key: Vec<char> = self.0.chars().collect();
97 let longest = key.len().min(out.len());
98 if let Some(len) = (MIN_KEY_FRAGMENT_CHARS..=longest)
99 .rev()
100 .find(|&len| out[out.len() - len..] == key[..len])
101 {
102 out.truncate(out.len() - len);
103 }
104 out.into_iter().collect()
105 }
106}
107
108impl fmt::Debug for ApiKey {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 f.write_str(REDACTED)
111 }
112}
113
114#[derive(Debug, thiserror::Error)]
120pub enum ConfigError {
121 #[error("{LLM_TIMEOUT_ENV} must be an integer number of seconds in 1..=300")]
124 InvalidTimeout,
125 #[error(
128 "{JEV_ENDPOINT_ENV} must be an https:// URL (http:// only for a loopback host) \
129 without credentials: {reason}"
130 )]
131 InvalidEndpoint { reason: String },
132 #[error("{JEV_MODEL_ENV} {reason}")]
136 InvalidModel { reason: &'static str },
137 #[error("{OPENROUTER_KEY_FILE_ENV}: key file cannot be read: {reason}")]
141 KeyFile { reason: String },
142 #[error("{OPENROUTER_KEY_FILE_ENV}: key file ignored: {reason}")]
145 KeyFileIgnored { reason: KeyFileIgnoredReason },
146}
147
148impl ConfigError {
149 pub fn review_reason(&self) -> String {
153 match self {
154 Self::InvalidTimeout => format!(
155 "{LLM_TIMEOUT_ENV} invalid: must be an integer number of seconds in 1..=300"
156 ),
157 Self::InvalidEndpoint { reason } => format!("{JEV_ENDPOINT_ENV} invalid: {reason}"),
158 Self::InvalidModel { reason } => format!("{JEV_MODEL_ENV} invalid: {reason}"),
159 Self::KeyFile { reason } => {
160 format!("key file unreadable: {reason} ({OPENROUTER_KEY_FILE_ENV})")
161 }
162 Self::KeyFileIgnored { reason } => {
163 format!("key file ignored: {reason} ({OPENROUTER_KEY_FILE_ENV})")
164 }
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum KeyFileIgnoredReason {
172 WorldReadable,
174 Empty,
176}
177
178impl fmt::Display for KeyFileIgnoredReason {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 f.write_str(match self {
181 Self::WorldReadable => "world-readable",
182 Self::Empty => "empty",
183 })
184 }
185}
186
187#[derive(Clone, Debug)]
189pub struct LlmConfig {
190 api_key: ApiKey,
191 jev_model: String,
192 jev_endpoint: String,
193 timeout: Duration,
194 llm_model: String,
195}
196
197impl LlmConfig {
198 pub fn new(api_key: impl Into<String>) -> Self {
200 Self {
201 api_key: ApiKey(api_key.into()),
202 jev_model: DEFAULT_JEV_MODEL.to_string(),
203 jev_endpoint: DEFAULT_JEV_ENDPOINT.to_string(),
204 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
205 llm_model: DEFAULT_LLM_MODEL.to_string(),
206 }
207 }
208
209 pub fn from_env() -> Result<Option<Self>, ConfigError> {
211 Self::from_lookup(|name| std::env::var(name).ok())
212 }
213
214 pub fn from_lookup(
223 lookup: impl Fn(&str) -> Option<String>,
224 ) -> Result<Option<Self>, ConfigError> {
225 let non_blank = |name: &str| lookup(name).filter(|v| !v.trim().is_empty());
226
227 let key = match non_blank(OPENROUTER_API_KEY_ENV) {
228 Some(key) => Some(key.trim().to_string()),
229 None => match non_blank(OPENROUTER_KEY_FILE_ENV) {
230 Some(path) => read_key_file(Path::new(path.trim()))?,
231 None => None,
232 },
233 };
234
235 let timeout = match non_blank(LLM_TIMEOUT_ENV) {
236 Some(raw) => parse_timeout(&raw)?,
237 None => Duration::from_secs(DEFAULT_TIMEOUT_SECS),
238 };
239 let jev_endpoint = match non_blank(JEV_ENDPOINT_ENV) {
240 Some(raw) => parse_endpoint(raw.trim())?,
241 None => DEFAULT_JEV_ENDPOINT.to_string(),
242 };
243 let Some(key) = key else {
244 return Ok(None);
245 };
246 let mut config = Self::new(key)
247 .with_jev_endpoint(jev_endpoint)
248 .with_timeout(timeout);
249 if let Some(model) = non_blank(JEV_MODEL_ENV) {
250 config.jev_model = parse_model(model.trim(), &config.api_key)?;
251 }
252 if let Some(model) = non_blank(LLM_MODEL_ENV) {
253 config.llm_model = model.trim().to_string();
254 }
255 Ok(Some(config))
256 }
257
258 pub fn with_jev_endpoint(mut self, endpoint: impl Into<String>) -> Self {
262 self.jev_endpoint = endpoint.into();
263 self
264 }
265
266 pub fn with_jev_model(mut self, model: impl Into<String>) -> Self {
268 self.jev_model = model.into();
269 self
270 }
271
272 pub fn with_timeout(mut self, timeout: Duration) -> Self {
275 self.timeout = timeout;
276 self
277 }
278
279 pub fn api_key(&self) -> &ApiKey {
281 &self.api_key
282 }
283
284 pub fn jev_model(&self) -> &str {
286 &self.jev_model
287 }
288
289 pub fn jev_endpoint(&self) -> &str {
291 &self.jev_endpoint
292 }
293
294 pub fn jev_endpoint_host(&self) -> Option<String> {
296 endpoint_host(&self.jev_endpoint)
297 }
298
299 pub fn timeout(&self) -> Duration {
301 self.timeout
302 }
303
304 pub fn llm_model(&self) -> &str {
306 &self.llm_model
307 }
308}
309
310fn parse_timeout(raw: &str) -> Result<Duration, ConfigError> {
311 raw.trim()
312 .parse::<u64>()
313 .ok()
314 .filter(|secs| TIMEOUT_SECS_RANGE.contains(secs))
315 .map(Duration::from_secs)
316 .ok_or(ConfigError::InvalidTimeout)
317}
318
319fn parse_model(raw: &str, key: &ApiKey) -> Result<String, ConfigError> {
322 let invalid = |reason| Err(ConfigError::InvalidModel { reason });
323 if raw.chars().count() > MAX_MODEL_ID_CHARS {
324 return invalid("must be at most 128 characters");
325 }
326 if !raw
327 .chars()
328 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '-'))
329 {
330 return invalid("may only contain A-Z a-z 0-9 . _ : / -");
331 }
332 if !key.expose().is_empty() && raw.contains(key.expose()) {
333 return invalid("must not contain the OpenRouter key");
334 }
335 Ok(raw.to_string())
336}
337
338fn parse_endpoint(raw: &str) -> Result<String, ConfigError> {
339 let invalid = |reason: &str| ConfigError::InvalidEndpoint {
340 reason: reason.to_string(),
341 };
342 let url = url::Url::parse(raw).map_err(|e| invalid(&e.to_string()))?;
345 if !url.username().is_empty() || url.password().is_some() {
346 return Err(invalid("credentials in the URL are not allowed"));
347 }
348 let loopback = match url.host() {
349 Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
350 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
351 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
352 None => return Err(invalid("missing host")),
353 };
354 match url.scheme() {
355 "https" => Ok(raw.to_string()),
356 "http" if loopback => Ok(raw.to_string()),
357 "http" => Err(invalid("http:// is only allowed for a loopback host")),
358 _ => Err(invalid("unsupported scheme")),
359 }
360}
361
362fn read_key_file(path: &Path) -> Result<Option<String>, ConfigError> {
367 use std::io::Read;
368 let key_file_error = |reason: String| ConfigError::KeyFile { reason };
369 let io_error = |e: std::io::Error| key_file_error(e.kind().to_string());
370 let ignored = |reason: KeyFileIgnoredReason| ConfigError::KeyFileIgnored { reason };
371
372 let mut options = std::fs::OpenOptions::new();
373 options.read(true);
374 #[cfg(unix)]
377 std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK);
378 let file = options.open(path).map_err(io_error)?;
379 let metadata = file.metadata().map_err(io_error)?;
380 if !metadata.is_file() {
381 return Err(key_file_error("not a regular file".to_string()));
382 }
383 #[cfg(unix)]
384 {
385 use std::os::unix::fs::PermissionsExt;
386 if metadata.permissions().mode() & 0o004 != 0 {
387 return Err(ignored(KeyFileIgnoredReason::WorldReadable));
388 }
389 }
390 let mut bytes = Vec::new();
391 file.take(MAX_KEY_FILE_BYTES + 1)
392 .read_to_end(&mut bytes)
393 .map_err(io_error)?;
394 if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
395 return Err(key_file_error(format!(
396 "larger than {MAX_KEY_FILE_BYTES} bytes"
397 )));
398 }
399 let contents =
400 String::from_utf8(bytes).map_err(|_| key_file_error("not valid UTF-8".to_string()))?;
401 let key = contents.trim();
402 if key.is_empty() {
403 return Err(ignored(KeyFileIgnoredReason::Empty));
404 }
405 Ok(Some(key.to_string()))
406}
407
408#[derive(Debug, Clone)]
410pub struct Decisions {
411 pub answers: BTreeMap<String, Answer>,
413 pub model: String,
415 pub usage: Option<Usage>,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
421pub enum JudgmentErrorKind {
422 Unauthorized,
424 RateLimited,
426 Timeout,
428 Upstream,
430 Decode,
432 Transport,
434 InvalidRequest,
436}
437
438impl JudgmentErrorKind {
439 pub fn as_str(self) -> &'static str {
441 match self {
442 Self::Unauthorized => "unauthorized",
443 Self::RateLimited => "rate_limited",
444 Self::Timeout => "timeout",
445 Self::Upstream => "upstream",
446 Self::Decode => "decode",
447 Self::Transport => "transport",
448 Self::InvalidRequest => "invalid_request",
449 }
450 }
451}
452
453impl fmt::Display for JudgmentErrorKind {
454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455 f.write_str(self.as_str())
456 }
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
467#[error("{kind}: {message}")]
468pub struct JudgmentError {
469 kind: JudgmentErrorKind,
470 message: String,
471}
472
473impl JudgmentError {
474 pub fn scrubbed(kind: JudgmentErrorKind, message: impl AsRef<str>, key: &ApiKey) -> Self {
477 Self {
478 kind,
479 message: key.excerpt(message.as_ref(), MAX_ERROR_MESSAGE_CHARS),
480 }
481 }
482
483 pub fn kind(&self) -> JudgmentErrorKind {
485 self.kind
486 }
487
488 pub fn message(&self) -> &str {
490 &self.message
491 }
492}
493
494#[async_trait]
497pub trait JudgmentModel: Send + Sync {
498 async fn decide(
500 &self,
501 state: serde_json::Value,
502 questions: BTreeMap<String, Question>,
503 ) -> Result<Decisions, JudgmentError>;
504
505 fn endpoint_host(&self) -> Option<String> {
508 None
509 }
510
511 fn model_id(&self) -> Option<String> {
514 None
515 }
516}
517
518pub fn endpoint_host(endpoint: &str) -> Option<String> {
521 url::Url::parse(endpoint)
522 .ok()
523 .and_then(|url| url.host_str().map(str::to_string))
524}