From a84861a618e413c34f5e517f63e42f9cfa6bb6d1 Mon Sep 17 00:00:00 2001 From: balalo Date: Sun, 28 Jun 2026 11:59:53 +0200 Subject: [PATCH] feat: add Ideogram 4.0 image provider Add a new `ideogram` image provider (Ideogram 4.0 / v4) alongside the existing Gemini and OpenAI providers, configured the same way via environment variables. - New `createIdeogramImageClient` calling the v4 `text_prompt` generate endpoint with `Api-Key` auth; downloads the ephemeral image URL. - `IDEOGRAM_API_KEY` config + validation, gated on `IMAGE_PROVIDER=ideogram`. - `IMAGE_QUALITY` drives both rendering speed (TURBO/DEFAULT/QUALITY) and the v4 resolution (aspect ratio selects orientation only, since v4 ties resolution to aspect ratio). - Prompt enhancement handled natively by Ideogram magic-prompt, so no separate text client/API key is required. - Rejects useGoogleSearch and image-to-image editing (unsupported on v4). - Docs (README, server.json) and tests updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 25 +- server.json | 10 +- src/api/__tests__/geminiClient.test.ts | 1 + src/api/__tests__/geminiTextClient.test.ts | 1 + src/api/__tests__/ideogramImageClient.test.ts | 338 ++++++++++++++++++ src/api/__tests__/openaiTextClient.test.ts | 1 + src/api/ideogramImageClient.ts | 307 ++++++++++++++++ src/server/__tests__/mcpServer.test.ts | 60 ++++ src/server/mcpServer.ts | 25 +- src/types/mcp.ts | 14 +- src/utils/__tests__/config.test.ts | 53 +++ src/utils/config.ts | 24 ++ 12 files changed, 848 insertions(+), 11 deletions(-) create mode 100644 src/api/__tests__/ideogramImageClient.test.ts create mode 100644 src/api/ideogramImageClient.ts diff --git a/README.md b/README.md index c499f51..5ebb123 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ npx mcp-image skills install --path ~/.claude/skills - **Node.js** 22 or higher - **Gemini API Key** - Get yours at [Google AI Studio](https://aistudio.google.com/apikey) for the default Gemini provider - **OpenAI API Key** - Get yours from [OpenAI](https://platform.openai.com/api-keys) when using `IMAGE_PROVIDER=openai` +- **Ideogram API Key** - Get yours from [Ideogram](https://ideogram.ai/manage-api) when using `IMAGE_PROVIDER=ideogram` - An MCP-compatible AI tool: **Cursor**, **Claude Code**, **Codex**, or others - Basic terminal/command line knowledge @@ -257,9 +258,10 @@ Set `SKIP_PROMPT_ENHANCEMENT=true` to disable automatic prompt optimization and | Variable | Default | Description | |----------|---------|-------------| -| `IMAGE_PROVIDER` | `gemini` | `gemini` or `openai` | +| `IMAGE_PROVIDER` | `gemini` | `gemini`, `openai`, or `ideogram` | | `GEMINI_API_KEY` | - | Required when `IMAGE_PROVIDER=gemini` | | `OPENAI_API_KEY` | - | Required when `IMAGE_PROVIDER=openai` | +| `IDEOGRAM_API_KEY` | - | Required when `IMAGE_PROVIDER=ideogram` | ### Using the OpenAI provider @@ -277,6 +279,27 @@ OpenAI provider behavior: Prompt enhancement uses a separate OpenAI Responses API call. Set `SKIP_PROMPT_ENHANCEMENT=true` to send prompts directly to the image model. +### Using the Ideogram provider + +Set `IMAGE_PROVIDER=ideogram` and `IDEOGRAM_API_KEY` to generate images with Ideogram 4.0 (`ideogram-v4`). Get an API key from [Ideogram](https://ideogram.ai/manage-api). + +Ideogram provider behavior: + +- Supports text-to-image generation. +- `IMAGE_QUALITY` controls the Ideogram v4 `resolution`: a higher preset selects a larger resolution for the chosen orientation. Because v4 ties resolution to aspect ratio (every v4 resolution is ~4 MP), `aspectRatio` only selects the orientation, and `imageSize` is not used. + + | `IMAGE_QUALITY` | Landscape | Portrait | Square | + |-----------------|-----------|----------|--------| + | `fast` | `2560x1440` | `1440x2560` | `2048x2048` | + | `balanced` | `2304x1728` | `1728x2304` | `2048x2048` | + | `quality` | `2496x1664` | `1664x2496` | `2048x2048` | + + Square has a single v4 resolution, so the preset has no size effect there. +- `IMAGE_QUALITY` also maps to Ideogram rendering speed as `fast -> TURBO`, `balanced -> DEFAULT`, and `quality -> QUALITY`. (v4 does not yet support its `FLASH` speed.) +- Does not support `useGoogleSearch` or image-to-image editing; use the Gemini or OpenAI provider for those. + +Prompt enhancement is handled natively: Ideogram v4 automatically applies magic-prompt to text prompts, so the Ideogram provider does not require a separate text model or API key. Because v4 always enhances text prompts, `SKIP_PROMPT_ENHANCEMENT` does not disable Ideogram's magic-prompt. + ## Usage Examples Once configured, just describe what you want in natural language: diff --git a/server.json b/server.json index 39c51fe..f517617 100644 --- a/server.json +++ b/server.json @@ -21,7 +21,7 @@ "environmentVariables": [ { "name": "IMAGE_PROVIDER", - "description": "Image provider to use: 'gemini' (default) or 'openai'", + "description": "Image provider to use: 'gemini' (default), 'openai', or 'ideogram'", "isRequired": false, "format": "string", "isSecret": false @@ -40,6 +40,13 @@ "format": "string", "isSecret": true }, + { + "name": "IDEOGRAM_API_KEY", + "description": "Ideogram API key for image generation (Ideogram 4.0) when IMAGE_PROVIDER=ideogram (get from https://ideogram.ai/manage-api)", + "isRequired": false, + "format": "string", + "isSecret": true + }, { "name": "IMAGE_OUTPUT_DIR", "description": "Absolute path to directory where generated images will be saved (defaults to ./output)", @@ -87,6 +94,7 @@ "prompt-enhancement", "gemini", "openai", + "ideogram", "gpt-image-2", "nano-banana", "nano-banana-2", diff --git a/src/api/__tests__/geminiClient.test.ts b/src/api/__tests__/geminiClient.test.ts index a05f90d..31db96b 100644 --- a/src/api/__tests__/geminiClient.test.ts +++ b/src/api/__tests__/geminiClient.test.ts @@ -32,6 +32,7 @@ describe('geminiClient', () => { imageProvider: 'gemini', geminiApiKey: 'test-api-key-12345', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, diff --git a/src/api/__tests__/geminiTextClient.test.ts b/src/api/__tests__/geminiTextClient.test.ts index 5ba704d..3c89bf9 100644 --- a/src/api/__tests__/geminiTextClient.test.ts +++ b/src/api/__tests__/geminiTextClient.test.ts @@ -59,6 +59,7 @@ describe('GeminiTextClient', () => { imageProvider: 'gemini', geminiApiKey: 'test-api-key', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './test-output', apiTimeout: 30000, skipPromptEnhancement: false, diff --git a/src/api/__tests__/ideogramImageClient.test.ts b/src/api/__tests__/ideogramImageClient.test.ts new file mode 100644 index 0000000..a51a8a7 --- /dev/null +++ b/src/api/__tests__/ideogramImageClient.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Config } from '../../utils/config' +import { ImageAPIError, NetworkError } from '../../utils/errors' +import { createIdeogramImageClient } from '../ideogramImageClient' + +const mockFetch = vi.fn() + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + json: async () => body, + text: async () => JSON.stringify(body), + headers: new Headers(), + } as unknown as Response +} + +function errorResponse(status: number, body = 'error'): Response { + return { + ok: false, + status, + json: async () => ({}), + text: async () => body, + headers: new Headers(), + } as unknown as Response +} + +function imageResponse(data: string, contentType = 'image/png'): Response { + return { + ok: true, + status: 200, + arrayBuffer: async () => { + const buf = Buffer.from(data) + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + }, + headers: new Headers({ 'content-type': contentType }), + } as unknown as Response +} + +describe('ideogramImageClient', () => { + const testConfig: Config = { + imageProvider: 'ideogram', + geminiApiKey: '', + openaiApiKey: '', + ideogramApiKey: 'test-ideogram-api-key-12345', + imageOutputDir: './output', + apiTimeout: 30000, + skipPromptEnhancement: false, + imageQuality: 'fast', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('createIdeogramImageClient', () => { + it('should create client with Ideogram API key', () => { + const result = createIdeogramImageClient(testConfig) + expect(result.success).toBe(true) + }) + + it('should return error when API key is missing', () => { + const result = createIdeogramImageClient({ ...testConfig, ideogramApiKey: '' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(ImageAPIError) + expect(result.error.message).toContain('IDEOGRAM_API_KEY is not configured') + } + }) + }) + + describe('IdeogramImageClient.generateImage', () => { + it('should generate an image successfully via the v4 endpoint', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + response_type: 'url', + created: '2026-01-01T00:00:00Z', + data: [{ url: 'https://ideogram.ai/api/images/ephemeral/abc.png', prompt: 'a cat' }], + }) + ) + .mockResolvedValueOnce(imageResponse('mock-ideogram-image-data')) + + const clientResult = createIdeogramImageClient(testConfig) + expect(clientResult.success).toBe(true) + if (!clientResult.success) return + + const result = await clientResult.data.generateImage({ prompt: 'a cat' }) + + expect(result.success).toBe(true) + // First call is the generation request. + const [url, init] = mockFetch.mock.calls[0] + expect(url).toBe('https://api.ideogram.ai/v1/ideogram-v4/generate') + expect(init.method).toBe('POST') + expect(init.headers).toEqual({ 'Api-Key': 'test-ideogram-api-key-12345' }) + const body = init.body as FormData + expect(body.get('text_prompt')).toBe('a cat') + expect(body.get('rendering_speed')).toBe('TURBO') + // v4 has no `prompt`, `magic_prompt`, or `num_images` fields. + expect(body.get('prompt')).toBeNull() + expect(body.get('magic_prompt')).toBeNull() + expect(body.get('num_images')).toBeNull() + + if (result.success) { + expect(result.data.imageData).toEqual(Buffer.from('mock-ideogram-image-data')) + expect(result.data.metadata.model).toBe('ideogram-v4') + expect(result.data.metadata.provider).toBe('ideogram') + expect(result.data.metadata.prompt).toBe('a cat') + expect(result.data.metadata.mimeType).toBe('image/png') + expect(result.data.metadata.inputImageProvided).toBe(false) + } + }) + + it('should map quality preset to QUALITY rendering speed', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png' }] })) + .mockResolvedValueOnce(imageResponse('img')) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + await clientResult.data.generateImage({ prompt: 'x', quality: 'quality' }) + + const body = mockFetch.mock.calls[0][1].body as FormData + expect(body.get('rendering_speed')).toBe('QUALITY') + }) + + it('should map balanced quality to DEFAULT rendering speed', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png' }] })) + .mockResolvedValueOnce(imageResponse('img')) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + await clientResult.data.generateImage({ prompt: 'x', quality: 'balanced' }) + + const body = mockFetch.mock.calls[0][1].body as FormData + expect(body.get('rendering_speed')).toBe('DEFAULT') + }) + + it('should use the aspect ratio for orientation only (no aspect_ratio field in v4)', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png' }] })) + .mockResolvedValueOnce(imageResponse('img')) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + // fast quality + landscape orientation + await clientResult.data.generateImage({ prompt: 'x', aspectRatio: '16:9' }) + + const body = mockFetch.mock.calls[0][1].body as FormData + expect(body.get('resolution')).toBe('2560x1440') + // v4 does not accept an aspect_ratio field. + expect(body.get('aspect_ratio')).toBeNull() + }) + + it('should let IMAGE_QUALITY select a larger resolution for the same orientation', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png' }] })) + .mockResolvedValueOnce(imageResponse('img')) + .mockResolvedValueOnce(jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png' }] })) + .mockResolvedValueOnce(imageResponse('img')) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + // Same landscape orientation, different quality presets -> different resolution. + await clientResult.data.generateImage({ prompt: 'x', aspectRatio: '16:9', quality: 'fast' }) + await clientResult.data.generateImage({ + prompt: 'x', + aspectRatio: '16:9', + quality: 'quality', + }) + + const fastBody = mockFetch.mock.calls[0][1].body as FormData + const qualityBody = mockFetch.mock.calls[2][1].body as FormData + expect(fastBody.get('resolution')).toBe('2560x1440') + expect(qualityBody.get('resolution')).toBe('2496x1664') + }) + + it('should pick a portrait resolution for portrait aspect ratios', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png' }] })) + .mockResolvedValueOnce(imageResponse('img')) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + await clientResult.data.generateImage({ + prompt: 'x', + aspectRatio: '9:16', + quality: 'balanced', + }) + + const body = mockFetch.mock.calls[0][1].body as FormData + expect(body.get('resolution')).toBe('1728x2304') + }) + + it('should default to the square resolution when no aspect ratio is provided', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png' }] })) + .mockResolvedValueOnce(imageResponse('img')) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + await clientResult.data.generateImage({ prompt: 'x', quality: 'quality' }) + + const body = mockFetch.mock.calls[0][1].body as FormData + // Square has a single v4 resolution, so the quality preset has no size effect. + expect(body.get('resolution')).toBe('2048x2048') + }) + + it('should expose the revised prompt when Ideogram rewrites it', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png', prompt: 'a fluffy cat' }] }) + ) + .mockResolvedValueOnce(imageResponse('img')) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + const result = await clientResult.data.generateImage({ prompt: 'a cat' }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.metadata.revisedPrompt).toBe('a fluffy cat') + } + }) + + it('should reject useGoogleSearch', async () => { + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + const result = await clientResult.data.generateImage({ + prompt: 'x', + useGoogleSearch: true, + }) + + expect(result.success).toBe(false) + expect(mockFetch).not.toHaveBeenCalled() + if (!result.success) { + expect(result.error).toBeInstanceOf(ImageAPIError) + expect(result.error.message).toContain('useGoogleSearch') + } + }) + + it('should reject image-to-image editing requests', async () => { + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + const result = await clientResult.data.generateImage({ + prompt: 'x', + inputImage: Buffer.from('input').toString('base64'), + inputImageMimeType: 'image/png', + }) + + expect(result.success).toBe(false) + expect(mockFetch).not.toHaveBeenCalled() + if (!result.success) { + expect(result.error).toBeInstanceOf(ImageAPIError) + expect(result.error.message).toContain('Image-to-image editing is not supported') + } + }) + + it('should return ImageAPIError when the response contains no image URL', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ data: [] })) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + const result = await clientResult.data.generateImage({ prompt: 'x' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(ImageAPIError) + expect(result.error.message).toContain('No image data returned') + } + }) + + it('should return ImageAPIError with the upstream status on API failure', async () => { + mockFetch.mockResolvedValueOnce(errorResponse(401, 'invalid api key')) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + const result = await clientResult.data.generateImage({ prompt: 'x' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(ImageAPIError) + expect(result.error.message).toContain('status 401') + } + }) + + it('should return ImageAPIError when the image download fails', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ data: [{ url: 'https://ideogram.ai/img.png' }] })) + .mockResolvedValueOnce(errorResponse(404)) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + const result = await clientResult.data.generateImage({ prompt: 'x' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(ImageAPIError) + expect(result.error.message).toContain('Failed to download generated image') + } + }) + + it('should return NetworkError for network failures', async () => { + const networkError = new Error('ECONNRESET') as Error & { code: string } + networkError.code = 'ECONNRESET' + mockFetch.mockRejectedValueOnce(networkError) + + const clientResult = createIdeogramImageClient(testConfig) + if (!clientResult.success) return + + const result = await clientResult.data.generateImage({ prompt: 'x' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(NetworkError) + } + }) + }) +}) diff --git a/src/api/__tests__/openaiTextClient.test.ts b/src/api/__tests__/openaiTextClient.test.ts index 7439ea3..3aeffb4 100644 --- a/src/api/__tests__/openaiTextClient.test.ts +++ b/src/api/__tests__/openaiTextClient.test.ts @@ -23,6 +23,7 @@ describe('openaiTextClient', () => { imageProvider: 'openai', geminiApiKey: '', openaiApiKey: 'test-openai-api-key-12345', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, diff --git a/src/api/ideogramImageClient.ts b/src/api/ideogramImageClient.ts new file mode 100644 index 0000000..45cc595 --- /dev/null +++ b/src/api/ideogramImageClient.ts @@ -0,0 +1,307 @@ +/** + * Ideogram API client for Ideogram 4.0 (v4) image generation. + * + * Uses the Ideogram HTTP API directly (there is no official Node SDK). + * The generate endpoint accepts a multipart/form-data request and returns + * ephemeral image URLs, so the bytes are downloaded before being returned. + * + * v4 differs from v3: the prompt field is `text_prompt` (which auto-enables + * magic-prompt), the image shape is chosen via a fixed `resolution` instead of + * an `aspect_ratio`, and `rendering_speed=FLASH` is not yet available. + */ + +import type { AspectRatio, ImageQuality } from '../types/mcp.js' +import { IDEOGRAM_MODEL } from '../types/mcp.js' +import type { Result } from '../types/result.js' +import { Err, Ok } from '../types/result.js' +import type { Config } from '../utils/config.js' +import { ImageAPIError, NetworkError } from '../utils/errors.js' +import { DEFAULT_MIME_TYPE, normalizeMimeType } from '../utils/mimeUtils.js' +import { isNetworkError } from './errorClassification.js' +import type { GeneratedImageResult, ImageApiParams, ImageClient } from './imageClient.js' + +const IDEOGRAM_GENERATE_URL = 'https://api.ideogram.ai/v1/ideogram-v4/generate' + +// FLASH is intentionally excluded: Ideogram v4 returns 400 for it ("coming soon"). +type IdeogramRenderingSpeed = 'TURBO' | 'DEFAULT' | 'QUALITY' + +type IdeogramResolution = + | '2048x2048' + | '2560x1440' + | '2304x1728' + | '2496x1664' + | '1440x2560' + | '1728x2304' + | '1664x2496' + +type Orientation = 'square' | 'landscape' | 'portrait' + +interface IdeogramImageData { + url?: string + prompt?: string + resolution?: string + is_image_safe?: boolean + seed?: number +} + +interface IdeogramGenerateResponse { + response_type?: string + created?: string + data?: IdeogramImageData[] +} + +/** + * Map the provider-neutral quality presets onto Ideogram rendering speeds. + * Ideogram trades latency for fidelity, so the presets line up cleanly. FLASH + * is unavailable on v4, so `fast` maps to the next-fastest speed (TURBO). + */ +function mapRenderingSpeed(quality: ImageQuality): IdeogramRenderingSpeed { + switch (quality) { + case 'quality': + return 'QUALITY' + case 'balanced': + return 'DEFAULT' + case 'fast': + return 'TURBO' + } +} + +/** + * Ideogram v4 selects the image shape via a fixed `resolution` rather than an + * aspect ratio, and every v4 resolution sits around the same ~4 MP. `aspectRatio` + * therefore only chooses the orientation, while `IMAGE_QUALITY` selects the + * resolution: higher presets pick the larger v4 resolution available for that + * orientation. (Square has a single v4 resolution, so the preset has no size + * effect there.) + */ +const RESOLUTION_BY_ORIENTATION: Record> = { + square: { + fast: '2048x2048', + balanced: '2048x2048', + quality: '2048x2048', + }, + landscape: { + fast: '2560x1440', // 3.69 MP + balanced: '2304x1728', // 3.98 MP + quality: '2496x1664', // 4.15 MP + }, + portrait: { + fast: '1440x2560', // 3.69 MP + balanced: '1728x2304', // 3.98 MP + quality: '1664x2496', // 4.15 MP + }, +} + +function getOrientation(aspectRatio?: AspectRatio): Orientation { + if (!aspectRatio) { + return 'square' + } + + const [widthRaw, heightRaw] = aspectRatio.split(':') + const width = Number(widthRaw) + const height = Number(heightRaw) + + if (!Number.isFinite(width) || !Number.isFinite(height) || width === height) { + return 'square' + } + + return width > height ? 'landscape' : 'portrait' +} + +function mapResolution( + aspectRatio: AspectRatio | undefined, + quality: ImageQuality +): IdeogramResolution { + return RESOLUTION_BY_ORIENTATION[getOrientation(aspectRatio)][quality] +} + +function validateIdeogramOptions(params: ImageApiParams): Result { + if (params.useGoogleSearch) { + return Err( + new ImageAPIError( + 'useGoogleSearch is not supported by the Ideogram image provider', + 'Disable useGoogleSearch or use IMAGE_PROVIDER=gemini for Google Search grounding' + ) + ) + } + + if (typeof params.inputImage === 'string' && params.inputImage.length > 0) { + return Err( + new ImageAPIError( + 'Image-to-image editing is not supported by the Ideogram image provider', + 'Remove the input image or use IMAGE_PROVIDER=gemini or IMAGE_PROVIDER=openai for image editing' + ) + ) + } + + return Ok(true) +} + +class IdeogramImageClientImpl implements ImageClient { + private readonly modelName = IDEOGRAM_MODEL + + constructor( + private readonly apiKey: string, + private readonly defaultQuality: ImageQuality = 'fast' + ) {} + + async generateImage( + params: ImageApiParams + ): Promise> { + try { + const optionsResult = validateIdeogramOptions(params) + if (!optionsResult.success) { + return optionsResult + } + + const quality = params.quality ?? this.defaultQuality + + const formData = new FormData() + // v4 uses `text_prompt`; supplying it auto-enables Ideogram's magic-prompt. + formData.append('text_prompt', params.prompt) + // IMAGE_QUALITY drives both the rendering speed and the resolution; the + // aspect ratio only chooses the orientation. + formData.append('rendering_speed', mapRenderingSpeed(quality)) + formData.append('resolution', mapResolution(params.aspectRatio, quality)) + + const response = await fetch(IDEOGRAM_GENERATE_URL, { + method: 'POST', + headers: { 'Api-Key': this.apiKey }, + body: formData, + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => '') + return this.handleApiError(response.status, errorBody, params.prompt) + } + + const payload = (await response.json()) as IdeogramGenerateResponse + const firstImage = payload.data?.[0] + if (!firstImage?.url) { + return Err( + new ImageAPIError('No image data returned from Ideogram API', { + provider: 'ideogram', + model: this.modelName, + stage: 'image_extraction', + suggestion: + 'Retry the request or verify that the prompt was not rejected by safety filters', + }) + ) + } + + const imageResponse = await fetch(firstImage.url) + if (!imageResponse.ok) { + return Err( + new ImageAPIError( + `Failed to download generated image from Ideogram (status ${imageResponse.status})`, + { + provider: 'ideogram', + model: this.modelName, + stage: 'image_download', + suggestion: + 'Ideogram image URLs are ephemeral. Retry the request to obtain a fresh image URL', + }, + imageResponse.status + ) + ) + } + + const arrayBuffer = await imageResponse.arrayBuffer() + const imageData = Buffer.from(arrayBuffer) + const contentType = imageResponse.headers.get('content-type') + const mimeType = contentType + ? normalizeMimeType(contentType.split(';')[0]?.trim() ?? DEFAULT_MIME_TYPE) + : DEFAULT_MIME_TYPE + + return Ok({ + imageData, + metadata: { + model: this.modelName, + provider: 'ideogram', + prompt: params.prompt, + mimeType, + timestamp: new Date(), + inputImageProvided: false, + ...(firstImage.prompt && + firstImage.prompt !== params.prompt && { revisedPrompt: firstImage.prompt }), + }, + }) + } catch (error) { + return this.handleError(error, params.prompt) + } + } + + private handleApiError( + status: number, + body: string, + prompt: string + ): Result { + return Err( + new ImageAPIError( + `Ideogram image generation failed with status ${status}`, + { + provider: 'ideogram', + prompt, + upstreamMessage: body, + suggestion: this.getAPIErrorSuggestion(status), + }, + status + ) + ) + } + + private handleError(error: unknown, prompt: string): Result { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + + if (isNetworkError(error)) { + return Err( + new NetworkError( + 'Network error during Ideogram image generation', + 'Check your internet connection and try again', + error instanceof Error ? error : undefined + ) + ) + } + + return Err( + new ImageAPIError('Failed to generate image with Ideogram', { + provider: 'ideogram', + prompt, + upstreamMessage: errorMessage, + suggestion: 'Check Ideogram API configuration and try again', + }) + ) + } + + private getAPIErrorSuggestion(status: number): string { + if (status === 401 || status === 403) { + return 'Check that your IDEOGRAM_API_KEY is valid and has image generation permissions' + } + if (status === 429) { + return 'You have exceeded your Ideogram API rate limit or quota. Wait before retrying' + } + if (status === 400 || status === 422) { + return 'Check the prompt and request parameters against the Ideogram API specification' + } + if (status >= 500) { + return 'The Ideogram service is temporarily unavailable. Please retry after a few moments' + } + return 'Check Ideogram API configuration and retry the request' + } +} + +/** + * Creates a new Ideogram image client. + */ +export function createIdeogramImageClient(config: Config): Result { + if (!config.ideogramApiKey || config.ideogramApiKey.trim().length === 0) { + return Err( + new ImageAPIError( + 'Failed to initialize Ideogram image client: IDEOGRAM_API_KEY is not configured', + 'Set the IDEOGRAM_API_KEY environment variable with your Ideogram API key' + ) + ) + } + + return Ok(new IdeogramImageClientImpl(config.ideogramApiKey, config.imageQuality)) +} diff --git a/src/server/__tests__/mcpServer.test.ts b/src/server/__tests__/mcpServer.test.ts index 5410f1b..00d23d6 100644 --- a/src/server/__tests__/mcpServer.test.ts +++ b/src/server/__tests__/mcpServer.test.ts @@ -51,6 +51,31 @@ vi.mock('../../api/openaiImageClient', () => { } }) +// Mock the Ideogram image client for provider routing tests +vi.mock('../../api/ideogramImageClient', () => { + return { + createIdeogramImageClient: vi.fn().mockImplementation(() => { + const mockClient = { + generateImage: vi.fn().mockResolvedValue({ + success: true, + data: { + imageData: Buffer.from('mock-ideogram-image-data', 'utf-8'), + metadata: { + model: 'ideogram-v4', + provider: 'ideogram', + prompt: 'test prompt', + mimeType: 'image/png', + timestamp: new Date(), + inputImageProvided: false, + }, + }, + }), + } + return { success: true, data: mockClient } + }), + } +}) + // Mock the OpenAI text client for provider routing tests vi.mock('../../api/openaiTextClient', () => { return { @@ -206,6 +231,7 @@ describe('MCP Server', () => { let originalApiKey: string | undefined let originalImageProvider: string | undefined let originalOpenAIApiKey: string | undefined + let originalIdeogramApiKey: string | undefined beforeEach(() => { vi.clearAllMocks() @@ -213,9 +239,11 @@ describe('MCP Server', () => { originalApiKey = process.env.GEMINI_API_KEY originalImageProvider = process.env.IMAGE_PROVIDER originalOpenAIApiKey = process.env.OPENAI_API_KEY + originalIdeogramApiKey = process.env.IDEOGRAM_API_KEY process.env.IMAGE_PROVIDER = undefined process.env.GEMINI_API_KEY = 'test-api-key-unit-tests' process.env.OPENAI_API_KEY = undefined + process.env.IDEOGRAM_API_KEY = undefined process.env.IMAGE_OUTPUT_DIR = './test-output' }) @@ -228,6 +256,7 @@ describe('MCP Server', () => { } process.env.IMAGE_PROVIDER = originalImageProvider process.env.OPENAI_API_KEY = originalOpenAIApiKey + process.env.IDEOGRAM_API_KEY = originalIdeogramApiKey }) it('should create MCP server instance', async () => { // Arrange & Act @@ -475,6 +504,37 @@ describe('MCP Server', () => { ) expect(createOpenAITextClient).toHaveBeenCalled() }) + + it('should route image generation through Ideogram provider when configured', async () => { + // Arrange + process.env.IMAGE_PROVIDER = 'ideogram' + process.env.GEMINI_API_KEY = undefined + process.env.OPENAI_API_KEY = undefined + process.env.IDEOGRAM_API_KEY = 'test-ideogram-api-key-unit-tests' + const mcpServer = createMCPServer() + + // Act + const result = await mcpServer.callTool('generate_image', { + prompt: 'test prompt', + }) + + // Assert + expect(result).toBeDefined() + expect(result.isError).toBe(false) + + const { createGeminiClient } = await import('../../api/geminiClient') + const { createOpenAIImageClient } = await import('../../api/openaiImageClient') + const { createIdeogramImageClient } = await import('../../api/ideogramImageClient') + + expect(createGeminiClient).not.toHaveBeenCalled() + expect(createOpenAIImageClient).not.toHaveBeenCalled() + expect(createIdeogramImageClient).toHaveBeenCalledWith( + expect.objectContaining({ + imageProvider: 'ideogram', + ideogramApiKey: 'test-ideogram-api-key-unit-tests', + }) + ) + }) }) // Test suite for aspectRatio parameter in generate_image tool schema diff --git a/src/server/mcpServer.ts b/src/server/mcpServer.ts index 4fd8d22..302a788 100644 --- a/src/server/mcpServer.ts +++ b/src/server/mcpServer.ts @@ -15,6 +15,7 @@ import { // API clients import { createGeminiClient } from '../api/geminiClient.js' import { createGeminiTextClient } from '../api/geminiTextClient.js' +import { createIdeogramImageClient } from '../api/ideogramImageClient.js' import type { ImageClient } from '../api/imageClient.js' import { createOpenAIImageClient } from '../api/openaiImageClient.js' import { createOpenAITextClient } from '../api/openaiTextClient.js' @@ -30,6 +31,7 @@ import { } from '../business/structuredPromptGenerator.js' // Types import type { GenerateImageParams, MCPServerConfig } from '../types/mcp.js' +import type { Result } from '../types/result.js' // Utilities import { getConfig } from '../utils/config.js' @@ -196,12 +198,17 @@ export class MCPServerImpl { } const config = configResult.data - if (this.imageClient && (config.skipPromptEnhancement || this.structuredPromptGenerator)) { + // Ideogram enhances prompts natively via its `magic_prompt` option, so it + // does not use the external text-model prompt enhancement pipeline. + const usesExternalPromptEnhancement = config.imageProvider !== 'ideogram' + const needsTextClient = usesExternalPromptEnhancement && !config.skipPromptEnhancement + + if (this.imageClient && (!needsTextClient || this.structuredPromptGenerator)) { return } // Initialize Text Client for prompt generation when enhancement is enabled. - if (!config.skipPromptEnhancement && !this.textClient) { + if (needsTextClient && !this.textClient) { const textClientResult = config.imageProvider === 'openai' ? createOpenAITextClient(config) @@ -213,16 +220,20 @@ export class MCPServerImpl { } // Initialize Structured Prompt Generator - if (!config.skipPromptEnhancement && this.textClient && !this.structuredPromptGenerator) { + if (needsTextClient && this.textClient && !this.structuredPromptGenerator) { this.structuredPromptGenerator = createStructuredPromptGenerator(this.textClient) } // Initialize image generation client. if (!this.imageClient) { - const clientResult = - config.imageProvider === 'openai' - ? createOpenAIImageClient(config) - : createGeminiClient(config) + let clientResult: Result + if (config.imageProvider === 'openai') { + clientResult = createOpenAIImageClient(config) + } else if (config.imageProvider === 'ideogram') { + clientResult = createIdeogramImageClient(config) + } else { + clientResult = createGeminiClient(config) + } if (!clientResult.success) { throw clientResult.error } diff --git a/src/types/mcp.ts b/src/types/mcp.ts index e984ed6..5f40fad 100644 --- a/src/types/mcp.ts +++ b/src/types/mcp.ts @@ -43,8 +43,9 @@ export type ImageQuality = 'fast' | 'balanced' | 'quality' * Supported image providers. * - 'gemini': Google Gemini/Nano Banana models (default) * - 'openai': OpenAI GPT Image models such as gpt-image-2 + * - 'ideogram': Ideogram 4.0 image models */ -export type ImageProvider = 'gemini' | 'openai' +export type ImageProvider = 'gemini' | 'openai' | 'ideogram' /** * Supported quality preset values @@ -58,7 +59,11 @@ export const IMAGE_QUALITY_VALUES: readonly ImageQuality[] = [ /** * Supported image provider values. */ -export const IMAGE_PROVIDER_VALUES: readonly ImageProvider[] = ['gemini', 'openai'] as const +export const IMAGE_PROVIDER_VALUES: readonly ImageProvider[] = [ + 'gemini', + 'openai', + 'ideogram', +] as const /** * Gemini image generation model identifiers @@ -70,6 +75,11 @@ export const GEMINI_MODELS = { PRO: 'gemini-3-pro-image-preview', } as const +/** + * Ideogram image generation model identifier (Ideogram 4.0 / v4) + */ +export const IDEOGRAM_MODEL = 'ideogram-v4' as const + /** * Parameters for image generation using Gemini API */ diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts index fde9e17..e56f9eb 100644 --- a/src/utils/__tests__/config.test.ts +++ b/src/utils/__tests__/config.test.ts @@ -11,6 +11,7 @@ describe('config', () => { process.env.IMAGE_PROVIDER = undefined process.env.GEMINI_API_KEY = undefined process.env.OPENAI_API_KEY = undefined + process.env.IDEOGRAM_API_KEY = undefined process.env.IMAGE_OUTPUT_DIR = undefined process.env.IMAGE_QUALITY = undefined }) @@ -27,6 +28,7 @@ describe('config', () => { imageProvider: 'gemini' as const, geminiApiKey: '', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, @@ -51,6 +53,7 @@ describe('config', () => { imageProvider: 'gemini' as const, geminiApiKey: 'short', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, @@ -74,6 +77,7 @@ describe('config', () => { imageProvider: 'gemini' as const, geminiApiKey: 'valid-api-key-12345', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: -1000, // Invalid negative timeout skipPromptEnhancement: false, @@ -101,6 +105,7 @@ describe('config', () => { imageProvider: 'gemini' as const, geminiApiKey: 'valid-api-key-12345', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, @@ -121,6 +126,7 @@ describe('config', () => { imageProvider: 'gemini' as const, geminiApiKey: 'valid-api-key-12345', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, @@ -147,6 +153,7 @@ describe('config', () => { imageProvider: 'gemini' as const, geminiApiKey: 'valid-api-key-12345', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, @@ -169,6 +176,7 @@ describe('config', () => { imageProvider: 'openai' as const, geminiApiKey: '', openaiApiKey: 'test-openai-api-key-12345', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, @@ -188,6 +196,7 @@ describe('config', () => { imageProvider: 'openai' as const, geminiApiKey: '', openaiApiKey: '', + ideogramApiKey: '', imageOutputDir: './output', apiTimeout: 30000, skipPromptEnhancement: false, @@ -204,6 +213,50 @@ describe('config', () => { expect(result.error.message).toContain('OPENAI_API_KEY') } }) + + it('should accept Ideogram provider without GEMINI_API_KEY', () => { + // Arrange + const config = { + imageProvider: 'ideogram' as const, + geminiApiKey: '', + openaiApiKey: '', + ideogramApiKey: 'test-ideogram-api-key-12345', + imageOutputDir: './output', + apiTimeout: 30000, + skipPromptEnhancement: false, + imageQuality: 'fast' as const, + } + + // Act + const result = validateConfig(config) + + // Assert + expect(result.success).toBe(true) + }) + + it('should require IDEOGRAM_API_KEY for Ideogram provider', () => { + // Arrange + const config = { + imageProvider: 'ideogram' as const, + geminiApiKey: '', + openaiApiKey: '', + ideogramApiKey: '', + imageOutputDir: './output', + apiTimeout: 30000, + skipPromptEnhancement: false, + imageQuality: 'fast' as const, + } + + // Act + const result = validateConfig(config) + + // Assert + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toBeInstanceOf(ConfigError) + expect(result.error.message).toContain('IDEOGRAM_API_KEY') + } + }) }) describe('getConfig', () => { diff --git a/src/utils/config.ts b/src/utils/config.ts index 5cd7f4f..1488b65 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -16,6 +16,7 @@ export interface Config { imageProvider: ImageProvider geminiApiKey: string openaiApiKey: string + ideogramApiKey: string imageOutputDir: string apiTimeout: number skipPromptEnhancement: boolean // Skip prompt enhancement for direct control @@ -99,6 +100,28 @@ export function validateConfig(config: Config): Result { ) } + // Validate IDEOGRAM_API_KEY only when Ideogram is the selected provider. + if ( + config.imageProvider === 'ideogram' && + (!config.ideogramApiKey || config.ideogramApiKey.trim().length === 0) + ) { + return Err( + new ConfigError( + 'IDEOGRAM_API_KEY is required but not provided', + 'Set IDEOGRAM_API_KEY environment variable with your Ideogram API key' + ) + ) + } + + if (config.imageProvider === 'ideogram' && config.ideogramApiKey.length < 10) { + return Err( + new ConfigError( + 'IDEOGRAM_API_KEY appears to be invalid - must be at least 10 characters', + 'Set the IDEOGRAM_API_KEY environment variable to your valid Ideogram API key' + ) + ) + } + // Validate apiTimeout if (config.apiTimeout <= 0) { return Err( @@ -141,6 +164,7 @@ export function getConfig(): Result { imageProvider: (readEnv('IMAGE_PROVIDER') || DEFAULT_CONFIG.imageProvider) as ImageProvider, geminiApiKey: readEnv('GEMINI_API_KEY') || '', openaiApiKey: readEnv('OPENAI_API_KEY') || '', + ideogramApiKey: readEnv('IDEOGRAM_API_KEY') || '', imageOutputDir: readEnv('IMAGE_OUTPUT_DIR') || DEFAULT_CONFIG.imageOutputDir, apiTimeout: DEFAULT_CONFIG.apiTimeout, skipPromptEnhancement: readEnv('SKIP_PROMPT_ENHANCEMENT') === 'true',