From d6d46d228b3654efb8cdcbfce58edd8aadd073f9 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Mon, 27 Jul 2026 14:15:58 +0200 Subject: [PATCH 1/3] fix(ai-llm-proxy): abort upstream fetch when client disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy never listened for the client connection closing, so an AbortSignal.timeout firing client-side, a closed tab, or a dropped connection left the outbound LLM fetch running for up to its own 60s timeout — burning LLM cost for a response nobody could receive, then attempting to write to an already-dead response. Wire an AbortController into handleRequest that aborts on req's 'close' event and is combined with the existing upstream timeout via AbortSignal.any(), so either condition stops the outbound fetch. Guard sendJson and the final response write against a disconnected client, and attach a no-op res error listener so a late write-after- close can never crash the process with an unhandled 'error' event. Add unit tests proving the outbound fetch signal aborts on client disconnect and that no write/crash happens afterwards. Signed-off-by: Lukas Hirt --- packages/ai-llm-proxy/src/index.ts | 37 ++- .../ai-llm-proxy/tests/unit/proxy.spec.ts | 213 +++++++++++++++++- 2 files changed, 246 insertions(+), 4 deletions(-) diff --git a/packages/ai-llm-proxy/src/index.ts b/packages/ai-llm-proxy/src/index.ts index 7e0e1adaa..9d845239c 100644 --- a/packages/ai-llm-proxy/src/index.ts +++ b/packages/ai-llm-proxy/src/index.ts @@ -209,7 +209,15 @@ export function isOriginAllowed(origin: string | undefined, expected: string): b return true } +/** + * Writes a JSON response, but no-ops when the client has already + * disconnected (`res.writableEnded`/`res.destroyed`) — writing to a response + * whose underlying socket is gone can emit an 'error' event on `res`, which + * would crash the process without a listener. See the `res.on('error', ...)` + * guard in `handleRequest` for the belt-and-suspenders backstop. + */ function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + if (res.writableEnded || res.destroyed) return const payload = JSON.stringify(body) res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(payload) @@ -219,7 +227,27 @@ function sendJson(res: http.ServerResponse, status: number, body: unknown): void // Request handler // --------------------------------------------------------------------------- -async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { +export async function handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse +): Promise { + // Defensive: once the client has disconnected (see the 'close' listener + // below), writing to `res` can emit an 'error' event on the response + // stream. Without a listener, an unhandled 'error' event on a stream + // crashes the whole process, so this is a no-op safety net regardless of + // where a write happens below. + res.on('error', () => {}) + + // Tracks whether the client is still there to receive a response, and + // lets us abort the outbound LLM fetch the moment it isn't. Node emits + // 'close' on the request once the underlying connection is terminated — + // whether that's a clean end, the client's own AbortSignal.timeout firing, + // or the tab/browser dropping the connection — so this is the one signal + // we need to stop doing (and paying for) work nobody will receive the + // result of. + const clientAbortController = new AbortController() + req.on('close', () => clientAbortController.abort()) + setCorsHeaders(res) // Preflight @@ -304,11 +332,15 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse let llmRes: Response try { + // Two independent reasons to give up on the outbound LLM call: it's + // taking too long (upstream timeout), or the client that asked for it + // is no longer there (clientAbortController, aborted from the 'close' + // listener above). Either one should stop the request. llmRes = await fetch(`${LLM_ENDPOINT}/chat/completions`, { method: 'POST', headers: llmHeaders, body: JSON.stringify(sanitized), - signal: AbortSignal.timeout(LLM_TIMEOUT_MS) + signal: AbortSignal.any([clientAbortController.signal, AbortSignal.timeout(LLM_TIMEOUT_MS)]) }) } catch (err) { console.error('[ai-llm-proxy] LLM request error:', err) @@ -317,6 +349,7 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse } const llmBody = await llmRes.text() + if (res.writableEnded || res.destroyed) return res.writeHead(llmRes.status, { 'Content-Type': llmRes.headers.get('content-type') ?? 'application/json' }) diff --git a/packages/ai-llm-proxy/tests/unit/proxy.spec.ts b/packages/ai-llm-proxy/tests/unit/proxy.spec.ts index e393fa54f..51e1e511a 100644 --- a/packages/ai-llm-proxy/tests/unit/proxy.spec.ts +++ b/packages/ai-llm-proxy/tests/unit/proxy.spec.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { Readable } from 'node:stream' +import { EventEmitter } from 'node:events' import type http from 'node:http' import { @@ -8,7 +9,8 @@ import { rateLimitWindows, readBody, BodyTooLargeError, - isOriginAllowed + isOriginAllowed, + handleRequest } from '../../src/index.js' // --------------------------------------------------------------------------- @@ -189,3 +191,210 @@ describe('isOriginAllowed', () => { expect(isOriginAllowed(undefined, '')).toBe(true) }) }) + +// --------------------------------------------------------------------------- +// handleRequest — client-disconnect aborts the outbound LLM fetch +// --------------------------------------------------------------------------- + +/** + * A minimal http.IncomingMessage double: an EventEmitter carrying the + * headers/method/url handleRequest reads, plus manual 'data'/'end'/'close' + * emission so tests can drive the request lifecycle explicitly (a real + * Readable's autoDestroy would fire 'close' right after 'end', which would + * defeat the point of testing 'close' as a distinct disconnect signal). + */ +function makeMockReq(overrides: Partial = {}): http.IncomingMessage { + const req = new EventEmitter() as unknown as http.IncomingMessage + Object.assign(req, { + method: 'POST', + url: '/v1/chat/completions', + headers: { authorization: 'Bearer test-token' }, + ...overrides + }) + return req +} + +/** + * A minimal http.ServerResponse double. `writableEnded`/`destroyed` mirror + * the real flags `sendJson`/`handleRequest` check before writing; `end` + * flips `writableEnded` the way a real response would. + */ +function makeMockRes(): http.ServerResponse & { writeHead: ReturnType } { + const res = new EventEmitter() as unknown as http.ServerResponse & { + writeHead: ReturnType + } + Object.assign(res, { + statusCode: 200, + headersSent: false, + writableEnded: false, + destroyed: false, + setHeader: vi.fn(), + writeHead: vi.fn(), + write: vi.fn(), + end: vi.fn(function end(this: typeof res) { + this.writableEnded = true + }) + }) + return res +} + +/** Emulates fetch's real abort semantics: rejects once the signal aborts. */ +function pendingUntilAborted(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + const err = new Error('This operation was aborted') + err.name = 'AbortError' + reject(err) + }) + }) +} + +/** + * handleRequest awaits two OIDC round-trips (discovery + userinfo) before it + * ever calls `readBody`, which is what attaches the 'data'/'end' listeners + * `req.emit(...)` needs a live listener for. Polling for the listener + * (rather than guessing a fixed number of microtask ticks) keeps the test + * from being coupled to handleRequest's exact internal await count. + */ +function waitForListener(emitter: EventEmitter, event: string): Promise { + return new Promise((resolve) => { + const check = () => { + if (emitter.listenerCount(event) > 0) { + resolve() + } else { + setImmediate(check) + } + } + check() + }) +} + +describe('handleRequest — aborts the upstream LLM fetch on client disconnect', () => { + afterEach(() => { + vi.unstubAllGlobals() + rateLimitWindows.clear() + }) + + it('aborts the outbound fetch signal when the client closes the connection mid-flight', async () => { + let capturedSignal: AbortSignal | undefined + let chatCompletionsCalled = false + + vi.stubGlobal( + 'fetch', + vi.fn((url: string, init?: RequestInit) => { + if (url.includes('.well-known/openid-configuration')) { + return Promise.resolve({ + ok: true, + json: async () => ({ userinfo_endpoint: 'https://ocis.example.test/userinfo' }) + }) + } + if (url.includes('/userinfo')) { + return Promise.resolve({ ok: true, json: async () => ({ sub: 'user-close-test' }) }) + } + if (url.includes('/chat/completions')) { + capturedSignal = init?.signal as AbortSignal + chatCompletionsCalled = true + return pendingUntilAborted(capturedSignal) + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)) + }) + ) + + const req = makeMockReq() + const res = makeMockRes() + + const pending = handleRequest(req, res) + + // handleRequest awaits OIDC discovery + userinfo before it ever attaches + // the body listeners, so wait for readBody's 'data' listener before + // emitting the body. + await waitForListener(req, 'data') + req.emit('data', Buffer.from(JSON.stringify({ model: 'test-model', messages: [] }))) + req.emit('end') + + // Wait until the outbound LLM fetch has actually been issued, then + // simulate the client disconnecting while it's still in flight. + await vi.waitFor(() => { + if (!chatCompletionsCalled) throw new Error('chat/completions not called yet') + }) + req.emit('close') + + await pending + + expect(capturedSignal).toBeDefined() + expect(capturedSignal?.aborted).toBe(true) + }) + + it('does not attempt to write to the response once the client has disconnected', async () => { + let chatCompletionsCalled = false + + vi.stubGlobal( + 'fetch', + vi.fn((url: string, init?: RequestInit) => { + if (url.includes('.well-known/openid-configuration')) { + return Promise.resolve({ + ok: true, + json: async () => ({ userinfo_endpoint: 'https://ocis.example.test/userinfo' }) + }) + } + if (url.includes('/userinfo')) { + return Promise.resolve({ ok: true, json: async () => ({ sub: 'user-write-test' }) }) + } + if (url.includes('/chat/completions')) { + chatCompletionsCalled = true + return pendingUntilAborted(init?.signal as AbortSignal) + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)) + }) + ) + + const req = makeMockReq() + const res = makeMockRes() + + const pending = handleRequest(req, res) + + await waitForListener(req, 'data') + req.emit('data', Buffer.from(JSON.stringify({ model: 'test-model', messages: [] }))) + req.emit('end') + + await vi.waitFor(() => { + if (!chatCompletionsCalled) throw new Error('chat/completions not called yet') + }) + + // The client's socket is gone: mark the response as no longer writable + // (mirrors what happens to a real http.ServerResponse sharing the same + // dead connection) and signal the disconnect. + res.writableEnded = true + res.destroyed = true + req.emit('close') + + // Should resolve cleanly — no unhandled rejection/exception — and must + // not have attempted to write the 502 "Could not reach LLM endpoint" + // fallback to a response nobody can receive. + await expect(pending).resolves.toBeUndefined() + expect(res.writeHead).not.toHaveBeenCalled() + expect(res.end).not.toHaveBeenCalled() + }) + + it('attaches an error listener to the response, so a late write-after-close error cannot crash the process', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + // Fails fast (missing auth header) so handleRequest returns quickly + // — this test only cares about the defensive res.on('error') guard + // that's installed unconditionally at the top of handleRequest. + return Promise.reject(new Error(`unexpected fetch url: ${url}`)) + }) + ) + + const req = makeMockReq({ headers: {} }) + const res = makeMockRes() + + await handleRequest(req, res) + + // Node's EventEmitter throws synchronously when 'error' is emitted with + // no listener attached — asserting this does NOT throw proves + // handleRequest's `res.on('error', () => {})` guard is in place. + expect(() => res.emit('error', new Error('write after close'))).not.toThrow() + }) +}) From 1efda7d6fc8cdfed4cacbe96b6ecf6aa27bddbcd Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Mon, 27 Jul 2026 14:16:12 +0200 Subject: [PATCH 2/3] fix(web-app-ai-*): align client LLM request timeouts above proxy timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every ai-llm-proxy caller set its own AbortSignal.timeout with wildly inconsistent values (30s or 60s), while the proxy's own upstream fetch allows up to 60s plus unaccounted latency for OIDC discovery/userinfo validation. Callers using the 30s value could abort mid-flight while the proxy was still legitimately working, cancelling a request that would otherwise have succeeded. Raise and unify every client-side timeout that calls the ai-llm-proxy to 90s — comfortably above the proxy's 60s upstream timeout plus margin — across web-app-ai-data-insights-sidebar, web-app-ai-doc-summary, web-app-ai-folder-brief-sidebar, web-app-ai-folder-readme-generator, web-app-ai-image-alt-text-sidebar (main call only; the separate 10s vision-capability probe is intentionally untouched), web-app-ai-multi- doc-synthesizer, web-app-ai-quick-draft-creator, web-app-ai-sensitive- data-scanner, web-app-ai-smart-collections-nav, web-app-ai-smart-file- tagger-qa, web-app-chat-with-file, and web-app-version-changelog. (web-app-ai-smart-collections-nav's useRecentFiles.ts REQUEST_TIMEOUT_MS is a WebDAV REPORT/getFileContents timeout, not an LLM proxy call, and is left untouched.) Also add TimeoutError-specific error handling to the two composables that were missing it entirely: web-app-ai-sensitive-data-scanner's useLlm/useScanner and web-app-ai-quick-draft-creator's useLLM (which required wrapping the fetch call itself in try/catch, since a timeout abort rejects before there's a Response to check .ok on). Signed-off-by: Lukas Hirt --- .../src/composables/useLLM.ts | 3 +- .../src/composables/useSummary.ts | 3 +- .../src/composables/useFolderBrief.ts | 3 +- .../src/composables/useLLM.ts | 6 ++-- .../src/composables/useAltText.ts | 3 +- .../src/composables/useLLM.ts | 3 +- .../src/composables/useLLM.ts | 31 +++++++++++++------ .../src/composables/useLlm.ts | 3 +- .../src/composables/useScanner.ts | 11 +++++-- .../src/composables/useLLM.ts | 3 +- .../src/composables/useLLM.ts | 6 ++-- .../src/composables/useChat.ts | 3 +- .../src/composables/useChangelog.ts | 3 +- 13 files changed, 56 insertions(+), 25 deletions(-) diff --git a/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts b/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts index a5670c868..6c5387d08 100644 --- a/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts +++ b/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts @@ -55,7 +55,8 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - signal: AbortSignal.timeout(60_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: cfg.model, messages, diff --git a/packages/web-app-ai-doc-summary/src/composables/useSummary.ts b/packages/web-app-ai-doc-summary/src/composables/useSummary.ts index ce7d2dce8..dd4e61ca2 100644 --- a/packages/web-app-ai-doc-summary/src/composables/useSummary.ts +++ b/packages/web-app-ai-doc-summary/src/composables/useSummary.ts @@ -147,7 +147,8 @@ export function useSummary( const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - signal: AbortSignal.timeout(30_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: cfg.model, messages: [ diff --git a/packages/web-app-ai-folder-brief-sidebar/src/composables/useFolderBrief.ts b/packages/web-app-ai-folder-brief-sidebar/src/composables/useFolderBrief.ts index fdba22907..258082472 100644 --- a/packages/web-app-ai-folder-brief-sidebar/src/composables/useFolderBrief.ts +++ b/packages/web-app-ai-folder-brief-sidebar/src/composables/useFolderBrief.ts @@ -169,7 +169,8 @@ export function useFolderBrief( const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - signal: AbortSignal.timeout(30_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: cfg.model, messages: [{ role: 'user', content: prompt }], diff --git a/packages/web-app-ai-folder-readme-generator/src/composables/useLLM.ts b/packages/web-app-ai-folder-readme-generator/src/composables/useLLM.ts index b1c85219f..cc08ef127 100644 --- a/packages/web-app-ai-folder-readme-generator/src/composables/useLLM.ts +++ b/packages/web-app-ai-folder-readme-generator/src/composables/useLLM.ts @@ -70,7 +70,8 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - signal: AbortSignal.timeout(60_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: cfg.model, messages, @@ -91,7 +92,8 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - signal: AbortSignal.timeout(60_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: cfg.model, messages, stream: true, max_tokens: 1024 }) }) if (!r.ok) throw new Error(`LLM stream failed: ${r.status}`) diff --git a/packages/web-app-ai-image-alt-text-sidebar/src/composables/useAltText.ts b/packages/web-app-ai-image-alt-text-sidebar/src/composables/useAltText.ts index fbf554c39..73cc200f6 100644 --- a/packages/web-app-ai-image-alt-text-sidebar/src/composables/useAltText.ts +++ b/packages/web-app-ai-image-alt-text-sidebar/src/composables/useAltText.ts @@ -193,7 +193,8 @@ export function useAltText( const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(cfg.endpoint), - signal: AbortSignal.timeout(30_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: cfg.model, messages: [ diff --git a/packages/web-app-ai-multi-doc-synthesizer/src/composables/useLLM.ts b/packages/web-app-ai-multi-doc-synthesizer/src/composables/useLLM.ts index b7f5992ce..fa5a12097 100644 --- a/packages/web-app-ai-multi-doc-synthesizer/src/composables/useLLM.ts +++ b/packages/web-app-ai-multi-doc-synthesizer/src/composables/useLLM.ts @@ -91,7 +91,8 @@ export function useLLM(cfg: LLMConfig): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - signal: AbortSignal.timeout(60_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: cfg.model, messages, diff --git a/packages/web-app-ai-quick-draft-creator/src/composables/useLLM.ts b/packages/web-app-ai-quick-draft-creator/src/composables/useLLM.ts index e73fecc43..648586962 100644 --- a/packages/web-app-ai-quick-draft-creator/src/composables/useLLM.ts +++ b/packages/web-app-ai-quick-draft-creator/src/composables/useLLM.ts @@ -59,17 +59,28 @@ export function useLLM(cfg: LLMConfig): UseLLMReturn { ) } - const r = await fetch(`${base}/chat/completions`, { - method: 'POST', - headers: buildHeaders(), - signal: AbortSignal.timeout(60_000), - body: JSON.stringify({ - model: cfg.model, - messages, - max_tokens: opts.maxTokens ?? 2048, - temperature: opts.temperature ?? 0.7 + let r: Response + try { + r = await fetch(`${base}/chat/completions`, { + method: 'POST', + headers: buildHeaders(), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), + body: JSON.stringify({ + model: cfg.model, + messages, + max_tokens: opts.maxTokens ?? 2048, + temperature: opts.temperature ?? 0.7 + }) }) - }) + } catch (err) { + // A timeout abort rejects the fetch itself, before there's any Response to + // check .ok on — so this has to be caught here rather than below. + if (err instanceof DOMException && err.name === 'TimeoutError') { + throw new Error($gettext('The AI service did not respond in time. Please try again later.')) + } + throw err + } if (!r.ok) { const status = r.status diff --git a/packages/web-app-ai-sensitive-data-scanner/src/composables/useLlm.ts b/packages/web-app-ai-sensitive-data-scanner/src/composables/useLlm.ts index 5e0fbd8ca..02f53c4de 100644 --- a/packages/web-app-ai-sensitive-data-scanner/src/composables/useLlm.ts +++ b/packages/web-app-ai-sensitive-data-scanner/src/composables/useLlm.ts @@ -34,7 +34,8 @@ export function useLlm(initialConfig: LlmConfig | null) { const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - signal: AbortSignal.timeout(30_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: cfg.model, messages, diff --git a/packages/web-app-ai-sensitive-data-scanner/src/composables/useScanner.ts b/packages/web-app-ai-sensitive-data-scanner/src/composables/useScanner.ts index 414d13573..3e673436a 100644 --- a/packages/web-app-ai-sensitive-data-scanner/src/composables/useScanner.ts +++ b/packages/web-app-ai-sensitive-data-scanner/src/composables/useScanner.ts @@ -120,6 +120,13 @@ export function useScanner(llmConfig: LlmConfig | null, resources: Ref const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - signal: AbortSignal.timeout(30_000), + // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. + signal: AbortSignal.timeout(90_000), body: JSON.stringify({ model: config.model, messages: [{ role: 'user', content: prompt }], From 3fe12ae8d7ac1830741862164af05a707fcc2a03 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Mon, 27 Jul 2026 14:40:21 +0200 Subject: [PATCH 3/3] fix(web-app-ai-*): raise client LLM timeout to a generous safety-net ceiling The proxy's upstream LLM timeout will become configurable via LLM_TIMEOUT_MS in a separate PR (#530), with a default of 60s but no fixed upper bound. A hardcoded 90s client-side timeout could again fire before a larger admin-configured proxy timeout, recreating the mid-flight cancellation issue this branch fixes. Raise every client composable's AbortSignal.timeout from 90_000 to 300_000 (5 minutes) so it acts purely as a generous outer bound against a hung network or dead proxy process, rather than an attempt to closely track the proxy's own (separately configurable) timeout. Signed-off-by: Lukas Hirt --- .../src/composables/useLLM.ts | 6 ++++-- .../src/composables/useSummary.ts | 6 ++++-- .../src/composables/useFolderBrief.ts | 6 ++++-- .../src/composables/useLLM.ts | 12 ++++++++---- .../src/composables/useAltText.ts | 6 ++++-- .../src/composables/useLLM.ts | 6 ++++-- .../src/composables/useLLM.ts | 6 ++++-- .../src/composables/useLlm.ts | 6 ++++-- .../src/composables/useLLM.ts | 6 ++++-- .../src/composables/useLLM.ts | 12 ++++++++---- .../src/composables/useChat.ts | 6 ++++-- .../src/composables/useChangelog.ts | 6 ++++-- 12 files changed, 56 insertions(+), 28 deletions(-) diff --git a/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts b/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts index 6c5387d08..b6a53372e 100644 --- a/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts +++ b/packages/web-app-ai-data-insights-sidebar/src/composables/useLLM.ts @@ -55,8 +55,10 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages, diff --git a/packages/web-app-ai-doc-summary/src/composables/useSummary.ts b/packages/web-app-ai-doc-summary/src/composables/useSummary.ts index dd4e61ca2..de35a62be 100644 --- a/packages/web-app-ai-doc-summary/src/composables/useSummary.ts +++ b/packages/web-app-ai-doc-summary/src/composables/useSummary.ts @@ -147,8 +147,10 @@ export function useSummary( const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages: [ diff --git a/packages/web-app-ai-folder-brief-sidebar/src/composables/useFolderBrief.ts b/packages/web-app-ai-folder-brief-sidebar/src/composables/useFolderBrief.ts index 258082472..4298d6601 100644 --- a/packages/web-app-ai-folder-brief-sidebar/src/composables/useFolderBrief.ts +++ b/packages/web-app-ai-folder-brief-sidebar/src/composables/useFolderBrief.ts @@ -169,8 +169,10 @@ export function useFolderBrief( const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages: [{ role: 'user', content: prompt }], diff --git a/packages/web-app-ai-folder-readme-generator/src/composables/useLLM.ts b/packages/web-app-ai-folder-readme-generator/src/composables/useLLM.ts index cc08ef127..204abf3d0 100644 --- a/packages/web-app-ai-folder-readme-generator/src/composables/useLLM.ts +++ b/packages/web-app-ai-folder-readme-generator/src/composables/useLLM.ts @@ -70,8 +70,10 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages, @@ -92,8 +94,10 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages, stream: true, max_tokens: 1024 }) }) if (!r.ok) throw new Error(`LLM stream failed: ${r.status}`) diff --git a/packages/web-app-ai-image-alt-text-sidebar/src/composables/useAltText.ts b/packages/web-app-ai-image-alt-text-sidebar/src/composables/useAltText.ts index 73cc200f6..ad6893f5a 100644 --- a/packages/web-app-ai-image-alt-text-sidebar/src/composables/useAltText.ts +++ b/packages/web-app-ai-image-alt-text-sidebar/src/composables/useAltText.ts @@ -193,8 +193,10 @@ export function useAltText( const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(cfg.endpoint), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages: [ diff --git a/packages/web-app-ai-multi-doc-synthesizer/src/composables/useLLM.ts b/packages/web-app-ai-multi-doc-synthesizer/src/composables/useLLM.ts index fa5a12097..ae058950c 100644 --- a/packages/web-app-ai-multi-doc-synthesizer/src/composables/useLLM.ts +++ b/packages/web-app-ai-multi-doc-synthesizer/src/composables/useLLM.ts @@ -91,8 +91,10 @@ export function useLLM(cfg: LLMConfig): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages, diff --git a/packages/web-app-ai-quick-draft-creator/src/composables/useLLM.ts b/packages/web-app-ai-quick-draft-creator/src/composables/useLLM.ts index 648586962..25808c0cc 100644 --- a/packages/web-app-ai-quick-draft-creator/src/composables/useLLM.ts +++ b/packages/web-app-ai-quick-draft-creator/src/composables/useLLM.ts @@ -64,8 +64,10 @@ export function useLLM(cfg: LLMConfig): UseLLMReturn { r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages, diff --git a/packages/web-app-ai-sensitive-data-scanner/src/composables/useLlm.ts b/packages/web-app-ai-sensitive-data-scanner/src/composables/useLlm.ts index 02f53c4de..0afc60d2a 100644 --- a/packages/web-app-ai-sensitive-data-scanner/src/composables/useLlm.ts +++ b/packages/web-app-ai-sensitive-data-scanner/src/composables/useLlm.ts @@ -34,8 +34,10 @@ export function useLlm(initialConfig: LlmConfig | null) { const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages, diff --git a/packages/web-app-ai-smart-collections-nav/src/composables/useLLM.ts b/packages/web-app-ai-smart-collections-nav/src/composables/useLLM.ts index 31afec9a9..6700f30da 100644 --- a/packages/web-app-ai-smart-collections-nav/src/composables/useLLM.ts +++ b/packages/web-app-ai-smart-collections-nav/src/composables/useLLM.ts @@ -83,8 +83,10 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { temperature: opts.temperature ?? 0.7, ...(opts.responseFormat && { response_format: opts.responseFormat }) }, - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - { signal: AbortSignal.timeout(90_000) } + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + { signal: AbortSignal.timeout(300_000) } ) const d = response.data as { choices: { message: { content: string } }[] } return d.choices[0]?.message?.content ?? '' diff --git a/packages/web-app-ai-smart-file-tagger-qa/src/composables/useLLM.ts b/packages/web-app-ai-smart-file-tagger-qa/src/composables/useLLM.ts index 3df1d0d46..88467ea3b 100644 --- a/packages/web-app-ai-smart-file-tagger-qa/src/composables/useLLM.ts +++ b/packages/web-app-ai-smart-file-tagger-qa/src/composables/useLLM.ts @@ -60,8 +60,10 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages, @@ -82,8 +84,10 @@ export function useLLM(cfg: LLMConfig | null): UseLLMReturn { const r = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages, stream: true, max_tokens: 1024 }) }) if (!r.ok) throw new Error(`LLM stream failed: ${r.status}`) diff --git a/packages/web-app-chat-with-file/src/composables/useChat.ts b/packages/web-app-chat-with-file/src/composables/useChat.ts index 68bd15e5b..7e34f8c93 100644 --- a/packages/web-app-chat-with-file/src/composables/useChat.ts +++ b/packages/web-app-chat-with-file/src/composables/useChat.ts @@ -264,8 +264,10 @@ export function useChat( const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: cfg.model, messages: requestMessages, max_tokens: 4096 }) }) diff --git a/packages/web-app-version-changelog/src/composables/useChangelog.ts b/packages/web-app-version-changelog/src/composables/useChangelog.ts index 5aa19419e..8a634846f 100644 --- a/packages/web-app-version-changelog/src/composables/useChangelog.ts +++ b/packages/web-app-version-changelog/src/composables/useChangelog.ts @@ -87,8 +87,10 @@ export function useChangelog(llmConfig: LlmConfig | null | Ref const res = await fetch(`${base}/chat/completions`, { method: 'POST', headers: buildHeaders(), - // Must exceed the proxy's own upstream timeout (60s) plus margin for OIDC validation. - signal: AbortSignal.timeout(90_000), + // Generous safety-net ceiling, decoupled from the proxy's own (separately + // configurable) upstream timeout; only guards against the network or proxy + // never responding at all. + signal: AbortSignal.timeout(300_000), body: JSON.stringify({ model: config.model, messages: [{ role: 'user', content: prompt }],