From 7830b2ad2071c145b4a6666afa08689166ceef16 Mon Sep 17 00:00:00 2001 From: Tom Bensimhon Date: Sat, 12 Sep 2026 12:09:34 +0300 Subject: [PATCH] feat(openai): support the gpt-image-2.5 line, default to gpt-image-2.5-flare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI's GPT Image 2.5 ships as two models: gpt-image-2.5-flare (their default — gpt-image-2 quality at up to half the latency) and gpt-image-2.5-sunburst (precision editing). Both accept the wider quality range (xhigh, max, auto) and arbitrary WIDTHxHEIGHT sizes (multiples of 16, aspect 1:3–3:1, no edge over 3840px). The request wiring already fit — the gpt-image branch sends output_format, and quality/size pass through untouched — so this switches DEFAULT_MODEL to gpt-image-2.5-flare, names both 2.5 models and the new quality/size rules on image.create and image.edit, and points the defaultModel hint at gpt-image-2 as the way to pin the older line. Tests pin the new default on both actions, per-call > connection > default precedence on image.create (edit already had it), that both 2.5 ids take the output_format branch while dall-e keeps response_format, that xhigh/max/auto and custom sizes reach the API unmodified, and that the input docs name both models. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U4xJ2FZDNXyW98r5AsnzUT --- packages/runline-plugins/openai/src/index.ts | 31 +++- .../src/tests/image-edit-plugins.test.ts | 156 ++++++++++++++++-- 2 files changed, 167 insertions(+), 20 deletions(-) diff --git a/packages/runline-plugins/openai/src/index.ts b/packages/runline-plugins/openai/src/index.ts index 896412c..7c7438e 100644 --- a/packages/runline-plugins/openai/src/index.ts +++ b/packages/runline-plugins/openai/src/index.ts @@ -26,8 +26,18 @@ import { const ENDPOINT = "https://api.openai.com/v1/images/generations"; const EDIT_ENDPOINT = "https://api.openai.com/v1/images/edits"; -/** Newest GPT Image model; override per call or via the connection. */ -const DEFAULT_MODEL = "gpt-image-2"; +/** + * Newest GPT Image model; override per call or via the connection. + * + * The GPT Image 2.5 line ships as two models: `gpt-image-2.5-flare` + * (OpenAI's default — matches gpt-image-2 quality at up to half the + * latency) and `gpt-image-2.5-sunburst` (precision editing). Both accept + * the wider `xhigh` / `max` / `auto` quality range and arbitrary + * `WIDTHxHEIGHT` sizes (multiples of 16, aspect between 1:3 and 3:1, no + * edge over 3840px). Pin `gpt-image-2` via `defaultModel` to keep the + * older line. + */ +const DEFAULT_MODEL = "gpt-image-2.5-flare"; /** * Model precedence, shared by create and edit: the call wins, then the @@ -84,7 +94,7 @@ export default function openai(rl: RunlinePluginAPI) { type: "string", required: false, description: - "Default image model when a call omits `model` — e.g. gpt-image-1 to pin the older line. Defaults to gpt-image-2.", + "Default image model when a call omits `model` — e.g. gpt-image-2 to pin the older line. Defaults to gpt-image-2.5-flare.", env: "OPENAI_IMAGE_MODEL", }, }); @@ -108,18 +118,19 @@ export default function openai(rl: RunlinePluginAPI) { type: "string", required: false, description: - "gpt-image-2 | gpt-image-1 | gpt-image-1-mini | dall-e-3 | dall-e-2. Omit to use the connection default.", + "gpt-image-2.5-flare | gpt-image-2.5-sunburst | gpt-image-2 | gpt-image-1 | gpt-image-1-mini | dall-e-3 | dall-e-2. Omit to use the connection default.", }, size: { type: "string", required: false, - description: "WxH (default: 1024x1024). Allowed sizes vary by model.", + description: + "WxH (default: 1024x1024) or auto. gpt-image-2.5-* accept any WIDTHxHEIGHT with both sides multiples of 16, aspect between 1:3 and 3:1 and no edge over 3840 (above 2560x1440 is experimental); older models take 1024x1024 | 1536x1024 | 1024x1536.", }, quality: { type: "string", required: false, description: - "low | medium | high (gpt-image) or standard | hd (dall-e-3)", + "low | medium | high | auto (gpt-image), plus xhigh | max on gpt-image-2.5-*; standard | hd (dall-e-3)", }, style: { type: "string", @@ -216,17 +227,19 @@ export default function openai(rl: RunlinePluginAPI) { type: "string", required: false, description: - "gpt-image-2 | gpt-image-1 | gpt-image-1-mini. Omit to use the connection default.", + "gpt-image-2.5-flare | gpt-image-2.5-sunburst | gpt-image-2 | gpt-image-1 | gpt-image-1-mini. Omit to use the connection default.", }, size: { type: "string", required: false, - description: "WxH output size. Omit to let the API match the input.", + description: + "WxH output size (gpt-image-2.5-* accept any WIDTHxHEIGHT with sides multiples of 16 and aspect between 1:3 and 3:1). Omit to let the API match the input.", }, quality: { type: "string", required: false, - description: "low | medium | high", + description: + "low | medium | high | auto, plus xhigh | max on gpt-image-2.5-*", }, n: { type: "number", diff --git a/packages/runline/src/tests/image-edit-plugins.test.ts b/packages/runline/src/tests/image-edit-plugins.test.ts index 7a17822..6291f79 100644 --- a/packages/runline/src/tests/image-edit-plugins.test.ts +++ b/packages/runline/src/tests/image-edit-plugins.test.ts @@ -101,7 +101,7 @@ describe("openai image.edit", () => { assert.equal(seen.url, "https://api.openai.com/v1/images/edits"); assert.ok(seen.form instanceof FormData); assert.equal(seen.form?.get("prompt"), "make the sky red"); - assert.equal(seen.form?.get("model"), "gpt-image-2"); + assert.equal(seen.form?.get("model"), "gpt-image-2.5-flare"); const file = seen.form?.get("image[]"); assert.ok(file instanceof Blob, "image[] should be a file part"); assert.equal(result.images.length, 1); @@ -126,15 +126,49 @@ describe("openai image.edit", () => { const base = { prompt: "x", imagePath: sourcePath, saveDir: dir }; await action.execute( - { ...base, model: "gpt-image-1-mini" }, - ctx({ apiKey: "sk-test", defaultModel: "gpt-image-1" }), + { ...base, model: "gpt-image-2.5-sunburst" }, + ctx({ apiKey: "sk-test", defaultModel: "gpt-image-2" }), ); await action.execute( base, - ctx({ apiKey: "sk-test", defaultModel: "gpt-image-1" }), + ctx({ apiKey: "sk-test", defaultModel: "gpt-image-2" }), ); - assert.deepEqual(seen, ["gpt-image-1-mini", "gpt-image-1"]); + assert.deepEqual(seen, ["gpt-image-2.5-sunburst", "gpt-image-2"]); + }); + + it("forwards the 2.5-only quality and size values untouched", async () => { + const action = getAction(makePlugin("openai", openai), "image.edit"); + let form: FormData | undefined; + globalThis.fetch = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { + form = init?.body as FormData; + return new Response( + JSON.stringify({ data: [{ b64_json: B64_RESULT }] }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + }) as typeof fetch; + + await action.execute( + { + prompt: "sharpen the label", + imagePath: sourcePath, + model: "gpt-image-2.5-sunburst", + quality: "max", + size: "1536x864", + saveDir: dir, + }, + ctx({ apiKey: "sk-test" }), + ); + + assert.equal(form?.get("model"), "gpt-image-2.5-sunburst"); + assert.equal(form?.get("quality"), "max"); + assert.equal(form?.get("size"), "1536x864"); }); it("rejects when neither imagePath nor imagePaths is given", async () => { @@ -147,14 +181,14 @@ describe("openai image.edit", () => { }); describe("openai image.create", () => { - it("defaults to gpt-image-2", async () => { - const action = getAction(makePlugin("openai", openai), "image.create"); - let model: unknown; + // Captures the JSON body of each generations call and answers with one image. + function captureBodies(): Record[] { + const bodies: Record[] = []; globalThis.fetch = (async ( _input: RequestInfo | URL, init?: RequestInit, ) => { - model = JSON.parse(String(init?.body)).model; + bodies.push(JSON.parse(String(init?.body))); return new Response( JSON.stringify({ data: [{ b64_json: B64_RESULT }] }), { @@ -163,12 +197,112 @@ describe("openai image.create", () => { }, ); }) as typeof fetch; + return bodies; + } - await action.execute( + it("defaults to gpt-image-2.5-flare", async () => { + const action = getAction(makePlugin("openai", openai), "image.create"); + const bodies = captureBodies(); + + const result = (await action.execute( { prompt: "a red bicycle", saveDir: dir }, ctx({ apiKey: "sk-test" }), + )) as { model: string }; + + assert.equal(bodies[0]?.model, "gpt-image-2.5-flare"); + assert.equal(result.model, "gpt-image-2.5-flare"); + }); + + it("prefers the per-call model, then the connection default", async () => { + const action = getAction(makePlugin("openai", openai), "image.create"); + const bodies = captureBodies(); + + await action.execute( + { prompt: "x", model: "gpt-image-2.5-sunburst", saveDir: dir }, + ctx({ apiKey: "sk-test", defaultModel: "gpt-image-2" }), + ); + await action.execute( + { prompt: "x", saveDir: dir }, + ctx({ apiKey: "sk-test", defaultModel: "gpt-image-2" }), + ); + + assert.deepEqual( + bodies.map((b) => b.model), + ["gpt-image-2.5-sunburst", "gpt-image-2"], + ); + }); + + it("treats both gpt-image-2.5 models as the gpt-image line (output_format, not response_format)", async () => { + const action = getAction(makePlugin("openai", openai), "image.create"); + const bodies = captureBodies(); + + for (const model of ["gpt-image-2.5-flare", "gpt-image-2.5-sunburst"]) { + await action.execute( + { prompt: "x", model, saveDir: dir }, + ctx({ apiKey: "sk-test" }), + ); + } + + for (const body of bodies) { + assert.equal(body.output_format, "png"); + assert.equal(body.response_format, undefined); + } + }); + + it("forwards the 2.5-only quality and size values untouched", async () => { + const action = getAction(makePlugin("openai", openai), "image.create"); + const bodies = captureBodies(); + + await action.execute( + { + prompt: "a wide banner", + model: "gpt-image-2.5-flare", + quality: "xhigh", + size: "1536x864", + saveDir: dir, + }, + ctx({ apiKey: "sk-test" }), + ); + await action.execute( + { prompt: "pick for me", quality: "auto", size: "auto", saveDir: dir }, + ctx({ apiKey: "sk-test" }), + ); + + assert.equal(bodies[0]?.quality, "xhigh"); + assert.equal(bodies[0]?.size, "1536x864"); + assert.equal(bodies[1]?.quality, "auto"); + assert.equal(bodies[1]?.size, "auto"); + }); + + it("still speaks response_format to the dall-e line", async () => { + const action = getAction(makePlugin("openai", openai), "image.create"); + const bodies = captureBodies(); + + await action.execute( + { prompt: "x", model: "dall-e-3", saveDir: dir }, + ctx({ apiKey: "sk-test" }), ); - assert.equal(model, "gpt-image-2"); + + assert.equal(bodies[0]?.response_format, "b64_json"); + assert.equal(bodies[0]?.output_format, undefined); + }); + + it("documents both gpt-image-2.5 models on the model inputs", () => { + const plugin = makePlugin("openai", openai); + for (const name of ["image.create", "image.edit"]) { + const schema = getAction(plugin, name).inputSchema as Record< + string, + { description?: string } + >; + for (const model of ["gpt-image-2.5-flare", "gpt-image-2.5-sunburst"]) { + assert.ok( + schema.model?.description?.includes(model), + `${name}.model should mention ${model}`, + ); + } + assert.ok(schema.quality?.description?.includes("xhigh")); + assert.ok(schema.quality?.description?.includes("max")); + } }); });