mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
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:
1
changelog.d/fixes/2482-minimax-image-provider.md
Normal file
1
changelog.d/fixes/2482-minimax-image-provider.md
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
138
open-sse/handlers/imageGeneration/providers/minimax.ts
Normal file
138
open-sse/handlers/imageGeneration/providers/minimax.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
102
tests/unit/minimax-image-provider-2482.test.ts
Normal file
102
tests/unit/minimax-image-provider-2482.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user