From 670f15352b3d338754af60846b3631da294d4551 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:44:39 +0800 Subject: [PATCH] Add MiniMax image generation extension --- apps/app/src/app/extensions.ts | 11 +- .../domains/settings/minimax-config.tsx | 2 + .../domains/settings/minimax-provider.ts | 8 + .../settings/openai-image-extension.ts | 1 + .../src/react-app/shell/settings-route.tsx | 8 + apps/app/tests/minimax-provider.test.ts | 19 + .../src/extensions-connect-gating.test.ts | 5 +- apps/server/src/extensions/index.ts | 11 + .../minimax-image-generation.test.ts | 184 ++++++++ .../extensions/minimax-image-generation.ts | 438 ++++++++++++++++++ apps/server/src/plugin-package-manifest.ts | 2 +- 11 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/extensions/minimax-image-generation.test.ts create mode 100644 apps/server/src/extensions/minimax-image-generation.ts diff --git a/apps/app/src/app/extensions.ts b/apps/app/src/app/extensions.ts index 0c2750bb2..c60669a0a 100644 --- a/apps/app/src/app/extensions.ts +++ b/apps/app/src/app/extensions.ts @@ -458,15 +458,19 @@ export const BUILT_IN_IPOLLOWORK_EXTENSION_MANIFESTS: iPolloWorkExtensionManifes schemaVersion: 1, id: "minimax", name: "MiniMax", - description: "Configure MiniMax models through regional OpenAI-compatible or Anthropic endpoints.", + description: "Configure MiniMax models and generate workspace image artifacts through regional endpoints.", source: { format: "ipollowork-builtin", origin: "builtin", trusted: true }, composer: { prompt: "Use MiniMax to " }, setup: { - instructions: "Save a MiniMax API key, choose a regional endpoint, and add both current MiniMax models to OpenCode.", + instructions: "Save a MiniMax API key, choose a regional endpoint, add the current models to OpenCode, and enable workspace image generation.", primaryCta: "Configure MiniMax", + requiredEnv: ["MINIMAX_API_KEY"], }, resources: [ { type: "provider", id: "minimax", label: "MiniMax", providerId: "minimax", required: true }, + { type: "secret", id: "minimax-api-key", envKey: "MINIMAX_API_KEY", required: true }, + { type: "local-service", id: "minimax-image-generation-service", label: "MiniMax image generation", required: true }, + { type: "tool", id: "minimax-image-generate", label: "Image generation", required: true }, ], contributions: [ { type: "settings-panel", ref: "ipollowork.minimax.settings", location: "settings-detail" }, @@ -474,8 +478,9 @@ export const BUILT_IN_IPOLLOWORK_EXTENSION_MANIFESTS: iPolloWorkExtensionManifes ], enablement: [ { type: "provider-connected", ref: "minimax", label: "MiniMax provider" }, + { type: "env-set", ref: "MINIMAX_API_KEY", label: "MiniMax API key" }, ], - lifecycle: { reload: ["config"], detection: ["provider:minimax"] }, + lifecycle: { reload: ["config"], detection: ["provider:minimax", "env:MINIMAX_API_KEY"] }, }, { schemaVersion: 1, diff --git a/apps/app/src/react-app/domains/settings/minimax-config.tsx b/apps/app/src/react-app/domains/settings/minimax-config.tsx index e51bfaf4e..c1a14f59d 100644 --- a/apps/app/src/react-app/domains/settings/minimax-config.tsx +++ b/apps/app/src/react-app/domains/settings/minimax-config.tsx @@ -27,6 +27,7 @@ import { registerExtensionConfig, type ExtensionConfigContext } from "./extensio import type { LocalProviderInstallInput } from "./openai-image-extension"; import { buildMiniMaxProviderConfig, + buildMiniMaxRuntimeEnv, getMiniMaxEndpoint, MINIMAX_ENDPOINTS, MINIMAX_PROVIDER, @@ -88,6 +89,7 @@ export function MiniMaxConfig(props: MiniMaxConfigProps) { modelId: selectedModel.id, modelName: selectedModel.id, models: provider.models, + userEnv: buildMiniMaxRuntimeEnv(endpointId, trimmedApiKey), setDefault, }); }; diff --git a/apps/app/src/react-app/domains/settings/minimax-provider.ts b/apps/app/src/react-app/domains/settings/minimax-provider.ts index 8fb866746..b79117f49 100644 --- a/apps/app/src/react-app/domains/settings/minimax-provider.ts +++ b/apps/app/src/react-app/domains/settings/minimax-provider.ts @@ -100,6 +100,14 @@ export function getMiniMaxEndpoint(endpointId: MiniMaxEndpointId): MiniMaxEndpoi return endpoint; } +export function buildMiniMaxRuntimeEnv(endpointId: MiniMaxEndpointId, apiKey: string) { + const endpoint = getMiniMaxEndpoint(endpointId); + return [ + { key: "MINIMAX_API_KEY", value: apiKey }, + { key: "MINIMAX_BASE_URL", value: new URL(endpoint.baseURL).origin }, + ]; +} + export function buildMiniMaxProviderConfig(endpointId: MiniMaxEndpointId): ProviderConfig { const endpoint = getMiniMaxEndpoint(endpointId); const models = Object.fromEntries( diff --git a/apps/app/src/react-app/domains/settings/openai-image-extension.ts b/apps/app/src/react-app/domains/settings/openai-image-extension.ts index c6ade1ebe..a51fa7834 100644 --- a/apps/app/src/react-app/domains/settings/openai-image-extension.ts +++ b/apps/app/src/react-app/domains/settings/openai-image-extension.ts @@ -12,6 +12,7 @@ export type LocalProviderInstallInput = { modelId: string; modelName: string; models?: Record; + userEnv?: Array<{ key: string; value: string }>; setDefault: boolean; }; diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index d530eaaaf..e5aee8622 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -959,6 +959,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const models = input.models ?? { [modelId]: { name: input.modelName.trim() || modelId }, }; + const userEnv = input.userEnv ?? []; if (!client || !workspaceId) { setLocalProviderError("iPolloWork server is not connected for this workspace."); return; @@ -997,6 +998,13 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { auth: { type: "api", key: input.apiKey.trim() }, }); } + if (userEnv.length) { + await client.upsertUserEnv(userEnv); + setUserEnvKeys((current) => Array.from(new Set([ + ...current, + ...userEnv.map((entry) => entry.key), + ]))); + } if (input.setDefault) { local.setPrefs((previous) => ({ ...previous, diff --git a/apps/app/tests/minimax-provider.test.ts b/apps/app/tests/minimax-provider.test.ts index 21660a3df..d8eee3b71 100644 --- a/apps/app/tests/minimax-provider.test.ts +++ b/apps/app/tests/minimax-provider.test.ts @@ -4,6 +4,7 @@ import { isRecommendedModel } from "../src/app/defaults/models"; import { BUILT_IN_IPOLLOWORK_EXTENSION_MANIFESTS } from "../src/app/extensions"; import { buildMiniMaxProviderConfig, + buildMiniMaxRuntimeEnv, MINIMAX_ENDPOINTS, MINIMAX_PROVIDER, } from "../src/react-app/domains/settings/minimax-provider"; @@ -110,6 +111,17 @@ describe("MiniMax provider preset", () => { }); }); + test("keeps image credentials server-side on the selected regional origin", () => { + expect(buildMiniMaxRuntimeEnv("global-openai", "test-key")).toEqual([ + { key: "MINIMAX_API_KEY", value: "test-key" }, + { key: "MINIMAX_BASE_URL", value: "https://api.minimax.io" }, + ]); + expect(buildMiniMaxRuntimeEnv("cn-anthropic", "test-key")).toEqual([ + { key: "MINIMAX_API_KEY", value: "test-key" }, + { key: "MINIMAX_BASE_URL", value: "https://api.minimaxi.com" }, + ]); + }); + test("registers the executable extension and recommends both models", () => { const extension = BUILT_IN_IPOLLOWORK_EXTENSION_MANIFESTS.find( (manifest) => manifest.id === "minimax", @@ -121,6 +133,13 @@ describe("MiniMax provider preset", () => { providerId: "minimax", required: true, }); + expect(extension?.resources).toContainEqual({ + type: "tool", + id: "minimax-image-generate", + label: "Image generation", + required: true, + }); + expect(extension?.setup?.requiredEnv).toEqual(["MINIMAX_API_KEY"]); expect(extension?.contributions).toContainEqual({ type: "settings-panel", ref: "ipollowork.minimax.settings", diff --git a/apps/server/src/extensions-connect-gating.test.ts b/apps/server/src/extensions-connect-gating.test.ts index 2f8743356..242f54c01 100644 --- a/apps/server/src/extensions-connect-gating.test.ts +++ b/apps/server/src/extensions-connect-gating.test.ts @@ -186,8 +186,9 @@ async function expectLegacyCallPassesThrough(base: string) { } function expectAllActions(actions: ActionItem[]) { - expect(actions).toHaveLength(33); + expect(actions).toHaveLength(35); expect(actions.filter((action) => action.extensionId === "google-workspace")).toHaveLength(14); + expect(actions.filter((action) => action.extensionId === "minimax")).toHaveLength(2); expect(actions.filter((action) => action.extensionId === "openai-image-generation")).toHaveLength(2); expect(actions.filter((action) => action.extensionId === "media")).toHaveLength(15); expect(actions.filter((action) => action.extensionId === "storage")).toHaveLength(2); @@ -277,6 +278,8 @@ describe("Connect-aware legacy extension gating", () => { "media/voice_clone_workspace_file", "media/voice_list", "media/voiceover_timeline_validate", + "minimax/image_generate", + "minimax/status", "openai-image-generation/image_generate", "openai-image-generation/status", "storage/status", diff --git a/apps/server/src/extensions/index.ts b/apps/server/src/extensions/index.ts index 213402848..1251e9043 100644 --- a/apps/server/src/extensions/index.ts +++ b/apps/server/src/extensions/index.ts @@ -22,6 +22,11 @@ import { OPENAI_IMAGE_GENERATION_EXTENSION_ACTIONS, OPENAI_IMAGE_GENERATION_EXTENSION_ID, } from "./openai-image-generation.js"; +import { + callMiniMaxExtensionAction, + MINIMAX_EXTENSION_ACTIONS, + MINIMAX_EXTENSION_ID, +} from "./minimax-image-generation.js"; import { MEDIA_EXTENSION_ACTIONS, MEDIA_EXTENSION_ID, @@ -36,6 +41,7 @@ import { const IPOLLOWORK_EXPERIMENTAL_EXTENSION_ACTIONS = [ ...GOOGLE_WORKSPACE_EXTENSION_ACTIONS, ...OPENAI_IMAGE_GENERATION_EXTENSION_ACTIONS, + ...MINIMAX_EXTENSION_ACTIONS, ...MEDIA_EXTENSION_ACTIONS, ...STORAGE_EXTENSION_ACTIONS, ]; @@ -114,6 +120,11 @@ export async function callExperimentalExtensionAction(config: ServerConfig, env: if (result) return result; } + if (extensionId === MINIMAX_EXTENSION_ID) { + const result = await callMiniMaxExtensionAction(config, env, action, args, context); + if (result) return result; + } + if (extensionId === MEDIA_EXTENSION_ID) { const result = await callMediaExtensionAction(config, env, action, args, context); if (result) return result; diff --git a/apps/server/src/extensions/minimax-image-generation.test.ts b/apps/server/src/extensions/minimax-image-generation.test.ts new file mode 100644 index 000000000..0e82488bb --- /dev/null +++ b/apps/server/src/extensions/minimax-image-generation.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { EnvService } from "../env-file.js"; +import type { ServerConfig } from "../types.js"; +import { + MINIMAX_EXTENSION_ACTIONS, + MINIMAX_IMAGE_ENDPOINT_REGISTRY, + MINIMAX_IMAGE_MODEL_REGISTRY, + callMiniMaxExtensionAction, +} from "./minimax-image-generation.js"; + +const nativeFetch = globalThis.fetch; +const previousApiKey = process.env.MINIMAX_API_KEY; +const previousBaseUrl = process.env.MINIMAX_BASE_URL; +const directories: string[] = []; +const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII="; + +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +function serverConfig(root: string): ServerConfig { + return { + host: "127.0.0.1", + port: 0, + token: "client", + hostToken: "host", + approval: { mode: "auto", timeoutMs: 1_000 }, + corsOrigins: ["*"], + workspaces: [{ id: "workspace", name: "Workspace", path: root, preset: "starter", workspaceType: "local" }], + authorizedRoots: [root], + readOnly: false, + startedAt: Date.now(), + tokenSource: "generated", + hostTokenSource: "generated", + logFormat: "pretty", + logRequests: false, + }; +} + +async function testContext(values: Record = {}) { + const root = await mkdtemp(join(tmpdir(), "ipollowork-minimax-image-")); + directories.push(root); + const env = new EnvService({ path: join(root, "env.json") }); + await env.upsertMany(Object.entries(values).map(([key, value]) => ({ key, value }))); + return { root, env, config: serverConfig(root) }; +} + +afterEach(async () => { + globalThis.fetch = nativeFetch; + restoreEnv("MINIMAX_API_KEY", previousApiKey); + restoreEnv("MINIMAX_BASE_URL", previousBaseUrl); + while (directories.length) { + const directory = directories.pop(); + if (directory) await rm(directory, { recursive: true, force: true }); + } +}); + +describe("MiniMax image generation extension", () => { + test("registers the supplied image models and regional endpoints", () => { + expect(MINIMAX_IMAGE_MODEL_REGISTRY).toEqual({ + defaultModel: "image-01", + models: ["image-01", "image-01-live"], + }); + expect(MINIMAX_IMAGE_ENDPOINT_REGISTRY).toEqual([ + { region: "global_en", url: "https://api.minimax.io/v1/image_generation" }, + { region: "cn_zh", url: "https://api.minimaxi.com/v1/image_generation" }, + ]); + expect(MINIMAX_EXTENSION_ACTIONS.map((action) => action.action)).toEqual(["status", "image_generate"]); + }); + + test("writes base64 image results as workspace artifacts", async () => { + const { root, env, config } = await testContext({ MINIMAX_API_KEY: "test-key" }); + globalThis.fetch = ((input, init) => { + expect(String(input)).toBe("https://api.minimaxi.com/v1/image_generation"); + expect(init?.headers).toMatchObject({ Authorization: "Bearer test-key" }); + expect(JSON.parse(String(init?.body))).toEqual({ + model: "image-01-live", + prompt: "A red paper lantern", + response_format: "base64", + aspect_ratio: "1:1", + width: 1024, + height: 1024, + seed: 7, + n: 1, + prompt_optimizer: false, + }); + return Promise.resolve(new Response(JSON.stringify({ + data: { image_urls: [pngBase64] }, + metadata: { success_count: 1, failed_count: 0 }, + base_resp: { status_code: 0 }, + }), { status: 200, headers: { "content-type": "application/json" } })); + }) as typeof fetch; + + const result = await callMiniMaxExtensionAction(config, env, "image_generate", { + prompt: "A red paper lantern", + model: "image-01-live", + region: "cn_zh", + filename: "lantern", + aspectRatio: "1:1", + width: 1024, + height: 1024, + responseFormat: "base64", + seed: 7, + n: 1, + promptOptimizer: false, + }, { directory: root }); + + expect(result).toMatchObject({ + ok: true, + path: "artifacts/lantern.png", + result: { + model: "image-01-live", + region: "cn_zh", + responseFormat: "base64", + metadata: { successCount: 1, failedCount: 0 }, + }, + }); + expect((await readFile(join(root, "artifacts/lantern.png"))).subarray(0, 8)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + expect(JSON.stringify(result)).not.toContain("test-key"); + }); + + test("uses the configured regional origin and downloads URL responses", async () => { + const { root, env, config } = await testContext({ + MINIMAX_API_KEY: "test-key", + MINIMAX_BASE_URL: "https://api.minimaxi.com", + }); + const requested: string[] = []; + globalThis.fetch = ((input) => { + requested.push(String(input)); + if (requested.length === 1) { + return Promise.resolve(new Response(JSON.stringify({ + data: { image_urls: ["https://cdn.example.test/image"] }, + metadata: { success_count: 1, failed_count: 0 }, + base_resp: { status_code: 0 }, + }), { status: 200, headers: { "content-type": "application/json" } })); + } + return Promise.resolve(new Response(Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]), { status: 200 })); + }) as typeof fetch; + + const result = await callMiniMaxExtensionAction(config, env, "image_generate", { + prompt: "A glass sculpture", + filename: "sculpture", + responseFormat: "url", + }, { directory: root }); + + expect(requested).toEqual([ + "https://api.minimaxi.com/v1/image_generation", + "https://cdn.example.test/image", + ]); + expect(result).toMatchObject({ path: "artifacts/sculpture.jpg", result: { region: "cn_zh" } }); + expect(await readFile(join(root, "artifacts/sculpture.jpg"))).toEqual( + Buffer.from([0xff, 0xd8, 0xff, 0xd9]), + ); + }); + + test("rejects missing credentials and non-zero response status codes", async () => { + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_BASE_URL; + const missing = await testContext(); + await expect(callMiniMaxExtensionAction(missing.config, missing.env, "image_generate", { + prompt: "A clean product sketch", + }, { directory: missing.root })).rejects.toMatchObject({ code: "minimax_api_key_missing" }); + + const configured = await testContext({ MINIMAX_API_KEY: "test-key" }); + globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ + data: { image_urls: [] }, + base_resp: { status_code: 2004, status_msg: "Invalid parameter" }, + }), { status: 200, headers: { "content-type": "application/json" } }))) as unknown as typeof fetch; + await expect(callMiniMaxExtensionAction(configured.config, configured.env, "image_generate", { + prompt: "A clean product sketch", + }, { directory: configured.root })).rejects.toMatchObject({ + status: 502, + code: "minimax_image_generation_failed", + message: "Invalid parameter", + }); + }); +}); diff --git a/apps/server/src/extensions/minimax-image-generation.ts b/apps/server/src/extensions/minimax-image-generation.ts new file mode 100644 index 000000000..7507c9b54 --- /dev/null +++ b/apps/server/src/extensions/minimax-image-generation.ts @@ -0,0 +1,438 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { ApiError } from "../errors.js"; +import type { EnvService } from "../env-file.js"; +import type { ServerConfig } from "../types.js"; +import { resolveWorkspaceFile, workspaceForContext } from "./storage.js"; + +export const MINIMAX_EXTENSION_ID = "minimax"; +export const MINIMAX_IMAGE_MODEL_REGISTRY = { + defaultModel: "image-01", + models: ["image-01", "image-01-live"], +} as const; +export const MINIMAX_IMAGE_ENDPOINT_REGISTRY = [ + { region: "global_en", url: "https://api.minimax.io/v1/image_generation" }, + { region: "cn_zh", url: "https://api.minimaxi.com/v1/image_generation" }, +] as const; + +const MINIMAX_IMAGE_TIMEOUT_MS = 60_000; +const MINIMAX_IMAGE_RESPONSE_FORMATS = ["url", "base64"] as const; + +type JsonRecord = Record; +type MiniMaxImageModel = (typeof MINIMAX_IMAGE_MODEL_REGISTRY.models)[number]; +type MiniMaxImageRegion = (typeof MINIMAX_IMAGE_ENDPOINT_REGISTRY)[number]["region"]; +type MiniMaxImageResponseFormat = (typeof MINIMAX_IMAGE_RESPONSE_FORMATS)[number]; + +export const MINIMAX_EXTENSION_ACTIONS = [ + { + extensionId: MINIMAX_EXTENSION_ID, + action: "status", + title: "MiniMax image generation status", + description: "Check whether MiniMax image generation is configured and ready for iPolloWork extension actions.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + extensionId: MINIMAX_EXTENSION_ID, + action: "image_generate", + title: "Generate MiniMax image artifacts", + description: "Generate workspace image artifacts from a text prompt with MiniMax image generation.", + inputSchema: { + type: "object", + properties: { + prompt: { type: "string", description: "Image prompt to turn into workspace artifacts." }, + model: { type: "string", enum: [...MINIMAX_IMAGE_MODEL_REGISTRY.models], description: "Optional MiniMax image model." }, + region: { type: "string", enum: MINIMAX_IMAGE_ENDPOINT_REGISTRY.map((entry) => entry.region), description: "Optional regional endpoint." }, + filename: { type: "string", description: "Optional output filename without extension." }, + aspectRatio: { type: "string", description: "Optional aspect_ratio value." }, + width: { type: "integer", description: "Optional output width." }, + height: { type: "integer", description: "Optional output height." }, + responseFormat: { type: "string", enum: [...MINIMAX_IMAGE_RESPONSE_FORMATS], description: "Optional response format. Defaults to base64." }, + seed: { type: "integer", description: "Optional deterministic seed." }, + n: { type: "integer", description: "Optional number of images." }, + promptOptimizer: { type: "boolean", description: "Optional prompt_optimizer value." }, + }, + required: ["prompt"], + additionalProperties: false, + }, + }, +]; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readStringField(value: unknown, key: string): string { + if (!isRecord(value)) return ""; + const field = value[key]; + return typeof field === "string" ? field.trim() : ""; +} + +function readOptionalNumber(value: unknown, key: string): number | undefined { + if (!isRecord(value)) return undefined; + const field = value[key]; + return typeof field === "number" && Number.isFinite(field) ? field : undefined; +} + +function readOptionalBoolean(value: unknown, key: string): boolean | undefined { + if (!isRecord(value)) return undefined; + const field = value[key]; + return typeof field === "boolean" ? field : undefined; +} + +function readInteger(value: unknown, key: string, positive = false): number | undefined { + const result = readOptionalNumber(value, key); + if (result === undefined) return undefined; + if (!Number.isInteger(result) || (positive && result <= 0)) { + throw new ApiError(400, "invalid_payload", `${key} must be ${positive ? "a positive " : "an "}integer`); + } + return result; +} + +function isMiniMaxImageModel(value: string): value is MiniMaxImageModel { + return MINIMAX_IMAGE_MODEL_REGISTRY.models.some((model) => model === value); +} + +function isMiniMaxImageRegion(value: string): value is MiniMaxImageRegion { + return MINIMAX_IMAGE_ENDPOINT_REGISTRY.some((endpoint) => endpoint.region === value); +} + +function isMiniMaxImageResponseFormat(value: string): value is MiniMaxImageResponseFormat { + return MINIMAX_IMAGE_RESPONSE_FORMATS.some((format) => format === value); +} + +function regionFromBaseUrl(value: string): MiniMaxImageRegion { + let url: URL; + try { + url = new URL(value); + } catch { + throw new ApiError(400, "invalid_minimax_base_url", "MINIMAX_BASE_URL must be a valid MiniMax HTTPS URL"); + } + const allowedPaths = new Set(["/", "/v1", "/anthropic"]); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !allowedPaths.has(url.pathname)) { + throw new ApiError(400, "invalid_minimax_base_url", "MINIMAX_BASE_URL must be a trusted MiniMax HTTPS endpoint"); + } + if (url.hostname === "api.minimax.io") return "global_en"; + if (url.hostname === "api.minimaxi.com") return "cn_zh"; + throw new ApiError(400, "invalid_minimax_base_url", "MINIMAX_BASE_URL must use an official MiniMax API host"); +} + +function endpointFor(region: MiniMaxImageRegion) { + const endpoint = MINIMAX_IMAGE_ENDPOINT_REGISTRY.find((entry) => entry.region === region); + if (!endpoint) throw new ApiError(400, "invalid_minimax_region", "MiniMax image region is not supported"); + return endpoint; +} + +function requestedRegion(value: string): MiniMaxImageRegion | null { + if (!value) return null; + if (!isMiniMaxImageRegion(value)) { + throw new ApiError( + 400, + "invalid_minimax_region", + `region must be one of: ${MINIMAX_IMAGE_ENDPOINT_REGISTRY.map((entry) => entry.region).join(", ")}`, + ); + } + return value; +} + +async function readMiniMaxEnvironment(env: EnvService) { + const values = new Map((await env.list()).map((entry) => [entry.key, entry.value.trim()] as const)); + return { + apiKey: values.get("MINIMAX_API_KEY") || process.env.MINIMAX_API_KEY?.trim() || "", + baseUrl: values.get("MINIMAX_BASE_URL") || process.env.MINIMAX_BASE_URL?.trim() || "", + }; +} + +async function resolveMiniMaxImageCredentials(env: EnvService, regionValue: string) { + const environment = await readMiniMaxEnvironment(env); + if (!environment.apiKey) { + throw new ApiError(400, "minimax_api_key_missing", "MiniMax API key missing. Configure MiniMax before generating images."); + } + const region = requestedRegion(regionValue) + ?? (environment.baseUrl ? regionFromBaseUrl(environment.baseUrl) : "global_en"); + return { apiKey: environment.apiKey, endpoint: endpointFor(region) }; +} + +function mediaProviderFetch(input: string | URL | Request, init?: RequestInit): Promise { + const desktopFetch: unknown = Reflect.get(globalThis, Symbol.for("ipollowork.mediaProviderFetch")); + return typeof desktopFetch === "function" + ? (desktopFetch as typeof fetch)(input, init) + : fetch(input, init); +} + +function minimaxErrorMessage(payload: unknown): string { + if (!isRecord(payload)) return ""; + const baseResp = isRecord(payload.base_resp) ? payload.base_resp : null; + return readStringField(baseResp, "status_msg") || readStringField(payload, "message"); +} + +async function requestMiniMaxImages(input: { + apiKey: string; + endpoint: string; + body: JsonRecord; +}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), MINIMAX_IMAGE_TIMEOUT_MS); + let response: Response; + try { + response = await mediaProviderFetch(input.endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${input.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(input.body), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new ApiError(504, "minimax_image_timeout", "MiniMax image generation timed out."); + } + throw new ApiError(502, "minimax_image_unreachable", "Could not reach MiniMax image generation."); + } finally { + clearTimeout(timeout); + } + + const payload: unknown = await response.json().catch(() => null); + const baseResp = isRecord(payload) && isRecord(payload.base_resp) ? payload.base_resp : null; + const statusCode = readOptionalNumber(baseResp, "status_code"); + if (!response.ok || (statusCode !== undefined && statusCode !== 0)) { + const status = response.ok ? 502 : response.status; + const suffix = statusCode === undefined ? "" : ` (status_code ${statusCode})`; + throw new ApiError( + status, + "minimax_image_generation_failed", + minimaxErrorMessage(payload) || `MiniMax image generation failed${suffix}.`, + ); + } + return payload; +} + +function imageValuesFromPayload(payload: unknown): string[] { + const data = isRecord(payload) && isRecord(payload.data) ? payload.data : null; + const values = data && Array.isArray(data.image_urls) + ? data.image_urls + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((value) => value.trim()) + : []; + if (!values.length) { + throw new ApiError(502, "minimax_image_invalid_response", "MiniMax did not return image data."); + } + return values; +} + +async function downloadImage(urlValue: string): Promise { + let url: URL; + try { + url = new URL(urlValue); + } catch { + throw new ApiError(502, "minimax_image_invalid_response", "MiniMax returned an invalid image URL."); + } + if (url.protocol !== "https:") { + throw new ApiError(502, "minimax_image_invalid_response", "MiniMax returned a non-HTTPS image URL."); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), MINIMAX_IMAGE_TIMEOUT_MS); + try { + const response = await mediaProviderFetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new ApiError( + response.status, + "minimax_image_download_failed", + `MiniMax image download failed (HTTP ${response.status}).`, + ); + } + return Buffer.from(await response.arrayBuffer()); + } catch (error) { + if (error instanceof ApiError) throw error; + if (error instanceof Error && error.name === "AbortError") { + throw new ApiError(504, "minimax_image_download_timeout", "MiniMax image download timed out."); + } + throw new ApiError(502, "minimax_image_download_failed", "Could not download the generated MiniMax image."); + } finally { + clearTimeout(timeout); + } +} + +async function imageBytes(value: string): Promise { + if (value.startsWith("https://")) return downloadImage(value); + const comma = value.indexOf(","); + const encoded = value.startsWith("data:") && comma >= 0 ? value.slice(comma + 1) : value; + if (!encoded || !/^[A-Za-z0-9+/=\s]+$/.test(encoded)) { + throw new ApiError(502, "minimax_image_invalid_response", "MiniMax returned invalid base64 image data."); + } + const bytes = Buffer.from(encoded, "base64"); + if (!bytes.byteLength) { + throw new ApiError(502, "minimax_image_invalid_response", "MiniMax returned empty image data."); + } + return bytes; +} + +function imageFileExtension(bytes: Buffer): "gif" | "jpg" | "png" | "webp" { + if ( + bytes.length >= 8 + && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) return "png"; + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "jpg"; + if ( + bytes.length >= 12 + && bytes.subarray(0, 4).toString("ascii") === "RIFF" + && bytes.subarray(8, 12).toString("ascii") === "WEBP" + ) return "webp"; + if (bytes.length >= 6 && bytes.subarray(0, 6).toString("ascii").startsWith("GIF8")) return "gif"; + throw new ApiError(502, "minimax_image_invalid_response", "MiniMax returned an unsupported image format."); +} + +function slugifyImageArtifactName(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "minimax-image"; +} + +function requestBody(args: JsonRecord) { + const prompt = readStringField(args, "prompt"); + if (!prompt) throw new ApiError(400, "invalid_payload", "prompt is required"); + const requestedModel = readStringField(args, "model"); + if (requestedModel && !isMiniMaxImageModel(requestedModel)) { + throw new ApiError( + 400, + "invalid_minimax_image_model", + `model must be one of: ${MINIMAX_IMAGE_MODEL_REGISTRY.models.join(", ")}`, + ); + } + const model = requestedModel || MINIMAX_IMAGE_MODEL_REGISTRY.defaultModel; + const requestedResponseFormat = readStringField(args, "responseFormat"); + if (requestedResponseFormat && !isMiniMaxImageResponseFormat(requestedResponseFormat)) { + throw new ApiError( + 400, + "invalid_minimax_response_format", + `responseFormat must be one of: ${MINIMAX_IMAGE_RESPONSE_FORMATS.join(", ")}`, + ); + } + const responseFormat = requestedResponseFormat || "base64"; + const aspectRatio = readStringField(args, "aspectRatio"); + const width = readInteger(args, "width", true); + const height = readInteger(args, "height", true); + const seed = readInteger(args, "seed"); + const count = readInteger(args, "n", true); + const promptOptimizer = readOptionalBoolean(args, "promptOptimizer"); + return { + model, + prompt, + responseFormat, + body: { + model, + prompt, + response_format: responseFormat, + ...(aspectRatio ? { aspect_ratio: aspectRatio } : {}), + ...(width === undefined ? {} : { width }), + ...(height === undefined ? {} : { height }), + ...(seed === undefined ? {} : { seed }), + ...(count === undefined ? {} : { n: count }), + ...(promptOptimizer === undefined ? {} : { prompt_optimizer: promptOptimizer }), + }, + }; +} + +async function generateMiniMaxImageArtifacts( + config: ServerConfig, + env: EnvService, + args: JsonRecord, + context: JsonRecord, +) { + const request = requestBody(args); + const credentials = await resolveMiniMaxImageCredentials(env, readStringField(args, "region")); + const payload = await requestMiniMaxImages({ + apiKey: credentials.apiKey, + endpoint: credentials.endpoint.url, + body: request.body, + }); + const values = imageValuesFromPayload(payload); + const workspace = workspaceForContext(config, context); + const baseName = slugifyImageArtifactName(readStringField(args, "filename") || request.prompt); + const artifacts: Array<{ path: string; bytes: number }> = []; + for (const [index, value] of values.entries()) { + const bytes = await imageBytes(value); + const suffix = values.length === 1 ? "" : `-${index + 1}`; + const relativePath = `artifacts/${baseName}${suffix}.${imageFileExtension(bytes)}`; + const output = resolveWorkspaceFile(workspace.path, relativePath); + await mkdir(dirname(output.absolutePath), { recursive: true }); + await writeFile(output.absolutePath, bytes); + artifacts.push({ path: output.relativePath, bytes: bytes.byteLength }); + } + const firstArtifact = artifacts[0]; + if (!firstArtifact) { + throw new ApiError(502, "minimax_image_invalid_response", "MiniMax did not produce an image artifact."); + } + const metadata = isRecord(payload) && isRecord(payload.metadata) ? payload.metadata : null; + return { + path: firstArtifact.path, + artifacts, + model: request.model, + region: credentials.endpoint.region, + responseFormat: request.responseFormat, + metadata: { + successCount: readOptionalNumber(metadata, "success_count"), + failedCount: readOptionalNumber(metadata, "failed_count"), + }, + workspaceId: workspace.id, + }; +} + +export async function miniMaxImageGenerationStatus(env: EnvService) { + try { + const environment = await readMiniMaxEnvironment(env); + const region = environment.baseUrl ? regionFromBaseUrl(environment.baseUrl) : "global_en"; + return { + configured: Boolean(environment.apiKey), + connected: Boolean(environment.apiKey), + defaultModel: MINIMAX_IMAGE_MODEL_REGISTRY.defaultModel, + models: [...MINIMAX_IMAGE_MODEL_REGISTRY.models], + region, + endpoint: endpointFor(region).url, + error: null, + }; + } catch (error) { + return { + configured: false, + connected: false, + defaultModel: MINIMAX_IMAGE_MODEL_REGISTRY.defaultModel, + models: [...MINIMAX_IMAGE_MODEL_REGISTRY.models], + region: "global_en" as const, + endpoint: endpointFor("global_en").url, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function callMiniMaxExtensionAction( + config: ServerConfig, + env: EnvService, + action: string, + args: JsonRecord, + context: JsonRecord, +) { + if (action === "status") { + return { + ok: true, + extensionId: MINIMAX_EXTENSION_ID, + action, + result: await miniMaxImageGenerationStatus(env), + context, + }; + } + if (action === "image_generate") { + const result = await generateMiniMaxImageArtifacts(config, env, args, context); + return { + ok: true, + extensionId: MINIMAX_EXTENSION_ID, + action, + path: result.path, + result, + context, + }; + } + return null; +} diff --git a/apps/server/src/plugin-package-manifest.ts b/apps/server/src/plugin-package-manifest.ts index b261a2ae7..0c04ea74b 100644 --- a/apps/server/src/plugin-package-manifest.ts +++ b/apps/server/src/plugin-package-manifest.ts @@ -7,7 +7,7 @@ const ID_RE = /^[a-z0-9]+(?:[._/-][a-z0-9]+)*$/; const SIMPLE_ID_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; const FIELD_ID_RE = /^[A-Za-z][A-Za-z0-9._-]*$/; const RELATION_RE = /^(?:action|authorization|resource|service|workflow):[a-z0-9]+(?:[._/-][a-z0-9]+)*$/; -const RESERVED_EXTENSION_IDS = new Set(["google-workspace", "media-center", "openai-image-generation", "storage"]); +const RESERVED_EXTENSION_IDS = new Set(["google-workspace", "media-center", "minimax", "openai-image-generation", "storage"]); const sourceFormatSchema = z.enum([ "ipollowork-builtin",