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
7 changes: 7 additions & 0 deletions .changeset/minimax-image-generation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@open-codesign/shared': minor
'@open-codesign/providers': minor
'@open-codesign/desktop': minor
---

Add MiniMax image generation with regional endpoints, custom image credentials, and validated image data for workspace assets.
65 changes: 65 additions & 0 deletions apps/desktop/src/main/image-generation-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
ERROR_CODES,
hydrateConfig,
IMAGE_GENERATION_SCHEMA_VERSION,
MINIMAX_IMAGE_BASE_URLS,
MINIMAX_IMAGE_MODEL,
type ProviderEntry,
} from '@open-codesign/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -13,6 +15,7 @@ import {
isGenerateImageAssetEnabled,
parseImageGenerationUpdate,
resolveImageGenerationConfig,
toGenerateImageOptions,
updateImageGenerationSettings,
} from './image-generation-settings';

Expand Down Expand Up @@ -121,6 +124,68 @@ describe('image generation enablement', () => {
mocks.writeConfig.mockReset();
});

it('switches image generation to MiniMax with isolated custom credentials', async () => {
mocks.cachedConfig = makeConfig(true);
const view = await updateImageGenerationSettings({ provider: 'minimax' });
expect(view).toMatchObject({
provider: 'minimax',
model: MINIMAX_IMAGE_MODEL,
baseUrl: MINIMAX_IMAGE_BASE_URLS.global_en,
credentialMode: 'custom',
hasCustomKey: false,
});
await expect(resolveImageGenerationConfig(mocks.cachedConfig)).rejects.toMatchObject({
code: ERROR_CODES.PROVIDER_KEY_MISSING,
});
await updateImageGenerationSettings({
apiKey: 'test-key',
baseUrl: MINIMAX_IMAGE_BASE_URLS.cn_zh,
});
const resolved = await resolveImageGenerationConfig(mocks.cachedConfig);
expect(resolved).toMatchObject({
provider: 'minimax',
apiKey: 'test-key',
baseUrl: MINIMAX_IMAGE_BASE_URLS.cn_zh,
});
expect(resolved).not.toBeNull();
if (resolved) {
expect(toGenerateImageOptions(resolved, 'poster', undefined, '16:9')).toMatchObject({
provider: 'minimax',
aspectRatio: '16:9',
prompt: 'poster',
});
}
});

it('keeps the MiniMax image endpoint independent of inherited text configuration', async () => {
const cfg = makeConfig(true);
const provider = Object.values(cfg.providers)[0];
const imageSettings = cfg.imageGeneration;
if (!provider || !imageSettings) throw new Error('Missing test configuration');
cfg.providers['minimax'] = {
...provider,
id: 'minimax',
baseUrl: 'https://text.example/text-api',
};
cfg.imageGeneration = {
...imageSettings,
provider: 'minimax',
model: MINIMAX_IMAGE_MODEL,
};
getApiKeyForProviderMock.mockReturnValue('test-key');
await expect(resolveImageGenerationConfig(cfg)).resolves.toMatchObject({
baseUrl: MINIMAX_IMAGE_BASE_URLS.global_en,
apiKey: 'test-key',
});
expect(getApiKeyForProviderMock).toHaveBeenCalledWith('minimax');
expect(
parseImageGenerationUpdate({
provider: 'minimax',
baseUrl: MINIMAX_IMAGE_BASE_URLS.cn_zh,
}),
).toMatchObject({ provider: 'minimax', baseUrl: MINIMAX_IMAGE_BASE_URLS.cn_zh });
});

it('disables generate_image_asset when image generation is turned off', async () => {
const cfg = makeConfig(false);
await expect(isGenerateImageAssetEnabled(cfg)).resolves.toBe(false);
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/main/image-generation-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,9 @@ export async function resolveImageGenerationConfig(
apiKey = getApiKeyForProvider(parsed.provider);
}
const inheritedBaseUrl =
parsed.credentialMode === 'inherit' ? cfg.providers[parsed.provider]?.baseUrl : undefined;
parsed.credentialMode === 'inherit' && parsed.provider !== 'minimax'
? cfg.providers[parsed.provider]?.baseUrl
: undefined;
log.info('resolve.ok', {
provider: parsed.provider,
model: parsed.model,
Expand Down Expand Up @@ -363,7 +365,8 @@ export async function updateImageGenerationSettings(
const credentialMode =
provider === CHATGPT_CODEX_PROVIDER_ID
? 'inherit'
: (patch.credentialMode ?? current.credentialMode);
: (patch.credentialMode ??
(providerChanged && provider === 'minimax' ? 'custom' : current.credentialMode));
let next: ImageGenerationSettings = {
...current,
...safePatch,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { useT } from '@open-codesign/i18n';
import { MINIMAX_IMAGE_BASE_URLS, MINIMAX_IMAGE_MODEL } from '@open-codesign/shared';
import { Image as ImageIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ImageGenerationSettingsView } from '../../../../preload/index';
import { useCodesignStore } from '../../store';
import { Label, NativeSelect, Row, SectionTitle, SegmentedControl } from './primitives';

function defaultImageModelFor(provider: ImageGenerationSettingsView['provider']): string {
if (provider === 'minimax') return MINIMAX_IMAGE_MODEL;
if (provider === 'openrouter') return 'openai/gpt-5.4-image-2';
if (provider === 'chatgpt-codex') return 'gpt-5.5';
return 'gpt-image-2';
}

function defaultImageBaseUrlFor(provider: ImageGenerationSettingsView['provider']): string {
if (provider === 'minimax') return MINIMAX_IMAGE_BASE_URLS.global_en;
if (provider === 'openrouter') return 'https://openrouter.ai/api/v1';
if (provider === 'chatgpt-codex') return 'https://chatgpt.com/backend-api';
return 'https://api.openai.com/v1';
Expand Down Expand Up @@ -140,6 +143,7 @@ function ImageGenerationPanel() {
value={settings.provider}
disabled={saving}
options={[
{ value: 'minimax', label: 'MiniMax' },
{ value: 'openai', label: 'OpenAI' },
{
value: 'chatgpt-codex',
Expand All @@ -153,7 +157,12 @@ function ImageGenerationPanel() {
const provider = value as ImageGenerationSettingsView['provider'];
void save({
provider,
credentialMode: provider === 'chatgpt-codex' ? 'inherit' : settings.credentialMode,
credentialMode:
provider === 'chatgpt-codex'
? 'inherit'
: provider === 'minimax'
? 'custom'
: settings.credentialMode,
model: defaultImageModelFor(provider),
baseUrl: defaultImageBaseUrlFor(provider),
});
Expand All @@ -173,6 +182,20 @@ function ImageGenerationPanel() {
</Row>
</div>

{settings.provider === 'minimax' ? (
<Row label={t('settings.imageGen.region', { defaultValue: 'MiniMax region' })}>
<NativeSelect
value={baseUrl}
disabled={saving}
options={[
{ value: MINIMAX_IMAGE_BASE_URLS.global_en, label: 'Global' },
{ value: MINIMAX_IMAGE_BASE_URLS.cn_zh, label: 'China' },
]}
onChange={(value) => void save({ baseUrl: value })}
/>
</Row>
) : null}

{settings.credentialMode === 'custom' ? (
<div className="flex items-center gap-[var(--space-2)]">
<input
Expand Down Expand Up @@ -225,7 +248,7 @@ function ImageGenerationPanel() {
<Row label={t('settings.imageGen.quality')}>
<NativeSelect
value={settings.quality}
disabled={saving}
disabled={saving || settings.provider === 'minimax'}
options={[
{ value: 'auto', label: 'Auto' },
{ value: 'low', label: 'Low' },
Expand Down
109 changes: 108 additions & 1 deletion packages/providers/src/images.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ERROR_CODES } from '@open-codesign/shared';
import { ERROR_CODES, MINIMAX_IMAGE_BASE_URLS, MINIMAX_IMAGE_MODEL } from '@open-codesign/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defaultImageModel, generateImage } from './images';

Expand All @@ -18,6 +18,113 @@ describe('generateImage', () => {
vi.unstubAllGlobals();
});

it.each(
Object.values(MINIMAX_IMAGE_BASE_URLS),
)('calls the MiniMax image endpoint at %s with Bearer auth and aspect ratio', async (baseUrl) => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
base_resp: { status_code: 0 },
data: { image_base64: [PNG_HEADER_BASE64] },
}),
),
);
vi.stubGlobal('fetch', fetchMock);
const signal = new AbortController().signal;
const result = await generateImage({
provider: 'minimax',
apiKey: 'test-key',
prompt: ' poster ',
baseUrl,
aspectRatio: '16:9',
size: '1536x1024',
quality: 'high',
outputFormat: 'webp',
signal,
});
expect(fetchMock).toHaveBeenCalledWith(
baseUrl + '/image_generation',
expect.objectContaining({
signal,
headers: expect.objectContaining({ authorization: 'Bearer test-key' }),
body: JSON.stringify({
model: MINIMAX_IMAGE_MODEL,
prompt: 'poster',
n: 1,
response_format: 'base64',
aspect_ratio: '16:9',
}),
}),
);
expect(result).toMatchObject({
provider: 'minimax',
model: MINIMAX_IMAGE_MODEL,
mimeType: 'image/png',
base64: PNG_HEADER_BASE64,
dataUrl: 'data:image/png;base64,' + PNG_HEADER_BASE64,
});
});

it('uses MiniMax defaults and dimensions while preserving the actual output encoding', async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
base_resp: { status_code: 0 },
data: { image_base64: ['/9j/'] },
}),
),
);
vi.stubGlobal('fetch', fetchMock);
const result = await generateImage({
provider: 'minimax',
apiKey: 'test-key',
prompt: 'poster',
size: '1024x1536',
});
expect(fetchMock).toHaveBeenCalledWith(
MINIMAX_IMAGE_BASE_URLS.global_en + '/image_generation',
expect.objectContaining({
body: JSON.stringify({
model: MINIMAX_IMAGE_MODEL,
prompt: 'poster',
n: 1,
response_format: 'base64',
width: 1024,
height: 1536,
}),
}),
);
expect(result.mimeType).toBe('image/jpeg');
});

it.each([
{ base_resp: { status_code: 1008, status_msg: 'Insufficient balance' } },
{ data: { image_base64: [PNG_HEADER_BASE64] } },
{ base_resp: { status_code: 0 }, data: { image_base64: [] } },
{ base_resp: { status_code: 0 }, data: { image_base64: ['invalid image'] } },
{ base_resp: { status_code: 0 }, data: { image_base64: ['aGVsbG8='] } },
])('rejects unsuccessful or invalid MiniMax image responses', async (response) => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(response))),
);
await expect(
generateImage({ provider: 'minimax', apiKey: 'test-key', prompt: 'poster' }),
).rejects.toMatchObject({ code: ERROR_CODES.PROVIDER_ERROR });
});

it('reports MiniMax HTTP errors', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('Unavailable', { status: 503 })),
);
await expect(
generateImage({ provider: 'minimax', apiKey: 'test-key', prompt: 'poster' }),
).rejects.toThrow('HTTP 503');
});

it('calls OpenAI image generations and normalizes b64_json', async () => {
const fetchMock = vi.fn(async () => {
return new Response(
Expand Down
Loading
Loading