From c61b2312ddded0366a878e0d14dac0b672c20495 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:27:51 +0000 Subject: [PATCH 1/2] feat(ai): add MiniMax provider for global and China Anthropic Messages endpoints --- README.md | 1 + src/config.ts | 19 +- src/services/ai/ai-provider-factory.ts | 6 +- .../ai/providers/anthropic-messages.ts | 51 +++- src/services/ai/providers/minimax.ts | 53 +++++ src/services/ai/session/session-types.ts | 3 +- src/types/index.ts | 3 +- tests/minimax-provider.test.ts | 220 ++++++++++++++++++ 8 files changed, 341 insertions(+), 15 deletions(-) create mode 100644 src/services/ai/providers/minimax.ts create mode 100644 tests/minimax-provider.test.ts 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..29065a1f 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,21 @@ 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" + // 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 +605,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..23435c20 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 { 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 }, }); @@ -114,7 +153,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 +164,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 +209,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 +249,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..fc6492c3 --- /dev/null +++ b/src/services/ai/providers/minimax.ts @@ -0,0 +1,53 @@ +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` or `/anthropic/v1` 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\/?$/, "/anthropic/v1"); + base = base.replace(/\/anthropic\/?$/, "/anthropic/v1"); + if (!/\/anthropic\/v1$/.test(base)) { + base = `${base}/anthropic/v1`; + } + return `${base}/messages`; + } + + override sessionProviderTag(): AIProviderType { + return "minimax"; + } +} 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..e65fbb56 --- /dev/null +++ b/tests/minimax-provider.test.ts @@ -0,0 +1,220 @@ +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[] = []; + + getSession(): any { + return null; + } + + createSession(): any { + return this.session; + } + + getMessages(): any[] { + return this.messages; + } + + getLastSequence(): number { + return this.messages.length - 1; + } + + addMessage(message: any): void { + this.messages.push(message); + } +} + +describe("MiniMaxProvider", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("reports the minimax provider name", () => { + const provider = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + expect(provider.getProviderName()).toBe("minimax"); + expect(provider.supportsSession()).toBe(true); + }); + + it("resolves the global endpoint at /anthropic/v1/messages", () => { + const provider = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("resolves the China endpoint at api.minimaxi.com", () => { + const provider = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimaxi.com", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + expect(provider.resolveEndpoint()).toBe("https://api.minimaxi.com/anthropic/v1/messages"); + }); + + it("normalizes a base URL that already includes /anthropic/v1", () => { + const provider = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io/anthropic/v1", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("normalizes a base URL that includes /anthropic", () => { + const provider = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io/anthropic/", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("strips a trailing slash from the base URL", () => { + const provider = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io/", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + expect(provider.resolveEndpoint()).toBe("https://api.minimax.io/anthropic/v1/messages"); + }); + + it("throws when memoryApiUrl is not configured", () => { + const provider = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + expect(() => provider.resolveEndpoint()).toThrow(); + }); + + 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 = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + + 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("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 = new MiniMaxProvider( + { + model: "MiniMax-M3", + apiUrl: "https://api.minimax.io", + apiKey: "test-key", + }, + new FakeSessionManager() as any + ); + + 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"); + }); +}); From 0914d2d2ecf354f21655ff179b6585fb9dd8fb1b Mon Sep 17 00:00:00 2001 From: EyJunge1 Date: Sun, 2 Aug 2026 20:56:44 +0200 Subject: [PATCH 2/2] fix(ai): harden MiniMax provider URL and extraParams handling Apply safe memoryExtraParams on the shared Anthropic Messages path so MiniMax adaptive thinking works as documented, normalize full /anthropic/v1/messages base URLs without duplication, and cover the session tag plus request forwarding in regression tests. Co-authored-by: Cursor --- src/config.ts | 2 + .../ai/providers/anthropic-messages.ts | 8 +- src/services/ai/providers/minimax.ts | 24 ++- tests/minimax-provider.test.ts | 159 +++++++++--------- 4 files changed, 108 insertions(+), 85 deletions(-) diff --git a/src/config.ts b/src/config.ts index 29065a1f..9247e7e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -393,6 +393,8 @@ const CONFIG_TEMPLATE = `{ // "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" diff --git a/src/services/ai/providers/anthropic-messages.ts b/src/services/ai/providers/anthropic-messages.ts index 23435c20..f022a0f0 100644 --- a/src/services/ai/providers/anthropic-messages.ts +++ b/src/services/ai/providers/anthropic-messages.ts @@ -1,4 +1,4 @@ -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"; @@ -136,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, @@ -144,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", diff --git a/src/services/ai/providers/minimax.ts b/src/services/ai/providers/minimax.ts index fc6492c3..afe5711d 100644 --- a/src/services/ai/providers/minimax.ts +++ b/src/services/ai/providers/minimax.ts @@ -32,22 +32,32 @@ export class MiniMaxProvider extends AnthropicMessagesProvider { * * 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` or `/anthropic/v1` suffix. + * 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\/?$/, "/anthropic/v1"); - base = base.replace(/\/anthropic\/?$/, "/anthropic/v1"); - if (!/\/anthropic\/v1$/.test(base)) { - base = `${base}/anthropic/v1`; - } - return `${base}/messages`; + 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/tests/minimax-provider.test.ts b/tests/minimax-provider.test.ts index e65fbb56..b9287063 100644 --- a/tests/minimax-provider.test.ts +++ b/tests/minimax-provider.test.ts @@ -19,12 +19,16 @@ const toolSchema: ChatCompletionTool = { class FakeSessionManager { private readonly session = { id: "session-1" }; private readonly messages: any[] = []; + lastCreateSessionArgs: any; - getSession(): any { + getSession(sessionId?: string, provider?: string): any { + void sessionId; + void provider; return null; } - createSession(): any { + createSession(args: any): any { + this.lastCreateSessionArgs = args; return this.session; } @@ -41,6 +45,24 @@ class FakeSessionManager { } } +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; @@ -49,90 +71,63 @@ describe("MiniMaxProvider", () => { }); it("reports the minimax provider name", () => { - const provider = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "https://api.minimax.io", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); + const { provider } = makeProvider(); expect(provider.getProviderName()).toBe("minimax"); expect(provider.supportsSession()).toBe(true); }); it("resolves the global endpoint at /anthropic/v1/messages", () => { - const provider = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "https://api.minimax.io", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); + 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 = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "https://api.minimaxi.com", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); + 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 = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "https://api.minimax.io/anthropic/v1", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); + 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 = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "https://api.minimax.io/anthropic/", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); + 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 = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "https://api.minimax.io/", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); + 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 = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); + 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; @@ -147,15 +142,7 @@ describe("MiniMaxProvider", () => { } as Response; }) as typeof fetch; - const provider = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "https://api.minimax.io", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); - + const { provider } = makeProvider(); await provider.executeToolCall("system", "user", toolSchema, "session-id"); expect(capturedUrl).toBe("https://api.minimax.io/anthropic/v1/messages"); @@ -163,6 +150,34 @@ describe("MiniMaxProvider", () => { 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) => { @@ -189,15 +204,7 @@ describe("MiniMaxProvider", () => { } as Response; }) as typeof fetch; - const provider = new MiniMaxProvider( - { - model: "MiniMax-M3", - apiUrl: "https://api.minimax.io", - apiKey: "test-key", - }, - new FakeSessionManager() as any - ); - + const { provider } = makeProvider(); const result = await provider.executeToolCall("system", "user", toolSchema, "session-id"); expect(capturedBody?.model).toBe("MiniMax-M3");