fix(providers): add MiniMax image-generation provider (port from 9router#2482)

MiniMax already had entries in the music/audio/video registries, but no entry at all in imageRegistry.ts and no dedicated provider handler under open-sse/handlers/imageGeneration/providers/. A MiniMax image-model request therefore fell through the format dispatch in imageGeneration.ts to a 404/unmatched-format response instead of reaching MiniMax's synchronous image_generation endpoint.

Registers a minimax image provider (format: minimax-image, models image-01/image-01-live) and a new handleMinimaxImageGeneration handler that POSTs to https://api.minimax.io/v1/image_generation and normalizes data.image_urls into the OpenAI-compatible images payload.

Reported-by: felipeleite (https://github.com/decolua/9router/issues/2482)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-13 23:15:39 -03:00
parent 2c62333b0b
commit 3813e40225
5 changed files with 268 additions and 0 deletions

View File

@@ -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)

View File

@@ -362,6 +362,21 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
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",

View File

@@ -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 });
}

View File

@@ -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)}`,
};
}
}

View File

@@ -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;
}
});