Skip to content
Open
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@

# OPENROUTER_API_KEY=sk-or-...
# OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Override for OpenRouter-compatible proxies

# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Override for Gemini-compatible proxies

# MINIMAX_API_KEY=...
# MINIMAX_MODEL=MiniMax-M2.7
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,9 @@ Create `~/.agentmemory/.env`:
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure
# GEMINI_API_KEY=...
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Gemini API / proxy base URL
# OPENROUTER_API_KEY=...
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: OpenRouter API / proxy base URL
# MINIMAX_API_KEY=...
# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the
# # OpenAI LLM provider (here) AND the OpenAI
Expand Down Expand Up @@ -1419,6 +1421,8 @@ Create `~/.agentmemory/.env`:
# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Also used by Gemini embeddings
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Also used by OpenRouter embeddings

# Outbound LLM / embedding timeout
# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function detectProvider(env: Record<string, string>): ProviderConfig {
provider: "gemini",
model: env["GEMINI_MODEL"] || "gemini-2.5-flash",
maxTokens,
baseURL: env["GEMINI_BASE_URL"],
};
}
if (hasRealValue(env["OPENROUTER_API_KEY"])) {
Expand Down Expand Up @@ -120,6 +121,7 @@ function detectProvider(env: Record<string, string>): ProviderConfig {
provider: "openrouter",
model,
maxTokens,
baseURL: env["OPENROUTER_BASE_URL"],
};
}

Expand Down
10 changes: 7 additions & 3 deletions src/providers/embedding/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ import { fetchWithTimeout } from "../_fetch.js";

const BATCH_LIMIT = 100;
const MODEL = "models/gemini-embedding-001";
const API_BASE = `https://generativelanguage.googleapis.com/v1beta/${MODEL}:batchEmbedContents`;
const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";

export class GeminiEmbeddingProvider implements EmbeddingProvider {
readonly name = "gemini";
readonly dimensions = 768;
private apiKey: string;
private endpoint: string;

constructor(apiKey?: string) {
constructor(apiKey?: string, baseURL?: string) {
this.apiKey = apiKey || getEnvVar("GEMINI_API_KEY") || "";
if (!this.apiKey) throw new Error("GEMINI_API_KEY is required");
const baseUrl = (baseURL || getEnvVar("GEMINI_BASE_URL") || DEFAULT_BASE_URL)
.replace(/\/+$/, "");
this.endpoint = `${baseUrl}/${MODEL}:batchEmbedContents`;
}

async embed(text: string): Promise<Float32Array> {
Expand All @@ -26,7 +30,7 @@ export class GeminiEmbeddingProvider implements EmbeddingProvider {

for (let i = 0; i < texts.length; i += BATCH_LIMIT) {
const chunk = texts.slice(i, i + BATCH_LIMIT);
const response = await fetchWithTimeout(`${API_BASE}?key=${this.apiKey}`, {
const response = await fetchWithTimeout(`${this.endpoint}?key=${this.apiKey}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down
10 changes: 7 additions & 3 deletions src/providers/embedding/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ import type { EmbeddingProvider } from "../../types.js";
import { getEnvVar } from "../../config.js";
import { fetchWithTimeout } from "../_fetch.js";

const API_URL = "https://openrouter.ai/api/v1/embeddings";
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";

export class OpenRouterEmbeddingProvider implements EmbeddingProvider {
readonly name = "openrouter";
readonly dimensions = 1536;
private apiKey: string;
private model: string;
private endpoint: string;

constructor(apiKey?: string) {
constructor(apiKey?: string, baseURL?: string) {
this.apiKey = apiKey || getEnvVar("OPENROUTER_API_KEY") || "";
if (!this.apiKey) throw new Error("OPENROUTER_API_KEY is required");
this.model =
getEnvVar("OPENROUTER_EMBEDDING_MODEL") ||
"openai/text-embedding-3-small";
const baseUrl = (baseURL || getEnvVar("OPENROUTER_BASE_URL") || DEFAULT_BASE_URL)
.replace(/\/+$/, "");
this.endpoint = `${baseUrl}/embeddings`;
}

async embed(text: string): Promise<Float32Array> {
Expand All @@ -24,7 +28,7 @@ export class OpenRouterEmbeddingProvider implements EmbeddingProvider {
}

async embedBatch(texts: string[]): Promise<Float32Array[]> {
const response = await fetchWithTimeout(API_URL, {
const response = await fetchWithTimeout(this.endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
Expand Down
73 changes: 73 additions & 0 deletions src/providers/gemini.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { MemoryProvider } from "../types.js";
import { getEnvVar } from "../config.js";
import { fetchWithTimeout } from "./_fetch.js";

const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";

export class GeminiProvider implements MemoryProvider {
name = "gemini";
private apiKey: string;
private model: string;
private maxTokens: number;
private endpoint: string;

constructor(
apiKey: string,
model: string,
maxTokens: number,
baseURL?: string,
) {
this.apiKey = apiKey;
this.model = model;
this.maxTokens = maxTokens;
const baseUrl = (baseURL || getEnvVar("GEMINI_BASE_URL") || DEFAULT_BASE_URL)
.replace(/\/+$/, "");
this.endpoint = `${baseUrl}/openai/chat/completions`;
}

async compress(systemPrompt: string, userPrompt: string): Promise<string> {
return this.call(systemPrompt, userPrompt);
}

async summarize(systemPrompt: string, userPrompt: string): Promise<string> {
return this.call(systemPrompt, userPrompt);
}

private async call(
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const response = await fetchWithTimeout(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
max_tokens: this.maxTokens,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
}),
});

if (!response.ok) {
const text = await response.text();
throw new Error(`Gemini API error (${response.status}): ${text}`);
}

const data = (await response.json()) as Record<string, unknown>;
const choices = data.choices as
| Array<{ message: { content: string } }>
| undefined;
const content = choices?.[0]?.message?.content;
if (!content) {
throw new Error(
`Gemini returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`,
);
}
return content;
}
}
7 changes: 4 additions & 3 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MinimaxProvider } from "./minimax.js";
import { NoopProvider } from "./noop.js";
import { OpenAIProvider } from "./openai.js";
import { OpenRouterProvider } from "./openrouter.js";
import { GeminiProvider } from "./gemini.js";
import { ResilientProvider } from "./resilient.js";
import { FallbackChainProvider } from "./fallback-chain.js";
import { getEnvVar } from "../config.js";
Expand Down Expand Up @@ -115,19 +116,19 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider {
"GEMINI_API_KEY (or GOOGLE_API_KEY) is required for the gemini provider",
);
}
return new OpenRouterProvider(
return new GeminiProvider(
geminiKey,
config.model,
config.maxTokens,
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
config.baseURL,
);
}
case "openrouter":
return new OpenRouterProvider(
requireEnvVar("OPENROUTER_API_KEY"),
config.model,
config.maxTokens,
"https://openrouter.ai/api/v1/chat/completions",
config.baseURL,
);
case "openai": {
const openaiKey = getEnvVar("OPENAI_API_KEY");
Expand Down
19 changes: 11 additions & 8 deletions src/providers/openrouter.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import type { MemoryProvider } from "../types.js";
import { getEnvVar } from "../config.js";
import { fetchWithTimeout } from "./_fetch.js";

const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";

export class OpenRouterProvider implements MemoryProvider {
name: string;
private apiKey: string;
private model: string;
private maxTokens: number;
private baseUrl: string;
private endpoint: string;

constructor(
apiKey: string,
model: string,
maxTokens: number,
baseUrl: string,
baseURL?: string,
) {
this.apiKey = apiKey;
this.model = model;
this.maxTokens = maxTokens;
this.baseUrl = baseUrl;
this.name = baseUrl.includes("openrouter") ? "openrouter" : "gemini";
const baseUrl = (baseURL || getEnvVar("OPENROUTER_BASE_URL") || DEFAULT_BASE_URL)
.replace(/\/+$/, "");
this.endpoint = `${baseUrl}/chat/completions`;
this.name = "openrouter";
}

async compress(systemPrompt: string, userPrompt: string): Promise<string> {
Expand All @@ -33,14 +38,12 @@ export class OpenRouterProvider implements MemoryProvider {
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const response = await fetchWithTimeout(this.baseUrl, {
const response = await fetchWithTimeout(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
...(this.baseUrl.includes("openrouter")
? { "HTTP-Referer": "https://github.com/rohitg00/agentmemory" }
: {}),
"HTTP-Referer": "https://github.com/rohitg00/agentmemory",
},
body: JSON.stringify({
model: this.model,
Expand Down
22 changes: 17 additions & 5 deletions test/fallback-model-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,23 @@ vi.mock("../src/providers/openai.js", () => ({
vi.mock("../src/providers/openrouter.js", () => ({
OpenRouterProvider: class {
name = "openrouter";
constructor(_key: string, model: string, _max: number, url?: string) {
captured.push({
provider: url?.includes("googleapis") ? "gemini" : "openrouter",
model,
});
constructor(_key: string, model: string) {
captured.push({ provider: "openrouter", model });
}
async compress() {
return "";
}
async summarize() {
return "";
}
},
}));

vi.mock("../src/providers/gemini.js", () => ({
GeminiProvider: class {
name = "gemini";
constructor(_key: string, model: string) {
captured.push({ provider: "gemini", model });
}
async compress() {
return "";
Expand Down
22 changes: 19 additions & 3 deletions test/fetch-timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { fetchWithTimeout } from "../src/providers/_fetch.js";
import { MinimaxProvider } from "../src/providers/minimax.js";
import { OpenRouterProvider } from "../src/providers/openrouter.js";
import { GeminiProvider } from "../src/providers/gemini.js";
import { OpenAIProvider } from "../src/providers/openai.js";
import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js";
import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js";
Expand Down Expand Up @@ -96,7 +97,7 @@ describe("Provider hang regression — MinimaxProvider", () => {
});
});

describe("Provider hang regression — OpenRouterProvider (covers Gemini LLM path)", () => {
describe("Provider hang regression — OpenRouterProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
Expand All @@ -111,12 +112,28 @@ describe("Provider hang regression — OpenRouterProvider (covers Gemini LLM pat
"test-key",
"gemini-2.5-flash",
1024,
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
"https://openrouter.ai/api/v1",
);
await expect(provider.compress("system", "user")).rejects.toThrow();
});
});

describe("Provider hang regression — GeminiProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
});
Comment on lines +122 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve pre-existing timeout env var in teardown.

The suite always deletes AGENTMEMORY_LLM_TIMEOUT_MS in afterEach, which can clobber a value that existed before this suite ran and cause cross-test interference.

Proposed fix
+const originalTimeoutMs = process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
+
 describe("Provider hang regression — GeminiProvider", () => {
   beforeEach(() => {
     vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
     process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50";
   });
   afterEach(() => {
     vi.restoreAllMocks();
-    delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
+    if (originalTimeoutMs === undefined) {
+      delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"];
+    } else {
+      process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = originalTimeoutMs;
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/fetch-timeout.test.ts` around lines 122 - 129, The afterEach hook
unconditionally deletes the AGENTMEMORY_LLM_TIMEOUT_MS environment variable
without checking if it existed before the test suite ran, which can cause
cross-test interference. Store the original value of AGENTMEMORY_LLM_TIMEOUT_MS
(if it exists) before the beforeEach hook sets it, then in the afterEach hook
restore the original value if it existed, or only delete it if it didn't exist
before the test suite started. This ensures the environment is left in its
original state after the test completes.


it("compress() aborts after timeout when upstream hangs", async () => {
const provider = new GeminiProvider("test-key", "gemini-2.5-flash", 1024);
await expect(provider.compress("system", "user")).rejects.toThrow();
});
});

describe("Provider hang regression — GeminiEmbeddingProvider", () => {
beforeEach(() => {
vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch);
Expand Down Expand Up @@ -343,4 +360,3 @@ describe("OpenAIProvider thinking-model fallback (#627)", () => {
expect(out).toBe("real content");
});
});

Loading