@oai404iao/pi-codex-minimal-tools 1.3.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.
- package/LICENSE +28 -0
- package/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/OpenAI-Codex-NOTICE.txt +6 -0
- package/README.md +410 -0
- package/THIRD_PARTY_NOTICES.md +97 -0
- package/config.schema.json +174 -0
- package/models.schema.json +217 -0
- package/package.json +87 -0
- package/provenance/openai-codex-eb9dceba-reserved-tools.json +140 -0
- package/src/activation.ts +56 -0
- package/src/background-image-generation.ts +574 -0
- package/src/capabilities.ts +146 -0
- package/src/codex-http.ts +133 -0
- package/src/codex-request-profile.ts +45 -0
- package/src/codex-reserved-tools.ts +323 -0
- package/src/codex-wire-identity.ts +182 -0
- package/src/fast-mode.ts +124 -0
- package/src/glyphs.ts +70 -0
- package/src/index.ts +332 -0
- package/src/model-catalog/catalog.ts +636 -0
- package/src/model-catalog/default-models.json +252 -0
- package/src/model-catalog/runtime.ts +113 -0
- package/src/model-catalog/types.ts +95 -0
- package/src/native-compaction.ts +393 -0
- package/src/patch/apply.ts +338 -0
- package/src/patch/parser.ts +224 -0
- package/src/patch/render.ts +201 -0
- package/src/provider-headers.ts +54 -0
- package/src/provider-native-tools.ts +71 -0
- package/src/provider-shim.ts +4338 -0
- package/src/providers/codex-apply-patch-tool.ts +23 -0
- package/src/providers/codex-apply-patch.lark +19 -0
- package/src/providers/openai-responses-shared.ts +1463 -0
- package/src/settings.ts +247 -0
- package/src/tools/apply-patch.ts +84 -0
- package/src/tools/image-generation.ts +274 -0
- package/src/tools/view-image.ts +98 -0
- package/src/tools/web-search.ts +524 -0
- package/src/utils/images.ts +73 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { extname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
|
|
5
|
+
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
import { supportsImageInput, type ModelLike } from "./capabilities.js";
|
|
7
|
+
import { frameGlyphs, glyphs, treeGlyph } from "./glyphs.js";
|
|
8
|
+
import { listResolvedModelProfiles } from "./model-catalog/catalog.js";
|
|
9
|
+
import { loadSettings } from "./settings.js";
|
|
10
|
+
import { loadModelSettings } from "./model-catalog/runtime.js";
|
|
11
|
+
import { standaloneImageGeneration } from "./tools/image-generation.js";
|
|
12
|
+
import {
|
|
13
|
+
buildGeneratedImageDisplayText,
|
|
14
|
+
IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
15
|
+
saveOpenAICodexGeneratedImage,
|
|
16
|
+
type SavedGeneratedImage,
|
|
17
|
+
} from "./provider-shim.js";
|
|
18
|
+
import { projectRoot } from "./utils/images.js";
|
|
19
|
+
import {
|
|
20
|
+
buildCodexJsonHeaders,
|
|
21
|
+
hasCodexRequestAuth,
|
|
22
|
+
} from "./codex-http.js";
|
|
23
|
+
import { setProviderGeneratedHeader } from "./provider-headers.js";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
26
|
+
const BACKGROUND_IMAGE_INSTRUCTIONS = "Generate or edit images with the hosted image_generation tool. Use the user's prompt and any provided reference images. Return the image_generation_call result.";
|
|
27
|
+
const IMAGE_GEN_STATUS_KEY = "codex-image-gen";
|
|
28
|
+
const IMAGE_GEN_ERROR_MESSAGE_TYPE = "codex-image-generation-error";
|
|
29
|
+
const PANEL_BAR_COLOR = "borderAccent";
|
|
30
|
+
const PANEL_TITLE_COLOR = "customMessageLabel";
|
|
31
|
+
const PANEL_RULE_COLOR = "muted";
|
|
32
|
+
const PANEL_CARD_PADDING_X = 1;
|
|
33
|
+
|
|
34
|
+
interface ActiveImageJob {
|
|
35
|
+
id: string;
|
|
36
|
+
startedAt: number;
|
|
37
|
+
prompt: string;
|
|
38
|
+
referenceCount: number;
|
|
39
|
+
imageModel: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const activeImageJobs = new Map<string, ActiveImageJob>();
|
|
43
|
+
let activeStatusCtx: ExtensionCommandContext | undefined;
|
|
44
|
+
let statusTimer: ReturnType<typeof setInterval> | undefined;
|
|
45
|
+
|
|
46
|
+
export interface ParsedImageGenCommand {
|
|
47
|
+
prompt: string;
|
|
48
|
+
imagePaths: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ReferenceImage {
|
|
52
|
+
path: string;
|
|
53
|
+
mimeType: string;
|
|
54
|
+
base64: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface CodexImageResult {
|
|
58
|
+
id: string;
|
|
59
|
+
result: string;
|
|
60
|
+
outputFormat?: string;
|
|
61
|
+
revisedPrompt?: string;
|
|
62
|
+
imageModel?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ImageGenerationErrorDetails {
|
|
66
|
+
message: string;
|
|
67
|
+
prompt?: string;
|
|
68
|
+
imageModel?: string;
|
|
69
|
+
referenceCount?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ModelRegistryLike {
|
|
73
|
+
getAll?: () => unknown;
|
|
74
|
+
getAvailable?: () => unknown;
|
|
75
|
+
find?: (provider: string, id: string) => unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
79
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isModelLike(value: unknown): value is ModelLike {
|
|
83
|
+
return isRecord(value) && typeof value.provider === "string" && (typeof value.id === "string" || typeof value.name === "string");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function registryModels(registry: ModelRegistryLike | undefined): ModelLike[] {
|
|
87
|
+
if (!registry) return [];
|
|
88
|
+
for (const method of [registry.getAll, registry.getAvailable]) {
|
|
89
|
+
if (typeof method !== "function") continue;
|
|
90
|
+
try {
|
|
91
|
+
const value = method.call(registry);
|
|
92
|
+
if (Array.isArray(value)) return value.filter(isModelLike);
|
|
93
|
+
} catch {
|
|
94
|
+
// Try the next registry shape.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isConfiguredImageModel(model: ModelLike | undefined): boolean {
|
|
101
|
+
if (!model || !supportsImageInput(model)) return false;
|
|
102
|
+
const settings = loadModelSettings(model);
|
|
103
|
+
return Boolean(
|
|
104
|
+
settings.modelProfile?.effective.enabled
|
|
105
|
+
&& settings.imageGenerationImplementation,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function selectCodexImageModel(currentModel: ModelLike | undefined, registry: ModelRegistryLike | undefined): ModelLike | undefined {
|
|
110
|
+
if (isConfiguredImageModel(currentModel)) return currentModel;
|
|
111
|
+
const discovered = registryModels(registry).find(isConfiguredImageModel);
|
|
112
|
+
if (discovered) return discovered;
|
|
113
|
+
for (const profile of listResolvedModelProfiles()) {
|
|
114
|
+
if (!profile.effective.enabled || !profile.effective.tools.imageGeneration) continue;
|
|
115
|
+
const slash = profile.id.indexOf("/");
|
|
116
|
+
const candidate = registry?.find?.(
|
|
117
|
+
profile.id.slice(0, slash),
|
|
118
|
+
profile.id.slice(slash + 1),
|
|
119
|
+
);
|
|
120
|
+
if (isModelLike(candidate) && isConfiguredImageModel(candidate)) return candidate;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tokenizeArgs(input: string): string[] {
|
|
126
|
+
const tokens: string[] = [];
|
|
127
|
+
let current = "";
|
|
128
|
+
let quote: '"' | "'" | undefined;
|
|
129
|
+
let escaped = false;
|
|
130
|
+
for (const ch of input) {
|
|
131
|
+
if (escaped) {
|
|
132
|
+
current += ch;
|
|
133
|
+
escaped = false;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === "\\") {
|
|
137
|
+
escaped = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (quote) {
|
|
141
|
+
if (ch === quote) quote = undefined;
|
|
142
|
+
else current += ch;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (ch === '"' || ch === "'") {
|
|
146
|
+
quote = ch;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (/\s/.test(ch)) {
|
|
150
|
+
if (current) tokens.push(current);
|
|
151
|
+
current = "";
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
current += ch;
|
|
155
|
+
}
|
|
156
|
+
if (current) tokens.push(current);
|
|
157
|
+
return tokens;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isSupportedImagePathToken(token: string): boolean {
|
|
161
|
+
const normalized = token.replace(/^file:\/\//, "").replace(/[),.;:]+$/, "");
|
|
162
|
+
if (/^https?:\/\//i.test(normalized) || normalized.startsWith("data:")) return false;
|
|
163
|
+
return /\.(?:png|jpe?g|webp)$/i.test(normalized);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function padAnsi(text: string, width: number): string {
|
|
167
|
+
const truncated = truncateToWidth(text, width, "");
|
|
168
|
+
return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function panelFrameContentWidth(width: number): number {
|
|
172
|
+
return Math.max(1, width - 2 - PANEL_CARD_PADDING_X * 2);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function panelFrame(lines: string[], width: number, theme: Theme): string[] {
|
|
176
|
+
const safeWidth = Math.max(1, width);
|
|
177
|
+
if (safeWidth < 8) return lines.map((line) => truncateToWidth(line, safeWidth, ""));
|
|
178
|
+
const inner = Math.max(1, safeWidth - 2);
|
|
179
|
+
const contentWidth = panelFrameContentWidth(safeWidth);
|
|
180
|
+
const border = (text: string) => theme.fg(PANEL_BAR_COLOR, text);
|
|
181
|
+
const frame = frameGlyphs();
|
|
182
|
+
return [
|
|
183
|
+
`${border(frame.tl)}${border(frame.h.repeat(inner))}${border(frame.tr)}`,
|
|
184
|
+
...lines.map((line) => `${border(frame.v)}${" ".repeat(PANEL_CARD_PADDING_X)}${padAnsi(line, contentWidth)}${" ".repeat(PANEL_CARD_PADDING_X)}${border(frame.v)}`),
|
|
185
|
+
`${border(frame.bl)}${border(frame.h.repeat(inner))}${border(frame.br)}`,
|
|
186
|
+
].map((line) => truncateToWidth(line, safeWidth, ""));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function panelBranch(theme: Theme, branch: "├" | "└" | "│"): string {
|
|
190
|
+
return theme.fg(PANEL_RULE_COLOR, treeGlyph(branch));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderStatusHeader(jobs: ActiveImageJob[], theme: Theme): string {
|
|
194
|
+
const oldest = jobs[0];
|
|
195
|
+
const elapsed = oldest ? Math.max(0, Math.round((Date.now() - oldest.startedAt) / 1000)) : 0;
|
|
196
|
+
const imageModels = [...new Set(jobs.map((job) => job.imageModel))];
|
|
197
|
+
const modelText = imageModels.length === 1 ? imageModels[0] : `${imageModels.length} models`;
|
|
198
|
+
const refs = jobs.reduce((total, job) => total + job.referenceCount, 0);
|
|
199
|
+
const refText = refs > 0 ? ` · ${refs} ref${refs === 1 ? "" : "s"}` : "";
|
|
200
|
+
return `${theme.fg(PANEL_TITLE_COLOR, theme.bold("Image Generation"))} ${theme.fg("muted", `${jobs.length} running · ${modelText}${refText} · ${elapsed}s`)}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderJobLines(theme: Theme, width: number): string[] {
|
|
204
|
+
const jobs = Array.from(activeImageJobs.values()).sort((a, b) => a.startedAt - b.startedAt);
|
|
205
|
+
if (jobs.length === 0) return [];
|
|
206
|
+
const dot = theme.fg("dim", glyphs().dot);
|
|
207
|
+
const lines = [renderStatusHeader(jobs, theme)];
|
|
208
|
+
const shown = jobs.slice(0, 4);
|
|
209
|
+
for (const [index, job] of shown.entries()) {
|
|
210
|
+
const ageSeconds = Math.max(0, Math.round((Date.now() - job.startedAt) / 1000));
|
|
211
|
+
const isLast = index === shown.length - 1 && jobs.length <= shown.length;
|
|
212
|
+
const refs = job.referenceCount > 0 ? `${dot}${theme.fg("dim", `${job.referenceCount} ref${job.referenceCount === 1 ? "" : "s"}`)}` : "";
|
|
213
|
+
const promptWidth = Math.max(16, width - 36);
|
|
214
|
+
lines.push(`${panelBranch(theme, isLast ? "└" : "├")}${theme.fg("accent", glyphs().bullet.trim())} ${theme.fg("accent", truncateToWidth(job.prompt, promptWidth, glyphs().ellipsis))}${dot}${theme.fg("muted", job.imageModel)}${refs}${dot}${theme.fg("dim", `${ageSeconds}s`)}`);
|
|
215
|
+
}
|
|
216
|
+
const hidden = jobs.length - shown.length;
|
|
217
|
+
if (hidden > 0) lines.push(`${panelBranch(theme, "└")}${theme.fg("muted", `${glyphs().ellipsis} ${hidden} more`)}`);
|
|
218
|
+
return panelFrame(lines, width, theme);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function createImageGenWidgetFactory(): (_tui: unknown, theme: Theme) => Component {
|
|
222
|
+
return (_tui, theme) => ({
|
|
223
|
+
invalidate() {},
|
|
224
|
+
render(width: number): string[] {
|
|
225
|
+
return renderJobLines(theme, width);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function ensureStatusTimer(): void {
|
|
231
|
+
if (statusTimer) return;
|
|
232
|
+
statusTimer = setInterval(() => {
|
|
233
|
+
if (!activeStatusCtx || activeImageJobs.size === 0) {
|
|
234
|
+
if (statusTimer) clearInterval(statusTimer);
|
|
235
|
+
statusTimer = undefined;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
updateImageGenStatus(activeStatusCtx);
|
|
239
|
+
}, 1000);
|
|
240
|
+
statusTimer.unref?.();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function updateImageGenStatus(ctx: ExtensionCommandContext): void {
|
|
244
|
+
activeStatusCtx = ctx;
|
|
245
|
+
const count = activeImageJobs.size;
|
|
246
|
+
ctx.ui.setStatus(IMAGE_GEN_STATUS_KEY, count > 0 ? `image-gen ${count}` : undefined);
|
|
247
|
+
ctx.ui.setWidget(IMAGE_GEN_STATUS_KEY, count > 0 ? createImageGenWidgetFactory() : undefined, { placement: "aboveEditor" });
|
|
248
|
+
if (count > 0) ensureStatusTimer();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function startImageJob(ctx: ExtensionCommandContext, parsed: ParsedImageGenCommand, imageModel: string): ActiveImageJob {
|
|
252
|
+
const job: ActiveImageJob = {
|
|
253
|
+
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
254
|
+
startedAt: Date.now(),
|
|
255
|
+
prompt: parsed.prompt,
|
|
256
|
+
referenceCount: parsed.imagePaths.length,
|
|
257
|
+
imageModel,
|
|
258
|
+
};
|
|
259
|
+
activeImageJobs.set(job.id, job);
|
|
260
|
+
updateImageGenStatus(ctx);
|
|
261
|
+
return job;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function finishImageJob(ctx: ExtensionCommandContext, jobId: string): void {
|
|
265
|
+
activeImageJobs.delete(jobId);
|
|
266
|
+
updateImageGenStatus(ctx);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function parseImageGenCommandArgs(input: string): ParsedImageGenCommand {
|
|
270
|
+
const imagePaths: string[] = [];
|
|
271
|
+
const promptParts: string[] = [];
|
|
272
|
+
for (const token of tokenizeArgs(input.trim())) {
|
|
273
|
+
if (token.startsWith("@") && token.length > 1) imagePaths.push(token.slice(1));
|
|
274
|
+
else if (isSupportedImagePathToken(token)) imagePaths.push(token.replace(/^file:\/\//, "").replace(/[),.;:]+$/, ""));
|
|
275
|
+
else promptParts.push(token);
|
|
276
|
+
}
|
|
277
|
+
return { prompt: promptParts.join(" ").trim(), imagePaths };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function mimeTypeForPath(path: string): string {
|
|
281
|
+
const ext = extname(path).toLowerCase();
|
|
282
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
283
|
+
if (ext === ".webp") return "image/webp";
|
|
284
|
+
if (ext === ".png") return "image/png";
|
|
285
|
+
throw new Error(`Unsupported reference image type: ${path}. Use PNG, JPEG, or WebP.`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function loadReferenceImage(cwd: string, rawPath: string): Promise<ReferenceImage> {
|
|
289
|
+
const path = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
290
|
+
const buffer = await readFile(path);
|
|
291
|
+
return { path, mimeType: mimeTypeForPath(path), base64: buffer.toString("base64") };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function resolveCodexUrl(baseUrl: string | undefined, options?: { apiKeyMode?: boolean }): string {
|
|
295
|
+
const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : DEFAULT_CODEX_BASE_URL;
|
|
296
|
+
const normalized = raw.replace(/\/+$/, "");
|
|
297
|
+
if (options?.apiKeyMode) {
|
|
298
|
+
if (normalized.endsWith("/responses")) return normalized;
|
|
299
|
+
return `${normalized}/responses`;
|
|
300
|
+
}
|
|
301
|
+
if (normalized.endsWith("/codex/responses")) return normalized;
|
|
302
|
+
if (normalized.endsWith("/codex")) return `${normalized}/responses`;
|
|
303
|
+
return `${normalized}/codex/responses`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function buildHeaders(
|
|
307
|
+
model: Model<Api>,
|
|
308
|
+
auth: { apiKey?: string; headers?: ProviderHeaders },
|
|
309
|
+
options?: { apiKeyMode?: boolean },
|
|
310
|
+
): Headers {
|
|
311
|
+
const headers = buildCodexJsonHeaders({
|
|
312
|
+
modelHeaders: model.headers,
|
|
313
|
+
auth,
|
|
314
|
+
apiKeyMode: options?.apiKeyMode ?? false,
|
|
315
|
+
});
|
|
316
|
+
setProviderGeneratedHeader(headers, "OpenAI-Beta", "responses=experimental");
|
|
317
|
+
setProviderGeneratedHeader(headers, "accept", "text/event-stream");
|
|
318
|
+
return headers;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function buildBackgroundImageRequest(options: {
|
|
322
|
+
prompt: string;
|
|
323
|
+
referenceImages: ReferenceImage[];
|
|
324
|
+
responsesModel: string;
|
|
325
|
+
imageModel: string;
|
|
326
|
+
}): Record<string, unknown> {
|
|
327
|
+
const content: Array<Record<string, unknown>> = [
|
|
328
|
+
{ type: "input_text", text: options.referenceImages.length > 0 ? `Edit the provided image(s): ${options.prompt}` : options.prompt },
|
|
329
|
+
...options.referenceImages.map((image) => ({
|
|
330
|
+
type: "input_image",
|
|
331
|
+
detail: "auto",
|
|
332
|
+
image_url: `data:${image.mimeType};base64,${image.base64}`,
|
|
333
|
+
})),
|
|
334
|
+
];
|
|
335
|
+
return {
|
|
336
|
+
model: options.responsesModel,
|
|
337
|
+
store: false,
|
|
338
|
+
stream: true,
|
|
339
|
+
instructions: BACKGROUND_IMAGE_INSTRUCTIONS,
|
|
340
|
+
input: [{ role: "user", content }],
|
|
341
|
+
tools: [{
|
|
342
|
+
type: "image_generation",
|
|
343
|
+
model: options.imageModel,
|
|
344
|
+
output_format: "png",
|
|
345
|
+
action: options.referenceImages.length > 0 ? "edit" : "generate",
|
|
346
|
+
}],
|
|
347
|
+
tool_choice: { type: "image_generation" },
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function* parseSseEvents(response: Response): AsyncIterable<Record<string, unknown>> {
|
|
352
|
+
if (!response.body) return;
|
|
353
|
+
const reader = response.body.getReader();
|
|
354
|
+
const decoder = new TextDecoder();
|
|
355
|
+
let buffer = "";
|
|
356
|
+
while (true) {
|
|
357
|
+
const { done, value } = await reader.read();
|
|
358
|
+
if (done) break;
|
|
359
|
+
buffer += decoder.decode(value, { stream: true });
|
|
360
|
+
let boundary: number;
|
|
361
|
+
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
|
|
362
|
+
const raw = buffer.slice(0, boundary);
|
|
363
|
+
buffer = buffer.slice(boundary + 2);
|
|
364
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
365
|
+
if (!line.startsWith("data:")) continue;
|
|
366
|
+
const data = line.slice(5).trim();
|
|
367
|
+
if (!data || data === "[DONE]") continue;
|
|
368
|
+
yield JSON.parse(data) as Record<string, unknown>;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function collectImageResult(results: CodexImageResult[], item: unknown, fallbackImageModel: string): void {
|
|
375
|
+
if (!item || typeof item !== "object") return;
|
|
376
|
+
const candidate = item as Record<string, unknown>;
|
|
377
|
+
if (candidate.type !== "image_generation_call" || typeof candidate.result !== "string") return;
|
|
378
|
+
const id = typeof candidate.id === "string" ? candidate.id : `ig_${results.length}`;
|
|
379
|
+
if (results.some((result) => result.id === id)) return;
|
|
380
|
+
results.push({
|
|
381
|
+
id,
|
|
382
|
+
result: candidate.result,
|
|
383
|
+
outputFormat: typeof candidate.output_format === "string" ? candidate.output_format : "png",
|
|
384
|
+
revisedPrompt: typeof candidate.revised_prompt === "string" ? candidate.revised_prompt : undefined,
|
|
385
|
+
imageModel: typeof candidate.model === "string" ? candidate.model : fallbackImageModel,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function collectResponseText(value: unknown, out: string[]): void {
|
|
390
|
+
if (!value || typeof value !== "object") return;
|
|
391
|
+
const item = value as Record<string, unknown>;
|
|
392
|
+
for (const key of ["text", "refusal", "summary_text", "message"] as const) {
|
|
393
|
+
const text = item[key];
|
|
394
|
+
if (typeof text === "string" && text.trim()) out.push(text.trim());
|
|
395
|
+
}
|
|
396
|
+
for (const key of ["content", "output", "summary"] as const) {
|
|
397
|
+
const child = item[key];
|
|
398
|
+
if (Array.isArray(child)) for (const part of child) collectResponseText(part, out);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function summarizeNonImageResponse(response: Record<string, unknown> | undefined): string {
|
|
403
|
+
if (!response) return "No image was returned by Codex.";
|
|
404
|
+
const status = typeof response.status === "string" ? response.status : undefined;
|
|
405
|
+
const error = response.error && typeof response.error === "object" ? response.error as Record<string, unknown> : undefined;
|
|
406
|
+
const errorMessage = typeof error?.message === "string" ? error.message : undefined;
|
|
407
|
+
const texts: string[] = [];
|
|
408
|
+
collectResponseText(response, texts);
|
|
409
|
+
const text = [...new Set(texts)].join(" ").replace(/\s+/g, " ").trim();
|
|
410
|
+
const details = [status && status !== "completed" ? `status ${status}` : undefined, errorMessage, text].filter(Boolean).join(" · ");
|
|
411
|
+
return details ? `No image was returned by Codex: ${details}` : "No image was returned by Codex.";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function renderImageGenError(details: ImageGenerationErrorDetails, theme: Theme): Component {
|
|
415
|
+
return {
|
|
416
|
+
invalidate() {},
|
|
417
|
+
render(width: number): string[] {
|
|
418
|
+
const safeWidth = Math.max(1, width);
|
|
419
|
+
const message = details.message || "Image generation failed.";
|
|
420
|
+
const promptWidth = Math.max(16, safeWidth - 28);
|
|
421
|
+
const lines = [
|
|
422
|
+
`${theme.fg("error", "● ")}${theme.fg("text", theme.bold("Image Generation "))}${theme.fg("error", "failed")}`,
|
|
423
|
+
];
|
|
424
|
+
if (details.imageModel) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Model ")}${theme.fg("muted", details.imageModel)}`);
|
|
425
|
+
if (details.referenceCount && details.referenceCount > 0) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Refs ")}${theme.fg("muted", `${details.referenceCount}`)}`);
|
|
426
|
+
if (details.prompt) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Prompt ")}${theme.fg("muted", truncateToWidth(details.prompt, promptWidth, "…"))}`);
|
|
427
|
+
lines.push(`${panelBranch(theme, "└")}${theme.fg("error", truncateToWidth(message, Math.max(16, safeWidth - 5), "…"))}`);
|
|
428
|
+
return lines.map((line) => truncateToWidth(line, safeWidth, ""));
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function runBackgroundImageGeneration(pi: ExtensionAPI, ctx: ExtensionCommandContext, parsed: ParsedImageGenCommand): Promise<void> {
|
|
434
|
+
const model = selectCodexImageModel(ctx.model as ModelLike | undefined, ctx.modelRegistry as ModelRegistryLike | undefined) as Model<Api> | undefined;
|
|
435
|
+
if (!model) throw new Error("No image-capable model with an enabled model catalog profile is available.");
|
|
436
|
+
const settings = loadModelSettings(model as ModelLike, ctx.cwd);
|
|
437
|
+
if (!settings.enabled) throw new Error("pi-codex-minimal-tools is disabled.");
|
|
438
|
+
if (settings.imageGenerationImplementation === "standalone") {
|
|
439
|
+
const result = await standaloneImageGeneration({
|
|
440
|
+
prompt: parsed.prompt,
|
|
441
|
+
referenced_image_paths: parsed.imagePaths,
|
|
442
|
+
}, {
|
|
443
|
+
cwd: ctx.cwd,
|
|
444
|
+
model,
|
|
445
|
+
modelRegistry: ctx.modelRegistry,
|
|
446
|
+
}, settings, undefined, { callId: "standalone" });
|
|
447
|
+
const saved = result.details.saved;
|
|
448
|
+
const workspaceRoot = projectRoot(ctx.cwd);
|
|
449
|
+
const relativePath = relative(workspaceRoot, saved.path);
|
|
450
|
+
const latestAbsolutePath = saved.latestPath ?? saved.path;
|
|
451
|
+
const latestRelativePath = relative(workspaceRoot, latestAbsolutePath);
|
|
452
|
+
const savedImage: SavedGeneratedImage = {
|
|
453
|
+
absolutePath: saved.path,
|
|
454
|
+
relativePath: relativePath && !relativePath.startsWith("..") ? relativePath : saved.path,
|
|
455
|
+
latestAbsolutePath,
|
|
456
|
+
latestRelativePath: latestRelativePath && !latestRelativePath.startsWith("..")
|
|
457
|
+
? latestRelativePath
|
|
458
|
+
: latestAbsolutePath,
|
|
459
|
+
responseId: undefined,
|
|
460
|
+
callId: "standalone",
|
|
461
|
+
outputFormat: saved.format,
|
|
462
|
+
imageModel: settings.imageModel,
|
|
463
|
+
revisedPrompt: parsed.prompt,
|
|
464
|
+
};
|
|
465
|
+
pi.sendMessage({
|
|
466
|
+
customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
467
|
+
content: [{ type: "text", text: buildGeneratedImageDisplayText(savedImage, { expanded: false }) }],
|
|
468
|
+
display: true,
|
|
469
|
+
details: { savedImages: [savedImage] },
|
|
470
|
+
}, { triggerTurn: false });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
474
|
+
if (!auth.ok) throw new Error(auth.error);
|
|
475
|
+
if (!hasCodexRequestAuth({
|
|
476
|
+
modelHeaders: model.headers,
|
|
477
|
+
auth: { apiKey: auth.apiKey, headers: auth.headers },
|
|
478
|
+
})) {
|
|
479
|
+
throw new Error(`No request authentication is configured for ${model.provider}.`);
|
|
480
|
+
}
|
|
481
|
+
const referenceImages = await Promise.all(parsed.imagePaths.map((path) => loadReferenceImage(ctx.cwd, path)));
|
|
482
|
+
const body = buildBackgroundImageRequest({
|
|
483
|
+
prompt: parsed.prompt,
|
|
484
|
+
referenceImages,
|
|
485
|
+
responsesModel: model.id,
|
|
486
|
+
imageModel: settings.imageModel,
|
|
487
|
+
});
|
|
488
|
+
const response = await fetch(resolveCodexUrl(model.baseUrl, { apiKeyMode: settings.apiKeyMode }), {
|
|
489
|
+
method: "POST",
|
|
490
|
+
headers: buildHeaders(model, {
|
|
491
|
+
apiKey: auth.apiKey,
|
|
492
|
+
headers: auth.headers,
|
|
493
|
+
}, { apiKeyMode: settings.apiKeyMode }),
|
|
494
|
+
body: JSON.stringify(body),
|
|
495
|
+
});
|
|
496
|
+
if (!response.ok) throw new Error(`Codex image generation failed: ${response.status} ${await response.text()}`);
|
|
497
|
+
const results: CodexImageResult[] = [];
|
|
498
|
+
let responseId: string | undefined;
|
|
499
|
+
let lastResponse: Record<string, unknown> | undefined;
|
|
500
|
+
for await (const event of parseSseEvents(response)) {
|
|
501
|
+
if (event.type === "response.created" && event.response && typeof event.response === "object") {
|
|
502
|
+
lastResponse = event.response as Record<string, unknown>;
|
|
503
|
+
const id = (event.response as { id?: unknown }).id;
|
|
504
|
+
if (typeof id === "string") responseId = id;
|
|
505
|
+
}
|
|
506
|
+
if (event.type === "response.output_item.done") collectImageResult(results, event.item, settings.imageModel);
|
|
507
|
+
if ((event.type === "response.completed" || event.type === "response.done") && event.response && typeof event.response === "object") {
|
|
508
|
+
lastResponse = event.response as Record<string, unknown>;
|
|
509
|
+
const responseOutput = (event.response as { output?: unknown }).output;
|
|
510
|
+
if (Array.isArray(responseOutput)) for (const item of responseOutput) collectImageResult(results, item, settings.imageModel);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (results.length === 0) throw new Error(summarizeNonImageResponse(lastResponse));
|
|
514
|
+
const savedImages = [];
|
|
515
|
+
for (const result of results) {
|
|
516
|
+
savedImages.push(await saveOpenAICodexGeneratedImage(ctx.cwd, {
|
|
517
|
+
responseId,
|
|
518
|
+
callId: result.id,
|
|
519
|
+
result: result.result,
|
|
520
|
+
outputFormat: result.outputFormat,
|
|
521
|
+
imageModel: result.imageModel,
|
|
522
|
+
revisedPrompt: result.revisedPrompt ?? parsed.prompt,
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
pi.sendMessage({
|
|
526
|
+
customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
527
|
+
content: [{ type: "text", text: buildGeneratedImageDisplayText(savedImages[0], { expanded: false }) }],
|
|
528
|
+
display: true,
|
|
529
|
+
details: { savedImages },
|
|
530
|
+
}, { triggerTurn: false });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function registerBackgroundImageGenerationCommand(pi: ExtensionAPI): void {
|
|
534
|
+
pi.registerMessageRenderer<ImageGenerationErrorDetails>(IMAGE_GEN_ERROR_MESSAGE_TYPE, (message, _options, theme) => {
|
|
535
|
+
const rawContent = typeof message.content === "string"
|
|
536
|
+
? message.content
|
|
537
|
+
: message.content
|
|
538
|
+
.filter((item) => item.type === "text")
|
|
539
|
+
.map((item) => item.text)
|
|
540
|
+
.join("\n");
|
|
541
|
+
const details: ImageGenerationErrorDetails = {
|
|
542
|
+
message: message.details?.message ?? rawContent.replace(/^Image generation failed:\s*/i, "") ?? "Image generation failed.",
|
|
543
|
+
prompt: message.details?.prompt,
|
|
544
|
+
imageModel: message.details?.imageModel,
|
|
545
|
+
referenceCount: message.details?.referenceCount,
|
|
546
|
+
};
|
|
547
|
+
return renderImageGenError(details, theme);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
pi.registerCommand("image-gen", {
|
|
551
|
+
description: "Generate or edit an image in the background with the current model catalog. Usage: /image-gen prompt text [@reference.png]",
|
|
552
|
+
handler: async (args, ctx) => {
|
|
553
|
+
const parsed = parseImageGenCommandArgs(args);
|
|
554
|
+
if (!parsed.prompt) {
|
|
555
|
+
ctx.ui.notify("Usage: /image-gen prompt text [@reference.png]", "warning");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const settings = loadSettings(ctx.cwd);
|
|
559
|
+
const job = startImageJob(ctx, parsed, settings.imageModel);
|
|
560
|
+
ctx.ui.notify(`Queued image generation with ${settings.imageModel}${parsed.imagePaths.length ? ` (${parsed.imagePaths.length} reference image${parsed.imagePaths.length === 1 ? "" : "s"})` : ""}.`, "info");
|
|
561
|
+
void runBackgroundImageGeneration(pi, ctx, parsed)
|
|
562
|
+
.catch((error) => {
|
|
563
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
564
|
+
pi.sendMessage({
|
|
565
|
+
customType: IMAGE_GEN_ERROR_MESSAGE_TYPE,
|
|
566
|
+
content: `Image generation failed: ${message}`,
|
|
567
|
+
display: true,
|
|
568
|
+
details: { message, prompt: parsed.prompt, imageModel: settings.imageModel, referenceCount: parsed.imagePaths.length } satisfies ImageGenerationErrorDetails,
|
|
569
|
+
}, { triggerTurn: false });
|
|
570
|
+
})
|
|
571
|
+
.finally(() => finishImageJob(ctx, job.id));
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
}
|