diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2821dc7..ec3ad6d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -16,6 +16,18 @@ Sibyl reads its configuration from `~/.config/sibyl/config.json`, created with s { "name": "SIBYL_SHOW_SEARCH_DESCRIPTION", "value": "true" + }, + { + "name": "SIBYL_SEARCH_TIMEOUT", + "value": "10000" + }, + { + "name": "SIBYL_FETCH_TIMEOUT", + "value": "10000" + }, + { + "name": "SIBYL_ASK_TIMEOUT", + "value": "30000" } ] } @@ -51,6 +63,21 @@ Precedence: **Variable in the configuration file wins over the environment.** A the same name; anything not listed here falls back to the real environment. For example, a plugin reading `process.env.EXA_API_KEY` gets the configuration value if present, otherwise whatever was exported in your shell. +### Timeout variables + +Timeout values are configured in milliseconds. Each variable is optional; when it is absent from both the configuration and +the environment, Sibyl uses its current default. + +| Variable | Default | Description | +| ---------------------- | -------------------- | ---------------------------------------------------------- | +| `SIBYL_SEARCH_TIMEOUT` | `10000` (10 seconds) | Timeout for HTTP requests made by built-in search plugins. | +| `SIBYL_FETCH_TIMEOUT` | `10000` (10 seconds) | Timeout for HTTP requests made by built-in fetch plugins. | +| `SIBYL_ASK_TIMEOUT` | `30000` (30 seconds) | Timeout for LLM generation by the built-in ask plugin. | + +Values must be integers from `1` through `2147483647` milliseconds. Invalid values cause the selected plugin call to fail. +The ask plugin fetches the URL first, so that request uses `SIBYL_FETCH_TIMEOUT`; `SIBYL_ASK_TIMEOUT` applies only to +LLM generation. Parse plugins are not timed, and custom plugins are not automatically timed. + ## Plugin environment variables Each built-in plugin reads the variables below (set them via `variables` or the real environment, per the precedence rule diff --git a/src/plugins/builtin-ai-ask/main.test.ts b/src/plugins/builtin-ai-ask/main.test.ts index 10fdcb0..e3dbca9 100644 --- a/src/plugins/builtin-ai-ask/main.test.ts +++ b/src/plugins/builtin-ai-ask/main.test.ts @@ -34,20 +34,25 @@ function makeContext(fetchFn?: (url: string) => Promise): PluginContext const okContext = makeContext(async () => "page body content"); +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.SIBYL_AI_PROVIDER = "openai"; process.env.SIBYL_MODEL_NAME = "gpt-test"; process.env.OPENAI_API_KEY = "test-key"; delete process.env.OLLAMA_BASE_URL; + delete process.env.SIBYL_ASK_TIMEOUT; + delete process.env.SIBYL_FETCH_TIMEOUT; mockedGenerateText.mockReset(); resolveAnswer("the answer"); }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -112,10 +117,11 @@ describe("builtin-ai-ask", () => { ); }); - it("sends the system prompt, content and question, with an abort signal", async () => { + it("uses the default generation timeout", async () => { const answer = await askFn("https://a.com", "what is it about?", okContext); expect(answer).toBe("the answer"); + expect(timeoutSpy).toHaveBeenCalledWith(30_000); expect(mockedGenerateText).toHaveBeenCalledWith( expect.objectContaining({ system: expect.stringContaining("Reply with just the answer"), @@ -125,4 +131,18 @@ describe("builtin-ai-ask", () => { ); expect(mockedGenerateText.mock.calls[0]?.[0]?.prompt).toContain("what is it about?"); }); + + it("uses the ask timeout rather than the fetch timeout for generation", async () => { + process.env.SIBYL_FETCH_TIMEOUT = "5678"; + process.env.SIBYL_ASK_TIMEOUT = "12345"; + const timeoutSignal = new AbortController().signal; + timeoutSpy.mockReturnValue(timeoutSignal); + + await askFn("https://a.com", "what is it about?", okContext); + + expect(timeoutSpy).toHaveBeenCalledWith(12345); + expect(mockedGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ abortSignal: timeoutSignal }), + ); + }); }); diff --git a/src/plugins/builtin-ai-ask/main.ts b/src/plugins/builtin-ai-ask/main.ts index e23c71d..b7ad274 100644 --- a/src/plugins/builtin-ai-ask/main.ts +++ b/src/plugins/builtin-ai-ask/main.ts @@ -3,8 +3,7 @@ * Since: 18/06/2026 */ import type { AskPlugin, FetchPlugin, PluginContext } from "../../@types/plugin.ts"; - -const REQUEST_TIMEOUT_MS = 30_000; +import { getPluginTimeout } from "../../utils.ts"; const SYSTEM_PROMPT = "You answer the user's question using only the provided web page content. " + @@ -97,7 +96,7 @@ async function askFn(src: string, query: string, context: PluginContext): Promis model, system: SYSTEM_PROMPT, prompt: `Web page content:\n\n${content}\n\nQuestion: ${query}`, - abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + abortSignal: AbortSignal.timeout(getPluginTimeout("ask")), }); return text.trim(); diff --git a/src/plugins/builtin-alterlab-fetch/main.test.ts b/src/plugins/builtin-alterlab-fetch/main.test.ts index f5ebd0c..e9a9d65 100644 --- a/src/plugins/builtin-alterlab-fetch/main.test.ts +++ b/src/plugins/builtin-alterlab-fetch/main.test.ts @@ -47,15 +47,19 @@ const emptyContext: PluginContext = { getPlugin: () => null, }; +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { parseFn.mockClear(); + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.ALTERLAB_API_KEY = "test-key"; + delete process.env.SIBYL_FETCH_TIMEOUT; }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -64,19 +68,29 @@ afterEach(() => { }); describe("builtin-alterlab-fetch", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch( makeResponse({ json: { url, status_code: 200, content: { html: "" } } }), ); await fetchFn(url, context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_FETCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ json: { url, status_code: 200, content: { html: "" } } })); + + await fetchFn(url, context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when `ALTERLAB_API_KEY` is missing", async () => { delete process.env.ALTERLAB_API_KEY; diff --git a/src/plugins/builtin-alterlab-fetch/main.ts b/src/plugins/builtin-alterlab-fetch/main.ts index bde9751..7bfb6df 100644 --- a/src/plugins/builtin-alterlab-fetch/main.ts +++ b/src/plugins/builtin-alterlab-fetch/main.ts @@ -3,6 +3,7 @@ * Since: 13/06/2026 */ import type { FetchPlugin, ParsePlugin, PluginContext } from "../../@types/plugin.ts"; +import { getPluginTimeout } from "../../utils.ts"; interface Result { html: string; @@ -14,8 +15,6 @@ interface AlterLabScrapeResponse { content?: Result; } -const REQUEST_TIMEOUT_MS = 10_000; - async function fetchFn(url: string, context: PluginContext): Promise { const apiKey = process.env.ALTERLAB_API_KEY; if (!apiKey) { @@ -29,7 +28,7 @@ async function fetchFn(url: string, context: PluginContext): Promise { "X-API-Key": apiKey, }, body: JSON.stringify({ url, force_refresh: true, sync: true }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("fetch")), }); if (!res.ok) { diff --git a/src/plugins/builtin-alterlab-search/main.test.ts b/src/plugins/builtin-alterlab-search/main.test.ts index 418cbfd..7c5288d 100644 --- a/src/plugins/builtin-alterlab-search/main.test.ts +++ b/src/plugins/builtin-alterlab-search/main.test.ts @@ -32,16 +32,20 @@ function stubFetch(res: unknown) { return mock; } +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.ALTERLAB_API_KEY = "test-key"; delete process.env.SIBYL_SHOW_SEARCH_DESCRIPTION; delete process.env.SIBYL_SEARCH_RESULTS_LIMIT; + delete process.env.SIBYL_SEARCH_TIMEOUT; }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -50,17 +54,27 @@ afterEach(() => { }); describe("builtin-alterlab-search", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch(makeResponse({ json: { query: "react vite", results: [] } })); await searchFn("react vite", context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_SEARCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ json: { query: "react vite", results: [] } })); + + await searchFn("react vite", context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when `ALTERLAB_API_KEY` is missing", async () => { delete process.env.ALTERLAB_API_KEY; diff --git a/src/plugins/builtin-alterlab-search/main.ts b/src/plugins/builtin-alterlab-search/main.ts index 9cac419..0029195 100644 --- a/src/plugins/builtin-alterlab-search/main.ts +++ b/src/plugins/builtin-alterlab-search/main.ts @@ -4,6 +4,7 @@ */ import type { SearchPlugin } from "../../@types/plugin.ts"; import { + getPluginTimeout, getSearchResultsLimit, shouldShowSearchDescription, stripSearchResultDatePrefix, @@ -21,8 +22,6 @@ interface AlterLabSearchResponse { results: AlterLabResult[]; } -const REQUEST_TIMEOUT_MS = 10_000; - async function searchFn(query: string) { const apiKey = process.env.ALTERLAB_API_KEY; if (!apiKey) { @@ -39,7 +38,7 @@ async function searchFn(query: string) { "X-API-Key": apiKey, }, body: JSON.stringify({ query, num_results: limit }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("search")), }); if (!res.ok) { diff --git a/src/plugins/builtin-brightdata-fetch/main.test.ts b/src/plugins/builtin-brightdata-fetch/main.test.ts index dffac61..6093cd4 100644 --- a/src/plugins/builtin-brightdata-fetch/main.test.ts +++ b/src/plugins/builtin-brightdata-fetch/main.test.ts @@ -36,16 +36,20 @@ function stubFetch(res: unknown) { return mock; } +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { parseFn.mockClear(); + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.BRIGHTDATA_API_KEY = "test-key"; process.env.BRIGHTDATA_WEB_UNLOCKER_API_ZONE = "test-zone"; + delete process.env.SIBYL_FETCH_TIMEOUT; }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -54,17 +58,27 @@ afterEach(() => { }); describe("builtin-brightdata-fetch", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch(makeResponse({ text: "" })); await fetchFn("https://a.com", context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_FETCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ text: "" })); + + await fetchFn("https://a.com", context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when `BRIGHTDATA_API_KEY` is missing", async () => { delete process.env.BRIGHTDATA_API_KEY; diff --git a/src/plugins/builtin-brightdata-fetch/main.ts b/src/plugins/builtin-brightdata-fetch/main.ts index 0cea7a5..d98d06e 100644 --- a/src/plugins/builtin-brightdata-fetch/main.ts +++ b/src/plugins/builtin-brightdata-fetch/main.ts @@ -3,8 +3,7 @@ * Since: 06/06/2026 */ import type { FetchPlugin, ParsePlugin, PluginContext } from "../../@types/plugin.ts"; - -const REQUEST_TIMEOUT_MS = 10_000; +import { getPluginTimeout } from "../../utils.ts"; async function fetchFn(url: string, context: PluginContext): Promise { const apiKey = process.env.BRIGHTDATA_API_KEY; @@ -28,7 +27,7 @@ async function fetchFn(url: string, context: PluginContext): Promise { url, format: "raw", }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("fetch")), }); if (!res.ok) { diff --git a/src/plugins/builtin-brightdata-search/main.test.ts b/src/plugins/builtin-brightdata-search/main.test.ts index 2a9316f..3ac846f 100644 --- a/src/plugins/builtin-brightdata-search/main.test.ts +++ b/src/plugins/builtin-brightdata-search/main.test.ts @@ -32,17 +32,21 @@ function stubFetch(res: unknown) { return mock; } +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.BRIGHTDATA_API_KEY = "test-key"; process.env.BRIGHTDATA_SERP_API_ZONE = "test-zone"; delete process.env.SIBYL_SEARCH_RESULTS_LIMIT; delete process.env.SIBYL_SHOW_SEARCH_DESCRIPTION; + delete process.env.SIBYL_SEARCH_TIMEOUT; }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -51,17 +55,27 @@ afterEach(() => { }); describe("builtin-brightdata-search", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch(makeResponse({ json: { organic: [] } })); await searchFn("react", context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_SEARCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ json: { organic: [] } })); + + await searchFn("react", context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when `BRIGHTDATA_API_KEY` is missing", async () => { delete process.env.BRIGHTDATA_API_KEY; diff --git a/src/plugins/builtin-brightdata-search/main.ts b/src/plugins/builtin-brightdata-search/main.ts index fa7a924..5f63382 100644 --- a/src/plugins/builtin-brightdata-search/main.ts +++ b/src/plugins/builtin-brightdata-search/main.ts @@ -4,6 +4,7 @@ */ import type { SearchPlugin } from "../../@types/plugin.ts"; import { + getPluginTimeout, getSearchResultsLimit, shouldShowSearchDescription, stripSearchResultDatePrefix, @@ -22,8 +23,6 @@ interface BrightDataSerpResult { const PATTERN_READ_MORE = /\.\.\.\s*read more$/i; -const REQUEST_TIMEOUT_MS = 10_000; - async function searchFn(query: string) { const apiKey = process.env.BRIGHTDATA_API_KEY; if (!apiKey) { @@ -58,7 +57,7 @@ async function searchFn(query: string) { format: "raw", data_format: "parsed_light", }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("search")), }); if (!res.ok) { diff --git a/src/plugins/builtin-crawl4ai-fetch/main.test.ts b/src/plugins/builtin-crawl4ai-fetch/main.test.ts index 56972ec..4e923ec 100644 --- a/src/plugins/builtin-crawl4ai-fetch/main.test.ts +++ b/src/plugins/builtin-crawl4ai-fetch/main.test.ts @@ -54,20 +54,24 @@ const emptyContext: PluginContext = { }; let warnSpy: ReturnType; +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); parseFn.mockClear(); envSnapshot = { ...process.env }; delete process.env.SIBYL_CRAWL4AI_URL; delete process.env.SIBYL_CRAWL4AI_PROXY_SERVER; delete process.env.SIBYL_CRAWL4AI_PROXY_USERNAME; delete process.env.SIBYL_CRAWL4AI_PROXY_PASSWORD; + delete process.env.SIBYL_FETCH_TIMEOUT; }); afterEach(() => { warnSpy.mockRestore(); + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -76,17 +80,27 @@ afterEach(() => { }); describe("builtin-crawl4ai-fetch", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch(makeResponse({ json: { success: true, results: [] } })); await fetchFn(url, context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_FETCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ json: { success: true, results: [] } })); + + await fetchFn(url, context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when the response is not ok", async () => { stubFetch(makeResponse({ ok: false, status: 500, statusText: "Internal Server Error" })); diff --git a/src/plugins/builtin-crawl4ai-fetch/main.ts b/src/plugins/builtin-crawl4ai-fetch/main.ts index 181cad8..6dbbcdf 100644 --- a/src/plugins/builtin-crawl4ai-fetch/main.ts +++ b/src/plugins/builtin-crawl4ai-fetch/main.ts @@ -3,6 +3,7 @@ * Since: 12/06/2026 */ import type { FetchPlugin, ParsePlugin, PluginContext } from "../../@types/plugin.ts"; +import { getPluginTimeout } from "../../utils.ts"; interface Result { url: string; @@ -16,8 +17,6 @@ interface Crawl4AiResult { results?: Result[]; } -const REQUEST_TIMEOUT_MS = 10_000; - async function fetchFn(url: string, context: PluginContext): Promise { const crawl4AiUrl = process.env.SIBYL_CRAWL4AI_URL ?? "http://localhost:11235"; const crawl4AiCrawlApiUrl = crawl4AiUrl + "/crawl"; @@ -57,7 +56,7 @@ async function fetchFn(url: string, context: PluginContext): Promise { : {}), }, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("fetch")), }); } catch (err) { console.warn( diff --git a/src/plugins/builtin-exa-fetch/main.test.ts b/src/plugins/builtin-exa-fetch/main.test.ts index 72e7883..16b246e 100644 --- a/src/plugins/builtin-exa-fetch/main.test.ts +++ b/src/plugins/builtin-exa-fetch/main.test.ts @@ -32,14 +32,18 @@ function stubFetch(res: unknown) { return mock; } +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.EXA_API_KEY = "test-key"; + delete process.env.SIBYL_FETCH_TIMEOUT; }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -48,17 +52,27 @@ afterEach(() => { }); describe("builtin-exa-fetch", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch(makeResponse({ json: { results: [] } })); await fetchFn("https://a.com", context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_FETCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ json: { results: [] } })); + + await fetchFn("https://a.com", context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when `EXA_API_KEY` is missing", async () => { delete process.env.EXA_API_KEY; diff --git a/src/plugins/builtin-exa-fetch/main.ts b/src/plugins/builtin-exa-fetch/main.ts index 112319e..deeb573 100644 --- a/src/plugins/builtin-exa-fetch/main.ts +++ b/src/plugins/builtin-exa-fetch/main.ts @@ -3,6 +3,7 @@ * Since: 06/06/2026 */ import type { FetchPlugin } from "../../@types/plugin.ts"; +import { getPluginTimeout } from "../../utils.ts"; interface ExaContentResult { url: string; @@ -14,8 +15,6 @@ interface ExaContentsResponse { results: ExaContentResult[]; } -const REQUEST_TIMEOUT_MS = 10_000; - async function fetchFn(url: string): Promise { const apiKey = process.env.EXA_API_KEY; if (!apiKey) { @@ -32,7 +31,7 @@ async function fetchFn(url: string): Promise { urls: [url], text: true, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("fetch")), }); if (!res.ok) { diff --git a/src/plugins/builtin-exa-search/main.test.ts b/src/plugins/builtin-exa-search/main.test.ts index 97fd216..44dedfb 100644 --- a/src/plugins/builtin-exa-search/main.test.ts +++ b/src/plugins/builtin-exa-search/main.test.ts @@ -32,16 +32,20 @@ function stubFetch(res: unknown) { return mock; } +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.EXA_API_KEY = "test-key"; delete process.env.SIBYL_SEARCH_RESULTS_LIMIT; delete process.env.SIBYL_SHOW_SEARCH_DESCRIPTION; + delete process.env.SIBYL_SEARCH_TIMEOUT; }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -50,17 +54,27 @@ afterEach(() => { }); describe("builtin-exa-search", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch(makeResponse({ json: { results: [] } })); await searchFn("react", context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_SEARCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ json: { results: [] } })); + + await searchFn("react", context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when `EXA_API_KEY` is missing", async () => { delete process.env.EXA_API_KEY; diff --git a/src/plugins/builtin-exa-search/main.ts b/src/plugins/builtin-exa-search/main.ts index 37c7b52..623db69 100644 --- a/src/plugins/builtin-exa-search/main.ts +++ b/src/plugins/builtin-exa-search/main.ts @@ -3,7 +3,11 @@ * Since: 06/06/2026 */ import type { SearchPlugin } from "../../@types/plugin.ts"; -import { getSearchResultsLimit, shouldShowSearchDescription } from "../../utils.ts"; +import { + getPluginTimeout, + getSearchResultsLimit, + shouldShowSearchDescription, +} from "../../utils.ts"; interface ExaResult { title: string | null; @@ -15,8 +19,6 @@ interface ExaResponse { results: ExaResult[]; } -const REQUEST_TIMEOUT_MS = 10_000; - async function searchFn(query: string) { const apiKey = process.env.EXA_API_KEY; if (!apiKey) { @@ -40,7 +42,7 @@ async function searchFn(query: string) { highlights: showDescription, }, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("search")), }); if (!res.ok) { diff --git a/src/plugins/builtin-firecrawl-fetch/main.test.ts b/src/plugins/builtin-firecrawl-fetch/main.test.ts index 7d84f13..ed31b0e 100644 --- a/src/plugins/builtin-firecrawl-fetch/main.test.ts +++ b/src/plugins/builtin-firecrawl-fetch/main.test.ts @@ -47,16 +47,20 @@ const emptyContext: PluginContext = { getPlugin: () => null, }; +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { parseFn.mockClear(); + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.FIRECRAWL_API_KEY = "test-key"; delete process.env.SIBYL_FIRECRAWL_FETCH_USE_HTML; + delete process.env.SIBYL_FETCH_TIMEOUT; }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -65,19 +69,29 @@ afterEach(() => { }); describe("builtin-firecrawl-fetch", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch( makeResponse({ json: { success: true, data: { markdown: "x", rawHtml: "" } } }), ); await fetchFn(url, context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_FETCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ json: { success: true, data: { markdown: "x", rawHtml: "" } } })); + + await fetchFn(url, context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when `FIRECRAWL_API_KEY` is missing", async () => { delete process.env.FIRECRAWL_API_KEY; diff --git a/src/plugins/builtin-firecrawl-fetch/main.ts b/src/plugins/builtin-firecrawl-fetch/main.ts index 1095779..bec008b 100644 --- a/src/plugins/builtin-firecrawl-fetch/main.ts +++ b/src/plugins/builtin-firecrawl-fetch/main.ts @@ -3,7 +3,7 @@ * Since: 13/06/2026 */ import type { FetchPlugin, ParsePlugin, PluginContext } from "../../@types/plugin.ts"; -import { collapseBlankLines } from "../../utils.ts"; +import { collapseBlankLines, getPluginTimeout } from "../../utils.ts"; interface FirecrawlFetchResponse { success: boolean; @@ -13,8 +13,6 @@ interface FirecrawlFetchResponse { }; } -const REQUEST_TIMEOUT_MS = 10_000; - async function fetchFn(url: string, context: PluginContext): Promise { const apiKey = process.env.FIRECRAWL_API_KEY; if (!apiKey) { @@ -31,7 +29,7 @@ async function fetchFn(url: string, context: PluginContext): Promise { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ url, formats: [format] }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("fetch")), }); if (!res.ok) { diff --git a/src/plugins/builtin-firecrawl-search/main.test.ts b/src/plugins/builtin-firecrawl-search/main.test.ts index be4bd9e..0a096ea 100644 --- a/src/plugins/builtin-firecrawl-search/main.test.ts +++ b/src/plugins/builtin-firecrawl-search/main.test.ts @@ -32,16 +32,20 @@ function stubFetch(res: unknown) { return mock; } +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; process.env.FIRECRAWL_API_KEY = "test-key"; delete process.env.SIBYL_SHOW_SEARCH_DESCRIPTION; delete process.env.SIBYL_SEARCH_RESULTS_LIMIT; + delete process.env.SIBYL_SEARCH_TIMEOUT; }); afterEach(() => { + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -50,19 +54,31 @@ afterEach(() => { }); describe("builtin-firecrawl-search", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch( makeResponse({ json: { success: true, data: { web: [] }, creditsUsed: 0, id: "abc" } }), ); await searchFn("web scraping python", context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_SEARCH_TIMEOUT = "1234"; + stubFetch( + makeResponse({ json: { success: true, data: { web: [] }, creditsUsed: 0, id: "abc" } }), + ); + + await searchFn("web scraping python", context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("throws when `FIRECRAWL_API_KEY` is missing", async () => { delete process.env.FIRECRAWL_API_KEY; diff --git a/src/plugins/builtin-firecrawl-search/main.ts b/src/plugins/builtin-firecrawl-search/main.ts index 167cd48..abb445a 100644 --- a/src/plugins/builtin-firecrawl-search/main.ts +++ b/src/plugins/builtin-firecrawl-search/main.ts @@ -3,7 +3,11 @@ * Since: 13/06/2026 */ import type { SearchPlugin } from "../../@types/plugin.ts"; -import { getSearchResultsLimit, shouldShowSearchDescription } from "../../utils.ts"; +import { + getPluginTimeout, + getSearchResultsLimit, + shouldShowSearchDescription, +} from "../../utils.ts"; interface FirecrawlWebResult { url: string; @@ -19,8 +23,6 @@ interface FirecrawlSearchResponse { id: string; } -const REQUEST_TIMEOUT_MS = 10_000; - async function searchFn(query: string) { const apiKey = process.env.FIRECRAWL_API_KEY; if (!apiKey) { @@ -37,7 +39,7 @@ async function searchFn(query: string) { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ query, limit }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("search")), }); if (!res.ok) { diff --git a/src/plugins/builtin-searxng-search/main.test.ts b/src/plugins/builtin-searxng-search/main.test.ts index 8cf96e3..309a559 100644 --- a/src/plugins/builtin-searxng-search/main.test.ts +++ b/src/plugins/builtin-searxng-search/main.test.ts @@ -41,20 +41,24 @@ function stubFetchReject(err: unknown) { } let warnSpy: ReturnType; +let timeoutSpy: ReturnType; let envSnapshot: NodeJS.ProcessEnv; beforeEach(() => { warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(new AbortController().signal); envSnapshot = { ...process.env }; delete process.env.SIBYL_SEARXNG_URL; delete process.env.SIBYL_SEARXNG_ENGINES; delete process.env.SIBYL_SHOW_SEARCH_DESCRIPTION; delete process.env.SIBYL_SEARCH_RESULTS_LIMIT; + delete process.env.SIBYL_SEARCH_TIMEOUT; }); afterEach(() => { warnSpy.mockRestore(); + timeoutSpy.mockRestore(); vi.unstubAllGlobals(); for (const key of Object.keys(process.env)) { if (!(key in envSnapshot)) delete process.env[key]; @@ -63,17 +67,27 @@ afterEach(() => { }); describe("builtin-searxng-search", () => { - it("an AbortSignal is present on the fetch", async () => { + it("uses the default timeout for the fetch", async () => { const fetchMock = stubFetch(makeResponse({ json: { results: [] } })); await searchFn("react vite", context); + expect(timeoutSpy).toHaveBeenCalledWith(10_000); expect(fetchMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + it("uses the configured timeout for the fetch", async () => { + process.env.SIBYL_SEARCH_TIMEOUT = "1234"; + stubFetch(makeResponse({ json: { results: [] } })); + + await searchFn("react vite", context); + + expect(timeoutSpy).toHaveBeenCalledWith(1234); + }); + it("queries the default url with `format=json` and no `engines` when unset", async () => { const fetchMock = stubFetch(makeResponse({ json: { results: [] } })); diff --git a/src/plugins/builtin-searxng-search/main.ts b/src/plugins/builtin-searxng-search/main.ts index 49a318b..6bafdd2 100644 --- a/src/plugins/builtin-searxng-search/main.ts +++ b/src/plugins/builtin-searxng-search/main.ts @@ -4,6 +4,7 @@ */ import type { SearchPlugin } from "../../@types/plugin.ts"; import { + getPluginTimeout, getSearchResultsLimit, shouldShowSearchDescription, stripSearchResultDatePrefix, @@ -21,8 +22,6 @@ interface SearXngResult { results: Result[]; } -const REQUEST_TIMEOUT_MS = 10_000; - async function searchFn(query: string) { const searxngUrl = process.env.SIBYL_SEARXNG_URL ?? "http://localhost:8080"; const showDescription = shouldShowSearchDescription(); @@ -38,7 +37,7 @@ async function searchFn(query: string) { try { res = await fetch(`${searxngUrl}/search?${params.toString()}`, { - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(getPluginTimeout("search")), }); } catch (err) { console.warn( diff --git a/src/setup.test.ts b/src/setup.test.ts index 249351f..4cbd0c8 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -33,6 +33,18 @@ const DEFAULT_CONFIG: SibylConfig = { name: "SIBYL_SHOW_SEARCH_DESCRIPTION", value: "true", }, + { + name: "SIBYL_SEARCH_TIMEOUT", + value: "10000", + }, + { + name: "SIBYL_FETCH_TIMEOUT", + value: "10000", + }, + { + name: "SIBYL_ASK_TIMEOUT", + value: "30000", + }, ], }; diff --git a/src/setup.ts b/src/setup.ts index 58ac137..1f42736 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -64,6 +64,18 @@ export function writeDefaultSibylConfig(): void { name: "SIBYL_SHOW_SEARCH_DESCRIPTION", value: "true", }, + { + name: "SIBYL_SEARCH_TIMEOUT", + value: "10000", + }, + { + name: "SIBYL_FETCH_TIMEOUT", + value: "10000", + }, + { + name: "SIBYL_ASK_TIMEOUT", + value: "30000", + }, ], }; diff --git a/src/utils.test.ts b/src/utils.test.ts index c271ff6..3295f0f 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { collapseBlankLines, + getPluginTimeout, getSearchResultsLimit, isValidHttpUrl, shouldShowSearchDescription, @@ -84,6 +85,64 @@ describe("collapseBlankLines", () => { }); }); +describe("getPluginTimeout", () => { + let envSnapshot: NodeJS.ProcessEnv; + + beforeEach(() => { + envSnapshot = { ...process.env }; + delete process.env.SIBYL_SEARCH_TIMEOUT; + delete process.env.SIBYL_FETCH_TIMEOUT; + delete process.env.SIBYL_ASK_TIMEOUT; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in envSnapshot)) delete process.env[key]; + } + Object.assign(process.env, envSnapshot); + }); + + it.each([ + ["search", 10_000], + ["fetch", 10_000], + ["ask", 30_000], + ] as const)("uses the current default when the %s timeout is unset", (type, expected) => { + expect(getPluginTimeout(type)).toBe(expected); + }); + + it.each([ + ["search", "1", 1], + ["fetch", "1234", 1234], + ["ask", "2147483647", 2_147_483_647], + ] as const)("parses the configured %s timeout", (type, value, expected) => { + process.env[`SIBYL_${type.toUpperCase()}_TIMEOUT`] = value; + + expect(getPluginTimeout(type)).toBe(expected); + }); + + it("allows surrounding whitespace around a configured timeout", () => { + process.env.SIBYL_SEARCH_TIMEOUT = " 1234 "; + + expect(getPluginTimeout("search")).toBe(1234); + }); + + it.each([ + ["SIBYL_SEARCH_TIMEOUT", "0", "search"], + ["SIBYL_FETCH_TIMEOUT", "-1", "fetch"], + ["SIBYL_ASK_TIMEOUT", "1.5", "ask"], + ["SIBYL_SEARCH_TIMEOUT", "abc", "search"], + ["SIBYL_FETCH_TIMEOUT", "1234ms", "fetch"], + ["SIBYL_ASK_TIMEOUT", "2e3", "ask"], + ["SIBYL_SEARCH_TIMEOUT", "2147483648", "search"], + ] as const)("rejects invalid %s value %j", (envName, value, type) => { + process.env[envName] = value; + + expect(() => getPluginTimeout(type)).toThrow( + `Invalid \`${envName}\`: expected an integer between 1 and 2147483647 milliseconds.`, + ); + }); +}); + describe("getSearchResultsLimit", () => { let envSnapshot: NodeJS.ProcessEnv; diff --git a/src/utils.ts b/src/utils.ts index c21e5b9..12e107f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,6 +25,47 @@ export function collapseBlankLines(markdown: string): string { return markdown.replace(/\n{2,}/g, "\n").trim(); } +const DEFAULT_PLUGIN_TIMEOUTS = { + search: 10_000, + fetch: 10_000, + ask: 30_000, +} as const; + +const PLUGIN_TIMEOUT_ENV_NAMES = { + search: "SIBYL_SEARCH_TIMEOUT", + fetch: "SIBYL_FETCH_TIMEOUT", + ask: "SIBYL_ASK_TIMEOUT", +} as const; + +const MAX_PLUGIN_TIMEOUT_MS = 2_147_483_647; + +type TimeoutPluginType = keyof typeof DEFAULT_PLUGIN_TIMEOUTS; + +export function getPluginTimeout(type: TimeoutPluginType): number { + const envName = PLUGIN_TIMEOUT_ENV_NAMES[type]; + const raw = process.env[envName]; + + if (raw === undefined) { + return DEFAULT_PLUGIN_TIMEOUTS[type]; + } + + const value = raw.trim(); + const timeout = Number(value); + + if ( + !/^\d+$/.test(value) || + !Number.isSafeInteger(timeout) || + timeout < 1 || + timeout > MAX_PLUGIN_TIMEOUT_MS + ) { + throw new Error( + `Invalid \`${envName}\`: expected an integer between 1 and ${MAX_PLUGIN_TIMEOUT_MS} milliseconds.`, + ); + } + + return timeout; +} + // Maximum number of results a search plugin should return. Read from // `SIBYL_SEARCH_RESULTS_LIMIT`, falling back to 10 when unset or // invalid (non-numeric, floating point or <= 0).