diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index b97caf13a..c2f8b0afd 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -112,7 +112,11 @@ export const envSchema = z.object({ // non-http(s) entries today — left unvalidated to keep that behavior. OVERPASS_URL: anyString, OVERPASS_TIMEOUT_MS: positiveNumber, - LLM_TIMEOUT_MS: positiveNumber, + // Whole milliseconds, and inside setTimeout's 32-bit range. A fractional value + // would floor to 0 and abort every model call on the next tick; anything past + // 2^31-1 makes Node clamp the delay to 1 ms and do the same. Both refuse at + // boot rather than degrading into a timeout of zero. + LLM_TIMEOUT_MS: integer(1, 2_147_483_647, 'must be a whole number of milliseconds between 1 and 2147483647'), KITINERARY_EXTRACTOR_PATH: anyString, // The OS search path. Not configuration anybody sets for TREK — it is here so // the kitinerary probe can resolve its binary to an absolute path itself diff --git a/server/tests/unit/app-config/validate.test.ts b/server/tests/unit/app-config/validate.test.ts index d009f268a..612f34631 100644 --- a/server/tests/unit/app-config/validate.test.ts +++ b/server/tests/unit/app-config/validate.test.ts @@ -34,6 +34,21 @@ describe('validateEnvAtBoot', () => { ).not.toThrow(); }); + it('refuses an LLM ceiling that would silently become a zero timeout', () => { + // Both shapes pass a plain "positive number" check and then degrade into an + // immediate abort: a fractional value floors to 0, and anything past the + // 32-bit range makes Node clamp the setTimeout delay to 1 ms. The wiki + // promises invalid values abort startup, so they have to refuse here. + expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '0.5' })).toThrow(); + expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '900000.5' })).toThrow(); + expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '2147483648' })).toThrow(); + expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '0' })).toThrow(); + expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '-1' })).toThrow(); + expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: 'abc' })).toThrow(); + expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '900000' })).not.toThrow(); + expect(() => validateEnvAtBoot({ LLM_TIMEOUT_MS: '' })).not.toThrow(); + }); + it('treats blank values as unset (defaults apply, no error)', () => { expect(() => validateEnvAtBoot({ DEMO_MODE: '', PORT: ' ', TZ: '' })).not.toThrow(); }); diff --git a/server/tests/unit/nest/llm-parse/clients.test.ts b/server/tests/unit/nest/llm-parse/clients.test.ts index 2a013b10f..00cb758cd 100644 --- a/server/tests/unit/nest/llm-parse/clients.test.ts +++ b/server/tests/unit/nest/llm-parse/clients.test.ts @@ -10,6 +10,7 @@ vi.mock('../../../../src/utils/ssrfGuard', () => ({ safeFetchLlm: safeFetchLlmMo import { OpenAiCompatibleClient } from '../../../../src/nest/llm-parse/clients/openai-compatible.client'; import { AnthropicClient } from '../../../../src/nest/llm-parse/clients/anthropic.client'; import type { LlmExtractionInput } from '../../../../src/nest/llm-parse/llm-provider.interface'; +import { readEnv } from '../../../../src/app-config'; const baseInput: LlmExtractionInput = { prompt: 'system', @@ -257,3 +258,32 @@ describe('AnthropicClient', () => { expect(blocks.some((b: any) => b.type === 'document' && b.source.type === 'base64')).toBe(true); }); }); + +/** + * The three per-client constants are gone; what replaced them is a config read. + * Nothing else in the suite would notice if one of them came back as a literal, + * so pin the seam itself: the abort deadline has to come from LLM_TIMEOUT_MS. + */ +describe('the abort deadline comes from the configured ceiling (#2230)', () => { + const setTimeoutSpy = () => vi.spyOn(globalThis, 'setTimeout'); + + beforeEach(() => vi.restoreAllMocks()); + + it('AnthropicClient arms its AbortController with the configured value', async () => { + const spy = setTimeoutSpy(); + mockFetch(() => jsonResponse({ content: [{ type: 'tool_use', name: 'emit_reservations', input: { reservations: [] } }] })); + + await new AnthropicClient().extract(baseInput); + + expect(spy).toHaveBeenCalledWith(expect.any(Function), readEnv().integrations.llmTimeoutMs); + }); + + it('OpenAiCompatibleClient arms its AbortController with the configured value', async () => { + const spy = setTimeoutSpy(); + mockFetch(() => jsonResponse({ choices: [{ message: { content: '{"reservations":[]}' } }] })); + + await new OpenAiCompatibleClient().extract({ ...baseInput, baseUrl: 'http://ollama.local:11434/v1' }); + + expect(spy).toHaveBeenCalledWith(expect.any(Function), readEnv().integrations.llmTimeoutMs); + }); +});