Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion src/plugins/builtin-ai-ask/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,25 @@ function makeContext(fetchFn?: (url: string) => Promise<string>): PluginContext

const okContext = makeContext(async () => "page body content");

let timeoutSpy: ReturnType<typeof vi.spyOn>;
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];
Expand Down Expand Up @@ -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"),
Expand All @@ -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 }),
);
});
});
5 changes: 2 additions & 3 deletions src/plugins/builtin-ai-ask/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. " +
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 15 additions & 1 deletion src/plugins/builtin-alterlab-fetch/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,19 @@ const emptyContext: PluginContext = {
getPlugin: () => null,
};

let timeoutSpy: ReturnType<typeof vi.spyOn>;
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];
Expand All @@ -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;

Expand Down
5 changes: 2 additions & 3 deletions src/plugins/builtin-alterlab-fetch/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,8 +15,6 @@ interface AlterLabScrapeResponse {
content?: Result;
}

const REQUEST_TIMEOUT_MS = 10_000;

async function fetchFn(url: string, context: PluginContext): Promise<string> {
const apiKey = process.env.ALTERLAB_API_KEY;
if (!apiKey) {
Expand All @@ -29,7 +28,7 @@ async function fetchFn(url: string, context: PluginContext): Promise<string> {
"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) {
Expand Down
16 changes: 15 additions & 1 deletion src/plugins/builtin-alterlab-search/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,20 @@ function stubFetch(res: unknown) {
return mock;
}

let timeoutSpy: ReturnType<typeof vi.spyOn>;
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];
Expand All @@ -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;

Expand Down
5 changes: 2 additions & 3 deletions src/plugins/builtin-alterlab-search/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import type { SearchPlugin } from "../../@types/plugin.ts";
import {
getPluginTimeout,
getSearchResultsLimit,
shouldShowSearchDescription,
stripSearchResultDatePrefix,
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
16 changes: 15 additions & 1 deletion src/plugins/builtin-brightdata-fetch/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,20 @@ function stubFetch(res: unknown) {
return mock;
}

let timeoutSpy: ReturnType<typeof vi.spyOn>;
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];
Expand All @@ -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: "<html></html>" }));

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: "<html></html>" }));

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;

Expand Down
5 changes: 2 additions & 3 deletions src/plugins/builtin-brightdata-fetch/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const apiKey = process.env.BRIGHTDATA_API_KEY;
Expand All @@ -28,7 +27,7 @@ async function fetchFn(url: string, context: PluginContext): Promise<string> {
url,
format: "raw",
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
signal: AbortSignal.timeout(getPluginTimeout("fetch")),
});

if (!res.ok) {
Expand Down
16 changes: 15 additions & 1 deletion src/plugins/builtin-brightdata-search/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,21 @@ function stubFetch(res: unknown) {
return mock;
}

let timeoutSpy: ReturnType<typeof vi.spyOn>;
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];
Expand All @@ -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;

Expand Down
Loading
Loading