Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions packages/runline-plugins/openai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
},
});
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
156 changes: 145 additions & 11 deletions packages/runline/src/tests/image-edit-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {
Expand All @@ -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<string, unknown>[] {
const bodies: Record<string, unknown>[] = [];
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 }] }),
{
Expand All @@ -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"));
}
});
});

Expand Down