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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
# The detection order is OPENAI_API_KEY → MINIMAX_API_KEY → ANTHROPIC_API_KEY
# → GEMINI_API_KEY → OPENROUTER_API_KEY → noop.

# OPENAI_API_KEY=sk-... # Used for OpenAI-compatible embeddings today. PR #307 will extend this to chat completions (DeepSeek, SiliconFlow, vLLM, LM Studio, Ollama via `/v1`).
# OPENAI_API_KEY=sk-... # OpenAI-compatible chat completions and embeddings
# OPENAI_MODEL=gpt-4o-mini # Chat model when OPENAI_API_KEY is the active LLM provider
# OPENAI_BASE_URL=https://api.openai.com # Override for OpenAI-compatible providers
# AZURE_OPENAI_API_VERSION=2024-10-21 # Azure OpenAI data-plane API version when OPENAI_BASE_URL is an Azure deployment URL

# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_MODEL=claude-sonnet-4-20250514 # Default Anthropic model
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ agentmemory auto-detects from your environment. No API key needed if you have a
| Provider | Config | Notes |
|----------|--------|-------|
| **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. |
| OpenAI-compatible | `OPENAI_API_KEY` | OpenAI, Azure OpenAI deployment URLs, DeepSeek, SiliconFlow, vLLM, LM Studio, Ollama-compatible proxies |
| Anthropic API | `ANTHROPIC_API_KEY` | Per-token billing |
| MiniMax | `MINIMAX_API_KEY` | Anthropic-compatible |
| Gemini | `GEMINI_API_KEY` | Also enables embeddings |
Expand All @@ -998,6 +999,10 @@ Create `~/.agentmemory/.env`:

```env
# LLM provider (pick one — default is the no-op provider: no LLM calls)
# OPENAI_API_KEY=sk-...
# OPENAI_MODEL=gpt-4o-mini
# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / DeepSeek / SiliconFlow / vLLM / LM Studio / Ollama / proxies
# AZURE_OPENAI_API_VERSION=2024-10-21 # Azure OpenAI data-plane API version for deployment URLs
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure
# GEMINI_API_KEY=...
Expand All @@ -1010,8 +1015,6 @@ Create `~/.agentmemory/.env`:
# Embedding provider (auto-detected, or override)
# EMBEDDING_PROVIDER=local
# VOYAGE_API_KEY=...
# OPENAI_API_KEY=sk-...
# 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

Expand Down
13 changes: 12 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ function hasRealValue(v: string | undefined): v is string {
function detectProvider(env: Record<string, string>): ProviderConfig {
const maxTokens = parseInt(env["MAX_TOKENS"] || "4096", 10);

if (hasRealValue(env["OPENAI_API_KEY"])) {
return {
provider: "openai",
model: env["OPENAI_MODEL"] || "gpt-4o-mini",
maxTokens,
baseURL: env["OPENAI_BASE_URL"],
};
}

// MiniMax: Anthropic-compatible API, requires raw fetch to avoid SDK stainless headers
if (hasRealValue(env["MINIMAX_API_KEY"])) {
return {
Expand Down Expand Up @@ -92,7 +101,7 @@ function detectProvider(env: Record<string, string>): ProviderConfig {
if (!allowAgentSdk) {
process.stderr.write(
"[agentmemory] No LLM provider key found " +
"(ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, MINIMAX_API_KEY). " +
"(OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, MINIMAX_API_KEY). " +
"LLM-backed compression and summarization are DISABLED — using no-op provider. " +
"This is the safe default: the agent-sdk fallback used to spawn Claude Agent SDK " +
"child sessions which inherit Claude Code's plugin hooks and cause infinite Stop-hook " +
Expand Down Expand Up @@ -153,6 +162,7 @@ export function detectLlmProviderKind(): "llm" | "noop" {
const env = getMergedEnv();
if (
hasRealValue(env["ANTHROPIC_API_KEY"]) ||
hasRealValue(env["OPENAI_API_KEY"]) ||
hasRealValue(env["GEMINI_API_KEY"]) ||
hasRealValue(env["GOOGLE_API_KEY"]) ||
hasRealValue(env["OPENROUTER_API_KEY"]) ||
Expand Down Expand Up @@ -287,6 +297,7 @@ export function getStandalonePersistPath(): string {
}

const VALID_PROVIDERS = new Set([
"openai",
"anthropic",
"gemini",
"openrouter",
Expand Down
2 changes: 1 addition & 1 deletion src/functions/summarize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function registerSummarizeFunction(
success: false,
error: "no_provider",
reason:
"No LLM provider key set; Summarize is a no-op. Set ANTHROPIC_API_KEY (or GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env to enable.",
"No LLM provider key set; Summarize is a no-op. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, or MINIMAX_API_KEY in ~/.agentmemory/.env to enable.",
};
}

Expand Down
8 changes: 8 additions & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -59,6 +60,13 @@ export function createFallbackProvider(

function createBaseProvider(config: ProviderConfig): MemoryProvider {
switch (config.provider) {
case "openai":
return new OpenAIProvider(
requireEnvVar("OPENAI_API_KEY"),
config.model,
config.maxTokens,
config.baseURL,
);
case "minimax":
return new MinimaxProvider(
requireEnvVar("MINIMAX_API_KEY"),
Expand Down
140 changes: 140 additions & 0 deletions src/providers/openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { MemoryProvider } from "../types.js";
import { getEnvVar } from "../config.js";

const DEFAULT_BASE_URL = "https://api.openai.com";
const DEFAULT_AZURE_API_VERSION = "2024-10-21";
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;

export class OpenAIProvider implements MemoryProvider {
name = "openai";
private apiKey: string;
private model: string;
private maxTokens: number;
private baseUrl: string;

constructor(
apiKey: string,
model: string,
maxTokens: number,
baseUrl?: string,
) {
this.apiKey = apiKey;
this.model = model;
this.maxTokens = maxTokens;
this.baseUrl = baseUrl || getEnvVar("OPENAI_BASE_URL") || DEFAULT_BASE_URL;
}

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

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

private isAzure(): boolean {
const url = this.baseUrl.toLowerCase();
return url.includes("openai.azure.com") || url.includes("/openai/deployments/");
}

private buildRequestUrl(): string {
const url = new URL(this.baseUrl);
const pathname = url.pathname.replace(/\/+$/, "");

if (this.isAzure()) {
if (!pathname.endsWith("/chat/completions")) {
url.pathname = `${pathname}/chat/completions`;
}

const apiVersion =
getEnvVar("AZURE_OPENAI_API_VERSION") ||
url.searchParams.get("api-version") ||
DEFAULT_AZURE_API_VERSION;
url.searchParams.set("api-version", apiVersion);

return url.toString();
}

if (pathname.endsWith("/chat/completions")) {
url.pathname = pathname;
} else if (pathname.endsWith("/v1")) {
url.pathname = `${pathname}/chat/completions`;
} else {
url.pathname = `${pathname}/v1/chat/completions`;
}

return url.toString();
}

private buildHeaders(): HeadersInit {
if (this.isAzure()) {
return {
"Content-Type": "application/json",
"api-key": this.apiKey,
};
}

return {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
};
}

private buildBody(systemPrompt: string, userPrompt: string): string {
return JSON.stringify({
...(this.isAzure() ? {} : { model: this.model }),
max_tokens: this.maxTokens,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
});
}

private async call(
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
DEFAULT_REQUEST_TIMEOUT_MS,
);
let response: Response;

try {
response = await fetch(this.buildRequestUrl(), {
method: "POST",
headers: this.buildHeaders(),
body: this.buildBody(systemPrompt, userPrompt),
signal: controller.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
`OpenAI API request timed out after ${DEFAULT_REQUEST_TIMEOUT_MS}ms`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}

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

const data = (await response.json()) as Record<string, unknown>;
const choices = data.choices as
| Array<{ message: { content: string } }>
| undefined;
const content = choices?.[0]?.message?.content;
if (!content) {
throw new Error(
`OpenAI returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`,
);
}
return content;
}
}
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" | "openai" | "openrouter" | "minimax" | "noop";

export interface MemoryProvider {
name: string;
Expand Down
Loading