Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/app/src/app/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,24 +458,29 @@ 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" },
{ type: "composer-prompt", prompt: "Use MiniMax to ", location: "composer" },
],
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,
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/react-app/domains/settings/minimax-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -88,6 +89,7 @@ export function MiniMaxConfig(props: MiniMaxConfigProps) {
modelId: selectedModel.id,
modelName: selectedModel.id,
models: provider.models,
userEnv: buildMiniMaxRuntimeEnv(endpointId, trimmedApiKey),
setDefault,
});
};
Expand Down
8 changes: 8 additions & 0 deletions apps/app/src/react-app/domains/settings/minimax-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type LocalProviderInstallInput = {
modelId: string;
modelName: string;
models?: Record<string, LocalProviderModelConfig>;
userEnv?: Array<{ key: string; value: string }>;
setDefault: boolean;
};

Expand Down
8 changes: 8 additions & 0 deletions apps/app/src/react-app/shell/settings-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions apps/app/tests/minimax-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/extensions-connect-gating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
];
Expand Down Expand Up @@ -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;
Expand Down
184 changes: 184 additions & 0 deletions apps/server/src/extensions/minimax-image-generation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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",
});
});
});
Loading
Loading