pi-codex-image-gen 0.1.11 → 0.1.13

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,286 @@
1
+ // The subscription Responses contract is private. Keep its parser and safety
2
+ // boundaries separate from Pi tool registration and file handling.
3
+ export const REQUEST_TIMEOUT_MS = 5 * 60_000;
4
+ export const MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
5
+ export const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
6
+ const MAX_ERROR_BYTES = 16 * 1024;
7
+ const MAX_TEXT_CHARS = 4000;
8
+
9
+ export interface ReportedImage {
10
+ model?: string;
11
+ size?: string;
12
+ quality?: string;
13
+ background?: string;
14
+ outputFormat?: string;
15
+ }
16
+
17
+ export interface ParsedCodexResponse {
18
+ image?: {
19
+ id: string;
20
+ status: "completed";
21
+ result: string;
22
+ revisedPrompt?: string;
23
+ reported: ReportedImage;
24
+ };
25
+ text: string[];
26
+ responseId?: string;
27
+ usage?: unknown;
28
+ }
29
+
30
+ function object(value: unknown): Record<string, unknown> {
31
+ return value !== null && typeof value === "object" && !Array.isArray(value)
32
+ ? value as Record<string, unknown> : {};
33
+ }
34
+
35
+ function text(value: unknown, secrets: string[] = []): string {
36
+ if (typeof value !== "string") return "";
37
+ let result = value;
38
+ for (const secret of secrets) {
39
+ if (secret) result = result.split(secret).join("[redacted]");
40
+ }
41
+ // Also hide a JWT cut short by the text bound; exact-token matching alone
42
+ // cannot redact a credential that arrives across the truncation boundary.
43
+ return result.replace(/\beyJ[A-Za-z0-9_.-]*/g, "[redacted]")
44
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, MAX_TEXT_CHARS);
45
+ }
46
+
47
+ function identifier(value: unknown, secrets: string[]): string | undefined {
48
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value)
49
+ && !secrets.some(secret => secret && value.includes(secret)) ? value : undefined;
50
+ }
51
+
52
+ export function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
53
+ return new Promise<T>((resolve, reject) => {
54
+ const abort = () => reject(signal.reason);
55
+ if (signal.aborted) abort();
56
+ else signal.addEventListener("abort", abort, { once: true });
57
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
58
+ });
59
+ }
60
+
61
+ export async function withRequestDeadline<T>(
62
+ signal: AbortSignal | undefined,
63
+ run: (signal: AbortSignal) => Promise<T>,
64
+ ): Promise<T> {
65
+ const controller = new AbortController();
66
+ const abort = () => controller.abort(new Error("Image generation was aborted."));
67
+ if (signal?.aborted) abort();
68
+ else signal?.addEventListener("abort", abort, { once: true });
69
+ const timer = setTimeout(() => controller.abort(new Error(
70
+ "Image generation timed out after 5 minutes. The backend may still finish; no automatic retry was made.",
71
+ )), REQUEST_TIMEOUT_MS);
72
+ try {
73
+ controller.signal.throwIfAborted();
74
+ return await run(controller.signal);
75
+ } catch (error) {
76
+ if (controller.signal.aborted) throw controller.signal.reason;
77
+ throw error;
78
+ } finally {
79
+ clearTimeout(timer);
80
+ signal?.removeEventListener("abort", abort);
81
+ }
82
+ }
83
+
84
+ async function* chunks(response: Response, limit: number, signal: AbortSignal): AsyncGenerator<Uint8Array> {
85
+ if (!response.body) throw new Error("Codex response did not include a stream body.");
86
+ const reader = response.body.getReader();
87
+ let bytes = 0;
88
+ try {
89
+ const declared = Number(response.headers.get("content-length"));
90
+ if (declared > limit) throw new Error("Codex response exceeded the size limit.");
91
+ while (true) {
92
+ signal.throwIfAborted();
93
+ let part: ReadableStreamReadResult<Uint8Array>;
94
+ try {
95
+ part = await abortable(reader.read(), signal);
96
+ } catch {
97
+ signal.throwIfAborted();
98
+ throw new Error("Codex response stream was interrupted. The backend may still finish; no automatic retry was made.");
99
+ }
100
+ const { done, value } = part;
101
+ if (done) break;
102
+ bytes += value.byteLength;
103
+ if (bytes > limit) throw new Error("Codex response exceeded the size limit.");
104
+ yield value;
105
+ }
106
+ } finally {
107
+ // Do not let a stalled stream cancellation defeat the request deadline.
108
+ void reader.cancel().catch(() => undefined);
109
+ reader.releaseLock();
110
+ }
111
+ }
112
+
113
+ const QUOTA_CODES = new Set([
114
+ "insufficient_quota", "quota_exceeded", "usage_limit_reached", "usage_limit_exceeded",
115
+ "billing_hard_limit_reached", "billing_not_active", "organization_usage_limit_exceeded",
116
+ "workspace_member_usage_limit_reached",
117
+ ]);
118
+
119
+ function isQuota(error: Record<string, unknown>): boolean {
120
+ return [error.code, error.type].some(value => typeof value === "string" && QUOTA_CODES.has(value));
121
+ }
122
+
123
+ function errorHint(error: unknown): string {
124
+ const { code, type } = object(error);
125
+ if (isQuota({ code, type })) {
126
+ return "Codex subscription quota is unavailable or exhausted. Check your plan or wait for its reset.";
127
+ }
128
+ if (code === "moderation_blocked" || type === "image_generation_user_error") {
129
+ return "Codex could not generate this image. Review the prompt and input images before trying again.";
130
+ }
131
+ return "Codex could not complete the image request.";
132
+ }
133
+
134
+ export async function httpFailure(response: Response, signal: AbortSignal): Promise<{ message: string; retry: boolean }> {
135
+ if (response.headers.get("cf-mitigated") === "challenge") {
136
+ void response.body?.cancel().catch(() => undefined);
137
+ return { message: "Codex connection was challenged by Cloudflare. This does not establish model or subscription availability.", retry: false };
138
+ }
139
+ let body = "";
140
+ try {
141
+ const decoder = new TextDecoder();
142
+ for await (const chunk of chunks(response, MAX_ERROR_BYTES, signal)) body += decoder.decode(chunk, { stream: true });
143
+ body += decoder.decode();
144
+ } catch {
145
+ signal.throwIfAborted();
146
+ // A large or unreadable error body is deliberately not exposed.
147
+ }
148
+ let error: Record<string, unknown> = {};
149
+ try {
150
+ error = object(object(JSON.parse(body)).error);
151
+ } catch { /* HTML and other non-JSON error bodies are not diagnostics. */ }
152
+ const terminal = isQuota(error)
153
+ || error.code === "moderation_blocked" || error.type === "image_generation_user_error";
154
+ const hint = response.status === 401
155
+ ? "Codex login was rejected. Run /login for openai-codex again."
156
+ : response.status === 403
157
+ ? "Codex access was denied. This can be a connection or account restriction; it does not identify the image model."
158
+ : errorHint(error);
159
+ return {
160
+ message: `Codex image request failed (HTTP ${response.status}). ${hint}`,
161
+ retry: !terminal && [429, 500, 502, 503, 504].includes(response.status),
162
+ };
163
+ }
164
+
165
+ function reportedImage(item: Record<string, unknown>, secrets: string[]): ReportedImage {
166
+ const reported: ReportedImage = {};
167
+ const model = item.model;
168
+ if (typeof model === "string" && /^gpt-image-[a-z0-9.-]{1,80}$/.test(model)
169
+ && !secrets.some(secret => secret && model.includes(secret))) reported.model = model;
170
+ if (typeof item.size === "string" && /^[1-9]\d{0,4}x[1-9]\d{0,4}$/.test(item.size)) reported.size = item.size;
171
+ if (typeof item.quality === "string" && ["low", "medium", "high", "xhigh", "max", "auto"].includes(item.quality)) reported.quality = item.quality;
172
+ if (typeof item.background === "string" && ["transparent", "opaque", "auto"].includes(item.background)) reported.background = item.background;
173
+ if (typeof item.output_format === "string" && ["png", "jpeg", "webp"].includes(item.output_format)) reported.outputFormat = item.output_format;
174
+ return reported;
175
+ }
176
+
177
+ export async function parseCodexSse(
178
+ response: Response,
179
+ signal: AbortSignal,
180
+ secrets: string[],
181
+ onProgress?: (stage: string) => void,
182
+ ): Promise<ParsedCodexResponse> {
183
+ const parsed: ParsedCodexResponse = { text: [] };
184
+ let completed = false;
185
+ let textChars = 0;
186
+ let lastStage: string | undefined;
187
+ const image = (value: unknown) => {
188
+ const item = object(value);
189
+ if (item.type !== "image_generation_call") return;
190
+ if (parsed.image) throw new Error("Codex returned more than one image. No automatic retry was made.");
191
+ if (item.status !== "completed") throw new Error("Codex image generation did not complete.");
192
+ if (typeof item.result !== "string" || !item.result) throw new Error("Codex image_generation_call did not contain image data.");
193
+ if (item.result.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4) throw new Error("Codex image exceeded the 32 MiB size limit.");
194
+ parsed.image = {
195
+ id: identifier(item.id, secrets) ?? "image_generation",
196
+ status: "completed",
197
+ result: item.result,
198
+ revisedPrompt: text(item.revised_prompt, secrets) || undefined,
199
+ reported: reportedImage(item, secrets),
200
+ };
201
+ };
202
+ const handle = (frame: string) => {
203
+ const data = frame.split(/\r?\n/).filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join("\n");
204
+ if (!data || data === "[DONE]") return;
205
+ let event: Record<string, unknown>;
206
+ try { event = object(JSON.parse(data)); }
207
+ catch { throw new Error("Codex returned an invalid stream event. No automatic retry was made."); }
208
+ switch (event.type) {
209
+ case "error":
210
+ case "response.failed":
211
+ throw new Error(errorHint(object(event.response).error ?? event.error ?? event));
212
+ case "response.incomplete":
213
+ throw new Error("Codex response was incomplete. No automatic retry was made.");
214
+ case "response.created":
215
+ parsed.responseId = identifier(object(event.response).id, secrets);
216
+ break;
217
+ case "response.output_text.delta":
218
+ if (typeof event.delta === "string" && textChars < MAX_TEXT_CHARS) {
219
+ const delta = event.delta.slice(0, MAX_TEXT_CHARS - textChars);
220
+ parsed.text.push(delta);
221
+ textChars += delta.length;
222
+ }
223
+ break;
224
+ case "response.output_item.done":
225
+ image(event.item);
226
+ break;
227
+ case "response.completed": {
228
+ const final = object(event.response);
229
+ parsed.responseId = identifier(final.id, secrets) ?? parsed.responseId;
230
+ // Usage contains numeric counters only; never persist arbitrary response objects.
231
+ const usage = object(final.usage);
232
+ const counters: Record<string, unknown> = Object.fromEntries(
233
+ ["input_tokens", "output_tokens", "total_tokens"].flatMap(key =>
234
+ typeof usage[key] === "number" && Number.isFinite(usage[key]) && usage[key] >= 0
235
+ ? [[key, usage[key]]] : []),
236
+ );
237
+ for (const [key, fields] of [
238
+ ["input_tokens_details", ["cached_tokens"]],
239
+ ["output_tokens_details", ["reasoning_tokens"]],
240
+ ] as const) {
241
+ const values = object(usage[key]);
242
+ const details = Object.fromEntries(fields.flatMap(field =>
243
+ typeof values[field] === "number" && Number.isFinite(values[field]) && values[field] >= 0
244
+ ? [[field, values[field]]] : []));
245
+ if (Object.keys(details).length) counters[key] = details;
246
+ }
247
+ if (Object.keys(counters).length) parsed.usage = counters;
248
+ if (!parsed.image && Array.isArray(final.output)) final.output.forEach(image);
249
+ completed = true;
250
+ break;
251
+ }
252
+ case "response.image_generation_call.in_progress":
253
+ case "response.image_generation_call.generating":
254
+ case "response.image_generation_call.completed":
255
+ if (event.type !== lastStage) {
256
+ lastStage = event.type;
257
+ onProgress?.(event.type.split(".").at(-1)!);
258
+ }
259
+ break;
260
+ }
261
+ };
262
+ const decoder = new TextDecoder();
263
+ let buffer = "";
264
+ let scanFrom = 0;
265
+ for await (const chunk of chunks(response, MAX_RESPONSE_BYTES, signal)) {
266
+ buffer += decoder.decode(chunk, { stream: true });
267
+ const separator = /\r?\n\r?\n/g;
268
+ separator.lastIndex = scanFrom;
269
+ let match: RegExpExecArray | null;
270
+ while ((match = separator.exec(buffer))) {
271
+ handle(buffer.slice(0, match.index));
272
+ buffer = buffer.slice(match.index + match[0].length);
273
+ if (completed) break;
274
+ separator.lastIndex = 0;
275
+ }
276
+ if (completed) break;
277
+ scanFrom = Math.max(0, buffer.length - 3);
278
+ }
279
+ if (!completed) {
280
+ buffer += decoder.decode();
281
+ if (buffer.trim()) handle(buffer);
282
+ }
283
+ if (!completed) throw new Error("Codex stream ended before completion. The backend may still finish; no automatic retry was made.");
284
+ parsed.text = [text(parsed.text.join(""), secrets)];
285
+ return parsed;
286
+ }
@@ -1,12 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
4
+ import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  const PACKAGE_NAME = "pi-codex-image-gen";
8
- const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
9
- const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
8
+ const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
10
9
  const CI_ENVIRONMENT_VARIABLES = [
11
10
  "APPVEYOR",
12
11
  "BITBUCKET_BUILD_NUMBER",
@@ -24,10 +23,6 @@ const CI_ENVIRONMENT_VARIABLES = [
24
23
  "VERCEL",
25
24
  ];
26
25
 
27
- interface InstallTelemetryState {
28
- lastReportedVersion?: string;
29
- }
30
-
31
26
  interface PiSettingsDocument {
32
27
  enableInstallTelemetry?: unknown;
33
28
  }
@@ -51,18 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
51
46
  return normalized !== "0" && normalized !== "false" && normalized !== "no";
52
47
  }
53
48
 
54
- function isCiEnvironment(): boolean {
55
- if (isTruthyEnvFlag(process.env.CI)) return true;
56
- return CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]));
57
- }
58
-
59
- function isInstallTelemetryEnabled(): boolean {
60
- if (isCiEnvironment()) return false;
61
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
62
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
49
+ export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
50
+ if (isTruthyEnvFlag(env.CI)) return false;
51
+ if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
52
+ if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
63
53
 
64
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
65
- return settings.enableInstallTelemetry !== false;
54
+ const settings = readJsonFile(settingsPath) as PiSettingsDocument;
55
+ if (settings.enableInstallTelemetry === false) return false;
56
+ if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
57
+ return true;
66
58
  }
67
59
 
68
60
  function getPackageVersion(): string {
@@ -70,35 +62,16 @@ function getPackageVersion(): string {
70
62
  return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
71
63
  }
72
64
 
73
- function getInstallTelemetryUserAgent(version: string): string {
74
- const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
75
- const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
76
- return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
77
- }
78
-
79
- async function reportInstallTelemetryAsync(): Promise<void> {
65
+ export function reportInstallTelemetry(): void {
80
66
  try {
81
- if (!isInstallTelemetryEnabled()) return;
82
-
83
- const version = getPackageVersion();
84
- const extensionsDir = join(getAgentDir(), "extensions");
85
- const statePath = join(extensionsDir, "pi-codex-image-gen-install.json");
86
- const state = readJsonFile(statePath) as InstallTelemetryState;
87
- if (state.lastReportedVersion === version) return;
88
-
89
- await mkdir(extensionsDir, { recursive: true });
90
- await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
91
-
92
- const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
93
- await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
94
- headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
95
- signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
96
- });
67
+ void report({
68
+ endpoint: INSTALL_TELEMETRY_ENDPOINT,
69
+ tool: PACKAGE_NAME,
70
+ version: getPackageVersion(),
71
+ statePath: join(getAgentDir(), "extensions", "pi-codex-image-gen-install.json"),
72
+ enabled: isInstallTelemetryEnabled(),
73
+ }).catch(() => undefined);
97
74
  } catch {
98
- // Best-effort telemetry: ignore settings, filesystem, and network failures.
75
+ // Best-effort telemetry: ignore local policy and filesystem failures.
99
76
  }
100
77
  }
101
-
102
- export function reportInstallTelemetry(): void {
103
- void reportInstallTelemetryAsync();
104
- }