diff --git a/.gitignore b/.gitignore
index 9506fd0cf..7e8128a7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,3 +70,5 @@ coverage/
undefined
servers.json
config.*.json
+
+volatile/
diff --git a/config.template.jsonc b/config.template.jsonc
index f42a98ddd..9f71bbd3e 100644
--- a/config.template.jsonc
+++ b/config.template.jsonc
@@ -252,6 +252,10 @@
"mistral": { "apiKey": "" },
"xai": { "apiKey": "" },
"moonshot": { "apiKey": "" },
+ "minimax": {
+ "apiKey": "",
+ "apiBaseUrl": "https://api.minimax.io/v1"
+ },
"openrouter": {
"apiKey": "",
"apiBaseUrl": "https://openrouter.ai/api/v1"
diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts
index 3af5c4b49..02896087a 100644
--- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts
+++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts
@@ -43,6 +43,7 @@ import { XAIProvider } from './providers/xai/XAIProvider.js';
import { ZAIProvider } from './providers/zai/ZAIProvider.js';
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
+import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
import type {
IChatCompleteResult,
IChatModel,
@@ -869,6 +870,18 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
+ const minimax = providers['minimax'];
+ const minimaxKey = readKey(minimax);
+ if (minimaxKey) {
+ this.#providers['minimax'] = new MiniMaxProvider(
+ {
+ apiKey: minimaxKey,
+ apiBaseUrl: minimax?.apiBaseUrl as string | undefined,
+ },
+ metering,
+ );
+ }
+
const zai = providers['zai'];
const zaiKey = readKey(zai);
if (zaiKey) {
diff --git a/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.test.ts b/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.test.ts
new file mode 100644
index 000000000..a187d0193
--- /dev/null
+++ b/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.test.ts
@@ -0,0 +1,355 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+ type MockInstance,
+} from 'vitest';
+
+import { SYSTEM_ACTOR } from '../../../../core/actor.js';
+import type { MeteringService } from '../../../../services/metering/MeteringService.js';
+import { PuterServer } from '../../../../server.js';
+import { setupTestServer } from '../../../../testUtil.js';
+import { withTestActor } from '../../../integrationTestUtil.js';
+import { MINIMAX_MODELS } from './models.js';
+import { MiniMaxProvider } from './MiniMaxProvider.js';
+
+const { createMock, openAICtor } = vi.hoisted(() => {
+ const createMock = vi.fn();
+ const openAICtor = vi.fn();
+ return { createMock, openAICtor };
+});
+
+vi.mock('openai', () => {
+ const OpenAICtor = vi.fn().mockImplementation(function (
+ this: Record,
+ opts: unknown,
+ ) {
+ openAICtor(opts);
+ this.chat = { completions: { create: createMock } };
+ });
+ return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
+});
+
+let server: PuterServer;
+let recordSpy: MockInstance;
+
+beforeAll(async () => {
+ server = await setupTestServer();
+});
+
+afterAll(async () => {
+ await server?.shutdown();
+});
+
+const makeProvider = () => {
+ const provider = new MiniMaxProvider(
+ { apiKey: 'test-key' },
+ server.services.metering,
+ );
+ return { provider };
+};
+
+const asAsyncIterable = (items: T[]): AsyncIterable => ({
+ async *[Symbol.asyncIterator]() {
+ for (const item of items) {
+ yield item;
+ }
+ },
+});
+
+beforeEach(() => {
+ createMock.mockReset();
+ openAICtor.mockReset();
+ recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('MiniMaxProvider construction', () => {
+ it('points the OpenAI SDK at the MiniMax base URL with the configured key', () => {
+ makeProvider();
+ expect(openAICtor).toHaveBeenCalledTimes(1);
+ expect(openAICtor).toHaveBeenCalledWith({
+ apiKey: 'test-key',
+ baseURL: 'https://api.minimax.io/v1',
+ });
+ });
+
+ it('allows overriding the MiniMax API base URL', () => {
+ new MiniMaxProvider(
+ {
+ apiKey: 'test-key',
+ apiBaseUrl: 'https://example.test/v1',
+ },
+ server.services.metering,
+ );
+ expect(openAICtor).toHaveBeenCalledWith({
+ apiKey: 'test-key',
+ baseURL: 'https://example.test/v1',
+ });
+ });
+});
+
+describe('MiniMaxProvider model catalog', () => {
+ it('returns minimax-m2.7 as the default', () => {
+ const { provider } = makeProvider();
+ expect(provider.getDefaultModel()).toBe('minimax-m2.7');
+ });
+
+ it('exposes the static MINIMAX_MODELS list verbatim from models()', () => {
+ const { provider } = makeProvider();
+ expect(provider.models()).toBe(MINIMAX_MODELS);
+ });
+
+ it('list() flattens canonical ids and aliases', () => {
+ const { provider } = makeProvider();
+ const ids = provider.list();
+ for (const model of MINIMAX_MODELS) {
+ expect(ids).toContain(model.id);
+ for (const alias of model.aliases ?? []) {
+ expect(ids).toContain(alias);
+ }
+ }
+ expect(ids).toContain('minimax-m2.7');
+ expect(ids).toContain('MiniMax-M2.7');
+ expect(ids).toContain('minimax/minimax-m2.7');
+ });
+
+ it('uses MiniMax completion-token limits instead of the context window', () => {
+ for (const model of MINIMAX_MODELS) {
+ expect(model.context).toBe(204_800);
+ expect(model.max_tokens).toBe(196_608);
+ }
+ });
+});
+
+describe('MiniMaxProvider.complete request shape', () => {
+ const baseCompletion = {
+ choices: [
+ {
+ message: { content: 'hi', role: 'assistant' },
+ finish_reason: 'stop',
+ },
+ ],
+ usage: { prompt_tokens: 1, completion_tokens: 1 },
+ };
+
+ it('sends the case-sensitive upstream MiniMax model id', async () => {
+ const { provider } = makeProvider();
+ createMock.mockResolvedValueOnce(baseCompletion);
+
+ await withTestActor(() =>
+ provider.complete({
+ model: 'minimax-m2.7',
+ messages: [{ role: 'user', content: 'hello' }],
+ }),
+ );
+
+ const [args] = createMock.mock.calls[0]!;
+ expect(args.model).toBe('MiniMax-M2.7');
+ expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
+ expect(args.max_tokens).toBe(1000);
+ });
+
+ it('resolves the upstream-cased alias to the canonical API model', async () => {
+ const { provider } = makeProvider();
+ createMock.mockResolvedValueOnce(baseCompletion);
+
+ await withTestActor(() =>
+ provider.complete({
+ model: 'MiniMax-M2.7-highspeed',
+ messages: [{ role: 'user', content: 'hello' }],
+ }),
+ );
+
+ expect(createMock.mock.calls[0]![0].model).toBe(
+ 'MiniMax-M2.7-highspeed',
+ );
+ expect(recordSpy).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.anything(),
+ 'minimax:minimax-m2.7-highspeed',
+ expect.any(Object),
+ );
+ });
+
+ it('passes standard chat completion options through', async () => {
+ const { provider } = makeProvider();
+ createMock.mockResolvedValueOnce(baseCompletion);
+ const tools = [
+ {
+ type: 'function',
+ function: {
+ name: 'lookup',
+ parameters: { type: 'object', properties: {} },
+ },
+ },
+ ];
+
+ await withTestActor(() =>
+ provider.complete({
+ model: 'minimax-m2.7',
+ messages: [{ role: 'user', content: 'hi' }],
+ tools,
+ tool_choice: 'auto',
+ max_tokens: 256,
+ temperature: 0.4,
+ top_p: 0.9,
+ }),
+ );
+
+ const [args] = createMock.mock.calls[0]!;
+ expect(args.tools).toBe(tools);
+ expect(args.tool_choice).toBe('auto');
+ expect(args.max_tokens).toBe(256);
+ expect(args.temperature).toBe(0.4);
+ expect(args.top_p).toBe(0.9);
+ });
+
+ it('clamps oversized max_tokens to the MiniMax completion limit', async () => {
+ const { provider } = makeProvider();
+ createMock.mockResolvedValueOnce(baseCompletion);
+
+ await withTestActor(() =>
+ provider.complete({
+ model: 'minimax-m2.1',
+ messages: [{ role: 'user', content: 'hi' }],
+ max_tokens: 200_000,
+ }),
+ );
+
+ const [args] = createMock.mock.calls[0]!;
+ expect(args.model).toBe('MiniMax-M2.1');
+ expect(args.max_tokens).toBe(196_608);
+ });
+
+ it('only sets stream_options.include_usage when streaming', async () => {
+ const { provider } = makeProvider();
+ createMock.mockResolvedValueOnce(baseCompletion);
+
+ await withTestActor(() =>
+ provider.complete({
+ model: 'minimax-m2.7',
+ messages: [{ role: 'user', content: 'hi' }],
+ stream: false,
+ }),
+ );
+ const [nonStreamArgs] = createMock.mock.calls[0]!;
+ expect(nonStreamArgs.stream).toBe(false);
+ expect('stream_options' in nonStreamArgs).toBe(false);
+
+ createMock.mockReturnValueOnce(asAsyncIterable([]));
+ await withTestActor(() =>
+ provider.complete({
+ model: 'minimax-m2.7',
+ messages: [{ role: 'user', content: 'hi' }],
+ stream: true,
+ }),
+ );
+ const [streamArgs] = createMock.mock.calls[1]!;
+ expect(streamArgs.stream).toBe(true);
+ expect(streamArgs.stream_options).toEqual({ include_usage: true });
+ });
+});
+
+describe('MiniMaxProvider.complete output and metering', () => {
+ it('returns the first choice and records MiniMax usage with model costs', async () => {
+ const { provider } = makeProvider();
+ createMock.mockResolvedValueOnce({
+ choices: [
+ {
+ message: { content: 'hi there', role: 'assistant' },
+ finish_reason: 'stop',
+ },
+ ],
+ usage: {
+ prompt_tokens: 100,
+ completion_tokens: 50,
+ prompt_tokens_details: { cached_tokens: 10 },
+ },
+ });
+
+ const result = await withTestActor(() =>
+ provider.complete({
+ model: 'minimax-m2.7',
+ messages: [{ role: 'user', content: 'hi' }],
+ }),
+ );
+
+ expect(result).toMatchObject({
+ message: { content: 'hi there', role: 'assistant' },
+ finish_reason: 'stop',
+ });
+ expect((result as { usage: unknown }).usage).toEqual({
+ prompt_tokens: 100,
+ completion_tokens: 50,
+ cached_tokens: 10,
+ });
+
+ expect(recordSpy).toHaveBeenCalledTimes(1);
+ const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
+ expect(usage).toEqual({
+ prompt_tokens: 100,
+ completion_tokens: 50,
+ cached_tokens: 10,
+ });
+ expect(actor).toBe(SYSTEM_ACTOR);
+ expect(prefix).toBe('minimax:minimax-m2.7');
+ expect(overrides).toEqual({
+ prompt_tokens: 100 * 30,
+ completion_tokens: 50 * 120,
+ cached_tokens: 10 * 6,
+ });
+ });
+
+ it('rethrows errors raised by the OpenAI client unchanged', async () => {
+ const { provider } = makeProvider();
+ const apiError = new Error('MiniMax exploded');
+ createMock.mockRejectedValueOnce(apiError);
+
+ await expect(
+ withTestActor(() =>
+ provider.complete({
+ model: 'minimax-m2.7',
+ messages: [{ role: 'user', content: 'boom' }],
+ }),
+ ),
+ ).rejects.toBe(apiError);
+ expect(recordSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe('MiniMaxProvider.checkModeration', () => {
+ it('throws because MiniMax provider does not implement moderation', () => {
+ const { provider } = makeProvider();
+ expect(() => provider.checkModeration('anything')).toThrow(
+ /not implemented/i,
+ );
+ });
+});
diff --git a/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts b/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts
new file mode 100644
index 000000000..13ed5ae58
--- /dev/null
+++ b/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { OpenAI } from 'openai';
+import { ChatCompletionCreateParams } from 'openai/resources/index.js';
+import { Context } from '../../../../core/context.js';
+import type { MeteringService } from '../../../../services/metering/MeteringService.js';
+import type { IChatProvider, ICompleteArguments } from '../../types.js';
+import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
+import { MINIMAX_MODELS } from './models.js';
+
+type MiniMaxConfig = {
+ apiKey: string;
+ apiBaseUrl?: string;
+};
+
+export class MiniMaxProvider implements IChatProvider {
+ #openai: OpenAI;
+
+ #meteringService: MeteringService;
+
+ #defaultModel = 'minimax-m2.7';
+
+ constructor(config: MiniMaxConfig, meteringService: MeteringService) {
+ this.#openai = new OpenAI({
+ apiKey: config.apiKey,
+ baseURL: config.apiBaseUrl ?? 'https://api.minimax.io/v1',
+ });
+ this.#meteringService = meteringService;
+ }
+
+ getDefaultModel() {
+ return this.#defaultModel;
+ }
+
+ models() {
+ return MINIMAX_MODELS;
+ }
+
+ list() {
+ const modelIds: string[] = [];
+ for (const model of this.models()) {
+ modelIds.push(model.id);
+ if (model.aliases) {
+ modelIds.push(...model.aliases);
+ }
+ }
+ return modelIds;
+ }
+
+ async complete({
+ messages,
+ stream,
+ model,
+ tools,
+ tool_choice,
+ max_tokens,
+ temperature,
+ top_p,
+ }: ICompleteArguments): ReturnType {
+ const actor = Context.get('actor');
+ const availableModels = this.models();
+ const modelUsed =
+ availableModels.find((m) =>
+ [m.id, ...(m.aliases || [])].includes(model),
+ ) || availableModels.find((m) => m.id === this.getDefaultModel())!;
+
+ messages = await OpenAIUtil.process_input_messages(messages);
+ const requestedMaxTokens = max_tokens ?? 1000;
+
+ const completion = await this.#openai.chat.completions.create({
+ messages,
+ model: modelUsed.apiModel,
+ ...(tools ? { tools } : {}),
+ ...(tool_choice !== undefined ? { tool_choice } : {}),
+ max_tokens: Math.min(requestedMaxTokens, modelUsed.max_tokens),
+ ...(temperature !== undefined ? { temperature } : {}),
+ ...(top_p !== undefined ? { top_p } : {}),
+ stream,
+ ...(stream
+ ? {
+ stream_options: { include_usage: true },
+ }
+ : {}),
+ } as ChatCompletionCreateParams);
+
+ return OpenAIUtil.handle_completion_output({
+ usage_calculator: ({ usage }) => {
+ const trackedUsage = usage
+ ? OpenAIUtil.extractMeteredUsage(usage)
+ : {
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ cached_tokens: 0,
+ };
+ const costsOverride = Object.fromEntries(
+ Object.entries(trackedUsage).map(([key, value]) => {
+ return [key, value * Number(modelUsed.costs[key] ?? 0)];
+ }),
+ );
+ this.#meteringService.utilRecordUsageObject(
+ trackedUsage,
+ actor!,
+ `minimax:${modelUsed.id}`,
+ costsOverride,
+ );
+ return trackedUsage;
+ },
+ stream,
+ completion,
+ });
+ }
+
+ checkModeration(
+ _text: string,
+ ): ReturnType {
+ throw new Error('Method not implemented.');
+ }
+}
diff --git a/src/backend/drivers/ai-chat/providers/minimax/models.ts b/src/backend/drivers/ai-chat/providers/minimax/models.ts
new file mode 100644
index 000000000..92167fc0b
--- /dev/null
+++ b/src/backend/drivers/ai-chat/providers/minimax/models.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { IChatModel } from '../../types.js';
+
+const CENTS_PER_USD = 100;
+const MTOK = 1_000_000;
+const CONTEXT_WINDOW = 204_800;
+const MAX_OUTPUT_TOKENS = 196_608;
+
+type MiniMaxChatModel = IChatModel & {
+ apiModel: string;
+};
+
+const usdPerMToken = (
+ inputUsd: number,
+ outputUsd: number,
+ cachedReadUsd = 0,
+) => ({
+ tokens: MTOK,
+ prompt_tokens: inputUsd * CENTS_PER_USD,
+ completion_tokens: outputUsd * CENTS_PER_USD,
+ cached_tokens: cachedReadUsd * CENTS_PER_USD,
+});
+
+const minimaxModel = (
+ apiModel: string,
+ name: string,
+ costs: IChatModel['costs'],
+): MiniMaxChatModel => {
+ const id = apiModel.toLowerCase();
+ return {
+ puterId: `minimax:minimax/${id}`,
+ id,
+ apiModel,
+ name,
+ aliases: [`minimax/${id}`, apiModel, `minimax/${apiModel}`],
+ modalities: { input: ['text'], output: ['text'] },
+ open_weights: false,
+ tool_call: true,
+ context: CONTEXT_WINDOW,
+ max_tokens: MAX_OUTPUT_TOKENS,
+ costs_currency: 'usd-cents',
+ input_cost_key: 'prompt_tokens',
+ output_cost_key: 'completion_tokens',
+ costs,
+ };
+};
+
+// Hardcoded from MiniMax OpenAI-compatible API docs and pay-as-you-go pricing:
+// https://platform.minimax.io/docs/api-reference/text-openai-api
+// https://platform.minimax.io/docs/guides/pricing-paygo
+export const MINIMAX_MODELS: MiniMaxChatModel[] = [
+ minimaxModel('MiniMax-M2.7', 'MiniMax M2.7', usdPerMToken(0.3, 1.2, 0.06)),
+ minimaxModel(
+ 'MiniMax-M2.7-highspeed',
+ 'MiniMax M2.7 Highspeed',
+ usdPerMToken(0.6, 2.4, 0.06),
+ ),
+ minimaxModel('MiniMax-M2.5', 'MiniMax M2.5', usdPerMToken(0.3, 1.2, 0.03)),
+ minimaxModel(
+ 'MiniMax-M2.5-highspeed',
+ 'MiniMax M2.5 Highspeed',
+ usdPerMToken(0.6, 2.4, 0.03),
+ ),
+ minimaxModel('MiniMax-M2.1', 'MiniMax M2.1', usdPerMToken(0.3, 1.2, 0.03)),
+ minimaxModel(
+ 'MiniMax-M2.1-highspeed',
+ 'MiniMax M2.1 Highspeed',
+ usdPerMToken(0.6, 2.4, 0.03),
+ ),
+ minimaxModel('MiniMax-M2', 'MiniMax M2', usdPerMToken(0.3, 1.2, 0.03)),
+];
diff --git a/volatile/runtime/.gitignore b/volatile/runtime/.gitignore
deleted file mode 100644
index d6b7ef32c..000000000
--- a/volatile/runtime/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore