diff --git a/.env.example b/.env.example index 77ca0f3a3..ceb541c9b 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,13 @@ # OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514 # MINIMAX_API_KEY=... -# MINIMAX_MODEL=MiniMax-M2.7 +# MINIMAX_MODEL=MiniMax-M3 # MiniMax-M2.7 is also supported +# MINIMAX_BASE_URL=https://api.minimax.io/anthropic # Global Anthropic-compatible API +# MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic # China Anthropic-compatible API +# For OpenAI-compatible access, use OPENAI_API_KEY with: +# OPENAI_MODEL=MiniMax-M3 # MiniMax-M2.7 is also supported +# OPENAI_BASE_URL=https://api.minimax.io/v1 # Global API +# OPENAI_BASE_URL=https://api.minimaxi.com/v1 # China API # MAX_TOKENS=4096 # Cap LLM completion tokens for compression / summarise calls diff --git a/README.md b/README.md index d04da3712..53fb5d08a 100644 --- a/README.md +++ b/README.md @@ -1232,6 +1232,13 @@ agentmemory auto-detects from your environment. By default, no LLM calls are mad | **Local (Ollama / LM Studio / vLLM / llama.cpp)** | `OPENAI_API_KEY=local` + `OPENAI_BASE_URL=http://localhost:11434/v1` (Ollama) or `http://localhost:1234/v1` (LM Studio) + `OPENAI_MODEL=` | Anything OpenAI-API-compatible. Zero cost, runs on your hardware. See [Local models](#local-models-ollama-lm-studio-vllm) below. | | 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 so it is no longer the default. | +MiniMax defaults to `MiniMax-M3`; set the model variable to `MiniMax-M2.7` to use another supported model. Choose the matching protocol configuration and regional base URL: + +| Protocol | Configuration | Global base URL | China base URL | +|----------|---------------|-----------------|----------------| +| Anthropic-compatible | `MINIMAX_API_KEY`, `MINIMAX_MODEL`, `MINIMAX_BASE_URL` | `https://api.minimax.io/anthropic` | `https://api.minimaxi.com/anthropic` | +| OpenAI-compatible | `OPENAI_API_KEY`, `OPENAI_MODEL`, `OPENAI_BASE_URL` | `https://api.minimax.io/v1` | `https://api.minimaxi.com/v1` | + ### Local models (Ollama / LM Studio / vLLM) agentmemory talks to any OpenAI-API-compatible server, so anything that exposes `/v1/chat/completions` works without code changes. No paid keys, no cloud, no rate limits — runs entirely on your hardware. diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts index 6d0493554..926cdbea9 100644 --- a/src/cli/onboarding.ts +++ b/src/cli/onboarding.ts @@ -53,7 +53,7 @@ const PROVIDERS: { value: string; label: string; envKey: string | null }[] = [ { value: "openai", label: "OpenAI — gpt", envKey: "OPENAI_API_KEY" }, { value: "gemini", label: "Google — gemini", envKey: "GEMINI_API_KEY" }, { value: "openrouter", label: "OpenRouter — multi-model", envKey: "OPENROUTER_API_KEY" }, - { value: "minimax", label: "MiniMax — minimax-m1", envKey: "MINIMAX_API_KEY" }, + { value: "minimax", label: "MiniMax — MiniMax-M3", envKey: "MINIMAX_API_KEY" }, { value: "skip", label: "Skip — BM25-only mode (no LLM key)", envKey: null }, ]; diff --git a/src/config.ts b/src/config.ts index 3348ac226..b9f3d2d2d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -67,7 +67,7 @@ function detectProvider(env: Record): ProviderConfig { if (hasRealValue(env["MINIMAX_API_KEY"])) { return { provider: "minimax", - model: env["MINIMAX_MODEL"] || "MiniMax-M2.7", + model: env["MINIMAX_MODEL"] || "MiniMax-M3", maxTokens, }; } diff --git a/src/providers/index.ts b/src/providers/index.ts index 0ec3feba0..35b2f3547 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -45,7 +45,7 @@ function defaultModelFor(providerType: ProviderConfig["provider"]): string { getEnvVar("OPENROUTER_MODEL") || "anthropic/claude-sonnet-4-20250514" ); case "minimax": - return getEnvVar("MINIMAX_MODEL") || "MiniMax-M2.7"; + return getEnvVar("MINIMAX_MODEL") || "MiniMax-M3"; case "agent-sdk": return "claude-sonnet-4-20250514"; case "noop": diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts index 72fc9ec90..2a1fc607b 100644 --- a/src/providers/minimax.ts +++ b/src/providers/minimax.ts @@ -10,11 +10,11 @@ import { fetchWithTimeout } from './_fetch.js' * * Required env vars (loaded from ~/.agentmemory/.env or process.env): * MINIMAX_API_KEY — your MiniMax API key - * MINIMAX_MODEL — model name (default: MiniMax-M2.7) - * MAX_TOKENS — max output tokens (default: 800; MiniMax-M2.7 needs ≤800) + * MINIMAX_MODEL — model name (default: MiniMax-M3) + * MAX_TOKENS — max output tokens (default: 4096) * * Optional: - * MINIMAX_BASE_URL — base URL without path (default: https://api.minimax.io/anthropic) + * MINIMAX_BASE_URL — Anthropic-compatible base URL (default: https://api.minimax.io/anthropic) */ export class MinimaxProvider implements MemoryProvider { name = 'minimax' @@ -31,6 +31,11 @@ export class MinimaxProvider implements MemoryProvider { getEnvVar('MINIMAX_BASE_URL') || 'https://api.minimax.io/anthropic' } + private messagesUrl(): string { + const baseUrl = this.baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '') + return `${baseUrl}/v1/messages` + } + async compress(systemPrompt: string, userPrompt: string): Promise { return this.call(systemPrompt, userPrompt) } @@ -40,7 +45,7 @@ export class MinimaxProvider implements MemoryProvider { } private async call(systemPrompt: string, userPrompt: string): Promise { - const url = `${this.baseUrl}/v1/messages` + const url = this.messagesUrl() const response = await fetchWithTimeout(url, { method: 'POST', headers: { diff --git a/test/fallback-model-resolution.test.ts b/test/fallback-model-resolution.test.ts index 91a821161..79c731a48 100644 --- a/test/fallback-model-resolution.test.ts +++ b/test/fallback-model-resolution.test.ts @@ -155,7 +155,7 @@ describe("Fallback provider model resolution (#778)", () => { const openai = captured.find((c) => c.provider === "openai"); const minimax = captured.find((c) => c.provider === "minimax"); expect(openai?.model).toBe("gpt-5"); - expect(minimax?.model).toBe("MiniMax-M2.7"); + expect(minimax?.model).toBe("MiniMax-M3"); // Neither inherits the Anthropic model name. expect(openai?.model).not.toBe("claude-sonnet-4-20250514"); expect(minimax?.model).not.toBe("claude-sonnet-4-20250514"); diff --git a/test/minimax-provider.test.ts b/test/minimax-provider.test.ts index 4167d4b37..625a5bda8 100644 --- a/test/minimax-provider.test.ts +++ b/test/minimax-provider.test.ts @@ -1,30 +1,76 @@ -import { describe, expect, it, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MinimaxProvider } from "../src/providers/minimax.js"; describe("MinimaxProvider — base URL resolution (#285)", () => { - const originalEnv = process.env["MINIMAX_BASE_URL"]; + let originalBaseUrl: string | undefined; + + beforeEach(() => { + originalBaseUrl = process.env["MINIMAX_BASE_URL"]; + }); afterEach(() => { - if (originalEnv === undefined) { + vi.restoreAllMocks(); + if (originalBaseUrl === undefined) { delete process.env["MINIMAX_BASE_URL"]; } else { - process.env["MINIMAX_BASE_URL"] = originalEnv; + process.env["MINIMAX_BASE_URL"] = originalBaseUrl; } }); - it("defaults to https://api.minimax.io/anthropic (not the legacy minimaxi.com host)", () => { - delete process.env["MINIMAX_BASE_URL"]; - const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800); - expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( - "https://api.minimax.io/anthropic", + function mockSuccessfulResponse() { + return vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ content: [{ type: "text", text: "ok" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + ), ); - }); + } + + it("sends default requests to the global Anthropic-compatible endpoint", async () => { + delete process.env["MINIMAX_BASE_URL"]; + const fetchSpy = mockSuccessfulResponse(); + const provider = new MinimaxProvider("test-key", "MiniMax-M3", 800); + + await provider.compress("system", "user"); - it("honors MINIMAX_BASE_URL via getEnvVar (merged ~/.agentmemory/.env + process.env)", () => { - process.env["MINIMAX_BASE_URL"] = "https://custom.example.com/anthropic"; - const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800); - expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( - "https://custom.example.com/anthropic", + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.minimax.io/anthropic/v1/messages", + expect.objectContaining({ method: "POST" }), ); + const request = fetchSpy.mock.calls[0]?.[1]; + expect(JSON.parse(String(request?.body))).toMatchObject({ + model: "MiniMax-M3", + }); }); + + it.each([ + [ + "China regional", + "https://api.minimaxi.com/anthropic/", + "https://api.minimaxi.com/anthropic/v1/messages", + ], + [ + "versioned custom", + "https://custom.example.com/anthropic/v1", + "https://custom.example.com/anthropic/v1/messages", + ], + ])( + "normalizes %s base URLs before sending requests", + async (_name, baseUrl, expectedUrl) => { + process.env["MINIMAX_BASE_URL"] = baseUrl; + const fetchSpy = mockSuccessfulResponse(); + const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800); + + await provider.compress("system", "user"); + + expect(fetchSpy).toHaveBeenCalledWith( + expectedUrl, + expect.objectContaining({ method: "POST" }), + ); + const request = fetchSpy.mock.calls[0]?.[1]; + expect(JSON.parse(String(request?.body))).toMatchObject({ + model: "MiniMax-M2.7", + }); + }, + ); });