feat(ai): add MiniMax image generation (#59929)

This commit is contained in:
Aiden Cline 2026-10-28 13:41:32 -05:00 • committed by GitHub
parent 506ac20c80
commit 7461d15208
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 635 additions and 15 deletions

View file

@ -586,7 +586,9 @@ M2.x models always think, even when a disabling option is supplied. For tool con
`response.message` in history before adding `Message.tool(...)` results; this preserves reasoning and any signatures.
The default API bases are `https://api.minimax.io/anthropic/v1` for Messages and `https://api.minimax.io/v1` for
Chat and Responses. `configure({ baseURL })` replaces the selected API's base, including its version prefix.
Chat, Responses and Images. `configure({ baseURL })` replaces the selected API's base, including its version prefix;
use `https://api.minimax.cn/v1` for the mainland China platform. `minimax.image("image-01")` (also
`@opencode/ai/providers/minimax/images`) generates images; see [MiniMax images](#minimax-images).
## Meta
@ -704,6 +706,7 @@ table before swapping only the `model`:
| Meta | ✓ | ✓ (hint) | ignored | ✓ | `size`, `outputFormat` |
| Z.ai | ✗ | width × height | width × height | ✗ | `size` |
| Alibaba | ✓ | width × height | width × height | ✓ | `size`, `seed` |
| MiniMax | ✓ | preset or w × h⁴ | preset or w × h⁴ | 1 (portrait subject) | `aspect_ratio`, `width`, `height`, `seed` |
| Black Forest Labs | 1 | per model | width × height models | per model (1–8) | `width`, `height`, `seed`, `output_format`, `mask` |
| fal | ✓ | per model | per model | 1 (several on `/edit`, `/multi`) | `image_size`, `seed`, `output_format`, `mask` |
| Replicate | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | everything |
@ -719,7 +722,8 @@ fixed sizes. `providerOptions.size` replaces `aspect` and `resolution`, so setti
forwards `outputFormat` but does not apply it, so the returned bytes keep the upstream model's own format. ² Workers AI
`@cf/black-forest-labs/flux-1-schnell` takes no size, and the provider rejects it. ³ Non-OpenAI gateway models
(`bfl/flux-2-pro`) get a computed width × height, which the gateway passes through; models with a fixed list, such as
Recraft, reject sizes outside it.
Recraft, reject sizes outside it. ⁴ MiniMax sends its `aspect_ratio` preset for a preset aspect at about `1K` and an
exact width × height (512–2048 px, steps of 8) otherwise; `image-01-live` takes only the presets, minus `21:9`.
`Media.Asset` is the one asset type shared by image requests, image responses, LLM messages, and tool results.
`asset.source` is the serializable `Media.Source` (`bytes`, `base64`, `url`, or `ref`); `asset.bytes()`,
@ -848,6 +852,26 @@ Z.ai does not include trustworthy MIME metadata for output URLs, so generated im
`application/octet-stream` until materialized. Output URLs expire after 30 days; call `asset.materialize()` and
persist the bytes promptly if they must remain available.
### MiniMax images
```ts
import { MiniMax } from "@opencode/ai/providers"
const minimax = Image.generate({
model: MiniMax.configure({ apiKey }).image("image-01"),
prompt,
n: 2,
aspect: "16:9",
images: [Media.url("https://example.com/portrait.jpg")], // optional subject reference
providerOptions: { promptOptimizer: true, seed: 42 },
})
```
The reference image is sent as a `character` subject, so it should be a single front-facing portrait. Images come
back inline (`responseFormat: "base64"` by default); `responseFormat: "url"` returns links that expire after 24 hours
and carry no MIME type until materialized. MiniMax status codes map to typed errors; code 1026 is a `ContentPolicy`
error.
### Partial images
OpenAI's GPT image models stream previews. `Image.stream` sends `stream: true` with `partialImages` (0–3, default 2)

View file

@ -79,6 +79,7 @@
"./providers/meta/responses": "./src/providers/meta/responses.ts",
"./providers/minimax": "./src/providers/minimax.ts",
"./providers/minimax/chat": "./src/providers/minimax/chat.ts",
"./providers/minimax/images": "./src/providers/minimax/images.ts",
"./providers/minimax/messages": "./src/providers/minimax/messages.ts",
"./providers/minimax/responses": "./src/providers/minimax/responses.ts",
"./providers/mistral": "./src/providers/mistral.ts",

View file

@ -0,0 +1,215 @@
import { Duration, Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/http"
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
import { Media } from "../media.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { AIError, QuotaExceededError, mergeJsonRecords, type OpenString } from "../schema/index.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { ImageSizing } from "./utils/image-sizing.js"
import { MediaInput } from "./utils/media-input.js"
const route = MediaProtocol.identity({ id: "minimax-images", name: "MiniMax Images", provider: "minimax" })
export const DEFAULT_BASE_URL = "https://api.minimax.io/v1"
export const PATH = "/image_generation"
const OUTPUT_RETENTION = Duration.hours(24)
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
/** Provider-native options. Common `aspect` and `resolution` lower to `aspect_ratio` or `width` and `height`. */
export type MiniMaxImageOptions = {
/** Defaults to `base64`, which returns the bytes inline; `url` links expire after 24 hours. */
readonly responseFormat?: OpenString<"url" | "base64">
readonly seed?: number
readonly promptOptimizer?: boolean
/** Native preset; it replaces `aspect` and `resolution`. */
readonly aspect_ratio?: string
/** Native size, 512 to 2048 in steps of 8 (`image-01` only); it replaces `aspect` and `resolution`. */
readonly width?: number
readonly height?: number
} & Record<string, unknown>
export type Request = ImageRequestFor<MiniMaxImageOptions>
// ---------------------------------------------------------------------------
// 2. Response schema
// ---------------------------------------------------------------------------
const MiniMaxImageResponse = Schema.Struct({
id: optionalNull(Schema.String),
data: optionalNull(
Schema.Struct({
image_urls: optionalNull(Schema.Array(Schema.String)),
image_base64: optionalNull(Schema.Array(Schema.String)),
}),
),
metadata: optionalNull(
Schema.Struct({
success_count: optionalNull(Schema.Union([Schema.Number, Schema.String])),
failed_count: optionalNull(Schema.Union([Schema.Number, Schema.String])),
}),
),
base_resp: optionalNull(
Schema.Struct({
status_code: Schema.Number,
status_msg: optionalNull(Schema.String),
}),
),
})
const FAILURE: Readonly<Record<string, MediaProtocol.Failure>> = {
1002: "RateLimit",
1004: "Authentication",
2013: "InvalidRequest",
2049: "Authentication",
}
const INSUFFICIENT_BALANCE = 1008
const CONTENT_POLICY = 1026
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
/** MiniMax's `aspect_ratio` presets and the sizes it renders them at. */
const PRESETS: Record<string, ImageSizing.Size> = {
"1:1": { width: 1024, height: 1024 },
"16:9": { width: 1280, height: 720 },
"4:3": { width: 1152, height: 864 },
"3:2": { width: 1248, height: 832 },
"2:3": { width: 832, height: 1248 },
"3:4": { width: 864, height: 1152 },
"9:16": { width: 720, height: 1280 },
"21:9": { width: 1344, height: 576 },
}
const LIMITS = { multiple: 8, minEdge: 512, maxEdge: 2048 }
const nativeOptions = (options: MiniMaxImageOptions = {}) => {
const { responseFormat, promptOptimizer, ...native } = options
return { response_format: responseFormat ?? "base64", prompt_optimizer: promptOptimizer, ...native }
}
/** `image-01-live` rejects `21:9`. */
const presets = (model: string) =>
model === "image-01" ? PRESETS : Object.fromEntries(Object.entries(PRESETS).filter(([key]) => key !== "21:9"))
/** Presets render at about 1K, so they serve a preset aspect with no resolution or `1K`. */
const preset = (request: Request) => {
const aspect = request.aspect ?? "1:1"
if (!Object.hasOwn(presets(request.model.id), aspect)) return undefined
if (request.resolution !== undefined && ImageSizing.tier(request.resolution) !== 1) return undefined
return aspect
}
/** Only `image-01` takes free `width` and `height`; other models render the presets alone. */
const sizing = (request: Request) => {
if (!ImageSizing.isRequested(request)) return Effect.succeed({})
const aspect = preset(request)
if (aspect !== undefined) return Effect.succeed({ aspect_ratio: aspect })
if (request.model.id === "image-01") return ImageSizing.exact(route, request, LIMITS)
const available = presets(request.model.id)
const closest = ImageSizing.nearest(request, Object.values(available))
const name = Object.keys(available).find((key) => available[key] === closest)
return Effect.fail(
route.unsupported(
request.resolution === undefined ? "media.aspect" : "media.resolution",
`${request.model.id} can't render ${[request.aspect, request.resolution].filter((value) => value !== undefined).join(" at ")}; the closest size it takes is ${closest.width}x${closest.height} (${name}). Change aspect or resolution, or set aspect_ratio in providerOptions`,
),
)
}
const isNativeSize = (options: MiniMaxImageOptions | undefined) =>
options?.aspect_ratio !== undefined || options?.width !== undefined || options?.height !== undefined
// The API only takes a `character` (portrait) subject, one per request.
const subject = (asset: Media.Asset) =>
ProviderShared.mediaReference(asset, undefined, `${route.name} input`).pipe(
Effect.map((item) => ({ type: "character", image_file: item.value })),
)
const fromRequest = Effect.fn("MiniMaxImages.fromRequest")(function* (request: Request) {
if ((request.images?.length ?? 0) > 1)
return yield* route.unsupported("media.images", `${route.name} takes one subject reference image`)
if (isNativeSize(request.providerOptions) && ImageSizing.isRequested(request))
return yield* ProviderShared.invalidRequest(
"MiniMax providerOptions aspect_ratio, width and height replace aspect and resolution; set one or the other",
)
const references = yield* Effect.forEach(request.images ?? [], subject)
return MediaProtocol.json(
mergeJsonRecords(
{
model: request.model.id,
prompt: request.prompt,
subject_reference: references.length === 0 ? undefined : references,
n: request.n,
...(yield* sizing(request)),
},
nativeOptions(request.providerOptions),
request.http?.body,
) ?? {},
)
})
// ---------------------------------------------------------------------------
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeDocument = route.decodeJson(MiniMaxImageResponse)
const decodeResponse = Effect.fn("MiniMaxImages.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
const output = yield* decodeDocument(response)
const decoded = output.value
// MiniMax answers errors with HTTP 200 and a non-zero `base_resp.status_code`.
const status = decoded.base_resp
if (status && status.status_code !== 0) {
const message = `${route.name} failed (${status.status_code}): ${status.status_msg ?? "unknown error"}`
if (status.status_code === CONTENT_POLICY) return yield* output.contentPolicy(message)
if (status.status_code === INSUFFICIENT_BALANCE)
return yield* new AIError({ reason: new QuotaExceededError({ message, body: output.body, http: output.http }) })
return yield* output.ended("failed", message, MediaProtocol.failure(FAILURE, status.status_code))
}
const images = [
...(yield* Effect.forEach(decoded.data?.image_base64 ?? [], (data, index) =>
MediaInput.decodedAsset(output.invalid, `${route.name} result ${index}`, data, undefined),
)),
// MiniMax returns no content type for URLs; the media type resolves when the asset is materialized.
...(yield* Effect.forEach(decoded.data?.image_urls ?? [], (url) =>
MediaProtocol.expiringUrl(url, OUTPUT_RETENTION),
)),
]
const failed = Number(decoded.metadata?.failed_count ?? 0)
if (images.length === 0 && failed > 0)
return yield* output.contentPolicy(`${route.name} blocked ${failed} image(s) for content safety`)
if (images.length === 0) return yield* output.invalid(`${route.name} returned no images`)
return new ImageResponse({
images,
providerMetadata: {
minimax: {
id: decoded.id ?? undefined,
successCount: decoded.metadata?.success_count ?? undefined,
failedCount: decoded.metadata?.failed_count ?? undefined,
},
},
})
})
// ---------------------------------------------------------------------------
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, ImageResponse>(route, {
body: { from: fromRequest },
response: { decode: decodeResponse },
})
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<MiniMaxImageOptions>({ protocol, baseURL: DEFAULT_BASE_URL, path: PATH }, input)
export const MiniMaxImages = {
protocol,
model,
} as const

View file

@ -1,6 +1,7 @@
import { Effect, Schema } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { MiniMaxImages, type MiniMaxImageOptions } from "../protocols/minimax-images.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { ProviderShared } from "../protocols/shared.js"
@ -8,8 +9,9 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { MediaRoute } from "../route/media.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { ProviderID, type LLMRequest, type ModelID, type ProviderOptions } from "../schema/index.js"
export const id = ProviderID.make("minimax")
@ -46,6 +48,8 @@ export type Settings<Options = MessagesOptionsInput> = ProviderPackage.Settings
readonly baseURL?: string
}
export type { MiniMaxImageOptions }
const ChatOptions = Schema.Struct({
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
reasoningSplit: Schema.optional(Schema.Boolean),
@ -106,10 +110,11 @@ export const routes = [messagesRoute, chatRoute, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const auth = AuthOptions.bearer(input, "MINIMAX_API_KEY")
const defaults = {
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: AuthOptions.bearer(input, "MINIMAX_API_KEY"),
auth,
}
const messages = (modelID: string | ModelID) =>
messagesRoute.with(defaults).model<MessagesOptionsInput>({ id: modelID })
@ -120,25 +125,34 @@ export const configure = (input: Config = {}) => {
})
const responses = (modelID: string | ModelID) =>
responsesRoute.with(defaults).model<ResponsesOptionsInput>({ id: modelID })
return { id, model: messages, messages, chat, responses, configure }
const media = MediaRoute.deployment(input, auth)
const image = (modelID: string | ModelID, providerOptions?: ProviderOptions) =>
MiniMaxImages.model({ ...media, id: modelID, providerOptions })
return { id, model: messages, messages, chat, responses, image, configure }
}
export const provider = configure()
export const model: ProviderPackage.Model<Settings<MessagesOptionsInput>, MessagesOptionsInput> = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions }: Settings<MessagesOptionsInput> = {},
) =>
configure({
export const model: ProviderPackage.Model<Settings<MessagesOptionsInput>, MessagesOptionsInput> = (modelID, settings) =>
configure(config(settings)).model(modelID)
export const imageModel = (modelID: string, settings?: Settings<MiniMaxImageOptions>) => {
const input = config(settings)
return configure(input).image(modelID, input.providerOptions)
}
export const messages = provider.messages
export const chat = provider.chat
export const responses = provider.responses
export const image = provider.image
function config({ apiKey, baseURL, body, headers, ...providerOptions }: Settings<{}> = {}) {
return {
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body },
providerOptions,
}).model(modelID)
export const messages = provider.messages
export const chat = provider.chat
export const responses = provider.responses
}
}
export * as MiniMax from "./minimax.js"

View file

@ -0,0 +1,4 @@
import type { MiniMax } from "../minimax.js"
export { imageModel as model } from "../minimax.js"
export type Settings = MiniMax.Settings<MiniMax.MiniMaxImageOptions>

View file

@ -36,6 +36,7 @@ import {
Fireworks,
Google,
Meta,
MiniMax,
OpenCode,
OpenAI,
OpenAICompatible,
@ -179,6 +180,8 @@ describe("public exports", () => {
expect(Meta.provider.image).toBe(Meta.image)
expect(ZAI.configure({ apiKey: "fixture" }).image("glm-image").route.id).toBe("zai-images")
expect(ZAI.provider.image).toBe(ZAI.image)
expect(MiniMax.configure({ apiKey: "fixture" }).image("image-01").route.id).toBe("minimax-images")
expect(MiniMax.provider.image).toBe(MiniMax.image)
expect(Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" }).image("qwen-image-2.0").route.id).toBe(
"alibaba-images",
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -142,6 +142,15 @@ const images: ReadonlyArray<Case<ImageModel>> = [
url: "https://open.bigmodel.test/api/paas/v4/images/generations",
auth: ["authorization", "Bearer zai-key"],
},
{
name: "minimax",
load: async () => {
const { model } = await import("@opencode/ai/providers/minimax/images")
return model("image-01", { ...shared, apiKey: "minimax-key", baseURL: "https://api.minimax.cn/v1" })
},
url: "https://api.minimax.cn/v1/image_generation",
auth: ["authorization", "Bearer minimax-key"],
},
{
name: "alibaba",
load: async () => {

View file

@ -0,0 +1,52 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image, Media } from "../../src/effect.js"
import { MiniMax } from "../../src/providers.js"
import { dimensions } from "../lib/image.js"
import { recordedTests } from "../recorded-test.js"
const model = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY ?? "fixture" }).image("image-01")
const recorded = recordedTests({
prefix: "minimax-images",
provider: "minimax",
protocol: "minimax-images",
requires: ["MINIMAX_API_KEY"],
})
describe("MiniMax Images recorded", () => {
recorded.effect("generates an image", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "A simple flat red circle centered on a plain white background.",
aspect: "1:1",
resolution: "0.5K",
})
expect(response.images).toHaveLength(1)
expect(response.image.mediaType).toMatch(/^image\/(jpeg|png)$/)
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 512, height: 512 })
expect(response.providerMetadata?.minimax).toBeDefined()
}),
)
recorded.effect("generates from a subject reference", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "The same woman reading a book in a quiet library, soft window light.",
// MiniMax's own documentation portrait.
images: [
Media.url(
"https://cdn.hailuoai.com/prod/2025-08-12-17/video_cover/1754990600020238321-411603868533342214-cover.jpg",
),
],
resolution: "0.5K",
})
expect(response.images).toHaveLength(1)
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 512, height: 512 })
}),
)
})

View file

@ -0,0 +1,233 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/http"
import { Image, ImageClient, Media } from "../../src/effect.js"
import type { ImageRequestInput } from "../../src/image.js"
import { MiniMax } from "../../src/providers.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
const minimax = MiniMax.configure({ apiKey: "test" })
const imageResponse = JSON.stringify({ data: { image_urls: ["https://cdn.minimax.test/a.jpeg"] } })
/** The JSON body MiniMax receives for a request. */
const sent = (
request: Omit<ImageRequestInput<ReturnType<typeof minimax.image>>, "model" | "prompt">,
model = "image-01",
) =>
Effect.gen(function* () {
let body: unknown
yield* Image.generate({ model: minimax.image(model), prompt: "test", ...request }).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
body = JSON.parse(input.text)
return Effect.succeed(input.respond(imageResponse, { headers: { "content-type": "application/json" } }))
}),
),
),
),
)
return body
})
const failure = (
request: Omit<ImageRequestInput<ReturnType<typeof minimax.image>>, "model" | "prompt">,
model = "image-01",
response = imageResponse,
) =>
Image.generate({ model: minimax.image(model), prompt: "test", ...request }).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(fixedResponse(response, { headers: { "content-type": "application/json" } })),
),
),
Effect.flip,
)
describe("MiniMax Images", () => {
it.effect("generates through the MiniMax image API", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: MiniMax.configure({
apiKey: "test",
baseURL: "https://api.minimax.test/v1",
headers: { "x-default": "yes" },
http: { body: { configured: true } },
}).image("image-01"),
prompt: "A girl stands by the library window",
n: 2,
aspect: "16:9",
images: [Media.url("https://example.test/face.jpg")],
providerOptions: { responseFormat: "url", response_format: "base64", promptOptimizer: true, seed: 7 },
http: { headers: { "x-request": "yes" }, body: { future_option: "http" } },
})
expect(response.images).toHaveLength(2)
expect(yield* response.images[0].bytes()).toEqual(Uint8Array.from([1, 2, 3]))
// MiniMax documents that output URLs expire after 24 hours; the test clock starts at 0.
expect(response.images[1].source).toEqual({
type: "url",
url: "https://cdn.minimax.test/b.jpeg",
expiresAt: 24 * 60 * 60 * 1000,
})
expect(response.providerMetadata).toEqual({ minimax: { id: "trace-1", successCount: 2, failedCount: 0 } })
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe("https://api.minimax.test/v1/image_generation")
expect(request.headers.get("authorization")).toBe("Bearer test")
expect(request.headers.get("x-default")).toBe("yes")
expect(request.headers.get("x-request")).toBe("yes")
expect(JSON.parse(input.text)).toEqual({
model: "image-01",
prompt: "A girl stands by the library window",
subject_reference: [{ type: "character", image_file: "https://example.test/face.jpg" }],
n: 2,
aspect_ratio: "16:9",
response_format: "base64",
prompt_optimizer: true,
seed: 7,
configured: true,
future_option: "http",
})
return input.respond(
JSON.stringify({
id: "trace-1",
data: { image_base64: ["AQID"], image_urls: ["https://cdn.minimax.test/b.jpeg"] },
metadata: { success_count: 2, failed_count: 0 },
base_resp: { status_code: 0, status_msg: "success" },
}),
{ headers: { "content-type": "application/json" } },
)
}),
),
),
),
),
),
)
it.effect("asks for base64 by default and keeps an explicit url format", () =>
Effect.gen(function* () {
expect(yield* sent({})).toEqual({ model: "image-01", prompt: "test", response_format: "base64" })
expect(yield* sent({ providerOptions: { responseFormat: "url" } })).toMatchObject({ response_format: "url" })
}),
)
it.effect("sends inline subject references as data URLs", () =>
Effect.gen(function* () {
expect(yield* sent({ images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png")] })).toMatchObject({
subject_reference: [{ type: "character", image_file: "data:image/png;base64,AQID" }],
})
}),
)
it.effect("uses aspect_ratio presets for about 1K and width and height otherwise", () =>
Effect.gen(function* () {
const cases = [
[{}, {}],
[{ aspect: "16:9" }, { aspect_ratio: "16:9" }],
[{ aspect: "21:9", resolution: "1K" }, { aspect_ratio: "21:9" }],
[{ resolution: "1K" }, { aspect_ratio: "1:1" }],
[
{ aspect: "16:9", resolution: "2K" },
{ width: 2048, height: 1152 },
],
[{ resolution: "2K" }, { width: 2048, height: 2048 }],
[{ aspect: "5:4" }, { width: 1144, height: 912 }],
] as const
yield* Effect.forEach(cases, ([request, expected]) =>
sent(request).pipe(
Effect.tap((body) =>
Effect.sync(() => {
const sizing = Object.fromEntries(
Object.entries(body as Record<string, unknown>).filter(([key]) =>
["aspect_ratio", "width", "height"].includes(key),
),
)
expect(sizing).toEqual(expected)
}),
),
),
)
}),
)
it.effect("fails with the closest size when MiniMax can't honor the shape", () =>
Effect.gen(function* () {
const tooLarge = yield* failure({ aspect: "16:9", resolution: "4K" })
expect(tooLarge.reason._tag).toBe("UnsupportedOperation")
expect(tooLarge.message).toContain("closest size it takes is 2048x1152 (16:9)")
const live = yield* failure({ aspect: "5:4" }, "image-01-live")
expect(live.reason._tag).toBe("UnsupportedOperation")
expect(live.message).toContain("image-01-live can't render 5:4; the closest size it takes is 1152x864 (4:3)")
const liveWide = yield* failure({ aspect: "21:9" }, "image-01-live")
expect(liveWide.message).toContain("the closest size it takes is 1280x720 (16:9)")
const liveLarge = yield* failure({ aspect: "16:9", resolution: "2K" }, "image-01-live")
expect(liveLarge.message).toContain("the closest size it takes is 1280x720 (16:9)")
}),
)
it.effect("keeps presets on image-01-live", () =>
Effect.gen(function* () {
expect(yield* sent({ aspect: "9:16" }, "image-01-live")).toMatchObject({ aspect_ratio: "9:16" })
}),
)
it.effect("rejects requests MiniMax can't take", () =>
Effect.gen(function* () {
const images = yield* failure({
images: [Media.url("https://example.test/a.jpg"), Media.url("https://example.test/b.jpg")],
})
expect(images.reason._tag).toBe("UnsupportedOperation")
const both = yield* failure({ aspect: "16:9", providerOptions: { width: 1024, height: 512 } })
expect(both.reason._tag).toBe("InvalidRequest")
expect(yield* sent({ providerOptions: { width: 1024, height: 512 } })).toMatchObject({ width: 1024, height: 512 })
}),
)
it.effect("maps MiniMax status codes", () =>
Effect.gen(function* () {
const cases = [
[1002, "RateLimit"],
[1004, "Authentication"],
[1008, "QuotaExceeded"],
[1026, "ContentPolicy"],
[2013, "InvalidRequest"],
[9999, "ProviderInternal"],
] as const
yield* Effect.forEach(cases, ([code, tag]) =>
failure(
{},
"image-01",
JSON.stringify({ data: null, base_resp: { status_code: code, status_msg: "message" } }),
).pipe(Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe(tag)))),
)
const blocked = yield* failure(
{},
"image-01",
JSON.stringify({
data: { image_urls: [] },
metadata: { success_count: "0", failed_count: "1" },
base_resp: { status_code: 0, status_msg: "success" },
}),
)
expect(blocked.reason._tag).toBe("ContentPolicy")
const empty = yield* failure({}, "image-01", JSON.stringify({ data: {}, base_resp: { status_code: 0 } }))
expect(empty.reason._tag).toBe("InvalidProviderOutput")
}),
)
})

View file

@ -118,6 +118,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/meta/responses", () => import("@opencode/ai/providers/meta/responses")],
["@opencode/ai/providers/minimax", () => import("@opencode/ai/providers/minimax")],
["@opencode/ai/providers/minimax/chat", () => import("@opencode/ai/providers/minimax/chat")],
["@opencode/ai/providers/minimax/images", () => import("@opencode/ai/providers/minimax/images")],
["@opencode/ai/providers/minimax/messages", () => import("@opencode/ai/providers/minimax/messages")],
["@opencode/ai/providers/minimax/responses", () => import("@opencode/ai/providers/minimax/responses")],
["@opencode/ai/providers/mistral", () => import("@opencode/ai/providers/mistral")],