From 0fe8956983449019ce32fae904c1bef2978b2a66 Mon Sep 17 00:00:00 2001 From: Yong-yuan-X <2463436064@qq.com> Date: Fri, 15 May 2026 22:45:52 +0800 Subject: [PATCH 1/3] fix: support Azure OpenAI chat completions Signed-off-by: Yong-yuan-X <2463436064@qq.com> --- .env.example | 4 +- README.md | 7 ++- src/config.ts | 13 ++++- src/functions/summarize.ts | 2 +- src/providers/index.ts | 8 +++ src/providers/openai.ts | 107 +++++++++++++++++++++++++++++++++++ src/types.ts | 2 +- test/openai-provider.test.ts | 107 +++++++++++++++++++++++++++++++++++ 8 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 src/providers/openai.ts create mode 100644 test/openai-provider.test.ts diff --git a/.env.example b/.env.example index b6653c6a..b3b453d5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 245dad27..68754489 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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=... @@ -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 diff --git a/src/config.ts b/src/config.ts index a4b676cf..67596657 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,6 +50,15 @@ function hasRealValue(v: string | undefined): v is string { function detectProvider(env: Record): 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 { @@ -92,7 +101,7 @@ function detectProvider(env: Record): 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 " + @@ -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"]) || @@ -287,6 +297,7 @@ export function getStandalonePersistPath(): string { } const VALID_PROVIDERS = new Set([ + "openai", "anthropic", "gemini", "openrouter", diff --git a/src/functions/summarize.ts b/src/functions/summarize.ts index 140e0e12..184bfc5a 100644 --- a/src/functions/summarize.ts +++ b/src/functions/summarize.ts @@ -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.", }; } diff --git a/src/providers/index.ts b/src/providers/index.ts index b22907bc..793bf2b9 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,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"), diff --git a/src/providers/openai.ts b/src/providers/openai.ts new file mode 100644 index 00000000..ec737c9a --- /dev/null +++ b/src/providers/openai.ts @@ -0,0 +1,107 @@ +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 { + return this.call(systemPrompt, userPrompt); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + 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 path = this.isAzure() ? "chat/completions" : "v1/chat/completions"; + const url = new URL(this.baseUrl); + url.pathname = `${url.pathname.replace(/\/+$/, "")}/${path}`; + + if (this.isAzure()) { + 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(); + } + + 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 { + const response = await fetch(this.buildRequestUrl(), { + method: "POST", + headers: this.buildHeaders(), + body: this.buildBody(systemPrompt, userPrompt), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenAI 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; + if (!content) { + throw new Error( + `OpenAI returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, + ); + } + return content; + } +} diff --git a/src/types.ts b/src/types.ts index a9347611..c8596802 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" | "openai" | "openrouter" | "minimax" | "noop"; export interface MemoryProvider { name: string; diff --git a/test/openai-provider.test.ts b/test/openai-provider.test.ts new file mode 100644 index 00000000..d67dbf47 --- /dev/null +++ b/test/openai-provider.test.ts @@ -0,0 +1,107 @@ +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; + 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; + expect(body).not.toHaveProperty("model"); + expect(body.max_tokens).toBe(456); + }); + + 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", + ); + }); +}); From 08de94db612feade1fb959791f56a30280660dbb Mon Sep 17 00:00:00 2001 From: Yong-yuan-X <2463436064@qq.com> Date: Fri, 15 May 2026 22:52:58 +0800 Subject: [PATCH 2/3] fix: avoid duplicate OpenAI chat paths Signed-off-by: Yong-yuan-X <2463436064@qq.com> --- src/providers/openai.ts | 17 +++++++++++++-- test/openai-provider.test.ts | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/providers/openai.ts b/src/providers/openai.ts index ec737c9a..c481c2d2 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -37,16 +37,29 @@ export class OpenAIProvider implements MemoryProvider { } private buildRequestUrl(): string { - const path = this.isAzure() ? "chat/completions" : "v1/chat/completions"; const url = new URL(this.baseUrl); - url.pathname = `${url.pathname.replace(/\/+$/, "")}/${path}`; + 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(); diff --git a/test/openai-provider.test.ts b/test/openai-provider.test.ts index d67dbf47..2c6c0967 100644 --- a/test/openai-provider.test.ts +++ b/test/openai-provider.test.ts @@ -84,6 +84,48 @@ describe("OpenAIProvider", () => { 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", From f5f02151a8e60936e2560b7c76e60e8f8210f912 Mon Sep 17 00:00:00 2001 From: Yong-yuan-X <2463436064@qq.com> Date: Sat, 16 May 2026 09:59:51 +0800 Subject: [PATCH 3/3] fix: add timeout to OpenAI provider requests --- src/providers/openai.ts | 30 +++++++++++++++++++++++++----- test/openai-provider.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/providers/openai.ts b/src/providers/openai.ts index c481c2d2..6e7b3fb3 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -3,6 +3,7 @@ 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"; @@ -94,11 +95,30 @@ export class OpenAIProvider implements MemoryProvider { systemPrompt: string, userPrompt: string, ): Promise { - 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(), + 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(); diff --git a/test/openai-provider.test.ts b/test/openai-provider.test.ts index 2c6c0967..a713e0e5 100644 --- a/test/openai-provider.test.ts +++ b/test/openai-provider.test.ts @@ -11,6 +11,7 @@ describe("OpenAIProvider", () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); process.env = originalEnv; }); @@ -35,6 +36,7 @@ describe("OpenAIProvider", () => { "https://api.example.com/v1/chat/completions", expect.objectContaining({ method: "POST", + signal: expect.any(AbortSignal), headers: { "Content-Type": "application/json", Authorization: "Bearer test-key", @@ -71,6 +73,7 @@ describe("OpenAIProvider", () => { "https://resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-10-21", expect.objectContaining({ method: "POST", + signal: expect.any(AbortSignal), headers: { "Content-Type": "application/json", "api-key": "azure-key", @@ -146,4 +149,29 @@ describe("OpenAIProvider", () => { "https://gateway.example.com/openai/deployments/deployment/chat/completions?api-version=2024-10-21", ); }); + + it("aborts stalled upstream requests", async () => { + vi.useFakeTimers(); + const provider = new OpenAIProvider( + "test-key", + "gpt-4o-mini", + 123, + "https://api.example.com", + ); + vi.spyOn(globalThis, "fetch").mockImplementation((_url, init) => { + const signal = (init as RequestInit).signal as AbortSignal; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }); + }); + + const expectation = expect(provider.summarize("sys", "user")).rejects.toThrow( + "OpenAI API request timed out after 30000ms", + ); + + await vi.advanceTimersByTimeAsync(30_000); + await expectation; + }); });