pi-codex-image-gen 0.1.12 → 0.1.14

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
+ ? "Image login was rejected. Run /login codex-images 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
+ }
@@ -0,0 +1,223 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer, type Server } from "node:http";
3
+ import type { OAuthAuth, OAuthCredential, Provider, ProviderAuthInteraction } from "@earendil-works/pi-ai";
4
+ import { abortable } from "./codex-response.js";
5
+
6
+ export const IMAGE_AUTH_PROVIDER = "codex-images";
7
+ // Public OAuth client used by the image-capable ChatGPT/Codex flow.
8
+ // This is a client identifier, not a secret. No Codex app or Pi legacy helper is used.
9
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
10
+ const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
11
+ const TOKEN_URL = "https://auth.openai.com/oauth/token";
12
+ const REDIRECT_URI = "http://localhost:1455/auth/callback";
13
+ const AUTH_CLAIM = "https://api.openai.com/auth";
14
+ const LOGIN_TIMEOUT_MS = 10 * 60_000;
15
+ const TOKEN_TIMEOUT_MS = 30_000;
16
+ const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
17
+ const LOGIN_HINT = `Run /login ${IMAGE_AUTH_PROVIDER} again.`;
18
+
19
+ class ImageOAuthDeniedError extends Error {
20
+ constructor() { super(`Image OAuth authorization was denied. ${LOGIN_HINT}`); }
21
+ }
22
+
23
+ export function extractImageAccountId(token: string): string {
24
+ try {
25
+ if (typeof token !== "string" || token.length > MAX_TOKEN_RESPONSE_BYTES
26
+ || /[^a-zA-Z0-9_.-]/.test(token)) throw new Error();
27
+ const parts = token.split(".");
28
+ if (parts.length !== 3 || parts.some(part => !part)) throw new Error();
29
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
30
+ const accountId = payload?.[AUTH_CLAIM]?.chatgpt_account_id;
31
+ if (typeof accountId !== "string" || accountId.length === 0 || accountId.length > 128
32
+ || /[^a-zA-Z0-9_-]/.test(accountId)) throw new Error();
33
+ // Routing hint only: OpenAI, not this client, authenticates the JWT.
34
+ return accountId;
35
+ } catch {
36
+ throw new Error(`Image OAuth token has no valid ChatGPT account ID. ${LOGIN_HINT}`);
37
+ }
38
+ }
39
+
40
+ export function parseImageOAuthCallback(input: string, state: string): string {
41
+ try {
42
+ if (input.length > 8192) throw new Error();
43
+ const url = new URL(input.trim());
44
+ const expected = new URL(REDIRECT_URI);
45
+ if (url.origin !== expected.origin || url.pathname !== expected.pathname || url.username || url.password || url.hash
46
+ || url.searchParams.getAll("state").length !== 1 || url.searchParams.get("state") !== state) throw new Error();
47
+ if (url.searchParams.has("error")) throw new ImageOAuthDeniedError();
48
+ const codes = url.searchParams.getAll("code");
49
+ if (codes.length !== 1 || !codes[0] || codes[0].length > 4096) throw new Error();
50
+ return codes[0];
51
+ } catch (error) {
52
+ if (error instanceof ImageOAuthDeniedError) throw error;
53
+ throw new Error("Invalid image OAuth callback. Paste the full redirect URL from this login attempt.");
54
+ }
55
+ }
56
+
57
+ async function startCallback(state: string): Promise<{ server: Server; code: Promise<string> }> {
58
+ let settle!: (code: string) => void;
59
+ let reject!: (error: Error) => void;
60
+ const code = new Promise<string>((resolve, rejectCode) => { settle = resolve; reject = rejectCode; });
61
+ void code.catch(() => undefined);
62
+ let consumed = false;
63
+ const server = createServer((request, response) => {
64
+ if (request.method !== "GET" || consumed) {
65
+ response.writeHead(400).end("Invalid OAuth callback.");
66
+ return;
67
+ }
68
+ try {
69
+ const value = parseImageOAuthCallback(new URL(request.url ?? "/", REDIRECT_URI).href, state);
70
+ consumed = true;
71
+ response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" })
72
+ .end("Image login completed. You can close this window.");
73
+ settle(value);
74
+ } catch (error) {
75
+ // Do not reflect a code, state, or arbitrary query text into the page.
76
+ response.writeHead(400).end("Invalid OAuth callback. Return to Pi and retry.");
77
+ if (error instanceof ImageOAuthDeniedError) {
78
+ consumed = true;
79
+ reject(error);
80
+ }
81
+ }
82
+ });
83
+ server.headersTimeout = 15_000;
84
+ server.requestTimeout = 15_000;
85
+ try {
86
+ await new Promise<void>((resolve, reject) => {
87
+ server.once("error", reject);
88
+ server.listen(1455, "127.0.0.1", () => {
89
+ server.removeListener("error", reject);
90
+ resolve();
91
+ });
92
+ });
93
+ server.on("error", () => reject(new Error(`Image OAuth callback failed. ${LOGIN_HINT}`)));
94
+ return { server, code };
95
+ } catch {
96
+ server.close();
97
+ throw new Error("Image OAuth callback listener is unavailable.");
98
+ }
99
+ }
100
+
101
+ async function requestTokens(body: URLSearchParams, signal: AbortSignal): Promise<OAuthCredential> {
102
+ const deadline = AbortSignal.any([signal, AbortSignal.timeout(TOKEN_TIMEOUT_MS)]);
103
+ try {
104
+ const response = await abortable(fetch(TOKEN_URL, {
105
+ method: "POST", redirect: "error", signal: deadline,
106
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
107
+ body,
108
+ }), deadline);
109
+ if (!response.ok) {
110
+ void response.body?.cancel().catch(() => undefined);
111
+ throw new Error();
112
+ }
113
+ if (Number(response.headers.get("content-length")) > MAX_TOKEN_RESPONSE_BYTES || !response.body) {
114
+ void response.body?.cancel().catch(() => undefined);
115
+ throw new Error();
116
+ }
117
+ const reader = response.body.getReader();
118
+ const blocks: Uint8Array[] = [];
119
+ let length = 0;
120
+ try {
121
+ while (true) {
122
+ const { done, value } = await abortable(reader.read(), deadline);
123
+ if (done) break;
124
+ length += value.byteLength;
125
+ if (length > MAX_TOKEN_RESPONSE_BYTES) throw new Error();
126
+ blocks.push(value);
127
+ }
128
+ } finally {
129
+ void reader.cancel().catch(() => undefined);
130
+ reader.releaseLock();
131
+ }
132
+ const data = JSON.parse(Buffer.concat(blocks).toString("utf8"));
133
+ if (typeof data?.access_token !== "string" || !data.access_token
134
+ || typeof data.refresh_token !== "string" || !data.refresh_token
135
+ || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) throw new Error();
136
+ const expires = Date.now() + data.expires_in * 1000;
137
+ if (!Number.isSafeInteger(expires)) throw new Error();
138
+ const accountId = extractImageAccountId(data.access_token);
139
+ return { type: "oauth", access: data.access_token, refresh: data.refresh_token, expires, accountId };
140
+ } catch {
141
+ // Never expose token responses, refresh tokens, codes, or fetch exceptions.
142
+ if (signal.aborted) throw new Error("Image OAuth was cancelled.");
143
+ throw new Error(`Image OAuth token exchange failed. ${LOGIN_HINT}`);
144
+ }
145
+ }
146
+
147
+ async function login(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
148
+ const deadline = AbortSignal.any([interaction.signal, AbortSignal.timeout(LOGIN_TIMEOUT_MS)]);
149
+ const manualAbort = new AbortController();
150
+ let callback: Awaited<ReturnType<typeof startCallback>> | undefined;
151
+ try {
152
+ deadline.throwIfAborted();
153
+ const state = randomBytes(32).toString("base64url");
154
+ const verifier = randomBytes(32).toString("base64url");
155
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
156
+ try {
157
+ callback = await startCallback(state);
158
+ } catch {
159
+ interaction.notify({ type: "info", message: "The local callback port is unavailable. Paste the full redirect URL to finish image login." });
160
+ }
161
+ const url = new URL(AUTHORIZE_URL);
162
+ url.search = new URLSearchParams({
163
+ response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
164
+ scope: "openid profile email offline_access", state,
165
+ code_challenge: challenge, code_challenge_method: "S256",
166
+ id_token_add_organizations: "true", codex_cli_simplified_flow: "true",
167
+ originator: "pi",
168
+ }).toString();
169
+ interaction.notify({
170
+ type: "auth_url", url: url.href,
171
+ instructions: "Sign in with ChatGPT for image generation. This is separate from Pi's OpenAI chat login."
172
+ + (callback ? "" : " The local callback port is unavailable. Paste the full redirect URL into Pi to finish login."),
173
+ });
174
+ const manual = interaction.prompt({
175
+ type: "manual_code",
176
+ message: callback ? "Complete image login in your browser, or paste the full redirect URL:"
177
+ : "The local callback port is unavailable. Paste the full redirect URL:",
178
+ placeholder: REDIRECT_URI, signal: AbortSignal.any([deadline, manualAbort.signal]),
179
+ }).then(input => parseImageOAuthCallback(input, state));
180
+ const code = await abortable(callback ? Promise.race([callback.code, manual]) : manual, deadline);
181
+ manualAbort.abort();
182
+ return await requestTokens(new URLSearchParams({
183
+ grant_type: "authorization_code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
184
+ code, code_verifier: verifier,
185
+ }), deadline);
186
+ } catch (error) {
187
+ if (deadline.aborted) throw new Error("Image OAuth login was cancelled or timed out.");
188
+ throw error;
189
+ } finally {
190
+ manualAbort.abort();
191
+ callback?.server.close();
192
+ callback?.server.closeAllConnections();
193
+ }
194
+ }
195
+
196
+ export const imageOAuth: OAuthAuth = {
197
+ name: "Codex Images (ChatGPT subscription)",
198
+ isSubscription: true,
199
+ loginLabel: "Sign in with ChatGPT for images",
200
+ login,
201
+ async refresh(credential, signal) {
202
+ if (typeof credential.refresh !== "string" || !credential.refresh || credential.refresh.length > MAX_TOKEN_RESPONSE_BYTES) {
203
+ throw new Error(`Missing image OAuth refresh token. ${LOGIN_HINT}`);
204
+ }
205
+ return requestTokens(new URLSearchParams({
206
+ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: credential.refresh,
207
+ }), signal);
208
+ },
209
+ async toAuth(credential) {
210
+ extractImageAccountId(credential.access);
211
+ return { apiKey: credential.access };
212
+ },
213
+ };
214
+
215
+ // Authentication-only provider: no chat models or model-picker entries.
216
+ export const imageAuthProvider: Provider = {
217
+ id: IMAGE_AUTH_PROVIDER,
218
+ name: imageOAuth.name,
219
+ auth: { oauth: imageOAuth },
220
+ getModels: () => [],
221
+ stream() { throw new Error("Codex Images is an authentication-only provider. Use codex_generate_image."); },
222
+ streamSimple() { throw new Error("Codex Images is an authentication-only provider. Use codex_generate_image."); },
223
+ };
@@ -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
- }