diff --git a/.env.template b/.env.template index b3336d64..09ac3b42 100644 --- a/.env.template +++ b/.env.template @@ -65,6 +65,16 @@ ANTHROPIC_API_VERSION= ### anthropic claude Api url (optional) ANTHROPIC_URL= +# (optional) +# Default: Empty +# MiniMax API key, set if you want to use MiniMax models. +MINIMAX_API_KEY= + +# (optional) +# Default: https://api.minimax.io +# MiniMax API url, set if you want to customize MiniMax API url. +MINIMAX_URL= + ### (optional) WHITE_WEBDEV_ENDPOINTS= diff --git a/README.md b/README.md index 57f14590..d0541562 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ ### 已支持 - [x] 原`ChatGPT-Next-Web`所有功能 +- [x] MiniMax(MiniMax-M2.7 / MiniMax-M2.5 / MiniMax-M2.5-highspeed) - [x] StabilityAI - [x] 支持 Stable Image Ultra - [x] 支持 Stable Image Core @@ -47,6 +48,8 @@ MJ Proxy的API链接地址 ### `MJ_PROXY_KEY` MJ Proxy的API密钥 +### `MINIMAX_API_KEY` +(可选)MiniMax API密钥,用于使用MiniMax模型(如MiniMax-M2.7、MiniMax-M2.5) ### `CODE` (可选)设置页面中的访问密码 ### `...其余参数` diff --git a/README_EN.md b/README_EN.md index bf96cc42..20919244 100644 --- a/README_EN.md +++ b/README_EN.md @@ -26,6 +26,7 @@ One-click to own your own `ChatGPT` + `many AI` aggregation web service (based o ### Already supported - [x] All functions of the original `ChatGPT-Next-Web` +- [x] MiniMax (MiniMax-M2.7 / MiniMax-M2.5 / MiniMax-M2.5-highspeed) - [x] StabilityAI - [x] Support for Stable Image Ultra - [x] Support for Stable Image Core @@ -47,6 +48,8 @@ One-click to own your own `ChatGPT` + `many AI` aggregation web service (based o MJ Proxy API link address ### `MJ_PROXY_KEY` MJ Proxy API key +### `MINIMAX_API_KEY` +(Optional) MiniMax API key for using MiniMax models (MiniMax-M2.7, MiniMax-M2.5, etc.) ### `CODE` (Optional) Set the access password on the page ### `...Other parameters` diff --git a/app/api/auth.ts b/app/api/auth.ts index 2913a147..bb1b2816 100644 --- a/app/api/auth.ts +++ b/app/api/auth.ts @@ -85,6 +85,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) { case ModelProvider.Qwen: systemApiKey = serverConfig.alibabaApiKey; break; + case ModelProvider.MiniMax: + systemApiKey = serverConfig.minimaxApiKey; + break; case ModelProvider.GPT: default: if (req.nextUrl.pathname.includes("azure/deployments")) { diff --git a/app/api/minimax/[...path]/route.ts b/app/api/minimax/[...path]/route.ts new file mode 100644 index 00000000..42ec2ffe --- /dev/null +++ b/app/api/minimax/[...path]/route.ts @@ -0,0 +1,153 @@ +import { getServerSideConfig } from "@/app/config/server"; +import { + MINIMAX_BASE_URL, + ApiPath, + ModelProvider, + ServiceProvider, +} from "@/app/constant"; +import { prettyObject } from "@/app/utils/format"; +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/app/api/auth"; +import { isModelAvailableInServer } from "@/app/utils/model"; + +const serverConfig = getServerSideConfig(); + +async function handle( + req: NextRequest, + { params }: { params: { path: string[] } }, +) { + console.log("[MiniMax Route] params ", params); + + if (req.method === "OPTIONS") { + return NextResponse.json({ body: "OK" }, { status: 200 }); + } + + const authResult = auth(req, ModelProvider.MiniMax); + if (authResult.error) { + return NextResponse.json(authResult, { + status: 401, + }); + } + + try { + const response = await request(req); + return response; + } catch (e) { + console.error("[MiniMax] ", e); + return NextResponse.json(prettyObject(e)); + } +} + +export const GET = handle; +export const POST = handle; + +export const runtime = "edge"; +export const preferredRegion = [ + "arn1", + "bom1", + "cdg1", + "cle1", + "cpt1", + "dub1", + "fra1", + "gru1", + "hnd1", + "iad1", + "icn1", + "kix1", + "lhr1", + "pdx1", + "sfo1", + "sin1", + "syd1", +]; + +async function request(req: NextRequest) { + const controller = new AbortController(); + + let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.MiniMax, ""); + + let baseUrl = serverConfig.minimaxUrl || MINIMAX_BASE_URL; + + if (!baseUrl.startsWith("http")) { + baseUrl = `https://${baseUrl}`; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, -1); + } + + console.log("[Proxy] ", path); + console.log("[Base Url]", baseUrl); + + const timeoutId = setTimeout( + () => { + controller.abort(); + }, + 10 * 60 * 1000, + ); + + const fetchUrl = `${baseUrl}${path}`; + + const fetchOptions: RequestInit = { + headers: { + "Content-Type": "application/json", + Authorization: req.headers.get("Authorization") ?? "", + }, + method: req.method, + body: req.body, + redirect: "manual", + // @ts-ignore + duplex: "half", + signal: controller.signal, + }; + + // #1815 try to refuse some request to some models + if (serverConfig.customModels && req.body) { + try { + const clonedBody = await req.text(); + fetchOptions.body = clonedBody; + + const jsonBody = JSON.parse(clonedBody) as { model?: string }; + + // not undefined and is false + if ( + isModelAvailableInServer( + serverConfig.customModels, + jsonBody?.model as string, + ServiceProvider.MiniMax as string, + ) + ) { + return NextResponse.json( + { + error: true, + message: `you are not allowed to use ${jsonBody?.model} model`, + }, + { + status: 403, + }, + ); + } + } catch (e) { + console.error(`[MiniMax] filter`, e); + } + } + + try { + const res = await fetch(fetchUrl, fetchOptions); + + // to prevent browser prompt for credentials + const newHeaders = new Headers(res.headers); + newHeaders.delete("www-authenticate"); + // to disable nginx buffering + newHeaders.set("X-Accel-Buffering", "no"); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: newHeaders, + }); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/app/client/api.ts b/app/client/api.ts index 102a4220..7739add9 100644 --- a/app/client/api.ts +++ b/app/client/api.ts @@ -12,6 +12,7 @@ import { ClaudeApi } from "./platforms/anthropic"; import { ErnieApi } from "./platforms/baidu"; import { DoubaoApi } from "./platforms/bytedance"; import { QwenApi } from "./platforms/alibaba"; +import { MiniMaxApi } from "./platforms/minimax"; export const ROLES = ["system", "user", "assistant"] as const; export type MessageRole = (typeof ROLES)[number]; @@ -117,6 +118,9 @@ export class ClientApi { case ModelProvider.Qwen: this.llm = new QwenApi(); break; + case ModelProvider.MiniMax: + this.llm = new MiniMaxApi(); + break; default: this.llm = new ChatGPTApi(); } @@ -199,6 +203,7 @@ export function getHeaders() { const isBaidu = modelConfig.providerName == ServiceProvider.Baidu; const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance; const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba; + const isMiniMax = modelConfig.providerName === ServiceProvider.MiniMax; const isEnabledAccessControl = accessStore.enabledAccessControl(); const apiKey = isGoogle ? accessStore.googleApiKey @@ -210,6 +215,8 @@ export function getHeaders() { ? accessStore.bytedanceApiKey : isAlibaba ? accessStore.alibabaApiKey + : isMiniMax + ? accessStore.minimaxApiKey : accessStore.openaiApiKey; return { isGoogle, @@ -267,6 +274,8 @@ export function getClientApi(provider: ServiceProvider): ClientApi { return new ClientApi(ModelProvider.Doubao); case ServiceProvider.Alibaba: return new ClientApi(ModelProvider.Qwen); + case ServiceProvider.MiniMax: + return new ClientApi(ModelProvider.MiniMax); default: return new ClientApi(ModelProvider.GPT); } diff --git a/app/client/platforms/minimax.ts b/app/client/platforms/minimax.ts new file mode 100644 index 00000000..3d77d4f0 --- /dev/null +++ b/app/client/platforms/minimax.ts @@ -0,0 +1,242 @@ +"use client"; +import { + ApiPath, + MiniMax, + MINIMAX_BASE_URL, + REQUEST_TIMEOUT_MS, +} from "@/app/constant"; +import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; + +import { + ChatOptions, + getHeaders, + LLMApi, + LLMModel, + MultimodalContent, +} from "../api"; +import Locale from "../../locales"; +import { + EventStreamContentType, + fetchEventSource, +} from "@fortaine/fetch-event-source"; +import { prettyObject } from "@/app/utils/format"; +import { getClientConfig } from "@/app/config/client"; +import { getMessageTextContent } from "@/app/utils"; + +interface RequestPayload { + messages: { + role: "system" | "user" | "assistant"; + content: string | MultimodalContent[]; + }[]; + stream?: boolean; + model: string; + temperature: number; + top_p: number; + max_tokens?: number; +} + +export class MiniMaxApi implements LLMApi { + path(path: string): string { + const accessStore = useAccessStore.getState(); + + let baseUrl = ""; + + if (accessStore.useCustomConfig) { + baseUrl = accessStore.minimaxUrl; + } + + if (baseUrl.length === 0) { + const isApp = !!getClientConfig()?.isApp; + baseUrl = isApp ? MINIMAX_BASE_URL : ApiPath.MiniMax; + } + + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.slice(0, baseUrl.length - 1); + } + if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.MiniMax)) { + baseUrl = "https://" + baseUrl; + } + + console.log("[Proxy Endpoint] ", baseUrl, path); + + return [baseUrl, path].join("/"); + } + + extractMessage(res: any) { + return res.choices?.at(0)?.message?.content ?? ""; + } + + async chat(options: ChatOptions) { + const messages = options.messages.map((v) => ({ + role: v.role, + content: getMessageTextContent(v), + })); + + const modelConfig = { + ...useAppConfig.getState().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, + ...{ + model: options.config.model, + }, + }; + + const shouldStream = !!options.config.stream; + const requestPayload: RequestPayload = { + messages, + stream: shouldStream, + model: modelConfig.model, + temperature: modelConfig.temperature, + top_p: modelConfig.top_p, + }; + + const controller = new AbortController(); + options.onController?.(controller); + + try { + const chatPath = this.path(MiniMax.ChatPath); + const chatPayload = { + method: "POST", + body: JSON.stringify(requestPayload), + signal: controller.signal, + headers: getHeaders(), + }; + + // make a fetch request + const requestTimeoutId = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS, + ); + + if (shouldStream) { + let responseText = ""; + let remainText = ""; + let finished = false; + + // animate response to make it looks smooth + function animateResponseText() { + if (finished || controller.signal.aborted) { + responseText += remainText; + console.log("[Response Animation] finished"); + if (responseText?.length === 0) { + options.onError?.(new Error("empty response from server")); + } + return; + } + + if (remainText.length > 0) { + const fetchCount = Math.max(1, Math.round(remainText.length / 60)); + const fetchText = remainText.slice(0, fetchCount); + responseText += fetchText; + remainText = remainText.slice(fetchCount); + options.onUpdate?.(responseText, fetchText); + } + + requestAnimationFrame(animateResponseText); + } + + // start animaion + animateResponseText(); + + const finish = () => { + if (!finished) { + finished = true; + options.onFinish(responseText + remainText); + } + }; + + controller.signal.onabort = finish; + + fetchEventSource(chatPath, { + ...chatPayload, + async onopen(res) { + clearTimeout(requestTimeoutId); + const contentType = res.headers.get("content-type"); + console.log( + "[MiniMax] request response content type: ", + contentType, + ); + + if (contentType?.startsWith("text/plain")) { + responseText = await res.clone().text(); + return finish(); + } + + if ( + !res.ok || + !res.headers + .get("content-type") + ?.startsWith(EventStreamContentType) || + res.status !== 200 + ) { + const responseTexts = [responseText]; + let extraInfo = await res.clone().text(); + try { + const resJson = await res.clone().json(); + extraInfo = prettyObject(resJson); + } catch {} + + if (res.status === 401) { + responseTexts.push(Locale.Error.Unauthorized); + } + + if (extraInfo) { + responseTexts.push(extraInfo); + } + + responseText = responseTexts.join("\n\n"); + + return finish(); + } + }, + onmessage(msg) { + if (msg.data === "[DONE]" || finished) { + return finish(); + } + const text = msg.data; + try { + const json = JSON.parse(text); + const choices = json.choices as Array<{ + delta: { content: string }; + }>; + const delta = choices[0]?.delta?.content; + if (delta) { + remainText += delta; + } + } catch (e) { + console.error("[Request] parse error", text, msg); + } + }, + onclose() { + finish(); + }, + onerror(e) { + options.onError?.(e); + throw e; + }, + openWhenHidden: true, + }); + } else { + const res = await fetch(chatPath, chatPayload); + clearTimeout(requestTimeoutId); + + const resJson = await res.json(); + const message = this.extractMessage(resJson); + options.onFinish(message); + } + } catch (e) { + console.log("[Request] failed to make a chat request", e); + options.onError?.(e as Error); + } + } + async usage() { + return { + used: 0, + total: 0, + }; + } + + async models(): Promise { + return []; + } +} +export { MiniMax }; diff --git a/app/components/settings.tsx b/app/components/settings.tsx index 86c2a6ee..421c78cd 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -56,6 +56,7 @@ import { Baidu, ByteDance, Alibaba, + MiniMax, Google, GoogleSafetySettingsThreshold, OPENAI_BASE_URL, @@ -1043,6 +1044,45 @@ export function Settings() { ); + const minimaxConfigComponent = accessStore.provider === + ServiceProvider.MiniMax && ( + <> + + + accessStore.update( + (access) => (access.minimaxUrl = e.currentTarget.value), + ) + } + > + + + { + accessStore.update( + (access) => (access.minimaxApiKey = e.currentTarget.value), + ); + }} + /> + + + ); + const stabilityConfigComponent = accessStore.provider === ServiceProvider.Stability && ( <> @@ -1404,6 +1444,7 @@ export function Settings() { {baiduConfigComponent} {byteDanceConfigComponent} {alibabaConfigComponent} + {minimaxConfigComponent} {stabilityConfigComponent} {mjpConfigComponent} diff --git a/app/config/server.ts b/app/config/server.ts index e5c2c1f7..6423c760 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -57,6 +57,10 @@ declare global { ALIBABA_URL?: string; ALIBABA_API_KEY?: string; + // minimax only + MINIMAX_URL?: string; + MINIMAX_API_KEY?: string; + // custom template for preprocessing user input DEFAULT_INPUT_TEMPLATE?: string; } @@ -121,6 +125,7 @@ export const getServerSideConfig = () => { const isBaidu = !!process.env.BAIDU_API_KEY; const isBytedance = !!process.env.BYTEDANCE_API_KEY; const isAlibaba = !!process.env.ALIBABA_API_KEY; + const isMiniMax = !!process.env.MINIMAX_API_KEY; // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); // const randomIndex = Math.floor(Math.random() * apiKeys.length); @@ -173,6 +178,10 @@ export const getServerSideConfig = () => { alibabaUrl: process.env.ALIBABA_URL, alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY), + isMiniMax, + minimaxUrl: process.env.MINIMAX_URL, + minimaxApiKey: getApiKey(process.env.MINIMAX_API_KEY), + cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID, cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID, cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY), diff --git a/app/constant.ts b/app/constant.ts index 49a27c2d..057a72f8 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -23,6 +23,8 @@ export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com"; export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/"; +export const MINIMAX_BASE_URL = "https://api.minimax.io"; + export const CACHE_URL_PREFIX = "/api/cache"; export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`; @@ -50,6 +52,7 @@ export enum ApiPath { ByteDance = "/api/bytedance", Alibaba = "/api/alibaba", Stability = "/api/stability", + MiniMax = "/api/minimax", Artifacts = "/api/artifacts", Mj = "/api/mj", } @@ -104,6 +107,7 @@ export enum ServiceProvider { Baidu = "Baidu", ByteDance = "ByteDance", Alibaba = "Alibaba", + MiniMax = "MiniMax", Stability = "Stability", Midjourney = "Midjourney", } @@ -125,6 +129,7 @@ export enum ModelProvider { Ernie = "Ernie", Doubao = "Doubao", Qwen = "Qwen", + MiniMax = "MiniMax", Mj = "mj", } @@ -200,6 +205,11 @@ export const Alibaba = { ChatPath: "v1/services/aigc/text-generation/generation", }; +export const MiniMax = { + ExampleEndpoint: "https://api.minimax.io", + ChatPath: "v1/chat/completions", +}; + export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang // export const DEFAULT_SYSTEM_TEMPLATE = ` // You are ChatGPT, a large language model trained by {{ServiceProvider}}. @@ -296,6 +306,12 @@ const bytedanceModels = [ "Doubao-pro-128k", ]; +const minimaxModels = [ + "MiniMax-M2.7", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", +]; + const alibabaModes = [ "qwen-turbo", "qwen-plus", @@ -370,6 +386,15 @@ export const DEFAULT_MODELS = [ providerType: "alibaba", }, })), + ...minimaxModels.map((name) => ({ + name, + available: true, + provider: { + id: "minimax", + providerName: "MiniMax", + providerType: "minimax", + }, + })), ] as const; export const CHAT_PAGE_SIZE = 15; diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 27cde2a7..8c35a7a4 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -393,6 +393,17 @@ const cn = { SubTitle: "样例:", }, }, + MiniMax: { + ApiKey: { + Title: "接口密钥", + SubTitle: "使用自定义 MiniMax API Key", + Placeholder: "MiniMax API Key", + }, + Endpoint: { + Title: "接口地址", + SubTitle: "样例:", + }, + }, Stability: { ApiKey: { Title: "接口密钥", diff --git a/app/locales/en.ts b/app/locales/en.ts index da6e5530..63c726b2 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -376,6 +376,17 @@ const en: LocaleType = { SubTitle: "Example: ", }, }, + MiniMax: { + ApiKey: { + Title: "MiniMax API Key", + SubTitle: "Use a custom MiniMax API Key", + Placeholder: "MiniMax API Key", + }, + Endpoint: { + Title: "Endpoint Address", + SubTitle: "Example: ", + }, + }, Stability: { ApiKey: { Title: "Stability API Key", diff --git a/app/store/access.ts b/app/store/access.ts index d463af92..a6819eff 100644 --- a/app/store/access.ts +++ b/app/store/access.ts @@ -39,6 +39,10 @@ const DEFAULT_ALIBABA_URL = isApp ? DEFAULT_API_HOST + "/api/proxy/alibaba" : ApiPath.Alibaba; +const DEFAULT_MINIMAX_URL = isApp + ? DEFAULT_API_HOST + "/api/proxy/minimax" + : ApiPath.MiniMax; + const DEFAULT_STABILITY_URL = isApp ? DEFAULT_API_HOST + "/api/proxy/stability" : ApiPath.Stability; @@ -84,6 +88,10 @@ const DEFAULT_ACCESS_STATE = { alibabaUrl: DEFAULT_ALIBABA_URL, alibabaApiKey: "", + // minimax + minimaxUrl: DEFAULT_MINIMAX_URL, + minimaxApiKey: "", + //stability stabilityUrl: DEFAULT_STABILITY_URL, stabilityApiKey: "", @@ -140,6 +148,10 @@ export const useAccessStore = createPersistStore( return ensure(get(), ["alibabaApiKey"]); }, + isValidMiniMax() { + return ensure(get(), ["minimaxApiKey"]); + }, + isAuthorized() { this.fetch(); @@ -152,6 +164,7 @@ export const useAccessStore = createPersistStore( this.isValidBaidu() || this.isValidByteDance() || this.isValidAlibaba() || + this.isValidMiniMax() || !this.enabledAccessControl() || (this.enabledAccessControl() && ensure(get(), ["accessCode"])) ); diff --git a/tests/minimax-integration.test.ts b/tests/minimax-integration.test.ts new file mode 100644 index 00000000..8c6975cf --- /dev/null +++ b/tests/minimax-integration.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock browser APIs used by the client platforms +vi.mock("@fortaine/fetch-event-source", () => ({ + EventStreamContentType: "text/event-stream", + fetchEventSource: vi.fn(), +})); + +// Mock client config +vi.mock("@/app/config/client", () => ({ + getClientConfig: () => ({ isApp: false, buildMode: "standalone" }), +})); + +// Mock stores +const mockAccessStore = { + useCustomConfig: false, + minimaxUrl: "", + minimaxApiKey: "test-minimax-key", + openaiApiKey: "", + azureApiKey: "", + googleApiKey: "", + anthropicApiKey: "", + bytedanceApiKey: "", + alibabaApiKey: "", + accessCode: "", + enabledAccessControl: () => false, +}; + +const mockAppConfig = { + modelConfig: { + model: "MiniMax-M2.7", + temperature: 0.7, + top_p: 0.9, + presence_penalty: 0, + frequency_penalty: 0, + max_tokens: 4096, + }, +}; + +const mockChatStore = { + currentSession: () => ({ + mask: { + modelConfig: { + model: "MiniMax-M2.7", + providerName: "MiniMax", + temperature: 0.7, + top_p: 0.9, + }, + }, + }), +}; + +vi.mock("@/app/store", () => ({ + useAccessStore: { + getState: () => mockAccessStore, + }, + useAppConfig: { + getState: () => mockAppConfig, + }, + useChatStore: { + getState: () => mockChatStore, + }, +})); + +vi.mock("@/app/utils", () => ({ + getMessageTextContent: (msg: any) => + typeof msg.content === "string" ? msg.content : "", +})); + +vi.mock("@/app/utils/format", () => ({ + prettyObject: (obj: any) => JSON.stringify(obj), +})); + +vi.mock("@/app/locales", () => ({ + default: { + Error: { + Unauthorized: "Unauthorized", + }, + }, +})); + +import { MiniMaxApi } from "@/app/client/platforms/minimax"; +import { ApiPath } from "@/app/constant"; + +describe("MiniMaxApi Client", () => { + let api: MiniMaxApi; + + beforeEach(() => { + api = new MiniMaxApi(); + }); + + describe("path()", () => { + it("should construct correct API path in web mode", () => { + const path = api.path("v1/chat/completions"); + expect(path).toBe(`${ApiPath.MiniMax}/v1/chat/completions`); + }); + + it("should use custom URL when useCustomConfig is true", () => { + mockAccessStore.useCustomConfig = true; + mockAccessStore.minimaxUrl = "https://custom-minimax.example.com"; + + const path = api.path("v1/chat/completions"); + expect(path).toBe( + "https://custom-minimax.example.com/v1/chat/completions", + ); + + // Reset + mockAccessStore.useCustomConfig = false; + mockAccessStore.minimaxUrl = ""; + }); + + it("should strip trailing slash from base URL", () => { + mockAccessStore.useCustomConfig = true; + mockAccessStore.minimaxUrl = "https://custom-minimax.example.com/"; + + const path = api.path("v1/chat/completions"); + expect(path).toBe( + "https://custom-minimax.example.com/v1/chat/completions", + ); + + mockAccessStore.useCustomConfig = false; + mockAccessStore.minimaxUrl = ""; + }); + + it("should prepend https:// when missing", () => { + mockAccessStore.useCustomConfig = true; + mockAccessStore.minimaxUrl = "custom-minimax.example.com"; + + const path = api.path("v1/chat/completions"); + expect(path).toBe( + "https://custom-minimax.example.com/v1/chat/completions", + ); + + mockAccessStore.useCustomConfig = false; + mockAccessStore.minimaxUrl = ""; + }); + }); + + describe("extractMessage()", () => { + it("should extract message from OpenAI-compatible response", () => { + const res = { + choices: [ + { + message: { + content: "Hello from MiniMax!", + }, + }, + ], + }; + expect(api.extractMessage(res)).toBe("Hello from MiniMax!"); + }); + + it("should return empty string for empty response", () => { + expect(api.extractMessage({})).toBe(""); + }); + + it("should return empty string for empty choices", () => { + expect(api.extractMessage({ choices: [] })).toBe(""); + }); + + it("should handle null content gracefully", () => { + const res = { + choices: [{ message: {} }], + }; + expect(api.extractMessage(res)).toBe(""); + }); + }); + + describe("usage()", () => { + it("should return zero usage", async () => { + const usage = await api.usage(); + expect(usage).toEqual({ used: 0, total: 0 }); + }); + }); + + describe("models()", () => { + it("should return empty array", async () => { + const models = await api.models(); + expect(models).toEqual([]); + }); + }); +}); + +describe("MiniMax Server Config Integration", () => { + it("should have MINIMAX_API_KEY env var declaration in server config type", async () => { + // Verify the server config module exports correctly + const serverModule = await import("@/app/config/server"); + expect(serverModule.getServerSideConfig).toBeDefined(); + }); +}); + +describe("MiniMax Provider Dispatch", () => { + it("should create MiniMaxApi via ClientApi factory", async () => { + const { ClientApi } = await import("@/app/client/api"); + const { ModelProvider } = await import("@/app/constant"); + const client = new ClientApi(ModelProvider.MiniMax); + expect(client.llm).toBeInstanceOf(MiniMaxApi); + }); + + it("should dispatch MiniMax ServiceProvider to MiniMax ModelProvider", async () => { + const { getClientApi } = await import("@/app/client/api"); + const { ServiceProvider, ModelProvider } = await import("@/app/constant"); + const client = getClientApi(ServiceProvider.MiniMax); + expect(client.llm).toBeInstanceOf(MiniMaxApi); + }); +}); diff --git a/tests/minimax-unit.test.ts b/tests/minimax-unit.test.ts new file mode 100644 index 00000000..907ab332 --- /dev/null +++ b/tests/minimax-unit.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { + ServiceProvider, + ModelProvider, + ApiPath, + DEFAULT_MODELS, + MINIMAX_BASE_URL, + MiniMax, +} from "@/app/constant"; + +describe("MiniMax Constants", () => { + it("should define MiniMax in ServiceProvider enum", () => { + expect(ServiceProvider.MiniMax).toBe("MiniMax"); + }); + + it("should define MiniMax in ModelProvider enum", () => { + expect(ModelProvider.MiniMax).toBe("MiniMax"); + }); + + it("should define MiniMax API path", () => { + expect(ApiPath.MiniMax).toBe("/api/minimax"); + }); + + it("should define MiniMax base URL", () => { + expect(MINIMAX_BASE_URL).toBe("https://api.minimax.io"); + }); + + it("should define MiniMax chat path", () => { + expect(MiniMax.ChatPath).toBe("v1/chat/completions"); + }); + + it("should define MiniMax example endpoint", () => { + expect(MiniMax.ExampleEndpoint).toBe("https://api.minimax.io"); + }); +}); + +describe("MiniMax Models", () => { + const minimaxModels = DEFAULT_MODELS.filter( + (m) => m.provider.providerName === "MiniMax", + ); + + it("should include MiniMax models in DEFAULT_MODELS", () => { + expect(minimaxModels.length).toBeGreaterThanOrEqual(3); + }); + + it("should include MiniMax-M2.7 model", () => { + const m27 = minimaxModels.find((m) => m.name === "MiniMax-M2.7"); + expect(m27).toBeDefined(); + expect(m27!.available).toBe(true); + expect(m27!.provider.id).toBe("minimax"); + expect(m27!.provider.providerType).toBe("minimax"); + }); + + it("should include MiniMax-M2.5 model", () => { + const m25 = minimaxModels.find((m) => m.name === "MiniMax-M2.5"); + expect(m25).toBeDefined(); + expect(m25!.available).toBe(true); + }); + + it("should include MiniMax-M2.5-highspeed model", () => { + const m25hs = minimaxModels.find( + (m) => m.name === "MiniMax-M2.5-highspeed", + ); + expect(m25hs).toBeDefined(); + expect(m25hs!.available).toBe(true); + }); + + it("should have correct provider metadata for all MiniMax models", () => { + for (const model of minimaxModels) { + expect(model.provider.id).toBe("minimax"); + expect(model.provider.providerName).toBe("MiniMax"); + expect(model.provider.providerType).toBe("minimax"); + } + }); + + it("should not affect other providers model count", () => { + const openaiModels = DEFAULT_MODELS.filter( + (m) => m.provider.providerName === "OpenAI", + ); + expect(openaiModels.length).toBeGreaterThan(0); + + const googleModels = DEFAULT_MODELS.filter( + (m) => m.provider.providerName === "Google", + ); + expect(googleModels.length).toBeGreaterThan(0); + }); +}); + +describe("MiniMax Provider Enumeration", () => { + it("should be selectable in ServiceProvider entries", () => { + const entries = Object.entries(ServiceProvider); + const minimax = entries.find(([k, v]) => k === "MiniMax"); + expect(minimax).toBeDefined(); + expect(minimax![1]).toBe("MiniMax"); + }); + + it("should have all required enum values for provider system", () => { + // Verify MiniMax doesn't break the enum ordering + const providers = Object.values(ServiceProvider); + expect(providers).toContain("MiniMax"); + expect(providers).toContain("OpenAI"); + expect(providers).toContain("Azure"); + expect(providers).toContain("Google"); + expect(providers).toContain("Anthropic"); + }); +}); + +describe("MiniMax API Configuration", () => { + it("should use OpenAI-compatible chat completions path", () => { + expect(MiniMax.ChatPath).toBe("v1/chat/completions"); + }); + + it("should have base URL pointing to api.minimax.io", () => { + expect(MINIMAX_BASE_URL).toMatch(/^https:\/\/api\.minimax\.io$/); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..ed2a7f1c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, + resolve: { + alias: { + "@/app": path.resolve(__dirname, "./app"), + }, + }, +});