opencode-gpt-imagegen
Advanced tools
+176
-181
@@ -0,198 +1,193 @@ | ||
| // src/index.ts | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| // src/auth.ts | ||
| import * as fs from "node:fs/promises"; | ||
| import * as path from "node:path"; | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| import { xdgData } from "xdg-basedir"; | ||
| async function loadAuthData() { | ||
| if (process.env.OPENCODE_AUTH_CONTENT) { | ||
| return JSON.parse(process.env.OPENCODE_AUTH_CONTENT); | ||
| } | ||
| if (!xdgData) { | ||
| throw new Error("could not determine XDG data directory"); | ||
| } | ||
| const raw = await fs.readFile(path.join(xdgData, "opencode", "auth.json"), "utf-8"); | ||
| return JSON.parse(raw); | ||
| } | ||
| async function loadOpenAIAuth() { | ||
| try { | ||
| const data = await loadAuthData(); | ||
| const entry = data.openai; | ||
| if (entry?.type === "oauth" && typeof entry.access === "string") { | ||
| return entry; | ||
| } | ||
| } catch { | ||
| return; | ||
| } | ||
| return; | ||
| } | ||
| // src/codex.ts | ||
| import { EventSourceParserStream } from "eventsource-parser/stream"; | ||
| var CODEX_RESPONSES_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; | ||
| var SUBSCRIPTION_MODEL = "gpt-5.5"; | ||
| async function parseImageGenerationResultFromSSE(stream) { | ||
| const events = stream.pipeThrough(new TextDecoderStream).pipeThrough(new EventSourceParserStream); | ||
| for await (const event of events) { | ||
| if (event.data === "[DONE]") | ||
| continue; | ||
| try { | ||
| const json = JSON.parse(event.data); | ||
| if (json.type === "response.output_item.done" && json.item?.type === "image_generation_call" && typeof json.item.result === "string") { | ||
| return json.item.result; | ||
| } | ||
| } catch {} | ||
| } | ||
| throw new Error("no image_generation result returned by codex backend"); | ||
| } | ||
| async function callViaCodexResponses(auth, args, inputImageDataUrls) { | ||
| const userContent = [{ type: "input_text", text: args.prompt }]; | ||
| for (const dataUrl of inputImageDataUrls) { | ||
| userContent.push({ type: "input_image", image_url: dataUrl }); | ||
| } | ||
| const body = { | ||
| model: SUBSCRIPTION_MODEL, | ||
| instructions: "You are an image generation assistant running inside the Codex backend. " + "Always satisfy the request by invoking the image_generation tool exactly once. " + "Do not respond with text only.", | ||
| input: [{ role: "user", content: userContent }], | ||
| tools: [ | ||
| { | ||
| type: "image_generation", | ||
| output_format: "png", | ||
| quality: args.quality, | ||
| ...args.size ? { size: args.size } : {} | ||
| } | ||
| ], | ||
| tool_choice: { type: "image_generation" }, | ||
| stream: true, | ||
| store: false | ||
| }; | ||
| const res = await fetch(CODEX_RESPONSES_ENDPOINT, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${auth.access}`, | ||
| ...auth.accountId ? { "ChatGPT-Account-Id": auth.accountId } : {}, | ||
| originator: "opencode", | ||
| Accept: "text/event-stream" | ||
| }, | ||
| body: JSON.stringify(body) | ||
| }); | ||
| if (!res.ok || !res.body) { | ||
| const detail = await res.text().catch(() => ""); | ||
| throw new Error(`codex responses request failed: ${res.status} ${detail.slice(0, 500)}`); | ||
| } | ||
| return parseImageGenerationResultFromSSE(res.body); | ||
| } | ||
| // src/input-image.ts | ||
| import * as fs2 from "node:fs/promises"; | ||
| import * as path2 from "node:path"; | ||
| import { fileTypeFromBuffer } from "file-type"; | ||
| import { xdgData } from "xdg-basedir"; | ||
| // Codex OAuth responses endpoint URL. | ||
| // https://github.com/openai/codex/blob/fca81eeb5bab4cad997622a359d446e6489c445b/codex-rs/model-provider-info/src/lib.rs#L37 | ||
| // https://github.com/openai/codex/blob/fca81eeb5bab4cad997622a359d446e6489c445b/codex-rs/core/src/client.rs#L146 | ||
| const CODEX_RESPONSES_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; | ||
| // Codex model slug used for the hosted image_generation turn. | ||
| // https://github.com/openai/codex/blob/fca81eeb5bab4cad997622a359d446e6489c445b/codex-rs/models-manager/models.json#L24 | ||
| const SUBSCRIPTION_MODEL = "gpt-5.5"; | ||
| const MAX_OUTPUT_VERSION_SUFFIX = 999; | ||
| async function readImageAsDataUrl(filePath, ctxDir) { | ||
| const abs = path.isAbsolute(filePath) ? filePath : path.resolve(ctxDir, filePath); | ||
| const buf = await fs.readFile(abs); | ||
| const detected = await fileTypeFromBuffer(buf); | ||
| if (!detected?.mime.startsWith("image/")) { | ||
| throw new Error(`unsupported image file type: ${abs}`); | ||
| } | ||
| return `data:${detected.mime};base64,${buf.toString("base64")}`; | ||
| const abs = path2.isAbsolute(filePath) ? filePath : path2.resolve(ctxDir, filePath); | ||
| const buf = await fs2.readFile(abs); | ||
| const detected = await fileTypeFromBuffer(buf); | ||
| if (!detected?.mime.startsWith("image/")) { | ||
| throw new Error(`unsupported image file type: ${abs}`); | ||
| } | ||
| return `data:${detected.mime};base64,${buf.toString("base64")}`; | ||
| } | ||
| async function readReferenceImages(paths, ctxDir) { | ||
| return Promise.all((paths ?? []).map((p) => readImageAsDataUrl(p, ctxDir))); | ||
| } | ||
| // src/output-image.ts | ||
| import * as fs3 from "node:fs/promises"; | ||
| import * as path3 from "node:path"; | ||
| var MAX_OUTPUT_VERSION_SUFFIX = 999; | ||
| async function pathExists(p) { | ||
| try { | ||
| await fs.access(p); | ||
| return true; | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| try { | ||
| await fs3.access(p); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| async function pickNonOverwritePath(requested) { | ||
| if (!(await pathExists(requested))) | ||
| return requested; | ||
| const dir = path.dirname(requested); | ||
| const ext = path.extname(requested); | ||
| const stem = path.basename(requested, ext); | ||
| for (let n = 2; n <= MAX_OUTPUT_VERSION_SUFFIX; n++) { | ||
| const candidate = path.join(dir, `${stem}-v${n}${ext}`); | ||
| if (!(await pathExists(candidate))) | ||
| return candidate; | ||
| } | ||
| throw new Error(`could not find a non-conflicting filename under ${dir}/${stem}-vN${ext} (tried up to v${MAX_OUTPUT_VERSION_SUFFIX})`); | ||
| async function pickNonOverwritePath(requested, maxVersion = MAX_OUTPUT_VERSION_SUFFIX) { | ||
| if (!await pathExists(requested)) | ||
| return requested; | ||
| const dir = path3.dirname(requested); | ||
| const ext = path3.extname(requested); | ||
| const stem = path3.basename(requested, ext); | ||
| for (let n = 2;n <= maxVersion; n++) { | ||
| const candidate = path3.join(dir, `${stem}-v${n}${ext}`); | ||
| if (!await pathExists(candidate)) | ||
| return candidate; | ||
| } | ||
| throw new Error(`could not find a non-conflicting filename under ${dir}/${stem}-vN${ext} (tried up to v${maxVersion})`); | ||
| } | ||
| function buildSavedMessage(savedPath, requestedPath) { | ||
| const versionNote = savedPath !== requestedPath | ||
| ? ` (the requested path ${requestedPath} already existed; the new image was versioned to avoid overwriting it)` | ||
| : ""; | ||
| return `Generated image saved to ${savedPath}${versionNote}.`; | ||
| const versionNote = savedPath !== requestedPath ? ` (the requested path ${requestedPath} already existed; the new image was versioned to avoid overwriting it)` : ""; | ||
| return `Generated image saved to ${savedPath}${versionNote}.`; | ||
| } | ||
| // Mirrors OpenCode's auth resolution: OPENCODE_AUTH_CONTENT overrides $XDG_DATA_HOME/opencode/auth.json. | ||
| // The Auth service is not exposed to external plugins, so this reproduces the rules directly. | ||
| async function loadAuthData() { | ||
| if (process.env.OPENCODE_AUTH_CONTENT) { | ||
| return JSON.parse(process.env.OPENCODE_AUTH_CONTENT); | ||
| } | ||
| if (!xdgData) { | ||
| throw new Error("could not determine XDG data directory"); | ||
| } | ||
| const raw = await fs.readFile(path.join(xdgData, "opencode", "auth.json"), "utf-8"); | ||
| return JSON.parse(raw); | ||
| async function saveGeneratedImage(out, ctxDir, base64) { | ||
| const requestedPath = path3.isAbsolute(out) ? out : path3.resolve(ctxDir, out); | ||
| await fs3.mkdir(path3.dirname(requestedPath), { recursive: true }); | ||
| const savedPath = await pickNonOverwritePath(requestedPath); | ||
| await fs3.writeFile(savedPath, Buffer.from(base64, "base64")); | ||
| return { | ||
| savedPath, | ||
| versioned: savedPath !== requestedPath, | ||
| message: buildSavedMessage(savedPath, requestedPath) | ||
| }; | ||
| } | ||
| async function loadOpenAIAuth() { | ||
| try { | ||
| const data = await loadAuthData(); | ||
| const entry = data.openai; | ||
| if (entry?.type === "oauth" && typeof entry.access === "string") { | ||
| return entry; | ||
| } | ||
| } | ||
| catch { | ||
| return undefined; | ||
| } | ||
| return undefined; | ||
| } | ||
| async function parseImageGenerationResultFromSSE(stream) { | ||
| const events = stream | ||
| .pipeThrough(new TextDecoderStream()) | ||
| .pipeThrough(new EventSourceParserStream()); | ||
| for await (const event of events) { | ||
| if (event.data === "[DONE]") | ||
| continue; | ||
| try { | ||
| const json = JSON.parse(event.data); | ||
| if (json.type === "response.output_item.done" && | ||
| json.item?.type === "image_generation_call" && | ||
| typeof json.item.result === "string") { | ||
| return json.item.result; | ||
| // src/index.ts | ||
| var GptImagePlugin = async (_input) => { | ||
| return { | ||
| tool: { | ||
| gpt_image_gen: tool({ | ||
| description: [ | ||
| "Generate raster images using OpenAI's hosted image_generation tool.", | ||
| "Use for AI-created bitmap visuals such as photos, illustrations, textures, sprites, and mockups.", | ||
| "Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas.", | ||
| "Reference images may be attached through `images`; label each image's role inline in `prompt`, for example: 'Image 1: reference image'.", | ||
| "For many distinct assets, invoke gpt_image_gen once per requested asset rather than relying on multi-image output; gpt_image_gen returns one image per call.", | ||
| "Requires OpenCode to be authenticated with ChatGPT OAuth. Returns the absolute path of the saved PNG." | ||
| ].join(" "), | ||
| args: { | ||
| prompt: tool.schema.string().describe("Description of the image to generate."), | ||
| out: tool.schema.string().describe("Output file path, relative to the project directory unless absolute. The plugin writes a PNG."), | ||
| quality: tool.schema.enum(["low", "medium", "high", "auto"]).describe("Generation quality passed to the hosted image_generation tool."), | ||
| size: tool.schema.string().optional().describe("Optional image size passed to the hosted image_generation tool. Use `auto` or `WIDTHxHEIGHT`; width and height must be multiples of 16px, max edge <= 3840px, long-to-short ratio <= 3:1, and total pixels between 655,360 and 8,294,400."), | ||
| images: tool.schema.array(tool.schema.string()).optional().describe("Optional reference image paths, relative to the project directory unless absolute.") | ||
| }, | ||
| async execute(args, ctx) { | ||
| const auth = await loadOpenAIAuth(); | ||
| if (!auth) { | ||
| throw new Error("OpenAI ChatGPT OAuth credentials not configured."); | ||
| } | ||
| const inputImageDataUrls = await readReferenceImages(args.images, ctx.directory); | ||
| const base64 = await callViaCodexResponses(auth, args, inputImageDataUrls); | ||
| const { savedPath, versioned, message } = await saveGeneratedImage(args.out, ctx.directory, base64); | ||
| return { | ||
| output: message, | ||
| metadata: { | ||
| out: savedPath, | ||
| versioned, | ||
| billing: "subscription" | ||
| } | ||
| }; | ||
| } | ||
| catch { | ||
| // SSE keepalive or non-JSON heartbeat | ||
| } | ||
| }) | ||
| } | ||
| throw new Error("no image_generation result returned by codex backend"); | ||
| } | ||
| async function callViaCodexResponses(auth, args, inputImageDataUrls) { | ||
| const userContent = [{ type: "input_text", text: args.prompt }]; | ||
| for (const dataUrl of inputImageDataUrls) { | ||
| userContent.push({ type: "input_image", image_url: dataUrl }); | ||
| } | ||
| // https://github.com/openai/codex/blob/fca81eeb5bab4cad997622a359d446e6489c445b/codex-rs/core/src/client.rs#L745-L763 | ||
| const body = { | ||
| model: SUBSCRIPTION_MODEL, | ||
| instructions: "You are an image generation assistant running inside the Codex backend. " + | ||
| "Always satisfy the request by invoking the image_generation tool exactly once. " + | ||
| "Do not respond with text only.", | ||
| input: [{ role: "user", content: userContent }], | ||
| tools: [ | ||
| { | ||
| type: "image_generation", | ||
| output_format: "png", | ||
| quality: args.quality, | ||
| ...(args.size ? { size: args.size } : {}), | ||
| }, | ||
| ], | ||
| tool_choice: { type: "image_generation" }, | ||
| stream: true, | ||
| store: false, | ||
| }; | ||
| const res = await fetch(CODEX_RESPONSES_ENDPOINT, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${auth.access}`, | ||
| ...(auth.accountId ? { "ChatGPT-Account-Id": auth.accountId } : {}), | ||
| originator: "opencode", | ||
| Accept: "text/event-stream", | ||
| }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| if (!res.ok || !res.body) { | ||
| const detail = await res.text().catch(() => ""); | ||
| throw new Error(`codex responses request failed: ${res.status} ${detail.slice(0, 500)}`); | ||
| } | ||
| return parseImageGenerationResultFromSSE(res.body); | ||
| } | ||
| const GptImagePlugin = async (_input) => { | ||
| return { | ||
| tool: { | ||
| gpt_image_gen: tool({ | ||
| description: [ | ||
| "Generate raster images using OpenAI's hosted image_generation tool.", | ||
| "Use for AI-created bitmap visuals such as photos, illustrations, textures, sprites, and mockups.", | ||
| "Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas.", | ||
| "Reference images may be attached through `images`; label each image's role inline in `prompt`, for example: 'Image 1: reference image'.", | ||
| "For many distinct assets, invoke gpt_image_gen once per requested asset rather than relying on multi-image output; gpt_image_gen returns one image per call.", | ||
| "Requires OpenCode to be authenticated with ChatGPT OAuth. Returns the absolute path of the saved PNG.", | ||
| ].join(" "), | ||
| // https://developers.openai.com/api/docs/guides/image-generation | ||
| args: { | ||
| prompt: tool.schema.string().describe("Description of the image to generate."), | ||
| out: tool.schema | ||
| .string() | ||
| .describe("Output file path, relative to the project directory unless absolute. The plugin writes a PNG."), | ||
| quality: tool.schema | ||
| .enum(["low", "medium", "high", "auto"]) | ||
| .describe("Generation quality passed to the hosted image_generation tool."), | ||
| size: tool.schema | ||
| .string() | ||
| .optional() | ||
| .describe("Optional image size passed to the hosted image_generation tool. Use `auto` or `WIDTHxHEIGHT`; width and height must be multiples of 16px, max edge <= 3840px, long-to-short ratio <= 3:1, and total pixels between 655,360 and 8,294,400."), | ||
| images: tool.schema | ||
| .array(tool.schema.string()) | ||
| .optional() | ||
| .describe("Optional reference image paths, relative to the project directory unless absolute."), | ||
| }, | ||
| async execute(args, ctx) { | ||
| const auth = await loadOpenAIAuth(); | ||
| if (!auth) { | ||
| throw new Error("OpenAI ChatGPT OAuth credentials not configured."); | ||
| } | ||
| const inputPaths = args.images ?? []; | ||
| const inputImageDataUrls = await Promise.all(inputPaths.map((path) => readImageAsDataUrl(path, ctx.directory))); | ||
| const base64 = await callViaCodexResponses(auth, args, inputImageDataUrls); | ||
| const requestedPath = path.isAbsolute(args.out) ? args.out : path.resolve(ctx.directory, args.out); | ||
| await fs.mkdir(path.dirname(requestedPath), { recursive: true }); | ||
| const savedPath = await pickNonOverwritePath(requestedPath); | ||
| await fs.writeFile(savedPath, Buffer.from(base64, "base64")); | ||
| const versioned = savedPath !== requestedPath; | ||
| return { | ||
| output: buildSavedMessage(savedPath, requestedPath), | ||
| metadata: { | ||
| out: savedPath, | ||
| versioned, | ||
| billing: "subscription", | ||
| }, | ||
| }; | ||
| }, | ||
| }), | ||
| }, | ||
| }; | ||
| }; | ||
| }; | ||
| export default { | ||
| id: "opencode-gpt-imagegen", | ||
| server: GptImagePlugin, | ||
| var src_default = { | ||
| id: "opencode-gpt-imagegen", | ||
| server: GptImagePlugin | ||
| }; | ||
| export { | ||
| src_default as default | ||
| }; |
+2
-4
| { | ||
| "name": "opencode-gpt-imagegen", | ||
| "version": "0.1.4", | ||
| "version": "0.1.5", | ||
| "description": "OpenCode plugin for GPT Image 2 generation through your ChatGPT subscription, with reference images and safe PNG output.", | ||
| "type": "module", | ||
| "main": "./dist/index.js", | ||
| "types": "./dist/index.d.ts", | ||
| "exports": { | ||
| ".": { | ||
| "types": "./dist/index.d.ts", | ||
| "import": "./dist/index.js" | ||
@@ -23,3 +21,3 @@ } | ||
| "prebuild": "rm -rf dist", | ||
| "build": "tsc -p tsconfig.build.json", | ||
| "build": "bun build ./src/index.ts --outdir ./dist --target node --format esm --packages external", | ||
| "test": "bun test", | ||
@@ -26,0 +24,0 @@ "test:e2e": "OPENCODE_MODEL=openai/gpt-5.5 RUN_E2E=1 bun test", |
| import type { Plugin } from "@opencode-ai/plugin"; | ||
| declare const _default: { | ||
| id: string; | ||
| server: Plugin; | ||
| }; | ||
| export default _default; |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
16006
-9.51%4
-20%188
-7.84%6
50%2
100%