af-llm 0.5.0

Unified async LLM client with timeout and circuit breaking for OpenAI-compatible endpoints.
Documentation
//! Helpers for coaxing structured JSON out of free-form LLM text.
//!
//! Port of `agent_core/llm/utils.py::parse_json_response`, generalized: instead
//! of always returning a `dict`, it deserializes into any `serde` type so the
//! caller gets a validated struct (the strongly-typed equivalent of "parse,
//! don't validate").

use serde::de::DeserializeOwned;

/// Strip a Markdown code fence (```json … ``` or ``` … ```) if present, and
/// trim surrounding whitespace. Returns the inner payload.
pub fn strip_code_fence(raw: &str) -> &str {
    let trimmed = raw.trim();
    let Some(after_open) = trimmed.strip_prefix("```") else {
        return trimmed;
    };

    // Drop an optional language tag on the opening fence line ("json", "JSON").
    let after_lang = match after_open.find('\n') {
        Some(nl) => &after_open[nl + 1..],
        None => after_open,
    };

    // Drop the closing fence.
    after_lang
        .trim_end()
        .strip_suffix("```")
        .unwrap_or(after_lang)
        .trim()
}

/// Extract and deserialize a JSON value from an LLM text response, tolerating
/// Markdown code fences.
///
/// Returns `None` if `content` is empty or the payload is not valid JSON for
/// the target type — same fail-soft contract as the Python original, but the
/// success type is whatever you ask for.
pub fn parse_json<T: DeserializeOwned>(content: &str) -> Option<T> {
    let payload = strip_code_fence(content);
    if payload.is_empty() {
        return None;
    }
    match serde_json::from_str(payload) {
        Ok(v) => Some(v),
        Err(err) => {
            tracing::warn!(target: "llm.json", error = %err, "parse_json_failed");
            None
        }
    }
}

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

    #[derive(Debug, Deserialize, PartialEq)]
    struct Verdict {
        ok: bool,
        score: f64,
    }

    #[test]
    fn parses_bare_json() {
        let v: Verdict = parse_json(r#"{"ok": true, "score": 0.9}"#).unwrap();
        assert_eq!(
            v,
            Verdict {
                ok: true,
                score: 0.9
            }
        );
    }

    #[test]
    fn parses_fenced_json_with_lang_tag() {
        let raw = "```json\n{\"ok\": false, \"score\": 0.1}\n```";
        let v: Verdict = parse_json(raw).unwrap();
        assert_eq!(
            v,
            Verdict {
                ok: false,
                score: 0.1
            }
        );
    }

    #[test]
    fn parses_fenced_json_without_lang_tag() {
        let raw = "```\n{\"ok\": true, \"score\": 1.0}\n```";
        let v: Verdict = parse_json(raw).unwrap();
        assert!(v.ok);
    }

    #[test]
    fn returns_none_on_garbage() {
        assert!(parse_json::<Verdict>("not json at all").is_none());
        assert!(parse_json::<Verdict>("").is_none());
    }
}