From be9cd9eb890095c07f76f31c8e0f47c970aa3295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kujund=C5=BEi=C4=87?= Date: Thu, 5 Nov 2026 11:13:43 +0100 Subject: [PATCH] feat(ai): add GPT Image 2.6 and default to Flare --- .../ai-image/ImageGenerationDriver.test.ts | 18 ++++- .../openai/OpenAiImageProvider.test.ts | 65 ++++++++++++++++++- .../providers/openai/OpenAiImageProvider.ts | 35 +++++++--- .../ai-image/providers/openai/models.ts | 40 ++++++++++++ src/docs/src/AI/txt2img.md | 12 ++-- .../examples/ai-txt2img-options.html | 2 +- src/puter-js/src/modules/ai/types.js | 4 +- 7 files changed, 156 insertions(+), 20 deletions(-) diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts index f17061c49..7fba32608 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts @@ -463,10 +463,26 @@ describe('ImageGenerationDriver.generate provider routing', () => { driver.generate({ prompt: 'a landscape' }), ); expect(openaiImagesGenerateMock).toHaveBeenCalledWith( - expect.objectContaining({ model: 'gpt-image-2' }), + expect.objectContaining({ model: 'gpt-image-2.6-flare' }), ); }); + it.each(['gpt-image-2.6-flare', 'gpt-image-2.6-sunburst'])( + 'routes %s and its OpenAI alias', async (model) => { + for (const id of [model, `openai/${model}`]) { + openaiImagesGenerateMock.mockResolvedValueOnce({ + data: [{ url: 'https://oai/image.png' }], + }); + await withDriverName('ai-image', () => driver.generate({ + model: id, prompt: 'a landscape', + })); + expect(openaiImagesGenerateMock).toHaveBeenLastCalledWith( + expect.objectContaining({ model }), + ); + } + }, + ); + it('rejects an excluded model regardless of caller casing', async () => { await expect(withActor(() => driver.generate({ 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 26cf4a524..0f0876a14 100644 --- a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts +++ b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.test.ts @@ -150,9 +150,9 @@ describe('OpenAiImageProvider construction', () => { // ── Model catalog ─────────────────────────────────────────────────── describe('OpenAiImageProvider model catalog', () => { - it('returns gpt-image-2 as the default', () => { + it('returns gpt-image-2.6-flare as the default', () => { const provider = makeProvider(); - expect(provider.getDefaultModel()).toBe('gpt-image-2'); + expect(provider.getDefaultModel()).toBe('gpt-image-2.6-flare'); }); it('drops the deprecated gpt-image-1 family and chatgpt-image-latest', () => { @@ -907,3 +907,64 @@ it('keeps a landscape aspect hint landscape at gpt-image-2 sizes', async () => { expect(w).toBeGreaterThan(h); expect(Math.abs(w / h - 1.7)).toBeLessThan(0.1); }); + + +describe('OpenAiImageProvider GPT Image 2.6', () => { + it.each(['gpt-image-2.6-flare', 'gpt-image-2.6-sunburst'])( + '%s generates and edits with token-based billing', async (model) => { + const provider = makeProvider(); + const response = { + data: [{ b64_json: 'AAAA' }], + usage: { + input_tokens: 100, + output_tokens: 800, + input_tokens_details: { text_tokens: 100, image_tokens: 0 }, + }, + }; + generateMock.mockResolvedValue(response); + editMock.mockResolvedValue(response); + for (const input_image of [undefined, 'data:image/png;base64,AAAA']) { + const result = await withTestActor(() => provider.generate({ + model, prompt: 'hi', quality: 'xhigh', + ratio: { w: 1024, h: 1024 }, input_image, + })); + expect(result).toBe('data:image/png;base64,AAAA'); + } + for (const mock of [generateMock, editMock]) { + expect(mock.mock.calls[0]![0]).toMatchObject({ + model, quality: 'xhigh', size: '1024x1024', + }); + } + const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!; + expect(entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ + usageType: `openai:${model}:xhigh:1024x1024:output`, + costOverride: 2_400_000, + }), + ])); + }, + ); + + it.each([ + ['low', 1024, 1024, 823], + ['medium', 1024, 1024, 1449], + ['high', 1024, 1024, 1756], + ['xhigh', 1024, 1024, 3122], + ['max', 1024, 1024, 7024], + ['auto', 1024, 1024, 1449], + ['low', 2048, 1088, 571], + ])('estimates %s at %sx%s from the 2.6 calculator', async (quality, w, h, tokens) => { + generateMock.mockResolvedValue({ data: [{ b64_json: 'AAAA' }] }); + await withTestActor(() => makeProvider().generate({ + model: 'gpt-image-2.6-flare', prompt: 'hi', quality: String(quality), + ratio: { w: Number(w), h: Number(h) }, + })); + const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!; + const output = (entries as Array<{ usageType: string; costOverride: number }>) + .find((entry) => entry.usageType.endsWith(':output'))!; + expect(output.usageType).toBe(`openai:gpt-image-2.6-flare:${quality}:${w}x${h}:output`); + // The metering conversion rounds fractional microcents upward. + expect(output.costOverride).toBeGreaterThanOrEqual(Number(tokens) * 3000); + expect(output.costOverride).toBeLessThanOrEqual(Number(tokens) * 3000 + 1); + }); +}); diff --git a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts index 1a0c279a9..0e325a291 100644 --- a/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts +++ b/src/backend/drivers/ai-image/providers/openai/OpenAiImageProvider.ts @@ -56,8 +56,8 @@ interface OpenAIImageUsage { /** * OpenAI image generation provider for v2. Supports the GPT Image models - * (gpt-image-2, -2.5-sunburst, -2.5-flare), including image-to-image editing - * via `input_images` (the `images.edit` endpoint). + * (gpt-image-2 and the 2.5/2.6 Sunburst and Flare models), including + * image-to-image editing via `input_images` (the `images.edit` endpoint). */ export class OpenAiImageProvider implements IImageProvider { #meteringService: AiMeteringService; @@ -109,7 +109,7 @@ export class OpenAiImageProvider implements IImageProvider { } getDefaultModel(): string { - return 'gpt-image-2'; + return 'gpt-image-2.6-flare'; } async generate({ @@ -604,17 +604,36 @@ export class OpenAiImageProvider implements IImageProvider { height: number, quality?: string, ): number { - const factors = model.startsWith('gpt-image-2.5') - ? OpenAiImageProvider.#GPT_IMAGE_2_5_LATENT_FACTORS - : OpenAiImageProvider.#GPT_IMAGE_2_LATENT_FACTORS; + const isGptImage26 = model.startsWith('gpt-image-2.6'); + const factors = + isGptImage26 || model.startsWith('gpt-image-2.5') + ? OpenAiImageProvider.#GPT_IMAGE_2_5_LATENT_FACTORS + : OpenAiImageProvider.#GPT_IMAGE_2_LATENT_FACTORS; const factor = factors[quality ?? ''] ?? factors.medium; const longEdge = Math.max(width, height); const shortEdge = Math.min(width, height); - const shortLatent = Math.round((factor * shortEdge) / longEdge); + const shortGrid = (factor * shortEdge) / longEdge; + const floorGrid = Math.floor(shortGrid); + const shortLatent = + isGptImage26 && shortGrid - floorGrid === 0.5 + ? floorGrid + (floorGrid % 2) + : Math.round(shortGrid); const latentW = width >= height ? factor : shortLatent; const latentH = width >= height ? shortLatent : factor; const baseArea = latentW * latentH; - return Math.ceil((baseArea * (2_000_000 + width * height)) / 4_000_000); + const tokens = Math.ceil( + (baseArea * (2_000_000 + width * height)) / 4_000_000, + ); + // GPT Image 2.6 bills more tokens at low and medium quality. + const estimatedQuality = + quality && quality in factors ? quality : 'medium'; + const multiplier = + estimatedQuality === 'low' + ? 4.2 + : estimatedQuality === 'medium' + ? 3.3 + : 1; + return isGptImage26 ? Math.round(tokens * multiplier) : tokens; } #estimateOutputCostFromTokens( diff --git a/src/backend/drivers/ai-image/providers/openai/models.ts b/src/backend/drivers/ai-image/providers/openai/models.ts index 4d309d95e..5b3537800 100644 --- a/src/backend/drivers/ai-image/providers/openai/models.ts +++ b/src/backend/drivers/ai-image/providers/openai/models.ts @@ -20,6 +20,46 @@ import type { IImageModel } from '../../types.js'; export const OPEN_AI_IMAGE_GENERATION_MODELS: IImageModel[] = [ + { + puterId: 'openai:openai/gpt-image-2.6-sunburst', + id: 'gpt-image-2.6-sunburst', + aliases: ['openai/gpt-image-2.6-sunburst'], + name: 'GPT Image 2.6 Sunburst', + version: '2.6', + costs_currency: 'usd-cents', + index_cost_key: 'low:1024x1024', + costs: { + // Text tokens (per 1M tokens) + text_input: 500, // $5.00 + text_cached_input: 125, // $1.25 + // Image tokens (per 1M tokens) + image_input: 800, // $8.00 + image_cached_input: 200, // $2.00 + image_output: 3000, // $30.00 + 'low:1024x1024': 2.469, + }, + allowedQualityLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'auto'], + }, + { + puterId: 'openai:openai/gpt-image-2.6-flare', + id: 'gpt-image-2.6-flare', + aliases: ['openai/gpt-image-2.6-flare'], + name: 'GPT Image 2.6 Flare', + version: '2.6', + costs_currency: 'usd-cents', + index_cost_key: 'low:1024x1024', + costs: { + // Text tokens (per 1M tokens) + text_input: 500, // $5.00 + text_cached_input: 125, // $1.25 + // Image tokens (per 1M tokens) + image_input: 800, // $8.00 + image_cached_input: 200, // $2.00 + image_output: 3000, // $30.00 + 'low:1024x1024': 2.469, + }, + allowedQualityLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'auto'], + }, { puterId: 'openai:openai/gpt-image-2.5-sunburst', id: 'gpt-image-2.5-sunburst', diff --git a/src/docs/src/AI/txt2img.md b/src/docs/src/AI/txt2img.md index 45fc5c87f..75b3a0bbd 100755 --- a/src/docs/src/AI/txt2img.md +++ b/src/docs/src/AI/txt2img.md @@ -47,7 +47,7 @@ Without a model, these provider defaults apply: | Provider | Default model | |----------|---------------| -| OpenAI | `gpt-image-2` | +| OpenAI | `gpt-image-2.6-flare` | | Gemini | `gemini-3.1-flash-image` | | Cloudflare | `@cf/black-forest-labs/flux-1-schnell` | | xAI | `grok-imagine-image` | @@ -55,7 +55,7 @@ Without a model, these provider defaults apply: | BytePlus | `seedream-5-0-lite-260128` | | Kling | `kling-v3` | -When neither is supplied, OpenAI's `gpt-image-2` is used. +When neither is supplied, OpenAI's `gpt-image-2.6-flare` is used. Discover the image catalog, including model IDs, aliases, provider names, and pricing metadata: @@ -98,12 +98,12 @@ Raw base64 carries no MIME type of its own. When it cannot be detected from the #### OpenAI Options -Available when `provider: 'openai'` or inferred from model (`gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, `gpt-image-2`): +Available when `provider: 'openai'` or inferred from model (`gpt-image-2.6-sunburst`, `gpt-image-2.6-flare`, `gpt-image-2.5-sunburst`, `gpt-image-2.5-flare`, `gpt-image-2`): | Option | Type | Description | |--------|------|-------------| -| `model` | `String` | Image model to use. Available: `'gpt-image-2.5-sunburst'`, `'gpt-image-2.5-flare'`, `'gpt-image-2'` (default). | -| `quality` | `String` | Image quality: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'`, and the `gpt-image-2.5-*` models also accept `'xhigh'`, `'max'`, and `'auto'`. Case-insensitive; unrecognized values fall back to `'low'`. | +| `model` | `String` | Image model to use. Available: `'gpt-image-2.6-flare'` (default), `'gpt-image-2.6-sunburst'`, `'gpt-image-2.5-sunburst'`, `'gpt-image-2.5-flare'`, `'gpt-image-2'`. | +| `quality` | `String` | Image quality: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'`, and the `gpt-image-2.5-*` and `gpt-image-2.6-*` models also accept `'xhigh'`, `'max'`, and `'auto'`. Case-insensitive; unrecognized values fall back to `'low'`. | | `ratio` | `Object` | Aspect ratio with `w` and `h` properties. All GPT Image models accept dynamic sizes subject to provider limits (16-pixel steps, 3:1 maximum ratio, 3840-pixel edge cap) | | `input_image` | `String` | An input image for image-to-image editing, as a URL or base64/data-URI. | | `input_images` | `Array` | Multiple input images (URL or base64/data-URI) for image-to-image editing. | @@ -301,7 +301,7 @@ Other `upstream_*` codes mean the provider rejected the request or was unavailab