From 861ba8bf604e5441f13a1355f09c6e955487d4f6 Mon Sep 17 00:00:00 2001 From: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:05:14 +0530 Subject: [PATCH] fix(ai): make provider test timeout configurable via AI_TEST_TIMEOUT_MS (#3384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ai): make provider test timeout configurable via AI_TEST_TIMEOUT_MS - Problem: the 30s hardcoded timeout is too short for self-hosted deployments with cold-start models (e.g. Ollama). Makes it impossible to pass the provider test (issue #3374). - Fix: read AI_TEST_TIMEOUT_MS from the environment, defaulting to 30_000. Zero behaviour change when the env var is absent. - Verification: existing test asserts "30 seconds" in the timeout message; default is unchanged so the test continues to pass. (CI needs Node 22+ — not available on this host.) * fix(ai): add AI_TEST_TIMEOUT_MS to turbo globalEnv so it reaches the API process - Problem: Turborepo filters env vars not listed in globalEnv, so AI_TEST_TIMEOUT_MS would always be undefined at runtime under turbo dev/start, making the override dead code. - Fix: add AI_TEST_TIMEOUT_MS to the globalEnv array. - Verification: turbo.json validates as valid JSON. * fix(ai): validate AI_TEST_TIMEOUT_MS as a finite non-negative integer * docs(ai): add JSDoc to timeout parser and test helper * test(ai): restore AI_TEST_TIMEOUT_MS after timeout tests - Problem: loadWithTimeout() mutates process.env.AI_TEST_TIMEOUT_MS but nothing restores it, so the last value tested ("999999999999") leaked to every test that runs after this describe block in the same file. - Fix: save the pre-test value and restore it in an afterEach hook. - Verification: pnpm exec vitest run src/features/ai/service.test.ts in packages/api — 18/18 passed. * test(api): isolate AI timeout environment cases --------- Co-authored-by: Amruth Pillai --- packages/api/src/features/ai/service.test.ts | 41 ++++++++++++++++++++ packages/api/src/features/ai/service.ts | 27 ++++++++++++- turbo.json | 3 +- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/api/src/features/ai/service.test.ts b/packages/api/src/features/ai/service.test.ts index ca1d2806a..811b2dc77 100644 --- a/packages/api/src/features/ai/service.test.ts +++ b/packages/api/src/features/ai/service.test.ts @@ -9,6 +9,7 @@ const envMock = vi.hoisted(() => ({ vi.mock("@reactive-resume/env/server", () => ({ env: envMock })); afterEach(() => { + vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -169,6 +170,46 @@ describe("AI provider connection test", () => { }); }); +describe("AI provider test connection timeout", () => { + /** + * Re-import the service module with `AI_TEST_TIMEOUT_MS` set to a specific value. + * + * @param value - The environment value to test, or `undefined` to unset it. + * @returns The `testConnection` function from the freshly imported module. + */ + async function loadWithTimeout(value: string | undefined) { + vi.stubEnv("AI_TEST_TIMEOUT_MS", value); + vi.resetModules(); + const mod = await import("./service"); + return mod.testConnection; + } + + it("uses a valid custom timeout", async () => { + const testConnectionWithEnv = await loadWithTimeout("5000"); + stubRejectedFetch(new DOMException("The operation was aborted due to timeout", "TimeoutError")); + + await expect(testConnectionWithEnv(testInput())).resolves.toMatchObject({ + ok: false, + message: expect.stringContaining("did not respond within 5 seconds"), + }); + }); + + it.each([ + ["negative", "-1"], + ["fractional", "30.5"], + ["non-numeric", "not-a-number"], + ["out-of-range", "999999999999"], + ])("falls back to the default for %s values", async (_label, value) => { + const testConnectionWithEnv = await loadWithTimeout(value); + stubRejectedFetch(new DOMException("The operation was aborted due to timeout", "TimeoutError")); + + await expect(testConnectionWithEnv(testInput())).resolves.toMatchObject({ + ok: false, + message: expect.stringContaining("did not respond within 30 seconds"), + }); + }); +}); + describe("AI chat service", () => { it("tests OpenAI-compatible providers without requiring structured output", async () => { const openAiCompatible = stubOpenAICompatibleResponse(); diff --git a/packages/api/src/features/ai/service.ts b/packages/api/src/features/ai/service.ts index 48d68b9cf..9f8b9da2f 100644 --- a/packages/api/src/features/ai/service.ts +++ b/packages/api/src/features/ai/service.ts @@ -90,8 +90,33 @@ type GetModelInput = { const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4; const TEST_CONNECTION_MAX_OUTPUT_TOKENS = 128; + +// AbortSignal.timeout stores the delay as a 32-bit signed integer. +const MAX_ABORT_SIGNAL_TIMEOUT_MS = 2_147_483_647; + +/** + * Parse `AI_TEST_TIMEOUT_MS` into a safe, finite, non-negative integer. + * + * Rejects empty, non-numeric, negative, fractional, and out-of-range values + * so that `AbortSignal.timeout` never receives an invalid delay. + * + * @param raw - The raw environment variable value, if set. + * @param fallback - Milliseconds to use when `raw` is missing or invalid. + * @returns The validated timeout in milliseconds. + */ +function parseTestConnectionTimeoutMs(raw: string | undefined, fallback: number): number { + if (raw === undefined) return fallback; + const trimmed = raw.trim(); + if (trimmed === "") return fallback; + if (!/^\d+$/.test(trimmed)) return fallback; + const value = Number(trimmed); + if (value < 0 || value > MAX_ABORT_SIGNAL_TIMEOUT_MS) return fallback; + return value; +} + // Long enough for a cold local model to load, short enough that the UI does not look frozen. -const TEST_CONNECTION_TIMEOUT_MS = 30_000; +// Self-hosted deployments with cold-start models (e.g. Ollama) can override via AI_TEST_TIMEOUT_MS. +const TEST_CONNECTION_TIMEOUT_MS = parseTestConnectionTimeoutMs(process.env.AI_TEST_TIMEOUT_MS, 30_000); const DOCX_DOCUMENT_XML_PATH = "word/document.xml"; const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; diff --git a/turbo.json b/turbo.json index 5483fe6d1..883f964b5 100644 --- a/turbo.json +++ b/turbo.json @@ -87,7 +87,8 @@ "FLAG_DISABLE_API_RATE_LIMIT", "FLAG_SHOW_SPONSORS", "FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI", - "FLAG_ALLOW_UNSAFE_AI_BASE_URL" + "FLAG_ALLOW_UNSAFE_AI_BASE_URL", + "AI_TEST_TIMEOUT_MS" ], "tasks": { "transit": {