fix(ai): make provider test timeout configurable via AI_TEST_TIMEOUT_MS (#3384)

* 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 <im.amruth@gmail.com>
This commit is contained in:
Santhi Prakash
2026-09-05 09:35:14 -07:00
committed by GitHub
co-authored by Amruth Pillai
parent e0c2f6d88a
commit 861ba8bf60
3 changed files with 69 additions and 2 deletions
@@ -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();
+26 -1
View File
@@ -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;
+2 -1
View File
@@ -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": {