diff --git a/src/backend/drivers/ai-image/inputImage.ts b/src/backend/drivers/ai-image/inputImage.ts
new file mode 100644
index 000000000..ba97031fc
--- /dev/null
+++ b/src/backend/drivers/ai-image/inputImage.ts
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * Shared helpers for `input_images` (image-to-image) handling across image
+ * providers. `input_images` is the canonical, cross-provider field; an entry
+ * may be a public URL, a data-URI, or raw base64. Providers whose upstream
+ * API needs base64 use these helpers to normalize URLs server-side (via the
+ * SSRF-guarded `secureFetch`); providers that accept URLs natively (Replicate,
+ * xAI) pass them through untouched.
+ */
+
+import { HttpError } from '../../core/http/HttpError.js';
+import { secureFetch } from '../../util/secureHttp.js';
+import type { IGenerateParams } from './types.js';
+
+export function isHttpUrl(s: string): boolean {
+ return s.startsWith('http://') || s.startsWith('https://');
+}
+
+/**
+ * Resolve the single input image for providers that only support one.
+ * Throws 400 if `input_images` carries more than one entry. Returns the
+ * chosen image string (URL / data-URI / raw base64) or undefined.
+ */
+export function resolveSingleInputImage(
+ params: Pick,
+ providerLabel: string,
+): string | undefined {
+ const imgs = params.input_images;
+ if (imgs && imgs.length > 1) {
+ throw new HttpError(
+ 400,
+ `${providerLabel} supports only a single input image; pass one image via input_image or a single-element input_images.`,
+ { legacyCode: 'bad_request' },
+ );
+ }
+ return params.input_image ?? imgs?.[0];
+}
+
+const DATA_URI_PATTERN = /^data:([^;,]+)?(?:;base64)?,(.*)$/s;
+
+/** Parse a `data:;base64,` URI into raw base64 + mime. */
+export function parseDataUri(
+ s: string,
+): { base64: string; mime: string } | null {
+ const m = DATA_URI_PATTERN.exec(s);
+ if (!m) return null;
+ return { base64: m[2] ?? '', mime: m[1] ?? 'image/png' };
+}
+
+/** Fetch an http(s) image and return raw base64 + mime (SSRF-guarded). */
+export async function fetchImageAsBase64(
+ url: string,
+): Promise<{ base64: string; mime: string }> {
+ const res = await secureFetch(url);
+ if (!res.ok) {
+ throw new HttpError(
+ 400,
+ `Failed to fetch input image (status ${res.status})`,
+ { legacyCode: 'bad_request' },
+ );
+ }
+ const buffer = Buffer.from(await res.arrayBuffer());
+ const mime =
+ res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
+ return { base64: buffer.toString('base64'), mime };
+}
+
+/**
+ * Normalize any input-image string to a base64 data-URI:
+ * • http(s) URL → fetched via secureFetch
+ * • data-URI → returned as-is
+ * • raw base64 → wrapped with `mimeHint` (default image/png)
+ */
+export async function toBase64DataUri(
+ img: string,
+ mimeHint?: string,
+): Promise {
+ if (img.startsWith('data:')) return img;
+ if (isHttpUrl(img)) {
+ const { base64, mime } = await fetchImageAsBase64(img);
+ return `data:${mime};base64,${base64}`;
+ }
+ return `data:${mimeHint ?? 'image/png'};base64,${img}`;
+}
diff --git a/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts b/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts
index 45b47814d..b3a00893d 100644
--- a/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts
+++ b/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts
@@ -47,6 +47,16 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
+// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
+const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
+ fetchImageAsBase64Mock: vi.fn(),
+}));
+
+vi.mock('../../inputImage.js', async (orig) => ({
+ ...(await orig()),
+ fetchImageAsBase64: fetchImageAsBase64Mock,
+}));
+
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -81,6 +91,7 @@ const makeProvider = (
);
beforeEach(() => {
+ fetchImageAsBase64Mock.mockReset();
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance;
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
@@ -470,3 +481,70 @@ describe('CloudflareImageProvider.generate cost components', () => {
);
});
});
+
+// ── input_images (canonical image-to-image field) ──────────────────
+
+describe('CloudflareImageProvider.generate input_images', () => {
+ const klein9bWith = (extra: Record) => {
+ const provider = makeProvider();
+ fetchSpy.mockResolvedValueOnce(
+ new Response(Buffer.from([1, 2, 3]).buffer, {
+ status: 200,
+ headers: { 'content-type': 'image/png' },
+ }),
+ );
+ return withTestActor(() =>
+ provider.generate({
+ model: '@cf/black-forest-labs/flux-2-klein-9b',
+ prompt: 'edit it',
+ ratio: { w: 2000, h: 1000 },
+ ...extra,
+ } as never),
+ );
+ };
+
+ const hasInputCostLine = () => {
+ const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
+ return (entries as Array<{ usageType: string }>).some((e) =>
+ e.usageType.endsWith(':input_image_mp'),
+ );
+ };
+
+ it('maps a base64/data-URI input_images entry to the input image (cost line appears)', async () => {
+ await klein9bWith({ input_images: ['data:image/png;base64,AAAA'] });
+ expect(hasInputCostLine()).toBe(true);
+ });
+
+ it('maps a singular input_image to the input image', async () => {
+ await klein9bWith({ input_image: 'data:image/png;base64,AAAA' });
+ expect(hasInputCostLine()).toBe(true);
+ });
+
+ it('fetches an http(s) URL input via secureFetch and uses it as the input image', async () => {
+ fetchImageAsBase64Mock.mockResolvedValueOnce({
+ base64: 'AAAA',
+ mime: 'image/png',
+ });
+ await klein9bWith({ input_images: ['https://example.com/in.png'] });
+ expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
+ 'https://example.com/in.png',
+ );
+ expect(hasInputCostLine()).toBe(true);
+ });
+
+ it('throws 400 when more than one input image is supplied (before any fetch)', async () => {
+ const provider = makeProvider();
+ await expect(
+ withTestActor(() =>
+ provider.generate({
+ model: '@cf/black-forest-labs/flux-2-klein-9b',
+ prompt: 'edit it',
+ ratio: { w: 1024, h: 1024 },
+ input_images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'],
+ } as never),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(fetchImageAsBase64Mock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts b/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts
index 6d1a551ac..d59c2a1e7 100644
--- a/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts
+++ b/src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts
@@ -29,6 +29,11 @@ import {
CLOUDFLARE_IMAGE_GENERATION_MODELS,
CloudflareImageModel,
} from './models.js';
+import {
+ fetchImageAsBase64,
+ isHttpUrl,
+ resolveSingleInputImage,
+} from '../../inputImage.js';
type CloudflareGenerateParams = IGenerateParams & {
steps?: number;
@@ -101,6 +106,16 @@ export class CloudflareImageProvider implements IImageProvider {
});
}
+ // Canonical `input_images`/`input_image` → Cloudflare's `image` field.
+ // Cloudflare accepts a single input image; a URL is fetched to base64
+ // server-side (SSRF-guarded) since the API has no URL field.
+ const singleInput = resolveSingleInputImage(options, 'Cloudflare');
+ if (singleInput) {
+ options.image ??= isHttpUrl(singleInput)
+ ? (await fetchImageAsBase64(singleInput)).base64
+ : singleInput;
+ }
+
const steps = this.#resolveSteps(selectedModel, options);
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
hasInputImage:
diff --git a/src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.test.ts b/src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.test.ts
index e728990b1..9c28ff09d 100644
--- a/src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.test.ts
+++ b/src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.test.ts
@@ -71,6 +71,16 @@ vi.mock('@google/genai', () => {
return { GoogleGenAI };
});
+// Stub the URL→data-URI normalizer so URL inputs stay offline; keep the rest real.
+const { toBase64DataUriMock } = vi.hoisted(() => ({
+ toBase64DataUriMock: vi.fn(),
+}));
+
+vi.mock('../../inputImage.js', async (orig) => ({
+ ...(await orig()),
+ toBase64DataUri: toBase64DataUriMock,
+}));
+
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -94,6 +104,7 @@ const makeProvider = () =>
beforeEach(() => {
generateContentMock.mockReset();
generateImagesMock.mockReset();
+ toBase64DataUriMock.mockReset();
googleAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
@@ -272,6 +283,31 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
expect(result).toBe('data:image/png;base64,BASE64IMG');
});
+ it('fetches an http(s) URL input and sends it as an inlineData part', async () => {
+ const provider = makeProvider();
+ generateContentMock.mockResolvedValueOnce(inlineImageResponse);
+ toBase64DataUriMock.mockResolvedValueOnce(
+ 'data:image/png;base64,URLBYTES',
+ );
+
+ await withTestActor(() =>
+ provider.generate({
+ model: 'gemini-2.5-flash-image',
+ prompt: 'add a hat',
+ input_images: ['https://example.com/in.png'],
+ }),
+ );
+
+ expect(toBase64DataUriMock).toHaveBeenCalledWith(
+ 'https://example.com/in.png',
+ undefined,
+ );
+ const sent = generateContentMock.mock.calls[0]![0];
+ expect(sent.contents).toContainEqual({
+ inlineData: { mimeType: 'image/png', data: 'URLBYTES' },
+ });
+ });
+
it('throws 400 when the SDK returns no inline image data', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce({
diff --git a/src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.ts b/src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.ts
index 6660134b2..69c6c70ef 100644
--- a/src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.ts
+++ b/src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.ts
@@ -31,6 +31,7 @@ import type {
IImageModel,
IImageProvider,
} from '../../types.js';
+import { isHttpUrl, toBase64DataUri } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
const MIME_SIGNATURES: Record = {
@@ -107,6 +108,18 @@ export class GeminiImageProvider implements IImageProvider {
input_images = [input_image];
}
+ // Resolve any http(s) URL inputs to base64 data-URIs server-side
+ // (SSRF-guarded) so the rest of the flow only deals with inline data.
+ if (input_images?.length) {
+ input_images = await Promise.all(
+ input_images.map((img) =>
+ isHttpUrl(img)
+ ? toBase64DataUri(img, input_image_mime_type)
+ : img,
+ ),
+ );
+ }
+
// Validate input images have detectable MIME types
if (input_images?.length) {
for (const img of input_images) {
diff --git a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts
index 95ceb4775..92cd52ee5 100644
--- a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts
+++ b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts
@@ -78,6 +78,16 @@ vi.mock('openai', () => {
};
});
+// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
+const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
+ fetchImageAsBase64Mock: vi.fn(),
+}));
+
+vi.mock('../../inputImage.js', async (orig) => ({
+ ...(await orig()),
+ fetchImageAsBase64: fetchImageAsBase64Mock,
+}));
+
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -100,6 +110,7 @@ const makeProvider = () =>
beforeEach(() => {
generateMock.mockReset();
editMock.mockReset();
+ fetchImageAsBase64Mock.mockReset();
openAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
@@ -326,6 +337,32 @@ describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => {
expect(Array.isArray(sent.image)).toBe(false);
expect((sent.image as { __file?: boolean }).__file).toBe(true);
});
+
+ it('fetches an http(s) URL input and sends the bytes to images.edit', async () => {
+ const provider = makeProvider();
+ editMock.mockResolvedValueOnce(editResponse);
+ fetchImageAsBase64Mock.mockResolvedValueOnce({
+ base64: 'iVBORw0KGgo=',
+ mime: 'image/png',
+ });
+
+ await withTestActor(() =>
+ provider.generate({
+ model: 'gpt-image-1',
+ prompt: 'add a hat',
+ ratio: { w: 1024, h: 1024 },
+ input_images: ['https://example.com/in.png'],
+ }),
+ );
+
+ expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
+ 'https://example.com/in.png',
+ );
+ expect(generateMock).not.toHaveBeenCalled();
+ expect(editMock).toHaveBeenCalledTimes(1);
+ const sent = editMock.mock.calls[0]![0];
+ expect((sent.image as { __file?: boolean }).__file).toBe(true);
+ });
});
// ── gpt-image (token-priced) request shape & metering ──────────────
diff --git a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts
index 856ed2e72..169f7c63f 100644
--- a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts
+++ b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts
@@ -31,6 +31,7 @@ import type {
IImageProvider,
} from '../../types.js';
import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js';
+import { fetchImageAsBase64, isHttpUrl } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
interface OpenAIImageUsage {
@@ -660,17 +661,23 @@ export class OpenAiImageProvider implements IImageProvider {
} as ImageEditParamsNonStreaming;
}
- // Accepts a `data:;base64,...` URI or a raw base64 string (what the
- // Gemini image provider documents callers to pass) and turns it into an
- // uploadable file for the OpenAI edit endpoint.
+ // Accepts a public URL, a `data:;base64,...` URI, or a raw base64
+ // string and turns it into an uploadable file for the OpenAI edit endpoint.
+ // URLs are fetched server-side via the SSRF-guarded secureFetch.
async #toUploadable(img: string, mimeHint?: string) {
let mime = mimeHint ?? 'image/png';
let base64 = img;
- const dataUri = /^data:([^;]+);base64,(.*)$/s.exec(img);
- if (dataUri) {
- mime = dataUri[1];
- base64 = dataUri[2];
+ if (isHttpUrl(img)) {
+ const fetched = await fetchImageAsBase64(img);
+ mime = fetched.mime;
+ base64 = fetched.base64;
+ } else {
+ const dataUri = /^data:([^;]+);base64,(.*)$/s.exec(img);
+ if (dataUri) {
+ mime = dataUri[1];
+ base64 = dataUri[2];
+ }
}
const buffer = Buffer.from(base64, 'base64');
diff --git a/src/backend/drivers/ai-image/providers/together/TogetherImageProvider.test.ts b/src/backend/drivers/ai-image/providers/together/TogetherImageProvider.test.ts
index 3ff4a9636..164d4fec9 100644
--- a/src/backend/drivers/ai-image/providers/together/TogetherImageProvider.test.ts
+++ b/src/backend/drivers/ai-image/providers/together/TogetherImageProvider.test.ts
@@ -332,6 +332,55 @@ describe('TogetherImageProvider.generate request shape', () => {
const sent = generateMock.mock.calls[0]![0];
expect(sent.image_base64).toBe('BASE64DATA');
});
+
+ it('routes a base64 input_images entry to image_base64', async () => {
+ const provider = makeProvider();
+ generateMock.mockResolvedValueOnce(sampleResponse);
+
+ await withTestActor(() =>
+ provider.generate({
+ model: 'togetherai:black-forest-labs/FLUX.1-schnell',
+ prompt: 'edit it',
+ input_images: ['BASE64DATA'],
+ }),
+ );
+
+ const sent = generateMock.mock.calls[0]![0];
+ expect(sent.image_base64).toBe('BASE64DATA');
+ });
+
+ it('routes a URL input_images entry to the native image_url field (no fetch)', async () => {
+ const provider = makeProvider();
+ generateMock.mockResolvedValueOnce(sampleResponse);
+
+ await withTestActor(() =>
+ provider.generate({
+ model: 'togetherai:black-forest-labs/FLUX.1-schnell',
+ prompt: 'edit it',
+ input_images: ['https://example.com/in.png'],
+ }),
+ );
+
+ const sent = generateMock.mock.calls[0]![0];
+ expect(sent.image_url).toBe('https://example.com/in.png');
+ expect(sent.image_base64).toBeUndefined();
+ });
+
+ it('throws 400 when more than one input image is supplied', async () => {
+ const provider = makeProvider();
+
+ await expect(
+ withTestActor(() =>
+ provider.generate({
+ model: 'togetherai:black-forest-labs/FLUX.1-schnell',
+ prompt: 'edit it',
+ input_images: ['BASE64A', 'BASE64B'],
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+
+ expect(generateMock).not.toHaveBeenCalled();
+ });
});
// ── Output extraction & error mapping ───────────────────────────────
diff --git a/src/backend/drivers/ai-image/providers/together/TogetherImageProvider.ts b/src/backend/drivers/ai-image/providers/together/TogetherImageProvider.ts
index 2909c2ea4..ca147141e 100644
--- a/src/backend/drivers/ai-image/providers/together/TogetherImageProvider.ts
+++ b/src/backend/drivers/ai-image/providers/together/TogetherImageProvider.ts
@@ -26,6 +26,7 @@ import type {
IImageProvider,
} from '../../types.js';
import { TOGETHER_IMAGE_GENERATION_MODELS } from './models.js';
+import { isHttpUrl, resolveSingleInputImage } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
const TOGETHER_DEFAULT_RATIO = { w: 1024, h: 1024 };
@@ -87,6 +88,18 @@ export class TogetherImageProvider implements IImageProvider {
});
}
+ // Canonical `input_images` → Together's native fields. Together accepts
+ // a single input image: a URL goes to `image_url`, base64/data-URI to
+ // `image_base64` (via the existing `input_image` alias).
+ const singleInput = resolveSingleInputImage(params, 'Together AI');
+ if (singleInput) {
+ if (isHttpUrl(singleInput)) {
+ options.image_url ??= singleInput;
+ } else {
+ options.input_image ??= singleInput;
+ }
+ }
+
ratio = ratio || TOGETHER_DEFAULT_RATIO;
const actor = Context.get('actor');
diff --git a/src/docs/src/AI/txt2img.md b/src/docs/src/AI/txt2img.md
index 25b6e6f22..f27607178 100755
--- a/src/docs/src/AI/txt2img.md
+++ b/src/docs/src/AI/txt2img.md
@@ -35,6 +35,21 @@ Additional settings for the generation request. Available options depend on the
| `model` | `String` | Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` (OpenAI) or `'grok-imagine-image'` when `provider: 'xai'` |
| `test_mode` | `Boolean` | When `true`, returns a sample image without using credits |
| `puter_output_path` | `String` | When set, the generated image is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination |
+| `input_images` | `Array` | Input image(s) for image-to-image — the canonical, cross-provider field (see below). |
+| `input_image` | `String` | Single-image shorthand for `input_images`. |
+
+#### Input images (image-to-image)
+
+`input_images` is the universal way to pass image-to-image inputs across providers; `input_image` is the single-image shorthand. Each entry may be a **public URL**, a **data-URI**, or **raw base64** — providers that need base64 fetch URLs server-side (SSRF-guarded), so a URL works everywhere.
+
+| Provider | Multiple images? | Accepted input forms |
+|----------|------------------|----------------------|
+| OpenAI `gpt-image-*` | Yes | URL, base64 / data-URI |
+| Gemini | Yes | URL, base64 / data-URI |
+| Replicate | Yes (model-dependent) | URL, base64 / data-URI |
+| xAI `grok-imagine-*` | Up to 3 | URL, base64 / data-URI |
+| Together | Single only (400 if more than one) | URL, base64 / data-URI |
+| Cloudflare | Single only (400 if more than one) | URL, base64 / data-URI (only some models use it) |
#### OpenAI Options
@@ -45,8 +60,8 @@ Available when `provider: 'openai-image-generation'` or inferred from model (`gp
| `model` | `String` | Image model to use. Available: `'gpt-image-2'`, `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'` |
| `quality` | `String` | Image quality: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'` |
| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. `gpt-image-2` accepts arbitrary sizes; other GPT models are restricted to fixed sizes |
-| `input_image` | `String` | A base64-encoded (or data-URI) input image for image-to-image editing. |
-| `input_images` | `Array` | Multiple base64-encoded (or data-URI) input images for image-to-image editing. Routes the request through OpenAI's image edit endpoint. |
+| `input_image` | `String` | An input image for image-to-image editing — a URL or base64/data-URI (URLs are fetched server-side). |
+| `input_images` | `Array` | Multiple input images (URL or base64/data-URI) for image-to-image editing. Routes the request through OpenAI's image edit endpoint. |
For more details, see the [OpenAI API reference](https://platform.openai.com/docs/api-reference/images/create).
@@ -59,7 +74,7 @@ Available when `provider: 'gemini'` or inferred from model:
| `model` | `String` | Image model to use. |
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
| `quality` | `String` | Output size tier: `'512'`, `'1K'`, `'2K'`, `'4K'` (availability varies by model) |
-| `input_images` | `Array` | Base64 input images for image-to-image (Gemini models only) |
+| `input_images` | `Array` | Input images for image-to-image — a URL or base64/data-URI (URLs are fetched server-side). |
#### xAI (Grok) Options
@@ -87,6 +102,8 @@ Available when `provider: 'together'` or inferred from model:
| `seed` | `Number` | Seed used for generation. Can be used to reproduce image generations |
| `negative_prompt` | `String` | The prompt or prompts not to guide the image generation |
| `n` | `Number` | Number of image results to generate. Default: `1` |
+| `input_images` | `Array` | Image-to-image input — **single image only** (400 if more than one). A URL is routed to `image_url`; base64/data-URI to `image_base64`. |
+| `input_image` | `String` | Single-image shorthand for `input_images`. |
| `image_url` | `String` | URL of an image to use for image models that support it |
| `image_base64` | `String` | Base64 encoded input image for image-to-image generation |
| `mask_image_url` | `String` | URL of mask image for inpainting |
@@ -107,8 +124,8 @@ Available when `provider: 'replicate-image-generation'` or inferred from model:
|--------|------|-------------|
| `model` | `String` | Model id (e.g. `'black-forest-labs/flux-schnell'`, `'leonardoai/lucid-origin'`). |
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
-| `input_image` | `String` | URL of an input image for image-to-image generation. |
-| `input_images` | `Array` | Array of input image URLs for multi-image generation. |
+| `input_image` | `String` | Input image for image-to-image generation — a URL or base64/data-URI. |
+| `input_images` | `Array` | Input images (URL or base64/data-URI) for multi-image generation. |
##### Per-model options