mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
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:
committed by
GitHub
parent
b914eb1b0f
commit
f9e95a12db
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)
|
||||
@@ -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",
|
||||
|
||||
55
open-sse/config/providers/registry/kie/imageModels.ts
Normal file
55
open-sse/config/providers/registry/kie/imageModels.ts
Normal 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 },
|
||||
];
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
190
open-sse/handlers/imageGeneration/providers/minimax.ts
Normal file
190
open-sse/handlers/imageGeneration/providers/minimax.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user