browser_automation_cli/
llm_local.rs1use std::sync::OnceLock;
13use std::time::Duration;
14
15use serde_json::{json, Value};
16
17use crate::error::{CliError, ErrorKind};
18use crate::xdg;
19
20pub 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
39pub const DEFAULT_LLM_BASE_URL: &str = "https://openrouter.ai/api/v1";
41
42pub const DEFAULT_LLM_MODEL: &str = "openai/gpt-4o-mini";
44
45pub 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
56pub fn base_url() -> String {
58 xdg::llm_base_url().unwrap_or_else(|| DEFAULT_LLM_BASE_URL.to_string())
59}
60
61pub fn model() -> String {
63 xdg::llm_model().unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string())
64}
65
66pub 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 let client = shared_blocking_http_client()?;
94
95 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 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
149pub 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}