car-inference 0.49.0

Local model inference for CAR — Candle backend with Qwen3 models
//! Whether the Parslee credential CAR holds is actually *accepted* — as
//! distinct from whether one exists.
//!
//! # Why this module exists
//!
//! [`car_auth::access_token_is_available`] is documented as an existence-only
//! probe: it answers "has a V2 auth record with an active slot been published",
//! and nothing more. The registry passed that answer straight through as
//! `parslee_oauth_available`, so on a machine with a stale sign-in every
//! routing pass confidently offered the managed `parslee/*` aliases as
//! top-quality candidates and every request to them failed immediately with
//!
//! ```text
//! inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required
//! ```
//!
//! The router then walked the fallback chain — silently, per call, forever. The
//! operator paid a dead-lane tax on every model call and, at the coder contract
//! gate, outright failures, with nothing naming the cause (Parslee-ai/car#887).
//!
//! # The same shape as the gateway observation, for the same reason
//!
//! This deliberately mirrors [`crate::openrouter::gateway_unconfigured`]
//! (Parslee-ai/car#786), which solved the neighbouring half of this problem:
//! there, authentication was necessary but not sufficient because the gateway
//! had no upstream; here it is not sufficient because the credential is dead.
//! Both are cases where availability derived from `ProprietaryAuth::OAuth2Pkce`
//! resolves to "is a Parslee session signed in", which is simply not the
//! question. Both are answered the same way: there is no discovery endpoint to
//! consult, so the server's own answer to a real request is the only truthful
//! signal, and once it has answered, stop advertising.
//!
//! The two are kept separate rather than folded together because they clear on
//! different evidence and have different blast radii. A gateway verdict is
//! about `parslee/openrouter/*` and expires on a timer because CAR cannot
//! observe provisioning; a credential verdict is about **every** `parslee/*`
//! row and is cleared the moment a real request proves the credential works.

use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::Duration;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// How long a "this credential was rejected" observation suppresses the managed
/// aliases before CAR optimistically advertises them again.
///
/// A backstop, not the primary recovery path. The definitive clears are
/// [`clear_credential_rejected`] — called when an org lookup actually succeeds,
/// and when the session goes away — and they cover sign-in and sign-out. What
/// they do not cover is a **silent refresh**: the stored refresh token renewing
/// the access token in place changes neither the record's existence nor
/// anything else this module observes, so without a timer a lane rejected once
/// could stay suppressed through a recovery CAR never saw.
///
/// Ten minutes costs at most one failed request per ten minutes to rediscover a
/// working credential, which is the price of being wrong in the recoverable
/// direction. The unrecoverable direction — advertising a dead lane forever —
/// is the bug.
const REJECTION_TTL: Duration = Duration::from_secs(10 * 60);

/// Default path for the durable observation: `parslee-credential-state.json`
/// under the CAR state root, so `CAR_HOME` moves it with every other daemon
/// state path.
///
/// Persisted for the same reason the gateway observation is: most callers are a
/// *new process*. `car models list` is daemon-first with an in-process
/// fallback, so a fresh daemon, a restarted one, or a CLI run with no daemon
/// would each start optimistic again and re-advertise lanes that are certain to
/// 401. An in-process static only ever helps the one long-lived process that
/// already made the failing call.
pub fn credential_state_path() -> std::path::PathBuf {
    car_home::root_or_relative().join("parslee-credential-state.json")
}

/// The durable half of the observation.
///
/// Wall-clock rather than `Instant` for the same reason as the gateway state: a
/// monotonic clock is meaningless across a process boundary.
#[derive(Debug, Default, Serialize, Deserialize)]
struct CredentialState {
    /// When the Parslee credential was last rejected by the server.
    #[serde(skip_serializing_if = "Option::is_none")]
    rejected_at: Option<DateTime<Utc>>,
}

#[derive(Default)]
struct Observation {
    /// Whether the durable state has been read in this process yet.
    loaded: bool,
    rejected_at: Option<DateTime<Utc>>,
}

fn observation_guard() -> MutexGuard<'static, Observation> {
    static OBSERVATION: OnceLock<Mutex<Observation>> = OnceLock::new();
    let cell = OBSERVATION.get_or_init(|| Mutex::new(Observation::default()));
    cell.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}

fn load_state(path: &std::path::Path) -> CredentialState {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|raw| serde_json::from_str(&raw).ok())
        .unwrap_or_default()
}

fn save_state(path: &std::path::Path, state: &CredentialState) {
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if let Ok(raw) = serde_json::to_string_pretty(state) {
        let _ = std::fs::write(path, raw);
    }
}

/// An observation is live until [`REJECTION_TTL`] has elapsed.
///
/// A timestamp in the future is treated as dead rather than as live-for-an-
/// arbitrarily-long-time: a clock adjustment must not be able to suppress a
/// working lane indefinitely. Fail toward advertising, which costs one failed
/// request.
fn observation_is_live(at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
    match (now - at).to_std() {
        Ok(elapsed) => elapsed < REJECTION_TTL,
        // Negative duration — `at` is in the future.
        Err(_) => false,
    }
}

/// Record that the Parslee server rejected this credential.
///
/// Called from the one place that can know it: the org lookup, on a 401/403.
pub fn note_credential_rejected() {
    let now = Utc::now();
    {
        let mut guard = observation_guard();
        guard.rejected_at = Some(now);
        guard.loaded = true;
    }
    save_state(
        &credential_state_path(),
        &CredentialState {
            rejected_at: Some(now),
        },
    );
}

/// Whether a recent observation says the Parslee credential is not accepted.
///
/// Read by [`crate::registry`] when deciding `available` for `parslee/*` rows,
/// so the catalog stops offering a lane that is certain to fail.
///
/// The observation is read from disk once per process, so what one process
/// learned the next already knows. Within a process the cached value wins: a
/// long-lived daemon does not re-read the file, so it learns first-hand from
/// its own next request rather than from another process's write. Same
/// trade-off as the gateway observation, and it keeps the hot path free of disk
/// I/O — this is consulted once per model row per availability refresh.
pub fn credential_rejected() -> bool {
    let mut guard = observation_guard();
    if !guard.loaded {
        guard.rejected_at = load_state(&credential_state_path()).rejected_at;
        guard.loaded = true;
    }
    guard
        .rejected_at
        .is_some_and(|at| observation_is_live(at, Utc::now()))
}

/// Forget the observation.
///
/// Two callers, both of which are real evidence rather than a guess:
/// 1. an org lookup that **succeeded** — the credential demonstrably works, so
///    whatever was learned is stale;
/// 2. the Parslee session going **away** — what was learned was learned about
///    that credential, and the next sign-in may be a working one. Without this,
///    signing out and back in would inherit the dead session's verdict from
///    disk, which is the failure the durability is otherwise buying.
///
/// Clears the durable copy too, for the same reason.
pub fn clear_credential_rejected() {
    {
        let mut guard = observation_guard();
        guard.rejected_at = None;
        guard.loaded = true;
    }
    save_state(&credential_state_path(), &CredentialState::default());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_observation_expires_after_the_ttl() {
        let at = Utc::now();
        assert!(observation_is_live(at, at));
        assert!(observation_is_live(
            at,
            at + chrono::Duration::seconds(REJECTION_TTL.as_secs() as i64 - 1)
        ));
        assert!(!observation_is_live(
            at,
            at + chrono::Duration::seconds(REJECTION_TTL.as_secs() as i64)
        ));
    }

    #[test]
    fn a_future_timestamp_is_dead_not_live_forever() {
        // A clock adjustment must not be able to suppress a working lane
        // indefinitely — fail toward advertising.
        let now = Utc::now();
        assert!(!observation_is_live(now + chrono::Duration::hours(1), now));
    }

    #[test]
    fn state_round_trips_through_disk() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("parslee-credential-state.json");
        assert!(
            load_state(&path).rejected_at.is_none(),
            "absent file is empty"
        );

        let at = Utc::now();
        save_state(
            &path,
            &CredentialState {
                rejected_at: Some(at),
            },
        );
        let loaded = load_state(&path).rejected_at.expect("round-trips");
        assert_eq!(loaded.timestamp(), at.timestamp());

        // The cleared shape must read back as "no observation", not as a parse
        // failure that happens to look the same.
        save_state(&path, &CredentialState::default());
        assert!(load_state(&path).rejected_at.is_none());
        let raw = std::fs::read_to_string(&path).unwrap();
        assert!(
            serde_json::from_str::<serde_json::Value>(&raw).is_ok(),
            "cleared state must still be valid JSON, got: {raw}"
        );
    }

    #[test]
    fn a_corrupt_file_reads_as_no_observation() {
        // Fail toward advertising: an unreadable verdict must not suppress a
        // lane that may be perfectly healthy.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("parslee-credential-state.json");
        std::fs::write(&path, "{ this is not json").unwrap();
        assert!(load_state(&path).rejected_at.is_none());
    }
}