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: a commodity model, not a frontier vendor.
57pub const DEFAULT_LLM_MODEL: &str = "deepseek/deepseek-v4.1-flash";
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_LLM_MODEL` names a frontier vendor this build refuses in favour
138    /// of a commodity model. The id is kept: it already passed the model-id
139    /// and key checks in `parse_model`.
140    #[error("{LLM_MODEL_ENV} must name a commodity model, not a frontier vendor: {model}")]
141    FrontierModel { model: String },
142    /// `CPM_OPENROUTER_KEY_FILE` names something that cannot be used as a
143    /// key file (missing, unreadable, not a regular file, too large). The
144    /// path is not kept.
145    #[error("{OPENROUTER_KEY_FILE_ENV}: key file cannot be read: {reason}")]
146    KeyFile { reason: String },
147    /// The key file was read but deliberately ignored. Distinct from "no key
148    /// configured" so callers can say why. The path is not kept.
149    #[error("{OPENROUTER_KEY_FILE_ENV}: key file ignored: {reason}")]
150    KeyFileIgnored { reason: KeyFileIgnoredReason },
151}
152
153impl ConfigError {
154    /// A short `plan.review` `review_unavailable` reason naming the setting.
155    /// It carries neither the key nor any path or raw value (only the
156    /// variable name and the validation reason).
157    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/// Why a key file was ignored.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum KeyFileIgnoredReason {
180    /// Other users can read it (unix mode `o+r`); `chmod 600` to use it.
181    WorldReadable,
182    /// It holds only whitespace.
183    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/// OpenRouter / Jev settings. Only constructed when a key is configured.
196#[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    /// Settings for `api_key` with every other field at its default.
207    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    /// Read the process environment. `Ok(None)` = no key configured.
218    pub fn from_env() -> Result<Option<Self>, ConfigError> {
219        Self::from_lookup(|name| std::env::var(name).ok())
220    }
221
222    /// [`Self::from_env`] over an arbitrary variable lookup (tests).
223    ///
224    /// The key comes from `OPENROUTER_API_KEY`, else from the file named by
225    /// `CPM_OPENROUTER_KEY_FILE` (contents trimmed). A world-readable (unix)
226    /// or empty key file is [`ConfigError::KeyFileIgnored`] (not logged here:
227    /// the caller logs it once).
228    /// A blank env key counts as absent. The endpoint is validated here:
229    /// https, or http to a loopback host, with no credentials.
230    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    /// Replace the Jev endpoint (complete URL including the route). Not
267    /// validated: programmatic callers (tests) own it; the env path is
268    /// validated by [`Self::from_lookup`].
269    pub fn with_jev_endpoint(mut self, endpoint: impl Into<String>) -> Self {
270        self.jev_endpoint = endpoint.into();
271        self
272    }
273
274    /// Replace the Jev model id.
275    pub fn with_jev_model(mut self, model: impl Into<String>) -> Self {
276        self.jev_model = model.into();
277        self
278    }
279
280    /// Replace the per-call timeout. The env var is bounded to 1..=300 s;
281    /// programmatic callers (tests) may pick any duration.
282    pub fn with_timeout(mut self, timeout: Duration) -> Self {
283        self.timeout = timeout;
284        self
285    }
286
287    /// The OpenRouter key (redacted `Debug`).
288    pub fn api_key(&self) -> &ApiKey {
289        &self.api_key
290    }
291
292    /// The Jev model id.
293    pub fn jev_model(&self) -> &str {
294        &self.jev_model
295    }
296
297    /// The complete Jev endpoint URL.
298    pub fn jev_endpoint(&self) -> &str {
299        &self.jev_endpoint
300    }
301
302    /// Host of the Jev endpoint, from the parsed URL (see [`endpoint_host`]).
303    pub fn jev_endpoint_host(&self) -> Option<String> {
304        endpoint_host(&self.jev_endpoint)
305    }
306
307    /// The bound applied to every network call.
308    pub fn timeout(&self) -> Duration {
309        self.timeout
310    }
311
312    /// The generative (chat) model id.
313    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
327/// A model id: 1..=[`MAX_MODEL_ID_CHARS`] chars of `[A-Za-z0-9._:/-]`, not
328/// containing `key`.
329fn 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
346/// Validate a [`LLM_MODEL_ENV`] value and refuse frontier vendors, matching
347/// Junior's `model-policy.ts`. `openai/gpt-oss*` is the one `openai/`
348/// exception.
349fn 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
357/// The frontier vendor prefix `model` belongs to, if any.
358fn 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    // url's ParseError messages are generic ("invalid port number") and
372    // never echo the input.
373    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
391/// Read and trim the key file. It must be a regular file of at most
392/// [`MAX_KEY_FILE_BYTES`]; a world-readable (unix) or empty file is
393/// [`ConfigError::KeyFileIgnored`]. Nothing is logged here (the caller logs
394/// the error once); errors carry neither the path nor the key.
395fn 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    // Non-blocking open so a FIFO cannot hang startup; the handle is then
404    // checked to be a regular file, so the file checked is the file read.
405    #[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/// Jev's answers to one batched evaluation.
438#[derive(Debug, Clone)]
439pub struct Decisions {
440    /// One answer per question id, keyed exactly as asked.
441    pub answers: BTreeMap<String, Answer>,
442    /// The model id the provider reports having used.
443    pub model: String,
444    /// Token accounting when the provider reports it.
445    pub usage: Option<Usage>,
446}
447
448/// Failure class of a [`JudgmentError`].
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
450pub enum JudgmentErrorKind {
451    /// The provider rejected the key (401/403).
452    Unauthorized,
453    /// The provider throttled the call (429).
454    RateLimited,
455    /// No reply within `CPM_LLM_TIMEOUT_SECS`.
456    Timeout,
457    /// Any other provider-side failure (5xx, other non-success statuses).
458    Upstream,
459    /// The reply arrived but does not decode or does not answer the request.
460    Decode,
461    /// No reply: connection refused/reset, DNS, TLS.
462    Transport,
463    /// The questions or state were rejected locally before sending.
464    InvalidRequest,
465}
466
467impl JudgmentErrorKind {
468    /// Stable snake_case name used in `review_unavailable` reasons.
469    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/// A classed judgment failure. `message` never contains the key, is at most
489/// [`MAX_ERROR_MESSAGE_CHARS`] chars, and carries only a short reason (an
490/// HTTP status, a decode reason) — never the upstream body.
491///
492/// The fields are private and the only constructor,
493/// [`JudgmentError::scrubbed`], scrubs the message of the key, so no caller
494/// can build an error carrying it.
495#[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    /// Build an error whose message is [`ApiKey::excerpt`]ed: scrubbed of
504    /// `key`, then cut to [`MAX_ERROR_MESSAGE_CHARS`].
505    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    /// The failure class.
513    pub fn kind(&self) -> JudgmentErrorKind {
514        self.kind
515    }
516
517    /// A safe, human-readable detail.
518    pub fn message(&self) -> &str {
519        &self.message
520    }
521}
522
523/// A calibrated-judgment model: one batched call answering runtime-defined
524/// questions about one shared state.
525#[async_trait]
526pub trait JudgmentModel: Send + Sync {
527    /// Ask every question in `questions` about `state` in a single call.
528    async fn decide(
529        &self,
530        state: serde_json::Value,
531        questions: BTreeMap<String, Question>,
532    ) -> Result<Decisions, JudgmentError>;
533
534    /// Host of the endpoint this model calls, for the review report. `None`
535    /// when unknown (fakes) or unparseable.
536    fn endpoint_host(&self) -> Option<String> {
537        None
538    }
539
540    /// The model id this judge is configured to ask, for audit records when
541    /// the provider never reported one. `None` when unknown (fakes).
542    fn model_id(&self) -> Option<String> {
543        None
544    }
545}
546
547/// Host of `endpoint` as [`url::Url`] parses it (lower-cased, no port,
548/// userinfo, path or query). `None` when it does not parse or has no host.
549pub 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}