From feffb998daf86e263c549880b73dad321275cd5d Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 16 May 2026 00:26:10 +0800 Subject: [PATCH] fix: add raw provider request timeouts Signed-off-by: AjTheSpidey --- .env.example | 1 + src/providers/fetch-timeout.ts | 34 +++++++++++++++ src/providers/minimax.ts | 3 +- src/providers/openrouter.ts | 3 +- test/provider-timeout.test.ts | 75 ++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 src/providers/fetch-timeout.ts create mode 100644 test/provider-timeout.test.ts diff --git a/.env.example b/.env.example index b6653c6a..abbe27d4 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,7 @@ # MINIMAX_MODEL=MiniMax-M2.7 # MAX_TOKENS=4096 # Cap LLM completion tokens for compression / summarise calls +# AGENTMEMORY_LLM_TIMEOUT_MS=120000 # Abort raw provider HTTP calls after this many ms # Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk # child sessions). Off by default — the agent-sdk fallback can trigger diff --git a/src/providers/fetch-timeout.ts b/src/providers/fetch-timeout.ts new file mode 100644 index 00000000..bdc27b12 --- /dev/null +++ b/src/providers/fetch-timeout.ts @@ -0,0 +1,34 @@ +import { getEnvVar } from "../config.js"; + +const DEFAULT_LLM_TIMEOUT_MS = 120_000; + +export function getLlmTimeoutMs(): number { + const raw = getEnvVar("AGENTMEMORY_LLM_TIMEOUT_MS")?.trim(); + if (!raw) return DEFAULT_LLM_TIMEOUT_MS; + if (!/^\d+$/.test(raw)) return DEFAULT_LLM_TIMEOUT_MS; + + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_LLM_TIMEOUT_MS; +} + +export async function fetchWithLlmTimeout( + providerName: string, + input: Parameters[0], + init: RequestInit, +): Promise { + const timeoutMs = getLlmTimeoutMs(); + + try { + return await fetch(input, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + throw new Error(`${providerName} request timed out after ${timeoutMs}ms`); + } + throw err; + } +} diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index e912fabc..9d5c3320 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -1,5 +1,6 @@ import type { MemoryProvider } from '../types.js' import { getEnvVar } from '../config.js' +import { fetchWithLlmTimeout } from './fetch-timeout.js' /** * MiniMax provider using raw fetch to call MiniMax's Anthropic-compatible API. @@ -40,7 +41,7 @@ export class MinimaxProvider implements MemoryProvider { private async call(systemPrompt: string, userPrompt: string): Promise { const url = `${this.baseUrl}/v1/messages` - const response = await fetch(url, { + const response = await fetchWithLlmTimeout(this.name, url, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts index 219ce421..638fc8dd 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openrouter.ts @@ -1,4 +1,5 @@ import type { MemoryProvider } from "../types.js"; +import { fetchWithLlmTimeout } from "./fetch-timeout.js"; export class OpenRouterProvider implements MemoryProvider { name: string; @@ -32,7 +33,7 @@ export class OpenRouterProvider implements MemoryProvider { systemPrompt: string, userPrompt: string, ): Promise { - const response = await fetch(this.baseUrl, { + const response = await fetchWithLlmTimeout(this.name, this.baseUrl, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/test/provider-timeout.test.ts b/test/provider-timeout.test.ts new file mode 100644 index 00000000..91508e4b --- /dev/null +++ b/test/provider-timeout.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getLlmTimeoutMs } from "../src/providers/fetch-timeout.js"; +import { MinimaxProvider } from "../src/providers/minimax.js"; +import { OpenRouterProvider } from "../src/providers/openrouter.js"; + +describe("provider HTTP timeouts", () => { + const originalTimeout = process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + + afterEach(() => { + vi.unstubAllGlobals(); + if (originalTimeout === undefined) { + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + } else { + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = originalTimeout; + } + }); + + it("defaults to a 120s LLM timeout", () => { + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + expect(getLlmTimeoutMs()).toBe(120_000); + }); + + it("uses AGENTMEMORY_LLM_TIMEOUT_MS when it is a positive integer", () => { + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + expect(getLlmTimeoutMs()).toBe(50); + }); + + it("falls back to the default for invalid timeout values", () => { + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "nope"; + expect(getLlmTimeoutMs()).toBe(120_000); + + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50ms"; + expect(getLlmTimeoutMs()).toBe(120_000); + + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "0"; + expect(getLlmTimeoutMs()).toBe(120_000); + }); + + it("passes an abort signal to MiniMax provider fetches", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + return new Response( + JSON.stringify({ content: [{ type: "text", text: "ok" }] }), + { status: 200 }, + ); + }), + ); + + const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800); + await expect(provider.summarize("system", "user")).resolves.toBe("ok"); + }); + + it("passes an abort signal to OpenRouter provider fetches", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + return new Response( + JSON.stringify({ choices: [{ message: { content: "ok" } }] }), + { status: 200 }, + ); + }), + ); + + const provider = new OpenRouterProvider( + "test-key", + "anthropic/claude-sonnet-4-20250514", + 4096, + "https://openrouter.ai/api/v1/chat/completions", + ); + await expect(provider.compress("system", "user")).resolves.toBe("ok"); + }); +});