diff --git a/packages/runline-plugins/openai/src/index.ts b/packages/runline-plugins/openai/src/index.ts index 896412c..3c03dcd 100644 --- a/packages/runline-plugins/openai/src/index.ts +++ b/packages/runline-plugins/openai/src/index.ts @@ -26,8 +26,31 @@ 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 own default — gpt-image-2 quality at up to half the latency) + * and `gpt-image-2.5-sunburst` (precision editing). Both widen the + * quality range and take custom sizes; see QUALITY_RULE and SIZE_RULE. + * Pin `gpt-image-2` via `defaultModel` to stay on the older line. + */ +const DEFAULT_MODEL = "gpt-image-2.5-flare"; + +/** + * The size contract, shared by create and edit so the two cannot drift apart. + * + * All four constraints bind together: the total-pixel floor is the one that + * surprises, because 512x512 satisfies every other rule and still 400s. + */ +const SIZE_RULE = + "gpt-image-2 and gpt-image-2.5-* take any WIDTHxHEIGHT with both sides multiples of 16, aspect between 1:3 and 3:1, " + + "no edge over 3840, and a total between 655,360 and 8,294,400 pixels — so 512x512 is too small and 1536x864 is fine; " + + "above 2560x1440 is experimental. gpt-image-1* and dall-e-* take 1024x1024 | 1536x1024 | 1024x1536."; + +/** Quality tiers, likewise shared. `auto` is what the 2.5 models pick by default. */ +const QUALITY_RULE = + "low | medium | high | auto, plus xhigh | max on gpt-image-2.5-*"; /** * Model precedence, shared by create and edit: the call wins, then the @@ -84,7 +107,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 +131,17 @@ 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. ${SIZE_RULE}`, }, quality: { type: "string", required: false, - description: - "low | medium | high (gpt-image) or standard | hd (dall-e-3)", + description: `${QUALITY_RULE}; standard | hd (dall-e-3)`, }, style: { type: "string", @@ -216,17 +238,17 @@ 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. Omit to let the API match the input. ${SIZE_RULE}`, }, quality: { type: "string", required: false, - description: "low | medium | high", + description: QUALITY_RULE, }, 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..a41aebd 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,118 @@ 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" }), ); - assert.equal(model, "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(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")); + // Three of the four size rules let 512x512 through; only the total-pixel + // floor rejects it, so an agent that never reads it fails at the API. + assert.ok( + schema.size?.description?.includes("655,360"), + `${name}.size should state the minimum total pixels`, + ); + } }); });