browser_automation_cli/
llm_local.rs1use std::time::Duration;
7
8use serde_json::{json, Value};
9
10use crate::error::{CliError, ErrorKind};
11use crate::xdg;
12
13pub const DEFAULT_LLM_BASE_URL: &str = "https://openrouter.ai/api/v1";
15
16pub const DEFAULT_LLM_MODEL: &str = "openai/gpt-4o-mini";
18
19pub 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
30pub fn base_url() -> String {
32 xdg::llm_base_url().unwrap_or_else(|| DEFAULT_LLM_BASE_URL.to_string())
33}
34
35pub fn model() -> String {
37 xdg::llm_model().unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string())
38}
39
40pub 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 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 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
126pub 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}