fix(providers): add MiniMax image-generation provider (#7108)

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

* refactor(providers): split KIE image catalog out of imageRegistry to respect file-size cap

imageRegistry.ts hit 805 lines after adding the MiniMax image provider (cap is
800). Extract the KIE image-model catalog (largest single provider entry,
~35 models) into its own semantic-family module,
providers/registry/kie/imageModels.ts, following the same pattern already
used for LMARENA_DIRECT_IMAGE_MODELS. imageRegistry.ts now imports
KIE_IMAGE_MODELS instead of inlining the list.

Also update minimax-media-servicekinds.test.ts: getRegistryMediaKinds derives
membership by design from every registry in MEDIA_KIND_REGISTRIES, including
IMAGE_PROVIDERS. Now that minimax is a key in IMAGE_PROVIDERS, it correctly
gains the "image" kind alongside tts/video/music — the same behavior already
asserted for openai in this file. The exact-match assertion is updated to
["image","music","tts","video"]; the other assertions (which only check
.includes for tts/video/music/llm) were already correct and untouched.

* fix(providers): extract minimax image-gen helpers to fix complexity ratchet

check:complexity-ratchets regressed 2056 -> 2058 (handleMinimaxImageGeneration:
complexity 25, max-lines-per-function 97). Split logging, upstream-error,
no-images, success and fetch-error branches into small named helpers so the
handler stays within the cyclomatic-complexity (15) and max-lines-per-function
(80) ratchets. No behavior change; existing minimax-image-provider-2482 and
minimax-media-servicekinds unit tests still pass.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 10:39:25 -03:00
committed by GitHub
parent b914eb1b0f
commit f9e95a12db
7 changed files with 381 additions and 42 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

@@ -6,6 +6,7 @@
*/
import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts";
import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts";
interface ImageModelEntry {
id: string;
@@ -311,44 +312,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
authType: "apikey",
authHeader: "bearer",
format: "kie-image",
models: [
{ id: "gpt4o-image", name: "KIE 4o Image" },
{ id: "seedream/4.5-text-to-image", name: "Seedream 4.5", isMarket: true },
{ id: "seedream/4.5-edit", name: "Seedream 4.5 Edit", isMarket: true },
{ id: "seedream/5.0-lite-text-to-image", name: "Seedream 5.0 Lite", isMarket: true },
{ id: "seedream/5.0-lite-image-to-image", name: "Seedream 5.0 Lite I2I", isMarket: true },
{ id: "z-image/4.0-text-to-image", name: "Z-Image v4.0", isMarket: true },
{ id: "z-image/4.5-text-to-image", name: "Z-Image v4.5", isMarket: true },
{ id: "google-imagen/imagen4-fast", name: "Imagen 4 Fast", isMarket: true },
{ id: "google-imagen/imagen4-ultra", name: "Imagen 4 Ultra", isMarket: true },
{ id: "google-imagen/imagen4", name: "Imagen 4", isMarket: true },
{ id: "google-imagen/nano-banana-2", name: "Nano Banana 2", isMarket: true },
{ id: "google-imagen/nano-banana", name: "Nano Banana", isMarket: true },
{ id: "google-imagen/nano-banana-pro", name: "Nano Banana Pro", isMarket: true },
{ id: "google-imagen/nano-banana-edit", name: "Nano Banana Edit", isMarket: true },
{ id: "flux/2-pro-image-to-image", name: "Flux 2 Pro I2I", isMarket: true },
{ id: "flux/2-pro-text-to-image", name: "Flux 2 Pro T2I", isMarket: true },
{ id: "flux/2-image-to-image", name: "Flux 2 I2I", isMarket: true },
{ id: "flux/2-text-to-image", name: "Flux 2 T2I", isMarket: true },
{ id: "flux/kontext", name: "Flux Kontext", isMarket: true },
{ id: "grok-imagine/text-to-image", name: "Grok Imagine T2I", isMarket: true },
{ id: "grok-imagine/image-to-image", name: "Grok Imagine I2I", isMarket: true },
{ id: "gpt/gpt-image-1.5-text-to-image", name: "GPT Image 1.5 T2I", isMarket: true },
{ id: "gpt/gpt-image-1.5-image-to-image", name: "GPT Image 1.5 I2I", isMarket: true },
{ id: "gpt/gpt-image-2-text-to-image", name: "GPT Image 2 T2I", isMarket: true },
{ id: "gpt/gpt-image-2-image-to-image", name: "GPT Image 2 I2I", isMarket: true },
{ id: "ideogram/v3-text-to-image", name: "Ideogram v3", isMarket: true },
{ id: "ideogram/v3-edit", name: "Ideogram v3 Edit", isMarket: true },
{ id: "ideogram/v3-remix", name: "Ideogram v3 Remix", isMarket: true },
{ id: "ideogram/v3-reframe", name: "Ideogram v3 Reframe", isMarket: true },
{ id: "qwen/text-to-image", name: "Qwen T2I", isMarket: true },
{ id: "qwen/image-to-image", name: "Qwen I2I", isMarket: true },
{ id: "qwen/image-edit", name: "Qwen Edit", isMarket: true },
{ id: "qwen2/image-edit", name: "Qwen2 Edit", isMarket: true },
{ id: "qwen2/text-to-image", name: "Qwen2 T2I", isMarket: true },
{ id: "wan/2.7-image", name: "Wan 2.7 Image", isMarket: true },
{ id: "wan/2.7-image-pro", name: "Wan 2.7 Image Pro", isMarket: true },
],
models: KIE_IMAGE_MODELS,
supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4"],
},
@@ -362,6 +326,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

@@ -0,0 +1,55 @@
/**
* KIE image-generation model catalog.
*
* Extracted out of imageRegistry.ts (which hit the 800-line file-size cap) so the
* catalog lives in its own semantic family module, following the same pattern as
* `providers/registry/lmarena/directModels.ts`. KIE aggregates many third-party
* image models (Seedream, Z-Image, Imagen, Flux, Grok Imagine, GPT Image, Ideogram,
* Qwen, Wan) behind a single `kie-image` format/handler — see `imageRegistry.ts`'s
* `kie` entry for baseUrl/auth/format wiring.
*/
export interface KieImageModelEntry {
id: string;
name: string;
isMarket?: boolean;
}
export const KIE_IMAGE_MODELS: KieImageModelEntry[] = [
{ id: "gpt4o-image", name: "KIE 4o Image" },
{ id: "seedream/4.5-text-to-image", name: "Seedream 4.5", isMarket: true },
{ id: "seedream/4.5-edit", name: "Seedream 4.5 Edit", isMarket: true },
{ id: "seedream/5.0-lite-text-to-image", name: "Seedream 5.0 Lite", isMarket: true },
{ id: "seedream/5.0-lite-image-to-image", name: "Seedream 5.0 Lite I2I", isMarket: true },
{ id: "z-image/4.0-text-to-image", name: "Z-Image v4.0", isMarket: true },
{ id: "z-image/4.5-text-to-image", name: "Z-Image v4.5", isMarket: true },
{ id: "google-imagen/imagen4-fast", name: "Imagen 4 Fast", isMarket: true },
{ id: "google-imagen/imagen4-ultra", name: "Imagen 4 Ultra", isMarket: true },
{ id: "google-imagen/imagen4", name: "Imagen 4", isMarket: true },
{ id: "google-imagen/nano-banana-2", name: "Nano Banana 2", isMarket: true },
{ id: "google-imagen/nano-banana", name: "Nano Banana", isMarket: true },
{ id: "google-imagen/nano-banana-pro", name: "Nano Banana Pro", isMarket: true },
{ id: "google-imagen/nano-banana-edit", name: "Nano Banana Edit", isMarket: true },
{ id: "flux/2-pro-image-to-image", name: "Flux 2 Pro I2I", isMarket: true },
{ id: "flux/2-pro-text-to-image", name: "Flux 2 Pro T2I", isMarket: true },
{ id: "flux/2-image-to-image", name: "Flux 2 I2I", isMarket: true },
{ id: "flux/2-text-to-image", name: "Flux 2 T2I", isMarket: true },
{ id: "flux/kontext", name: "Flux Kontext", isMarket: true },
{ id: "grok-imagine/text-to-image", name: "Grok Imagine T2I", isMarket: true },
{ id: "grok-imagine/image-to-image", name: "Grok Imagine I2I", isMarket: true },
{ id: "gpt/gpt-image-1.5-text-to-image", name: "GPT Image 1.5 T2I", isMarket: true },
{ id: "gpt/gpt-image-1.5-image-to-image", name: "GPT Image 1.5 I2I", isMarket: true },
{ id: "gpt/gpt-image-2-text-to-image", name: "GPT Image 2 T2I", isMarket: true },
{ id: "gpt/gpt-image-2-image-to-image", name: "GPT Image 2 I2I", isMarket: true },
{ id: "ideogram/v3-text-to-image", name: "Ideogram v3", isMarket: true },
{ id: "ideogram/v3-edit", name: "Ideogram v3 Edit", isMarket: true },
{ id: "ideogram/v3-remix", name: "Ideogram v3 Remix", isMarket: true },
{ id: "ideogram/v3-reframe", name: "Ideogram v3 Reframe", isMarket: true },
{ id: "qwen/text-to-image", name: "Qwen T2I", isMarket: true },
{ id: "qwen/image-to-image", name: "Qwen I2I", isMarket: true },
{ id: "qwen/image-edit", name: "Qwen Edit", isMarket: true },
{ id: "qwen2/image-edit", name: "Qwen2 Edit", isMarket: true },
{ id: "qwen2/text-to-image", name: "Qwen2 T2I", isMarket: true },
{ id: "wan/2.7-image", name: "Wan 2.7 Image", isMarket: true },
{ id: "wan/2.7-image-pro", name: "Wan 2.7 Image Pro", isMarket: true },
];

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,190 @@
// #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;
}
interface MinimaxCallLogParams {
status: number;
model: string;
provider: string;
duration: number;
error?: string;
requestBody?: unknown;
responseBody?: unknown;
}
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";
}
/** Fire-and-forget usage log for a MiniMax image-generation call. */
function logMinimaxCall(params: MinimaxCallLogParams): void {
saveCallLog({
method: "POST",
path: "/v1/images/generations",
...params,
}).catch(() => {});
}
/** Builds the upstream MiniMax request body from the OpenAI-shaped input body. */
function buildMinimaxUpstreamBody(model: string, prompt: string, body: MinimaxImageGenArgs["body"]) {
return {
model: model || "image-01",
prompt,
aspect_ratio: mapMinimaxAspectRatio(body.size),
n: body.n ?? 1,
response_format: "url",
};
}
/** Handles a non-2xx MiniMax response: logs, records the call, and shapes the error result. */
async function handleMinimaxUpstreamError(
response: Response,
ctx: { provider: string; model: string; startTime: number; upstreamBody: unknown; log?: MinimaxImageGenArgs["log"] }
) {
const errorText = await response.text();
ctx.log?.error?.("IMAGE", `${ctx.provider} error ${response.status}: ${errorText.slice(0, 200)}`);
logMinimaxCall({
status: response.status,
model: `${ctx.provider}/${ctx.model}`,
provider: ctx.provider,
duration: Date.now() - ctx.startTime,
error: errorText.slice(0, 500),
requestBody: ctx.upstreamBody,
});
return { success: false as const, status: response.status, error: errorText };
}
/** Extracts and validates the `image_urls` array from a MiniMax response payload. */
function extractMinimaxImageUrls(data: unknown): unknown[] {
const record = data as { data?: { image_urls?: unknown } } | undefined;
return Array.isArray(record?.data?.image_urls) ? (record?.data?.image_urls as unknown[]) : [];
}
interface MinimaxResultCtx {
provider: string;
model: string;
startTime: number;
}
/** MiniMax returned 2xx but no images — logs and shapes the empty-result error. */
function buildMinimaxNoImagesResult(data: unknown, ctx: MinimaxResultCtx) {
const record = data as { base_resp?: { status_msg?: string } } | undefined;
const errorMsg = record?.base_resp?.status_msg || "No images returned from MiniMax";
logMinimaxCall({
status: 502,
model: `${ctx.provider}/${ctx.model}`,
provider: ctx.provider,
duration: Date.now() - ctx.startTime,
error: errorMsg,
});
return { success: false as const, status: 502, error: errorMsg };
}
/** MiniMax returned images — logs and shapes the OpenAI-compatible success result. */
function buildMinimaxSuccessResult(imageUrls: unknown[], prompt: string, ctx: MinimaxResultCtx) {
const images = imageUrls.map((url) => ({ url, revised_prompt: prompt }));
logMinimaxCall({
status: 200,
model: `${ctx.provider}/${ctx.model}`,
provider: ctx.provider,
duration: Date.now() - ctx.startTime,
responseBody: { images_count: images.length },
});
return {
success: true as const,
data: { created: Math.floor(Date.now() / 1000), data: images },
};
}
/** Network/parse failure reaching MiniMax — logs and shapes the sanitized error result. */
function buildMinimaxFetchErrorResult(
err: unknown,
ctx: MinimaxResultCtx & { log?: MinimaxImageGenArgs["log"] }
) {
const errMsg = err instanceof Error ? err.message : String(err);
ctx.log?.error?.("IMAGE", `${ctx.provider} fetch error: ${errMsg}`);
logMinimaxCall({
status: 502,
model: `${ctx.provider}/${ctx.model}`,
provider: ctx.provider,
duration: Date.now() - ctx.startTime,
error: errMsg,
});
return {
success: false as const,
status: 502,
error: `Image provider error: ${sanitizeErrorMessage(errMsg)}`,
};
}
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 upstreamBody = buildMinimaxUpstreamBody(model, prompt, body);
log?.info?.(
"IMAGE",
`${provider}/${model} (minimax-image) | prompt: "${prompt.slice(0, 60)}..." | aspect_ratio: ${upstreamBody.aspect_ratio}`
);
try {
const response = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(upstreamBody),
});
if (!response.ok) {
return handleMinimaxUpstreamError(response, { provider, model, startTime, upstreamBody, log });
}
const data = await response.json();
const imageUrls = extractMinimaxImageUrls(data);
const ctx: MinimaxResultCtx = { provider, model, startTime };
if (imageUrls.length === 0) {
return buildMinimaxNoImagesResult(data, ctx);
}
return buildMinimaxSuccessResult(imageUrls, prompt, ctx);
} catch (err: unknown) {
return buildMinimaxFetchErrorResult(err, { provider, model, startTime, log });
}
}

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

View File

@@ -10,16 +10,16 @@ import assert from "node:assert/strict";
// serviceKinds, so every media page was empty. The fix derives media membership from
// the registries (single source of truth) and unions it with declared serviceKinds.
//
// MiniMax was the flagged case: its international endpoint serves TTS/video/music, the
// China variant (minimax-cn) has no media registry entries.
// MiniMax was the flagged case: its international endpoint serves TTS/video/music/image,
// the China variant (minimax-cn) has no media registry entries.
const { getRegistryMediaKinds, resolveProviderServiceKinds, REGISTRY_MEDIA_KINDS } =
await import("../../open-sse/config/mediaServiceKinds.ts");
const { AI_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
test("minimax (international) derives tts/video/music from the registries", () => {
test("minimax (international) derives image/tts/video/music from the registries", () => {
const kinds = getRegistryMediaKinds("minimax").sort();
assert.deepEqual(kinds, ["music", "tts", "video"]);
assert.deepEqual(kinds, ["image", "music", "tts", "video"]);
});
test("minimax-cn derives no media kinds (China endpoint has no media registry entries)", () => {