feat(llm): propagate cancellation through reconstruction providers

This commit is contained in:
2026-10-08 17:45:02 +01:00
parent 35a6303ce8
commit 92ae463d10
2 changed files with 97 additions and 20 deletions
+42 -20
View File
@@ -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) {