Skip to main content

browser_automation_cli/
llm_local.rs

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