pi-better-openai 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { extname, isAbsolute, join, resolve } from "node:path";
5
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import type { ResolvedConfig } from "./config.ts";
7
+ import { isRecord } from "./config.ts";
8
+ import { readCodexAuth } from "./usage.ts";
9
+
10
+ const OPENAI_IMAGE_TOOL = "openai_image";
11
+ const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
12
+ const DEFAULT_TIMEOUT_MS = 180_000;
13
+
14
+ export const IMAGE_SAVE_MODES = ["none", "project", "global", "custom"] as const;
15
+ export const IMAGE_ACTIONS = ["auto", "generate", "edit"] as const;
16
+ export const IMAGE_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const;
17
+
18
+ export type ImageSaveMode = typeof IMAGE_SAVE_MODES[number];
19
+ export type ImageAction = typeof IMAGE_ACTIONS[number];
20
+ export type ImageOutputFormat = typeof IMAGE_OUTPUT_FORMATS[number];
21
+
22
+ const TOOL_PARAMS = {
23
+ type: "object",
24
+ properties: {
25
+ prompt: { type: "string", description: "Image generation/editing prompt." },
26
+ action: { type: "string", enum: IMAGE_ACTIONS, description: "Whether to generate a new image, edit/reference provided images, or let the model decide." },
27
+ images: {
28
+ type: "array",
29
+ items: { type: "string" },
30
+ description: "Local image paths to use as edit targets or references."
31
+ },
32
+ model: { type: "string", description: "OpenAI Codex model to drive the hosted image_generation tool. Defaults to current openai-codex model or config default." },
33
+ outputFormat: { type: "string", enum: IMAGE_OUTPUT_FORMATS, description: "Generated image format." },
34
+ save: { type: "string", enum: IMAGE_SAVE_MODES, description: "Where to save the generated image." },
35
+ saveDir: { type: "string", description: "Directory to save image when save=custom." }
36
+ },
37
+ required: ["prompt"],
38
+ additionalProperties: false
39
+ } as const;
40
+
41
+ type ToolParams = {
42
+ prompt: string;
43
+ action?: ImageAction;
44
+ images?: string[];
45
+ model?: string;
46
+ outputFormat?: ImageOutputFormat;
47
+ save?: ImageSaveMode;
48
+ saveDir?: string;
49
+ };
50
+
51
+ type CodexImageCredentials = {
52
+ accessToken: string;
53
+ accountId: string;
54
+ source: "modelRegistry" | "authFile";
55
+ };
56
+
57
+ type ImageInput = {
58
+ path: string;
59
+ data: string;
60
+ mimeType: string;
61
+ };
62
+
63
+ export type CodexImageResult = {
64
+ id: string;
65
+ status: string;
66
+ prompt: string;
67
+ revisedPrompt?: string;
68
+ data: string;
69
+ mimeType: string;
70
+ savedPath?: string;
71
+ model: string;
72
+ action: ImageAction;
73
+ outputFormat: ImageOutputFormat;
74
+ };
75
+
76
+ export type ImageGenerationDebug = {
77
+ authFound: boolean;
78
+ authSource?: string;
79
+ accountId?: string;
80
+ endpoint: string;
81
+ defaultModel: string;
82
+ defaultSave: ImageSaveMode;
83
+ enabled: boolean;
84
+ lastStatus?: string;
85
+ lastError?: string;
86
+ };
87
+
88
+ function decodeBase64Url(value: string): string {
89
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
90
+ const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
91
+ return Buffer.from(padded, "base64").toString("utf8");
92
+ }
93
+
94
+ export function extractAccountIdFromJwt(token: string): string | undefined {
95
+ try {
96
+ const [, payload] = token.split(".");
97
+ if (!payload) return undefined;
98
+ const parsed = JSON.parse(decodeBase64Url(payload)) as unknown;
99
+ if (!isRecord(parsed)) return undefined;
100
+ const auth = parsed["https://api.openai.com/auth"];
101
+ if (!isRecord(auth)) return undefined;
102
+ const accountId = auth.chatgpt_account_id;
103
+ return typeof accountId === "string" && accountId.trim() ? accountId.trim() : undefined;
104
+ } catch {
105
+ return undefined;
106
+ }
107
+ }
108
+
109
+ function parseRegistryCredentials(raw: string | undefined): Omit<CodexImageCredentials, "source"> | undefined {
110
+ const value = raw?.trim();
111
+ if (!value) return undefined;
112
+ try {
113
+ const parsed = JSON.parse(value) as unknown;
114
+ if (isRecord(parsed)) {
115
+ const accessToken = typeof parsed.access === "string" ? parsed.access : typeof parsed.token === "string" ? parsed.token : undefined;
116
+ const accountId = typeof parsed.accountId === "string" ? parsed.accountId : typeof parsed.account_id === "string" ? parsed.account_id : undefined;
117
+ if (accessToken?.trim() && accountId?.trim()) return { accessToken: accessToken.trim(), accountId: accountId.trim() };
118
+ }
119
+ } catch {
120
+ // Plain bearer token is expected for openai-codex in pi.
121
+ }
122
+ const accountId = extractAccountIdFromJwt(value);
123
+ return accountId ? { accessToken: value, accountId } : undefined;
124
+ }
125
+
126
+ async function getCredentials(ctx: ExtensionContext): Promise<CodexImageCredentials> {
127
+ const registryToken = await ctx.modelRegistry.getApiKeyForProvider("openai-codex").catch(() => undefined);
128
+ const registryCredentials = parseRegistryCredentials(registryToken);
129
+ if (registryCredentials) return { ...registryCredentials, source: "modelRegistry" };
130
+ const auth = readCodexAuth();
131
+ if (auth) return { ...auth, source: "authFile" };
132
+ throw new Error("Missing openai-codex OAuth credentials. Run /login openai-codex.");
133
+ }
134
+
135
+ function resolveModel(params: Pick<ToolParams, "model">, ctx: ExtensionContext, cfg: ResolvedConfig): string {
136
+ const model = params.model?.trim();
137
+ if (model) return model.includes("/") ? model.split("/").pop() || model : model;
138
+ if (ctx.model?.provider === "openai-codex") return ctx.model.id;
139
+ return cfg.image.defaultModel;
140
+ }
141
+
142
+ function resolveImageConfig(cfg: ResolvedConfig, params: ToolParams) {
143
+ const action = params.action ?? "auto";
144
+ const outputFormat = params.outputFormat ?? cfg.image.outputFormat;
145
+ const save = params.save ?? cfg.image.defaultSave;
146
+ return { action, outputFormat, save };
147
+ }
148
+
149
+ function imageMimeType(path: string, outputFormat?: string): string {
150
+ const ext = extname(path).toLowerCase();
151
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
152
+ if (ext === ".webp") return "image/webp";
153
+ if (ext === ".gif") return "image/gif";
154
+ if (outputFormat === "jpeg") return "image/jpeg";
155
+ if (outputFormat === "webp") return "image/webp";
156
+ return "image/png";
157
+ }
158
+
159
+ function extensionForFormat(format: ImageOutputFormat): string {
160
+ return format === "jpeg" ? "jpg" : format;
161
+ }
162
+
163
+ async function readImageInputs(paths: string[] | undefined, cwd: string): Promise<ImageInput[]> {
164
+ const inputs: ImageInput[] = [];
165
+ for (const rawPath of paths ?? []) {
166
+ const trimmed = rawPath.trim();
167
+ if (!trimmed) continue;
168
+ const path = isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
169
+ const data = (await readFile(path)).toString("base64");
170
+ inputs.push({ path, data, mimeType: imageMimeType(path) });
171
+ }
172
+ return inputs;
173
+ }
174
+
175
+ function resolveSaveDir(mode: ImageSaveMode, params: Pick<ToolParams, "saveDir">, cwd: string): string | undefined {
176
+ if (mode === "none") return undefined;
177
+ if (mode === "project") return join(cwd, ".pi", "generated-images");
178
+ if (mode === "global") return join(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"), "generated-images");
179
+ const dir = params.saveDir?.trim() || process.env.PI_IMAGE_SAVE_DIR?.trim();
180
+ if (!dir) throw new Error("save=custom requires saveDir or PI_IMAGE_SAVE_DIR.");
181
+ return dir;
182
+ }
183
+
184
+ async function saveImage(data: string, format: ImageOutputFormat, outputDir: string, id: string): Promise<string> {
185
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
186
+ const safeId = id.replace(/[^a-zA-Z0-9_-]/g, "_") || randomUUID().slice(0, 8);
187
+ const path = join(outputDir, `openai-image-${timestamp}-${safeId}.${extensionForFormat(format)}`);
188
+ await mkdir(outputDir, { recursive: true });
189
+ await writeFile(path, Buffer.from(data, "base64"));
190
+ return path;
191
+ }
192
+
193
+ function buildRequest(params: ToolParams, model: string, cfg: ResolvedConfig, images: ImageInput[]) {
194
+ const { action, outputFormat } = resolveImageConfig(cfg, params);
195
+ const content: Array<Record<string, unknown>> = [{ type: "input_text", text: params.prompt }];
196
+ for (const image of images) {
197
+ content.push({ type: "input_image", detail: "auto", image_url: `data:${image.mimeType};base64,${image.data}` });
198
+ }
199
+ const tool: Record<string, unknown> = { type: "image_generation", output_format: outputFormat };
200
+ if (action !== "auto") tool.action = action;
201
+ return {
202
+ model,
203
+ instructions: "",
204
+ input: [{ role: "user", content }],
205
+ tools: [tool],
206
+ tool_choice: { type: "image_generation" },
207
+ parallel_tool_calls: false,
208
+ store: false,
209
+ stream: true,
210
+ include: [],
211
+ client_metadata: { "x-codex-installation-id": "pi-better-openai" }
212
+ };
213
+ }
214
+
215
+ function dataUrlParts(value: string, fallbackMimeType: string): { data: string; mimeType: string } {
216
+ const match = value.match(/^data:([^;,]+);base64,(.*)$/s);
217
+ if (match) return { mimeType: match[1] || fallbackMimeType, data: match[2].trim() };
218
+ return { data: value.trim(), mimeType: fallbackMimeType };
219
+ }
220
+
221
+ function asImageResultItem(value: unknown): { id?: string; status?: string; revised_prompt?: string; result?: string; b64_json?: string } | undefined {
222
+ if (!isRecord(value) || value.type !== "image_generation_call") return undefined;
223
+ return value as { id?: string; status?: string; revised_prompt?: string; result?: string; b64_json?: string };
224
+ }
225
+
226
+ function extractImageFromEvent(event: unknown, fallbackMimeType: string): Omit<CodexImageResult, "savedPath" | "model" | "action" | "outputFormat"> | undefined {
227
+ if (!isRecord(event)) return undefined;
228
+ const item = asImageResultItem(event.item) ?? asImageResultItem(event);
229
+ if (item) {
230
+ const raw = typeof item.result === "string" && item.result.trim() ? item.result : typeof item.b64_json === "string" ? item.b64_json : undefined;
231
+ if (!raw) return undefined;
232
+ const { data, mimeType } = dataUrlParts(raw, fallbackMimeType);
233
+ return {
234
+ id: typeof item.id === "string" ? item.id : `ig_${randomUUID().slice(0, 8)}`,
235
+ status: typeof item.status === "string" ? item.status : "completed",
236
+ revisedPrompt: typeof item.revised_prompt === "string" ? item.revised_prompt : undefined,
237
+ data,
238
+ mimeType
239
+ };
240
+ }
241
+ const partial = event.partial_image_b64 ?? event.b64_json;
242
+ if (typeof partial === "string" && partial.trim()) {
243
+ const { data, mimeType } = dataUrlParts(partial, fallbackMimeType);
244
+ return { id: `ig_${randomUUID().slice(0, 8)}`, status: "completed", data, mimeType };
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ async function parseSseForImage(response: Response, fallbackMimeType: string, signal?: AbortSignal): Promise<Omit<CodexImageResult, "savedPath" | "model" | "action" | "outputFormat">> {
250
+ if (!response.body) throw new Error("No response body from Codex image request.");
251
+ const reader = response.body.getReader();
252
+ const decoder = new TextDecoder();
253
+ let buffer = "";
254
+ let lastImage: Omit<CodexImageResult, "savedPath" | "model" | "action" | "outputFormat"> | undefined;
255
+ try {
256
+ while (true) {
257
+ if (signal?.aborted) throw new Error("Image request was aborted.");
258
+ const { done, value } = await reader.read();
259
+ if (done) break;
260
+ buffer += decoder.decode(value, { stream: true });
261
+ let idx = buffer.indexOf("\n\n");
262
+ while (idx !== -1) {
263
+ const chunk = buffer.slice(0, idx);
264
+ buffer = buffer.slice(idx + 2);
265
+ const data = chunk
266
+ .split("\n")
267
+ .filter((line) => line.startsWith("data:"))
268
+ .map((line) => line.slice(5).trim())
269
+ .join("\n")
270
+ .trim();
271
+ if (data && data !== "[DONE]") {
272
+ let event: unknown;
273
+ try {
274
+ event = JSON.parse(data);
275
+ } catch {
276
+ event = undefined;
277
+ }
278
+ const image = extractImageFromEvent(event, fallbackMimeType);
279
+ if (image?.data) {
280
+ lastImage = image;
281
+ if (image.status === "completed") {
282
+ await reader.cancel().catch(() => undefined);
283
+ return image;
284
+ }
285
+ }
286
+ if (isRecord(event) && event.type === "response.failed") {
287
+ const error = isRecord(event.response) && isRecord(event.response.error) ? event.response.error : undefined;
288
+ const message = typeof error?.message === "string" ? error.message : "Codex image request failed.";
289
+ throw new Error(message);
290
+ }
291
+ if (isRecord(event) && event.type === "error") {
292
+ const message = typeof event.message === "string" ? event.message : JSON.stringify(event);
293
+ throw new Error(`Codex image error: ${message}`);
294
+ }
295
+ }
296
+ idx = buffer.indexOf("\n\n");
297
+ }
298
+ }
299
+ } finally {
300
+ reader.releaseLock();
301
+ }
302
+ if (lastImage) return lastImage;
303
+ throw new Error("No image_generation_call result returned by Codex.");
304
+ }
305
+
306
+ async function requestCodexImage(params: ToolParams, ctx: ExtensionContext, cfg: ResolvedConfig, requestSignal?: AbortSignal): Promise<CodexImageResult> {
307
+ if (!cfg.image.enabled) throw new Error("OpenAI image generation is disabled in config.");
308
+ const credentials = await getCredentials(ctx);
309
+ const model = resolveModel(params, ctx, cfg);
310
+ const { action, outputFormat, save } = resolveImageConfig(cfg, params);
311
+ const images = await readImageInputs(params.images, ctx.cwd || process.cwd());
312
+ const request = buildRequest(params, model, cfg, images);
313
+ const timeoutSignal = AbortSignal.timeout(cfg.image.timeoutMs);
314
+ const baseSignal = requestSignal ?? ctx.signal;
315
+ const signal = baseSignal ? AbortSignal.any([baseSignal, timeoutSignal]) : timeoutSignal;
316
+ const response = await fetch(CODEX_RESPONSES_URL, {
317
+ method: "POST",
318
+ headers: {
319
+ authorization: `Bearer ${credentials.accessToken}`,
320
+ "chatgpt-account-id": credentials.accountId,
321
+ "OpenAI-Beta": "responses=experimental",
322
+ accept: "text/event-stream",
323
+ "content-type": "application/json",
324
+ originator: "codex_cli_rs",
325
+ "User-Agent": "codex_cli_rs/0.0.0 (pi-better-openai)"
326
+ },
327
+ body: JSON.stringify(request),
328
+ signal
329
+ });
330
+ if (!response.ok) {
331
+ const text = await response.text().catch(() => "");
332
+ throw new Error(`Codex image request failed (${response.status}): ${text || response.statusText}`);
333
+ }
334
+ const parsed = await parseSseForImage(response, imageMimeType(`image.${outputFormat}`, outputFormat), signal);
335
+ const saveDir = resolveSaveDir(save, params, ctx.cwd || process.cwd());
336
+ const savedPath = saveDir ? await saveImage(parsed.data, outputFormat, saveDir, parsed.id) : undefined;
337
+ return { ...parsed, prompt: params.prompt, savedPath, model, action, outputFormat };
338
+ }
339
+
340
+ function resultText(result: CodexImageResult): string {
341
+ const parts = [
342
+ `Generated image via openai-codex/${result.model}.`,
343
+ `Action: ${result.action}.`,
344
+ `Prompt: ${result.prompt}`
345
+ ];
346
+ if (result.revisedPrompt) parts.push(`Revised prompt: ${result.revisedPrompt}`);
347
+ if (result.savedPath) parts.push(`Saved: ${result.savedPath}`);
348
+ return parts.join("\n");
349
+ }
350
+
351
+ export function registerOpenAIImage(pi: ExtensionAPI, getConfig: (ctx: ExtensionContext) => ResolvedConfig): { getDebug: (ctx: ExtensionContext) => Promise<ImageGenerationDebug> } {
352
+ let lastStatus: string | undefined;
353
+ let lastError: string | undefined;
354
+
355
+ async function generate(params: ToolParams, ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<CodexImageResult> {
356
+ try {
357
+ lastStatus = "requesting";
358
+ lastError = undefined;
359
+ const result = await requestCodexImage(params, ctx, getConfig(ctx), requestSignal);
360
+ lastStatus = `completed (${result.id})`;
361
+ return result;
362
+ } catch (error) {
363
+ lastStatus = "error";
364
+ lastError = error instanceof Error ? error.message : String(error);
365
+ throw error;
366
+ }
367
+ }
368
+
369
+ async function getDebug(ctx: ExtensionContext): Promise<ImageGenerationDebug> {
370
+ const cfg = getConfig(ctx);
371
+ let credentials: CodexImageCredentials | undefined;
372
+ try {
373
+ credentials = await getCredentials(ctx);
374
+ } catch {
375
+ credentials = undefined;
376
+ }
377
+ return {
378
+ authFound: credentials !== undefined,
379
+ authSource: credentials?.source,
380
+ accountId: credentials?.accountId,
381
+ endpoint: CODEX_RESPONSES_URL,
382
+ defaultModel: ctx.model?.provider === "openai-codex" ? ctx.model.id : cfg.image.defaultModel,
383
+ defaultSave: cfg.image.defaultSave,
384
+ enabled: cfg.image.enabled,
385
+ lastStatus,
386
+ lastError
387
+ };
388
+ }
389
+
390
+ pi.registerTool({
391
+ name: OPENAI_IMAGE_TOOL,
392
+ label: "OpenAI image",
393
+ description: "Generate or edit images through OpenAI Codex subscription auth using the hosted image_generation tool. Supports local reference/edit images and saves to the project by default.",
394
+ promptSnippet: "Generate or edit raster images via OpenAI Codex subscription auth.",
395
+ promptGuidelines: [
396
+ "Use openai_image when the user asks to generate or edit a raster image, photo, illustration, mockup, texture, sprite, or bitmap asset.",
397
+ "Use openai_image with images for local reference images or edit targets; save project assets into the workspace when requested."
398
+ ],
399
+ parameters: TOOL_PARAMS,
400
+ async execute(_toolCallId, params: ToolParams, signal, onUpdate, ctx) {
401
+ const cfg = getConfig(ctx);
402
+ const model = resolveModel(params, ctx, cfg);
403
+ onUpdate?.({ content: [{ type: "text", text: `Requesting OpenAI image via openai-codex/${model}...` }] });
404
+ const result = await generate(params, ctx, signal);
405
+ return {
406
+ content: [
407
+ { type: "text", text: resultText(result) },
408
+ { type: "image", data: result.data, mimeType: result.mimeType }
409
+ ],
410
+ details: result
411
+ };
412
+ }
413
+ });
414
+
415
+ return { getDebug };
416
+ }
417
+
418
+ export const _imageTest = {
419
+ CODEX_RESPONSES_URL,
420
+ DEFAULT_TIMEOUT_MS,
421
+ OPENAI_IMAGE_TOOL,
422
+ extractAccountIdFromJwt,
423
+ imageMimeType,
424
+ dataUrlParts,
425
+ extractImageFromEvent,
426
+ buildRequest
427
+ };