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 = "deepseek/deepseek-v4.1-flash";
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("{LLM_MODEL_ENV} must name a commodity model, not a frontier vendor: {model}")]
141 FrontierModel { model: String },
142 #[error("{OPENROUTER_KEY_FILE_ENV}: key file cannot be read: {reason}")]
146 KeyFile { reason: String },
147 #[error("{OPENROUTER_KEY_FILE_ENV}: key file ignored: {reason}")]
150 KeyFileIgnored { reason: KeyFileIgnoredReason },
151}
152
153impl ConfigError {
154 pub fn review_reason(&self) -> String {
158 match self {
159 Self::InvalidTimeout => format!(
160 "{LLM_TIMEOUT_ENV} invalid: must be an integer number of seconds in 1..=300"
161 ),
162 Self::InvalidEndpoint { reason } => format!("{JEV_ENDPOINT_ENV} invalid: {reason}"),
163 Self::InvalidModel { reason } => format!("{JEV_MODEL_ENV} invalid: {reason}"),
164 Self::FrontierModel { model } => {
165 format!("{LLM_MODEL_ENV} refused frontier model: {model}")
166 }
167 Self::KeyFile { reason } => {
168 format!("key file unreadable: {reason} ({OPENROUTER_KEY_FILE_ENV})")
169 }
170 Self::KeyFileIgnored { reason } => {
171 format!("key file ignored: {reason} ({OPENROUTER_KEY_FILE_ENV})")
172 }
173 }
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum KeyFileIgnoredReason {
180 WorldReadable,
182 Empty,
184}
185
186impl fmt::Display for KeyFileIgnoredReason {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 f.write_str(match self {
189 Self::WorldReadable => "world-readable",
190 Self::Empty => "empty",
191 })
192 }
193}
194
195#[derive(Clone, Debug)]
197pub struct LlmConfig {
198 api_key: ApiKey,
199 jev_model: String,
200 jev_endpoint: String,
201 timeout: Duration,
202 llm_model: String,
203}
204
205impl LlmConfig {
206 pub fn new(api_key: impl Into<String>) -> Self {
208 Self {
209 api_key: ApiKey(api_key.into()),
210 jev_model: DEFAULT_JEV_MODEL.to_string(),
211 jev_endpoint: DEFAULT_JEV_ENDPOINT.to_string(),
212 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
213 llm_model: DEFAULT_LLM_MODEL.to_string(),
214 }
215 }
216
217 pub fn from_env() -> Result<Option<Self>, ConfigError> {
219 Self::from_lookup(|name| std::env::var(name).ok())
220 }
221
222 pub fn from_lookup(
231 lookup: impl Fn(&str) -> Option<String>,
232 ) -> Result<Option<Self>, ConfigError> {
233 let non_blank = |name: &str| lookup(name).filter(|v| !v.trim().is_empty());
234
235 let key = match non_blank(OPENROUTER_API_KEY_ENV) {
236 Some(key) => Some(key.trim().to_string()),
237 None => match non_blank(OPENROUTER_KEY_FILE_ENV) {
238 Some(path) => read_key_file(Path::new(path.trim()))?,
239 None => None,
240 },
241 };
242
243 let timeout = match non_blank(LLM_TIMEOUT_ENV) {
244 Some(raw) => parse_timeout(&raw)?,
245 None => Duration::from_secs(DEFAULT_TIMEOUT_SECS),
246 };
247 let jev_endpoint = match non_blank(JEV_ENDPOINT_ENV) {
248 Some(raw) => parse_endpoint(raw.trim())?,
249 None => DEFAULT_JEV_ENDPOINT.to_string(),
250 };
251 let Some(key) = key else {
252 return Ok(None);
253 };
254 let mut config = Self::new(key)
255 .with_jev_endpoint(jev_endpoint)
256 .with_timeout(timeout);
257 if let Some(model) = non_blank(JEV_MODEL_ENV) {
258 config.jev_model = parse_model(model.trim(), &config.api_key)?;
259 }
260 if let Some(model) = non_blank(LLM_MODEL_ENV) {
261 config.llm_model = parse_generative_model(model.trim(), &config.api_key)?;
262 }
263 Ok(Some(config))
264 }
265
266 pub fn with_jev_endpoint(mut self, endpoint: impl Into<String>) -> Self {
270 self.jev_endpoint = endpoint.into();
271 self
272 }
273
274 pub fn with_jev_model(mut self, model: impl Into<String>) -> Self {
276 self.jev_model = model.into();
277 self
278 }
279
280 pub fn with_timeout(mut self, timeout: Duration) -> Self {
283 self.timeout = timeout;
284 self
285 }
286
287 pub fn api_key(&self) -> &ApiKey {
289 &self.api_key
290 }
291
292 pub fn jev_model(&self) -> &str {
294 &self.jev_model
295 }
296
297 pub fn jev_endpoint(&self) -> &str {
299 &self.jev_endpoint
300 }
301
302 pub fn jev_endpoint_host(&self) -> Option<String> {
304 endpoint_host(&self.jev_endpoint)
305 }
306
307 pub fn timeout(&self) -> Duration {
309 self.timeout
310 }
311
312 pub fn llm_model(&self) -> &str {
314 &self.llm_model
315 }
316}
317
318fn parse_timeout(raw: &str) -> Result<Duration, ConfigError> {
319 raw.trim()
320 .parse::<u64>()
321 .ok()
322 .filter(|secs| TIMEOUT_SECS_RANGE.contains(secs))
323 .map(Duration::from_secs)
324 .ok_or(ConfigError::InvalidTimeout)
325}
326
327fn parse_model(raw: &str, key: &ApiKey) -> Result<String, ConfigError> {
330 let invalid = |reason| Err(ConfigError::InvalidModel { reason });
331 if raw.chars().count() > MAX_MODEL_ID_CHARS {
332 return invalid("must be at most 128 characters");
333 }
334 if !raw
335 .chars()
336 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '-'))
337 {
338 return invalid("may only contain A-Z a-z 0-9 . _ : / -");
339 }
340 if !key.expose().is_empty() && raw.contains(key.expose()) {
341 return invalid("must not contain the OpenRouter key");
342 }
343 Ok(raw.to_string())
344}
345
346fn parse_generative_model(raw: &str, key: &ApiKey) -> Result<String, ConfigError> {
350 let model = parse_model(raw, key)?;
351 if frontier_vendor(&model).is_some() {
352 return Err(ConfigError::FrontierModel { model });
353 }
354 Ok(model)
355}
356
357fn frontier_vendor(model: &str) -> Option<&'static str> {
359 if model.starts_with("openai/gpt-oss") {
360 return None;
361 }
362 ["anthropic/", "openai/", "google/", "x-ai/", "xai/"]
363 .into_iter()
364 .find(|prefix| model.starts_with(prefix))
365}
366
367fn parse_endpoint(raw: &str) -> Result<String, ConfigError> {
368 let invalid = |reason: &str| ConfigError::InvalidEndpoint {
369 reason: reason.to_string(),
370 };
371 let url = url::Url::parse(raw).map_err(|e| invalid(&e.to_string()))?;
374 if !url.username().is_empty() || url.password().is_some() {
375 return Err(invalid("credentials in the URL are not allowed"));
376 }
377 let loopback = match url.host() {
378 Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
379 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
380 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
381 None => return Err(invalid("missing host")),
382 };
383 match url.scheme() {
384 "https" => Ok(raw.to_string()),
385 "http" if loopback => Ok(raw.to_string()),
386 "http" => Err(invalid("http:// is only allowed for a loopback host")),
387 _ => Err(invalid("unsupported scheme")),
388 }
389}
390
391fn read_key_file(path: &Path) -> Result<Option<String>, ConfigError> {
396 use std::io::Read;
397 let key_file_error = |reason: String| ConfigError::KeyFile { reason };
398 let io_error = |e: std::io::Error| key_file_error(e.kind().to_string());
399 let ignored = |reason: KeyFileIgnoredReason| ConfigError::KeyFileIgnored { reason };
400
401 let mut options = std::fs::OpenOptions::new();
402 options.read(true);
403 #[cfg(unix)]
406 std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK);
407 let file = options.open(path).map_err(io_error)?;
408 let metadata = file.metadata().map_err(io_error)?;
409 if !metadata.is_file() {
410 return Err(key_file_error("not a regular file".to_string()));
411 }
412 #[cfg(unix)]
413 {
414 use std::os::unix::fs::PermissionsExt;
415 if metadata.permissions().mode() & 0o004 != 0 {
416 return Err(ignored(KeyFileIgnoredReason::WorldReadable));
417 }
418 }
419 let mut bytes = Vec::new();
420 file.take(MAX_KEY_FILE_BYTES + 1)
421 .read_to_end(&mut bytes)
422 .map_err(io_error)?;
423 if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
424 return Err(key_file_error(format!(
425 "larger than {MAX_KEY_FILE_BYTES} bytes"
426 )));
427 }
428 let contents =
429 String::from_utf8(bytes).map_err(|_| key_file_error("not valid UTF-8".to_string()))?;
430 let key = contents.trim();
431 if key.is_empty() {
432 return Err(ignored(KeyFileIgnoredReason::Empty));
433 }
434 Ok(Some(key.to_string()))
435}
436
437#[derive(Debug, Clone)]
439pub struct Decisions {
440 pub answers: BTreeMap<String, Answer>,
442 pub model: String,
444 pub usage: Option<Usage>,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
450pub enum JudgmentErrorKind {
451 Unauthorized,
453 RateLimited,
455 Timeout,
457 Upstream,
459 Decode,
461 Transport,
463 InvalidRequest,
465}
466
467impl JudgmentErrorKind {
468 pub fn as_str(self) -> &'static str {
470 match self {
471 Self::Unauthorized => "unauthorized",
472 Self::RateLimited => "rate_limited",
473 Self::Timeout => "timeout",
474 Self::Upstream => "upstream",
475 Self::Decode => "decode",
476 Self::Transport => "transport",
477 Self::InvalidRequest => "invalid_request",
478 }
479 }
480}
481
482impl fmt::Display for JudgmentErrorKind {
483 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484 f.write_str(self.as_str())
485 }
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
496#[error("{kind}: {message}")]
497pub struct JudgmentError {
498 kind: JudgmentErrorKind,
499 message: String,
500}
501
502impl JudgmentError {
503 pub fn scrubbed(kind: JudgmentErrorKind, message: impl AsRef<str>, key: &ApiKey) -> Self {
506 Self {
507 kind,
508 message: key.excerpt(message.as_ref(), MAX_ERROR_MESSAGE_CHARS),
509 }
510 }
511
512 pub fn kind(&self) -> JudgmentErrorKind {
514 self.kind
515 }
516
517 pub fn message(&self) -> &str {
519 &self.message
520 }
521}
522
523#[async_trait]
526pub trait JudgmentModel: Send + Sync {
527 async fn decide(
529 &self,
530 state: serde_json::Value,
531 questions: BTreeMap<String, Question>,
532 ) -> Result<Decisions, JudgmentError>;
533
534 fn endpoint_host(&self) -> Option<String> {
537 None
538 }
539
540 fn model_id(&self) -> Option<String> {
543 None
544 }
545}
546
547pub fn endpoint_host(endpoint: &str) -> Option<String> {
550 url::Url::parse(endpoint)
551 .ok()
552 .and_then(|url| url.host_str().map(str::to_string))
553}