Skip to content

Commit 859136a

Browse files
make input_images universal
1 parent 3985fdd commit 859136a

10 files changed

Lines changed: 379 additions & 12 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright (C) 2024-present Puter Technologies Inc.
3+
*
4+
* This file is part of Puter.
5+
*
6+
* Puter is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
20+
/**
21+
* Shared helpers for `input_images` (image-to-image) handling across image
22+
* providers. `input_images` is the canonical, cross-provider field; an entry
23+
* may be a public URL, a data-URI, or raw base64. Providers whose upstream
24+
* API needs base64 use these helpers to normalize URLs server-side (via the
25+
* SSRF-guarded `secureFetch`); providers that accept URLs natively (Replicate,
26+
* xAI) pass them through untouched.
27+
*/
28+
29+
import { HttpError } from '../../core/http/HttpError.js';
30+
import { secureFetch } from '../../util/secureHttp.js';
31+
import type { IGenerateParams } from './types.js';
32+
33+
export function isHttpUrl(s: string): boolean {
34+
return s.startsWith('http://') || s.startsWith('https://');
35+
}
36+
37+
/**
38+
* Resolve the single input image for providers that only support one.
39+
* Throws 400 if `input_images` carries more than one entry. Returns the
40+
* chosen image string (URL / data-URI / raw base64) or undefined.
41+
*/
42+
export function resolveSingleInputImage(
43+
params: Pick<IGenerateParams, 'input_image' | 'input_images'>,
44+
providerLabel: string,
45+
): string | undefined {
46+
const imgs = params.input_images;
47+
if (imgs && imgs.length > 1) {
48+
throw new HttpError(
49+
400,
50+
`${providerLabel} supports only a single input image; pass one image via input_image or a single-element input_images.`,
51+
{ legacyCode: 'bad_request' },
52+
);
53+
}
54+
return params.input_image ?? imgs?.[0];
55+
}
56+
57+
const DATA_URI_PATTERN = /^data:([^;,]+)?(?:;base64)?,(.*)$/s;
58+
59+
/** Parse a `data:<mime>;base64,<payload>` URI into raw base64 + mime. */
60+
export function parseDataUri(
61+
s: string,
62+
): { base64: string; mime: string } | null {
63+
const m = DATA_URI_PATTERN.exec(s);
64+
if (!m) return null;
65+
return { base64: m[2] ?? '', mime: m[1] ?? 'image/png' };
66+
}
67+
68+
/** Fetch an http(s) image and return raw base64 + mime (SSRF-guarded). */
69+
export async function fetchImageAsBase64(
70+
url: string,
71+
): Promise<{ base64: string; mime: string }> {
72+
const res = await secureFetch(url);
73+
if (!res.ok) {
74+
throw new HttpError(
75+
400,
76+
`Failed to fetch input image (status ${res.status})`,
77+
{ legacyCode: 'bad_request' },
78+
);
79+
}
80+
const buffer = Buffer.from(await res.arrayBuffer());
81+
const mime =
82+
res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
83+
return { base64: buffer.toString('base64'), mime };
84+
}
85+
86+
/**
87+
* Normalize any input-image string to a base64 data-URI:
88+
* • http(s) URL → fetched via secureFetch
89+
* • data-URI → returned as-is
90+
* • raw base64 → wrapped with `mimeHint` (default image/png)
91+
*/
92+
export async function toBase64DataUri(
93+
img: string,
94+
mimeHint?: string,
95+
): Promise<string> {
96+
if (img.startsWith('data:')) return img;
97+
if (isHttpUrl(img)) {
98+
const { base64, mime } = await fetchImageAsBase64(img);
99+
return `data:${mime};base64,${base64}`;
100+
}
101+
return `data:${mimeHint ?? 'image/png'};base64,${img}`;
102+
}

src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ import { withTestActor } from '../../../integrationTestUtil.js';
4747
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
4848
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
4949

50+
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
51+
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
52+
fetchImageAsBase64Mock: vi.fn(),
53+
}));
54+
55+
vi.mock('../../inputImage.js', async (orig) => ({
56+
...(await orig<typeof import('../../inputImage.js')>()),
57+
fetchImageAsBase64: fetchImageAsBase64Mock,
58+
}));
59+
5060
// ── Test harness ────────────────────────────────────────────────────
5161

5262
let server: PuterServer;
@@ -81,6 +91,7 @@ const makeProvider = (
8191
);
8292

8393
beforeEach(() => {
94+
fetchImageAsBase64Mock.mockReset();
8495
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
8596
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
8697
batchIncrementUsagesSpy = vi.spyOn(
@@ -470,3 +481,70 @@ describe('CloudflareImageProvider.generate cost components', () => {
470481
);
471482
});
472483
});
484+
485+
// ── input_images (canonical image-to-image field) ──────────────────
486+
487+
describe('CloudflareImageProvider.generate input_images', () => {
488+
const klein9bWith = (extra: Record<string, unknown>) => {
489+
const provider = makeProvider();
490+
fetchSpy.mockResolvedValueOnce(
491+
new Response(Buffer.from([1, 2, 3]).buffer, {
492+
status: 200,
493+
headers: { 'content-type': 'image/png' },
494+
}),
495+
);
496+
return withTestActor(() =>
497+
provider.generate({
498+
model: '@cf/black-forest-labs/flux-2-klein-9b',
499+
prompt: 'edit it',
500+
ratio: { w: 2000, h: 1000 },
501+
...extra,
502+
} as never),
503+
);
504+
};
505+
506+
const hasInputCostLine = () => {
507+
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
508+
return (entries as Array<{ usageType: string }>).some((e) =>
509+
e.usageType.endsWith(':input_image_mp'),
510+
);
511+
};
512+
513+
it('maps a base64/data-URI input_images entry to the input image (cost line appears)', async () => {
514+
await klein9bWith({ input_images: ['data:image/png;base64,AAAA'] });
515+
expect(hasInputCostLine()).toBe(true);
516+
});
517+
518+
it('maps a singular input_image to the input image', async () => {
519+
await klein9bWith({ input_image: 'data:image/png;base64,AAAA' });
520+
expect(hasInputCostLine()).toBe(true);
521+
});
522+
523+
it('fetches an http(s) URL input via secureFetch and uses it as the input image', async () => {
524+
fetchImageAsBase64Mock.mockResolvedValueOnce({
525+
base64: 'AAAA',
526+
mime: 'image/png',
527+
});
528+
await klein9bWith({ input_images: ['https://example.com/in.png'] });
529+
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
530+
'https://example.com/in.png',
531+
);
532+
expect(hasInputCostLine()).toBe(true);
533+
});
534+
535+
it('throws 400 when more than one input image is supplied (before any fetch)', async () => {
536+
const provider = makeProvider();
537+
await expect(
538+
withTestActor(() =>
539+
provider.generate({
540+
model: '@cf/black-forest-labs/flux-2-klein-9b',
541+
prompt: 'edit it',
542+
ratio: { w: 1024, h: 1024 },
543+
input_images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'],
544+
} as never),
545+
),
546+
).rejects.toMatchObject({ statusCode: 400 });
547+
expect(fetchSpy).not.toHaveBeenCalled();
548+
expect(fetchImageAsBase64Mock).not.toHaveBeenCalled();
549+
});
550+
});

src/backend/drivers/ai-image/providers/cloudflare/CloudflareImageProvider.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ import {
2929
CLOUDFLARE_IMAGE_GENERATION_MODELS,
3030
CloudflareImageModel,
3131
} from './models.js';
32+
import {
33+
fetchImageAsBase64,
34+
isHttpUrl,
35+
resolveSingleInputImage,
36+
} from '../../inputImage.js';
3237

3338
type CloudflareGenerateParams = IGenerateParams & {
3439
steps?: number;
@@ -101,6 +106,16 @@ export class CloudflareImageProvider implements IImageProvider {
101106
});
102107
}
103108

109+
// Canonical `input_images`/`input_image` → Cloudflare's `image` field.
110+
// Cloudflare accepts a single input image; a URL is fetched to base64
111+
// server-side (SSRF-guarded) since the API has no URL field.
112+
const singleInput = resolveSingleInputImage(options, 'Cloudflare');
113+
if (singleInput) {
114+
options.image ??= isHttpUrl(singleInput)
115+
? (await fetchImageAsBase64(singleInput)).base64
116+
: singleInput;
117+
}
118+
104119
const steps = this.#resolveSteps(selectedModel, options);
105120
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
106121
hasInputImage:

src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ vi.mock('@google/genai', () => {
7171
return { GoogleGenAI };
7272
});
7373

74+
// Stub the URL→data-URI normalizer so URL inputs stay offline; keep the rest real.
75+
const { toBase64DataUriMock } = vi.hoisted(() => ({
76+
toBase64DataUriMock: vi.fn(),
77+
}));
78+
79+
vi.mock('../../inputImage.js', async (orig) => ({
80+
...(await orig<typeof import('../../inputImage.js')>()),
81+
toBase64DataUri: toBase64DataUriMock,
82+
}));
83+
7484
// ── Test harness ────────────────────────────────────────────────────
7585

7686
let server: PuterServer;
@@ -94,6 +104,7 @@ const makeProvider = () =>
94104
beforeEach(() => {
95105
generateContentMock.mockReset();
96106
generateImagesMock.mockReset();
107+
toBase64DataUriMock.mockReset();
97108
googleAICtor.mockReset();
98109
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
99110
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
@@ -272,6 +283,31 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
272283
expect(result).toBe('data:image/png;base64,BASE64IMG');
273284
});
274285

286+
it('fetches an http(s) URL input and sends it as an inlineData part', async () => {
287+
const provider = makeProvider();
288+
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
289+
toBase64DataUriMock.mockResolvedValueOnce(
290+
'data:image/png;base64,URLBYTES',
291+
);
292+
293+
await withTestActor(() =>
294+
provider.generate({
295+
model: 'gemini-2.5-flash-image',
296+
prompt: 'add a hat',
297+
input_images: ['https://example.com/in.png'],
298+
}),
299+
);
300+
301+
expect(toBase64DataUriMock).toHaveBeenCalledWith(
302+
'https://example.com/in.png',
303+
undefined,
304+
);
305+
const sent = generateContentMock.mock.calls[0]![0];
306+
expect(sent.contents).toContainEqual({
307+
inlineData: { mimeType: 'image/png', data: 'URLBYTES' },
308+
});
309+
});
310+
275311
it('throws 400 when the SDK returns no inline image data', async () => {
276312
const provider = makeProvider();
277313
generateContentMock.mockResolvedValueOnce({

src/backend/drivers/ai-image/providers/gemini/GeminiImageProvider.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
IImageModel,
3232
IImageProvider,
3333
} from '../../types.js';
34+
import { isHttpUrl, toBase64DataUri } from '../../inputImage.js';
3435
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
3536

3637
const MIME_SIGNATURES: Record<string, string> = {
@@ -107,6 +108,18 @@ export class GeminiImageProvider implements IImageProvider {
107108
input_images = [input_image];
108109
}
109110

111+
// Resolve any http(s) URL inputs to base64 data-URIs server-side
112+
// (SSRF-guarded) so the rest of the flow only deals with inline data.
113+
if (input_images?.length) {
114+
input_images = await Promise.all(
115+
input_images.map((img) =>
116+
isHttpUrl(img)
117+
? toBase64DataUri(img, input_image_mime_type)
118+
: img,
119+
),
120+
);
121+
}
122+
110123
// Validate input images have detectable MIME types
111124
if (input_images?.length) {
112125
for (const img of input_images) {

src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ vi.mock('openai', () => {
7878
};
7979
});
8080

81+
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
82+
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
83+
fetchImageAsBase64Mock: vi.fn(),
84+
}));
85+
86+
vi.mock('../../inputImage.js', async (orig) => ({
87+
...(await orig<typeof import('../../inputImage.js')>()),
88+
fetchImageAsBase64: fetchImageAsBase64Mock,
89+
}));
90+
8191
// ── Test harness ────────────────────────────────────────────────────
8292

8393
let server: PuterServer;
@@ -100,6 +110,7 @@ const makeProvider = () =>
100110
beforeEach(() => {
101111
generateMock.mockReset();
102112
editMock.mockReset();
113+
fetchImageAsBase64Mock.mockReset();
103114
openAICtor.mockReset();
104115
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
105116
batchIncrementUsagesSpy = vi.spyOn(
@@ -326,6 +337,32 @@ describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => {
326337
expect(Array.isArray(sent.image)).toBe(false);
327338
expect((sent.image as { __file?: boolean }).__file).toBe(true);
328339
});
340+
341+
it('fetches an http(s) URL input and sends the bytes to images.edit', async () => {
342+
const provider = makeProvider();
343+
editMock.mockResolvedValueOnce(editResponse);
344+
fetchImageAsBase64Mock.mockResolvedValueOnce({
345+
base64: 'iVBORw0KGgo=',
346+
mime: 'image/png',
347+
});
348+
349+
await withTestActor(() =>
350+
provider.generate({
351+
model: 'gpt-image-1',
352+
prompt: 'add a hat',
353+
ratio: { w: 1024, h: 1024 },
354+
input_images: ['https://example.com/in.png'],
355+
}),
356+
);
357+
358+
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
359+
'https://example.com/in.png',
360+
);
361+
expect(generateMock).not.toHaveBeenCalled();
362+
expect(editMock).toHaveBeenCalledTimes(1);
363+
const sent = editMock.mock.calls[0]![0];
364+
expect((sent.image as { __file?: boolean }).__file).toBe(true);
365+
});
329366
});
330367

331368
// ── gpt-image (token-priced) request shape & metering ──────────────

0 commit comments

Comments
 (0)