From b233259f18d68af478f4181350cc0cfe0c683ba8 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 15:53:34 +0200 Subject: [PATCH] feat(ai): OpenAI-compatible adapter for MiniMax --- package-lock.json | 38 ++++++++++++++++ package.json | 1 + src/lib/ai/provider-openai.ts | 82 +++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 src/lib/ai/provider-openai.ts diff --git a/package-lock.json b/package-lock.json index cc920c5..4272f71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "clsx": "^2.1.1", "lucide-react": "^1.46.0", "next": "16.2.6", + "openai": "^7.15.0", "pocketbase": "^0.26.9", "react": "19.2.4", "react-dom": "19.2.4", @@ -3342,6 +3343,43 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/openai": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.15.0.tgz", + "integrity": "sha512-2DIwesnPSduw68MFx7nAdgvshrgvS8WOan5W8+tgUMlAy/tY+Q41g80Pa+w/v64rHA6rtn+dketa8QvMVxU96w==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", diff --git a/package.json b/package.json index 85d85cb..4b2110c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "lucide-react": "^1.46.0", "next": "16.2.6", + "openai": "^7.15.0", "pocketbase": "^0.26.9", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/src/lib/ai/provider-openai.ts b/src/lib/ai/provider-openai.ts new file mode 100644 index 0000000..82e8adb --- /dev/null +++ b/src/lib/ai/provider-openai.ts @@ -0,0 +1,82 @@ +import "server-only"; + +import OpenAI from "openai"; + +import { stripThinking } from "@/lib/ai/think-strip"; +import { AiError, type AiProvider, type CompletionRequest, type CompletionResult, type ProviderName } from "@/lib/ai/types"; + +/** + * OpenAI-compatible adapter — this is the MiniMax path. + * + * MiniMax specifics, taken from comments in MEGAIFRASTRUCTURE's equivalent + * adapter. They are the product of it going wrong, not of reading docs: + * + * - Use the NATIVE OpenAI-compatible route, https://api.minimax.io/v1. Their + * /anthropic shim does not reliably re-parse stringified arguments. + * - The key must be an Open Platform key, NOT a Token Plan Subscription Key. + * They are not interchangeable and the error does not say which you have. + * - `n` must be 1; presence_penalty and frequency_penalty are ignored. + * - M2.x emits its chain of thought inline in `content`, wrapped in + * tags — hence stripThinking below. Without it every client-facing summary + * opens with the model deliberating. + */ + +const MINIMAX_BASE_URL = "https://api.minimax.io/v1"; + +export function createOpenAiCompatibleProvider( + apiKey: string, + model: string, + provider: ProviderName +): AiProvider { + const client = new OpenAI({ + apiKey, + ...(provider === "minimax" + ? { baseURL: process.env.MINIMAX_BASE_URL || MINIMAX_BASE_URL } + : {}), + }); + + return { + name: provider, + + async complete(request: CompletionRequest): Promise { + let response: OpenAI.Chat.Completions.ChatCompletion; + + try { + response = await client.chat.completions.create({ + model, + max_tokens: Math.max(request.maxTokens, 4096), + n: 1, + messages: [ + { role: "system", content: request.system }, + { role: "user", content: request.user }, + ], + }); + } catch (error) { + if (error instanceof OpenAI.APIError) { + const retryable = error.status === 429 || (error.status ?? 0) >= 500; + throw new AiError(`${provider} error ${error.status}: ${error.message}`, retryable); + } + throw new AiError( + error instanceof Error ? error.message : `${provider} request failed`, + true + ); + } + + const choice = response.choices[0]; + + if (choice?.finish_reason === "length") { + throw new AiError("The response was cut off by max_tokens", true); + } + + // stripThinking is not optional on MiniMax — see the header. + const text = stripThinking(choice?.message?.content ?? ""); + + return { + text, + inputTokens: response.usage?.prompt_tokens ?? 0, + outputTokens: response.usage?.completion_tokens ?? 0, + model: response.model, + }; + }, + }; +} \ No newline at end of file