From cf7d6785c5323208377e068ada28d67508e08315 Mon Sep 17 00:00:00 2001 From: jellyjelly814 Date: Tue, 10 Nov 2026 14:57:23 +0800 Subject: [PATCH 1/2] feat: support MiniMax-H3-Image generation and editing --- README.md | 11 + README_CN.md | 11 + SDK.md | 23 ++ skill/SKILL.md | 23 +- src/client/endpoints.ts | 8 + src/commands/image/generate.ts | 197 ++++++++++++++- src/errors/api.ts | 37 ++- src/image/h3.ts | 355 +++++++++++++++++++++++++++ src/registry.ts | 2 +- src/sdk/image/index.ts | 84 ++++++- src/types/api.ts | 62 +++++ test/commands/image/generate.test.ts | 148 +++++++++++ test/errors/api-images.test.ts | 65 +++++ test/image/h3.test.ts | 180 ++++++++++++++ test/sdk/image.test.ts | 70 ++++++ 15 files changed, 1257 insertions(+), 19 deletions(-) create mode 100644 src/image/h3.ts create mode 100644 test/errors/api-images.test.ts create mode 100644 test/image/h3.test.ts diff --git a/README.md b/README.md index 1b6eb540..a22ca149 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,19 @@ cat messages.json | mmx text chat --messages-file - --output json mmx image "A cat in a spacesuit" mmx image generate --prompt "A cat" --n 3 --aspect-ratio 16:9 mmx image generate --prompt "Logo" --out-dir ./out/ + +# MiniMax-H3-Image text-to-image +mmx image generate --model MiniMax-H3-Image --prompt "An orange cat basking on a windowsill, warm afternoon light, film look" --aspect-ratio 16:9 --area 2K +mmx image generate --model MiniMax-H3-Image --prompt "Minimal poster" --size 2720x1536 --output-format png --n 2 + +# MiniMax-H3-Image image editing (1-16 reference images, numbered Picture 1, 2, ... in order) +mmx image generate --model MiniMax-H3-Image --prompt "Replace the cat in Picture 1 with the Shiba Inu in Picture 2, keep the background" --image cat.jpg --image https://example.com/dog.png ``` +Without `--image`, MiniMax-H3-Image calls `/v1/images/generations` (text-to-image); with `--image` it calls `/v1/images/edits` (image editing). Choose one way to set the output size: `--size WIDTHxHEIGHT` (area 655,360–20,000,000 px, aspect ratio 1:3–3:1), or `--aspect-ratio` plus `--area 1K|2K|4K` (default 4K). `--no-prompt-enhance` turns off prompt enhancement for text-to-image and then requires `--size` or a concrete `--aspect-ratio`. A failed image (for example one that fails the content safety check) does not stop the others; files keep their original position in the name and failures are reported on stderr (even with `--quiet`). With `--response-format base64`, the total output area (`n` × width × height) is limited to 20,000,000 px for png and 80,000,000 px for jpg/webp; with `size auto` each image counts as its `--area` level (4K = 4096 × 4096). Requests over the limit are rejected locally. + +The H3-Image endpoints are not idempotent, so a retry after a timeout generates and bills again. The CLI never retries them automatically and raises the timeout for these requests to at least 600 seconds, matching the server. `--seed`, `--prompt-optimizer`, and `--subject-ref` apply only to image-01. + ### `mmx video` ```bash diff --git a/README_CN.md b/README_CN.md index eb041291..e6d59abf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -81,8 +81,19 @@ cat messages.json | mmx text chat --messages-file - --output json mmx image "一只穿宇航服的猫" mmx image generate --prompt "科技感 Logo" --n 3 --aspect-ratio 16:9 mmx image generate --prompt "山水画" --out-dir ./output/ + +# MiniMax-H3-Image 文生图 +mmx image generate --model MiniMax-H3-Image --prompt "一只橘猫趴在窗台上晒太阳,午后暖光,胶片质感" --aspect-ratio 16:9 --area 2K +mmx image generate --model MiniMax-H3-Image --prompt "极简海报" --size 2720x1536 --output-format png --n 2 + +# MiniMax-H3-Image 图像编辑(1~16 张参考图,按顺序对应图1、图2…) +mmx image generate --model MiniMax-H3-Image --prompt "把图1里的猫换成图2里的柴犬,保持背景不变" --image cat.jpg --image https://example.com/dog.png ``` +MiniMax-H3-Image 不传 `--image` 时调用 `/v1/images/generations`(文生图),传 `--image` 时调用 `/v1/images/edits`(图像编辑)。输出尺寸二选一:`--size 宽x高`(面积 655,360~20,000,000 像素、宽高比 1:3~3:1),或 `--aspect-ratio` + `--area 1K|2K|4K`(默认 4K)。`--no-prompt-enhance` 可关闭文生图的 Prompt 增强,此时须指定 `--size` 或具体的 `--aspect-ratio`。单张失败(如未通过内容安全检查)不会中断其它图片,文件按原始张序命名,失败原因输出到 stderr(`--quiet` 时也会输出)。使用 `--response-format base64` 时,总输出面积(`n` × 宽 × 高)上限为 png 20,000,000 像素、jpg/webp 80,000,000 像素;`size` 为 auto 时每张按 `--area` 计(4K 按 4096 × 4096),超出会在本地直接报错。 + +H3-Image 接口不支持幂等,超时重试会重复生成并计费,因此 CLI 不会自动重试,并把该请求的超时下限设为 600 秒(与服务端一致)。`--seed`、`--prompt-optimizer`、`--subject-ref` 仅适用于 image-01。 + ### `mmx video` ```bash diff --git a/SDK.md b/SDK.md index c463d289..bcb9f461 100644 --- a/SDK.md +++ b/SDK.md @@ -56,8 +56,31 @@ const result = await sdk.image.generate({ height: 1024, n: 1, }); + +// MiniMax-H3-Image text-to-image → POST /v1/images/generations +const h3 = await sdk.image.generate({ + model: 'MiniMax-H3-Image', + prompt: 'An orange cat basking on a windowsill', + aspect_ratio: '16:9', + area: '2K', + n: 2, +}); +// Partial failures return 200; failed items only carry `error`. +h3.data.forEach((item, i) => console.log(i, item.url ?? item.error?.message)); + +// MiniMax-H3-Image image editing → POST /v1/images/edits (1-16 images) +const edited = await sdk.image.generate({ + model: 'MiniMax-H3-Image', + prompt: 'Replace the cat in Picture 1 with the dog in Picture 2', + images: [{ image_url: 'https://example.com/cat.jpg' }, { image_url: 'mm_file://123' }], +}); + +// Save successful images; the extension follows output_format. +const paths = await sdk.image.save(edited, { outDir: './out' }); ``` +H3-Image requests are checked locally against the documented limits (size, aspect ratio, `n`, reference images, `b64_json` total area, 64 MiB request body) before sending. Passing `images` (even an empty list) always targets the edits endpoint. All H3-Image requests use a timeout of at least 600 seconds. The endpoints are not idempotent, so the SDK does not retry them. Streaming (`stream: true`) is not supported by the SDK. + ### Video ```typescript diff --git a/skill/SKILL.md b/skill/SKILL.md index aa638fbc..7467f106 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -84,7 +84,7 @@ cat conversation.json | mmx text chat --messages-file - --output json ### image generate -Generate images. Model: `image-01`. +Generate images. Default model: `image-01`. Use `--model MiniMax-H3-Image` for higher-resolution text-to-image (up to 20 MP) or for image editing with `--image`. ```bash mmx image generate --prompt [flags] @@ -104,6 +104,27 @@ mmx image generate --prompt [flags] | `--response-format ` | string | `url` (default) or `base64`. Base64 bypasses CDN download | | `--out-dir ` | string | Download images to directory | | `--out-prefix ` | string | Filename prefix (default: `image`) | +| `--model ` | string | `image-01` (default), `image-01-live`, or `MiniMax-H3-Image`; other values go to the image-01 endpoint unchanged | + +**MiniMax-H3-Image only** (`--seed`, `--prompt-optimizer`, `--subject-ref` are rejected; `--width/--height` become `--size`): + +| Flag | Type | Description | +|---|---|---| +| `--image ` | string, repeatable | 1–16 reference images → image editing (`/v1/images/edits`). Order = Picture 1, 2, … | +| `--size ` | string | `auto` or e.g. `2720x1536`; area 655,360–20,000,000 px, ratio 1:3–3:1. Not with `--aspect-ratio`/`--area` | +| `--aspect-ratio ` | string | `auto` (default) or X:Y (integers 1–300) between 1:3 and 3:1 | +| `--area ` | string | `1K`, `2K`, `4K` (default) | +| `--n ` | number | 1–10 | +| `--output-format ` | string | `jpg` (default), `png`, `webp` | +| `--output-compression ` | number | 0–100 (default 95), jpg/webp only | +| `--no-prompt-enhance` | boolean | Text-to-image only; needs `--size` or a concrete `--aspect-ratio` | + +H3-Image is synchronous and **not idempotent** — do not re-run a command just because it was slow; each run is billed. Partial failures exit 0 with `failed_count`/`failures` in JSON output (failures are also printed to stderr, even with `--quiet`). With `--response-format base64`, the total area `n` × W × H is limited to 20 MP for png and 80 MP for jpg/webp (`size auto` counts the `--area` level, e.g. 4K = 4096×4096). + +```bash +mmx image generate --model MiniMax-H3-Image --prompt "A cat on a windowsill" --aspect-ratio 16:9 --area 2K --output json --quiet +mmx image generate --model MiniMax-H3-Image --prompt "Put the hat from Picture 2 on the person in Picture 1" --image person.jpg --image hat.png --out result.jpg --quiet +``` ```bash mmx image generate --prompt "A cat in a spacesuit" --output json --quiet diff --git a/src/client/endpoints.ts b/src/client/endpoints.ts index 9b60f833..41e3e04b 100644 --- a/src/client/endpoints.ts +++ b/src/client/endpoints.ts @@ -18,6 +18,14 @@ export function imageEndpoint(baseUrl: string): string { return `${baseUrl}/v1/image_generation`; } +export function imageGenerationsEndpoint(baseUrl: string): string { + return `${baseUrl}/v1/images/generations`; +} + +export function imageEditsEndpoint(baseUrl: string): string { + return `${baseUrl}/v1/images/edits`; +} + export function videoGenerateEndpoint(baseUrl: string): string { return `${baseUrl}/v1/video_generation`; } diff --git a/src/commands/image/generate.ts b/src/commands/image/generate.ts index b530a72b..2251f930 100644 --- a/src/commands/image/generate.ts +++ b/src/commands/image/generate.ts @@ -2,34 +2,189 @@ import { defineCommand } from '../../command'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { requestJson } from '../../client/http'; -import { imageEndpoint } from '../../client/endpoints'; +import { imageEditsEndpoint, imageEndpoint, imageGenerationsEndpoint } from '../../client/endpoints'; import { downloadFile } from '../../files/download'; import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; -import type { ImageRequest, ImageResponse } from '../../types/api'; +import type { ImageH3Request, ImageH3Response, ImageRequest, ImageResponse } from '../../types/api'; import { mkdirSync, existsSync, writeFileSync } from 'fs'; -import { dirname, join, resolve } from 'path'; +import { dirname, extname, join, resolve } from 'path'; import { localFileToDataUri } from '../../utils/image'; import { promptOrFail } from '../../utils/prompt'; +import { + buildImageH3Request, + IMAGE_H3_MIN_TIMEOUT_SEC, + IMAGE_H3_MODEL, + imageH3Extension, + ImageH3InputError, + isImageH3EditRequest, + isImageH3Model, + resolveImageH3Input, + saveImageH3Images, +} from '../../image/h3'; + +const H3_HINT = 'mmx image generate --model MiniMax-H3-Image --prompt '; + +/** Flags only understood by MiniMax-H3-Image (camelCase key → CLI spelling). */ +const H3_ONLY_FLAGS: Array<[string, string]> = [ + ['image', '--image'], + ['size', '--size'], + ['area', '--area'], + ['outputFormat', '--output-format'], + ['outputCompression', '--output-compression'], + ['noPromptEnhance', '--no-prompt-enhance'], +]; + +/** Flags only understood by the legacy image-01 endpoint. */ +const LEGACY_ONLY_FLAGS: Array<[string, string]> = [ + ['seed', '--seed'], + ['promptOptimizer', '--prompt-optimizer'], + ['subjectRef', '--subject-ref'], +]; + +function usedFlags(flags: GlobalFlags, defs: Array<[string, string]>): string[] { + const record = flags as Record; + return defs + .filter(([key]) => record[key] !== undefined && record[key] !== false) + .map(([, flag]) => flag); +} + +async function runImageH3(config: Config, flags: GlobalFlags, prompt: string): Promise { + const legacyOnly = usedFlags(flags, LEGACY_ONLY_FLAGS); + if (legacyOnly.length > 0) { + throw new CLIError( + `${legacyOnly.join(', ')} ${legacyOnly.length > 1 ? 'are' : 'is'} not supported by ${IMAGE_H3_MODEL}.`, + ExitCode.USAGE, + 'Use --image for reference images; prompt enhancement is on by default (--no-prompt-enhance to disable).', + ); + } + + // --width/--height are a convenience spelling of size=WIDTHxHEIGHT. + const width = flags.width as number | undefined; + const height = flags.height as number | undefined; + if ((width === undefined) !== (height === undefined)) { + throw new CLIError('--width and --height must be specified together.', ExitCode.USAGE, H3_HINT); + } + let size = flags.size as string | undefined; + if (width !== undefined && height !== undefined) { + if (size !== undefined) { + throw new CLIError('Use either --size or --width/--height, not both.', ExitCode.USAGE, H3_HINT); + } + size = `${width}x${height}`; + } + + const outPath = flags.out as string | undefined; + if (outPath && (flags.n as number) > 1) { + throw new CLIError('--out cannot be used with --n > 1. Use --out-dir instead.', ExitCode.USAGE); + } + + let body: ImageH3Request; + try { + const images = ((flags.image as string[] | undefined) ?? []).map(resolveImageH3Input); + body = buildImageH3Request({ + prompt, + images, + n: flags.n as number | undefined, + size, + aspectRatio: flags.aspectRatio as string | undefined, + area: flags.area as string | undefined, + outputFormat: flags.outputFormat as string | undefined, + outputCompression: flags.outputCompression as number | undefined, + responseFormat: flags.responseFormat as string | undefined, + disablePromptEnhanced: flags.noPromptEnhance === true, + aigcWatermark: flags.aigcWatermark === true, + }); + } catch (error) { + if (error instanceof ImageH3InputError) { + throw new CLIError(error.message, ExitCode.USAGE, H3_HINT); + } + throw error; + } + + if (dryRun(config, body)) return; + + const format = detectOutputFormat(config.output); + const isEdit = isImageH3EditRequest(body); + const response = await requestJson(config, { + url: isEdit ? imageEditsEndpoint(config.baseUrl) : imageGenerationsEndpoint(config.baseUrl), + method: 'POST', + body, + // Not idempotent: timing out early still bills the images, so wait as long as the server. + timeout: Math.max(config.timeout, IMAGE_H3_MIN_TIMEOUT_SEC), + }); + + if (!config.quiet) { + process.stderr.write(`[Model: ${IMAGE_H3_MODEL}${isEdit ? ' (edit)' : ''}]\n`); + } + + const { saved, failures } = await saveImageH3Images(response, { + out: outPath, + outDir: flags.outDir as string | undefined, + prefix: flags.outPrefix as string | undefined, + quiet: config.quiet, + warnOverwrite: true, + }); + + // Always reported, even with --quiet: a missing image is a result, not progress noise. + for (const failure of failures) { + process.stderr.write( + `Warning: image ${failure.index + 1} failed${failure.code ? ` (${failure.code})` : ''}: ${failure.message ?? 'unknown error'}\n`, + ); + } + + if (outPath && saved.length > 0) { + const expected = imageH3Extension(response.output_format); + const actual = extname(outPath).toLowerCase().replace(/^\./, '').replace(/^jpeg$/, 'jpg'); + if (actual !== expected) { + process.stderr.write(`Warning: --out ${outPath} has a .${actual || '(none)'} extension but the image is ${expected}.\n`); + } + } + + const result: Record = { + id: response.request_id, + saved, + success_count: saved.length, + failed_count: failures.length, + }; + if (failures.length > 0) result.failures = failures; + + if (format === 'json') { + result.sizes = response.data.map(item => item.size ?? null); + result.revised_prompts = response.data.map(item => item.revised_prompt ?? null); + if (response.usage) result.usage = response.usage; + console.log(formatOutput(result, format)); + } else if (config.quiet) { + console.log(saved.join('\n')); + } else { + console.log(formatOutput(result, format)); + } +} export default defineCommand({ name: 'image generate', - description: 'Generate images (image-01 / image-01-live)', + description: 'Generate or edit images (image-01 / MiniMax-H3-Image)', apiDocs: '/docs/api-reference/image-generation-t2i', usage: 'mmx image generate --prompt [flags]', options: [ + { flag: '--model ', description: 'Model ID: image-01 (default), image-01-live, or MiniMax-H3-Image. Other values are passed to the image-01 endpoint as-is.' }, { flag: '--prompt ', description: 'Image description', required: true }, - { flag: '--aspect-ratio ', description: 'Aspect ratio (e.g. 16:9, 1:1). Ignored if --width and --height are both specified.' }, - { flag: '--n ', description: 'Number of images to generate (default: 1)', type: 'number' }, + { flag: '--image ', description: 'H3: reference image for image editing (repeatable, 1-16; local file, URL, data URI, or mm_file:// ID).', type: 'array' }, + { flag: '--aspect-ratio ', description: 'Aspect ratio (e.g. 16:9, 1:1). image-01: ignored if --width and --height are both specified. H3: auto or X:Y (integers 1-300) between 1:3 and 3:1; cannot be combined with --size/--width/--height.' }, + { flag: '--size ', description: 'H3: output size, auto (default) or WIDTHxHEIGHT (e.g. 2720x1536). Cannot be combined with --aspect-ratio/--area.' }, + { flag: '--area ', description: 'H3: area level 1K, 2K, or 4K (default: 4K).' }, + { flag: '--output-format ', description: 'H3: jpg (default), png, or webp.' }, + { flag: '--output-compression ', description: 'H3: JPEG/WEBP quality 0-100 (default: 95).', type: 'number' }, + { flag: '--no-prompt-enhance', description: 'H3 text-to-image: disable prompt enhancement (requires --size or a concrete --aspect-ratio).' }, + { flag: '--n ', description: 'Number of images to generate (default: 1; H3: 1-10)', type: 'number' }, { flag: '--seed ', description: 'Random seed for reproducible generation (same seed + prompt = identical output)', type: 'number' }, - { flag: '--width ', description: 'Custom width in pixels. Range [512, 2048], must be multiple of 8. Only effective for image-01 model. Overrides --aspect-ratio if set.', type: 'number' }, - { flag: '--height ', description: 'Custom height in pixels. Range [512, 2048], must be multiple of 8. Only effective for image-01 model. Overrides --aspect-ratio if set.', type: 'number' }, + { flag: '--width ', description: 'Custom width in pixels. image-01: range [512, 2048], multiple of 8, overrides --aspect-ratio. H3: shorthand for --size WIDTHxHEIGHT.', type: 'number' }, + { flag: '--height ', description: 'Custom height in pixels. image-01: range [512, 2048], multiple of 8, overrides --aspect-ratio. H3: shorthand for --size WIDTHxHEIGHT.', type: 'number' }, { flag: '--prompt-optimizer', description: 'Automatically optimize the prompt before generation for better results.' }, { flag: '--aigc-watermark', description: 'Embed AI-generated content watermark in the output image.' }, { flag: '--subject-ref ', description: 'Subject reference for character consistency. Format: type=character,image=path-or-url' }, { flag: '--out ', description: 'Save image to exact file path (single image only)' }, - { flag: '--response-format ', description: 'Response format: url (download), base64 (embed). Default: url' }, + { flag: '--response-format ', description: 'Response format: url (download), base64 (embed). Default: url. H3 also accepts b64_json; total area n x W x H is limited to 20 MP (png) or 80 MP (jpg/webp).' }, { flag: '--out-dir ', description: 'Download images to directory' }, { flag: '--out-prefix ', description: 'Filename prefix (default: image)' }, ], @@ -47,6 +202,11 @@ export default defineCommand({ 'mmx image generate --prompt "A cat" --out /tmp/cat.jpg', '# Base64 response (bypasses CDN, useful when image URLs are unreachable)', 'mmx image generate --prompt "A cat" --response-format base64', + '# MiniMax-H3-Image text-to-image', + 'mmx image generate --model MiniMax-H3-Image --prompt "An orange cat on a sunny windowsill" --aspect-ratio 16:9 --area 2K', + 'mmx image generate --model MiniMax-H3-Image --prompt "Poster" --size 2720x1536 --output-format png', + '# MiniMax-H3-Image image editing (1-16 reference images)', + 'mmx image generate --model MiniMax-H3-Image --prompt "Replace the cat in Picture 1 with the dog in Picture 2" --image cat.jpg --image https://example.com/dog.png', ], async run(config: Config, flags: GlobalFlags) { let prompt = (flags.prompt ?? (flags._positional as string[]|undefined)?.[0]) as string | undefined; @@ -60,6 +220,21 @@ export default defineCommand({ nonInteractive: config.nonInteractive, }); + const model = (flags.model as string | undefined) || 'image-01'; + if (isImageH3Model(model)) { + await runImageH3(config, flags, prompt); + return; + } + + const h3Only = usedFlags(flags, H3_ONLY_FLAGS); + if (h3Only.length > 0) { + throw new CLIError( + `${h3Only.join(', ')} ${h3Only.length > 1 ? 'require' : 'requires'} --model ${IMAGE_H3_MODEL}.`, + ExitCode.USAGE, + H3_HINT, + ); + } + // Validate width/height const width = flags.width as number | undefined; const height = flags.height as number | undefined; @@ -91,7 +266,7 @@ export default defineCommand({ const responseFormat = (flags.responseFormat as 'url' | 'base64' | undefined) || 'url'; const body: ImageRequest = { - model: 'image-01', + model, prompt, aspect_ratio: (width !== undefined && height !== undefined) ? undefined : ((flags.aspectRatio as string) || undefined), n: (flags.n as number) ?? 1, @@ -139,7 +314,7 @@ export default defineCommand({ }); if (!config.quiet) { - process.stderr.write('[Model: image-01]\n'); + process.stderr.write(`[Model: ${model}]\n`); } const saved: string[] = []; diff --git a/src/errors/api.ts b/src/errors/api.ts index f88c7ff2..6c30fda7 100644 --- a/src/errors/api.ts +++ b/src/errors/api.ts @@ -12,6 +12,8 @@ export interface ApiErrorBody { code?: number; http_code?: string | number; }; + /** Endpoint-specific; on /v1/images/* it holds per-image failure reasons when every image fails. */ + data?: unknown; } function planHintForUrl(url?: string): string { @@ -30,13 +32,37 @@ function upgradeUrl(url?: string): string { return `${host}/subscribe/token-plan`; } +/** Per-image failure reasons from an all-failed /v1/images/* response, one line each. */ +function imageFailureDetails(body: ApiErrorBody): string | undefined { + if (!Array.isArray(body.data)) return undefined; + const items = body.data as Array<{ error?: { code?: string | number; message?: string } } | null>; + const lines = items.flatMap((item, index) => item?.error + ? [`Image ${index + 1}${item.error.code !== undefined ? ` (${item.error.code})` : ''}: ${item.error.message ?? 'unknown error'}`] + : []); + return lines.length > 0 ? lines.join('\n') : undefined; +} + export function mapApiError(status: number, body: ApiErrorBody, url?: string): CLIError { + const error = mapApiErrorBase(status, body, url); + const details = url?.includes('/v1/images/') ? imageFailureDetails(body) : undefined; + if (!details) return error; + return new CLIError(error.message, error.exitCode, error.hint ? `${details}\n${error.hint}` : details); +} + +function mapApiErrorBase(status: number, body: ApiErrorBody, url?: string): CLIError { const apiMsg = body.base_resp?.status_msg || body.error?.message || `HTTP ${status}`; - const apiCode = body.base_resp?.status_code || body.error?.code; + // /v1/images/* (OpenAI-style) errors put the MiniMax code at the end of the + // message: "no image generated: all 2 images failed content safety check (1027)". + // Scoped to that endpoint so other APIs keep their existing classification. + const trailingCode = url?.includes('/v1/images/') + ? body.error?.message?.match(/\((\d{4})\)\s*$/)?.[1] + : undefined; + const apiCode = body.base_resp?.status_code || body.error?.code + || (trailingCode ? Number(trailingCode) : undefined); const errorType = body.error?.type; if (status === 401 || status === 403) { @@ -55,6 +81,15 @@ export function mapApiError(status: number, body: ApiErrorBody, url?: string): C ); } + // 2068: the current plan tier does not include this model (returned as HTTP 402). + if (apiCode === 2068 && url?.includes('/v1/images/')) { + return new CLIError( + `This model is not available on your current plan. ${apiMsg}`, + ExitCode.QUOTA, + `Check usage: mmx quota show\nUpgrade plan: ${upgradeUrl(url)}`, + ); + } + if (status === 402 || errorType === 'insufficient_balance_error') { return new CLIError( `Quota or balance exhausted. ${apiMsg}`, diff --git a/src/image/h3.ts b/src/image/h3.ts new file mode 100644 index 00000000..64be9b4f --- /dev/null +++ b/src/image/h3.ts @@ -0,0 +1,355 @@ +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { dirname, extname, join, resolve } from 'path'; +import { downloadFile } from '../files/download'; +import type { + ImageH3Area, + ImageH3EditRequest, + ImageH3GenerationRequest, + ImageH3OutputFormat, + ImageH3Request, + ImageH3Response, + ImageH3ResponseFormat, +} from '../types/api'; +import { IMAGE_MIME_TYPES, localFileToDataUri } from '../utils/image'; +import { dataUriDecodedSize } from '../utils/media'; + +export const IMAGE_H3_MODEL = 'MiniMax-H3-Image' as const; + +/** + * The server waits up to 600 s. The endpoints are not idempotent: a client-side + * timeout still gets the request billed, so never time out before the server. + */ +export const IMAGE_H3_MIN_TIMEOUT_SEC = 600; + +export const IMAGE_H3_MAX_PROMPT_CHARS = 32000; +export const IMAGE_H3_MAX_IMAGES_PER_REQUEST = 10; +export const IMAGE_H3_MAX_REFERENCE_IMAGES = 16; +export const IMAGE_H3_MIN_AREA = 655_360; +export const IMAGE_H3_MAX_AREA = 20_000_000; +export const IMAGE_H3_MAX_FILE_BYTES = 64 * 1024 * 1024; +export const IMAGE_H3_MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024; + +/** Per-image area used for the b64_json budget when size is auto (e.g. 4K = 4096 x 4096). */ +const AREA_LEVEL_PIXELS: Record = { + '1K': 1024 * 1024, + '2K': 2048 * 2048, + '4K': 4096 * 4096, +}; +/** b64_json total output area (n x width x height) limits by output format. */ +export const IMAGE_H3_B64_MAX_TOTAL_AREA_PNG = 20_000_000; +export const IMAGE_H3_B64_MAX_TOTAL_AREA = 80_000_000; + +const AREAS = new Set(['1K', '2K', '4K']); +const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'jpg', 'webp']); +const RESPONSE_FORMATS = new Set(['url', 'b64_json']); + +export class ImageH3InputError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImageH3InputError'; + } +} + +export interface BuildImageH3RequestOptions { + prompt: string; + /** Reference images (URL, data URI or mm_file://). Non-empty switches to the edits endpoint. */ + images?: string[]; + n?: number; + size?: string; + aspectRatio?: string; + area?: string; + outputFormat?: string; + outputCompression?: number; + responseFormat?: string; + disablePromptEnhanced?: boolean; + aigcWatermark?: boolean; +} + +export function isImageH3Model(model: string | undefined): model is typeof IMAGE_H3_MODEL { + return model === IMAGE_H3_MODEL; +} + +export function isImageH3EditRequest(request: ImageH3Request): request is ImageH3EditRequest { + return Array.isArray((request as ImageH3EditRequest).images); +} + +export function isImageH3Response(response: unknown): response is ImageH3Response { + return typeof response === 'object' && response !== null + && Array.isArray((response as ImageH3Response).data); +} + +/** File extension for a response `output_format` (`jpeg` → `jpg`). */ +export function imageH3Extension(format: string | undefined): string { + if (format === 'png' || format === 'webp') return format; + return 'jpg'; +} + +/** Normalise a CLI response-format value; `base64` is kept as an alias of `b64_json`. */ +export function normalizeImageH3ResponseFormat(value: string | undefined): string | undefined { + return value === 'base64' ? 'b64_json' : value; +} + +/** + * Resolve a reference image for the edits endpoint: URLs, data URIs and + * mm_file:// IDs pass through; local files are checked and Base64-encoded. + */ +export function resolveImageH3Input(input: string): string { + if ( + input.startsWith('http://') || + input.startsWith('https://') || + input.startsWith('data:') || + input.startsWith('mm_file://') + ) { + return input; + } + if (!existsSync(input)) { + throw new ImageH3InputError(`Reference image not found: ${input}`); + } + const ext = extname(input).toLowerCase(); + if (!IMAGE_MIME_TYPES[ext]) { + throw new ImageH3InputError( + `Unsupported reference image format "${ext}". Supported: jpg, jpeg, png, webp, heic, heif.`, + ); + } + return localFileToDataUri(input, IMAGE_H3_MAX_FILE_BYTES); +} + +export function buildImageH3Request(options: BuildImageH3RequestOptions): ImageH3Request { + const base = { + model: IMAGE_H3_MODEL, + prompt: options.prompt, + n: options.n, + size: options.size, + aspect_ratio: options.aspectRatio, + area: options.area as ImageH3Area | undefined, + output_format: options.outputFormat as ImageH3OutputFormat | undefined, + output_compression: options.outputCompression, + response_format: normalizeImageH3ResponseFormat(options.responseFormat) as ImageH3ResponseFormat | undefined, + aigc_watermark: options.aigcWatermark || undefined, + }; + + const images = options.images ?? []; + let request: ImageH3Request; + if (images.length > 0) { + if (options.disablePromptEnhanced) { + throw new ImageH3InputError( + 'Prompt enhancement is always on for image edits; --no-prompt-enhance only applies to text-to-image.', + ); + } + request = { ...base, images: images.map(url => ({ image_url: url })) }; + } else { + request = { ...base, disable_prompt_enhanced: options.disablePromptEnhanced || undefined }; + } + + // Drop undefined keys so --dry-run shows exactly what is sent. + for (const key of Object.keys(request) as Array) { + if (request[key] === undefined) delete request[key]; + } + + validateImageH3Request(request); + return request; +} + +function parseRatio(value: string): [number, number] | undefined { + const match = /^(\d+):(\d+)$/.exec(value); + if (!match) return undefined; + return [Number(match[1]), Number(match[2])]; +} + +function withinRatioRange(a: number, b: number): boolean { + return Math.max(a, b) <= 3 * Math.min(a, b); +} + +export function validateImageH3Request(request: ImageH3Request): void { + if (!isImageH3Model(request.model)) { + throw new ImageH3InputError(`Only ${IMAGE_H3_MODEL} is supported by /v1/images/*.`); + } + + const prompt = typeof request.prompt === 'string' ? request.prompt.trim() : ''; + if (!prompt) { + throw new ImageH3InputError('MiniMax-H3-Image requires a non-empty prompt.'); + } + if ([...prompt].length > IMAGE_H3_MAX_PROMPT_CHARS) { + throw new ImageH3InputError(`MiniMax-H3-Image prompt must not exceed ${IMAGE_H3_MAX_PROMPT_CHARS} characters.`); + } + + if (request.n !== undefined + && (!Number.isInteger(request.n) || request.n < 1 || request.n > IMAGE_H3_MAX_IMAGES_PER_REQUEST)) { + throw new ImageH3InputError(`MiniMax-H3-Image n must be an integer from 1 to ${IMAGE_H3_MAX_IMAGES_PER_REQUEST}.`); + } + + const size = request.size; + const explicitSize = size !== undefined && size !== 'auto'; + if (explicitSize) { + const match = /^(\d+)x(\d+)$/.exec(size); + if (!match) { + throw new ImageH3InputError('MiniMax-H3-Image size must be "auto" or WIDTHxHEIGHT, e.g. 2720x1536.'); + } + const width = Number(match[1]); + const height = Number(match[2]); + const area = width * height; + if (width < 1 || height < 1 || area < IMAGE_H3_MIN_AREA || area > IMAGE_H3_MAX_AREA) { + throw new ImageH3InputError( + `MiniMax-H3-Image size area (width x height) must be between 655,360 and 20,000,000 pixels, got ${area.toLocaleString('en-US')}.`, + ); + } + if (!withinRatioRange(width, height)) { + throw new ImageH3InputError('MiniMax-H3-Image size aspect ratio must be between 1:3 and 3:1.'); + } + if (request.aspect_ratio !== undefined || request.area !== undefined) { + throw new ImageH3InputError('MiniMax-H3-Image size WIDTHxHEIGHT cannot be combined with aspect ratio or area.'); + } + } + + const aspectRatio = request.aspect_ratio; + if (aspectRatio !== undefined && aspectRatio !== 'auto') { + const ratio = parseRatio(aspectRatio); + if (!ratio || ratio.some(v => v < 1 || v > 300)) { + throw new ImageH3InputError('MiniMax-H3-Image aspect ratio must be "auto" or X:Y with integers from 1 to 300.'); + } + if (!withinRatioRange(ratio[0], ratio[1])) { + throw new ImageH3InputError('MiniMax-H3-Image aspect ratio must be between 1:3 and 3:1.'); + } + } + + if (request.area !== undefined && !AREAS.has(request.area)) { + throw new ImageH3InputError('MiniMax-H3-Image area must be one of: 1K, 2K, 4K.'); + } + if (request.output_format !== undefined && !OUTPUT_FORMATS.has(request.output_format)) { + throw new ImageH3InputError('MiniMax-H3-Image output format must be one of: jpg, jpeg, png, webp.'); + } + if (request.output_compression !== undefined + && (!Number.isInteger(request.output_compression) || request.output_compression < 0 || request.output_compression > 100)) { + throw new ImageH3InputError('MiniMax-H3-Image output compression must be an integer from 0 to 100.'); + } + if (request.response_format !== undefined && !RESPONSE_FORMATS.has(request.response_format)) { + throw new ImageH3InputError('MiniMax-H3-Image response format must be url or b64_json (alias: base64).'); + } + + if (request.response_format === 'b64_json') { + const perImage = explicitSize + ? Number(size.split('x')[0]) * Number(size.split('x')[1]) + : AREA_LEVEL_PIXELS[request.area ?? '4K']; + const total = (request.n ?? 1) * perImage; + const isPng = request.output_format === 'png'; + const limit = isPng ? IMAGE_H3_B64_MAX_TOTAL_AREA_PNG : IMAGE_H3_B64_MAX_TOTAL_AREA; + if (total > limit) { + throw new ImageH3InputError( + `MiniMax-H3-Image b64_json total output area (n x width x height) is ${total.toLocaleString('en-US')} px; ` + + `the limit for ${isPng ? 'png' : 'jpeg/webp'} is ${limit.toLocaleString('en-US')} px. ` + + 'Lower n or the size/area, or use the url response format.', + ); + } + } + + if (isImageH3EditRequest(request)) { + if ((request as ImageH3GenerationRequest).disable_prompt_enhanced !== undefined) { + throw new ImageH3InputError('disable_prompt_enhanced is not supported for image edits (enhancement is always on).'); + } + const count = request.images.length; + if (count < 1 || count > IMAGE_H3_MAX_REFERENCE_IMAGES) { + throw new ImageH3InputError(`MiniMax-H3-Image accepts 1 to ${IMAGE_H3_MAX_REFERENCE_IMAGES} reference images.`); + } + for (const image of request.images) { + if (typeof image?.image_url !== 'string' || !image.image_url) { + throw new ImageH3InputError('Each reference image needs a non-empty image_url.'); + } + const decoded = dataUriDecodedSize(image.image_url); + if (decoded !== undefined && decoded > IMAGE_H3_MAX_FILE_BYTES) { + throw new ImageH3InputError( + `MiniMax-H3-Image reference image is ${(decoded / 1024 / 1024).toFixed(1)} MB; the maximum is 64 MB.`, + ); + } + } + } else if (request.disable_prompt_enhanced) { + const hasConcreteRatio = aspectRatio !== undefined && aspectRatio !== 'auto'; + if (!explicitSize && !hasConcreteRatio) { + throw new ImageH3InputError( + 'With prompt enhancement disabled, MiniMax-H3-Image needs an explicit size (WIDTHxHEIGHT) or aspect ratio (X:Y).', + ); + } + } + + const bodyBytes = Buffer.byteLength(JSON.stringify(request)); + if (bodyBytes > IMAGE_H3_MAX_REQUEST_BODY_BYTES) { + throw new ImageH3InputError( + `MiniMax-H3-Image request body is ${(bodyBytes / 1024 / 1024).toFixed(1)} MB; the maximum is 64 MB. Use public URLs or mm_file:// file IDs instead of local files.`, + ); + } +} + +export interface ImageH3SaveOptions { + /** Save to an exact file path (only valid when at most one image succeeded). */ + out?: string; + /** Directory for numbered files (default: "."). */ + outDir?: string; + /** Filename prefix (default: "image"). */ + prefix?: string; + /** Suppress download progress. */ + quiet?: boolean; + /** Print a warning to stderr before overwriting an existing file. */ + warnOverwrite?: boolean; +} + +export interface ImageH3Failure { + /** 0-based position in the response `data` array. */ + index: number; + code?: string; + message?: string; +} + +export interface ImageH3SaveResult { + /** Absolute paths of saved images. */ + saved: string[]; + failures: ImageH3Failure[]; +} + +/** + * Save the successful items of a MiniMax-H3-Image response. Files are named by + * their position in `data` (`image_001.jpg`, `image_002.jpg`, ...), so a failed + * item leaves a gap instead of shifting later images. + */ +export async function saveImageH3Images( + response: ImageH3Response, + options: ImageH3SaveOptions = {}, +): Promise { + const ext = imageH3Extension(response.output_format); + const failures: ImageH3Failure[] = []; + const succeeded: Array<{ index: number; url?: string; b64?: string }> = []; + + response.data.forEach((item, index) => { + if (item.url || item.b64_json) { + succeeded.push({ index, url: item.url, b64: item.b64_json }); + } else { + failures.push({ index, code: item.error?.code, message: item.error?.message }); + } + }); + + if (options.out && succeeded.length > 1) { + throw new ImageH3InputError('Cannot use `out` with multiple images. Use `outDir` instead.'); + } + + const saved: string[] = []; + const quiet = options.quiet ?? true; + const outDir = resolve(options.outDir || '.'); + const prefix = options.prefix || 'image'; + + for (const image of succeeded) { + const destPath = options.out + ? resolve(options.out) + : join(outDir, `${prefix}_${String(image.index + 1).padStart(3, '0')}.${ext}`); + const dir = dirname(destPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + if (options.warnOverwrite && existsSync(destPath)) { + process.stderr.write(`Warning: overwriting existing file: ${destPath}\n`); + } + if (image.b64) { + writeFileSync(destPath, image.b64, 'base64'); + } else { + await downloadFile(image.url!, destPath, { quiet }); + } + saved.push(destPath); + } + + return { saved, failures }; +} diff --git a/src/registry.ts b/src/registry.ts index 3e3eac0c..e04ca157 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -214,7 +214,7 @@ ${b('Resources:')} ${a('auth')} ${d('Authentication (login, status, refresh, logout)')} ${a('text')} ${d('Text generation (chat)')} ${a('speech')} ${d('Speech synthesis and transcription (synthesize, voices, transcribe)')} - ${a('image')} ${d('Image generation (generate)')} + ${a('image')} ${d('Image generation and editing (generate)')} ${a('video')} ${d('Video generation (generate, task get, download)')} ${a('search')} ${d('Web search (query)')} ${a('vision')} ${d('Image understanding (describe)')} diff --git a/src/sdk/image/index.ts b/src/sdk/image/index.ts index 92998642..7b820f86 100644 --- a/src/sdk/image/index.ts +++ b/src/sdk/image/index.ts @@ -1,8 +1,24 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { Client } from "../client"; -import { imageEndpoint } from "../../client/endpoints"; -import { ImageRequest, ImageResponse } from "../../types/api"; +import { imageEditsEndpoint, imageEndpoint, imageGenerationsEndpoint } from "../../client/endpoints"; +import { + ImageH3EditRequest, + ImageH3GenerationRequest, + ImageH3Request, + ImageH3Response, + ImageRequest, + ImageResponse, +} from "../../types/api"; +import { + IMAGE_H3_MIN_TIMEOUT_SEC, + ImageH3InputError, + isImageH3EditRequest, + isImageH3Model, + isImageH3Response, + saveImageH3Images, + validateImageH3Request, +} from "../../image/h3"; import { ModelPartial } from "../types"; import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; @@ -22,9 +38,25 @@ export interface ImageSaveOptions { quiet?: boolean; } +/** + * MiniMax-H3-Image request. Without `images` it calls `/v1/images/generations`; + * with 1-16 `images` it calls `/v1/images/edits`. + */ +export type ImageH3GenerateRequest = ImageH3GenerationRequest & { + images?: ImageH3EditRequest['images']; +}; + export class ImageSDK extends Client { - async generate(request: ModelPartial): Promise { - const body = this.validateParams(request); + async generate(request: ImageH3GenerateRequest): Promise; + async generate(request: ModelPartial): Promise; + async generate( + request: ModelPartial | ImageH3GenerateRequest, + ): Promise { + if (isImageH3Model(request.model)) { + return await this.generateH3(request as ImageH3GenerateRequest); + } + + const body = this.validateParams(request as ModelPartial); const url = imageEndpoint(this.config.baseUrl); return await this.requestJson({ @@ -34,6 +66,32 @@ export class ImageSDK extends Client { }); } + private async generateH3(request: ImageH3GenerateRequest): Promise { + const { images, ...rest } = request; + if ((rest as { stream?: unknown }).stream) { + throw new SDKError('stream is not supported by the SDK for MiniMax-H3-Image.', ExitCode.USAGE); + } + // Any `images` (even empty) targets edits, so an empty list fails the 1-16 check instead of silently becoming text-to-image. + const body: ImageH3Request = images !== undefined ? { ...rest, images } : rest; + + try { + validateImageH3Request(body); + } catch (error) { + if (error instanceof ImageH3InputError) throw new SDKError(error.message, ExitCode.USAGE); + throw error; + } + + return await this.requestJson({ + url: isImageH3EditRequest(body) + ? imageEditsEndpoint(this.config.baseUrl) + : imageGenerationsEndpoint(this.config.baseUrl), + method: "POST", + body, + // Not idempotent: a shorter client timeout still bills the images. + timeout: Math.max(this.config.timeout, IMAGE_H3_MIN_TIMEOUT_SEC), + }); + } + /** * Download and save images from a `generate()` response to disk. * @@ -41,7 +99,23 @@ export class ImageSDK extends Client { * and creates intermediate directories as needed. Returns the absolute * paths of all saved files. */ - async save(response: ImageResponse, options: ImageSaveOptions = {}): Promise { + async save(response: ImageResponse | ImageH3Response, options: ImageSaveOptions = {}): Promise { + if (isImageH3Response(response)) { + // H3 responses carry per-item url/b64_json/error, so responseFormat is not needed. + try { + const { saved } = await saveImageH3Images(response, { + out: options.out, + outDir: options.outDir, + prefix: options.prefix, + quiet: options.quiet ?? true, + }); + return saved; + } catch (error) { + if (error instanceof ImageH3InputError) throw new SDKError(error.message, ExitCode.USAGE); + throw error; + } + } + const fmt = options.responseFormat || 'url'; const quiet = options.quiet ?? true; const saved: string[] = []; diff --git a/src/types/api.ts b/src/types/api.ts index 6aedd059..446429a1 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -228,6 +228,68 @@ export interface ImageResponse { }; } +// ---- Image (MiniMax-H3-Image) ---- + +export type ImageH3Area = '1K' | '2K' | '4K'; +export type ImageH3OutputFormat = 'png' | 'jpeg' | 'jpg' | 'webp'; +export type ImageH3ResponseFormat = 'url' | 'b64_json'; + +/** Shared body of `POST /v1/images/generations` and `POST /v1/images/edits`. */ +export interface ImageH3BaseRequest { + model: 'MiniMax-H3-Image'; + prompt: string; + n?: number; + /** `auto` (default) or `WIDTHxHEIGHT`, e.g. `2720x1536`. */ + size?: string; + /** `auto` (default) or `X:Y`. Only valid when size is unset or `auto`. */ + aspect_ratio?: string; + /** Area level. Only valid when size is unset or `auto`. Default: `4K`. */ + area?: ImageH3Area; + output_format?: ImageH3OutputFormat; + /** JPEG / WEBP quality, 0-100 (default 95). Ignored for PNG. */ + output_compression?: number; + response_format?: ImageH3ResponseFormat; + aigc_watermark?: boolean; +} + +/** Text-to-image: `POST /v1/images/generations`. */ +export interface ImageH3GenerationRequest extends ImageH3BaseRequest { + disable_prompt_enhanced?: boolean; +} + +/** Image edit: `POST /v1/images/edits` with 1-16 reference images. */ +export interface ImageH3EditRequest extends ImageH3BaseRequest { + images: Array<{ image_url: string }>; +} + +export type ImageH3Request = ImageH3GenerationRequest | ImageH3EditRequest; + +export interface ImageH3Item { + url?: string; + b64_json?: string; + size?: string; + revised_prompt?: string; + error?: { code?: string; message?: string }; +} + +export interface ImageH3Usage { + generated_images?: number; + total_tokens?: number; + input_tokens?: number; + output_tokens?: number; + input_tokens_details?: { text_tokens?: number; image_tokens?: number }; + output_tokens_details?: { image_tokens?: number }; +} + +export interface ImageH3Response { + created?: number; + /** Always `n` items in order; failed items carry only `error`. */ + data: ImageH3Item[]; + output_format?: 'png' | 'jpeg' | 'webp'; + usage?: ImageH3Usage; + request_id?: string; +} + // ---- Video ---- export interface VideoRequest { diff --git a/test/commands/image/generate.test.ts b/test/commands/image/generate.test.ts index 1cdd56a4..73832aae 100644 --- a/test/commands/image/generate.test.ts +++ b/test/commands/image/generate.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'bun:test'; import { default as generateCommand } from '../../../src/commands/image/generate'; +import { createMockServer, jsonResponse } from '../../helpers/mock-server'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; const baseConfig = { apiKey: 'test-key', @@ -92,3 +96,147 @@ describe('image generate command', () => { expect(parsed.request.model).toBe('image-01'); }); }); + +describe('image generate command (MiniMax-H3-Image)', () => { + const h3Flags = { ...baseFlags, model: 'MiniMax-H3-Image' }; + + async function captureDryRun(flags: Record) { + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg; }; + try { + await generateCommand.execute( + { ...baseConfig, dryRun: true, output: 'json' as const }, + { ...h3Flags, dryRun: true, ...flags }, + ); + } finally { + console.log = origLog; + } + return JSON.parse(captured).request; + } + + it('builds a text-to-image request', async () => { + const req = await captureDryRun({ prompt: 'A cat', aspectRatio: '16:9', area: '2K', n: 2, outputFormat: 'png' }); + expect(req).toEqual({ + model: 'MiniMax-H3-Image', prompt: 'A cat', n: 2, aspect_ratio: '16:9', area: '2K', output_format: 'png', + }); + }); + + it('maps --width/--height to size and --no-prompt-enhance', async () => { + const req = await captureDryRun({ prompt: 'A cat', width: 2720, height: 1536, noPromptEnhance: true }); + expect(req.size).toBe('2720x1536'); + expect(req.disable_prompt_enhanced).toBe(true); + }); + + it('builds an edit request from --image', async () => { + const req = await captureDryRun({ prompt: 'swap', image: ['https://a/cat.jpg', 'mm_file://7'], responseFormat: 'base64' }); + expect(req.images).toEqual([{ image_url: 'https://a/cat.jpg' }, { image_url: 'mm_file://7' }]); + expect(req.response_format).toBe('b64_json'); + expect(req.disable_prompt_enhanced).toBeUndefined(); + }); + + it('rejects legacy-only flags', async () => { + await expect( + generateCommand.execute(baseConfig, { ...h3Flags, prompt: 'x', seed: 1, subjectRef: 'type=character,image=a.jpg' }), + ).rejects.toThrow('--seed, --subject-ref are not supported by MiniMax-H3-Image'); + }); + + it('rejects H3-only flags without --model MiniMax-H3-Image', async () => { + await expect( + generateCommand.execute(baseConfig, { ...baseFlags, prompt: 'x', size: '2048x2048' }), + ).rejects.toThrow('--size requires --model MiniMax-H3-Image'); + }); + + it('rejects --size together with --width/--height', async () => { + await expect( + generateCommand.execute(baseConfig, { ...h3Flags, prompt: 'x', size: '2048x2048', width: 2048, height: 2048 }), + ).rejects.toThrow('either --size or --width/--height'); + }); + + it('surfaces validation errors as usage errors', async () => { + await expect( + generateCommand.execute(baseConfig, { ...h3Flags, prompt: 'x', size: '4096x1024' }), + ).rejects.toThrow('between 1:3 and 3:1'); + }); + + it('calls /v1/images/generations and saves partial results', async () => { + let path = ''; + let body: Record = {}; + const server = createMockServer({ + routes: { + '/v1/images/': async (req) => { + path = new URL(req.url).pathname; + body = await req.json() as Record; + return jsonResponse({ + created: 1, + output_format: 'webp', + request_id: 'req-1', + data: [ + { b64_json: Buffer.from('img').toString('base64'), size: '2048x2048', revised_prompt: 'rp' }, + { error: { code: '1027', message: 'output image failed content safety check' } }, + ], + usage: { generated_images: 1 }, + }); + }, + }, + }); + const dir = mkdtempSync(join(tmpdir(), 'mmx-h3-cmd-')); + let captured = ''; + let stderr = ''; + const origLog = console.log; + const origErr = process.stderr.write.bind(process.stderr); + console.log = (msg: string) => { captured += msg; }; + process.stderr.write = ((chunk: string | Uint8Array) => { stderr += String(chunk); return true; }) as typeof process.stderr.write; + try { + await generateCommand.execute( + { ...baseConfig, baseUrl: server.url, output: 'json' as const, quiet: true }, + { ...h3Flags, prompt: 'A cat', n: 2, responseFormat: 'base64', outputFormat: 'webp', outDir: dir }, + ); + } finally { + console.log = origLog; + process.stderr.write = origErr; + server.close(); + } + + // Failures are reported on stderr even with --quiet. + expect(stderr).toContain('image 2 failed (1027)'); + expect(path).toBe('/v1/images/generations'); + expect(body.n).toBe(2); + const out = JSON.parse(captured); + expect(out.id).toBe('req-1'); + expect(out.saved).toEqual([join(dir, 'image_001.webp')]); + expect(out.success_count).toBe(1); + expect(out.failed_count).toBe(1); + expect(out.failures[0].code).toBe('1027'); + expect(out.revised_prompts).toEqual(['rp', null]); + expect(readFileSync(join(dir, 'image_001.webp'), 'utf8')).toBe('img'); + rmSync(dir, { recursive: true }); + }); + + it('calls /v1/images/edits when --image is given', async () => { + let path = ''; + const server = createMockServer({ + routes: { + '/v1/images/': (req) => { + path = new URL(req.url).pathname; + return jsonResponse({ output_format: 'jpeg', data: [{ b64_json: 'AA==' }] }); + }, + }, + }); + const dir = mkdtempSync(join(tmpdir(), 'mmx-h3-edit-')); + const origLog = console.log; + console.log = () => {}; + try { + await generateCommand.execute( + { ...baseConfig, baseUrl: server.url, quiet: true }, + { ...h3Flags, prompt: 'edit', image: ['https://a/cat.jpg'], responseFormat: 'b64_json', out: join(dir, 'x.jpg') }, + ); + } finally { + console.log = origLog; + server.close(); + } + expect(path).toBe('/v1/images/edits'); + expect(existsSync(join(dir, 'x.jpg'))).toBe(true); + rmSync(dir, { recursive: true }); + }); +}); diff --git a/test/errors/api-images.test.ts b/test/errors/api-images.test.ts new file mode 100644 index 00000000..dcfe7f5a --- /dev/null +++ b/test/errors/api-images.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'bun:test'; +import { mapApiError } from '../../src/errors/api'; +import { ExitCode } from '../../src/errors/codes'; + +describe('mapApiError (OpenAI-style /v1/images/* errors)', () => { + const url = 'https://api.minimax.io/v1/images/generations'; + + it('maps an all-images content safety failure (1027) to CONTENT_FILTER', () => { + const err = mapApiError(422, { + error: { + type: 'unprocessable_entity_error', + message: 'no image generated: all 2 images failed content safety check (1027)', + http_code: '422', + }, + }, url); + expect(err.exitCode).toBe(ExitCode.CONTENT_FILTER); + }); + + it('maps an input safety failure (1026) to CONTENT_FILTER', () => { + const err = mapApiError(422, { + error: { type: 'unprocessable_entity_error', message: 'input content failed safety check: prompt (1026)' }, + }, url); + expect(err.exitCode).toBe(ExitCode.CONTENT_FILTER); + }); + + it('maps 2068 (plan tier lacks the model) to a plan message', () => { + const err = mapApiError(402, { + error: { type: 'insufficient_balance_error', message: 'model not supported by current plan (2068)' }, + }, url); + expect(err.exitCode).toBe(ExitCode.QUOTA); + expect(err.message).toContain('not available on your current plan'); + }); + + it('lists per-image reasons when every image fails', () => { + const err = mapApiError(500, { + error: { type: 'api_error', message: 'no image generated: all 2 images failed (1000)' }, + data: [ + { error: { code: '1027', message: 'output image failed content safety check' } }, + { error: { code: '1000', message: 'internal error' } }, + ], + }, url); + expect(err.hint).toContain('Image 1 (1027): output image failed content safety check'); + expect(err.hint).toContain('Image 2 (1000): internal error'); + }); + + it('keeps 2068 handling scoped to /v1/images/*', () => { + const err = mapApiError(400, { base_resp: { status_code: 2068, status_msg: 'x' } }, 'https://api.minimax.io/v1/t2a_v2'); + expect(err.exitCode).toBe(ExitCode.GENERAL); + }); + + it('only parses trailing codes on /v1/images/* endpoints', () => { + const err = mapApiError(400, { + error: { type: 'bad_request_error', message: 'something odd (1002)' }, + }, 'https://api.minimax.io/v1/text/chatcompletion_v2'); + expect(err.exitCode).toBe(ExitCode.GENERAL); + }); + + it('keeps generic 400 errors as API errors', () => { + const err = mapApiError(400, { + error: { type: 'bad_request_error', message: 'invalid params, size 4096x1024: aspect ratio must be within 1:3 and 3:1 (2013)' }, + }, url); + expect(err.exitCode).toBe(ExitCode.GENERAL); + expect(err.message).toContain('(2013)'); + }); +}); diff --git a/test/image/h3.test.ts b/test/image/h3.test.ts new file mode 100644 index 00000000..21f39001 --- /dev/null +++ b/test/image/h3.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'bun:test'; +import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + buildImageH3Request, + imageH3Extension, + ImageH3InputError, + isImageH3EditRequest, + resolveImageH3Input, + saveImageH3Images, + validateImageH3Request, +} from '../../src/image/h3'; +import type { ImageH3Request } from '../../src/types/api'; + +describe('b64_json total output area', () => { + it('allows 4 x 4K jpeg but rejects 5 x 4K (auto size counts the area level)', () => { + expect(() => buildImageH3Request({ prompt: 'x', responseFormat: 'b64_json', n: 4 })).not.toThrow(); + expect(() => buildImageH3Request({ prompt: 'x', responseFormat: 'b64_json', n: 5 })).toThrow('80,000,000'); + }); + + it('allows 10 x 2K jpeg', () => { + expect(() => buildImageH3Request({ prompt: 'x', responseFormat: 'b64_json', n: 10, area: '2K' })).not.toThrow(); + }); + + it('uses the 20 MP png limit with explicit sizes', () => { + expect(() => buildImageH3Request({ prompt: 'x', responseFormat: 'b64_json', outputFormat: 'png', size: '4096x4096' })) + .not.toThrow(); + expect(() => buildImageH3Request({ prompt: 'x', responseFormat: 'b64_json', outputFormat: 'png', size: '4096x4096', n: 2 })) + .toThrow('20,000,000'); + }); + + it('does not limit url responses', () => { + expect(() => buildImageH3Request({ prompt: 'x', n: 10 })).not.toThrow(); + }); +}); + +describe('buildImageH3Request', () => { + it('builds a text-to-image request and drops unset fields', () => { + const req = buildImageH3Request({ prompt: 'cat', aspectRatio: '16:9', area: '2K', n: 2 }); + expect(req).toEqual({ model: 'MiniMax-H3-Image', prompt: 'cat', n: 2, aspect_ratio: '16:9', area: '2K' }); + expect(isImageH3EditRequest(req)).toBe(false); + }); + + it('builds an edit request when images are given', () => { + const req = buildImageH3Request({ prompt: 'swap', images: ['https://a/1.jpg', 'mm_file://9'] }); + expect(isImageH3EditRequest(req)).toBe(true); + expect((req as { images: unknown }).images).toEqual([ + { image_url: 'https://a/1.jpg' }, + { image_url: 'mm_file://9' }, + ]); + }); + + it('maps base64 to b64_json', () => { + expect(buildImageH3Request({ prompt: 'x', responseFormat: 'base64' }).response_format).toBe('b64_json'); + }); + + it('rejects disabling enhancement for edits', () => { + expect(() => buildImageH3Request({ prompt: 'x', images: ['https://a/1.jpg'], disablePromptEnhanced: true })) + .toThrow('always on for image edits'); + }); +}); + +describe('validateImageH3Request', () => { + const ok = (extra: Partial) => + validateImageH3Request({ model: 'MiniMax-H3-Image', prompt: 'x', ...extra } as ImageH3Request); + + it('accepts documented sizes', () => { + expect(() => ok({ size: '2720x1536' })).not.toThrow(); + expect(() => ok({ size: '7104x2368' })).not.toThrow(); + expect(() => ok({ size: 'auto', aspect_ratio: '21:9', area: '1K' })).not.toThrow(); + }); + + it('rejects empty prompts and prompts over 32000 characters', () => { + expect(() => ok({ prompt: ' ' })).toThrow('non-empty prompt'); + expect(() => ok({ prompt: '猫'.repeat(32001) })).toThrow('32000'); + expect(() => ok({ prompt: '猫'.repeat(32000) })).not.toThrow(); + }); + + it('rejects n outside 1-10', () => { + expect(() => ok({ n: 0 })).toThrow('1 to 10'); + expect(() => ok({ n: 11 })).toThrow('1 to 10'); + expect(() => ok({ n: 1.5 })).toThrow('1 to 10'); + }); + + it('checks size area and ratio bounds', () => { + expect(() => ok({ size: '800x800' })).toThrow('655,360'); + expect(() => ok({ size: '5000x5000' })).toThrow('20,000,000'); + expect(() => ok({ size: '4096x1024' })).toThrow('1:3 and 3:1'); + expect(() => ok({ size: '2720*1536' })).toThrow('WIDTHxHEIGHT'); + }); + + it('rejects size WxH combined with aspect_ratio or area', () => { + expect(() => ok({ size: '2720x1536', aspect_ratio: '16:9' })).toThrow('cannot be combined'); + expect(() => ok({ size: '2720x1536', area: '2K' })).toThrow('cannot be combined'); + }); + + it('checks aspect ratio format and range', () => { + expect(() => ok({ aspect_ratio: '16/9' })).toThrow('X:Y'); + expect(() => ok({ aspect_ratio: '301:300' })).toThrow('1 to 300'); + expect(() => ok({ aspect_ratio: '4:1' })).toThrow('1:3 and 3:1'); + expect(() => ok({ aspect_ratio: '3:1' })).not.toThrow(); + }); + + it('checks enums and compression', () => { + expect(() => ok({ area: '8K' as never })).toThrow('1K, 2K, 4K'); + expect(() => ok({ output_format: 'gif' as never })).toThrow('jpg, jpeg, png, webp'); + expect(() => ok({ output_compression: 101 })).toThrow('0 to 100'); + expect(() => ok({ response_format: 'base64' as never })).toThrow('url or b64_json'); + }); + + it('requires a concrete size or ratio when enhancement is disabled', () => { + expect(() => ok({ disable_prompt_enhanced: true })).toThrow('explicit size'); + expect(() => ok({ disable_prompt_enhanced: true, aspect_ratio: 'auto' })).toThrow('explicit size'); + expect(() => ok({ disable_prompt_enhanced: true, aspect_ratio: '1:1' })).not.toThrow(); + expect(() => ok({ disable_prompt_enhanced: true, size: '2048x2048' })).not.toThrow(); + }); + + it('limits reference images to 1-16', () => { + const images = (n: number) => Array.from({ length: n }, (_, i) => ({ image_url: `https://a/${i}.jpg` })); + expect(() => ok({ images: images(16) } as never)).not.toThrow(); + expect(() => ok({ images: images(17) } as never)).toThrow('1 to 16'); + expect(() => ok({ images: [] } as never)).toThrow('1 to 16'); + }); +}); + +describe('resolveImageH3Input', () => { + it('passes remote references through', () => { + for (const v of ['https://a/b.png', 'http://a/b.png', 'mm_file://1', 'data:image/png;base64,AA==']) { + expect(resolveImageH3Input(v)).toBe(v); + } + }); + + it('encodes local files and rejects missing or unsupported ones', () => { + const dir = mkdtempSync(join(tmpdir(), 'mmx-h3-in-')); + const png = join(dir, 'a.png'); + writeFileSync(png, Buffer.from([1, 2, 3])); + expect(resolveImageH3Input(png)).toBe('data:image/png;base64,AQID'); + const gif = join(dir, 'a.gif'); + writeFileSync(gif, 'x'); + expect(() => resolveImageH3Input(gif)).toThrow(ImageH3InputError); + expect(() => resolveImageH3Input(join(dir, 'missing.jpg'))).toThrow('not found'); + rmSync(dir, { recursive: true }); + }); +}); + +describe('saveImageH3Images', () => { + it('maps output_format to extensions', () => { + expect(imageH3Extension('jpeg')).toBe('jpg'); + expect(imageH3Extension('png')).toBe('png'); + expect(imageH3Extension('webp')).toBe('webp'); + expect(imageH3Extension(undefined)).toBe('jpg'); + }); + + it('saves successes by position and reports failures', async () => { + const dir = mkdtempSync(join(tmpdir(), 'mmx-h3-save-')); + const result = await saveImageH3Images({ + output_format: 'png', + data: [ + { b64_json: Buffer.from('one').toString('base64'), size: '2048x2048' }, + { error: { code: '1027', message: 'output image failed content safety check' } }, + { b64_json: Buffer.from('three').toString('base64') }, + ], + }, { outDir: dir, prefix: 'h3' }); + + expect(result.saved).toEqual([join(dir, 'h3_001.png'), join(dir, 'h3_003.png')]); + expect(readFileSync(join(dir, 'h3_003.png'), 'utf8')).toBe('three'); + expect(existsSync(join(dir, 'h3_002.png'))).toBe(false); + expect(result.failures).toEqual([ + { index: 1, code: '1027', message: 'output image failed content safety check' }, + ]); + rmSync(dir, { recursive: true }); + }); + + it('rejects out with more than one successful image', async () => { + await expect(saveImageH3Images({ + data: [{ b64_json: 'AA==' }, { b64_json: 'AA==' }], + }, { out: join(tmpdir(), 'x.jpg') })).rejects.toThrow('Cannot use `out` with multiple images'); + }); +}); diff --git a/test/sdk/image.test.ts b/test/sdk/image.test.ts index 0ed039bc..a7e5f91d 100644 --- a/test/sdk/image.test.ts +++ b/test/sdk/image.test.ts @@ -151,3 +151,73 @@ describe('ImageSDK.validateParams', () => { ).rejects.not.toThrow(/width|height/); }); }); + +describe('MiniMaxSDK.image (MiniMax-H3-Image)', () => { + let server: MockServer; + + afterEach(() => { + server?.close(); + }); + + it('routes to /v1/images/generations without images', async () => { + let path = ''; + let body: Record = {}; + server = createMockServer({ + routes: { + '/v1/images/': async (req) => { + path = new URL(req.url).pathname; + body = await req.json() as Record; + return jsonResponse({ output_format: 'jpeg', data: [{ url: 'https://cdn/x.jpg', size: '2048x2048' }], request_id: 'r1' }); + }, + }, + }); + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: server.url }); + const result = await sdk.image.generate({ model: 'MiniMax-H3-Image', prompt: 'cat', aspect_ratio: '16:9', area: '2K' }); + expect(path).toBe('/v1/images/generations'); + expect(body).toEqual({ model: 'MiniMax-H3-Image', prompt: 'cat', aspect_ratio: '16:9', area: '2K' }); + expect(result.data[0]!.url).toBe('https://cdn/x.jpg'); + expect(result.request_id).toBe('r1'); + }); + + it('routes to /v1/images/edits with images', async () => { + let path = ''; + server = createMockServer({ + routes: { + '/v1/images/': (req) => { + path = new URL(req.url).pathname; + return jsonResponse({ data: [{ b64_json: 'AA==' }] }); + }, + }, + }); + const sdk = new MiniMaxSDK({ apiKey: 'test-key', baseUrl: server.url }); + await sdk.image.generate({ + model: 'MiniMax-H3-Image', + prompt: 'swap', + images: [{ image_url: 'https://a/cat.jpg' }], + }); + expect(path).toBe('/v1/images/edits'); + }); + + it('validates H3 requests before sending', async () => { + const sdk = new ImageSDK({ apiKey: 'sk-test', region: 'global' }); + await expect(sdk.generate({ model: 'MiniMax-H3-Image', prompt: 'x', n: 11 })).rejects.toThrow('1 to 10'); + await expect(sdk.generate({ model: 'MiniMax-H3-Image', prompt: 'x', size: '2048x2048', area: '2K' })) + .rejects.toThrow('cannot be combined'); + // An empty images list is an edit request with no images, not a silent text-to-image call. + await expect(sdk.generate({ model: 'MiniMax-H3-Image', prompt: 'x', images: [] })) + .rejects.toThrow('1 to 16'); + }); + + it('saves H3 responses with the returned output format', async () => { + const sdk = new ImageSDK({ apiKey: 'sk-test', region: 'global' }); + const dir = join(tmpdir(), `mmx-sdk-h3-${Date.now()}`); + const saved = await sdk.save({ + output_format: 'png', + data: [{ error: { code: '1000' } }, { b64_json: 'AA==' }], + }, { outDir: dir }); + expect(saved).toEqual([join(dir, 'image_002.png')]); + expect(existsSync(saved[0]!)).toBe(true); + unlinkSync(saved[0]!); + rmdirSync(dir); + }); +}); From 40325825e0a86980da0f3d4356e4e523058d7bb5 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:14:41 +0800 Subject: [PATCH 2/2] fix: use H3 image-generation guide link and treat image 1002 as rate limit mmx image generate help now points at /docs/guides/image-generation. On /v1/images/*, error 1002 (IPM) uses the existing rate-limit path; 1026 and 1027 remain content-safety mappings. --- src/commands/image/generate.ts | 2 +- src/errors/api.ts | 2 +- test/errors/api-images.test.ts | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/commands/image/generate.ts b/src/commands/image/generate.ts index 2251f930..1c4507ef 100644 --- a/src/commands/image/generate.ts +++ b/src/commands/image/generate.ts @@ -164,7 +164,7 @@ async function runImageH3(config: Config, flags: GlobalFlags, prompt: string): P export default defineCommand({ name: 'image generate', description: 'Generate or edit images (image-01 / MiniMax-H3-Image)', - apiDocs: '/docs/api-reference/image-generation-t2i', + apiDocs: '/docs/guides/image-generation', usage: 'mmx image generate --prompt [flags]', options: [ { flag: '--model ', description: 'Model ID: image-01 (default), image-01-live, or MiniMax-H3-Image. Other values are passed to the image-01 endpoint as-is.' }, diff --git a/src/errors/api.ts b/src/errors/api.ts index 6c30fda7..6b57da08 100644 --- a/src/errors/api.ts +++ b/src/errors/api.ts @@ -73,7 +73,7 @@ function mapApiErrorBase(status: number, body: ApiErrorBody, url?: string): CLIE ); } - if (status === 429) { + if (status === 429 || (apiCode === 1002 && url?.includes('/v1/images/'))) { return new CLIError( `Rate limit or quota exceeded. ${apiMsg}`, ExitCode.QUOTA, diff --git a/test/errors/api-images.test.ts b/test/errors/api-images.test.ts index dcfe7f5a..22f84c2e 100644 --- a/test/errors/api-images.test.ts +++ b/test/errors/api-images.test.ts @@ -43,6 +43,21 @@ describe('mapApiError (OpenAI-style /v1/images/* errors)', () => { expect(err.hint).toContain('Image 2 (1000): internal error'); }); + it('maps 1002 (IPM rate limit) on /v1/images/* to QUOTA, not CONTENT_FILTER', () => { + const err = mapApiError(429, { + error: { type: 'rate_limit_error', message: 'rate limit exceeded(IPM) (1002)', http_code: '429' }, + }, url); + expect(err.exitCode).toBe(ExitCode.QUOTA); + expect(err.message).toContain('Rate limit'); + + // Same trailing code with a non-429 status used to hit the content-filter branch. + const byCode = mapApiError(400, { + error: { type: 'rate_limit_error', message: 'rate limit exceeded(IPM) (1002)' }, + }, url); + expect(byCode.exitCode).toBe(ExitCode.QUOTA); + expect(byCode.message).toContain('Rate limit'); + }); + it('keeps 2068 handling scoped to /v1/images/*', () => { const err = mapApiError(400, { base_resp: { status_code: 2068, status_msg: 'x' } }, 'https://api.minimax.io/v1/t2a_v2'); expect(err.exitCode).toBe(ExitCode.GENERAL);