From 0bfb8e3383fe958594466e3e084c98b98dac2058 Mon Sep 17 00:00:00 2001 From: ProgrammerIn-wonderland Date: Wed, 27 May 2026 13:02:39 -0400 Subject: [PATCH] add minimax provider --- .gitignore | 2 + config.template.jsonc | 4 + .../drivers/ai-chat/ChatCompletionDriver.ts | 13 + .../providers/minimax/MiniMaxProvider.test.ts | 355 ++++++++++++++++++ .../providers/minimax/MiniMaxProvider.ts | 135 +++++++ .../ai-chat/providers/minimax/models.ts | 89 +++++ volatile/runtime/.gitignore | 2 - 7 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.test.ts create mode 100644 src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts create mode 100644 src/backend/drivers/ai-chat/providers/minimax/models.ts delete mode 100644 volatile/runtime/.gitignore 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