-
Notifications
You must be signed in to change notification settings - Fork 3k
Support MiniMax image generation schema #8322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
a98e3f6
959c8e6
434d911
42c0ea4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -21,6 +21,8 @@ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); | |||||||||||||
| const PNG_SIGNATURE = Buffer.from([ | ||||||||||||||
| 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, | ||||||||||||||
| ]); | ||||||||||||||
| const MINIMAX_IMAGE_GENERATION_PATH = '/v1/image_generation'; | ||||||||||||||
| const MINIMAX_IMAGE_GENERATION_SUFFIX = '/image_generation'; | ||||||||||||||
|
|
||||||||||||||
| class ResponseSizeLimitError extends Error {} | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -77,6 +79,9 @@ export async function generateImage( | |||||||||||||
| 'Image generation baseUrl must be a valid HTTPS URL without credentials, query, or fragment.', | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| if (isMiniMaxImageGenerationBaseUrl(baseUrl)) { | ||||||||||||||
| return generateMiniMaxImage({ ...request, baseUrl, fetchFn }); | ||||||||||||||
| } | ||||||||||||||
| const generationUrl = baseUrl.endsWith( | ||||||||||||||
| '/services/aigc/multimodal-generation/generation', | ||||||||||||||
| ) | ||||||||||||||
|
|
@@ -146,6 +151,86 @@ export async function generateImage( | |||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async function generateMiniMaxImage( | ||||||||||||||
| request: ImageGenerationRequest & { baseUrl: string; fetchFn: typeof fetch }, | ||||||||||||||
| ): Promise<GeneratedImage> { | ||||||||||||||
| const generationUrl = request.baseUrl.endsWith(MINIMAX_IMAGE_GENERATION_PATH) | ||||||||||||||
| ? request.baseUrl | ||||||||||||||
| : `${request.baseUrl}${MINIMAX_IMAGE_GENERATION_SUFFIX}`; | ||||||||||||||
| const body: Record<string, unknown> = { | ||||||||||||||
| model: request.model, | ||||||||||||||
| prompt: request.prompt, | ||||||||||||||
| n: 1, | ||||||||||||||
| prompt_optimizer: true, | ||||||||||||||
| response_format: 'url', | ||||||||||||||
| }; | ||||||||||||||
| const dimensions = parseImageSize(request.size); | ||||||||||||||
| if (dimensions) { | ||||||||||||||
| body['width'] = dimensions.width; | ||||||||||||||
| body['height'] = dimensions.height; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| let response: Response; | ||||||||||||||
| try { | ||||||||||||||
| response = await request.fetchFn(generationUrl, { | ||||||||||||||
| method: 'POST', | ||||||||||||||
| headers: { | ||||||||||||||
| Authorization: `Bearer ${request.apiKey}`, | ||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||
| }, | ||||||||||||||
| body: JSON.stringify(body), | ||||||||||||||
| redirect: 'error', | ||||||||||||||
| signal: combineWithTimeout(request.signal, GENERATION_TIMEOUT_MS), | ||||||||||||||
| }); | ||||||||||||||
| } catch (error) { | ||||||||||||||
| throw new Error( | ||||||||||||||
| `Image generation request failed: ${getErrorMessage(error)}`, | ||||||||||||||
| { cause: error }, | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (!response.ok) { | ||||||||||||||
| let payload: unknown = {}; | ||||||||||||||
| try { | ||||||||||||||
| payload = await readJsonResponse(response, MAX_API_RESPONSE_BYTES); | ||||||||||||||
| } catch { | ||||||||||||||
| // non-JSON error body — formatImageGenerationError handles missing fields | ||||||||||||||
| } | ||||||||||||||
| throw new Error(formatImageGenerationError(response.status, payload)); | ||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] MiniMax error responses nest diagnostic information under Additionally, MiniMax can return HTTP 200 with application-level errors ( Consider extracting — qwen3.7-max via Qwen Code /review |
||||||||||||||
| } | ||||||||||||||
| const payload = await readJsonResponse(response, MAX_API_RESPONSE_BYTES); | ||||||||||||||
| const baseResponse = isRecord(payload) ? payload['base_resp'] : undefined; | ||||||||||||||
| const statusCode = readStringOrNumber(baseResponse, 'status_code'); | ||||||||||||||
| if (statusCode && statusCode !== '0') { | ||||||||||||||
| throw new Error(formatImageGenerationError(response.status, payload)); | ||||||||||||||
| } | ||||||||||||||
| const image = findMiniMaxGeneratedImage(payload); | ||||||||||||||
|
Comment on lines
+199
to
+207
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This — qwen3.8-max-preview via Qwen Code /review |
||||||||||||||
| if (!image) { | ||||||||||||||
| throw new Error('Image generation response did not contain an image URL.'); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+208
to
+210
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This — qwen3.8-max via Qwen Code /review (v0.21.10) |
||||||||||||||
|
|
||||||||||||||
| const requestId = | ||||||||||||||
| readString(payload, 'request_id', 'requestId') ?? | ||||||||||||||
| readString( | ||||||||||||||
| isRecord(payload) ? payload['base_resp'] : undefined, | ||||||||||||||
| 'request_id', | ||||||||||||||
| ); | ||||||||||||||
| if (image.kind === 'base64') { | ||||||||||||||
| return { | ||||||||||||||
| bytes: decodePngBase64Image(image.value), | ||||||||||||||
| mimeType: 'image/png', | ||||||||||||||
| ...(requestId ? { requestId } : {}), | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const bytes = await downloadPng(image.value, request.fetchFn, request.signal); | ||||||||||||||
| return { | ||||||||||||||
| bytes, | ||||||||||||||
| mimeType: 'image/png', | ||||||||||||||
| ...(requestId ? { requestId } : {}), | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async function readJsonResponse( | ||||||||||||||
| response: Response, | ||||||||||||||
| maxBytes: number, | ||||||||||||||
|
|
@@ -201,8 +286,12 @@ async function readBoundedBody( | |||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function formatImageGenerationError(status: number, payload: unknown): string { | ||||||||||||||
| const code = readString(payload, 'code'); | ||||||||||||||
| const message = readString(payload, 'message'); | ||||||||||||||
| const baseResponse = isRecord(payload) ? payload['base_resp'] : undefined; | ||||||||||||||
| const code = | ||||||||||||||
| readStringOrNumber(payload, 'code') ?? | ||||||||||||||
| readStringOrNumber(baseResponse, 'status_code'); | ||||||||||||||
|
Comment on lines
+290
to
+292
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] MiniMax application errors delivered with HTTP 200 — the exact shape this PR adds the Mirror the rate-limit branch and test code+message (and/or map known MiniMax auth/moderation codes): if (
status === 401 ||
status === 403 ||
/access|permission/i.test(`${code} ${message}`)
) {Note this alone only flips payloads whose — qwen3.8-max via Qwen Code /review (v0.21.10) |
||||||||||||||
| const message = | ||||||||||||||
| readString(payload, 'message') ?? readString(baseResponse, 'status_msg'); | ||||||||||||||
| const suffix = [code, message].filter(Boolean).join(': '); | ||||||||||||||
|
|
||||||||||||||
| if (status === 429 || /throttl|rate.?limit/i.test(`${code} ${message}`)) { | ||||||||||||||
|
|
@@ -218,7 +307,9 @@ function formatImageGenerationError(status: number, payload: unknown): string { | |||||||||||||
| if (/DataInspectionFailed/i.test(code ?? '')) { | ||||||||||||||
| return `The image generation endpoint blocked the prompt during content moderation${message ? `: ${message}` : '.'}`; | ||||||||||||||
| } | ||||||||||||||
| return `Image generation failed with HTTP ${status}${suffix ? ` (${suffix})` : ''}.`; | ||||||||||||||
| const statusText = | ||||||||||||||
| status >= 200 && status < 300 ? '' : ` with HTTP ${status}`; | ||||||||||||||
| return `Image generation failed${statusText}${suffix ? ` (${suffix})` : ''}.`; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function findGeneratedImageUrl(payload: unknown): string | undefined { | ||||||||||||||
|
|
@@ -241,6 +332,70 @@ function findGeneratedImageUrl(payload: unknown): string | undefined { | |||||||||||||
| return undefined; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function isMiniMaxImageGenerationBaseUrl(baseUrl: string): boolean { | ||||||||||||||
| let parsed: URL; | ||||||||||||||
| try { | ||||||||||||||
| parsed = new URL(baseUrl); | ||||||||||||||
| } catch { | ||||||||||||||
| return false; | ||||||||||||||
| } | ||||||||||||||
| const normalizedPath = parsed.pathname.replace(/\/+$/, ''); | ||||||||||||||
| return ( | ||||||||||||||
| (parsed.hostname === 'api.minimax.io' || | ||||||||||||||
| parsed.hostname === 'api.minimaxi.com') && | ||||||||||||||
|
Comment on lines
+344
to
+345
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This hostname allowlist exact-matches only the two official hosts, while the chat provider (
Suggested change
Longer-term, hoist one shared known-hosts/suffixes constant, or carry the wire schema from the resolved provider config instead of sniffing hostnames. — qwen3.8-max via Qwen Code /review (v0.21.10) |
||||||||||||||
| (normalizedPath === '/v1' || | ||||||||||||||
| normalizedPath === MINIMAX_IMAGE_GENERATION_PATH) | ||||||||||||||
|
Comment on lines
+344
to
+347
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This hostname allowlist is implicitly coupled to the preset's — qwen3.8-max-preview via Qwen Code /review |
||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function parseImageSize( | ||||||||||||||
| value: string | undefined, | ||||||||||||||
| ): { width: number; height: number } | undefined { | ||||||||||||||
| const match = value?.trim().match(/^(\d+)\s*[*xX]\s*(\d+)$/); | ||||||||||||||
| if (!match) return undefined; | ||||||||||||||
| const width = Number(match[1]); | ||||||||||||||
| const height = Number(match[2]); | ||||||||||||||
| if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) { | ||||||||||||||
| return undefined; | ||||||||||||||
| } | ||||||||||||||
| return { width, height }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function findMiniMaxGeneratedImage( | ||||||||||||||
| payload: unknown, | ||||||||||||||
| ): { kind: 'url' | 'base64'; value: string } | undefined { | ||||||||||||||
| if (!isRecord(payload)) return undefined; | ||||||||||||||
| const data = payload['data']; | ||||||||||||||
| if (!isRecord(data)) return undefined; | ||||||||||||||
| const imageUrls = data['image_urls']; | ||||||||||||||
| if (!Array.isArray(imageUrls)) return undefined; | ||||||||||||||
|
|
||||||||||||||
| for (const candidate of imageUrls) { | ||||||||||||||
| if (typeof candidate !== 'string' || !candidate.trim()) continue; | ||||||||||||||
| const value = candidate.trim(); | ||||||||||||||
| if (/^https:\/\//i.test(value)) { | ||||||||||||||
| return { kind: 'url', value }; | ||||||||||||||
| } | ||||||||||||||
| return { kind: 'base64', value }; | ||||||||||||||
| } | ||||||||||||||
| return undefined; | ||||||||||||||
|
Comment on lines
+376
to
+381
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This — qwen3.8-max-preview via Qwen Code /review |
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function decodePngBase64Image(value: string): Buffer { | ||||||||||||||
| const match = value.match(/^data:image\/png;base64,(.+)$/i); | ||||||||||||||
| const base64 = (match?.[1] ?? value).trim(); | ||||||||||||||
|
Comment on lines
+384
to
+386
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The PNG-signature validation in — qwen3.8-max-preview via Qwen Code /review
Comment on lines
+385
to
+386
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The Add a case to // image_urls: [Buffer.from(PNG_BYTES).toString('base64')]
// expect the decoded result.bytes to equal PNG_BYTES and fetchFn not to be called— qwen3.8-max via Qwen Code /review (v0.21.10) |
||||||||||||||
| const bytes = Buffer.from(base64, 'base64'); | ||||||||||||||
| if ( | ||||||||||||||
| bytes.length < PNG_SIGNATURE.length || | ||||||||||||||
| !bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) | ||||||||||||||
| ) { | ||||||||||||||
| throw new Error( | ||||||||||||||
| 'Image generation response did not contain a valid PNG image.', | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| return bytes; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async function downloadPng( | ||||||||||||||
| imageUrl: string, | ||||||||||||||
| fetchFn: typeof fetch, | ||||||||||||||
|
|
@@ -381,6 +536,23 @@ function readString(value: unknown, ...keys: string[]): string | undefined { | |||||||||||||
| return undefined; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function readStringOrNumber( | ||||||||||||||
| value: unknown, | ||||||||||||||
| ...keys: string[] | ||||||||||||||
| ): string | undefined { | ||||||||||||||
| if (!isRecord(value)) return undefined; | ||||||||||||||
| for (const key of keys) { | ||||||||||||||
| const candidate = value[key]; | ||||||||||||||
| if (typeof candidate === 'number' && Number.isFinite(candidate)) { | ||||||||||||||
| return String(candidate); | ||||||||||||||
| } | ||||||||||||||
| if (typeof candidate === 'string' && candidate.trim()) { | ||||||||||||||
| return candidate.trim(); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| return undefined; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||||||||||||||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||||||||||||||
| } | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The
size-absent branch (request body omitswidth/height) has no test assertion: the schema test pins the body only with size, and 'accepts a full MiniMax image generation endpoint' omitssizebut asserts only the request URL. — Failure scenario: a mutation that always appends dimensions (e.g.Number(undefined)serialized asnull, or a hardcoded default) passes the suite green and sends MiniMax a malformed or wrong-dimension request for users who run image generation without a configured size. Current behavior was probe-confirmed correct (body carries nowidth/heightkeys whensizeis absent) — this only pins it.In the full-endpoint test, also assert the body:
— qwen3.8-max via Qwen Code /review (v0.21.10)