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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
120 changes: 120 additions & 0 deletions src/providers/openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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";

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 response = await fetch(this.buildRequestUrl(), {
method: "POST",
headers: this.buildHeaders(),
body: this.buildBody(systemPrompt, userPrompt),
});
Comment on lines +97 to +101
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a timeout to outbound OpenAI requests.

fetch at Line 97 has no timeout, so stalled upstream/network connections can block summarize/compress indefinitely. Add an abort timeout and clear it in finally.

Suggested fix
   private async call(
     systemPrompt: string,
     userPrompt: string,
   ): Promise<string> {
-    const response = await fetch(this.buildRequestUrl(), {
-      method: "POST",
-      headers: this.buildHeaders(),
-      body: this.buildBody(systemPrompt, userPrompt),
-    });
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 30_000);
+    let response: Response;
+    try {
+      response = await fetch(this.buildRequestUrl(), {
+        method: "POST",
+        headers: this.buildHeaders(),
+        body: this.buildBody(systemPrompt, userPrompt),
+        signal: controller.signal,
+      });
+    } finally {
+      clearTimeout(timeout);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/openai.ts` around lines 97 - 101, The outbound fetch call that
creates `response` (calling this.buildRequestUrl(), this.buildHeaders(),
this.buildBody()) needs an AbortController-based timeout so stalled
upstream/network requests don't block; create an AbortController, pass
controller.signal to fetch, start a setTimeout that calls controller.abort()
after a configured timeout, and ensure you clear that timeout in a finally block
so it doesn't leak; update the fetch invocation to include the signal and handle
the abort error path as appropriate in the surrounding method.


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
149 changes: 149 additions & 0 deletions test/openai-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { OpenAIProvider } from "../src/providers/openai.js";

describe("OpenAIProvider", () => {
const originalEnv = { ...process.env };

beforeEach(() => {
process.env = { ...originalEnv };
delete process.env["AZURE_OPENAI_API_VERSION"];
delete process.env["OPENAI_BASE_URL"];
});

afterEach(() => {
vi.restoreAllMocks();
process.env = originalEnv;
});

it("uses OpenAI-compatible chat completions with bearer auth by default", async () => {
const provider = new OpenAIProvider(
"test-key",
"gpt-4o-mini",
123,
"https://api.example.com",
);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
{ status: 200 },
),
);

await expect(provider.summarize("sys", "user")).resolves.toBe("ok");

expect(fetchSpy).toHaveBeenCalledWith(
"https://api.example.com/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-key",
},
}),
);
const body = JSON.parse(
(fetchSpy.mock.calls[0][1] as RequestInit).body as string,
) as Record<string, unknown>;
expect(body).toMatchObject({
model: "gpt-4o-mini",
max_tokens: 123,
});
});

it("uses Azure deployment URLs with api-version and api-key auth", async () => {
process.env["AZURE_OPENAI_API_VERSION"] = "2024-10-21";
const provider = new OpenAIProvider(
"azure-key",
"ignored-deployment-model",
456,
"https://resource.openai.azure.com/openai/deployments/my-deployment",
);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ choices: [{ message: { content: "azure ok" } }] }),
{ status: 200 },
),
);

await expect(provider.compress("sys", "user")).resolves.toBe("azure ok");

expect(fetchSpy).toHaveBeenCalledWith(
"https://resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-10-21",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
"api-key": "azure-key",
},
}),
);
const body = JSON.parse(
(fetchSpy.mock.calls[0][1] as RequestInit).body as string,
) as Record<string, unknown>;
expect(body).not.toHaveProperty("model");
expect(body.max_tokens).toBe(456);
});

it("does not duplicate /v1 for non-Azure base URLs", async () => {
const provider = new OpenAIProvider(
"test-key",
"gpt-4o-mini",
123,
"https://api.example.com/v1",
);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
{ status: 200 },
),
);

await provider.summarize("sys", "user");

expect(fetchSpy.mock.calls[0][0]).toBe(
"https://api.example.com/v1/chat/completions",
);
});

it("does not duplicate /chat/completions for full non-Azure endpoints", async () => {
const provider = new OpenAIProvider(
"test-key",
"gpt-4o-mini",
123,
"https://api.example.com/v1/chat/completions",
);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
{ status: 200 },
),
);

await provider.summarize("sys", "user");

expect(fetchSpy.mock.calls[0][0]).toBe(
"https://api.example.com/v1/chat/completions",
);
});

it("defaults Azure api-version to the latest GA data-plane version", async () => {
const provider = new OpenAIProvider(
"azure-key",
"deployment",
456,
"https://gateway.example.com/openai/deployments/deployment",
);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ choices: [{ message: { content: "azure ok" } }] }),
{ status: 200 },
),
);

await provider.summarize("sys", "user");

expect(fetchSpy.mock.calls[0][0]).toBe(
"https://gateway.example.com/openai/deployments/deployment/chat/completions?api-version=2024-10-21",
);
});
});