Skip to main content

cpm_planner/llm/
mod.rs

1//! OpenRouter / Jev configuration and the [`JudgmentModel`] seam (P6, #26).
2//!
3//! [`LlmConfig::from_env`] reads the OpenRouter key and the Jev / generative
4//! model settings. `Ok(None)` means "no key configured": callers report
5//! `review_unavailable` rather than silently falling back.
6//!
7//! [`JudgmentModel`] is the one async seam `plan.review` depends on, so the
8//! review logic is testable with a fake. [`jev::JevJudge`] implements it with
9//! `rig-typesafeai` pointed at OpenRouter's System One endpoint, and
10//! [`openrouter::chat_client`] builds the rig-core OpenRouter chat client for
11//! future headless use.
12//!
13//! # Key hygiene
14//!
15//! The key lives in an [`ApiKey`] whose `Debug` is redacted and which has no
16//! `Display`/`Serialize`. [`JudgmentError`] messages are scrubbed of the key
17//! before they are built, so neither logs, errors, audit records nor tool
18//! output can carry it.
19
20pub 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
31/// Environment variable holding the OpenRouter key.
32pub const OPENROUTER_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
33/// Environment variable naming a file that holds the OpenRouter key.
34pub const OPENROUTER_KEY_FILE_ENV: &str = "CPM_OPENROUTER_KEY_FILE";
35/// Environment variable overriding the Jev model id.
36pub const JEV_MODEL_ENV: &str = "CPM_JEV_MODEL";
37/// Environment variable overriding the Jev endpoint URL.
38pub const JEV_ENDPOINT_ENV: &str = "CPM_JEV_ENDPOINT";
39/// Environment variable bounding every LLM network call, in seconds.
40pub const LLM_TIMEOUT_ENV: &str = "CPM_LLM_TIMEOUT_SECS";
41/// Environment variable naming the generative (chat) model.
42pub const LLM_MODEL_ENV: &str = "CPM_LLM_MODEL";
43
44/// Default Jev model on OpenRouter.
45pub const DEFAULT_JEV_MODEL: &str = "typesafe/jev-1.13";
46/// Default Jev endpoint: OpenRouter's System One route.
47pub const DEFAULT_JEV_ENDPOINT: &str = "https://openrouter.ai/api/v1/systemone";
48/// Longest accepted [`JEV_MODEL_ENV`] value, in chars.
49pub const MAX_MODEL_ID_CHARS: usize = 128;
50/// Longest provider-reported model id kept in a review, in chars.
51pub const MAX_REPORTED_MODEL_CHARS: usize = 64;
52/// Default per-call timeout, seconds.
53pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
54/// Inclusive bounds for [`LLM_TIMEOUT_ENV`], seconds.
55pub const TIMEOUT_SECS_RANGE: std::ops::RangeInclusive<u64> = 1..=300;
56/// Default generative model (not used by any tool yet).
57pub const DEFAULT_LLM_MODEL: &str = "openai/gpt-5-mini";
58
59/// Redaction marker used wherever the key would otherwise appear.
60pub const REDACTED: &str = "[redacted]";
61/// Upper bound on [`JudgmentError::message`], in chars (it becomes tool output).
62pub const MAX_ERROR_MESSAGE_CHARS: usize = 300;
63/// Largest key file accepted, in bytes.
64pub const MAX_KEY_FILE_BYTES: u64 = 4096;
65/// Shortest key prefix treated as a leak when it ends a truncated excerpt.
66const MIN_KEY_FRAGMENT_CHARS: usize = 8;
67
68/// The OpenRouter key. `Debug` is redacted; there is deliberately no
69/// `Display` or `Serialize`.
70#[derive(Clone, PartialEq, Eq)]
71pub struct ApiKey(String);
72
73impl ApiKey {
74    /// The raw key. Every call site is a place the key can leak: only the
75    /// HTTP clients should call this.
76    pub fn expose(&self) -> &str {
77        &self.0
78    }
79
80    /// `text` with every occurrence of the key replaced by [`REDACTED`].
81    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    /// At most `max_chars` of `text`, safe to show: the WHOLE text is
90    /// scrubbed first, then truncated, and a trailing key prefix of
91    /// `MIN_KEY_FRAGMENT_CHARS` (8)+ chars (a key cut by the boundary, or a
92    /// partial key in the input) is dropped.
93    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/// Why the LLM configuration is unusable.
115///
116/// Neither `Display` nor `Debug` carries a variable's value or the key-file
117/// path: a key pasted into the wrong variable must not reach a log. Each
118/// message names the variable and the rule it breaks.
119#[derive(Debug, thiserror::Error)]
120pub enum ConfigError {
121    /// `CPM_LLM_TIMEOUT_SECS` is not an integer in 1..=300. The value is not
122    /// kept.
123    #[error("{LLM_TIMEOUT_ENV} must be an integer number of seconds in 1..=300")]
124    InvalidTimeout,
125    /// `CPM_JEV_ENDPOINT` is not an acceptable URL. The value itself is not
126    /// echoed (it may carry credentials).
127    #[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    /// `CPM_JEV_MODEL` is not a plausible model id (at most
133    /// [`MAX_MODEL_ID_CHARS`] chars of `[A-Za-z0-9._:/-]`), or it contains
134    /// the configured key. The value is not kept.
135    #[error("{JEV_MODEL_ENV} {reason}")]
136    InvalidModel { reason: &'static str },
137    /// `CPM_OPENROUTER_KEY_FILE` names something that cannot be used as a
138    /// key file (missing, unreadable, not a regular file, too large). The
139    /// path is not kept.
140    #[error("{OPENROUTER_KEY_FILE_ENV}: key file cannot be read: {reason}")]
141    KeyFile { reason: String },
142    /// The key file was read but deliberately ignored. Distinct from "no key
143    /// configured" so callers can say why. The path is not kept.
144    #[error("{OPENROUTER_KEY_FILE_ENV}: key file ignored: {reason}")]
145    KeyFileIgnored { reason: KeyFileIgnoredReason },
146}
147
148impl ConfigError {
149    /// A short `plan.review` `review_unavailable` reason naming the setting.
150    /// It carries neither the key nor any path or raw value (only the
151    /// variable name and the validation reason).
152    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/// Why a key file was ignored.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum KeyFileIgnoredReason {
172    /// Other users can read it (unix mode `o+r`); `chmod 600` to use it.
173    WorldReadable,
174    /// It holds only whitespace.
175    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/// OpenRouter / Jev settings. Only constructed when a key is configured.
188#[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    /// Settings for `api_key` with every other field at its default.
199    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    /// Read the process environment. `Ok(None)` = no key configured.
210    pub fn from_env() -> Result<Option<Self>, ConfigError> {
211        Self::from_lookup(|name| std::env::var(name).ok())
212    }
213
214    /// [`Self::from_env`] over an arbitrary variable lookup (tests).
215    ///
216    /// The key comes from `OPENROUTER_API_KEY`, else from the file named by
217    /// `CPM_OPENROUTER_KEY_FILE` (contents trimmed). A world-readable (unix)
218    /// or empty key file is [`ConfigError::KeyFileIgnored`] (not logged here:
219    /// the caller logs it once).
220    /// A blank env key counts as absent. The endpoint is validated here:
221    /// https, or http to a loopback host, with no credentials.
222    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    /// Replace the Jev endpoint (complete URL including the route). Not
259    /// validated: programmatic callers (tests) own it; the env path is
260    /// validated by [`Self::from_lookup`].
261    pub fn with_jev_endpoint(mut self, endpoint: impl Into<String>) -> Self {
262        self.jev_endpoint = endpoint.into();
263        self
264    }
265
266    /// Replace the Jev model id.
267    pub fn with_jev_model(mut self, model: impl Into<String>) -> Self {
268        self.jev_model = model.into();
269        self
270    }
271
272    /// Replace the per-call timeout. The env var is bounded to 1..=300 s;
273    /// programmatic callers (tests) may pick any duration.
274    pub fn with_timeout(mut self, timeout: Duration) -> Self {
275        self.timeout = timeout;
276        self
277    }
278
279    /// The OpenRouter key (redacted `Debug`).
280    pub fn api_key(&self) -> &ApiKey {
281        &self.api_key
282    }
283
284    /// The Jev model id.
285    pub fn jev_model(&self) -> &str {
286        &self.jev_model
287    }
288
289    /// The complete Jev endpoint URL.
290    pub fn jev_endpoint(&self) -> &str {
291        &self.jev_endpoint
292    }
293
294    /// Host of the Jev endpoint, from the parsed URL (see [`endpoint_host`]).
295    pub fn jev_endpoint_host(&self) -> Option<String> {
296        endpoint_host(&self.jev_endpoint)
297    }
298
299    /// The bound applied to every network call.
300    pub fn timeout(&self) -> Duration {
301        self.timeout
302    }
303
304    /// The generative (chat) model id.
305    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
319/// A model id: 1..=[`MAX_MODEL_ID_CHARS`] chars of `[A-Za-z0-9._:/-]`, not
320/// containing `key`.
321fn 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    // url's ParseError messages are generic ("invalid port number") and
343    // never echo the input.
344    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
362/// Read and trim the key file. It must be a regular file of at most
363/// [`MAX_KEY_FILE_BYTES`]; a world-readable (unix) or empty file is
364/// [`ConfigError::KeyFileIgnored`]. Nothing is logged here (the caller logs
365/// the error once); errors carry neither the path nor the key.
366fn 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    // Non-blocking open so a FIFO cannot hang startup; the handle is then
375    // checked to be a regular file, so the file checked is the file read.
376    #[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/// Jev's answers to one batched evaluation.
409#[derive(Debug, Clone)]
410pub struct Decisions {
411    /// One answer per question id, keyed exactly as asked.
412    pub answers: BTreeMap<String, Answer>,
413    /// The model id the provider reports having used.
414    pub model: String,
415    /// Token accounting when the provider reports it.
416    pub usage: Option<Usage>,
417}
418
419/// Failure class of a [`JudgmentError`].
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
421pub enum JudgmentErrorKind {
422    /// The provider rejected the key (401/403).
423    Unauthorized,
424    /// The provider throttled the call (429).
425    RateLimited,
426    /// No reply within `CPM_LLM_TIMEOUT_SECS`.
427    Timeout,
428    /// Any other provider-side failure (5xx, other non-success statuses).
429    Upstream,
430    /// The reply arrived but does not decode or does not answer the request.
431    Decode,
432    /// No reply: connection refused/reset, DNS, TLS.
433    Transport,
434    /// The questions or state were rejected locally before sending.
435    InvalidRequest,
436}
437
438impl JudgmentErrorKind {
439    /// Stable snake_case name used in `review_unavailable` reasons.
440    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/// A classed judgment failure. `message` never contains the key, is at most
460/// [`MAX_ERROR_MESSAGE_CHARS`] chars, and carries only a short reason (an
461/// HTTP status, a decode reason) — never the upstream body.
462///
463/// The fields are private and the only constructor,
464/// [`JudgmentError::scrubbed`], scrubs the message of the key, so no caller
465/// can build an error carrying it.
466#[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    /// Build an error whose message is [`ApiKey::excerpt`]ed: scrubbed of
475    /// `key`, then cut to [`MAX_ERROR_MESSAGE_CHARS`].
476    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    /// The failure class.
484    pub fn kind(&self) -> JudgmentErrorKind {
485        self.kind
486    }
487
488    /// A safe, human-readable detail.
489    pub fn message(&self) -> &str {
490        &self.message
491    }
492}
493
494/// A calibrated-judgment model: one batched call answering runtime-defined
495/// questions about one shared state.
496#[async_trait]
497pub trait JudgmentModel: Send + Sync {
498    /// Ask every question in `questions` about `state` in a single call.
499    async fn decide(
500        &self,
501        state: serde_json::Value,
502        questions: BTreeMap<String, Question>,
503    ) -> Result<Decisions, JudgmentError>;
504
505    /// Host of the endpoint this model calls, for the review report. `None`
506    /// when unknown (fakes) or unparseable.
507    fn endpoint_host(&self) -> Option<String> {
508        None
509    }
510
511    /// The model id this judge is configured to ask, for audit records when
512    /// the provider never reported one. `None` when unknown (fakes).
513    fn model_id(&self) -> Option<String> {
514        None
515    }
516}
517
518/// Host of `endpoint` as [`url::Url`] parses it (lower-cased, no port,
519/// userinfo, path or query). `None` when it does not parse or has no host.
520pub 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}