From caecd8622f09d9f2c06b02d783cdd5456d0fcef6 Mon Sep 17 00:00:00 2001 From: saem Date: Tue, 21 Apr 2026 18:00:13 -0700 Subject: [PATCH 01/28] providers: add OpenAI compatible provider Summary ======= Created OpenAI compatible model and embedding providers. Details ===== The new provider is compatible with any OpenAI API endpoint. This includes OpenRouter, which has been subsumed as part of this change. Detection has been added for: - OpenAI - LM Studio - ollama - vllm This should allow for easy local model detection and usage. --- README.md | 9 ++ src/config.ts | 44 ++++++ src/providers/embedding/index.ts | 31 +++- src/providers/embedding/openai.ts | 19 +-- src/providers/embedding/openrouter.ts | 51 ------- src/providers/index.ts | 53 +++++-- src/providers/{openrouter.ts => openai.ts} | 45 +++--- src/types.ts | 2 +- test/embedding-provider.test.ts | 45 +++--- test/local-providers.test.ts | 158 +++++++++++++++++++++ 10 files changed, 337 insertions(+), 120 deletions(-) delete mode 100644 src/providers/embedding/openrouter.ts rename src/providers/{openrouter.ts => openai.ts} (57%) create mode 100644 test/local-providers.test.ts diff --git a/README.md b/README.md index 5fcd243..ee9d2b1 100644 --- a/README.md +++ b/README.md @@ -782,6 +782,10 @@ agentmemory auto-detects from your environment. No API key needed if you have a | **Claude subscription** (default) | No config needed | Uses `@anthropic-ai/claude-agent-sdk` | | Anthropic API | `ANTHROPIC_API_KEY` | Per-token billing | | MiniMax | `MINIMAX_API_KEY` | Anthropic-compatible | +| OpenAI | `OPENAI_API_KEY` | Standard API | +| LM Studio | `LMSTUDIO_BASE_URL` | Local OpenAI-compatible | +| Ollama | `OLLAMA_BASE_URL` | Local OpenAI-compatible | +| vLLM | `VLLM_BASE_URL` | Local OpenAI-compatible | | Gemini | `GEMINI_API_KEY` | Also enables embeddings | | OpenRouter | `OPENROUTER_API_KEY` | Any model | @@ -794,10 +798,15 @@ Create `~/.agentmemory/.env`: # ANTHROPIC_API_KEY=sk-ant-... # GEMINI_API_KEY=... # OPENROUTER_API_KEY=... +# OPENAI_API_KEY=sk-... +# OLLAMA_MODEL=llama3 +# LMSTUDIO_BASE_URL=http://localhost:1234/v1/chat/completions # Embedding provider (auto-detected, or override) # EMBEDDING_PROVIDER=local # VOYAGE_API_KEY=... +# OLLAMA_EMBEDDING_MODEL=nomic-embed-text +# LMSTUDIO_EMBEDDING_BASE_URL=http://localhost:1234/v1/embeddings # Search tuning # BM25_WEIGHT=0.4 diff --git a/src/config.ts b/src/config.ts index 45e5088..310f70c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,42 @@ function detectProvider(env: Record): ProviderConfig { }; } + if (env["LMSTUDIO_BASE_URL"] || env["LMSTUDIO_MODEL"]) { + return { + provider: "lmstudio", + model: env["LMSTUDIO_MODEL"] || "local-model", + maxTokens, + baseURL: env["LMSTUDIO_BASE_URL"], + }; + } + + if (env["OLLAMA_BASE_URL"] || env["OLLAMA_MODEL"]) { + return { + provider: "ollama", + model: env["OLLAMA_MODEL"] || "llama3", + maxTokens, + baseURL: env["OLLAMA_BASE_URL"] || "http://localhost:11434/v1/chat/completions", + }; + } + + if (env["VLLM_BASE_URL"] || env["VLLM_MODEL"]) { + return { + provider: "vllm", + model: env["VLLM_MODEL"] || "local-model", + maxTokens, + baseURL: env["VLLM_BASE_URL"], + }; + } + + if (env["OPENAI_API_KEY"] || env["OPENAI_BASE_URL"]) { + return { + provider: "openai", + model: env["OPENAI_MODEL"] || "gpt-4o", + maxTokens, + baseURL: env["OPENAI_BASE_URL"] || "https://api.openai.com/v1/chat/completions", + }; + } + if (env["ANTHROPIC_API_KEY"]) { return { provider: "anthropic", @@ -152,6 +188,10 @@ export function detectEmbeddingProvider( if (source["VOYAGE_API_KEY"]) return "voyage"; if (source["COHERE_API_KEY"]) return "cohere"; if (source["OPENROUTER_API_KEY"]) return "openrouter"; + if (source["OLLAMA_EMBEDDING_BASE_URL"] || source["OLLAMA_EMBEDDING_MODEL"]) + return "ollama"; + if (source["LMSTUDIO_EMBEDDING_BASE_URL"] || source["LMSTUDIO_EMBEDDING_MODEL"]) + return "lmstudio"; return null; } @@ -254,6 +294,10 @@ const VALID_PROVIDERS = new Set([ "openrouter", "agent-sdk", "minimax", + "lmstudio", + "openai", + "ollama", + "vllm", ]); export function loadFallbackConfig(): FallbackConfig { diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index 4580232..06417e6 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -1,10 +1,20 @@ import type { EmbeddingProvider } from "../../types.js"; import { detectEmbeddingProvider, getEnvVar } from "../../config.js"; + +function requireEnvVar(key: string): string { + const value = getEnvVar(key); + if (!value) { + throw new Error( + `Missing required environment variable: ${key}. Set it in ~/.agentmemory/.env or as an environment variable.`, + ); + } + return value; +} + import { GeminiEmbeddingProvider } from "./gemini.js"; import { OpenAIEmbeddingProvider } from "./openai.js"; import { VoyageEmbeddingProvider } from "./voyage.js"; import { CohereEmbeddingProvider } from "./cohere.js"; -import { OpenRouterEmbeddingProvider } from "./openrouter.js"; import { LocalEmbeddingProvider } from "./local.js"; import { ClipEmbeddingProvider } from "./clip.js"; @@ -13,7 +23,6 @@ export { OpenAIEmbeddingProvider, VoyageEmbeddingProvider, CohereEmbeddingProvider, - OpenRouterEmbeddingProvider, LocalEmbeddingProvider, ClipEmbeddingProvider, }; @@ -35,13 +44,27 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { case "gemini": return new GeminiEmbeddingProvider(getEnvVar("GEMINI_API_KEY")!); case "openai": - return new OpenAIEmbeddingProvider(getEnvVar("OPENAI_API_KEY")!); + return new OpenAIEmbeddingProvider( + getEnvVar("OPENAI_API_KEY"), + getEnvVar("OPENAI_EMBEDDING_BASE_URL"), + getEnvVar("OPENAI_EMBEDDING_MODEL"), + ); + case "ollama": + return new OpenAIEmbeddingProvider( + "no-key-required", + getEnvVar("OLLAMA_EMBEDDING_BASE_URL") || "http://localhost:11434/v1/embeddings", + getEnvVar("OLLAMA_EMBEDDING_MODEL") || "llama3", + ); case "voyage": return new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!); case "cohere": return new CohereEmbeddingProvider(getEnvVar("COHERE_API_KEY")!); case "openrouter": - return new OpenRouterEmbeddingProvider(getEnvVar("OPENROUTER_API_KEY")!); + return new OpenAIEmbeddingProvider( + requireEnvVar("OPENROUTER_API_KEY"), + "https://openrouter.ai/api/v1/embeddings", + getEnvVar("OPENROUTER_EMBEDDING_MODEL") || "openai/text-embedding-3-small", + ); case "local": return new LocalEmbeddingProvider(); default: diff --git a/src/providers/embedding/openai.ts b/src/providers/embedding/openai.ts index c868d4a..7a5bcb8 100644 --- a/src/providers/embedding/openai.ts +++ b/src/providers/embedding/openai.ts @@ -1,16 +1,17 @@ import type { EmbeddingProvider } from "../../types.js"; import { getEnvVar } from "../../config.js"; -const API_URL = "https://api.openai.com/v1/embeddings"; - export class OpenAIEmbeddingProvider implements EmbeddingProvider { readonly name = "openai"; readonly dimensions = 1536; private apiKey: string; + private baseUrl: string; + private model: string; - constructor(apiKey?: string) { - this.apiKey = apiKey || getEnvVar("OPENAI_API_KEY") || ""; - if (!this.apiKey) throw new Error("OPENAI_API_KEY is required"); + constructor(apiKey?: string, baseUrl?: string, model?: string) { + this.apiKey = apiKey || getEnvVar("OPENAI_API_KEY") || "no-key-required"; + this.baseUrl = baseUrl || "https://api.openai.com/v1/embeddings"; + this.model = model || "text-embedding-3-small"; } async embed(text: string): Promise { @@ -19,14 +20,16 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider { } async embedBatch(texts: string[]): Promise { - const response = await fetch(API_URL, { + const response = await fetch(this.baseUrl, { method: "POST", headers: { - Authorization: `Bearer ${this.apiKey}`, + ...(this.apiKey && this.apiKey !== "no-key-required" + ? { Authorization: `Bearer ${this.apiKey}` } + : {}), "Content-Type": "application/json", }, body: JSON.stringify({ - model: "text-embedding-3-small", + model: this.model, input: texts, }), }); diff --git a/src/providers/embedding/openrouter.ts b/src/providers/embedding/openrouter.ts deleted file mode 100644 index 7d4acb2..0000000 --- a/src/providers/embedding/openrouter.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { EmbeddingProvider } from "../../types.js"; -import { getEnvVar } from "../../config.js"; - -const API_URL = "https://openrouter.ai/api/v1/embeddings"; - -export class OpenRouterEmbeddingProvider implements EmbeddingProvider { - readonly name = "openrouter"; - readonly dimensions = 1536; - private apiKey: string; - private model: string; - - constructor(apiKey?: 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"; - } - - async embed(text: string): Promise { - const [result] = await this.embedBatch([text]); - return result; - } - - async embedBatch(texts: string[]): Promise { - const response = await fetch(API_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${this.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: this.model, - input: texts, - }), - }); - - if (!response.ok) { - const err = await response.text(); - throw new Error( - `OpenRouter embedding failed (${response.status}): ${err}`, - ); - } - - const data = (await response.json()) as { - data: Array<{ embedding: number[] }>; - }; - - return data.data.map((d) => new Float32Array(d.embedding)); - } -} diff --git a/src/providers/index.ts b/src/providers/index.ts index c9e09b4..7cabda4 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -6,7 +6,7 @@ import type { import { AgentSDKProvider } from "./agent-sdk.js"; import { AnthropicProvider } from "./anthropic.js"; import { MinimaxProvider } from "./minimax.js"; -import { OpenRouterProvider } from "./openrouter.js"; +import { OpenAIProvider } from "./openai.js"; import { ResilientProvider } from "./resilient.js"; import { FallbackChainProvider } from "./fallback-chain.js"; import { getEnvVar } from "../config.js"; @@ -58,6 +58,47 @@ export function createFallbackProvider( function createBaseProvider(config: ProviderConfig): MemoryProvider { switch (config.provider) { + case "openai": + return new OpenAIProvider( + "openai", + getEnvVar("OPENAI_API_KEY") || "no-key-required", + config.model, + config.maxTokens, + config.baseURL || "https://api.openai.com/v1/chat/completions", + ); + case "ollama": + return new OpenAIProvider( + "ollama", + "no-key-required", + config.model, + config.maxTokens, + config.baseURL || "http://localhost:11434/v1/chat/completions", + ); + case "vllm": + return new OpenAIProvider( + "vllm", + "no-key-required", + config.model, + config.maxTokens, + config.baseURL!, + ); + case "lmstudio": + return new OpenAIProvider( + "lmstudio", + "no-key-required", + config.model, + config.maxTokens, + config.baseURL || "http://localhost:1234/v1/chat/completions", + ); + case "openrouter": + return new OpenAIProvider( + "openrouter", + requireEnvVar("OPENROUTER_API_KEY"), + config.model, + config.maxTokens, + "https://openrouter.ai/api/v1/chat/completions", + { "HTTP-Referer": "https://github.com/rohitg00/agentmemory" }, + ); case "minimax": return new MinimaxProvider( requireEnvVar("MINIMAX_API_KEY"), @@ -79,20 +120,14 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { "GEMINI_API_KEY (or GOOGLE_API_KEY) is required for the gemini provider", ); } - return new OpenRouterProvider( + return new OpenAIProvider( + "gemini", geminiKey, config.model, config.maxTokens, "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", ); } - case "openrouter": - return new OpenRouterProvider( - requireEnvVar("OPENROUTER_API_KEY"), - config.model, - config.maxTokens, - "https://openrouter.ai/api/v1/chat/completions", - ); case "agent-sdk": default: return new AgentSDKProvider(); diff --git a/src/providers/openrouter.ts b/src/providers/openai.ts similarity index 57% rename from src/providers/openrouter.ts rename to src/providers/openai.ts index 219ce42..1c48268 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openai.ts @@ -1,24 +1,18 @@ import type { MemoryProvider } from "../types.js"; -export class OpenRouterProvider implements MemoryProvider { - name: string; - private apiKey: string; - private model: string; - private maxTokens: number; - private baseUrl: string; - +/** + * Generic OpenAI-compatible provider. + * Works with OpenAI, LM Studio, Ollama, vLLM, Groq, OpenRouter, etc. + */ +export class OpenAIProvider implements MemoryProvider { constructor( - apiKey: string, - model: string, - maxTokens: number, - baseUrl: string, - ) { - this.apiKey = apiKey; - this.model = model; - this.maxTokens = maxTokens; - this.baseUrl = baseUrl; - this.name = baseUrl.includes("openrouter") ? "openrouter" : "gemini"; - } + public name: string, + private apiKey: string, + private model: string, + private maxTokens: number, + private baseUrl: string, + private extraHeaders: Record = {}, + ) {} async compress(systemPrompt: string, userPrompt: string): Promise { return this.call(systemPrompt, userPrompt); @@ -36,10 +30,10 @@ export class OpenRouterProvider implements MemoryProvider { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - ...(this.baseUrl.includes("openrouter") - ? { "HTTP-Referer": "https://github.com/rohitg00/agentmemory" } + ...(this.apiKey && this.apiKey !== "no-key-required" + ? { Authorization: `Bearer ${this.apiKey}` } : {}), + ...this.extraHeaders, }, body: JSON.stringify({ model: this.model, @@ -56,11 +50,10 @@ export class OpenRouterProvider implements MemoryProvider { throw new Error(`${this.name} API error (${response.status}): ${text}`); } - const data = (await response.json()) as Record; - const choices = data.choices as - | Array<{ message: { content: string } }> - | undefined; - const content = choices?.[0]?.message?.content; + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content; if (!content) { throw new Error( `${this.name} returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, diff --git a/src/types.ts b/src/types.ts index 5a450b9..97829c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -127,7 +127,7 @@ export interface ProviderConfig { baseURL?: string; } -export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax"; +export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax" | "lmstudio" | "openai" | "ollama" | "vllm"; export interface MemoryProvider { name: string; diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index 05d01bd..139b0b0 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -2,47 +2,50 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createEmbeddingProvider } from "../src/providers/embedding/index.js"; import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js"; import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; +import * as config from "../src/config.js"; + +vi.mock("../src/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + detectEmbeddingProvider: vi.fn(), + getEnvVar: vi.fn(), + }; +}); describe("createEmbeddingProvider", () => { - const originalEnv = { ...process.env }; + const mockDetect = config.detectEmbeddingProvider as any; + const mockGetEnvVar = config.getEnvVar as any; beforeEach(() => { - process.env = { ...originalEnv }; - delete process.env["GEMINI_API_KEY"]; - delete process.env["OPENAI_API_KEY"]; - delete process.env["VOYAGE_API_KEY"]; - delete process.env["COHERE_API_KEY"]; - delete process.env["OPENROUTER_API_KEY"]; - delete process.env["EMBEDDING_PROVIDER"]; - }); - - afterEach(() => { - process.env = originalEnv; + vi.resetAllMocks(); }); - it("returns null when no API keys are set", () => { + it("returns null when no provider is detected", () => { + mockDetect.mockReturnValue(null); const provider = createEmbeddingProvider(); expect(provider).toBeNull(); }); - it("returns GeminiEmbeddingProvider when GEMINI_API_KEY is set", () => { - process.env["GEMINI_API_KEY"] = "test-key-123"; + it("returns GeminiEmbeddingProvider when gemini is detected", () => { + mockDetect.mockReturnValue("gemini"); + mockGetEnvVar.mockReturnValue("test-key"); const provider = createEmbeddingProvider(); expect(provider).toBeInstanceOf(GeminiEmbeddingProvider); expect(provider!.name).toBe("gemini"); }); - it("returns OpenAIEmbeddingProvider when OPENAI_API_KEY is set", () => { - process.env["OPENAI_API_KEY"] = "test-key-456"; + it("returns OpenAIEmbeddingProvider when openai is detected", () => { + mockDetect.mockReturnValue("openai"); + mockGetEnvVar.mockReturnValue("test-key"); const provider = createEmbeddingProvider(); expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); expect(provider!.name).toBe("openai"); }); - it("EMBEDDING_PROVIDER override takes precedence", () => { - process.env["GEMINI_API_KEY"] = "test-key-123"; - process.env["OPENAI_API_KEY"] = "test-key-456"; - process.env["EMBEDDING_PROVIDER"] = "openai"; + it("uses the detected provider", () => { + mockDetect.mockReturnValue("openai"); + mockGetEnvVar.mockReturnValue("test-key"); const provider = createEmbeddingProvider(); expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); }); diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts new file mode 100644 index 0000000..487d362 --- /dev/null +++ b/test/local-providers.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { loadConfig, detectEmbeddingProvider } from "../src/config.js"; +import { createProvider } from "../src/providers/index.js"; +import { OpenAIProvider } from "../src/providers/openai.js"; +import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; +import { ResilientProvider } from "../src/providers/resilient.js"; + +describe("Local Providers", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + delete process.env["LMSTUDIO_BASE_URL"]; + delete process.env["LMSTUDIO_MODEL"]; + delete process.env["OLLAMA_BASE_URL"]; + delete process.env["OLLAMA_MODEL"]; + delete process.env["VLLM_BASE_URL"]; + delete process.env["VLLM_MODEL"]; + delete process.env["OPENAI_BASE_URL"]; + delete process.env["OPENAI_MODEL"]; + delete process.env["ANTHROPIC_API_KEY"]; + delete process.env["GEMINI_API_KEY"]; + delete process.env["GOOGLE_API_KEY"]; + delete process.env["OPENROUTER_API_KEY"]; + delete process.env["MINIMAX_API_KEY"]; + delete process.env["LMSTUDIO_EMBEDDING_BASE_URL"]; + delete process.env["OLLAMA_EMBEDDING_BASE_URL"]; + delete process.env["OLLAMA_EMBEDDING_MODEL"]; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("detects lmstudio provider when LMSTUDIO_BASE_URL is set", () => { + process.env["LMSTUDIO_BASE_URL"] = "http://localhost:1234/v1/chat/completions"; + const config = loadConfig(); + expect(config.provider.provider).toBe("lmstudio"); + expect(config.provider.baseURL).toBe("http://localhost:1234/v1/chat/completions"); + }); + + it("detects ollama provider when OLLAMA_MODEL is set", () => { + process.env["OLLAMA_MODEL"] = "llama3"; + const config = loadConfig(); + expect(config.provider.provider).toBe("ollama"); + expect(config.provider.model).toBe("llama3"); + expect(config.provider.baseURL).toBe("http://localhost:11434/v1/chat/completions"); + }); + + it("detects vllm provider when VLLM_BASE_URL is set", () => { + process.env["VLLM_BASE_URL"] = "http://vllm-server:8000/v1/chat/completions"; + const config = loadConfig(); + expect(config.provider.provider).toBe("vllm"); + expect(config.provider.baseURL).toBe("http://vllm-server:8000/v1/chat/completions"); + }); + + it("detects lmstudio embedding provider when LMSTUDIO_EMBEDDING_BASE_URL is set", () => { + process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234/v1/embeddings"; + const provider = detectEmbeddingProvider(process.env); + expect(provider).toBe("lmstudio"); + }); + + it("detects ollama embedding provider when OLLAMA_EMBEDDING_MODEL is set", () => { + process.env["OLLAMA_EMBEDDING_MODEL"] = "nomic-embed-text"; + const provider = detectEmbeddingProvider(process.env); + expect(provider).toBe("ollama"); + }); + + it("creates OpenAIProvider from config for lmstudio", () => { + const provider = createProvider({ + provider: "lmstudio", + model: "local-model", + maxTokens: 1000, + baseURL: "http://localhost:1234/v1/chat/completions", + }); + expect(provider).toBeInstanceOf(ResilientProvider); + }); +}); + +describe("OpenAIProvider implementation", () => { + it("calls fetch with correct parameters", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: "mock response" } }], + }), + }); + global.fetch = mockFetch; + + const provider = new OpenAIProvider( + "test-provider", + "test-key", + "test-model", + 100, + "http://test-url/v1/chat/completions" + ); + const result = await provider.compress("system", "user"); + + expect(result).toBe("mock response"); + expect(mockFetch).toHaveBeenCalledWith( + "http://test-url/v1/chat/completions", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer test-key", + }, + body: JSON.stringify({ + model: "test-model", + max_tokens: 100, + messages: [ + { role: "system", content: "system" }, + { role: "user", content: "user" }, + ], + }), + }) + ); + }); +}); + +describe("OpenAIEmbeddingProvider implementation (Local compatibility)", () => { + it("calls fetch with correct parameters for local models", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + }), + }); + global.fetch = mockFetch; + + const provider = new OpenAIEmbeddingProvider( + "no-key-required", + "http://local-ollama:11434/v1/embeddings", + "nomic-embed-text" + ); + const result = await provider.embed("test text"); + + expect(result).toBeInstanceOf(Float32Array); + const resultArr = Array.from(result); + expect(resultArr[0]).toBeCloseTo(0.1); + expect(resultArr[1]).toBeCloseTo(0.2); + expect(resultArr[2]).toBeCloseTo(0.3); + expect(mockFetch).toHaveBeenCalledWith( + "http://local-ollama:11434/v1/embeddings", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "nomic-embed-text", + input: ["test text"], + }), + }) + ); + }); +}); From 79b408ee2a5e871623e73b71644dfef211fc2847 Mon Sep 17 00:00:00 2001 From: saem Date: Tue, 21 Apr 2026 18:46:47 -0700 Subject: [PATCH 02/28] remove the use of mocks also consolidated embedding provider tests --- test/embedding-provider.test.ts | 63 ++++++++++++++++++++------------- test/local-providers.test.ts | 17 +-------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index 139b0b0..3df4f70 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -1,51 +1,66 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createEmbeddingProvider } from "../src/providers/embedding/index.js"; import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js"; import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; import * as config from "../src/config.js"; -vi.mock("../src/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - detectEmbeddingProvider: vi.fn(), - getEnvVar: vi.fn(), - }; -}); - describe("createEmbeddingProvider", () => { - const mockDetect = config.detectEmbeddingProvider as any; - const mockGetEnvVar = config.getEnvVar as any; + const originalEnv = { ...process.env }; beforeEach(() => { - vi.resetAllMocks(); + process.env = { ...originalEnv }; + // Set to empty string to override any local .env values for testing "not set" state + process.env["GEMINI_API_KEY"] = ""; + process.env["OPENAI_API_KEY"] = ""; + process.env["VOYAGE_API_KEY"] = ""; + process.env["COHERE_API_KEY"] = ""; + process.env["OPENROUTER_API_KEY"] = ""; + process.env["EMBEDDING_PROVIDER"] = ""; + process.env["OLLAMA_EMBEDDING_BASE_URL"] = ""; + process.env["OLLAMA_EMBEDDING_MODEL"] = ""; + process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = ""; + process.env["LMSTUDIO_EMBEDDING_MODEL"] = ""; + }); + + afterEach(() => { + process.env = originalEnv; }); - it("returns null when no provider is detected", () => { - mockDetect.mockReturnValue(null); + it("returns null when no API keys are set", () => { const provider = createEmbeddingProvider(); expect(provider).toBeNull(); }); - it("returns GeminiEmbeddingProvider when gemini is detected", () => { - mockDetect.mockReturnValue("gemini"); - mockGetEnvVar.mockReturnValue("test-key"); + it("returns GeminiEmbeddingProvider when GEMINI_API_KEY is set", () => { + process.env["GEMINI_API_KEY"] = "test-key-123"; const provider = createEmbeddingProvider(); expect(provider).toBeInstanceOf(GeminiEmbeddingProvider); expect(provider!.name).toBe("gemini"); }); - it("returns OpenAIEmbeddingProvider when openai is detected", () => { - mockDetect.mockReturnValue("openai"); - mockGetEnvVar.mockReturnValue("test-key"); + it("returns OpenAIEmbeddingProvider when OPENAI_API_KEY is set", () => { + process.env["OPENAI_API_KEY"] = "test-key-456"; const provider = createEmbeddingProvider(); expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); expect(provider!.name).toBe("openai"); }); - it("uses the detected provider", () => { - mockDetect.mockReturnValue("openai"); - mockGetEnvVar.mockReturnValue("test-key"); + it("detects lmstudio embedding provider when LMSTUDIO_EMBEDDING_BASE_URL is set", () => { + process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234/v1/embeddings"; + const provider = config.detectEmbeddingProvider(process.env); + expect(provider).toBe("lmstudio"); + }); + + it("detects ollama embedding provider when OLLAMA_EMBEDDING_MODEL is set", () => { + process.env["OLLAMA_EMBEDDING_MODEL"] = "nomic-embed-text"; + const provider = config.detectEmbeddingProvider(process.env); + expect(provider).toBe("ollama"); + }); + + it("EMBEDDING_PROVIDER override takes precedence", () => { + process.env["GEMINI_API_KEY"] = "test-key-123"; + process.env["OPENAI_API_KEY"] = "test-key-456"; + process.env["EMBEDDING_PROVIDER"] = "openai"; const provider = createEmbeddingProvider(); expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); }); diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index 487d362..93da429 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { loadConfig, detectEmbeddingProvider } from "../src/config.js"; +import { loadConfig } from "../src/config.js"; import { createProvider } from "../src/providers/index.js"; import { OpenAIProvider } from "../src/providers/openai.js"; import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; @@ -24,9 +24,6 @@ describe("Local Providers", () => { delete process.env["GOOGLE_API_KEY"]; delete process.env["OPENROUTER_API_KEY"]; delete process.env["MINIMAX_API_KEY"]; - delete process.env["LMSTUDIO_EMBEDDING_BASE_URL"]; - delete process.env["OLLAMA_EMBEDDING_BASE_URL"]; - delete process.env["OLLAMA_EMBEDDING_MODEL"]; }); afterEach(() => { @@ -55,18 +52,6 @@ describe("Local Providers", () => { expect(config.provider.baseURL).toBe("http://vllm-server:8000/v1/chat/completions"); }); - it("detects lmstudio embedding provider when LMSTUDIO_EMBEDDING_BASE_URL is set", () => { - process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234/v1/embeddings"; - const provider = detectEmbeddingProvider(process.env); - expect(provider).toBe("lmstudio"); - }); - - it("detects ollama embedding provider when OLLAMA_EMBEDDING_MODEL is set", () => { - process.env["OLLAMA_EMBEDDING_MODEL"] = "nomic-embed-text"; - const provider = detectEmbeddingProvider(process.env); - expect(provider).toBe("ollama"); - }); - it("creates OpenAIProvider from config for lmstudio", () => { const provider = createProvider({ provider: "lmstudio", From 3627620c80bbd7f4bb9cb5dd88b6f6ad72dd4c18 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 20:20:12 -0700 Subject: [PATCH 03/28] restore openrouter --- src/providers/embedding/index.ts | 6 +-- src/providers/embedding/openrouter.ts | 45 +++++++++++++++++ src/providers/index.ts | 6 +-- src/providers/openrouter.ts | 69 +++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 src/providers/embedding/openrouter.ts create mode 100644 src/providers/openrouter.ts diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index 06417e6..3d0dc8b 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -13,6 +13,7 @@ function requireEnvVar(key: string): string { import { GeminiEmbeddingProvider } from "./gemini.js"; import { OpenAIEmbeddingProvider } from "./openai.js"; +import { OpenRouterEmbeddingProvider } from "./openrouter.js"; import { VoyageEmbeddingProvider } from "./voyage.js"; import { CohereEmbeddingProvider } from "./cohere.js"; import { LocalEmbeddingProvider } from "./local.js"; @@ -60,10 +61,9 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { case "cohere": return new CohereEmbeddingProvider(getEnvVar("COHERE_API_KEY")!); case "openrouter": - return new OpenAIEmbeddingProvider( + return new OpenRouterEmbeddingProvider( requireEnvVar("OPENROUTER_API_KEY"), - "https://openrouter.ai/api/v1/embeddings", - getEnvVar("OPENROUTER_EMBEDDING_MODEL") || "openai/text-embedding-3-small", + getEnvVar("OPENROUTER_EMBEDDING_MODEL"), ); case "local": return new LocalEmbeddingProvider(); diff --git a/src/providers/embedding/openrouter.ts b/src/providers/embedding/openrouter.ts new file mode 100644 index 0000000..c7e963d --- /dev/null +++ b/src/providers/embedding/openrouter.ts @@ -0,0 +1,45 @@ +import type { EmbeddingProvider } from "../../types.js"; + +export class OpenRouterEmbeddingProvider implements EmbeddingProvider { + readonly name = "openrouter"; + readonly dimensions = 1536; + private apiKey: string; + private model: string; + + constructor(apiKey: string, model = "openai/text-embedding-3-small") { + this.apiKey = apiKey; + this.model = model; + } + + async embed(text: string): Promise { + const [result] = await this.embedBatch([text]); + return result; + } + + async embedBatch(texts: string[]): Promise { + const response = await fetch("https://openrouter.ai/api/v1/embeddings", { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/rohitg00/agentmemory", + "X-Title": "agentmemory", + }, + body: JSON.stringify({ + model: this.model, + input: texts, + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`OpenRouter embedding failed (${response.status}): ${err}`); + } + + const data = (await response.json()) as { + data: Array<{ embedding: number[] }>; + }; + + return data.data.map((d) => new Float32Array(d.embedding)); + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 7cabda4..ac91246 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -7,6 +7,7 @@ import { AgentSDKProvider } from "./agent-sdk.js"; import { AnthropicProvider } from "./anthropic.js"; import { MinimaxProvider } from "./minimax.js"; import { OpenAIProvider } from "./openai.js"; +import { OpenRouterProvider } from "./openrouter.js"; import { ResilientProvider } from "./resilient.js"; import { FallbackChainProvider } from "./fallback-chain.js"; import { getEnvVar } from "../config.js"; @@ -91,13 +92,10 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { config.baseURL || "http://localhost:1234/v1/chat/completions", ); case "openrouter": - return new OpenAIProvider( - "openrouter", + return new OpenRouterProvider( requireEnvVar("OPENROUTER_API_KEY"), config.model, config.maxTokens, - "https://openrouter.ai/api/v1/chat/completions", - { "HTTP-Referer": "https://github.com/rohitg00/agentmemory" }, ); case "minimax": return new MinimaxProvider( diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts new file mode 100644 index 0000000..43025a4 --- /dev/null +++ b/src/providers/openrouter.ts @@ -0,0 +1,69 @@ +import type { MemoryProvider } from "../types.js"; + +export class OpenRouterProvider implements MemoryProvider { + name: string; + private apiKey: string; + private model: string; + private maxTokens: number; + private baseUrl: string; + + constructor( + apiKey: string, + model: string, + maxTokens: number, + baseUrl = "https://openrouter.ai/api/v1/chat/completions", + ) { + this.name = "openrouter"; + this.apiKey = apiKey; + this.model = model; + this.maxTokens = maxTokens; + this.baseUrl = baseUrl; + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + private async call( + systemPrompt: string, + userPrompt: string, + ): Promise { + const response = await fetch(this.baseUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/rohitg00/agentmemory", + "X-Title": "agentmemory", + }, + 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(`OpenRouter API error (${response.status}): ${text}`); + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error( + `OpenRouter returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, + ); + } + return content; + } +} From bf12968e1cf2068ec65d2e1a6e95489f766fb88c Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 21:04:07 -0700 Subject: [PATCH 04/28] use actual base urls everywhere --- src/config.ts | 4 +-- src/providers/embedding/index.ts | 8 ++++- src/providers/index.ts | 6 ++-- src/providers/openai.ts | 3 +- test/embedding-provider.test.ts | 2 +- test/local-providers.test.ts | 58 ++++++++++++++++++++------------ 6 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/config.ts b/src/config.ts index 98d259d..faeff95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,7 +71,7 @@ function detectProvider(env: Record): ProviderConfig { provider: "ollama", model: env["OLLAMA_MODEL"] || "llama3", maxTokens, - baseURL: env["OLLAMA_BASE_URL"] || "http://localhost:11434/v1/chat/completions", + baseURL: env["OLLAMA_BASE_URL"] || "http://localhost:11434", }; } @@ -89,7 +89,7 @@ function detectProvider(env: Record): ProviderConfig { provider: "openai", model: env["OPENAI_MODEL"] || "gpt-4o", maxTokens, - baseURL: env["OPENAI_BASE_URL"] || "https://api.openai.com/v1/chat/completions", + baseURL: env["OPENAI_BASE_URL"] || "https://api.openai.com", }; } diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index 3d0dc8b..702fdea 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -53,9 +53,15 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { case "ollama": return new OpenAIEmbeddingProvider( "no-key-required", - getEnvVar("OLLAMA_EMBEDDING_BASE_URL") || "http://localhost:11434/v1/embeddings", + getEnvVar("OLLAMA_EMBEDDING_BASE_URL") || "http://localhost:11434", getEnvVar("OLLAMA_EMBEDDING_MODEL") || "llama3", ); + case "lmstudio": + return new OpenAIEmbeddingProvider( + "no-key-required", + getEnvVar("LMSTUDIO_EMBEDDING_BASE_URL") || "http://localhost:1234", + getEnvVar("LMSTUDIO_EMBEDDING_MODEL"), + ); case "voyage": return new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!); case "cohere": diff --git a/src/providers/index.ts b/src/providers/index.ts index f262e27..91b3893 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -66,7 +66,7 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { getEnvVar("OPENAI_API_KEY") || "no-key-required", config.model, config.maxTokens, - config.baseURL || "https://api.openai.com/v1/chat/completions", + config.baseURL || "https://api.openai.com", ); case "ollama": return new OpenAIProvider( @@ -74,7 +74,7 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { "no-key-required", config.model, config.maxTokens, - config.baseURL || "http://localhost:11434/v1/chat/completions", + config.baseURL || "http://localhost:11434", ); case "vllm": return new OpenAIProvider( @@ -90,7 +90,7 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { "no-key-required", config.model, config.maxTokens, - config.baseURL || "http://localhost:1234/v1/chat/completions", + config.baseURL || "http://localhost:1234", ); case "openrouter": return new OpenRouterProvider( diff --git a/src/providers/openai.ts b/src/providers/openai.ts index 1c48268..4f2bbb2 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -26,7 +26,8 @@ export class OpenAIProvider implements MemoryProvider { systemPrompt: string, userPrompt: string, ): Promise { - const response = await fetch(this.baseUrl, { + const url = `${this.baseUrl}/v1/chat/completions`; + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index a22e1c8..cb71ad3 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createEmbeddingProvider } from "../src/providers/embedding/index.js"; import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js"; import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index 93da429..cafc812 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { loadConfig } from "../src/config.js"; import { createProvider } from "../src/providers/index.js"; +import { createEmbeddingProvider } from "../src/providers/embedding/index.js"; import { OpenAIProvider } from "../src/providers/openai.js"; import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; import { ResilientProvider } from "../src/providers/resilient.js"; @@ -11,19 +12,20 @@ describe("Local Providers", () => { beforeEach(() => { vi.resetModules(); process.env = { ...originalEnv }; - delete process.env["LMSTUDIO_BASE_URL"]; - delete process.env["LMSTUDIO_MODEL"]; - delete process.env["OLLAMA_BASE_URL"]; - delete process.env["OLLAMA_MODEL"]; - delete process.env["VLLM_BASE_URL"]; - delete process.env["VLLM_MODEL"]; - delete process.env["OPENAI_BASE_URL"]; - delete process.env["OPENAI_MODEL"]; - delete process.env["ANTHROPIC_API_KEY"]; - delete process.env["GEMINI_API_KEY"]; - delete process.env["GOOGLE_API_KEY"]; - delete process.env["OPENROUTER_API_KEY"]; - delete process.env["MINIMAX_API_KEY"]; + process.env["LMSTUDIO_BASE_URL"] = ""; + process.env["LMSTUDIO_MODEL"] = ""; + process.env["OLLAMA_BASE_URL"] = ""; + process.env["OLLAMA_MODEL"] = ""; + process.env["VLLM_BASE_URL"] = ""; + process.env["VLLM_MODEL"] = ""; + process.env["OPENAI_BASE_URL"] = ""; + process.env["OPENAI_MODEL"] = ""; + process.env["ANTHROPIC_API_KEY"] = ""; + process.env["GEMINI_API_KEY"] = ""; + process.env["GOOGLE_API_KEY"] = ""; + process.env["OPENROUTER_API_KEY"] = ""; + process.env["MINIMAX_API_KEY"] = ""; + process.env["EMBEDDING_PROVIDER"] = ""; }); afterEach(() => { @@ -31,10 +33,10 @@ describe("Local Providers", () => { }); it("detects lmstudio provider when LMSTUDIO_BASE_URL is set", () => { - process.env["LMSTUDIO_BASE_URL"] = "http://localhost:1234/v1/chat/completions"; + process.env["LMSTUDIO_BASE_URL"] = "http://localhost:1234"; const config = loadConfig(); expect(config.provider.provider).toBe("lmstudio"); - expect(config.provider.baseURL).toBe("http://localhost:1234/v1/chat/completions"); + expect(config.provider.baseURL).toBe("http://localhost:1234"); }); it("detects ollama provider when OLLAMA_MODEL is set", () => { @@ -42,14 +44,14 @@ describe("Local Providers", () => { const config = loadConfig(); expect(config.provider.provider).toBe("ollama"); expect(config.provider.model).toBe("llama3"); - expect(config.provider.baseURL).toBe("http://localhost:11434/v1/chat/completions"); + expect(config.provider.baseURL).toBe("http://localhost:11434"); }); it("detects vllm provider when VLLM_BASE_URL is set", () => { - process.env["VLLM_BASE_URL"] = "http://vllm-server:8000/v1/chat/completions"; + process.env["VLLM_BASE_URL"] = "http://vllm-server:8000"; const config = loadConfig(); expect(config.provider.provider).toBe("vllm"); - expect(config.provider.baseURL).toBe("http://vllm-server:8000/v1/chat/completions"); + expect(config.provider.baseURL).toBe("http://vllm-server:8000"); }); it("creates OpenAIProvider from config for lmstudio", () => { @@ -57,10 +59,24 @@ describe("Local Providers", () => { provider: "lmstudio", model: "local-model", maxTokens: 1000, - baseURL: "http://localhost:1234/v1/chat/completions", + baseURL: "http://localhost:1234", }); expect(provider).toBeInstanceOf(ResilientProvider); }); + + it("creates OpenAIEmbeddingProvider for lmstudio", () => { + process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234"; + const provider = createEmbeddingProvider(); + expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); + expect(provider!.name).toBe("openai"); + }); + + it("creates OpenAIEmbeddingProvider for ollama", () => { + process.env["OLLAMA_EMBEDDING_BASE_URL"] = "http://localhost:11434"; + const provider = createEmbeddingProvider(); + expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); + expect(provider!.name).toBe("openai"); + }); }); describe("OpenAIProvider implementation", () => { @@ -78,7 +94,7 @@ describe("OpenAIProvider implementation", () => { "test-key", "test-model", 100, - "http://test-url/v1/chat/completions" + "http://test-url" ); const result = await provider.compress("system", "user"); @@ -116,7 +132,7 @@ describe("OpenAIEmbeddingProvider implementation (Local compatibility)", () => { const provider = new OpenAIEmbeddingProvider( "no-key-required", - "http://local-ollama:11434/v1/embeddings", + "http://local-ollama:11434", "nomic-embed-text" ); const result = await provider.embed("test text"); From 5a5ac74b23c746e9a40e871ed6e2b0c59e9c32a2 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 21:04:32 -0700 Subject: [PATCH 05/28] fix fs-watcher intermitent timeout --- test/fs-watcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fs-watcher.test.ts b/test/fs-watcher.test.ts index 21e260e..674b836 100644 --- a/test/fs-watcher.test.ts +++ b/test/fs-watcher.test.ts @@ -49,7 +49,7 @@ describe("FilesystemWatcher", () => { w.start(); try { writeFileSync(join(root, "notes.md"), "hello world\n"); - await wait(800); + await wait(1500); expect(captured.length).toBeGreaterThanOrEqual(1); const obs = captured[captured.length - 1]; expect(obs.url).toBe("http://localhost:3111/agentmemory/observe"); From 909f05a5a99a8b0e198df968d630b91c855b0d5e Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 21:12:07 -0700 Subject: [PATCH 06/28] restore openrouter --- src/providers/embedding/openrouter.ts | 20 +++++++++++++------- src/providers/index.ts | 17 ++++++++--------- src/providers/openrouter.ts | 24 +++++++++++++----------- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/providers/embedding/openrouter.ts b/src/providers/embedding/openrouter.ts index c7e963d..7d4acb2 100644 --- a/src/providers/embedding/openrouter.ts +++ b/src/providers/embedding/openrouter.ts @@ -1,4 +1,7 @@ import type { EmbeddingProvider } from "../../types.js"; +import { getEnvVar } from "../../config.js"; + +const API_URL = "https://openrouter.ai/api/v1/embeddings"; export class OpenRouterEmbeddingProvider implements EmbeddingProvider { readonly name = "openrouter"; @@ -6,9 +9,12 @@ export class OpenRouterEmbeddingProvider implements EmbeddingProvider { private apiKey: string; private model: string; - constructor(apiKey: string, model = "openai/text-embedding-3-small") { - this.apiKey = apiKey; - this.model = model; + constructor(apiKey?: 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"; } async embed(text: string): Promise { @@ -17,13 +23,11 @@ export class OpenRouterEmbeddingProvider implements EmbeddingProvider { } async embedBatch(texts: string[]): Promise { - const response = await fetch("https://openrouter.ai/api/v1/embeddings", { + const response = await fetch(API_URL, { method: "POST", headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", - "HTTP-Referer": "https://github.com/rohitg00/agentmemory", - "X-Title": "agentmemory", }, body: JSON.stringify({ model: this.model, @@ -33,7 +37,9 @@ export class OpenRouterEmbeddingProvider implements EmbeddingProvider { if (!response.ok) { const err = await response.text(); - throw new Error(`OpenRouter embedding failed (${response.status}): ${err}`); + throw new Error( + `OpenRouter embedding failed (${response.status}): ${err}`, + ); } const data = (await response.json()) as { diff --git a/src/providers/index.ts b/src/providers/index.ts index 91b3893..1602a9a 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -92,13 +92,6 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { config.maxTokens, config.baseURL || "http://localhost:1234", ); - case "openrouter": - return new OpenRouterProvider( - requireEnvVar("OPENROUTER_API_KEY"), - config.model, - config.maxTokens, - "https://openrouter.ai/api/v1/chat/completions", - ); case "minimax": return new MinimaxProvider( requireEnvVar("MINIMAX_API_KEY"), @@ -120,14 +113,20 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { "GEMINI_API_KEY (or GOOGLE_API_KEY) is required for the gemini provider", ); } - return new OpenAIProvider( - "gemini", + return new OpenRouterProvider( geminiKey, config.model, config.maxTokens, "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", ); } + case "openrouter": + return new OpenRouterProvider( + requireEnvVar("OPENROUTER_API_KEY"), + config.model, + config.maxTokens, + "https://openrouter.ai/api/v1/chat/completions", + ); case "noop": return new NoopProvider(); case "agent-sdk": diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts index 43025a4..219ce42 100644 --- a/src/providers/openrouter.ts +++ b/src/providers/openrouter.ts @@ -11,13 +11,13 @@ export class OpenRouterProvider implements MemoryProvider { apiKey: string, model: string, maxTokens: number, - baseUrl = "https://openrouter.ai/api/v1/chat/completions", + baseUrl: string, ) { - this.name = "openrouter"; this.apiKey = apiKey; this.model = model; this.maxTokens = maxTokens; this.baseUrl = baseUrl; + this.name = baseUrl.includes("openrouter") ? "openrouter" : "gemini"; } async compress(systemPrompt: string, userPrompt: string): Promise { @@ -35,10 +35,11 @@ export class OpenRouterProvider implements MemoryProvider { const response = await fetch(this.baseUrl, { method: "POST", headers: { - Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", - "HTTP-Referer": "https://github.com/rohitg00/agentmemory", - "X-Title": "agentmemory", + Authorization: `Bearer ${this.apiKey}`, + ...(this.baseUrl.includes("openrouter") + ? { "HTTP-Referer": "https://github.com/rohitg00/agentmemory" } + : {}), }, body: JSON.stringify({ model: this.model, @@ -52,16 +53,17 @@ export class OpenRouterProvider implements MemoryProvider { if (!response.ok) { const text = await response.text(); - throw new Error(`OpenRouter API error (${response.status}): ${text}`); + throw new Error(`${this.name} API error (${response.status}): ${text}`); } - const data = (await response.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - const content = data.choices?.[0]?.message?.content; + const data = (await response.json()) as Record; + const choices = data.choices as + | Array<{ message: { content: string } }> + | undefined; + const content = choices?.[0]?.message?.content; if (!content) { throw new Error( - `OpenRouter returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, + `${this.name} returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, ); } return content; From b631a7dde4f87a80f636bb11fc9941d6a971fe7f Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 21:12:19 -0700 Subject: [PATCH 07/28] remove .gemini directory --- .gemini/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .gemini/settings.json diff --git a/.gemini/settings.json b/.gemini/settings.json deleted file mode 100644 index 8715189..0000000 --- a/.gemini/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "general": {} -} \ No newline at end of file From ebd45af9477f75d668dc599930e3c8af96938129 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 21:19:40 -0700 Subject: [PATCH 08/28] undo further openrouter changes Also add a test because `OpenRouterEmbeddingProvider` was dropped from the export but not detected. --- src/providers/embedding/index.ts | 19 +++---------------- test/embedding-provider.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index 702fdea..b20f285 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -1,21 +1,10 @@ import type { EmbeddingProvider } from "../../types.js"; import { detectEmbeddingProvider, getEnvVar } from "../../config.js"; - -function requireEnvVar(key: string): string { - const value = getEnvVar(key); - if (!value) { - throw new Error( - `Missing required environment variable: ${key}. Set it in ~/.agentmemory/.env or as an environment variable.`, - ); - } - return value; -} - import { GeminiEmbeddingProvider } from "./gemini.js"; import { OpenAIEmbeddingProvider } from "./openai.js"; -import { OpenRouterEmbeddingProvider } from "./openrouter.js"; import { VoyageEmbeddingProvider } from "./voyage.js"; import { CohereEmbeddingProvider } from "./cohere.js"; +import { OpenRouterEmbeddingProvider } from "./openrouter.js"; import { LocalEmbeddingProvider } from "./local.js"; import { ClipEmbeddingProvider } from "./clip.js"; @@ -24,6 +13,7 @@ export { OpenAIEmbeddingProvider, VoyageEmbeddingProvider, CohereEmbeddingProvider, + OpenRouterEmbeddingProvider, LocalEmbeddingProvider, ClipEmbeddingProvider, }; @@ -67,10 +57,7 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { case "cohere": return new CohereEmbeddingProvider(getEnvVar("COHERE_API_KEY")!); case "openrouter": - return new OpenRouterEmbeddingProvider( - requireEnvVar("OPENROUTER_API_KEY"), - getEnvVar("OPENROUTER_EMBEDDING_MODEL"), - ); + return new OpenRouterEmbeddingProvider(getEnvVar("OPENROUTER_API_KEY")); case "local": return new LocalEmbeddingProvider(); default: diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index cb71ad3..c9502b1 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createEmbeddingProvider } from "../src/providers/embedding/index.js"; import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js"; import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; +import { OpenRouterEmbeddingProvider } from "../src/providers/embedding/openrouter.js"; import * as config from "../src/config.js"; describe("createEmbeddingProvider", () => { @@ -45,6 +46,13 @@ describe("createEmbeddingProvider", () => { expect(provider!.name).toBe("openai"); }); + it("returns OpenRouterEmbeddingProvider when OPENROUTER_API_KEY is set", () => { + process.env["OPENROUTER_API_KEY"] = "test-key-789"; + const provider = createEmbeddingProvider(); + expect(provider).toBeInstanceOf(OpenRouterEmbeddingProvider); + expect(provider!.name).toBe("openrouter"); + }); + it("detects lmstudio embedding provider when LMSTUDIO_EMBEDDING_BASE_URL is set", () => { process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234/v1/embeddings"; const provider = config.detectEmbeddingProvider(process.env); From 585af16bdda4b97fe52e86650fb648685773e15e Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 21:20:44 -0700 Subject: [PATCH 09/28] missed a `!` --- src/providers/embedding/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index b20f285..83b882f 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -57,7 +57,7 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { case "cohere": return new CohereEmbeddingProvider(getEnvVar("COHERE_API_KEY")!); case "openrouter": - return new OpenRouterEmbeddingProvider(getEnvVar("OPENROUTER_API_KEY")); + return new OpenRouterEmbeddingProvider(getEnvVar("OPENROUTER_API_KEY")!); case "local": return new LocalEmbeddingProvider(); default: From 2e20a85f94b858c7ddc1db5e68212c446ee51a2e Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 21:23:28 -0700 Subject: [PATCH 10/28] fix broken api key check --- src/providers/embedding/openai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/embedding/openai.ts b/src/providers/embedding/openai.ts index 760c934..7b9e5cc 100644 --- a/src/providers/embedding/openai.ts +++ b/src/providers/embedding/openai.ts @@ -52,7 +52,7 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider { constructor(apiKey?: string, baseUrl?: string, model?: string) { this.apiKey = apiKey || getEnvVar("OPENAI_API_KEY") || ""; - if (!this.apiKey && !baseUrl) throw new Error("OPENAI_API_KEY is required"); + if (!this.apiKey) throw new Error("OPENAI_API_KEY is required"); this.baseUrl = baseUrl || getEnvVar("OPENAI_BASE_URL") || DEFAULT_BASE_URL; this.model = From 13da1a1839f404eca5e4bf556a04bd234296fe76 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 21:40:51 -0700 Subject: [PATCH 11/28] missed vllm --- src/config.ts | 2 + src/providers/embedding/index.ts | 6 ++ test/local-providers.test.ts | 120 ++++++++++++++++++++----------- 3 files changed, 85 insertions(+), 43 deletions(-) diff --git a/src/config.ts b/src/config.ts index faeff95..cde3854 100644 --- a/src/config.ts +++ b/src/config.ts @@ -214,6 +214,8 @@ export function detectEmbeddingProvider( return "ollama"; if (source["LMSTUDIO_EMBEDDING_BASE_URL"] || source["LMSTUDIO_EMBEDDING_MODEL"]) return "lmstudio"; + if (source["VLLM_EMBEDDING_BASE_URL"] || source["VLLM_EMBEDDING_MODEL"]) + return "vllm"; return null; } diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index 83b882f..a9184cd 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -52,6 +52,12 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { getEnvVar("LMSTUDIO_EMBEDDING_BASE_URL") || "http://localhost:1234", getEnvVar("LMSTUDIO_EMBEDDING_MODEL"), ); + case "vllm": + return new OpenAIEmbeddingProvider( + "no-key-required", + getEnvVar("VLLM_EMBEDDING_BASE_URL"), + getEnvVar("VLLM_EMBEDDING_MODEL"), + ); case "voyage": return new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!); case "cohere": diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index cafc812..8ff27d8 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { loadConfig } from "../src/config.js"; +import { loadConfig, detectEmbeddingProvider } from "../src/config.js"; import { createProvider } from "../src/providers/index.js"; import { createEmbeddingProvider } from "../src/providers/embedding/index.js"; import { OpenAIProvider } from "../src/providers/openai.js"; import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; import { ResilientProvider } from "../src/providers/resilient.js"; -describe("Local Providers", () => { +describe("OpenAI Family Providers", () => { const originalEnv = { ...process.env }; beforeEach(() => { @@ -18,6 +18,7 @@ describe("Local Providers", () => { process.env["OLLAMA_MODEL"] = ""; process.env["VLLM_BASE_URL"] = ""; process.env["VLLM_MODEL"] = ""; + process.env["OPENAI_API_KEY"] = ""; process.env["OPENAI_BASE_URL"] = ""; process.env["OPENAI_MODEL"] = ""; process.env["ANTHROPIC_API_KEY"] = ""; @@ -26,61 +27,94 @@ describe("Local Providers", () => { process.env["OPENROUTER_API_KEY"] = ""; process.env["MINIMAX_API_KEY"] = ""; process.env["EMBEDDING_PROVIDER"] = ""; + process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = ""; + process.env["VLLM_EMBEDDING_BASE_URL"] = ""; + process.env["OLLAMA_EMBEDDING_BASE_URL"] = ""; }); afterEach(() => { process.env = originalEnv; }); - it("detects lmstudio provider when LMSTUDIO_BASE_URL is set", () => { - process.env["LMSTUDIO_BASE_URL"] = "http://localhost:1234"; - const config = loadConfig(); - expect(config.provider.provider).toBe("lmstudio"); - expect(config.provider.baseURL).toBe("http://localhost:1234"); - }); + describe("Detection", () => { + it("detects openai provider", () => { + process.env["OPENAI_API_KEY"] = "sk-test"; + const config = loadConfig(); + expect(config.provider.provider).toBe("openai"); + }); - it("detects ollama provider when OLLAMA_MODEL is set", () => { - process.env["OLLAMA_MODEL"] = "llama3"; - const config = loadConfig(); - expect(config.provider.provider).toBe("ollama"); - expect(config.provider.model).toBe("llama3"); - expect(config.provider.baseURL).toBe("http://localhost:11434"); - }); + it("detects lmstudio provider", () => { + process.env["LMSTUDIO_BASE_URL"] = "http://localhost:1234"; + const config = loadConfig(); + expect(config.provider.provider).toBe("lmstudio"); + }); - it("detects vllm provider when VLLM_BASE_URL is set", () => { - process.env["VLLM_BASE_URL"] = "http://vllm-server:8000"; - const config = loadConfig(); - expect(config.provider.provider).toBe("vllm"); - expect(config.provider.baseURL).toBe("http://vllm-server:8000"); - }); + it("detects ollama provider", () => { + process.env["OLLAMA_MODEL"] = "llama3"; + const config = loadConfig(); + expect(config.provider.provider).toBe("ollama"); + }); + + it("detects vllm provider", () => { + process.env["VLLM_BASE_URL"] = "http://vllm-server:8000"; + const config = loadConfig(); + expect(config.provider.provider).toBe("vllm"); + }); - it("creates OpenAIProvider from config for lmstudio", () => { - const provider = createProvider({ - provider: "lmstudio", - model: "local-model", - maxTokens: 1000, - baseURL: "http://localhost:1234", + it("detects vllm embedding provider", () => { + process.env["VLLM_EMBEDDING_BASE_URL"] = "http://vllm-server:8000"; + const provider = detectEmbeddingProvider(process.env); + expect(provider).toBe("vllm"); }); - expect(provider).toBeInstanceOf(ResilientProvider); }); - it("creates OpenAIEmbeddingProvider for lmstudio", () => { - process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234"; - const provider = createEmbeddingProvider(); - expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); - expect(provider!.name).toBe("openai"); + describe("Instantiation (LLM)", () => { + const providers: Array<"openai" | "lmstudio" | "ollama" | "vllm"> = [ + "openai", + "lmstudio", + "ollama", + "vllm", + ]; + + providers.forEach((p) => { + it(`creates OpenAIProvider for ${p}`, () => { + const provider = createProvider({ + provider: p, + model: "test-model", + maxTokens: 1000, + baseURL: "http://localhost:1234", + }); + expect(provider).toBeInstanceOf(ResilientProvider); + }); + }); }); - it("creates OpenAIEmbeddingProvider for ollama", () => { - process.env["OLLAMA_EMBEDDING_BASE_URL"] = "http://localhost:11434"; - const provider = createEmbeddingProvider(); - expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); - expect(provider!.name).toBe("openai"); + describe("Instantiation (Embedding)", () => { + const providers: Array<"openai" | "lmstudio" | "ollama" | "vllm"> = [ + "openai", + "lmstudio", + "ollama", + "vllm", + ]; + + providers.forEach((p) => { + it(`creates OpenAIEmbeddingProvider for ${p}`, () => { + // Set necessary env vars for detection/creation + if (p === "openai") process.env["OPENAI_API_KEY"] = "sk-test"; + else if (p === "lmstudio") process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234"; + else if (p === "ollama") process.env["OLLAMA_EMBEDDING_BASE_URL"] = "http://localhost:11434"; + else if (p === "vllm") process.env["VLLM_EMBEDDING_BASE_URL"] = "http://localhost:8000"; + + const provider = createEmbeddingProvider(); + expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); + expect(provider!.name).toBe("openai"); + }); + }); }); }); describe("OpenAIProvider implementation", () => { - it("calls fetch with correct parameters", async () => { + it("calls fetch with correct parameters and appends /v1/chat/completions", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -120,8 +154,8 @@ describe("OpenAIProvider implementation", () => { }); }); -describe("OpenAIEmbeddingProvider implementation (Local compatibility)", () => { - it("calls fetch with correct parameters for local models", async () => { +describe("OpenAIEmbeddingProvider implementation", () => { + it("calls fetch with correct parameters and appends /v1/embeddings", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -132,7 +166,7 @@ describe("OpenAIEmbeddingProvider implementation (Local compatibility)", () => { const provider = new OpenAIEmbeddingProvider( "no-key-required", - "http://local-ollama:11434", + "http://local-runner:11434", "nomic-embed-text" ); const result = await provider.embed("test text"); @@ -143,7 +177,7 @@ describe("OpenAIEmbeddingProvider implementation (Local compatibility)", () => { expect(resultArr[1]).toBeCloseTo(0.2); expect(resultArr[2]).toBeCloseTo(0.3); expect(mockFetch).toHaveBeenCalledWith( - "http://local-ollama:11434/v1/embeddings", + "http://local-runner:11434/v1/embeddings", expect.objectContaining({ method: "POST", headers: { From b5c49612bf79de8fae99da02ac72038e113a0758 Mon Sep 17 00:00:00 2001 From: Saem Ghani Date: Thu, 23 Apr 2026 22:08:54 -0700 Subject: [PATCH 12/28] don't try to use llama3 as an embedding model Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/providers/embedding/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index a9184cd..0328272 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -44,7 +44,7 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { return new OpenAIEmbeddingProvider( "no-key-required", getEnvVar("OLLAMA_EMBEDDING_BASE_URL") || "http://localhost:11434", - getEnvVar("OLLAMA_EMBEDDING_MODEL") || "llama3", + getEnvVar("OLLAMA_EMBEDDING_MODEL") || "nomic-embed-text", ); case "lmstudio": return new OpenAIEmbeddingProvider( From edf60f222fe895cc03909721e4db0bb69e5bede2 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 22:09:42 -0700 Subject: [PATCH 13/28] fix bad baseurl guidance in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b33b169..a8f39d5 100644 --- a/README.md +++ b/README.md @@ -803,7 +803,7 @@ Create `~/.agentmemory/.env`: # MINIMAX_API_KEY=... # OPENAI_API_KEY=sk-... # OLLAMA_MODEL=llama3 -# LMSTUDIO_BASE_URL=http://localhost:1234/v1/chat/completions +# LMSTUDIO_BASE_URL=http://localhost:1234 # Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); # leave OFF unless you understand the Stop-hook recursion risk (#149 follow-up): # AGENTMEMORY_ALLOW_AGENT_SDK=true @@ -816,7 +816,7 @@ Create `~/.agentmemory/.env`: # OPENAI_EMBEDDING_MODEL=text-embedding-3-small # OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table # OLLAMA_EMBEDDING_MODEL=nomic-embed-text -# LMSTUDIO_EMBEDDING_BASE_URL=http://localhost:1234/v1/embeddings +# LMSTUDIO_EMBEDDING_BASE_URL=http://localhost:1234 # Search tuning # BM25_WEIGHT=0.4 From b14480f793e5a7c3584be234f9912df8495ec7e7 Mon Sep 17 00:00:00 2001 From: Saem Ghani Date: Thu, 23 Apr 2026 22:14:38 -0700 Subject: [PATCH 14/28] require url and model parameters for lm studio and vllm Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/providers/embedding/index.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index 0328272..36302c1 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -47,17 +47,20 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { getEnvVar("OLLAMA_EMBEDDING_MODEL") || "nomic-embed-text", ); case "lmstudio": - return new OpenAIEmbeddingProvider( - "no-key-required", - getEnvVar("LMSTUDIO_EMBEDDING_BASE_URL") || "http://localhost:1234", - getEnvVar("LMSTUDIO_EMBEDDING_MODEL"), - ); + { + const base = getEnvVar("LMSTUDIO_EMBEDDING_BASE_URL") || "http://localhost:1234"; + const model = getEnvVar("LMSTUDIO_EMBEDDING_MODEL"); + if (!model) throw new Error("LMSTUDIO_EMBEDDING_MODEL is required for the lmstudio embedding provider"); + return new OpenAIEmbeddingProvider("no-key-required", base, model); + } case "vllm": - return new OpenAIEmbeddingProvider( - "no-key-required", - getEnvVar("VLLM_EMBEDDING_BASE_URL"), - getEnvVar("VLLM_EMBEDDING_MODEL"), - ); + { + const base = getEnvVar("VLLM_EMBEDDING_BASE_URL"); + const model = getEnvVar("VLLM_EMBEDDING_MODEL"); + if (!base) throw new Error("VLLM_EMBEDDING_BASE_URL is required for the vllm embedding provider"); + if (!model) throw new Error("VLLM_EMBEDDING_MODEL is required for the vllm embedding provider"); + return new OpenAIEmbeddingProvider("no-key-required", base, model); + } case "voyage": return new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!); case "cohere": From f95cd2c05086f47d53c7eab4457852f381b337ef Mon Sep 17 00:00:00 2001 From: Saem Ghani Date: Thu, 23 Apr 2026 22:19:32 -0700 Subject: [PATCH 15/28] more forgiving baseUrl handling Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/providers/openai.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/providers/openai.ts b/src/providers/openai.ts index 4f2bbb2..ebdd61e 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -26,7 +26,9 @@ export class OpenAIProvider implements MemoryProvider { systemPrompt: string, userPrompt: string, ): Promise { - const url = `${this.baseUrl}/v1/chat/completions`; + const base = this.baseUrl.replace(/\/+$/, ""); + const path = base.endsWith("/v1") ? "/chat/completions" : "/v1/chat/completions"; + const url = `${base}${path}`; const response = await fetch(url, { method: "POST", headers: { From 0e9338489407f01c7ac2a9dd0036e4a32800d94a Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 22:23:20 -0700 Subject: [PATCH 16/28] use a timeout abort signal in OpenAIProvider --- src/providers/openai.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/providers/openai.ts b/src/providers/openai.ts index ebdd61e..64455fa 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -12,6 +12,7 @@ export class OpenAIProvider implements MemoryProvider { private maxTokens: number, private baseUrl: string, private extraHeaders: Record = {}, + private timeoutMs: number = 60_000, ) {} async compress(systemPrompt: string, userPrompt: string): Promise { @@ -31,6 +32,7 @@ export class OpenAIProvider implements MemoryProvider { const url = `${base}${path}`; const response = await fetch(url, { method: "POST", + signal: AbortSignal.timeout(this.timeoutMs), headers: { "Content-Type": "application/json", ...(this.apiKey && this.apiKey !== "no-key-required" From e15c9b4550be11ff74d61d00c5535c3fa7585ac9 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 22:47:15 -0700 Subject: [PATCH 17/28] clean-up vllm in embedding providers test setup --- test/embedding-provider.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index c9502b1..5616bc7 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -21,6 +21,8 @@ describe("createEmbeddingProvider", () => { process.env["OLLAMA_EMBEDDING_MODEL"] = ""; process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = ""; process.env["LMSTUDIO_EMBEDDING_MODEL"] = ""; + process.env["VLLM_EMBEDDING_BASE_URL"] = ""; + process.env["VLLM_EMBEDDING_MODEL"] = ""; }); afterEach(() => { From 22aae21e702827d7e2aa659778de90df569dbf0e Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 22:47:42 -0700 Subject: [PATCH 18/28] provide all necessary params --- test/local-providers.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index 8ff27d8..d6b0b48 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -28,8 +28,11 @@ describe("OpenAI Family Providers", () => { process.env["MINIMAX_API_KEY"] = ""; process.env["EMBEDDING_PROVIDER"] = ""; process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = ""; + process.env["LMSTUDIO_EMBEDDING_MODEL"] = ""; process.env["VLLM_EMBEDDING_BASE_URL"] = ""; + process.env["VLLM_EMBEDDING_MODEL"] = ""; process.env["OLLAMA_EMBEDDING_BASE_URL"] = ""; + process.env["OLLAMA_EMBEDDING_MODEL"] = ""; }); afterEach(() => { @@ -100,10 +103,17 @@ describe("OpenAI Family Providers", () => { providers.forEach((p) => { it(`creates OpenAIEmbeddingProvider for ${p}`, () => { // Set necessary env vars for detection/creation - if (p === "openai") process.env["OPENAI_API_KEY"] = "sk-test"; - else if (p === "lmstudio") process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234"; - else if (p === "ollama") process.env["OLLAMA_EMBEDDING_BASE_URL"] = "http://localhost:11434"; - else if (p === "vllm") process.env["VLLM_EMBEDDING_BASE_URL"] = "http://localhost:8000"; + if (p === "openai") { + process.env["OPENAI_API_KEY"] = "sk-test"; + } else if (p === "lmstudio") { + process.env["LMSTUDIO_EMBEDDING_BASE_URL"] = "http://localhost:1234"; + process.env["LMSTUDIO_EMBEDDING_MODEL"] = "test-model"; + } else if (p === "ollama") { + process.env["OLLAMA_EMBEDDING_BASE_URL"] = "http://localhost:11434"; + } else if (p === "vllm") { + process.env["VLLM_EMBEDDING_BASE_URL"] = "http://localhost:8000"; + process.env["VLLM_EMBEDDING_MODEL"] = "test-model"; + } const provider = createEmbeddingProvider(); expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); From 1fabbaf0fbbdac1290c806c5f578631f69ac18c8 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 22:56:58 -0700 Subject: [PATCH 19/28] openai reasoning models set correct max tokens param --- src/providers/openai.ts | 24 ++++++++++++++++-------- test/local-providers.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/providers/openai.ts b/src/providers/openai.ts index 64455fa..798715d 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -30,6 +30,21 @@ export class OpenAIProvider implements MemoryProvider { const base = this.baseUrl.replace(/\/+$/, ""); const path = base.endsWith("/v1") ? "/chat/completions" : "/v1/chat/completions"; const url = `${base}${path}`; + const isReasoningModel = this.model.startsWith("o1-") || this.model.startsWith("o3-"); + const body: Record = { + model: this.model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }; + + if (isReasoningModel) { + body.max_completion_tokens = this.maxTokens; + } else { + body.max_tokens = this.maxTokens; + } + const response = await fetch(url, { method: "POST", signal: AbortSignal.timeout(this.timeoutMs), @@ -40,14 +55,7 @@ export class OpenAIProvider implements MemoryProvider { : {}), ...this.extraHeaders, }, - body: JSON.stringify({ - model: this.model, - max_tokens: this.maxTokens, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - }), + body: JSON.stringify(body), }); if (!response.ok) { diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index d6b0b48..aec9a10 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -162,6 +162,29 @@ describe("OpenAIProvider implementation", () => { }) ); }); + + it("uses max_completion_tokens for reasoning models", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: "mock reasoning response" } }], + }), + }); + global.fetch = mockFetch; + + const provider = new OpenAIProvider( + "openai", + "test-key", + "o1-mini", + 500, + "https://api.openai.com" + ); + await provider.compress("system", "user"); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string); + expect(body.max_completion_tokens).toBe(500); + expect(body.max_tokens).toBeUndefined(); + }); }); describe("OpenAIEmbeddingProvider implementation", () => { From 144aa4ad6444c77f6486174c2938f3350bc37bd1 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 22:58:44 -0700 Subject: [PATCH 20/28] set default base url for vllm provider --- src/providers/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/index.ts b/src/providers/index.ts index 1602a9a..9007371 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -82,7 +82,7 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { "no-key-required", config.model, config.maxTokens, - config.baseURL!, + config.baseURL || "http://localhost:8000", ); case "lmstudio": return new OpenAIProvider( From 7acacb90f80941d6b113dcf804755bf6d50aaf02 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 23:07:08 -0700 Subject: [PATCH 21/28] add a small wait to ensure chokidar is ready --- test/fs-watcher.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/fs-watcher.test.ts b/test/fs-watcher.test.ts index 674b836..da21d3a 100644 --- a/test/fs-watcher.test.ts +++ b/test/fs-watcher.test.ts @@ -48,6 +48,7 @@ describe("FilesystemWatcher", () => { }); w.start(); try { + await wait(100); writeFileSync(join(root, "notes.md"), "hello world\n"); await wait(1500); expect(captured.length).toBeGreaterThanOrEqual(1); From 6334687f5136f4f2a838ffc8b6e408ad015c3ca3 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 23:07:32 -0700 Subject: [PATCH 22/28] clean-up mocks --- test/local-providers.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index aec9a10..0ba209f 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -124,6 +124,10 @@ describe("OpenAI Family Providers", () => { }); describe("OpenAIProvider implementation", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("calls fetch with correct parameters and appends /v1/chat/completions", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, @@ -131,7 +135,7 @@ describe("OpenAIProvider implementation", () => { choices: [{ message: { content: "mock response" } }], }), }); - global.fetch = mockFetch; + vi.stubGlobal("fetch", mockFetch); const provider = new OpenAIProvider( "test-provider", @@ -147,17 +151,18 @@ describe("OpenAIProvider implementation", () => { "http://test-url/v1/chat/completions", expect.objectContaining({ method: "POST", + signal: expect.any(AbortSignal), headers: { "Content-Type": "application/json", "Authorization": "Bearer test-key", }, body: JSON.stringify({ model: "test-model", - max_tokens: 100, messages: [ { role: "system", content: "system" }, { role: "user", content: "user" }, ], + max_tokens: 100, }), }) ); @@ -170,7 +175,7 @@ describe("OpenAIProvider implementation", () => { choices: [{ message: { content: "mock reasoning response" } }], }), }); - global.fetch = mockFetch; + vi.stubGlobal("fetch", mockFetch); const provider = new OpenAIProvider( "openai", @@ -188,6 +193,10 @@ describe("OpenAIProvider implementation", () => { }); describe("OpenAIEmbeddingProvider implementation", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("calls fetch with correct parameters and appends /v1/embeddings", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, @@ -195,7 +204,7 @@ describe("OpenAIEmbeddingProvider implementation", () => { data: [{ embedding: [0.1, 0.2, 0.3] }], }), }); - global.fetch = mockFetch; + vi.stubGlobal("fetch", mockFetch); const provider = new OpenAIEmbeddingProvider( "no-key-required", From ac16ed5231a63921f9daaff6dd002b5841ca4dc4 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 23:12:37 -0700 Subject: [PATCH 23/28] remove unused `extraHeaders` in OpenAIProviders --- src/providers/openai.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/providers/openai.ts b/src/providers/openai.ts index 798715d..328b2d6 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -11,7 +11,6 @@ export class OpenAIProvider implements MemoryProvider { private model: string, private maxTokens: number, private baseUrl: string, - private extraHeaders: Record = {}, private timeoutMs: number = 60_000, ) {} @@ -53,7 +52,6 @@ export class OpenAIProvider implements MemoryProvider { ...(this.apiKey && this.apiKey !== "no-key-required" ? { Authorization: `Bearer ${this.apiKey}` } : {}), - ...this.extraHeaders, }, body: JSON.stringify(body), }); From 2238d124434c95992dcf2450c39987a14f93a1c2 Mon Sep 17 00:00:00 2001 From: saem Date: Thu, 23 Apr 2026 23:17:19 -0700 Subject: [PATCH 24/28] require api key to connect to openapi proper --- src/config.ts | 2 +- test/local-providers.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index cde3854..539f228 100644 --- a/src/config.ts +++ b/src/config.ts @@ -84,7 +84,7 @@ function detectProvider(env: Record): ProviderConfig { }; } - if (hasRealValue(env["OPENAI_API_KEY"]) || hasRealValue(env["OPENAI_BASE_URL"])) { + if (hasRealValue(env["OPENAI_API_KEY"])) { return { provider: "openai", model: env["OPENAI_MODEL"] || "gpt-4o", diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index 0ba209f..7c89c39 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -46,6 +46,13 @@ describe("OpenAI Family Providers", () => { expect(config.provider.provider).toBe("openai"); }); + it("does not detect openai provider with bare base URL", () => { + process.env["OPENAI_BASE_URL"] = "https://my-proxy.com"; + const config = loadConfig(); + // Should fall back to noop or next available + expect(config.provider.provider).not.toBe("openai"); + }); + it("detects lmstudio provider", () => { process.env["LMSTUDIO_BASE_URL"] = "http://localhost:1234"; const config = loadConfig(); From 3979281872fd9bb8da7381c01cda2ea76f9d7402 Mon Sep 17 00:00:00 2001 From: saem Date: Fri, 24 Apr 2026 18:54:32 -0700 Subject: [PATCH 25/28] address feedback ### **Blockers (Resolved)** 1. **Provider Detection Order:** I reordered the detection logic in `src/config.ts` so that legacy keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) are checked first. This prevents existing users from being silently switched to local providers upon upgrading. 2. **Explicit Override:** I implemented the `AGENTMEMORY_PROVIDER` environment variable, which allows users to deliberately select a provider regardless of detection priority. 3. **Removed Hardcoded `"gpt-4o"`:** The default OpenAI model is now `"openai-default"`, removing the capability assumption and requiring users to be explicit with `OPENAI_MODEL` if they want a specific OpenAI model. ### **Cleanup (Resolved)** 1. **Removed Sentinel Strings:** I eliminated the magic `"no-key-required"` string. The constructors for `OpenAIProvider` and `OpenAIEmbeddingProvider` now accept `string | null`, and the `Authorization` header is only attached if a key is present. 2. **CHANGELOG and Rebase:** I added the required notes to the `CHANGELOG.md` under `## [Unreleased]`, specifically documenting the constructor changes for `OpenAIEmbeddingProvider`. The only item **not addressed** is the consolidation of `OpenRouter` into `OpenAIProvider`. As the maintainer noted this was non-blocking and could be handled in a later release, I've left both code paths in place to minimize risk for this merge. All 852 tests, including the new detection tests, are passing. --- CHANGELOG.md | 8 ++++ src/config.ts | 76 ++++++++++++++++--------------- src/providers/embedding/index.ts | 8 ++-- src/providers/embedding/openai.ts | 21 +++++---- src/providers/index.ts | 8 ++-- src/providers/openai.ts | 16 ++++--- test/embedding-provider.test.ts | 5 +- test/local-providers.test.ts | 2 +- test/provider-detection.test.ts | 71 +++++++++++++++++++++++++++++ 9 files changed, 151 insertions(+), 64 deletions(-) create mode 100644 test/provider-detection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cedf7..0bb8be9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed + +- `OpenAIEmbeddingProvider` constructor now accepts optional `baseUrl` + `model` args; env vars remain fallback. +- `OpenAIProvider` and `OpenAIEmbeddingProvider` now support `null` API keys to better support local-only providers (Ollama, LM Studio, vLLM). +- Provider detection order updated to prioritize legacy providers (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) over new local auto-detection to prevent silent behavior flips. +- Added `AGENTMEMORY_PROVIDER` environment variable to explicitly select a provider and skip auto-detection. +- Default OpenAI model changed from `gpt-4o` to `openai-default` to avoid hardcoded pricing/capability assumptions. + ## [0.9.2] — 2026-04-22 Safety + import-pipeline patch. Kills the infinite Stop-hook recursion loop that burned Claude Pro tokens on unkeyed installs, repairs every empty viewer tab after `import-jsonl`, derives lessons and crystals automatically from imported sessions, and opens up OpenAI-compatible embedding endpoints. diff --git a/src/config.ts b/src/config.ts index 539f228..ab3ee52 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,77 +48,81 @@ function hasRealValue(v: string | undefined): v is string { function detectProvider(env: Record): ProviderConfig { const maxTokens = parseInt(env["MAX_TOKENS"] || "4096", 10); - // MiniMax: Anthropic-compatible API, requires raw fetch to avoid SDK stainless headers - if (hasRealValue(env["MINIMAX_API_KEY"])) { + const explicitProvider = env["AGENTMEMORY_PROVIDER"]; + + if (explicitProvider === "openai" || (!explicitProvider && hasRealValue(env["OPENAI_API_KEY"]))) { return { - provider: "minimax", - model: env["MINIMAX_MODEL"] || "MiniMax-M2.7", + provider: "openai", + model: env["OPENAI_MODEL"] || "openai-default", maxTokens, + baseURL: env["OPENAI_BASE_URL"] || "https://api.openai.com", }; } - if (hasRealValue(env["LMSTUDIO_BASE_URL"]) || hasRealValue(env["LMSTUDIO_MODEL"])) { + if (explicitProvider === "anthropic" || (!explicitProvider && hasRealValue(env["ANTHROPIC_API_KEY"]))) { return { - provider: "lmstudio", - model: env["LMSTUDIO_MODEL"] || "local-model", + provider: "anthropic", + model: env["ANTHROPIC_MODEL"] || "claude-sonnet-4-20250514", maxTokens, - baseURL: env["LMSTUDIO_BASE_URL"], + baseURL: env["ANTHROPIC_BASE_URL"], }; } - if (hasRealValue(env["OLLAMA_BASE_URL"]) || hasRealValue(env["OLLAMA_MODEL"])) { + if (explicitProvider === "gemini" || (!explicitProvider && (hasRealValue(env["GEMINI_API_KEY"]) || hasRealValue(env["GOOGLE_API_KEY"])))) { + if (!hasRealValue(env["GEMINI_API_KEY"]) && hasRealValue(env["GOOGLE_API_KEY"])) { + process.stderr.write( + "[agentmemory] GOOGLE_API_KEY detected — treating as GEMINI_API_KEY. " + + "Set GEMINI_API_KEY in ~/.agentmemory/.env to silence this warning.\n", + ); + } return { - provider: "ollama", - model: env["OLLAMA_MODEL"] || "llama3", + provider: "gemini", + model: env["GEMINI_MODEL"] || "gemini-2.0-flash", maxTokens, - baseURL: env["OLLAMA_BASE_URL"] || "http://localhost:11434", }; } - if (hasRealValue(env["VLLM_BASE_URL"]) || hasRealValue(env["VLLM_MODEL"])) { + if (explicitProvider === "openrouter" || (!explicitProvider && hasRealValue(env["OPENROUTER_API_KEY"]))) { return { - provider: "vllm", - model: env["VLLM_MODEL"] || "local-model", + provider: "openrouter", + model: env["OPENROUTER_MODEL"] || "anthropic/claude-sonnet-4-20250514", maxTokens, - baseURL: env["VLLM_BASE_URL"], }; } - if (hasRealValue(env["OPENAI_API_KEY"])) { + // Local/Compatible providers (moved after legacy ones to avoid flipping existing users) + if (explicitProvider === "minimax" || (!explicitProvider && hasRealValue(env["MINIMAX_API_KEY"]))) { return { - provider: "openai", - model: env["OPENAI_MODEL"] || "gpt-4o", + provider: "minimax", + model: env["MINIMAX_MODEL"] || "MiniMax-M2.7", maxTokens, - baseURL: env["OPENAI_BASE_URL"] || "https://api.openai.com", }; } - if (hasRealValue(env["ANTHROPIC_API_KEY"])) { + if (explicitProvider === "lmstudio" || (!explicitProvider && (hasRealValue(env["LMSTUDIO_BASE_URL"]) || hasRealValue(env["LMSTUDIO_MODEL"])))) { return { - provider: "anthropic", - model: env["ANTHROPIC_MODEL"] || "claude-sonnet-4-20250514", + provider: "lmstudio", + model: env["LMSTUDIO_MODEL"] || "local-model", maxTokens, - baseURL: env["ANTHROPIC_BASE_URL"], + baseURL: env["LMSTUDIO_BASE_URL"], }; } - if (hasRealValue(env["GEMINI_API_KEY"]) || hasRealValue(env["GOOGLE_API_KEY"])) { - if (!hasRealValue(env["GEMINI_API_KEY"]) && hasRealValue(env["GOOGLE_API_KEY"])) { - process.stderr.write( - "[agentmemory] GOOGLE_API_KEY detected — treating as GEMINI_API_KEY. " + - "Set GEMINI_API_KEY in ~/.agentmemory/.env to silence this warning.\n", - ); - } + + if (explicitProvider === "ollama" || (!explicitProvider && (hasRealValue(env["OLLAMA_BASE_URL"]) || hasRealValue(env["OLLAMA_MODEL"])))) { return { - provider: "gemini", - model: env["GEMINI_MODEL"] || "gemini-2.0-flash", + provider: "ollama", + model: env["OLLAMA_MODEL"] || "llama3", maxTokens, + baseURL: env["OLLAMA_BASE_URL"] || "http://localhost:11434", }; } - if (hasRealValue(env["OPENROUTER_API_KEY"])) { + + if (explicitProvider === "vllm" || (!explicitProvider && (hasRealValue(env["VLLM_BASE_URL"]) || hasRealValue(env["VLLM_MODEL"])))) { return { - provider: "openrouter", - model: env["OPENROUTER_MODEL"] || "anthropic/claude-sonnet-4-20250514", + provider: "vllm", + model: env["VLLM_MODEL"] || "local-model", maxTokens, + baseURL: env["VLLM_BASE_URL"], }; } diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts index 36302c1..3c93779 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -36,13 +36,13 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { return new GeminiEmbeddingProvider(getEnvVar("GEMINI_API_KEY")!); case "openai": return new OpenAIEmbeddingProvider( - getEnvVar("OPENAI_API_KEY"), + getEnvVar("OPENAI_API_KEY") || null, getEnvVar("OPENAI_EMBEDDING_BASE_URL"), getEnvVar("OPENAI_EMBEDDING_MODEL"), ); case "ollama": return new OpenAIEmbeddingProvider( - "no-key-required", + null, getEnvVar("OLLAMA_EMBEDDING_BASE_URL") || "http://localhost:11434", getEnvVar("OLLAMA_EMBEDDING_MODEL") || "nomic-embed-text", ); @@ -51,7 +51,7 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { const base = getEnvVar("LMSTUDIO_EMBEDDING_BASE_URL") || "http://localhost:1234"; const model = getEnvVar("LMSTUDIO_EMBEDDING_MODEL"); if (!model) throw new Error("LMSTUDIO_EMBEDDING_MODEL is required for the lmstudio embedding provider"); - return new OpenAIEmbeddingProvider("no-key-required", base, model); + return new OpenAIEmbeddingProvider(null, base, model); } case "vllm": { @@ -59,7 +59,7 @@ export function createEmbeddingProvider(): EmbeddingProvider | null { const model = getEnvVar("VLLM_EMBEDDING_MODEL"); if (!base) throw new Error("VLLM_EMBEDDING_BASE_URL is required for the vllm embedding provider"); if (!model) throw new Error("VLLM_EMBEDDING_MODEL is required for the vllm embedding provider"); - return new OpenAIEmbeddingProvider("no-key-required", base, model); + return new OpenAIEmbeddingProvider(null, base, model); } case "voyage": return new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!); diff --git a/src/providers/embedding/openai.ts b/src/providers/embedding/openai.ts index 7b9e5cc..00552d4 100644 --- a/src/providers/embedding/openai.ts +++ b/src/providers/embedding/openai.ts @@ -46,13 +46,12 @@ function resolveDimensions(model: string, override: string | undefined): number export class OpenAIEmbeddingProvider implements EmbeddingProvider { readonly name = "openai"; readonly dimensions: number; - private apiKey: string; + private apiKey: string | null; private baseUrl: string; private model: string; - constructor(apiKey?: string, baseUrl?: string, model?: string) { - this.apiKey = apiKey || getEnvVar("OPENAI_API_KEY") || ""; - if (!this.apiKey) throw new Error("OPENAI_API_KEY is required"); + constructor(apiKey?: string | null, baseUrl?: string, model?: string) { + this.apiKey = apiKey !== undefined ? apiKey : (getEnvVar("OPENAI_API_KEY") || null); this.baseUrl = baseUrl || getEnvVar("OPENAI_BASE_URL") || DEFAULT_BASE_URL; this.model = @@ -70,14 +69,16 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider { async embedBatch(texts: string[]): Promise { const url = `${this.baseUrl}/v1/embeddings`; + const headers: Record = { + "Content-Type": "application/json", + }; + if (this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}`; + } + const response = await fetch(url, { method: "POST", - headers: { - ...(this.apiKey && this.apiKey !== "no-key-required" - ? { Authorization: `Bearer ${this.apiKey}` } - : {}), - "Content-Type": "application/json", - }, + headers, body: JSON.stringify({ model: this.model, input: texts, diff --git a/src/providers/index.ts b/src/providers/index.ts index 9007371..eac71f7 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -63,7 +63,7 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { case "openai": return new OpenAIProvider( "openai", - getEnvVar("OPENAI_API_KEY") || "no-key-required", + getEnvVar("OPENAI_API_KEY") || null, config.model, config.maxTokens, config.baseURL || "https://api.openai.com", @@ -71,7 +71,7 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { case "ollama": return new OpenAIProvider( "ollama", - "no-key-required", + null, config.model, config.maxTokens, config.baseURL || "http://localhost:11434", @@ -79,7 +79,7 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { case "vllm": return new OpenAIProvider( "vllm", - "no-key-required", + null, config.model, config.maxTokens, config.baseURL || "http://localhost:8000", @@ -87,7 +87,7 @@ function createBaseProvider(config: ProviderConfig): MemoryProvider { case "lmstudio": return new OpenAIProvider( "lmstudio", - "no-key-required", + null, config.model, config.maxTokens, config.baseURL || "http://localhost:1234", diff --git a/src/providers/openai.ts b/src/providers/openai.ts index 328b2d6..df2c32a 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -7,7 +7,7 @@ import type { MemoryProvider } from "../types.js"; export class OpenAIProvider implements MemoryProvider { constructor( public name: string, - private apiKey: string, + private apiKey: string | null, private model: string, private maxTokens: number, private baseUrl: string, @@ -44,15 +44,17 @@ export class OpenAIProvider implements MemoryProvider { body.max_tokens = this.maxTokens; } + const headers: Record = { + "Content-Type": "application/json", + }; + if (this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}`; + } + const response = await fetch(url, { method: "POST", signal: AbortSignal.timeout(this.timeoutMs), - headers: { - "Content-Type": "application/json", - ...(this.apiKey && this.apiKey !== "no-key-required" - ? { Authorization: `Bearer ${this.apiKey}` } - : {}), - }, + headers, body: JSON.stringify(body), }); diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index 5616bc7..4167b85 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -96,9 +96,10 @@ describe("OpenAIEmbeddingProvider", () => { expect(provider.dimensions).toBe(1536); }); - it("throws when no API key is provided", () => { + it("does not throw when no API key is provided", () => { delete process.env["OPENAI_API_KEY"]; - expect(() => new OpenAIEmbeddingProvider()).toThrow("OPENAI_API_KEY is required"); + const provider = new OpenAIEmbeddingProvider(); + expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); }); it("respects OPENAI_BASE_URL env var", async () => { diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index 7c89c39..44587e3 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -214,7 +214,7 @@ describe("OpenAIEmbeddingProvider implementation", () => { vi.stubGlobal("fetch", mockFetch); const provider = new OpenAIEmbeddingProvider( - "no-key-required", + null, "http://local-runner:11434", "nomic-embed-text" ); diff --git a/test/provider-detection.test.ts b/test/provider-detection.test.ts new file mode 100644 index 0000000..d3c838f --- /dev/null +++ b/test/provider-detection.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { loadConfig } from "../src/config.js"; + +describe("Provider Detection Priority and Overrides", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + // Clear all relevant env vars + const vars = [ + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", + "MINIMAX_API_KEY", "LMSTUDIO_BASE_URL", "OLLAMA_MODEL", "VLLM_BASE_URL", + "AGENTMEMORY_PROVIDER", "OPENAI_MODEL" + ]; + vars.forEach(v => delete process.env[v]); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("prioritizes legacy providers over new local providers", () => { + process.env["ANTHROPIC_API_KEY"] = "sk-ant-test"; + process.env["OLLAMA_MODEL"] = "llama3"; + + const config = loadConfig(); + expect(config.provider.provider).toBe("anthropic"); + expect(config.provider.model).toBe("claude-sonnet-4-20250514"); + }); + + it("prioritizes openai over anthropic", () => { + process.env["OPENAI_API_KEY"] = "sk-openai-test"; + process.env["ANTHROPIC_API_KEY"] = "sk-ant-test"; + + const config = loadConfig(); + expect(config.provider.provider).toBe("openai"); + expect(config.provider.model).toBe("openai-default"); + }); + + it("respects AGENTMEMORY_PROVIDER override and sets correct default model", () => { + process.env["OPENAI_API_KEY"] = "sk-openai-test"; + process.env["AGENTMEMORY_PROVIDER"] = "ollama"; + + const config = loadConfig(); + expect(config.provider.provider).toBe("ollama"); + expect(config.provider.model).toBe("llama3"); + }); + + it("allows AGENTMEMORY_PROVIDER without a corresponding API key", () => { + process.env["AGENTMEMORY_PROVIDER"] = "openai"; + // No OPENAI_API_KEY set + + const config = loadConfig(); + expect(config.provider.provider).toBe("openai"); + expect(config.provider.model).toBe("openai-default"); + }); + + it("uses openai-default when OPENAI_MODEL is unset", () => { + process.env["OPENAI_API_KEY"] = "sk-openai-test"; + const config = loadConfig(); + expect(config.provider.model).toBe("openai-default"); + }); + + it("uses provided OPENAI_MODEL when set", () => { + process.env["OPENAI_API_KEY"] = "sk-openai-test"; + process.env["OPENAI_MODEL"] = "gpt-4o-mini"; + const config = loadConfig(); + expect(config.provider.model).toBe("gpt-4o-mini"); + }); +}); From ba6b298356624d74e095898972c1f57a8866f69f Mon Sep 17 00:00:00 2001 From: saem Date: Fri, 24 Apr 2026 19:10:15 -0700 Subject: [PATCH 26/28] fix the changelog --- CHANGELOG.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a437c49..e1df309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- **Support for local OpenAI-compatible providers.** Full support for Ollama, vLLM, and LM Studio as first-class providers. Added new configuration variables: + - **Ollama**: `OLLAMA_BASE_URL` (default: `http://localhost:11434`) and `OLLAMA_MODEL`. + - **LM Studio**: `LMSTUDIO_BASE_URL` and `LMSTUDIO_MODEL`. + - **vLLM**: `VLLM_BASE_URL` and `VLLM_MODEL`. + Auto-detection now supports these local endpoints while ensuring existing legacy keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) remain prioritized for stable upgrades. +- **`AGENTMEMORY_PROVIDER` environment variable.** Allows explicit selection of the LLM provider (e.g., `AGENTMEMORY_PROVIDER=ollama`), bypassing auto-detection and supporting setups with multiple keys. +- **Improved `OpenAIEmbeddingProvider` flexibility.** The constructor now accepts optional `baseUrl` and `model` overrides, enabling direct instantiation for custom OpenAI-compatible embedding proxies. + ### Changed -- `OpenAIEmbeddingProvider` constructor now accepts optional `baseUrl` + `model` args; env vars remain fallback. -- `OpenAIProvider` and `OpenAIEmbeddingProvider` now support `null` API keys to better support local-only providers (Ollama, LM Studio, vLLM). -- Provider detection order updated to prioritize legacy providers (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) over new local auto-detection to prevent silent behavior flips. -- Added `AGENTMEMORY_PROVIDER` environment variable to explicitly select a provider and skip auto-detection. -- Default OpenAI model changed from `gpt-4o` to `openai-default` to avoid hardcoded pricing/capability assumptions. +- **Provider detection order and MiniMax priority.** Detection order updated to prioritize legacy providers (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) over local auto-detection. `MiniMax` detection now also honors this priority and supports `MINIMAX_MODEL`. +- **`OpenAIProvider` and `OpenAIEmbeddingProvider` key handling.** Both now support `null` API keys to natively support local-only endpoints that don't require an `Authorization` header. +- **Neutral OpenAI default model.** The OpenAI provider now defaults to `openai-default` instead of a specific commercial model to avoid opinionated pricing or capability assumptions in the library core. ## [0.9.3] — 2026-04-24 From 43f6780d2f2482362068e9a030915a49df38e89f Mon Sep 17 00:00:00 2001 From: saem Date: Fri, 24 Apr 2026 19:31:31 -0700 Subject: [PATCH 27/28] reasoning model detection if there are prefixes --- src/providers/openai.ts | 6 +++++- test/local-providers.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/providers/openai.ts b/src/providers/openai.ts index df2c32a..e187ed9 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -29,7 +29,11 @@ export class OpenAIProvider implements MemoryProvider { const base = this.baseUrl.replace(/\/+$/, ""); const path = base.endsWith("/v1") ? "/chat/completions" : "/v1/chat/completions"; const url = `${base}${path}`; - const isReasoningModel = this.model.startsWith("o1-") || this.model.startsWith("o3-"); + + // Detect reasoning models (o1, o3, etc) which require max_completion_tokens. + // We check for the pattern o1- or o3- anywhere in the string to handle prefixes. + const isReasoningModel = /\bo[13]-/.test(this.model); + const body: Record = { model: this.model, messages: [ diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts index 44587e3..fbac125 100644 --- a/test/local-providers.test.ts +++ b/test/local-providers.test.ts @@ -197,6 +197,28 @@ describe("OpenAIProvider implementation", () => { expect(body.max_completion_tokens).toBe(500); expect(body.max_tokens).toBeUndefined(); }); + + it("detects reasoning models with prefixes, in case of proxies", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [{ message: { content: "mock reasoning response" } }], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const provider = new OpenAIProvider( + "openai", + "test-key", + "openai/o1-mini", + 500, + "https://api.openai.com" + ); + await provider.compress("system", "user"); + + const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string); + expect(body.max_completion_tokens).toBe(500); + }); }); describe("OpenAIEmbeddingProvider implementation", () => { From 5994cfa781fca82956d54eee4647809f7bb03008 Mon Sep 17 00:00:00 2001 From: Saem Ghani Date: Sat, 25 Apr 2026 01:41:17 -0700 Subject: [PATCH 28/28] README: note that OpenAIEmbeddingProvider env var fallback remain --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1df309..a0f0f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **vLLM**: `VLLM_BASE_URL` and `VLLM_MODEL`. Auto-detection now supports these local endpoints while ensuring existing legacy keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) remain prioritized for stable upgrades. - **`AGENTMEMORY_PROVIDER` environment variable.** Allows explicit selection of the LLM provider (e.g., `AGENTMEMORY_PROVIDER=ollama`), bypassing auto-detection and supporting setups with multiple keys. -- **Improved `OpenAIEmbeddingProvider` flexibility.** The constructor now accepts optional `baseUrl` and `model` overrides, enabling direct instantiation for custom OpenAI-compatible embedding proxies. +- **Improved `OpenAIEmbeddingProvider` flexibility.** The constructor now accepts optional `baseUrl` and `model` overrides, enabling direct instantiation for custom OpenAI-compatible embedding proxies. Environment variables remain as fallback. ### Changed