From f9e95a12db34af4010fef6bedbd7ad75f6900751 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:39:25 -0300 Subject: [PATCH] fix(providers): add MiniMax image-generation provider (#7108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../fixes/2482-minimax-image-provider.md | 1 + open-sse/config/imageRegistry.ts | 55 ++--- .../providers/registry/kie/imageModels.ts | 55 +++++ open-sse/handlers/imageGeneration.ts | 12 ++ .../imageGeneration/providers/minimax.ts | 190 ++++++++++++++++++ .../unit/minimax-image-provider-2482.test.ts | 102 ++++++++++ tests/unit/minimax-media-servicekinds.test.ts | 8 +- 7 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 changelog.d/fixes/2482-minimax-image-provider.md create mode 100644 open-sse/config/providers/registry/kie/imageModels.ts create mode 100644 open-sse/handlers/imageGeneration/providers/minimax.ts create mode 100644 tests/unit/minimax-image-provider-2482.test.ts 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..8c61ef02c1 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -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 = { 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 = { 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/config/providers/registry/kie/imageModels.ts b/open-sse/config/providers/registry/kie/imageModels.ts new file mode 100644 index 0000000000..5fbcd7b18d --- /dev/null +++ b/open-sse/config/providers/registry/kie/imageModels.ts @@ -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 }, +]; 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..1aa0595622 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/minimax.ts @@ -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 }); + } +} 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; + } +}); diff --git a/tests/unit/minimax-media-servicekinds.test.ts b/tests/unit/minimax-media-servicekinds.test.ts index 6eecb2a1c2..026e01d73f 100644 --- a/tests/unit/minimax-media-servicekinds.test.ts +++ b/tests/unit/minimax-media-servicekinds.test.ts @@ -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)", () => {