src / toolsProvider.ts
import { tool, Tool, ToolsProviderController } from "@lmstudio/sdk";
import ky, { HTTPError, TimeoutError } from "ky";
import { z } from "zod/v3";
import { configSchematics, globalConfigSchematics } from "./config";
const SearxngResultSchema = z.object({
title: z.string().optional(),
url: z.string().optional(),
content: z.string().optional(),
publishedDate: z.string().nullable().optional(),
score: z.number().optional(),
category: z.string().optional(),
});
const SearxngResponseSchema = z.object({
query: z.string().optional(),
number_of_results: z.number().optional(),
results: z.array(SearxngResultSchema).optional(),
answers: z.array(z.unknown()).optional(),
infoboxes: z.array(z.unknown()).optional(),
suggestions: z.array(z.string()).optional(),
});
type SearxngResponse = z.infer<typeof SearxngResponseSchema>;
export async function toolsProvider(ctl: ToolsProviderController) {
const tools: Tool[] = [];
const searchTool = tool({
name: "searxng_search",
description:
"Search the web using a SearXNG metasearch instance. Use this tool to find " +
"up-to-date information on the internet. Returns a list of results with title, " +
"URL, and snippet. Supports filtering by categories, language, " +
"time range, and safe-search level.",
parameters: {
query: z
.string()
.min(1)
.describe(
"The search query. Supports engine-specific syntax (e.g. 'site:github.com searxng').",
),
max_results: z
.number()
.int()
.min(1)
.max(50)
.optional()
.describe(
"Maximum number of results to return. May be clamped by the plugin's configured limit.",
),
categories: z
.array(
z.enum([
"general",
"images",
"videos",
"news",
"map",
"music",
"it",
"science",
"files",
"social media",
]),
)
.optional()
.describe("Restrict search to specific categories."),
language: z
.string()
.optional()
.describe(
"Language code for results (e.g. 'en', 'he', 'de', 'en-US'). Use 'all' for any language.",
),
page: z
.number()
.int()
.min(1)
.max(20)
.optional()
.describe(
"Page number of results (1-indexed). Use to paginate through more results.",
),
time_range: z
.enum(["day", "month", "year"])
.optional()
.describe(
"Restrict results to a recent time range. Useful for time-sensitive queries.",
),
safesearch: z
.union([z.literal(0), z.literal(1), z.literal(2)])
.optional()
.describe("Safe-search level: 0 = off, 1 = moderate, 2 = strict."),
},
implementation: async (
{
query,
max_results,
categories,
language,
page,
time_range,
safesearch,
},
{ signal, status, warn },
) => {
const baseUrl = ctl
.getGlobalPluginConfig(globalConfigSchematics)
.get("searxngUrl");
const maxResultsLimit = ctl
.getPluginConfig(configSchematics)
.get("maxResultsLimit");
if (!baseUrl || baseUrl.trim() === "") {
return "Error: SearXNG API URL is not configured. Please set it in the plugin's global configuration.";
}
const requested = max_results ?? maxResultsLimit;
const effectiveMax = Math.min(requested, maxResultsLimit);
if (max_results !== undefined && max_results > maxResultsLimit) {
warn(
`Model requested ${max_results} results, but the configured limit is ${maxResultsLimit}. Clamping.`,
);
}
const searchParams: Record<string, string> = {
q: query,
format: "json",
};
if (categories && categories.length > 0) {
searchParams.categories = categories.join(",");
}
if (language) {
searchParams.language = language;
}
if (page !== undefined) {
searchParams.pageno = String(page);
}
if (time_range) {
searchParams.time_range = time_range;
}
if (safesearch !== undefined) {
searchParams.safesearch = String(safesearch);
}
status(`Searching SearXNG for: "${query}"`);
let data: SearxngResponse;
try {
const raw = await ky
.get("search", {
prefix: baseUrl,
searchParams,
signal,
timeout: 30000,
headers: {
Accept: "application/json",
},
})
.json<unknown>();
const parsed = SearxngResponseSchema.safeParse(raw);
if (!parsed.success) {
return `Error: Unexpected response shape from SearXNG: ${parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`;
}
data = parsed.data;
} catch (err) {
if (signal.aborted) {
throw err;
}
if (err instanceof HTTPError) {
const detail =
typeof err.data === "string"
? err.data
: err.data !== undefined
? JSON.stringify(err.data)
: err.message;
return `Error: SearXNG returned ${err.response.status}: ${detail}`;
}
if (err instanceof TimeoutError) {
return `Error: Request to SearXNG timed out.`;
}
const message = err instanceof Error ? err.message : String(err);
return `Error: Failed to query SearXNG: ${message}`;
}
const results = data.results ?? [];
if (results.length === 0) {
const suggestionText =
data.suggestions && data.suggestions.length > 0
? ` Suggestions: ${data.suggestions.slice(0, 5).join(", ")}.`
: "";
return `No results found for "${query}".${suggestionText}`;
}
const trimmed = results.slice(0, effectiveMax).map((r, i) => ({
index: i + 1,
title: r.title ?? "(no title)",
url: r.url ?? "",
snippet: (r.content ?? "").trim(),
category: r.category,
published_date: r.publishedDate ?? undefined,
}));
status(
`Found ${results.length} result(s), returning top ${trimmed.length}.`,
);
return {
query,
total_results: results.length,
returned_results: trimmed.length,
page: page ?? 1,
suggestions: data.suggestions?.slice(0, 5),
results: trimmed,
};
},
});
tools.push(searchTool);
return tools;
}