car-inference 0.52.1

Local model inference for CAR — Candle backend with Qwen3 models
use serde::{Deserialize, Serialize};

/// Passive STT presentation may observe explicit process configuration, but it
/// must never query the OS credential store merely to render health/catalog UI.
pub(crate) fn provider_configured_for_passive_status(env_var: &str) -> bool {
    std::env::var(env_var).is_ok_and(|value| !value.trim().is_empty())
}

/// Resolve an STT provider credential only after a transcription request has
/// explicitly selected a remote provider.
pub(crate) fn resolve_provider_credential_for_request(env_var: &str) -> Option<String> {
    car_secrets::resolve_env_or_keychain(env_var)
}

/// A speech-to-text transcription request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscribeRequest {
    /// Path to an audio file on disk.
    pub audio_path: String,
    /// Optional model override.
    #[serde(default)]
    pub model: Option<String>,
    /// Optional spoken language hint.
    #[serde(default)]
    pub language: Option<String>,
    /// Optional prompt/context hint to bias transcription.
    #[serde(default)]
    pub prompt: Option<String>,
    /// Return verbose output with per-word timing when the backend
    /// supports it. When false, the result's `words` field is empty
    /// and only `text` is populated.
    #[serde(default)]
    pub timestamps: bool,
}

/// A timed word span — start/end in seconds from the beginning of the
/// audio clip, plus the decoded text for that span. Parakeet-TDT emits
/// one of these per word (tokens grouped by sentencepiece `▁` markers).
///
/// Field names match the convention used by Whisper, ElevenLabs,
/// Deepgram, AssemblyAI, and NeMo CTM output (`start`, `end` in seconds).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscribedWord {
    /// Start time of this word in seconds.
    pub start: f32,
    /// End time of this word in seconds.
    pub end: f32,
    /// The decoded word text (no leading space).
    pub text: String,
}

/// Speech-to-text result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscribeResult {
    pub text: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model_used: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
    /// Per-word timing spans. Populated when `TranscribeRequest::timestamps`
    /// is true and the backend supports timing (Parakeet-TDT does natively;
    /// ElevenLabs Scribe provides them when verbose output is enabled).
    /// Empty vec when timing wasn't requested OR the backend can't produce it.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub words: Vec<TranscribedWord>,
    /// Present when adaptive routing changed the privacy/cost boundary.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub routing_explanation: Option<String>,
}

impl TranscribeResult {
    /// Build a text-only result (no timing). Used by backends that
    /// don't produce word-level spans or when the caller didn't ask.
    pub fn text_only(text: String, model_used: Option<String>, language: Option<String>) -> Self {
        Self {
            text,
            model_used,
            language,
            words: Vec::new(),
            routing_explanation: None,
        }
    }
}

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

    #[test]
    fn passive_stt_status_does_not_touch_the_secret_store() {
        const UNSET: &str = "CAR_TEST_PASSIVE_STT_CREDENTIAL_MUST_NOT_EXIST";
        assert!(std::env::var_os(UNSET).is_none());
        let before = car_secrets::secret_store_activity();
        assert!(!provider_configured_for_passive_status(UNSET));
        assert_eq!(car_secrets::secret_store_activity(), before);
    }

    #[test]
    fn adaptive_remote_fallback_explanation_survives_result_json() {
        let mut result =
            TranscribeResult::text_only("hello".into(), Some("remote/stt".into()), None);
        result.routing_explanation = Some(
            "Local speech model was blocked by the RAM allocation; routed to a remote provider, which may affect privacy and cost.".into(),
        );
        let json = serde_json::to_string(&result).unwrap();
        assert!(json.contains("privacy"));
        assert!(json.contains("cost"));
    }
}