diff --git a/CHANGELOG.md b/CHANGELOG.md index 2496453..a0f0f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ 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. Environment variables remain as fallback. + +### Changed + +- **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 Developer-experience patch. Every disabled feature flag is now visible in the viewer, the CLI, and REST error responses, so devs no longer hit empty tabs wondering whether the install is broken or just opt-in. Adds a `doctor` command that diagnoses the whole stack in one shot and a first-run hero in the viewer that points at the magical-moment `demo` command. diff --git a/README.md b/README.md index 9d4d5ad..a8f39d5 100644 --- a/README.md +++ b/README.md @@ -534,7 +534,7 @@ Memories decay over time (Ebbinghaus curve). Frequently accessed memories streng |------|----------| | `SessionStart` | Project path, session ID | | `UserPromptSubmit` | User prompts (privacy-filtered) | -| `PreToolUse` | File access patterns + enriched context | +| `PreToolUse` | File access pattern + enriched context | | `PostToolUse` | Tool name, input, output | | `PostToolUseFailure` | Error context | | `PreCompact` | Re-injects memory before compaction | @@ -782,6 +782,10 @@ agentmemory auto-detects from your environment. No API key needed if you have a | **No-op (default)** | No config needed | LLM-backed compress/summarize is DISABLED. Synthetic BM25 compression + recall still work. See `AGENTMEMORY_ALLOW_AGENT_SDK` below if you used to rely on the Claude-subscription fallback. | | 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 | | Claude subscription fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Opt-in only. Spawns `@anthropic-ai/claude-agent-sdk` sessions — used to cause unbounded Stop-hook recursion (#149 follow-up) so it is no longer the default. | @@ -797,6 +801,9 @@ Create `~/.agentmemory/.env`: # GEMINI_API_KEY=... # OPENROUTER_API_KEY=... # MINIMAX_API_KEY=... +# OPENAI_API_KEY=sk-... +# OLLAMA_MODEL=llama3 +# 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 @@ -808,6 +815,8 @@ Create `~/.agentmemory/.env`: # OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies # OPENAI_EMBEDDING_MODEL=text-embedding-3-small # OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table +# OLLAMA_EMBEDDING_MODEL=nomic-embed-text +# LMSTUDIO_EMBEDDING_BASE_URL=http://localhost:1234 # Search tuning # BM25_WEIGHT=0.4 diff --git a/src/config.ts b/src/config.ts index 09bf229..ab3ee52 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,16 +48,18 @@ 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["ANTHROPIC_API_KEY"])) { + if (explicitProvider === "anthropic" || (!explicitProvider && hasRealValue(env["ANTHROPIC_API_KEY"]))) { return { provider: "anthropic", model: env["ANTHROPIC_MODEL"] || "claude-sonnet-4-20250514", @@ -65,7 +67,8 @@ function detectProvider(env: Record): ProviderConfig { baseURL: env["ANTHROPIC_BASE_URL"], }; } - if (hasRealValue(env["GEMINI_API_KEY"]) || hasRealValue(env["GOOGLE_API_KEY"])) { + + 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. " + @@ -78,7 +81,8 @@ function detectProvider(env: Record): ProviderConfig { maxTokens, }; } - if (hasRealValue(env["OPENROUTER_API_KEY"])) { + + if (explicitProvider === "openrouter" || (!explicitProvider && hasRealValue(env["OPENROUTER_API_KEY"]))) { return { provider: "openrouter", model: env["OPENROUTER_MODEL"] || "anthropic/claude-sonnet-4-20250514", @@ -86,6 +90,42 @@ function detectProvider(env: Record): ProviderConfig { }; } + // Local/Compatible providers (moved after legacy ones to avoid flipping existing users) + if (explicitProvider === "minimax" || (!explicitProvider && hasRealValue(env["MINIMAX_API_KEY"]))) { + return { + provider: "minimax", + model: env["MINIMAX_MODEL"] || "MiniMax-M2.7", + maxTokens, + }; + } + + if (explicitProvider === "lmstudio" || (!explicitProvider && (hasRealValue(env["LMSTUDIO_BASE_URL"]) || hasRealValue(env["LMSTUDIO_MODEL"])))) { + return { + provider: "lmstudio", + model: env["LMSTUDIO_MODEL"] || "local-model", + maxTokens, + baseURL: env["LMSTUDIO_BASE_URL"], + }; + } + + if (explicitProvider === "ollama" || (!explicitProvider && (hasRealValue(env["OLLAMA_BASE_URL"]) || hasRealValue(env["OLLAMA_MODEL"])))) { + return { + provider: "ollama", + model: env["OLLAMA_MODEL"] || "llama3", + maxTokens, + baseURL: env["OLLAMA_BASE_URL"] || "http://localhost:11434", + }; + } + + if (explicitProvider === "vllm" || (!explicitProvider && (hasRealValue(env["VLLM_BASE_URL"]) || hasRealValue(env["VLLM_MODEL"])))) { + return { + provider: "vllm", + model: env["VLLM_MODEL"] || "local-model", + maxTokens, + baseURL: env["VLLM_BASE_URL"], + }; + } + const allowAgentSdk = env["AGENTMEMORY_ALLOW_AGENT_SDK"] === "true"; if (!allowAgentSdk) { process.stderr.write( @@ -174,6 +214,12 @@ 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"; + if (source["VLLM_EMBEDDING_BASE_URL"] || source["VLLM_EMBEDDING_MODEL"]) + return "vllm"; return null; } @@ -276,6 +322,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..3c93779 100644 --- a/src/providers/embedding/index.ts +++ b/src/providers/embedding/index.ts @@ -35,7 +35,32 @@ 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") || null, + getEnvVar("OPENAI_EMBEDDING_BASE_URL"), + getEnvVar("OPENAI_EMBEDDING_MODEL"), + ); + case "ollama": + return new OpenAIEmbeddingProvider( + null, + getEnvVar("OLLAMA_EMBEDDING_BASE_URL") || "http://localhost:11434", + getEnvVar("OLLAMA_EMBEDDING_MODEL") || "nomic-embed-text", + ); + case "lmstudio": + { + 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(null, base, model); + } + case "vllm": + { + 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(null, base, model); + } case "voyage": return new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!); case "cohere": diff --git a/src/providers/embedding/openai.ts b/src/providers/embedding/openai.ts index 308479f..00552d4 100644 --- a/src/providers/embedding/openai.ts +++ b/src/providers/embedding/openai.ts @@ -46,17 +46,16 @@ 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) { - 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 = - getEnvVar("OPENAI_BASE_URL") || DEFAULT_BASE_URL; + baseUrl || getEnvVar("OPENAI_BASE_URL") || DEFAULT_BASE_URL; this.model = - getEnvVar("OPENAI_EMBEDDING_MODEL") || DEFAULT_MODEL; + model || getEnvVar("OPENAI_EMBEDDING_MODEL") || DEFAULT_MODEL; this.dimensions = resolveDimensions( this.model, getEnvVar("OPENAI_EMBEDDING_DIMENSIONS"), @@ -70,12 +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: { - 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 b22907b..eac71f7 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 { NoopProvider } from "./noop.js"; +import { OpenAIProvider } from "./openai.js"; import { OpenRouterProvider } from "./openrouter.js"; import { ResilientProvider } from "./resilient.js"; import { FallbackChainProvider } from "./fallback-chain.js"; @@ -59,6 +60,38 @@ export function createFallbackProvider( function createBaseProvider(config: ProviderConfig): MemoryProvider { switch (config.provider) { + case "openai": + return new OpenAIProvider( + "openai", + getEnvVar("OPENAI_API_KEY") || null, + config.model, + config.maxTokens, + config.baseURL || "https://api.openai.com", + ); + case "ollama": + return new OpenAIProvider( + "ollama", + null, + config.model, + config.maxTokens, + config.baseURL || "http://localhost:11434", + ); + case "vllm": + return new OpenAIProvider( + "vllm", + null, + config.model, + config.maxTokens, + config.baseURL || "http://localhost:8000", + ); + case "lmstudio": + return new OpenAIProvider( + "lmstudio", + null, + config.model, + config.maxTokens, + config.baseURL || "http://localhost:1234", + ); case "minimax": return new MinimaxProvider( requireEnvVar("MINIMAX_API_KEY"), diff --git a/src/providers/openai.ts b/src/providers/openai.ts new file mode 100644 index 0000000..e187ed9 --- /dev/null +++ b/src/providers/openai.ts @@ -0,0 +1,81 @@ +import type { MemoryProvider } from "../types.js"; + +/** + * Generic OpenAI-compatible provider. + * Works with OpenAI, LM Studio, Ollama, vLLM, Groq, OpenRouter, etc. + */ +export class OpenAIProvider implements MemoryProvider { + constructor( + public name: string, + private apiKey: string | null, + private model: string, + private maxTokens: number, + private baseUrl: string, + private timeoutMs: number = 60_000, + ) {} + + 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 base = this.baseUrl.replace(/\/+$/, ""); + const path = base.endsWith("/v1") ? "/chat/completions" : "/v1/chat/completions"; + const url = `${base}${path}`; + + // 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: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }; + + if (isReasoningModel) { + body.max_completion_tokens = this.maxTokens; + } else { + 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, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.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; + if (!content) { + throw new Error( + `${this.name} returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, + ); + } + return content; + } +} diff --git a/src/types.ts b/src/types.ts index 74a2496..b874bcb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -129,7 +129,7 @@ export interface ProviderConfig { baseURL?: string; } -export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax" | "noop"; +export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax" | "lmstudio" | "openai" | "ollama" | "vllm" | "noop"; export interface MemoryProvider { name: string; @@ -839,7 +839,6 @@ export interface RetentionScore { reinforcementBoost: number; lastAccessed: string; accessCount: number; - source?: "episodic" | "semantic"; } export interface DecayConfig { diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index 48092eb..4167b85 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -2,18 +2,27 @@ 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", () => { const originalEnv = { ...process.env }; 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"]; + // 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"] = ""; + process.env["VLLM_EMBEDDING_BASE_URL"] = ""; + process.env["VLLM_EMBEDDING_MODEL"] = ""; }); afterEach(() => { @@ -39,6 +48,25 @@ 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); + 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"; @@ -68,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/fs-watcher.test.ts b/test/fs-watcher.test.ts index 21e260e..da21d3a 100644 --- a/test/fs-watcher.test.ts +++ b/test/fs-watcher.test.ts @@ -48,8 +48,9 @@ describe("FilesystemWatcher", () => { }); w.start(); try { + await wait(100); 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"); diff --git a/test/local-providers.test.ts b/test/local-providers.test.ts new file mode 100644 index 0000000..fbac125 --- /dev/null +++ b/test/local-providers.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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("OpenAI Family Providers", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + 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_API_KEY"] = ""; + 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"] = ""; + 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(() => { + process.env = originalEnv; + }); + + describe("Detection", () => { + it("detects openai provider", () => { + process.env["OPENAI_API_KEY"] = "sk-test"; + const config = loadConfig(); + 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(); + expect(config.provider.provider).toBe("lmstudio"); + }); + + 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("detects vllm embedding provider", () => { + process.env["VLLM_EMBEDDING_BASE_URL"] = "http://vllm-server:8000"; + const provider = detectEmbeddingProvider(process.env); + expect(provider).toBe("vllm"); + }); + }); + + 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); + }); + }); + }); + + 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"; + 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); + expect(provider!.name).toBe("openai"); + }); + }); + }); +}); + +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, + json: async () => ({ + choices: [{ message: { content: "mock response" } }], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const provider = new OpenAIProvider( + "test-provider", + "test-key", + "test-model", + 100, + "http://test-url" + ); + 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", + signal: expect.any(AbortSignal), + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer test-key", + }, + body: JSON.stringify({ + model: "test-model", + messages: [ + { role: "system", content: "system" }, + { role: "user", content: "user" }, + ], + max_tokens: 100, + }), + }) + ); + }); + + it("uses max_completion_tokens for reasoning models", 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", + "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(); + }); + + 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", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("calls fetch with correct parameters and appends /v1/embeddings", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const provider = new OpenAIEmbeddingProvider( + null, + "http://local-runner:11434", + "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-runner:11434/v1/embeddings", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "nomic-embed-text", + input: ["test 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"); + }); +});