diff --git a/.changeset/minimax-image-generation.md b/.changeset/minimax-image-generation.md new file mode 100644 index 00000000..29557290 --- /dev/null +++ b/.changeset/minimax-image-generation.md @@ -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. diff --git a/apps/desktop/src/main/image-generation-settings.test.ts b/apps/desktop/src/main/image-generation-settings.test.ts index 8532e8de..b958de0f 100644 --- a/apps/desktop/src/main/image-generation-settings.test.ts +++ b/apps/desktop/src/main/image-generation-settings.test.ts @@ -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'; @@ -13,6 +15,7 @@ import { isGenerateImageAssetEnabled, parseImageGenerationUpdate, resolveImageGenerationConfig, + toGenerateImageOptions, updateImageGenerationSettings, } from './image-generation-settings'; @@ -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); diff --git a/apps/desktop/src/main/image-generation-settings.ts b/apps/desktop/src/main/image-generation-settings.ts index a55e47e2..e5824130 100644 --- a/apps/desktop/src/main/image-generation-settings.ts +++ b/apps/desktop/src/main/image-generation-settings.ts @@ -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, @@ -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, diff --git a/apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx b/apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx index 10956b9a..665b6a3f 100644 --- a/apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx +++ b/apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx @@ -1,4 +1,5 @@ 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'; @@ -6,12 +7,14 @@ 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'; @@ -140,6 +143,7 @@ function ImageGenerationPanel() { value={settings.provider} disabled={saving} options={[ + { value: 'minimax', label: 'MiniMax' }, { value: 'openai', label: 'OpenAI' }, { value: 'chatgpt-codex', @@ -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), }); @@ -173,6 +182,20 @@ function ImageGenerationPanel() { + {settings.provider === 'minimax' ? ( + + void save({ baseUrl: value })} + /> + + ) : null} + {settings.credentialMode === 'custom' ? (
{ 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( diff --git a/packages/providers/src/images.ts b/packages/providers/src/images.ts index 5718afe5..350fd5d5 100644 --- a/packages/providers/src/images.ts +++ b/packages/providers/src/images.ts @@ -1,6 +1,11 @@ -import { CodesignError, ERROR_CODES } from '@open-codesign/shared'; - -export type ImageGenerationProvider = 'openai' | 'openrouter' | 'chatgpt-codex'; +import { + CodesignError, + ERROR_CODES, + MINIMAX_IMAGE_BASE_URLS, + MINIMAX_IMAGE_MODEL, +} from '@open-codesign/shared'; + +export type ImageGenerationProvider = 'minimax' | 'openai' | 'openrouter' | 'chatgpt-codex'; export type ImageOutputFormat = 'png' | 'jpeg' | 'webp'; export type ImageQuality = 'auto' | 'low' | 'medium' | 'high'; export type ImageSize = 'auto' | '1024x1024' | '1536x1024' | '1024x1536'; @@ -78,12 +83,14 @@ const DEFAULT_CHATGPT_CODEX_IMAGE_MODEL = 'gpt-5.5'; const BASE64_IMAGE_RE = /^[A-Za-z0-9+/]+={0,2}$/; export function defaultImageModel(provider: ImageGenerationProvider): string { + if (provider === 'minimax') return MINIMAX_IMAGE_MODEL; if (provider === 'openrouter') return DEFAULT_OPENROUTER_IMAGE_MODEL; if (provider === 'chatgpt-codex') return DEFAULT_CHATGPT_CODEX_IMAGE_MODEL; return DEFAULT_OPENAI_IMAGE_MODEL; } export function defaultImageBaseUrl(provider: ImageGenerationProvider): string { + if (provider === 'minimax') return MINIMAX_IMAGE_BASE_URLS.global_en; if (provider === 'openrouter') return DEFAULT_OPENROUTER_BASE_URL; if (provider === 'chatgpt-codex') return DEFAULT_CHATGPT_CODEX_BASE_URL; return DEFAULT_OPENAI_BASE_URL; @@ -97,6 +104,7 @@ export async function generateImage(options: GenerateImageOptions): Promise { + const model = options.model?.trim() || MINIMAX_IMAGE_MODEL; + const body: Record = { + model, + prompt: options.prompt, + n: 1, + response_format: 'base64', + }; + if (options.aspectRatio !== undefined) { + body['aspect_ratio'] = options.aspectRatio; + } else if (options.size !== undefined && options.size !== 'auto') { + const [width, height] = options.size.split('x').map(Number); + body['width'] = width; + body['height'] = height; + } + const json = await postJson( + joinEndpoint(options.baseUrl ?? MINIMAX_IMAGE_BASE_URLS.global_en, 'image_generation'), + body, + options, + ); + if (json.base_resp?.status_code !== 0) { + throw new CodesignError( + `MiniMax image generation failed: ${json.base_resp?.status_msg ?? 'Missing success status'}`, + ERROR_CODES.PROVIDER_ERROR, + ); + } + const image = json.data?.image_base64?.[0]; + if (typeof image !== 'string') { + throw new CodesignError( + 'MiniMax image response did not include base64 image data', + ERROR_CODES.PROVIDER_ERROR, + ); + } + const base64 = normalizeBase64ImageData(image, 'MiniMax image response'); + // The API controls the output encoding; preserve the actual image MIME type. + const bytes = Buffer.from(base64, 'base64'); + const mimeType = + bytes[0] === 0x89 ? 'image/png' : bytes[0] === 0xff ? 'image/jpeg' : 'image/webp'; + validateImageSignature(mimeType, base64, 'MiniMax image response'); + return { + dataUrl: `data:${mimeType};base64,${base64}`, + base64, + mimeType, + model, + provider: 'minimax', + }; +} + async function generateOpenAIImage( options: GenerateImageOptions & { prompt: string }, ): Promise { diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index dbc74077..2c57cad2 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -120,7 +120,14 @@ export type ProviderCapabilities = z.infer; export const IMAGE_GENERATION_SCHEMA_VERSION = 1 as const; +export const MINIMAX_IMAGE_MODEL = 'image-01'; +export const MINIMAX_IMAGE_BASE_URLS = { + global_en: 'https://api.minimax.io/v1', + cn_zh: 'https://api.minimaxi.com/v1', +} as const; + export const ImageGenerationProviderSchema = z.enum([ + 'minimax', 'openai', 'openrouter', CHATGPT_CODEX_PROVIDER_ID, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index da8c5e13..0b455050 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -341,6 +341,8 @@ export { ImageGenerationSettingsSchema, ImageGenerationSizeSchema, isSupportedOnboardingProvider, + MINIMAX_IMAGE_BASE_URLS, + MINIMAX_IMAGE_MODEL, migrateLegacyToV3, PROVIDER_SHORTLIST, ProviderCapabilitiesSchema,