pi-openai-codex-compat 0.0.1-alpha.0

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +20 -0
  3. package/LICENSES/Apache-2.0.txt +201 -0
  4. package/LICENSES/pi-ai-MIT.txt +21 -0
  5. package/README.md +331 -0
  6. package/THIRD_PARTY_NOTICES.md +21 -0
  7. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +436 -0
  8. package/extensions/openai-codex-compat/apply-patch-engine.ts +1004 -0
  9. package/extensions/openai-codex-compat/apply-patch-render.ts +133 -0
  10. package/extensions/openai-codex-compat/apply-patch.ts +142 -0
  11. package/extensions/openai-codex-compat/codex-protocol.ts +598 -0
  12. package/extensions/openai-codex-compat/codex-provider.ts +740 -0
  13. package/extensions/openai-codex-compat/codex-stream.ts +444 -0
  14. package/extensions/openai-codex-compat/codex-tool-surface.ts +186 -0
  15. package/extensions/openai-codex-compat/codex-transport.ts +855 -0
  16. package/extensions/openai-codex-compat/compaction-checkpoint.ts +304 -0
  17. package/extensions/openai-codex-compat/config.ts +268 -0
  18. package/extensions/openai-codex-compat/footer.ts +99 -0
  19. package/extensions/openai-codex-compat/image-generation-render.ts +166 -0
  20. package/extensions/openai-codex-compat/image-generation.ts +355 -0
  21. package/extensions/openai-codex-compat/index.ts +65 -0
  22. package/extensions/openai-codex-compat/model-policy.ts +67 -0
  23. package/extensions/openai-codex-compat/namespaced-tools.ts +43 -0
  24. package/extensions/openai-codex-compat/native-history.ts +78 -0
  25. package/extensions/openai-codex-compat/remote-compaction.ts +198 -0
  26. package/extensions/openai-codex-compat/request-options.ts +121 -0
  27. package/extensions/openai-codex-compat/responses-replay.ts +33 -0
  28. package/extensions/openai-codex-compat/settings-pane.ts +298 -0
  29. package/extensions/openai-codex-compat/tool-runtime.ts +32 -0
  30. package/extensions/openai-codex-compat/tools.ts +70 -0
  31. package/extensions/openai-codex-compat/vendor/pi-ai/README.md +15 -0
  32. package/extensions/openai-codex-compat/vendor/pi-ai/openai-responses-serialization.ts +660 -0
  33. package/extensions/openai-codex-compat/web-run-description.txt +105 -0
  34. package/extensions/openai-codex-compat/web-run-output.ts +172 -0
  35. package/extensions/openai-codex-compat/web-run-render.ts +681 -0
  36. package/extensions/openai-codex-compat/web-run-schema.ts +301 -0
  37. package/extensions/openai-codex-compat/web-run.ts +164 -0
  38. package/package.json +63 -0
@@ -0,0 +1,166 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, type Component, Text, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import {
4
+ CodexToolSurfaceComponent,
5
+ type CodexToolBackgroundResolver,
6
+ } from "./codex-tool-surface.ts";
7
+ import { DEFAULT_CONFIG } from "./config.ts";
8
+ import { IMAGE_GENERATION_TOOL_NAME } from "./namespaced-tools.ts";
9
+
10
+ export type ImageGenerationDetails = {
11
+ operation: "generate" | "edit";
12
+ revisedPrompt: string;
13
+ savedPath?: string;
14
+ saveError?: string;
15
+ };
16
+
17
+ type ImageGenerationArgs = {
18
+ prompt: string;
19
+ referenced_image_paths?: string[] | null;
20
+ num_last_images_to_include?: number | null;
21
+ };
22
+
23
+ type ImageGenerationRenderContext = {
24
+ args: ImageGenerationArgs;
25
+ isPartial: boolean;
26
+ expanded: boolean;
27
+ isError: boolean;
28
+ };
29
+
30
+ type ImageGenerationResult = {
31
+ content: Array<{ type: string; text?: string }>;
32
+ details?: unknown;
33
+ };
34
+
35
+ function promptPreview(prompt: string, maximum = 250): string {
36
+ const singleLine = prompt.replace(/\s+/gu, " ").trim();
37
+ const preview =
38
+ singleLine.length > maximum ? `${singleLine.slice(0, Math.max(0, maximum - 1))}…` : singleLine;
39
+ return `"${preview}"`;
40
+ }
41
+
42
+ function imageCount(args: ImageGenerationArgs): number | undefined {
43
+ const paths = args.referenced_image_paths ?? [];
44
+ if (paths.length > 0) return paths.length;
45
+ return args.num_last_images_to_include ?? undefined;
46
+ }
47
+
48
+ function describeImageCall(args: ImageGenerationArgs): string {
49
+ const count = imageCount(args);
50
+ const operation =
51
+ count === undefined ? "generate" : `edit ${count} ${count === 1 ? "image" : "images"}`;
52
+ return `${operation} ${promptPreview(args.prompt)}`;
53
+ }
54
+
55
+ function isImageGenerationDetails(value: unknown): value is ImageGenerationDetails {
56
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
57
+ const details = value as Partial<ImageGenerationDetails>;
58
+ return (
59
+ (details.operation === "generate" || details.operation === "edit") &&
60
+ typeof details.revisedPrompt === "string" &&
61
+ (details.savedPath === undefined || typeof details.savedPath === "string") &&
62
+ (details.saveError === undefined || typeof details.saveError === "string")
63
+ );
64
+ }
65
+
66
+ function textOutput(result: ImageGenerationResult): string {
67
+ return result.content
68
+ .filter((item) => item.type === "text" && typeof item.text === "string")
69
+ .map((item) => item.text)
70
+ .join("\n");
71
+ }
72
+
73
+ function wrapLines(lines: readonly string[], width: number): string[] {
74
+ return lines.flatMap((line) => (line === "" ? [""] : wrapTextWithAnsi(line, width)));
75
+ }
76
+
77
+ class ImageGenerationResultComponent implements Component {
78
+ private readonly result: ImageGenerationResult;
79
+ private readonly expanded: boolean;
80
+ private readonly theme: Theme;
81
+ private readonly isError: boolean;
82
+
83
+ constructor(result: ImageGenerationResult, expanded: boolean, theme: Theme, isError: boolean) {
84
+ this.result = result;
85
+ this.expanded = expanded;
86
+ this.theme = theme;
87
+ this.isError = isError;
88
+ }
89
+
90
+ render(width: number): string[] {
91
+ const output = textOutput(this.result);
92
+ if (this.isError) {
93
+ const lines = [this.theme.bold(this.theme.fg("error", "✘ Image generation failed"))];
94
+ if (this.expanded && output) {
95
+ lines.push("", ...output.split("\n").map((line) => this.theme.fg("error", line)));
96
+ }
97
+ return wrapLines(lines, width);
98
+ }
99
+
100
+ if (!isImageGenerationDetails(this.result.details)) {
101
+ return wrapLines([this.theme.fg("warning", "Image result metadata unavailable")], width);
102
+ }
103
+ const details = this.result.details;
104
+ const verb = details.operation === "edit" ? "Edited" : "Generated";
105
+ const lines = [
106
+ `${this.theme.fg("dim", "• ")}${this.theme.bold(`${verb} image`)}${
107
+ details.savedPath ? ` ${this.theme.fg("accent", details.savedPath)}` : ""
108
+ }`,
109
+ ];
110
+ if (details.saveError) {
111
+ lines.push(this.theme.fg("warning", ` Could not save image: ${details.saveError}`));
112
+ }
113
+ if (this.expanded) {
114
+ lines.push(
115
+ "",
116
+ `${this.theme.fg("muted", "Prompt")} ${this.theme.fg("toolOutput", details.revisedPrompt)}`,
117
+ );
118
+ if (details.savedPath) {
119
+ lines.push(
120
+ `${this.theme.fg("muted", "Saved")} ${this.theme.fg("accent", details.savedPath)}`,
121
+ );
122
+ }
123
+ }
124
+ return wrapLines(lines, width);
125
+ }
126
+
127
+ invalidate(): void {}
128
+ }
129
+
130
+ export function renderImageGenerationCall(
131
+ args: ImageGenerationArgs,
132
+ theme: Theme,
133
+ context: ImageGenerationRenderContext,
134
+ resolveBackground: CodexToolBackgroundResolver = () => DEFAULT_CONFIG.toolBackground,
135
+ ): Component {
136
+ const title = theme.fg("toolTitle", theme.bold(IMAGE_GENERATION_TOOL_NAME));
137
+ const summary = theme.fg("muted", describeImageCall(args));
138
+ return new CodexToolSurfaceComponent(new Text(`${title} ${summary}`, 0, 0), theme, {
139
+ background: resolveBackground,
140
+ status: context.isPartial ? "pending" : context.isError ? "error" : "success",
141
+ top: true,
142
+ bottom: context.isPartial,
143
+ });
144
+ }
145
+
146
+ export function renderImageGenerationResult(
147
+ result: ImageGenerationResult,
148
+ options: { expanded: boolean; isPartial: boolean },
149
+ theme: Theme,
150
+ context: ImageGenerationRenderContext,
151
+ resolveBackground: CodexToolBackgroundResolver = () => DEFAULT_CONFIG.toolBackground,
152
+ ): Component {
153
+ if (options.isPartial) {
154
+ return new Container();
155
+ }
156
+ return new CodexToolSurfaceComponent(
157
+ new ImageGenerationResultComponent(result, options.expanded, theme, context.isError),
158
+ theme,
159
+ {
160
+ background: resolveBackground,
161
+ status: context.isError ? "error" : "success",
162
+ top: false,
163
+ bottom: true,
164
+ },
165
+ );
166
+ }
@@ -0,0 +1,355 @@
1
+ import { readFile, mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import {
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { Model } from "@earendil-works/pi-ai";
9
+ import { Type } from "typebox";
10
+ import type { CodexCompatConfig } from "./config.ts";
11
+ import type { CodexToolBackgroundResolver } from "./codex-tool-surface.ts";
12
+ import { DEFAULT_CONFIG } from "./config.ts";
13
+ import { isObject, type JsonRecord, type ResponsesItem } from "./codex-protocol.ts";
14
+ import { requestCodexJson, type CodexJsonRequestOptions } from "./codex-transport.ts";
15
+ import { IMAGE_GENERATION_TOOL_NAME } from "./namespaced-tools.ts";
16
+ import {
17
+ renderImageGenerationCall,
18
+ renderImageGenerationResult,
19
+ type ImageGenerationDetails,
20
+ } from "./image-generation-render.ts";
21
+ import { isCodexModel } from "./request-options.ts";
22
+ import { codexToolAuthentication, codexToolHistory } from "./tool-runtime.ts";
23
+
24
+ const IMAGE_MODEL = "gpt-image-2";
25
+ const MAX_EDIT_IMAGES = 5;
26
+ const GENERATION_ENDPOINT = "images/generations";
27
+ const EDIT_ENDPOINT = "images/edits";
28
+ const GENERATED_IMAGES_DIRECTORY = "generated_images";
29
+
30
+ const IMAGE_GENERATION_DESCRIPTION = `The \`image_gen.imagegen\` tool enables image generation from descriptions and editing of existing images based on specific instructions. Use it when:
31
+
32
+ - The user requests an image based on a scene description, such as a diagram, portrait, comic, meme, or any other visual.
33
+ - The user wants to modify an attached or previously generated image with specific changes, including adding or removing elements, altering colors, improving quality/resolution, or transforming the style (e.g., cartoon, oil painting).
34
+
35
+ Guidelines:
36
+ - imagegen needs a few minutes to finish. In code-mode, use the first-line @exec directive to give the initial call 120 seconds and the same yield for any waits that follow. Once it finishes, return the image with generatedImage(result).
37
+ - Omit both \`referenced_image_paths\` and \`num_last_images_to_include\` when generating a brand new image.
38
+ - For edits, use \`referenced_image_paths\` when every target image has a local file path.
39
+ - If you have not seen a local image yet, use \`view_image\` to inspect it before editing.
40
+ - Use \`num_last_images_to_include\` only when at least one target image has no local file path.
41
+ - Set \`num_last_images_to_include\` to the smallest number of recent conversation images that includes every target image, up to 5.
42
+ - Never provide both \`referenced_image_paths\` and \`num_last_images_to_include\`.
43
+ - If neither mechanism can include every target image, ask the user to attach the missing images again.
44
+ - Directly generate the image without reconfirmation or clarification unless required images must be attached again.
45
+ - Always use this tool for image editing unless the user explicitly requests otherwise. Do not use the \`python\` tool for image editing unless specifically instructed.
46
+ `;
47
+
48
+ const imageGenerationParameters = Type.Unsafe<{
49
+ prompt: string;
50
+ referenced_image_paths?: string[] | null;
51
+ num_last_images_to_include?: number | null;
52
+ }>({
53
+ type: "object",
54
+ properties: {
55
+ num_last_images_to_include: {
56
+ type: ["integer", "null"],
57
+ },
58
+ prompt: {
59
+ type: "string",
60
+ },
61
+ referenced_image_paths: {
62
+ type: ["array", "null"],
63
+ items: {
64
+ type: "string",
65
+ description:
66
+ "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
67
+ },
68
+ },
69
+ },
70
+ required: ["prompt"],
71
+ additionalProperties: false,
72
+ });
73
+
74
+ export type { ImageGenerationDetails } from "./image-generation-render.ts";
75
+
76
+ type JsonRequester = (
77
+ model: Model<any>,
78
+ path: string,
79
+ body: JsonRecord,
80
+ options: CodexJsonRequestOptions,
81
+ ) => Promise<unknown>;
82
+ type ConfigResolver = (ctx: ExtensionContext) => CodexCompatConfig;
83
+
84
+ type ImageRequest = {
85
+ operation: "generate" | "edit";
86
+ endpoint: typeof GENERATION_ENDPOINT | typeof EDIT_ENDPOINT;
87
+ body: JsonRecord;
88
+ };
89
+
90
+ function imageUrlsFromContent(content: unknown): string[] {
91
+ if (!Array.isArray(content)) return [];
92
+ return content
93
+ .filter(isObject)
94
+ .toReversed()
95
+ .filter((item) => item.type === "input_image" && typeof item["image_url"] === "string")
96
+ .map((item) => item["image_url"] as string);
97
+ }
98
+
99
+ /** Return recent provider-history images in chronological order. */
100
+ export function recentImageUrls(history: readonly ResponsesItem[], count: number): string[] {
101
+ const functionCallIds = new Set<string>();
102
+ const customToolCallIds = new Set<string>();
103
+ for (const item of history) {
104
+ if (item.type === "function_call" && typeof item["call_id"] === "string") {
105
+ functionCallIds.add(item["call_id"]);
106
+ } else if (item.type === "custom_tool_call" && typeof item["call_id"] === "string") {
107
+ customToolCallIds.add(item["call_id"]);
108
+ }
109
+ }
110
+
111
+ const newestFirst: string[] = [];
112
+ for (const item of history.toReversed()) {
113
+ let imageUrls: string[] = [];
114
+ if (item.type === undefined || item.type === "message") {
115
+ imageUrls = imageUrlsFromContent(item.content);
116
+ } else if (
117
+ item.type === "function_call_output" &&
118
+ typeof item["call_id"] === "string" &&
119
+ functionCallIds.has(item["call_id"])
120
+ ) {
121
+ imageUrls = imageUrlsFromContent(item["output"]);
122
+ } else if (
123
+ item.type === "custom_tool_call_output" &&
124
+ typeof item["call_id"] === "string" &&
125
+ customToolCallIds.has(item["call_id"])
126
+ ) {
127
+ imageUrls = imageUrlsFromContent(item["output"]);
128
+ } else if (item.type === "image_generation_call" && typeof item["result"] === "string") {
129
+ imageUrls = [`data:image/png;base64,${item["result"]}`];
130
+ }
131
+
132
+ for (const imageUrl of imageUrls) {
133
+ newestFirst.push(imageUrl);
134
+ if (newestFirst.length === count) return newestFirst.reverse();
135
+ }
136
+ }
137
+ return newestFirst.reverse();
138
+ }
139
+
140
+ function normalizeImagePath(path: string): string {
141
+ const normalized = path.startsWith("@") ? path.slice(1) : path;
142
+ if (!isAbsolute(normalized)) {
143
+ throw new Error(`referenced image path must be absolute: ${path}`);
144
+ }
145
+ return normalized;
146
+ }
147
+
148
+ function imageMimeType(bytes: Uint8Array, path: string): string {
149
+ if (
150
+ bytes.length >= 8 &&
151
+ bytes[0] === 0x89 &&
152
+ bytes[1] === 0x50 &&
153
+ bytes[2] === 0x4e &&
154
+ bytes[3] === 0x47 &&
155
+ bytes[4] === 0x0d &&
156
+ bytes[5] === 0x0a &&
157
+ bytes[6] === 0x1a &&
158
+ bytes[7] === 0x0a
159
+ ) {
160
+ return "image/png";
161
+ }
162
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
163
+ return "image/jpeg";
164
+ }
165
+ const signature = Buffer.from(bytes.subarray(0, 12)).toString("ascii");
166
+ if (signature.startsWith("GIF87a") || signature.startsWith("GIF89a")) return "image/gif";
167
+ if (signature.startsWith("RIFF") && signature.slice(8, 12) === "WEBP") return "image/webp";
168
+ throw new Error(`unsupported referenced image format: ${path}`);
169
+ }
170
+
171
+ async function localImageUrl(path: string): Promise<string> {
172
+ const absolutePath = normalizeImagePath(path);
173
+ const bytes = await readFile(absolutePath);
174
+ const mimeType = imageMimeType(bytes, absolutePath);
175
+ return `data:${mimeType};base64,${bytes.toString("base64")}`;
176
+ }
177
+
178
+ async function imageRequest(
179
+ params: {
180
+ prompt: string;
181
+ referenced_image_paths?: string[] | null;
182
+ num_last_images_to_include?: number | null;
183
+ },
184
+ history: readonly ResponsesItem[],
185
+ ): Promise<ImageRequest> {
186
+ const paths = params.referenced_image_paths ?? [];
187
+ const recentCount = params.num_last_images_to_include ?? undefined;
188
+ if (paths.length > MAX_EDIT_IMAGES) {
189
+ throw new Error(`referenced_image_paths must contain at most ${MAX_EDIT_IMAGES} paths`);
190
+ }
191
+ if (
192
+ recentCount !== undefined &&
193
+ (!Number.isInteger(recentCount) || recentCount < 1 || recentCount > MAX_EDIT_IMAGES)
194
+ ) {
195
+ throw new Error(`num_last_images_to_include must be between 1 and ${MAX_EDIT_IMAGES}`);
196
+ }
197
+ if (paths.length > 0 && recentCount !== undefined) {
198
+ throw new Error("provide only one of referenced_image_paths or num_last_images_to_include");
199
+ }
200
+ if (paths.length === 0 && recentCount === undefined) {
201
+ return {
202
+ operation: "generate",
203
+ endpoint: GENERATION_ENDPOINT,
204
+ body: {
205
+ prompt: params.prompt,
206
+ background: "auto",
207
+ model: IMAGE_MODEL,
208
+ quality: "auto",
209
+ size: "auto",
210
+ },
211
+ };
212
+ }
213
+
214
+ const imageUrls =
215
+ paths.length > 0
216
+ ? await Promise.all(paths.map(localImageUrl))
217
+ : recentImageUrls(history, recentCount ?? 0);
218
+ const expectedCount = paths.length > 0 ? paths.length : recentCount;
219
+ if (expectedCount === undefined || imageUrls.length !== expectedCount) {
220
+ throw new Error(
221
+ `requested ${expectedCount ?? 0} conversation images, but only ${imageUrls.length} were available`,
222
+ );
223
+ }
224
+ return {
225
+ operation: "edit",
226
+ endpoint: EDIT_ENDPOINT,
227
+ body: {
228
+ images: imageUrls.map((imageUrl) => ({ image_url: imageUrl })),
229
+ prompt: params.prompt,
230
+ background: "auto",
231
+ model: IMAGE_MODEL,
232
+ quality: "auto",
233
+ size: "auto",
234
+ },
235
+ };
236
+ }
237
+
238
+ function normalizedBase64(value: string): string {
239
+ const normalized = value.replace(/\s+/g, "");
240
+ if (
241
+ normalized.length === 0 ||
242
+ normalized.length % 4 !== 0 ||
243
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)
244
+ ) {
245
+ throw new Error("OpenAI Codex returned invalid generated-image data.");
246
+ }
247
+ return normalized;
248
+ }
249
+
250
+ function safePathSegment(value: string): string {
251
+ const sanitized = value.replace(/[^A-Za-z0-9_-]/gu, "_");
252
+ return sanitized || "generated_image";
253
+ }
254
+
255
+ async function saveGeneratedImage(
256
+ sessionId: string,
257
+ callId: string,
258
+ imageBase64: string,
259
+ ): Promise<string> {
260
+ const directory = join(getAgentDir(), GENERATED_IMAGES_DIRECTORY, safePathSegment(sessionId));
261
+ const outputPath = join(directory, `${safePathSegment(callId)}.png`);
262
+ const imageBytes = Buffer.from(imageBase64, "base64");
263
+ await mkdir(directory, { recursive: true });
264
+ try {
265
+ await writeFile(outputPath, imageBytes, {
266
+ flag: "wx",
267
+ mode: 0o600,
268
+ });
269
+ } catch (error) {
270
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
271
+ const existing = await readFile(outputPath);
272
+ if (!existing.equals(imageBytes)) throw error;
273
+ }
274
+ return outputPath;
275
+ }
276
+
277
+ function outputHint(outputPath: string): string {
278
+ return `Generated images are saved to ${dirname(outputPath)} as ${outputPath} by default.
279
+ If you need to use the generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.
280
+ The generated image is already displayed to the user. There is no need to render it in the final response as a Markdown image or file link.`;
281
+ }
282
+
283
+ export default function registerImageGeneration(
284
+ pi: ExtensionAPI,
285
+ resolveConfig: ConfigResolver,
286
+ resolveToolBackground: CodexToolBackgroundResolver = () => DEFAULT_CONFIG.toolBackground,
287
+ requestJson: JsonRequester = requestCodexJson,
288
+ ): void {
289
+ pi.registerTool({
290
+ name: IMAGE_GENERATION_TOOL_NAME,
291
+ label: IMAGE_GENERATION_TOOL_NAME,
292
+ description: IMAGE_GENERATION_DESCRIPTION,
293
+ parameters: imageGenerationParameters,
294
+ executionMode: "sequential",
295
+ renderShell: "self",
296
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
297
+ const model = ctx.model;
298
+ if (!isCodexModel(model)) {
299
+ throw new Error("image_gen.imagegen is available only with an OpenAI Codex model.");
300
+ }
301
+ onUpdate?.({
302
+ content: [{ type: "text", text: "Generating image…" }],
303
+ details: undefined,
304
+ });
305
+ const config = resolveConfig(ctx);
306
+ const history = codexToolHistory(pi, ctx, model, config.imageDetail);
307
+ const request = await imageRequest(params, history);
308
+ const authentication = await codexToolAuthentication(ctx, model);
309
+ const callId = toolCallId.split("|")[0] || toolCallId;
310
+ const response = await requestJson(model, request.endpoint, request.body, {
311
+ ...authentication,
312
+ extraHeaders: { "x-codex-image-turn-id": callId },
313
+ ...(signal ? { signal } : {}),
314
+ });
315
+ if (!isObject(response) || !Array.isArray(response["data"])) {
316
+ throw new Error("OpenAI Codex returned an invalid image-generation response.");
317
+ }
318
+ const first = response["data"].find(isObject);
319
+ if (!first || typeof first["b64_json"] !== "string") {
320
+ throw new Error("OpenAI Codex image generation returned no image data.");
321
+ }
322
+ const imageBase64 = normalizedBase64(first["b64_json"]);
323
+ let savedPath: string | undefined;
324
+ let saveError: string | undefined;
325
+ try {
326
+ savedPath = await saveGeneratedImage(
327
+ ctx.sessionManager.getSessionId(),
328
+ callId,
329
+ imageBase64,
330
+ );
331
+ } catch (error) {
332
+ saveError = error instanceof Error ? error.message : String(error);
333
+ }
334
+
335
+ return {
336
+ content: [
337
+ { type: "image", data: imageBase64, mimeType: "image/png" },
338
+ ...(savedPath ? [{ type: "text" as const, text: outputHint(savedPath) }] : []),
339
+ ],
340
+ details: {
341
+ operation: request.operation,
342
+ revisedPrompt: params.prompt,
343
+ ...(savedPath ? { savedPath } : {}),
344
+ ...(saveError ? { saveError } : {}),
345
+ } satisfies ImageGenerationDetails,
346
+ };
347
+ },
348
+ renderCall(args, theme, context) {
349
+ return renderImageGenerationCall(args, theme, context, resolveToolBackground);
350
+ },
351
+ renderResult(result, options, theme, context) {
352
+ return renderImageGenerationResult(result, options, theme, context, resolveToolBackground);
353
+ },
354
+ });
355
+ }
@@ -0,0 +1,65 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ DEFAULT_CONFIG,
4
+ loadConfig,
5
+ writableConfigPath,
6
+ type CodexCompatConfig,
7
+ } from "./config.ts";
8
+ import { registerCodexProvider } from "./codex-provider.ts";
9
+ import { installCodexFooter } from "./footer.ts";
10
+ import registerCodexModelPolicy from "./model-policy.ts";
11
+ import registerRemoteCompaction from "./remote-compaction.ts";
12
+ import registerCodexRequestOptions from "./request-options.ts";
13
+ import registerCodexSettings from "./settings-pane.ts";
14
+ import registerCodexTools, { syncCodexTools } from "./tools.ts";
15
+
16
+ const DISPLAY_NAME = "OpenAI Codex Compat";
17
+
18
+ function settingsSummary(ctx: ExtensionContext, config: CodexCompatConfig): string {
19
+ return [
20
+ DISPLAY_NAME,
21
+ `fast mode: ${config.fastMode ? "on" : "off"}`,
22
+ `reasoning mode: ${config.reasoningMode}`,
23
+ `Codex tool background: ${config.toolBackground}`,
24
+ `apply_patch: ${config.applyPatch ? "on" : "off"}`,
25
+ `image_gen.imagegen: ${config.imageGeneration ? "on" : "off"}`,
26
+ `image result detail: ${config.imageDetail}`,
27
+ `web.run: ${config.webRun ? "on" : "off"}`,
28
+ `web search: ${config.webSearch}`,
29
+ `text verbosity: ${config.textVerbosity}`,
30
+ `reasoning summary: ${config.reasoningSummary}`,
31
+ `auto-compact threshold: ${config.autoCompactAtPercent ?? "Pi default"}`,
32
+ `save target: ${writableConfigPath(ctx.cwd, ctx.isProjectTrusted())}`,
33
+ "settings: /codex-settings (session-only until Ctrl+S)",
34
+ ].join("\n");
35
+ }
36
+
37
+ export default function registerOpenAICodexCompat(pi: ExtensionAPI): void {
38
+ let activeConfig: CodexCompatConfig | undefined;
39
+ const resolveConfig = (ctx: ExtensionContext): CodexCompatConfig => {
40
+ activeConfig ??= loadConfig(ctx.cwd, ctx.isProjectTrusted());
41
+ return activeConfig;
42
+ };
43
+ const resolveToolBackground = () => activeConfig?.toolBackground ?? DEFAULT_CONFIG.toolBackground;
44
+
45
+ pi.on("session_start", (event, ctx) => {
46
+ activeConfig = loadConfig(ctx.cwd, ctx.isProjectTrusted());
47
+ installCodexFooter(ctx, resolveConfig);
48
+ if (event.reason !== "reload" && ctx.mode === "tui") {
49
+ ctx.ui.notify(settingsSummary(ctx, activeConfig), "info");
50
+ }
51
+ });
52
+
53
+ registerCodexTools(pi, resolveConfig, resolveToolBackground);
54
+ const codexProvider = registerCodexProvider(pi, resolveConfig);
55
+ registerCodexRequestOptions(pi, resolveConfig);
56
+ registerRemoteCompaction(pi, codexProvider, resolveConfig);
57
+ registerCodexModelPolicy(pi, resolveConfig);
58
+ registerCodexSettings(pi, {
59
+ getConfig: resolveConfig,
60
+ onChange(config, ctx) {
61
+ activeConfig = config;
62
+ syncCodexTools(pi, ctx.model, config);
63
+ },
64
+ });
65
+ }
@@ -0,0 +1,67 @@
1
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { CodexCompatConfig } from "./config.ts";
3
+ import { hasNativeCheckpointEntry } from "./compaction-checkpoint.ts";
4
+ import { syncCodexTools } from "./tools.ts";
5
+
6
+ type ConfigResolver = (ctx: ExtensionContext) => CodexCompatConfig;
7
+
8
+ function activeBranchHasCheckpoint(ctx: ExtensionContext): boolean {
9
+ return hasNativeCheckpointEntry(ctx.sessionManager.getBranch() as SessionEntry[]);
10
+ }
11
+
12
+ export default function registerCodexModelPolicy(
13
+ pi: ExtensionAPI,
14
+ resolveConfig: ConfigResolver,
15
+ ): void {
16
+ let restoringRejectedSwitch = false;
17
+
18
+ pi.on("session_start", (_event, ctx) => {
19
+ syncCodexTools(pi, ctx.model, resolveConfig(ctx));
20
+ });
21
+
22
+ pi.on("model_select", async (event, ctx) => {
23
+ if (restoringRejectedSwitch) {
24
+ syncCodexTools(pi, event.model, resolveConfig(ctx));
25
+ return;
26
+ }
27
+
28
+ const previousModel = event.previousModel;
29
+ if (
30
+ event.source === "restore" ||
31
+ previousModel === undefined ||
32
+ !activeBranchHasCheckpoint(ctx)
33
+ ) {
34
+ syncCodexTools(pi, event.model, resolveConfig(ctx));
35
+ return;
36
+ }
37
+
38
+ restoringRejectedSwitch = true;
39
+ let restored = false;
40
+ try {
41
+ restored = await pi.setModel(previousModel);
42
+ } catch (error) {
43
+ ctx.ui.notify(
44
+ `OpenAI Codex could not restore the previous model after rejecting the switch: ${
45
+ error instanceof Error ? error.message : String(error)
46
+ }`,
47
+ "error",
48
+ );
49
+ } finally {
50
+ restoringRejectedSwitch = false;
51
+ }
52
+
53
+ if (!restored) {
54
+ syncCodexTools(pi, event.model, resolveConfig(ctx));
55
+ ctx.ui.notify(
56
+ "The active branch contains a native OpenAI Codex compaction checkpoint, but the previous model could not be restored.",
57
+ "error",
58
+ );
59
+ return;
60
+ }
61
+
62
+ ctx.ui.notify(
63
+ "Model switch rejected because the active branch contains a native OpenAI Codex compaction checkpoint. Navigate to a branch before the checkpoint or start a new session first.",
64
+ "warning",
65
+ );
66
+ });
67
+ }
@@ -0,0 +1,43 @@
1
+ export const IMAGE_GENERATION_TOOL_NAME = "image_gen.imagegen";
2
+ export const WEB_RUN_TOOL_NAME = "web.run";
3
+
4
+ export const CODEX_NAMESPACED_TOOL_NAMES: ReadonlySet<string> = new Set([
5
+ IMAGE_GENERATION_TOOL_NAME,
6
+ WEB_RUN_TOOL_NAME,
7
+ ]);
8
+
9
+ /** Tools whose successful text output Codex transports as `input_text` content items. */
10
+ export const CODEX_TEXT_CONTENT_ITEM_TOOL_RESULT_NAMES: ReadonlySet<string> = new Set([
11
+ WEB_RUN_TOOL_NAME,
12
+ ]);
13
+
14
+ export type NamespacedToolName = {
15
+ namespace: string;
16
+ name: string;
17
+ };
18
+
19
+ export function splitNamespacedToolName(
20
+ toolName: string,
21
+ allowedNames: ReadonlySet<string> = CODEX_NAMESPACED_TOOL_NAMES,
22
+ ): NamespacedToolName | undefined {
23
+ if (!allowedNames.has(toolName)) return undefined;
24
+ const separator = toolName.indexOf(".");
25
+ if (separator <= 0 || separator === toolName.length - 1) {
26
+ throw new Error(`Invalid namespaced Codex tool name: ${toolName}`);
27
+ }
28
+ return {
29
+ namespace: toolName.slice(0, separator),
30
+ name: toolName.slice(separator + 1),
31
+ };
32
+ }
33
+
34
+ export function namespacedToolCallName(namespace: unknown, name: unknown): string {
35
+ if (typeof namespace !== "string" || typeof name !== "string") {
36
+ throw new Error("Codex returned a namespaced tool call without a valid namespace and name.");
37
+ }
38
+ const toolName = `${namespace}.${name}`;
39
+ if (!CODEX_NAMESPACED_TOOL_NAMES.has(toolName)) {
40
+ throw new Error(`Codex returned an unsupported namespaced tool call: ${toolName}`);
41
+ }
42
+ return toolName;
43
+ }