From 92ae463d10b986514a652ffe5993d3f5ee15dcf2 Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 8 Oct 2026 17:45:02 +0100 Subject: [PATCH] feat(llm): propagate cancellation through reconstruction providers --- lib/llm/provider.js | 62 ++++++++++++++++++++++++++------------ tests/llm/provider.test.js | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/lib/llm/provider.js b/lib/llm/provider.js index d8d26e1..5ceaf7e 100644 --- a/lib/llm/provider.js +++ b/lib/llm/provider.js @@ -290,7 +290,27 @@ export function __resetChatSupportForTests() { _chatSupported = null; } -async function detectChatSupport(baseUrl, modelName) { +function isAbortError(error) { + return error?.name === "AbortError"; +} + +function createRequestAbortController(signal, timeoutMs = 300000) { + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(); + if (signal?.aborted) controller.abort(); + else signal?.addEventListener("abort", abortFromCaller, { once: true }); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + return { + signal: controller.signal, + dispose() { + clearTimeout(timeout); + signal?.removeEventListener("abort", abortFromCaller); + }, + }; +} + +async function detectChatSupport(baseUrl, modelName, signal) { if (_chatSupported !== null) return _chatSupported; try { @@ -302,6 +322,7 @@ async function detectChatSupport(baseUrl, modelName) { messages: [{ role: "user", content: "test" }], stream: false, }), + signal, }); if (res.ok) { @@ -314,7 +335,8 @@ async function detectChatSupport(baseUrl, modelName) { await res.text(); _chatSupported = false; } - } catch { + } catch (error) { + if (isAbortError(error) && signal?.aborted) throw error; _chatSupported = false; } @@ -322,7 +344,7 @@ async function detectChatSupport(baseUrl, modelName) { } class OllamaLlmProvider { - async generateReconstruction(scenario, modelName, outputSchema) { + async generateReconstruction(scenario, modelName, outputSchema, { signal } = {}) { // scenario is ALREADY a fully-built prompt text (built by analyseScenario). // Do NOT call buildPrompt() again — that would double-wrap the prompt. const prompt = scenario; @@ -345,18 +367,19 @@ class OllamaLlmProvider { // Step 1: Detect whether /api/chat exists (cache result) // ================================================================ try { - chatSupported = await detectChatSupport(baseUrl, modelName); - } catch { /* failed silently — defaults to false */ } + chatSupported = await detectChatSupport(baseUrl, modelName, signal); + } catch (error) { + if (isAbortError(error) && signal?.aborted) throw error; + // Failed silently — defaults to false. + } providerExecution.chatCapabilityDetected = chatSupported; // ================================================================ // Step 2: Try /api/chat if supported with the supplied or reconstruction schema // ================================================================ if (chatSupported) { + const requestAbort = createRequestAbortController(signal); try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 300000); // 5 min for reconstruction - apiUsed = "/api/chat"; providerExecution.chatRequestAttempted = true; const res = await fetch(`${baseUrl}/api/chat`, { @@ -368,11 +391,9 @@ class OllamaLlmProvider { stream: false, format: outputSchema ?? reconstructionJsonSchema, }), - signal: controller.signal, + signal: requestAbort.signal, }); - clearTimeout(timeout); - if (res.ok) { providerExecution.chatRequestSucceeded = true; fullResponseData = await res.json(); @@ -384,7 +405,9 @@ class OllamaLlmProvider { await res.text(); } } catch (e) { - if (!e.message.includes("abort")) { /* non-fatal */ } + if (isAbortError(e)) throw e; + } finally { + requestAbort.dispose(); } } @@ -393,10 +416,8 @@ class OllamaLlmProvider { // Use a long timeout — cold starts can take 2-4 minutes for large models. // ================================================================ if (rawResponse == null || rawResponse === "") { + const requestAbort = createRequestAbortController(signal); try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 300000); // 5 min for cold start - apiUsed = "/api/generate"; // set BEFORE the request so we know which API failed providerExecution.generateRequestAttempted = true; @@ -410,11 +431,9 @@ class OllamaLlmProvider { // No format:json — older Ollama doesn't support it on /api/generate either. // We rely on the strong prompt instruction above instead. }), - signal: controller.signal, + signal: requestAbort.signal, }); - clearTimeout(timeout); - if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`/api/generate returned ${res.status}: ${text.slice(0, 500)}`); @@ -439,7 +458,7 @@ class OllamaLlmProvider { `- Use a smaller model (e.g., llama3.1 instead of llama3.1:70b)\n` + `- Check Ollama logs: \`ollama serve\` or look at your system logs` ); - if (e?.name === "AbortError" || e instanceof TypeError) { + if (isAbortError(e) || e instanceof TypeError) { error.code = "PROVIDER_UNAVAILABLE"; } error.providerApiPath = apiUsed; @@ -447,6 +466,8 @@ class OllamaLlmProvider { throw error; } throw e; + } finally { + requestAbort.dispose(); } } @@ -525,7 +546,7 @@ class OpenAIReconstructionProvider { this.fetchImpl = fetchImpl; } - async generateReconstruction(prompt, modelName = "gpt-5.6-terra", outputSchema) { + async generateReconstruction(prompt, modelName = "gpt-5.6-terra", outputSchema, { signal } = {}) { if (!this.apiKey) throw new Error("OPENAI_API_KEY is not set"); const structuredOutputSchema = outputSchema ? createOpenAIStrictSchema(outputSchema, null) @@ -551,6 +572,7 @@ class OpenAIReconstructionProvider { format, }, }), + signal, }); if (!response.ok) { diff --git a/tests/llm/provider.test.js b/tests/llm/provider.test.js index 1e925c2..3b08311 100644 --- a/tests/llm/provider.test.js +++ b/tests/llm/provider.test.js @@ -128,6 +128,38 @@ describe("OllamaLlmProvider chat capability detection", () => { } }); + it("forwards caller cancellation to the Ollama capability probe without starting inference", async () => { + const originalBaseUrl = process.env.OLLAMA_BASE_URL; + const controller = new AbortController(); + const fetchSpy = vi.fn().mockImplementation((_, options) => new Promise((_, reject) => { + options.signal.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }, { once: true }); + })); + vi.stubGlobal("fetch", fetchSpy); + process.env.OLLAMA_BASE_URL = "http://ollama.test"; + + try { + __resetChatSupportForTests(); + const request = getProvider().generateReconstruction( + "prompt", + "configured-model", + undefined, + { signal: controller.signal }, + ); + controller.abort(); + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][1].signal).toBe(controller.signal); + } finally { + vi.unstubAllGlobals(); + if (originalBaseUrl === undefined) delete process.env.OLLAMA_BASE_URL; + else process.env.OLLAMA_BASE_URL = originalBaseUrl; + } + }); + it("reports chat-skipped generate fallback execution", async () => { const originalBaseUrl = process.env.OLLAMA_BASE_URL; const fetchSpy = vi.fn() @@ -393,6 +425,29 @@ describe("OpenAI reconstruction provider experiment seam", () => { assertStrictObjectInvariant(reparsedSchema); }); + it("forwards caller cancellation to the OpenAI response request", async () => { + const controller = new AbortController(); + const fetchSpy = vi.fn().mockImplementation((_, options) => new Promise((_, reject) => { + options.signal.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }, { once: true }); + })); + const provider = createOpenAIReconstructionProvider({ apiKey: "test-key", fetchImpl: fetchSpy }); + + const request = provider.generateReconstruction( + "focused prompt", + "gpt-5.6-terra", + focusedDeconstructJsonSchema, + { signal: controller.signal }, + ); + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect(fetchSpy.mock.calls[0][1].signal).toBe(controller.signal); + }); + it("emits one safe final-schema trace only when explicitly enabled", async () => { const previousTraceFlag = process.env.CONFIDENCE_ENGINE_EXPERIMENT_TRACE_OPENAI_SCHEMA; const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});