Skip to main content

browser_automation_cli/
llm_local.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! One-shot optional LLM HTTP extract (XDG key only; no product env vars).
3//!
4//! Uses an OpenAI-compatible chat completions endpoint configured via XDG
5//! (`openrouter_api_key`, `llm_base_url`, `llm_model`). No telemetry.
6//!
7//! # Workload
8//!
9//! **I/O-bound** (blocking HTTP to operator-configured LLM endpoint). Client is
10//! process-wide via `OnceLock` (rules: create `reqwest` client once).
11
12use std::sync::OnceLock;
13use std::time::Duration;
14
15use serde_json::{json, Value};
16
17use crate::error::{CliError, ErrorKind};
18use crate::xdg;
19
20/// Process-wide blocking HTTP client for rare LLM/webhook one-shots.
21///
22/// Stable `get_or_init` path (`get_or_try_init` still unstable on MSRV 1.88).
23pub fn shared_blocking_http_client() -> Result<&'static reqwest::blocking::Client, CliError> {
24    static CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
25    if let Some(c) = CLIENT.get() {
26        return Ok(c);
27    }
28    let built = reqwest::blocking::Client::builder()
29        .timeout(Duration::from_secs(60))
30        .user_agent(concat!(
31            "browser-automation-cli/",
32            env!("CARGO_PKG_VERSION")
33        ))
34        .build()
35        .map_err(|e| CliError::new(ErrorKind::Software, format!("http client: {e}")))?;
36    Ok(CLIENT.get_or_init(|| built))
37}
38
39/// Default OpenAI-compatible base URL (path ends before `/chat/completions`).
40pub const DEFAULT_LLM_BASE_URL: &str = "https://openrouter.ai/api/v1";
41
42/// Default model id when XDG `llm_model` is unset.
43pub const DEFAULT_LLM_MODEL: &str = "openai/gpt-4o-mini";
44
45/// Resolve API key from XDG only.
46pub fn require_api_key() -> Result<String, CliError> {
47    xdg::openrouter_api_key().ok_or_else(|| {
48        CliError::with_suggestion(
49            ErrorKind::Usage,
50            "LLM extract requires XDG openrouter_api_key",
51            "Run: browser-automation-cli config set openrouter_api_key <key>",
52        )
53    })
54}
55
56/// Base URL from XDG or default constant.
57pub fn base_url() -> String {
58    xdg::llm_base_url().unwrap_or_else(|| DEFAULT_LLM_BASE_URL.to_string())
59}
60
61/// Model from XDG or default constant.
62pub fn model() -> String {
63    xdg::llm_model().unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string())
64}
65
66/// Call chat completions with retry/backoff (one-shot; no daemon).
67pub fn chat_completion(
68    system: &str,
69    user: &str,
70    schema_hint: Option<&str>,
71) -> Result<Value, CliError> {
72    let key = require_api_key()?;
73    let model = model();
74    let base = base_url().trim_end_matches('/').to_string();
75    let url = format!("{base}/chat/completions");
76
77    let mut user_content = user.to_string();
78    if let Some(schema) = schema_hint {
79        user_content.push_str("\n\nRespond with JSON matching this schema:\n");
80        user_content.push_str(schema);
81    }
82
83    let body = json!({
84        "model": model,
85        "messages": [
86            { "role": "system", "content": system },
87            { "role": "user", "content": user_content }
88        ],
89        "temperature": 0.2,
90    });
91
92    // Process-wide blocking client (rules: create once; LLM path is rare one-shot).
93    let client = shared_blocking_http_client()?;
94
95    // GAP-013: named RetryConfig::llm() (budget + jitter), not ad-hoc delay array.
96    let cfg = crate::retry::RetryConfig::llm();
97    let mut attempt_no = 0u32;
98    let result = crate::retry::retry_blocking(cfg, || {
99        attempt_no += 1;
100        let resp = client
101            .post(&url)
102            .header("Authorization", format!("Bearer {key}"))
103            .header("Content-Type", "application/json")
104            .json(&body)
105            .send();
106        match resp {
107            Ok(r) if r.status().is_success() => {
108                let v: Value = r.json().map_err(|e| {
109                    CliError::new(ErrorKind::Data, format!("llm response json: {e}"))
110                })?;
111                let answer = v
112                    .pointer("/choices/0/message/content")
113                    .and_then(|c| c.as_str())
114                    .unwrap_or("")
115                    .to_string();
116                Ok(json!({
117                    "llm": true,
118                    "model": model,
119                    "base_url": base,
120                    "answer": answer,
121                    "raw": v,
122                    "attempt": attempt_no,
123                }))
124            }
125            Ok(r) => {
126                let code = r.status().as_u16();
127                let err = CliError::new(ErrorKind::Unavailable, format!("llm HTTP {code}"));
128                // Permanent client errors (except 429) must not retry.
129                if code < 500 && code != 429 {
130                    return Err(CliError::new(
131                        ErrorKind::Usage,
132                        format!("llm HTTP {code} (non-retryable)"),
133                    ));
134                }
135                Err(err)
136            }
137            Err(e) => Err(CliError::new(ErrorKind::Unavailable, format!("llm: {e}"))),
138        }
139    });
140    result.map_err(|e| {
141        CliError::with_suggestion(
142            e.kind(),
143            e.message(),
144            "Check XDG openrouter_api_key, llm_base_url, llm_model and network reachability",
145        )
146    })
147}
148
149/// Build extract+LLM payload from free text and optional question/schema.
150pub fn extract_with_llm(
151    source_text: &str,
152    question: Option<&str>,
153    schema_json: Option<&str>,
154) -> Result<Value, CliError> {
155    let q = question.unwrap_or("Summarize the key facts from the content.");
156    let system =
157        "You are a careful extraction assistant for a local CLI. Answer concisely. No telemetry.";
158    let user = format!("Question: {q}\n\nContent:\n{source_text}");
159    let mut out = chat_completion(system, &user, schema_json)?;
160    out["question"] = json!(q);
161    out["source_chars"] = json!(source_text.chars().count());
162    if let Some(s) = schema_json {
163        if let Ok(parsed) =
164            serde_json::from_str::<Value>(out.get("answer").and_then(|a| a.as_str()).unwrap_or(""))
165        {
166            out["json"] = parsed;
167        }
168        out["schema_requested"] = json!(true);
169        let _ = s;
170    }
171    Ok(out)
172}