diff --git a/changelog.d/fixes/2482-minimax-image-provider.md b/changelog.d/fixes/2482-minimax-image-provider.md new file mode 100644 index 0000000000..d6fcbbbc4b --- /dev/null +++ b/changelog.d/fixes/2482-minimax-image-provider.md @@ -0,0 +1 @@ +- **fix(providers):** MiniMax Text-to-Image now works — a `minimax` image-generation provider (`minimax-image` format, `image-01`/`image-01-live` models) was registered, since MiniMax previously had entries in the music/audio/video registries but none in the image registry, so any MiniMax image-model request fell through to a 404/unmatched-format response. (thanks @felipeleite) diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 41850fe2cf..cb28c09c34 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -362,6 +362,21 @@ export const IMAGE_PROVIDERS: Record = { models: [{ id: "gen2", name: "Gen 2 Image" }], supportedSizes: ["16:9", "9:16", "1:1", "4:3", "3:4"], }, + // #2482: MiniMax already has entries in musicRegistry/audioRegistry/videoRegistry, + // but was missing an image provider entirely, so MiniMax image-model requests + // fell through the format dispatch below to a 400/unmatched-format response. + minimax: { + id: "minimax", + baseUrl: "https://api.minimax.io/v1/image_generation", + authType: "apikey", + authHeader: "bearer", + format: "minimax-image", + models: [ + { id: "image-01", name: "MiniMax Image-01" }, + { id: "image-01-live", name: "MiniMax Image-01 Live" }, + ], + supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "1024x1024"], + }, leonardo: { id: "leonardo", baseUrl: "https://cloud.leonardo.ai/api/rest/v1/generations", diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 2bfc452a0a..e7474cbfd8 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -62,6 +62,7 @@ import { CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; +import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; interface KieImageOptions { @@ -535,6 +536,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "minimax-image") { + return handleMinimaxImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log }); } diff --git a/open-sse/handlers/imageGeneration/providers/minimax.ts b/open-sse/handlers/imageGeneration/providers/minimax.ts new file mode 100644 index 0000000000..a95655f9c9 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/minimax.ts @@ -0,0 +1,138 @@ +// #2482: MiniMax Text-to-Image provider handler. +// MiniMax's image_generation endpoint is synchronous (unlike its video/music +// endpoints, which are task-based and polled) and returns image URLs directly +// in `data.image_urls`. This normalizes that response into the OpenAI-compatible +// images payload the rest of the handler expects. + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +interface MinimaxImageGenArgs { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: { prompt?: string; size?: string; n?: number; response_format?: string }; + credentials: { apiKey?: string; accessToken?: string }; + log?: { + info?: (tag: string, msg: string) => void; + error?: (tag: string, msg: string) => void; + } | null; +} + +const MINIMAX_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]); + +function mapMinimaxAspectRatio(size?: string): string { + if (size && MINIMAX_ASPECT_RATIOS.has(size)) return size; + return "1:1"; +} + +export async function handleMinimaxImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: MinimaxImageGenArgs) { + const startTime = Date.now(); + const token = credentials?.apiKey || credentials?.accessToken || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const aspectRatio = mapMinimaxAspectRatio(body.size); + + const upstreamBody = { + model: model || "image-01", + prompt, + aspect_ratio: aspectRatio, + n: body.n ?? 1, + response_format: "url", + }; + + if (log) { + log.info?.( + "IMAGE", + `${provider}/${model} (minimax-image) | prompt: "${prompt.slice(0, 60)}..." | aspect_ratio: ${aspectRatio}` + ); + } + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) log.error?.("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + requestBody: upstreamBody, + }).catch(() => {}); + + return { success: false, status: response.status, error: errorText }; + } + + const data = await response.json(); + const imageUrls: unknown[] = Array.isArray(data?.data?.image_urls) ? data.data.image_urls : []; + + if (imageUrls.length === 0) { + const errorMsg = data?.base_resp?.status_msg || "No images returned from MiniMax"; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorMsg, + }).catch(() => {}); + return { success: false, status: 502, error: errorMsg }; + } + + const images = imageUrls.map((url) => ({ url, revised_prompt: prompt })); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + if (log) log.error?.("IMAGE", `${provider} fetch error: ${errMsg}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errMsg, + }).catch(() => {}); + + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage(errMsg)}`, + }; + } +} diff --git a/tests/unit/minimax-image-provider-2482.test.ts b/tests/unit/minimax-image-provider-2482.test.ts new file mode 100644 index 0000000000..3d18946899 --- /dev/null +++ b/tests/unit/minimax-image-provider-2482.test.ts @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// 9router#2482: MiniMax Text-to-Image returns "404 page not found". +// MiniMax already has entries in musicRegistry.ts/audioRegistry.ts/videoRegistry.ts, +// but no entry at all in imageRegistry.ts (nor a dedicated provider handler under +// open-sse/handlers/imageGeneration/providers/), so a MiniMax image-model request +// falls through the format dispatch in imageGeneration.ts to a 400/unmatched-format +// path instead of reaching MiniMax's image_generation endpoint. +// +// handleImageGeneration is imported statically (not dynamically inside a test) so +// its transitive imports (e.g. the proxy-aware fetch dispatcher) finish installing +// their own globalThis.fetch wrapper before any test reassigns it for mocking — +// a dynamic import after the mock assignment would let that wrapper silently +// clobber the test's mock and hit the real network. +const { getImageProvider } = await import("../../open-sse/config/imageRegistry.ts"); +const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); + +test("MiniMax is registered as an image provider with a dedicated minimax-image format", () => { + const cfg = getImageProvider("minimax"); + assert.ok(cfg, "expected an IMAGE_PROVIDERS entry for minimax"); + assert.equal(cfg.id, "minimax"); + assert.equal( + cfg.format, + "minimax-image", + "MiniMax image_generation is not OpenAI-compatible, must use its own format" + ); + assert.equal(cfg.authType, "apikey"); + assert.equal(cfg.authHeader, "bearer"); + assert.match( + cfg.baseUrl, + /api\.minimax\.io\/v1\/image_generation$/, + "image baseUrl must target MiniMax's image_generation endpoint" + ); +}); + +test("MiniMax image provider exposes at least one text-to-image model", () => { + const cfg = getImageProvider("minimax"); + const ids = (cfg?.models || []).map((m) => m.id); + assert.ok(ids.length > 0, `expected at least one MiniMax image model, got: ${ids.join(", ")}`); + assert.ok( + Array.isArray(cfg?.supportedSizes) && cfg.supportedSizes.length > 0, + "image provider must declare at least one supported size" + ); +}); + +test("handleImageGeneration dispatches minimax-image format to the MiniMax handler and normalizes the response", async () => { + const originalFetch = globalThis.fetch; + try { + let fetchCalled = false; + globalThis.fetch = (async (url: string) => { + fetchCalled = true; + assert.match(String(url), /api\.minimax\.io\/v1\/image_generation$/); + return { + ok: true, + status: 200, + json: async () => ({ + id: "abc123", + data: { image_urls: ["https://cdn.minimax.io/generated/one.png"] }, + base_resp: { status_code: 0, status_msg: "success" }, + }), + } as unknown as Response; + }) as typeof fetch; + + const result = await handleImageGeneration({ + body: { model: "minimax/image-01", prompt: "a red panda in the snow", n: 1 }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(fetchCalled, true, "expected the MiniMax handler to call fetch"); + assert.equal(result.success, true, `expected success, got: ${JSON.stringify(result)}`); + assert.ok(Array.isArray(result.data?.data) && result.data.data.length === 1); + assert.equal(result.data.data[0].url, "https://cdn.minimax.io/generated/one.png"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration surfaces MiniMax upstream errors without a network 404", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => { + return { + ok: false, + status: 401, + text: async () => "login fail: invalid API key", + } as unknown as Response; + }) as typeof fetch; + + const result = await handleImageGeneration({ + body: { model: "minimax/image-01", prompt: "a red panda in the snow", n: 1 }, + credentials: { apiKey: "bad-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 401); + } finally { + globalThis.fetch = originalFetch; + } +});