sniff-cli 0.1.3

An exhaustive LLM-backed slop finder for codebases
Documentation
use super::llm_content;
use super::llm_text::truncate_for_log;
use super::llm_transport::{EndpointKind, endpoint_kind, request_url};
use super::llm_usage;
use crate::config::ResolvedConfig;
use std::env;
use std::time::Duration;

fn configured_timeout(names: &[&str]) -> Duration {
    let secs = names
        .iter()
        .find_map(|name| env::var(name).ok())
        .and_then(|value| value.trim().parse::<u64>().ok())
        .filter(|secs| *secs > 0)
        .unwrap_or(600);
    Duration::from_secs(secs)
}

fn request_timeout() -> Duration {
    configured_timeout(&[
        "SNIFF_LLM_REQUEST_TIMEOUT_SECS",
        "SNIFF_LLM_CLIENT_TIMEOUT_SECS",
        "LLM_REQUEST_TIMEOUT_SECS",
    ])
}

fn body_timeout() -> Duration {
    configured_timeout(&["SNIFF_LLM_BODY_TIMEOUT_SECS", "LLM_BODY_TIMEOUT_SECS"])
}

pub(super) async fn try_call_raw(
    client: &reqwest::Client,
    config: &ResolvedConfig,
    api_key: Option<&String>,
    prompt: &str,
) -> Result<(String, usize, usize), String> {
    let (payload, mut headers) = super::llm_transport::build_payload(config, prompt);
    let timeout = request_timeout();
    let request_url = request_url(config);
    let endpoint_label = match endpoint_kind(config) {
        EndpointKind::OpenAi => "OpenAI-style",
        EndpointKind::Anthropic => "Anthropic-style",
    };

    if let Some(api_key) = api_key {
        if let Ok(auth_val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key))
        {
            headers.insert(reqwest::header::AUTHORIZATION, auth_val);
        }
        if endpoint_kind(config) == EndpointKind::Anthropic {
            if let Ok(key_val) = reqwest::header::HeaderValue::from_str(api_key) {
                headers.insert("x-api-key", key_val);
            }
            headers.insert(
                "anthropic-version",
                reqwest::header::HeaderValue::from_static("2023-06-01"),
            );
        }
    }

    let request = async {
        let mut resp = client
            .post(&request_url)
            .headers(headers.clone())
            .json(&payload)
            .send()
            .await
            .map_err(|e| e.to_string())?;

        let status = resp.status();
        if !status.is_success() {
            let text = resp.text().await.unwrap_or_default();
            let snippet = truncate_for_log(&text, 200);
            if status.as_u16() == 402 && text.to_lowercase().contains("insufficient balance") {
                return Err(format!(
                    "LLM provider balance is insufficient at {}: HTTP 402: {}",
                    request_url, snippet
                ));
            }
            return Err(format!(
                "HTTP {} from {}: {}",
                status.as_u16(),
                request_url,
                snippet
            ));
        }

        let body_deadline = tokio::time::Instant::now() + body_timeout();
        let mut body = String::new();
        let data: serde_json::Value = loop {
            let next = tokio::time::timeout_at(body_deadline, resp.chunk())
                .await
                .map_err(|_| {
                    format!(
                        "Timed out reading response body from {endpoint_label} endpoint ({request_url}) before a full JSON object arrived"
                    )
                })?
                .map_err(|e| e.to_string())?;

            let Some(chunk) = next else {
                let snippet = truncate_for_log(&body, 200);
                return Err(format!(
                    "Invalid JSON response from {endpoint_label} endpoint ({request_url}): {snippet}"
                ));
            };

            body.push_str(&String::from_utf8_lossy(&chunk));
            match serde_json::from_str::<serde_json::Value>(&body) {
                Ok(value) => break value,
                Err(_) if body.len() <= 200_000 => continue,
                Err(_) => {
                    let snippet = truncate_for_log(&body, 200);
                    return Err(format!(
                        "Invalid JSON response from {endpoint_label} endpoint ({request_url}): {snippet}"
                    ));
                }
            }
        };

        if let Some(err) = data.get("error") {
            return Err(format!("API error response: {}", err));
        }

        let (content, in_t, out_t) = match endpoint_kind(config) {
            EndpointKind::OpenAi => {
                let content = llm_content::openai_content(&data);
                let (in_t, out_t) = llm_usage::openai_usage(&data, prompt, &content);
                (content, in_t, out_t)
            }
            EndpointKind::Anthropic => {
                let content = llm_content::anthropic_content(&data);
                let (in_t, out_t) = llm_usage::anthropic_usage(&data, prompt, &content);
                (content, in_t, out_t)
            }
        };

        if content.trim().is_empty() {
            return Err(format!(
                "Empty assistant content from {endpoint_label} endpoint ({request_url})"
            ));
        }

        Ok((content, in_t, out_t))
    };

    match tokio::time::timeout(timeout, request).await {
        Ok(result) => result,
        Err(_) => Err(format!(
            "LLM request timed out after {}s waiting for {} response",
            timeout.as_secs(),
            endpoint_label
        )),
    }
}

#[cfg(test)]
#[path = "tests/llm_response.rs"]
mod tests;