Skip to content

Commit 99c1368

Browse files
Add timeout
Signed-off-by: Leslie Lazzarino <leslie.lazzarino@gmail.com> Signed-off-by: Leslie Lazzarino <leslie.lazzarino@gmail.com>
1 parent 9dde93a commit 99c1368

11 files changed

Lines changed: 182 additions & 24 deletions

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"update_url": "https://raw.githubusercontent.com/TNG/tb-llm-composer/refs/heads/main/updates.json"
1212
}
1313
},
14-
"permissions": ["menus", "compose", "storage", "notifications", "messagesRead", "accountsRead"],
14+
"permissions": ["menus", "compose", "storage", "notifications", "messagesRead", "accountsRead", "alarms"],
1515
"icons": {
1616
"64": "icons/icon-64px.png",
1717
"32": "icons/icon-32px.png",

public/options.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ <h2>LLM API Settings</h2>
126126
<label for="api_token">Api token:</label>
127127
<input type="text" id="api_token" name="api token" />
128128
</div>
129+
<div class="form-group">
130+
<label for="timeout">
131+
Request timeout (seconds):
132+
<sup title="Timeout for API requests in seconds. Leave empty or set to 0 for no timeout.">
133+
&#8505;&#65039;
134+
</sup>
135+
</label>
136+
<input type="number" id="timeout" name="timeout" min="0" step="1" placeholder="No timeout" />
137+
</div>
129138
</div>
130139

131140
<div class="settings-section">

src/__tests__/llmConnection.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterAll, describe, expect, test } from "vitest";
22
import { type LlmApiRequestMessage, LlmRoles, sendContentToLlm } from "../llmConnection";
3-
import { getExpectedRequestContent, getMockResponseBody, mockBrowserAndFetch } from "./testUtils";
3+
import { getMockResponseBody, mockBrowserAndFetch } from "./testUtils";
44

55
const originalBrowser = global.browser;
66
const originalFetch = global.fetch;
@@ -39,7 +39,12 @@ describe("Testing sentContentToLlm", () => {
3939
expect(global.fetch).toHaveBeenCalledTimes(1);
4040
expect(global.fetch).toHaveBeenCalledWith(
4141
MOCK_MODEL_URL,
42-
getExpectedRequestContent([MOCK_CONTEXT, MOCK_PROMPT], abortSignal),
42+
expect.objectContaining({
43+
method: "POST",
44+
headers: { "Content-Type": "application/json" },
45+
body: JSON.stringify({ messages: [MOCK_CONTEXT, MOCK_PROMPT] }),
46+
signal: expect.any(AbortSignal),
47+
}),
4348
);
4449
expect(result).toEqual(mockResponseBody);
4550
});
@@ -57,7 +62,12 @@ describe("Testing sentContentToLlm", () => {
5762
expect(global.fetch).toHaveBeenCalledTimes(1);
5863
expect(global.fetch).toHaveBeenCalledWith(
5964
MOCK_MODEL_URL,
60-
getExpectedRequestContent([MOCK_CONTEXT, MOCK_PROMPT], abortSignal, mockToken),
65+
expect.objectContaining({
66+
method: "POST",
67+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${mockToken}` },
68+
body: JSON.stringify({ messages: [MOCK_CONTEXT, MOCK_PROMPT] }),
69+
signal: expect.any(AbortSignal),
70+
}),
6171
);
6272
expect(result).toEqual(mockResponseBody);
6373
});

src/__tests__/testUtils.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { vi } from "vitest";
2+
import type { LlmPluginAction } from "../llmButtonClickHandling";
23
import {
34
type LlmApiRequestMessage,
45
type LlmChoice,
@@ -11,8 +12,6 @@ import { DEFAULT_OPTIONS, type LlmParameters, type Options } from "../optionsPar
1112
import ComposeDetails = browser.compose.ComposeDetails;
1213
import _CreateCreateProperties = browser.menus._CreateCreateProperties;
1314

14-
import type { LlmPluginAction } from "../llmButtonClickHandling";
15-
1615
const MOCK_IDENTITY_ID = "MOCK_IDENTITY_ID";
1716
export const MOCK_TAB_DETAILS: browser.compose.ComposeDetails = { identityId: MOCK_IDENTITY_ID };
1817
export const MOCK_USER_NAME = "MOCK_USER_NAME";
@@ -135,6 +134,16 @@ export function mockBrowser(args: mockBrowserArgs) {
135134
commands: {
136135
getAll: async () => allShortcuts,
137136
},
137+
// @ts-ignore
138+
alarms: {
139+
create: vi.fn(),
140+
clear: vi.fn(),
141+
onAlarm: {
142+
addListener: vi.fn(),
143+
removeListener: vi.fn(),
144+
hasListener: vi.fn(),
145+
},
146+
},
138147
};
139148
}
140149

@@ -203,13 +212,17 @@ export function getExpectedRequestContent(
203212
...params,
204213
};
205214

215+
const headers: { [key: string]: string } = {
216+
"Content-Type": "application/json",
217+
};
218+
if (api_token) {
219+
headers.Authorization = `Bearer ${api_token}`;
220+
}
221+
206222
return {
207223
body: JSON.stringify(expectedRequestBody),
208224
signal,
209-
headers: {
210-
"Content-Type": "application/json",
211-
Authorization: api_token ? `Bearer ${api_token}` : undefined,
212-
},
225+
headers,
213226
method: "POST",
214227
};
215228
}

src/background.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { handleKeepAliveAlarm } from "./keepAlive";
12
import { executeLlmAction, type LlmPluginAction } from "./llmButtonClickHandling";
23
import { addLlmActionsToMenu, enableSummarizeMenuEntryIfReply, handleMenuClickListener } from "./menu";
34
import { deleteFromOriginalTabCache, storeOriginalReplyText } from "./originalTabConversation";
@@ -8,6 +9,9 @@ import Tab = browser.tabs.Tab;
89
// Otherwise, the shortcuts may not work if the background script is not running (which is after 90s of idling or so)
910
browser.commands.onCommand.addListener((command, tab) => executeLlmAction(command as LlmPluginAction, tab));
1011

12+
// Keep the background page alive during long-running LLM requests
13+
browser.alarms.onAlarm.addListener(handleKeepAliveAlarm);
14+
1115
browser.tabs.onCreated.addListener(async (tab: Tab) => {
1216
await storeOriginalReplyText(tab);
1317
await enableSummarizeMenuEntryIfReply(tab);

src/keepAlive.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
const KEEP_ALIVE_ALARM_NAME = "llm-keep-alive";
2+
const KEEP_ALIVE_INTERVAL_MINUTES = 0.4; // ~24 seconds, well under the ~90s idle threshold
3+
4+
let activeRequests = 0;
5+
6+
/**
7+
* Starts a periodic alarm to keep the background page alive during long-running LLM requests.
8+
* Thunderbird MV3 event pages can be suspended after ~90s of inactivity.
9+
* While an active fetch() should prevent suspension, this alarm acts as a safety net
10+
* for very long local LLM inference times (minutes to hours).
11+
*/
12+
export async function startKeepAlive(): Promise<void> {
13+
activeRequests++;
14+
if (activeRequests === 1) {
15+
console.log("KEEP-ALIVE: Starting keep-alive alarm for long-running LLM request");
16+
browser.alarms.create(KEEP_ALIVE_ALARM_NAME, {
17+
periodInMinutes: KEEP_ALIVE_INTERVAL_MINUTES,
18+
});
19+
}
20+
}
21+
22+
/**
23+
* Stops the keep-alive alarm when no more LLM requests are active.
24+
*/
25+
export async function stopKeepAlive(): Promise<void> {
26+
activeRequests = Math.max(0, activeRequests - 1);
27+
if (activeRequests === 0) {
28+
console.log("KEEP-ALIVE: Stopping keep-alive alarm, no active LLM requests");
29+
await browser.alarms.clear(KEEP_ALIVE_ALARM_NAME);
30+
}
31+
}
32+
33+
/**
34+
* Handler for the keep-alive alarm. Simply logs to keep the background page active.
35+
*/
36+
export function handleKeepAliveAlarm(alarm: browser.alarms.Alarm): void {
37+
if (alarm.name === KEEP_ALIVE_ALARM_NAME) {
38+
console.log("KEEP-ALIVE: Heartbeat - background page still active");
39+
}
40+
}

src/llmConnection.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { startKeepAlive, stopKeepAlive } from "./keepAlive";
12
import { getPluginOptions, type LlmParameters } from "./optionsParams";
23

34
export enum LlmRoles {
@@ -81,14 +82,15 @@ export async function sendContentToLlm(
8182
...options.params,
8283
};
8384

84-
return callLlmApi(options.model, requestBody, abortSignal, options.api_token);
85+
return callLlmApi(options.model, requestBody, abortSignal, options.api_token, options.timeout);
8586
}
8687

8788
async function callLlmApi(
8889
url: string,
8990
requestBody: LlmApiRequestBody,
9091
signal: AbortSignal,
9192
token?: string,
93+
timeout?: number,
9294
): Promise<LlmTextCompletionResponse | TgiErrorResponse> {
9395
const headers: { [key: string]: string } = {
9496
"Content-Type": "application/json",
@@ -97,21 +99,51 @@ async function callLlmApi(
9799
headers.Authorization = `Bearer ${token}`;
98100
}
99101

100-
console.log(`LLM-CONNECTION: Sending request to LLM: POST ${url} with body:\n`, JSON.stringify(requestBody));
101-
const response = await fetch(url, {
102-
signal: signal,
103-
method: "POST",
104-
headers: headers,
105-
body: JSON.stringify(requestBody),
106-
});
107-
if (!response.ok) {
108-
const errorResponseBody = await response.text();
109-
throw Error(`LLM-CONNECTION: Error response from ${url}: ${errorResponseBody}`);
102+
// Create a combined abort controller for both user cancellation and timeout
103+
const combinedAbortController = new AbortController();
104+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
105+
106+
// Listen to the user's abort signal
107+
const abortHandler = () => combinedAbortController.abort(signal.reason);
108+
signal.addEventListener("abort", abortHandler);
109+
110+
// Set up timeout if specified
111+
if (timeout !== undefined && timeout > 0) {
112+
timeoutId = setTimeout(() => {
113+
combinedAbortController.abort(
114+
new DOMException(
115+
`LLM request timed out after ${timeout / 1000} seconds. You can increase or disable the timeout in the extension settings.`,
116+
"TimeoutError",
117+
),
118+
);
119+
}, timeout);
110120
}
111-
const responseBody = (await response.json()) as LlmTextCompletionResponse | TgiErrorResponse;
112-
console.log("LLM-CONNECTION: LLM responded with:", response.status, responseBody);
113121

114-
return responseBody;
122+
await startKeepAlive();
123+
try {
124+
console.log(`LLM-CONNECTION: Sending request to LLM: POST ${url} with body:\n`, JSON.stringify(requestBody));
125+
const response = await fetch(url, {
126+
signal: combinedAbortController.signal,
127+
method: "POST",
128+
headers: headers,
129+
body: JSON.stringify(requestBody),
130+
});
131+
if (!response.ok) {
132+
const errorResponseBody = await response.text();
133+
throw Error(`LLM-CONNECTION: Error response from ${url}: ${errorResponseBody}`);
134+
}
135+
const responseBody = (await response.json()) as LlmTextCompletionResponse | TgiErrorResponse;
136+
console.log("LLM-CONNECTION: LLM responded with:", response.status, responseBody);
137+
138+
return responseBody;
139+
} finally {
140+
await stopKeepAlive();
141+
// Clean up
142+
signal.removeEventListener("abort", abortHandler);
143+
if (timeoutId !== undefined) {
144+
clearTimeout(timeoutId);
145+
}
146+
}
115147
}
116148

117149
export function isLlmTextCompletionResponse(response: LlmTextCompletionResponse | TgiErrorResponse) {

src/notifications.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export function notifyOnError<T>(callback: () => Promise<T>) {
1414
console.debug("User cancelled request, do not notify", e);
1515
return;
1616
}
17+
if (e.name === "TimeoutError") {
18+
timedNotification(
19+
"LLM Request Timeout",
20+
e.message || "The LLM request timed out. You can increase the timeout in the extension settings.",
21+
15000,
22+
);
23+
return;
24+
}
1725
const message = e?.message || e.toString();
1826
timedNotification("Thunderbird LLM Extension Error", message);
1927
});

src/options.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getInputElement } from "./utils";
55
document.addEventListener("DOMContentLoaded", restoreOptions);
66
document.querySelector("#url")?.addEventListener("change", updateUrl);
77
document.querySelector("#api_token")?.addEventListener("change", updateApiToken);
8+
document.querySelector("#timeout")?.addEventListener("change", updateTimeout);
89
document.querySelector("#llm_context")?.addEventListener("change", updateLlmContext);
910
document.querySelector("#use_last_mails")?.addEventListener("change", updateUseLastMails);
1011
document.querySelector("#context_window")?.addEventListener("change", updateContextWindow);
@@ -29,6 +30,15 @@ async function updateApiToken(event: Event) {
2930
await browser.storage.sync.set({ options });
3031
}
3132

33+
async function updateTimeout(event: Event) {
34+
const timeoutInput = event.target as HTMLInputElement;
35+
const options = await getPluginOptions();
36+
const timeoutSeconds = timeoutInput.valueAsNumber;
37+
// Convert seconds to milliseconds, or set to undefined if 0 or empty
38+
options.timeout = timeoutSeconds > 0 ? timeoutSeconds * 1000 : undefined;
39+
await browser.storage.sync.set({ options });
40+
}
41+
3242
async function updateLlmContext(event: Event) {
3343
const llmContextInput = event.target as HTMLTextAreaElement;
3444
const options = await getPluginOptions();
@@ -64,6 +74,7 @@ export async function restoreOptions(): Promise<void> {
6474

6575
getInputElement("#url").value = options.model;
6676
getInputElement("#api_token").value = options.api_token || "";
77+
getInputElement("#timeout").value = options.timeout ? `${options.timeout / 1000}` : "";
6778
getInputElement("#context_window").value = `${options.context_window}`;
6879
getInputElement("#use_last_mails").checked = options.include_recent_mails;
6980
getInputElement("#other_options").value = JSON.stringify(options.params, null, 2);

src/optionsParams.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface Options {
2323
include_recent_mails: boolean;
2424
params: LlmParameters;
2525
llmContext: string;
26+
timeout?: number; // Timeout in milliseconds, undefined means no timeout
2627
}
2728

2829
export const DEFAULT_PARAMS: LlmParameters = {};

0 commit comments

Comments
 (0)