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": {