Skip to content

Commit 69009d4

Browse files
authored
Merge pull request #1 from justyns/init-cookiecutter
Initial plugin
2 parents 2b68523 + 24f4482 commit 69009d4

File tree

8 files changed

+1252
-0
lines changed

8 files changed

+1252
-0
lines changed

.github/workflows/publish.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Publish Python Package
2+
3+
on:
4+
release:
5+
types: [created]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
16+
steps:
17+
- uses: actions/checkout@v4
18+
- name: Set up uv
19+
uses: astral-sh/setup-uv@v6
20+
with:
21+
enable-cache: true
22+
python-version: ${{ matrix.python-version }}
23+
- name: Install dependencies
24+
run: |
25+
uv sync --group test
26+
- name: Run tests
27+
run: |
28+
uv run python -m pytest
29+
deploy:
30+
runs-on: ubuntu-latest
31+
needs: [test]
32+
environment: release
33+
permissions:
34+
id-token: write
35+
steps:
36+
- uses: actions/checkout@v4
37+
- name: Set up uv
38+
uses: astral-sh/setup-uv@v6
39+
with:
40+
enable-cache: true
41+
python-version: "3.13"
42+
- name: Build package
43+
run: |
44+
uv build
45+
- name: Publish
46+
uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/test.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Test
2+
3+
on: [push, pull_request]
4+
5+
permissions:
6+
contents: read
7+
8+
jobs:
9+
lint:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Set up uv
14+
uses: astral-sh/setup-uv@v6
15+
with:
16+
enable-cache: true
17+
python-version: "3.12"
18+
- name: Install dependencies
19+
run: |
20+
uv sync --group dev
21+
- name: Run ruff linter
22+
run: |
23+
uv run ruff check .
24+
- name: Run ruff formatter
25+
run: |
26+
uv run ruff format --check .
27+
28+
test:
29+
runs-on: ubuntu-latest
30+
needs: lint
31+
strategy:
32+
matrix:
33+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
34+
steps:
35+
- uses: actions/checkout@v4
36+
- name: Set up uv
37+
uses: astral-sh/setup-uv@v6
38+
with:
39+
enable-cache: true
40+
python-version: ${{ matrix.python-version }}
41+
- name: Install dependencies
42+
run: |
43+
uv sync --group test
44+
- name: Run tests
45+
run: |
46+
uv run python -m pytest

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.venv
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
venv
6+
.eggs
7+
.pytest_cache
8+
*.egg-info
9+
.DS_Store
10+
.vscode
11+
dist
12+
build

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# llm-tools-searxng
2+
3+
[PyPI](https://img.shields.io/pypi/v/llm-tools-searxng.svg)](https://pypi.org/project/llm-tools-searxng/)
4+
[![Changelog](https://img.shields.io/github/v/release/justyns/llm-tools-searxng?include_prereleases&label=changelog)](https://github.com/justyns/llm-tools-searxng/releases)
5+
[![Tests](https://github.com/justyns/llm-tools-searxng/actions/workflows/test.yml/badge.svg)](https://github.com/justyns/llm-tools-searxng/actions/workflows/test.yml)
6+
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/justyns/llm-tools-searxng/blob/main/LICENSE)
7+
8+
A tool to search the web using SearXNG search engines.
9+
10+
## Installation
11+
12+
Install this plugin in the same environment as [LLM](https://llm.datasette.io/).
13+
```bash
14+
llm install llm-tools-searxng
15+
```
16+
17+
## Configuration
18+
19+
By default, the tool does not have a default SEARXNG_URL set, you must set this environment variable to a searxng instance you have access to:
20+
21+
```bash
22+
export SEARXNG_URL=https://your-searxng-instance.com
23+
export SEARXNG_METHOD=GET # or POST (default)
24+
```
25+
26+
**Note:** Public SearXNG instances typically don't allow API access or JSON output.
27+
28+
## Usage
29+
30+
### Simple search function
31+
32+
Use the `searxng_search` function for basic web searches:
33+
34+
```bash
35+
llm --tool searxng_search "latest developments in AI" --tools-debug
36+
```
37+
38+
### With LLM chat
39+
40+
This plugin works well with `llm chat`:
41+
42+
```bash
43+
llm chat --tool searxng_search --tools-debug
44+
```
45+
46+
### Python API usage
47+
48+
```python
49+
import llm
50+
from llm_tools_searxng import SearXNG, searxng_search
51+
52+
# Using the simple function
53+
model = llm.get_model("gpt-4.1-mini")
54+
result = model.chain(
55+
"What are the latest developments in renewable energy?",
56+
tools=[searxng_search]
57+
).text()
58+
```
59+
60+
## Development
61+
62+
To set up this plugin locally, first checkout the code. Then create a new virtual environment:
63+
64+
```bash
65+
cd llm-tools-searxng
66+
uv sync --all-extras
67+
```
68+
69+
Now install the dependencies and test dependencies:
70+
71+
```bash
72+
llm install -e '.[test]'
73+
```
74+
75+
To run the tests:
76+
77+
```bash
78+
uv run python -m pytest
79+
```

llm_tools_searxng.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import json
2+
import os
3+
4+
import httpx
5+
import llm
6+
7+
8+
class SearXNG:
9+
"""A tool for searching the web using SearXNG search engine"""
10+
11+
def __init__(self, base_url: str = None):
12+
self.base_url = base_url or os.getenv("SEARXNG_URL")
13+
if not self.base_url:
14+
raise ValueError("SEARXNG_URL environment variable is required")
15+
self.method = os.getenv("SEARXNG_METHOD", "POST").upper()
16+
17+
def search(
18+
self,
19+
query: str,
20+
format: str = "json",
21+
categories: str = None,
22+
engines: str = None,
23+
language: str = "en",
24+
pageno: int = 1,
25+
time_range: str = None,
26+
safesearch: int = 1,
27+
) -> str:
28+
"""
29+
Search the web using SearXNG.
30+
31+
Args:
32+
query: The search query (required)
33+
format: Output format (json, csv, rss) - default: json
34+
categories: Comma separated list of search categories (optional)
35+
engines: Comma separated list of search engines (optional)
36+
language: Language code - default: en
37+
pageno: Search page number - default: 1
38+
time_range: Time range (day, month, year) - optional
39+
safesearch: Safe search level (0, 1, 2) - default: 1
40+
"""
41+
search_url = f"{self.base_url.rstrip('/')}/search"
42+
43+
params = {
44+
"q": query,
45+
"format": format,
46+
"language": language,
47+
"pageno": pageno,
48+
"safesearch": safesearch,
49+
}
50+
51+
if categories:
52+
params["categories"] = categories
53+
if engines:
54+
params["engines"] = engines
55+
if time_range:
56+
params["time_range"] = time_range
57+
58+
try:
59+
# TODO: Set user agent?
60+
headers = None
61+
62+
with httpx.Client(follow_redirects=True, timeout=30.0, headers=headers) as client:
63+
if self.method == "POST":
64+
response = client.post(search_url, data=params)
65+
else:
66+
response = client.get(search_url, params=params)
67+
response.raise_for_status()
68+
69+
if format == "json":
70+
result = response.json()
71+
if "results" in result:
72+
formatted_results = []
73+
for item in result["results"][:10]:
74+
formatted_result = {
75+
"title": item.get("title", ""),
76+
"url": item.get("url", ""),
77+
"snippet": item.get("content", ""),
78+
"engine": item.get("engine", ""),
79+
}
80+
formatted_results.append(formatted_result)
81+
82+
summary = {
83+
"query": query,
84+
"number_of_results": len(result.get("results", [])),
85+
"results": formatted_results,
86+
}
87+
return json.dumps(summary, indent=2)
88+
else:
89+
return json.dumps(result, indent=2)
90+
else:
91+
return response.text
92+
93+
except httpx.HTTPError as e:
94+
raise Exception(f"HTTP error occurred: {e}")
95+
except json.JSONDecodeError as e:
96+
raise Exception(f"Error parsing JSON response: {e}")
97+
except Exception as e:
98+
raise Exception(f"Error performing search: {e}")
99+
100+
101+
def searxng_search(query: str) -> str:
102+
"""
103+
Search the web using SearXNG. Returns a JSON string with results including
104+
title, URL, snippet, and search engine used.
105+
106+
Args:
107+
query: Search query to search for. SearxNG search syntax (https://docs.searxng.org/user/search-syntax.html) is supported.
108+
"""
109+
searxng = SearXNG()
110+
return searxng.search(query)
111+
112+
113+
@llm.hookimpl
114+
def register_tools(register):
115+
if not os.environ.get("SEARXNG_URL"):
116+
raise RuntimeError("SEARXNG_URL environment variable is not set.")
117+
register(searxng_search)

pyproject.toml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
[project]
2+
name = "llm-tools-searxng"
3+
version = "0.1"
4+
description = "A tool to search the web using SearXNG."
5+
readme = "README.md"
6+
authors = [{name = "Justyn Shull"}]
7+
license = "Apache-2.0"
8+
classifiers = []
9+
requires-python = ">=3.9"
10+
dependencies = [
11+
"httpx>=0.28.1",
12+
"llm>=0.26",
13+
]
14+
15+
[build-system]
16+
requires = ["setuptools"]
17+
build-backend = "setuptools.build_meta"
18+
19+
[dependency-groups]
20+
dev = [
21+
"ruff>=0.11.12",
22+
]
23+
24+
[project.urls]
25+
Homepage = "https://github.com/justyns/llm-tools-searxng"
26+
Changelog = "https://github.com/justyns/llm-tools-searxng/releases"
27+
Issues = "https://github.com/justyns/llm-tools-searxng/issues"
28+
CI = "https://github.com/justyns/llm-tools-searxng/actions"
29+
30+
[project.entry-points.llm]
31+
llm_tools_searxng = "llm_tools_searxng"
32+
33+
[project.optional-dependencies]
34+
test = ["pytest", "llm-echo>=0.3a1", "pytest-httpx"]
35+
36+
[tool.ruff]
37+
line-length = 120
38+
target-version = "py39"
39+
40+
[tool.ruff.lint]
41+
ignore = []
42+
43+
[tool.ruff.format]
44+
quote-style = "double"
45+
indent-style = "space"
46+
skip-magic-trailing-comma = false
47+
line-ending = "auto"

0 commit comments

Comments
 (0)