diff --git a/README.md b/README.md index fec9d810..073375a2 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,7 @@ Manual `memoryProvider` modes: - `openai-chat`: OpenAI Chat Completions compatible API with tool/function calling. This can work with compatible proxies such as LiteLLM only when the selected upstream model and proxy preserve tool calls. - `openai-responses`: OpenAI Responses API with function-call output. - `anthropic`: Anthropic Messages API with tool use. +- `minimax`: MiniMax Anthropic Messages-compatible endpoint. Set `memoryApiUrl` to the global endpoint (`https://api.minimax.io`) or the China endpoint (`https://api.minimaxi.com`); the `/anthropic/v1/messages` path and `x-api-key` header are applied automatically. MiniMax text models such as `MiniMax-M3` support the adaptive thinking modes used by this plugin via `memoryExtraParams`. Troubleshooting: diff --git a/src/config.ts b/src/config.ts index ed8591a1..9247e7e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -43,7 +43,7 @@ interface OpenCodeMemConfig { autoCaptureIterationTimeout?: number; autoCaptureMaxRetries?: number; autoCaptureLanguage?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -124,7 +124,7 @@ const DEFAULTS: Required< memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; memoryTemperature?: number | false; memoryExtraParams?: Record; opencodeProvider?: string; @@ -343,7 +343,7 @@ const CONFIG_TEMPLATE = `{ "autoCaptureEnabled": true, - // Provider type: "openai-chat" | "openai-responses" | "anthropic" + // Provider type: "openai-chat" | "openai-responses" | "anthropic" | "minimax" // Note: "openai-chat" is a generic OpenAI API-compatible mode. // Any service that follows the OpenAI Chat Completions API can use it via custom "memoryApiUrl". "memoryProvider": "openai-chat", @@ -386,14 +386,23 @@ const CONFIG_TEMPLATE = `{ // "memoryModel": "claude-3-5-haiku-20241022" // "memoryApiUrl": "https://api.anthropic.com/v1" // "memoryApiKey": "sk-ant-..." - + + // MiniMax (Anthropic Messages-compatible endpoint, with session support): + // "memoryProvider": "minimax" + // "memoryModel": "MiniMax-M3" + // "memoryApiUrl": "https://api.minimax.io" // global endpoint + // "memoryApiKey": "" + // // China endpoint: "memoryApiUrl": "https://api.minimaxi.com" + // // Optional adaptive thinking for MiniMax-M3: + // "memoryExtraParams": { "thinking": { "type": "adaptive" } } + // Groq (OpenAI-compatible, use openai-chat provider): // "memoryProvider": "openai-chat" // "memoryModel": "llama-3.3-70b-versatile" // "memoryApiUrl": "https://api.groq.com/openai/v1" // "memoryApiKey": "gsk_..." - // Maximum iterations for multi-turn AI analysis (for openai-responses and anthropic) + // Maximum iterations for multi-turn AI analysis (for openai-responses, anthropic, and minimax) "autoCaptureMaxIterations": 5, // Timeout per iteration in milliseconds (30 seconds default) @@ -598,7 +607,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { autoCaptureMaxRetries: fileConfig.autoCaptureMaxRetries ?? DEFAULTS.autoCaptureMaxRetries, autoCaptureLanguage: fileConfig.autoCaptureLanguage, memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as - "openai-chat" | "openai-responses" | "anthropic", + "openai-chat" | "openai-responses" | "anthropic" | "minimax", memoryModel: fileConfig.memoryModel, memoryApiUrl: fileConfig.memoryApiUrl, memoryApiKey, diff --git a/src/services/ai/ai-provider-factory.ts b/src/services/ai/ai-provider-factory.ts index e26364d2..c3b5e08c 100644 --- a/src/services/ai/ai-provider-factory.ts +++ b/src/services/ai/ai-provider-factory.ts @@ -2,6 +2,7 @@ import { BaseAIProvider, type ProviderConfig } from "./providers/base-provider.j import { OpenAIChatCompletionProvider } from "./providers/openai-chat-completion.js"; import { OpenAIResponsesProvider } from "./providers/openai-responses.js"; import { AnthropicMessagesProvider } from "./providers/anthropic-messages.js"; +import { MiniMaxProvider } from "./providers/minimax.js"; import { GoogleGeminiProvider } from "./providers/google-gemini.js"; import { aiSessionManager } from "./session/ai-session-manager.js"; import type { AIProviderType } from "./session/session-types.js"; @@ -18,6 +19,9 @@ export class AIProviderFactory { case "anthropic": return new AnthropicMessagesProvider(config, aiSessionManager); + case "minimax": + return new MiniMaxProvider(config, aiSessionManager); + case "google-gemini": return new GoogleGeminiProvider(config, aiSessionManager); @@ -27,7 +31,7 @@ export class AIProviderFactory { } static getSupportedProviders(): AIProviderType[] { - return ["openai-chat", "openai-responses", "anthropic", "google-gemini"]; + return ["openai-chat", "openai-responses", "anthropic", "minimax", "google-gemini"]; } static async cleanupExpiredSessions(): Promise { diff --git a/src/services/ai/providers/anthropic-messages.ts b/src/services/ai/providers/anthropic-messages.ts index 02868316..f022a0f0 100644 --- a/src/services/ai/providers/anthropic-messages.ts +++ b/src/services/ai/providers/anthropic-messages.ts @@ -1,6 +1,7 @@ -import { BaseAIProvider, type ToolCallResult } from "./base-provider.js"; +import { applySafeExtraParams, BaseAIProvider, type ToolCallResult } from "./base-provider.js"; import { AISessionManager } from "../session/ai-session-manager.js"; import { ToolSchemaConverter, type ChatCompletionTool } from "../tools/tool-schema.js"; +import type { AIProviderType } from "../session/session-types.js"; import { log } from "../../logger.js"; import { UserProfileValidator } from "../validators/user-profile-validator.js"; @@ -28,6 +29,14 @@ interface AnthropicResponse { }; } +/** + * Base implementation for providers that speak the Anthropic Messages API + * (Anthropic itself plus Anthropic-compatible gateways such as MiniMax). + * + * Subclasses override the session provider tag and the resolved endpoint URL so + * the session store and request routing reflect the upstream provider while the + * message/tool-call handling stays shared. + */ export class AnthropicMessagesProvider extends BaseAIProvider { private aiSessionManager: AISessionManager; @@ -44,17 +53,47 @@ export class AnthropicMessagesProvider extends BaseAIProvider { return true; } + /** + * Provider tag stored on AI sessions so a subclass (e.g. MiniMax) records its + * own tag instead of the literal "anthropic" value. + */ + protected sessionProviderTag(): AIProviderType { + return "anthropic"; + } + + /** + * Resolve the Messages endpoint URL. The default appends `/messages` to the + * configured base URL, matching Anthropic's `https://api.anthropic.com/v1` + * base. Subclasses with a different path layout override this. + */ + protected resolveEndpoint(): string { + return `${this.config.apiUrl}/messages`; + } + + protected apiErrorLogLabel(): string { + return "Anthropic Messages API error"; + } + + protected toolValidationErrorLogLabel(): string { + return "Anthropic tool response validation failed"; + } + + protected timeoutLabel(): string { + return "Anthropic API request timeout"; + } + async executeToolCall( systemPrompt: string, userPrompt: string, toolSchema: ChatCompletionTool, sessionId: string ): Promise { - let session = await this.aiSessionManager.getSession(sessionId, "anthropic"); + const providerTag = this.sessionProviderTag(); + let session = await this.aiSessionManager.getSession(sessionId, providerTag); if (!session) { session = await this.aiSessionManager.createSession({ - provider: "anthropic", + provider: providerTag, sessionId, metadata: { systemPrompt }, }); @@ -97,7 +136,7 @@ export class AnthropicMessagesProvider extends BaseAIProvider { try { const tool = ToolSchemaConverter.toAnthropic(toolSchema); - const requestBody = { + const requestBody: Record = { model: this.config.model, max_tokens: this.config.maxTokens ?? 4096, system: systemPrompt, @@ -105,6 +144,10 @@ export class AnthropicMessagesProvider extends BaseAIProvider { tools: [tool], }; + if (this.config.extraParams) { + applySafeExtraParams(requestBody, this.config.extraParams); + } + const headers: Record = { "Content-Type": "application/json", "anthropic-version": "2023-06-01", @@ -114,7 +157,7 @@ export class AnthropicMessagesProvider extends BaseAIProvider { headers["x-api-key"] = this.config.apiKey; } - const response = await fetch(`${this.config.apiUrl}/messages`, { + const response = await fetch(this.resolveEndpoint(), { method: "POST", headers, body: JSON.stringify(requestBody), @@ -125,7 +168,7 @@ export class AnthropicMessagesProvider extends BaseAIProvider { if (!response.ok) { const errorText = await response.text().catch(() => response.statusText); - log("Anthropic Messages API error", { + log(this.apiErrorLogLabel(), { provider: this.getProviderName(), model: this.config.model, status: response.status, @@ -170,7 +213,7 @@ export class AnthropicMessagesProvider extends BaseAIProvider { }; } catch (validationError) { const errorStack = validationError instanceof Error ? validationError.stack : undefined; - log("Anthropic tool response validation failed", { + log(this.toolValidationErrorLogLabel(), { error: String(validationError), stack: errorStack, errorType: @@ -210,7 +253,7 @@ export class AnthropicMessagesProvider extends BaseAIProvider { if (error instanceof Error && error.name === "AbortError") { return { success: false, - error: `API request timeout (${this.config.iterationTimeout}ms)`, + error: `${this.timeoutLabel()} (${this.config.iterationTimeout}ms)`, iterations, }; } diff --git a/src/services/ai/providers/minimax.ts b/src/services/ai/providers/minimax.ts new file mode 100644 index 00000000..afe5711d --- /dev/null +++ b/src/services/ai/providers/minimax.ts @@ -0,0 +1,63 @@ +import { type ProviderConfig } from "./base-provider.js"; +import { AISessionManager } from "../session/ai-session-manager.js"; +import { AnthropicMessagesProvider } from "./anthropic-messages.js"; +import type { AIProviderType } from "../session/session-types.js"; + +/** + * MiniMax provider. + * + * MiniMax exposes an Anthropic Messages-compatible endpoint for its text + * models. The global endpoint (https://api.minimax.io) and the China endpoint + * (https://api.minimaxi.com) both expose the same `/anthropic/v1/messages` + * path and authenticate with the `x-api-key` header. This provider reuses the + * Anthropic Messages request/response handling and only overrides the resolved + * request endpoint URL and the session provider tag, so MiniMax is + * distinguishable from Anthropic in the session store and diagnostics. + * + * Users configure the base URL as `memoryApiUrl`: + * - global endpoint: "https://api.minimax.io" + * - China endpoint: "https://api.minimaxi.com" + */ +export class MiniMaxProvider extends AnthropicMessagesProvider { + constructor(config: ProviderConfig, aiSessionManager: AISessionManager) { + super(config, aiSessionManager); + } + + override getProviderName(): string { + return "minimax"; + } + + /** + * Resolve the Anthropic Messages endpoint URL for MiniMax. + * + * MiniMax's Messages endpoint lives at `/anthropic/v1/messages`. The + * base URL is normalized so users can supply the host with or without a + * trailing `/anthropic`, `/anthropic/v1`, or `/anthropic/v1/messages` suffix. + */ + override resolveEndpoint(): string { + let base = (this.config.apiUrl || "").trim().replace(/\/+$/, ""); + if (!base) { + throw new Error("MiniMax provider requires a configured memoryApiUrl"); + } + base = base.replace(/\/anthropic\/v1\/messages\/?$/, ""); + base = base.replace(/\/anthropic\/v1\/?$/, ""); + base = base.replace(/\/anthropic\/?$/, ""); + return `${base}/anthropic/v1/messages`; + } + + override sessionProviderTag(): AIProviderType { + return "minimax"; + } + + protected override apiErrorLogLabel(): string { + return "MiniMax Messages API error"; + } + + protected override toolValidationErrorLogLabel(): string { + return "MiniMax tool response validation failed"; + } + + protected override timeoutLabel(): string { + return "MiniMax API request timeout"; + } +} diff --git a/src/services/ai/session/session-types.ts b/src/services/ai/session/session-types.ts index 64c6cd00..6e162d07 100644 --- a/src/services/ai/session/session-types.ts +++ b/src/services/ai/session/session-types.ts @@ -1,4 +1,5 @@ -export type AIProviderType = "openai-chat" | "openai-responses" | "anthropic" | "google-gemini"; +export type AIProviderType = + "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; export interface AIMessage { id?: number; diff --git a/src/types/index.ts b/src/types/index.ts index f72a37ea..5c6fc744 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,4 +17,5 @@ export interface MemoryMetadata { [key: string]: unknown; } -export type AIProviderType = "openai-chat" | "openai-responses" | "anthropic"; +export type AIProviderType = + "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; diff --git a/tests/minimax-provider.test.ts b/tests/minimax-provider.test.ts new file mode 100644 index 00000000..b9287063 --- /dev/null +++ b/tests/minimax-provider.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { MiniMaxProvider } from "../src/services/ai/providers/minimax.js"; +import { AIProviderFactory } from "../src/services/ai/ai-provider-factory.js"; +import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; + +const toolSchema: ChatCompletionTool = { + type: "function", + function: { + name: "save_memories", + description: "Save memories", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, +}; + +class FakeSessionManager { + private readonly session = { id: "session-1" }; + private readonly messages: any[] = []; + lastCreateSessionArgs: any; + + getSession(sessionId?: string, provider?: string): any { + void sessionId; + void provider; + return null; + } + + createSession(args: any): any { + this.lastCreateSessionArgs = args; + return this.session; + } + + getMessages(): any[] { + return this.messages; + } + + getLastSequence(): number { + return this.messages.length - 1; + } + + addMessage(message: any): void { + this.messages.push(message); + } +} + +function makeProvider( + overrides: Record = {}, + sessionManager = new FakeSessionManager() +) { + return { + provider: new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io", + apiKey: "test-key", + ...overrides, + }, + sessionManager as any + ), + sessionManager, + }; +} + +describe("MiniMaxProvider", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("reports the minimax provider name", () => { + const { provider } = makeProvider(); + expect(provider.getProviderName()).toBe("minimax"); + expect(provider.supportsSession()).toBe(true); + }); + + it("resolves the global endpoint at /anthropic/v1/messages", () => { + const { provider } = makeProvider(); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("resolves the China endpoint at api.minimaxi.com", () => { + const { provider } = makeProvider({ apiUrl: "https://api.minimaxi.com" }); + expect(provider.resolveEndpoint()).toBe("https://api.minimaxi.com/anthropic/v1/messages"); + }); + + it("normalizes a base URL that already includes /anthropic/v1", () => { + const { provider } = makeProvider({ apiUrl: "https://api.minimax.io/anthropic/v1" }); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("normalizes a base URL that includes /anthropic", () => { + const { provider } = makeProvider({ apiUrl: "https://api.minimax.io/anthropic/" }); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("normalizes a full /anthropic/v1/messages URL without duplicating the path", () => { + const { provider } = makeProvider({ + apiUrl: "https://api.minimax.io/anthropic/v1/messages", + }); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("strips a trailing slash from the base URL", () => { + const { provider } = makeProvider({ apiUrl: "https://api.minimax.io/" }); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("throws when memoryApiUrl is not configured", () => { + const { provider } = makeProvider({ apiUrl: "" }); + expect(() => provider.resolveEndpoint()).toThrow(); + }); + + it("records the minimax session provider tag", async () => { + globalThis.fetch = (async () => + ({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + }) as Response) as typeof fetch; + + const { provider, sessionManager } = makeProvider(); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(sessionManager.lastCreateSessionArgs?.provider).toBe("minimax"); + }); + + it("targets /anthropic/v1/messages and authenticates with x-api-key", async () => { + let capturedUrl: string | undefined; + let capturedHeaders: Record | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(input); + capturedHeaders = init?.headers as Record; + return { + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + } as Response; + }) as typeof fetch; + + const { provider } = makeProvider(); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedUrl).toBe("https://api.minimax.io/anthropic/v1/messages"); + expect(capturedHeaders?.["x-api-key"]).toBe("test-key"); + expect(capturedHeaders?.["anthropic-version"]).toBe("2023-06-01"); + }); + + it("forwards adaptive thinking via memoryExtraParams", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + } as Response; + }) as typeof fetch; + + const { provider } = makeProvider({ + extraParams: { + thinking: { type: "adaptive" }, + model: "should-not-overwrite", + messages: ["should-not-overwrite"], + tools: ["should-not-overwrite"], + }, + }); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedBody?.thinking).toEqual({ type: "adaptive" }); + expect(capturedBody?.model).toBe("MiniMax-M3"); + expect(Array.isArray(capturedBody?.messages)).toBe(true); + expect(Array.isArray(capturedBody?.tools)).toBe(true); + }); + + it("extracts tool input from a MiniMax Anthropic-format response", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { + ok: true, + status: 200, + json: async () => ({ + id: "msg_1", + type: "message", + role: "assistant", + model: "MiniMax-M3", + stop_reason: "tool_use", + content: [ + { + type: "tool_use", + id: "tool_1", + name: "save_memories", + input: { memory: "captured fact" }, + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }), + } as Response; + }) as typeof fetch; + + const { provider } = makeProvider(); + const result = await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedBody?.model).toBe("MiniMax-M3"); + expect(capturedBody?.max_tokens).toBeDefined(); + expect(result.success).toBe(true); + expect((result.data as any).memory).toBe("captured fact"); + }); +}); + +describe("AIProviderFactory minimax wiring", () => { + it("creates a MiniMax provider and lists it as supported", () => { + const provider = AIProviderFactory.createProvider("minimax", { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io", + apiKey: "test-key", + }); + expect(provider.getProviderName()).toBe("minimax"); + expect(AIProviderFactory.getSupportedProviders()).toContain("minimax"); + }); +});