mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 02:24:53 +00:00
feat(ai): add MiniMax image generation (#59929)
This commit is contained in:
parent
506ac20c80
commit
7461d15208
12 changed files with 635 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
215
packages/ai/src/protocols/minimax-images.ts
Normal file
215
packages/ai/src/protocols/minimax-images.ts
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
4
packages/ai/src/providers/minimax/images.ts
Normal file
4
packages/ai/src/providers/minimax/images.ts
Normal 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>
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
32
packages/ai/test/fixtures/recordings/minimax-images/generates-an-image.json
vendored
Normal file
32
packages/ai/test/fixtures/recordings/minimax-images/generates-an-image.json
vendored
Normal file
File diff suppressed because one or more lines are too long
32
packages/ai/test/fixtures/recordings/minimax-images/generates-from-a-subject-reference.json
vendored
Normal file
32
packages/ai/test/fixtures/recordings/minimax-images/generates-from-a-subject-reference.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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 () => {
|
||||
|
|
|
|||
52
packages/ai/test/provider/minimax-images.recorded.test.ts
Normal file
52
packages/ai/test/provider/minimax-images.recorded.test.ts
Normal 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 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
233
packages/ai/test/provider/minimax-images.test.ts
Normal file
233
packages/ai/test/provider/minimax-images.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -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")],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue