Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/providers/fetch-timeout.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>[0],
init: RequestInit,
): Promise<Response> {
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;
}
}
3 changes: 2 additions & 1 deletion src/providers/minimax.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -40,7 +41,7 @@ export class MinimaxProvider implements MemoryProvider {

private async call(systemPrompt: string, userPrompt: string): Promise<string> {
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',
Expand Down
3 changes: 2 additions & 1 deletion src/providers/openrouter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MemoryProvider } from "../types.js";
import { fetchWithLlmTimeout } from "./fetch-timeout.js";

export class OpenRouterProvider implements MemoryProvider {
name: string;
Expand Down Expand Up @@ -32,7 +33,7 @@ export class OpenRouterProvider implements MemoryProvider {
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const response = await fetch(this.baseUrl, {
const response = await fetchWithLlmTimeout(this.name, this.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down
75 changes: 75 additions & 0 deletions test/provider-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});