@shanepadgett/tau-agent 0.5.1 → 0.7.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.
@@ -2,17 +2,21 @@ import {
2
2
  createReadToolDefinition,
3
3
  DEFAULT_MAX_BYTES,
4
4
  formatSize,
5
+ generateUnifiedPatch,
5
6
  truncateHead,
6
7
  type ReadToolDetails,
7
8
  type ToolDefinition,
8
9
  } from "@earendil-works/pi-coding-agent";
9
- import { Text } from "@earendil-works/pi-tui";
10
+ import { Container, Text } from "@earendil-works/pi-tui";
11
+ import { createHash } from "node:crypto";
10
12
  import { readFile } from "node:fs/promises";
11
13
  import { isAbsolute, resolve } from "node:path";
12
14
  import { Type, type Static } from "typebox";
13
15
  import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.js";
14
16
  import { normalizeCountLimit } from "./limits.ts";
15
17
  import { stripLeadingAt } from "./path-display.ts";
18
+ import { createReadCacheStore, type ReadCacheMetaV1, type ReadCacheStore } from "./read-cache.ts";
19
+ import { createReadSnapshotStore, type ReadSnapshotStore } from "./read-snapshots.ts";
16
20
 
17
21
  const readSchema = Type.Object({
18
22
  path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
@@ -23,11 +27,26 @@ const readSchema = Type.Object({
23
27
 
24
28
  type ReadToolInput = Static<typeof readSchema>;
25
29
  type BaseReadDefinition = ReturnType<typeof createReadToolDefinition>;
26
- type ReadDefinition = ToolDefinition<typeof readSchema, ReadToolDetails | undefined>;
30
+ interface ExploreReadDetails extends ReadToolDetails {
31
+ readCache?: ReadCacheMetaV1;
32
+ }
33
+ type ReadDefinition = ToolDefinition<typeof readSchema, ExploreReadDetails | undefined>;
27
34
  type ReadExecute = ReadDefinition["execute"];
28
35
  type ReadRenderCall = NonNullable<ReadDefinition["renderCall"]>;
29
36
  type ReadRenderResult = NonNullable<ReadDefinition["renderResult"]>;
30
37
 
38
+ interface BaselineTextResult {
39
+ text: string;
40
+ details: ReadToolDetails | undefined;
41
+ totalLines: number;
42
+ startLine: number;
43
+ endLine: number;
44
+ completeFile: boolean;
45
+ scopeKey: string;
46
+ summary: string;
47
+ cacheable: boolean;
48
+ }
49
+
31
50
  const readDefinitionByCwd = new Map<string, BaseReadDefinition>();
32
51
 
33
52
  function readDefinitionForCwd(cwd: string): BaseReadDefinition {
@@ -67,7 +86,114 @@ function renderCallSummary(args: ReadToolInput | undefined): string {
67
86
  return `${path}:${start}${end === "" ? "" : `-${end}`}`;
68
87
  }
69
88
 
70
- export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefinition {
89
+ function estimateTokens(text: string): number {
90
+ return Math.ceil(text.length / 4);
91
+ }
92
+
93
+ function baselineText(text: string, params: ReadToolInput): BaselineTextResult {
94
+ const allLines = text.split("\n");
95
+ const startIndex = params.offset ? Math.max(0, params.offset - 1) : 0;
96
+ const startLine = startIndex + 1;
97
+ if (startIndex >= allLines.length) {
98
+ throw new Error(`Offset ${params.offset} is beyond end of file (${allLines.length} lines total)`);
99
+ }
100
+
101
+ const selectedEnd =
102
+ params.limit === undefined ? allLines.length : Math.min(startIndex + params.limit, allLines.length);
103
+ const selectedLines = allLines.slice(startIndex, selectedEnd);
104
+ const selectedContent = selectedLines
105
+ .map((line, index) => (params.lineNumbers ? `${startLine + index}: ${line}` : line))
106
+ .join("\n");
107
+ const truncation = truncateHead(selectedContent);
108
+ let outputText: string;
109
+ let details: ReadToolDetails | undefined;
110
+ let outputLines = selectedLines.length;
111
+ let cacheable = true;
112
+
113
+ if (truncation.firstLineExceedsLimit) {
114
+ const firstLineSize = formatSize(Buffer.byteLength(selectedLines[0] ?? "", "utf-8"));
115
+ outputText = `[Line ${startLine} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLine}p' ${params.path} | head -c ${DEFAULT_MAX_BYTES}]`;
116
+ details = { truncation };
117
+ outputLines = 0;
118
+ cacheable = false;
119
+ } else if (truncation.truncated) {
120
+ outputLines = truncation.outputLines;
121
+ const endLineDisplay = startLine + outputLines - 1;
122
+ const nextOffset = endLineDisplay + 1;
123
+ outputText = truncation.content;
124
+ outputText +=
125
+ truncation.truncatedBy === "lines"
126
+ ? `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${allLines.length}. Use offset=${nextOffset} to continue.]`
127
+ : `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${allLines.length} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
128
+ details = { truncation };
129
+ } else if (selectedEnd < allLines.length) {
130
+ outputText = `${truncation.content}\n\n[${allLines.length - selectedEnd} more lines in file. Use offset=${selectedEnd + 1} to continue.]`;
131
+ } else {
132
+ outputText = truncation.content;
133
+ }
134
+
135
+ const endLine = outputLines === 0 ? startLine : startLine + outputLines - 1;
136
+ const completeFile = startIndex === 0 && selectedEnd === allLines.length && !truncation.truncated;
137
+ const scopeKey = `${completeFile ? "full" : `r:${startLine}:${endLine}`}:n${params.lineNumbers ? 1 : 0}`;
138
+ const summary = completeFile ? `${allLines.length} lines` : `${outputLines} lines`;
139
+ return {
140
+ text: outputText,
141
+ details,
142
+ totalLines: allLines.length,
143
+ startLine,
144
+ endLine,
145
+ completeFile,
146
+ scopeKey,
147
+ summary,
148
+ cacheable,
149
+ };
150
+ }
151
+
152
+ function withMeta(baseline: BaselineTextResult, meta: ReadCacheMetaV1, text = baseline.text) {
153
+ return {
154
+ content: [{ type: "text" as const, text }],
155
+ details: { ...baseline.details, readCache: meta },
156
+ };
157
+ }
158
+
159
+ function createMeta(
160
+ baseline: BaselineTextResult,
161
+ pathKey: string,
162
+ hash: string,
163
+ mode: ReadCacheMetaV1["mode"],
164
+ returnedText: string,
165
+ baseHash?: string,
166
+ summary = baseline.summary,
167
+ ): ReadCacheMetaV1 {
168
+ return {
169
+ v: 1,
170
+ pathKey,
171
+ scopeKey: baseline.scopeKey,
172
+ servedHash: hash,
173
+ baseHash,
174
+ mode,
175
+ baselineTokens: estimateTokens(baseline.text),
176
+ returnedTokens: estimateTokens(returnedText),
177
+ totalLines: baseline.totalLines,
178
+ summary,
179
+ };
180
+ }
181
+
182
+ function countDiffLines(patch: string): { added: number; removed: number } {
183
+ let added = 0;
184
+ let removed = 0;
185
+ for (const line of patch.split("\n")) {
186
+ if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
187
+ else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
188
+ }
189
+ return { added, removed };
190
+ }
191
+
192
+ export function createExploreReadTool(
193
+ rowState: ToolRowStateStore,
194
+ cache: ReadCacheStore = createReadCacheStore(),
195
+ snapshots: ReadSnapshotStore = createReadSnapshotStore(),
196
+ ): ReadDefinition {
71
197
  const baseDefinition = readDefinitionForCwd(process.cwd());
72
198
  return {
73
199
  ...baseDefinition,
@@ -81,7 +207,7 @@ export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefiniti
81
207
  ) {
82
208
  const definition = readDefinitionForCwd(ctx.cwd);
83
209
  const normalized = normalizeReadParams(params);
84
- const path = isAbsolute(normalized.path) ? normalized.path : resolve(ctx.cwd, normalized.path);
210
+ const path = isAbsolute(normalized.path) ? resolve(normalized.path) : resolve(ctx.cwd, normalized.path);
85
211
  const buffer = await readFile(path);
86
212
  if (isSupportedImage(buffer)) {
87
213
  return definition.execute(
@@ -94,42 +220,49 @@ export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefiniti
94
220
  }
95
221
 
96
222
  if (signal?.aborted) throw new Error("Operation aborted");
97
- const allLines = buffer.toString("utf-8").split("\n");
98
- const startLine = normalized.offset ? Math.max(0, normalized.offset - 1) : 0;
99
- const startLineDisplay = startLine + 1;
100
- if (startLine >= allLines.length) {
101
- throw new Error(`Offset ${normalized.offset} is beyond end of file (${allLines.length} lines total)`);
223
+ let text: string;
224
+ try {
225
+ text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
226
+ } catch {
227
+ return definition.execute(toolCallId, normalized, signal, onUpdate, ctx);
228
+ }
229
+
230
+ const baseline = baselineText(text, normalized);
231
+ if (!baseline.cacheable) {
232
+ return { content: [{ type: "text", text: baseline.text }], details: baseline.details };
102
233
  }
234
+ const hash = createHash("sha256").update(buffer).digest("hex");
235
+ const decision = cache.decision(ctx, path, baseline.scopeKey);
236
+ let output = baseline.text;
237
+ let mode: ReadCacheMetaV1["mode"] = decision.recovery ? "recovery" : "baseline";
238
+ let summary = baseline.summary;
103
239
 
104
- const endLine =
105
- normalized.limit === undefined ? allLines.length : Math.min(startLine + normalized.limit, allLines.length);
106
- const selectedLines = allLines.slice(startLine, endLine);
107
- const selectedContent = selectedLines
108
- .map((line, index) => (normalized.lineNumbers ? `${startLineDisplay + index}: ${line}` : line))
109
- .join("\n");
110
- const truncation = truncateHead(selectedContent);
111
- let outputText: string;
112
- let details: ReadToolDetails | undefined;
113
- if (truncation.firstLineExceedsLimit) {
114
- const firstLineSize = formatSize(Buffer.byteLength(selectedLines[0] ?? "", "utf-8"));
115
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${normalized.path} | head -c ${DEFAULT_MAX_BYTES}]`;
116
- details = { truncation };
117
- } else if (truncation.truncated) {
118
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
119
- const nextOffset = endLineDisplay + 1;
120
- outputText = truncation.content;
121
- outputText +=
122
- truncation.truncatedBy === "lines"
123
- ? `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${allLines.length}. Use offset=${nextOffset} to continue.]`
124
- : `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${allLines.length} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
125
- details = { truncation };
126
- } else if (endLine < allLines.length) {
127
- outputText = `${truncation.content}\n\n[${allLines.length - endLine} more lines in file. Use offset=${endLine + 1} to continue.]`;
128
- } else {
129
- outputText = truncation.content;
240
+ if (!decision.recovery && decision.baseHash === hash) {
241
+ output = baseline.completeFile
242
+ ? `unchanged, ${baseline.totalLines} lines`
243
+ : `unchanged, lines ${baseline.startLine}-${baseline.endLine} of ${baseline.totalLines}`;
244
+ mode = "unchanged";
245
+ summary = output;
246
+ } else if (!decision.recovery && decision.baseHash && baseline.completeFile && !normalized.lineNumbers) {
247
+ const baseText = snapshots.get(decision.baseHash);
248
+ if (baseText !== undefined) {
249
+ const patch = generateUnifiedPatch(normalized.path, baseText, text, 3);
250
+ const counts = countDiffLines(patch);
251
+ const candidate = `[read: ${counts.added} lines added, ${counts.removed} removed of ${baseline.totalLines}]\n${patch}`;
252
+ const candidateTruncation = truncateHead(candidate);
253
+ if (!candidateTruncation.truncated && estimateTokens(candidate) < estimateTokens(baseline.text)) {
254
+ output = candidate;
255
+ mode = "diff";
256
+ summary = `+${counts.added} -${counts.removed}`;
257
+ }
258
+ }
130
259
  }
131
260
 
132
- return { content: [{ type: "text", text: outputText }], details };
261
+ if (signal?.aborted) throw new Error("Operation aborted");
262
+ snapshots.set(hash, text, buffer.byteLength);
263
+ const meta = createMeta(baseline, path, hash, mode, output, decision.baseHash, summary);
264
+ cache.record(ctx, meta);
265
+ return withMeta(baseline, meta, output);
133
266
  },
134
267
  renderCall(
135
268
  args: Parameters<ReadRenderCall>[0],
@@ -138,6 +271,10 @@ export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefiniti
138
271
  ) {
139
272
  rowState.watch(context.toolCallId, context.invalidate);
140
273
  const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
274
+ if (context.executionStarted) {
275
+ text.setText("");
276
+ return text;
277
+ }
141
278
  const title = formatToolRowTitle(rowState, context.toolCallId, "read", theme);
142
279
  text.setText(`${title} ${theme.fg("muted", renderCallSummary(args))}`);
143
280
  return text;
@@ -148,13 +285,32 @@ export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefiniti
148
285
  theme: Parameters<ReadRenderResult>[2],
149
286
  context: Parameters<ReadRenderResult>[3],
150
287
  ) {
151
- if (!context.expanded) {
152
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
153
- text.setText("");
154
- return text;
288
+ rowState.watch(context.toolCallId, context.invalidate);
289
+ const container = new Container();
290
+ const title = formatToolRowTitle(rowState, context.toolCallId, "read", theme);
291
+ const args = context.args as ReadToolInput | undefined;
292
+ const details = result.details as ExploreReadDetails | undefined;
293
+ const summary = details?.readCache?.summary;
294
+ const summaryText = context.isError
295
+ ? theme.fg("error", "error")
296
+ : summary
297
+ ? theme.fg(
298
+ details?.readCache?.mode === "unchanged"
299
+ ? "success"
300
+ : details?.readCache?.mode === "diff"
301
+ ? "accent"
302
+ : "muted",
303
+ summary,
304
+ )
305
+ : "";
306
+ const header = `${title} ${theme.fg("muted", renderCallSummary(args))}${summaryText ? ` ${summaryText}` : ""}`;
307
+ container.addChild(new Text(header, 0, 0));
308
+ if (options.expanded) {
309
+ const definition = readDefinitionForCwd(context.cwd);
310
+ const body = definition.renderResult?.(result, { ...options, expanded: true }, theme, context);
311
+ if (body) container.addChild(body);
155
312
  }
156
- const definition = readDefinitionForCwd(context.cwd);
157
- return definition.renderResult?.(result, { ...options, expanded: true }, theme, context) ?? new Text("", 0, 0);
313
+ return container;
158
314
  },
159
315
  };
160
316
  }
@@ -1,11 +1,11 @@
1
1
  # Image Generation
2
2
 
3
- `image_gen` generates raster images and edits up to five local raster images with OpenAI Codex. It uses the current `gpt-image-2` model and saves results under `~/.local/share/tau-agent/images/` by default. Pass an explicit PNG path when the image should be saved in the current repository or another chosen location.
3
+ `image_gen` generates raster images and edits up to three local raster images with Grok Imagine. It uses `grok-imagine-image-quality` and saves results under `~/.local/share/tau-agent/images/` by default. Pass an explicit path with the expected image extension when the image should be saved in the current repository or another chosen location.
4
4
 
5
- Use `/login` and select OpenAI Codex before invoking the tool. No `OPENAI_API_KEY` is needed.
5
+ Use `/login` and select xAI (Grok subscription OAuth) before invoking the tool. No xAI API key is used.
6
6
 
7
7
  Run `/reload` after installing or changing the extension.
8
8
 
9
- The model invokes `image_gen` with a prompt. Size, quality, and background use the Codex automatic settings. For edits, it also supplies one to five local PNG, JPEG, or WebP paths. Successful PNGs up to 12 MiB are returned inline for inspection; larger results remain available at the saved path.
9
+ The model invokes `image_gen` with a prompt. For edits, it also supplies one to three local PNG, JPEG, or WebP paths. Successful images up to 12 MiB are returned inline for inspection; larger results remain available at the saved path.
10
10
 
11
- This extension calls a private Codex backend. OpenAI may change its availability or protocol without notice.
11
+ This extension uses xAI's undocumented subscription OAuth access. xAI may change its availability, entitlement rules, or protocol without notice.
@@ -1,60 +1,34 @@
1
- const CODEX_IMAGES_URL = "https://chatgpt.com/backend-api/codex/images";
2
- const MODEL = "gpt-image-2";
3
- const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
1
+ import { XAI_API_BASE_URL, XAI_IMAGE_MODEL } from "../xai/constants.ts";
2
+
4
3
  const MAX_ERROR_BODY_BYTES = 8192;
5
4
  const MAX_ERROR_MESSAGE_LENGTH = 2000;
6
-
7
- interface FetchResponse {
8
- ok: boolean;
9
- status: number;
10
- body: ReadableStream<Uint8Array> | null;
11
- json(): Promise<unknown>;
12
- }
13
-
14
- export interface CodexAuth {
15
- token: string;
16
- accountId: string;
17
- }
5
+ const REQUEST_TIMEOUT_MS = 60_000;
6
+ const MAX_ATTEMPTS = 3;
7
+ const RETRY_BASE_DELAY_MS = 500;
18
8
 
19
9
  export interface EditImage {
20
10
  mimeType: "image/png" | "image/jpeg" | "image/webp";
21
11
  data: string;
22
12
  }
23
13
 
24
- interface GeneratedImage {
14
+ export interface GeneratedImage {
25
15
  bytes: Buffer;
26
16
  base64: string;
27
- mimeType: "image/png";
17
+ mimeType: EditImage["mimeType"];
28
18
  }
29
19
 
30
- function isRecord(value: unknown): value is Record<string, unknown> {
31
- return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ interface HttpResponse {
21
+ ok: boolean;
22
+ status: number;
23
+ body: ReadableStream<Uint8Array> | null;
24
+ json(): Promise<unknown>;
32
25
  }
33
26
 
34
- export function resolveCodexAuth(token: string): CodexAuth {
35
- const invalidCredential = () =>
36
- new Error("The OpenAI Codex credential does not contain a usable ChatGPT account ID. Run /login again.");
37
- const segments = token.split(".");
38
- if (segments.length !== 3 || !segments[1]) throw invalidCredential();
39
- if (!/^[A-Za-z0-9_-]+$/.test(segments[1]) || segments[1].length % 4 === 1) throw invalidCredential();
40
-
41
- let payload: unknown;
42
- try {
43
- const decoded = Buffer.from(segments[1], "base64url");
44
- if (decoded.toString("base64url") !== segments[1]) throw invalidCredential();
45
- payload = JSON.parse(decoded.toString("utf8"));
46
- } catch {
47
- throw invalidCredential();
48
- }
49
- if (!isRecord(payload)) throw invalidCredential();
50
- const authClaim = payload["https://api.openai.com/auth"];
51
- if (!isRecord(authClaim)) throw invalidCredential();
52
- const accountId = authClaim.chatgpt_account_id;
53
- if (typeof accountId !== "string" || !accountId.trim()) throw invalidCredential();
54
- return { token, accountId: accountId.trim() };
27
+ function isRecord(value: unknown): value is Record<string, unknown> {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
55
29
  }
56
30
 
57
- async function readBoundedError(response: FetchResponse): Promise<string> {
31
+ async function boundedError(response: HttpResponse): Promise<string> {
58
32
  if (!response.body) return "";
59
33
  const reader = response.body.getReader();
60
34
  const chunks: Uint8Array[] = [];
@@ -63,8 +37,7 @@ async function readBoundedError(response: FetchResponse): Promise<string> {
63
37
  while (length < MAX_ERROR_BODY_BYTES) {
64
38
  const result = await reader.read();
65
39
  if (result.done) break;
66
- const remaining = MAX_ERROR_BODY_BYTES - length;
67
- const chunk = result.value.subarray(0, remaining);
40
+ const chunk = result.value.subarray(0, MAX_ERROR_BODY_BYTES - length);
68
41
  chunks.push(chunk);
69
42
  length += chunk.length;
70
43
  if (chunk.length < result.value.length) break;
@@ -72,23 +45,21 @@ async function readBoundedError(response: FetchResponse): Promise<string> {
72
45
  } finally {
73
46
  await reader.cancel().catch(() => undefined);
74
47
  }
75
- const bytes = new Uint8Array(length);
76
- let offset = 0;
77
- for (const chunk of chunks) {
78
- bytes.set(chunk, offset);
79
- offset += chunk.length;
80
- }
81
- return new TextDecoder().decode(bytes);
48
+ return Buffer.concat(
49
+ chunks.map((chunk) => Buffer.from(chunk)),
50
+ length,
51
+ )
52
+ .toString()
53
+ .trim();
82
54
  }
83
55
 
84
56
  function serverErrorMessage(body: string, token: string): string {
85
- let message = body.trim();
57
+ let message = body;
86
58
  try {
87
- const parsed: unknown = JSON.parse(message);
88
- if (isRecord(parsed)) {
89
- const error = parsed.error;
90
- if (isRecord(error) && typeof error.message === "string") message = error.message;
91
- else if (typeof parsed.message === "string") message = parsed.message;
59
+ const value: unknown = JSON.parse(body);
60
+ if (isRecord(value)) {
61
+ if (isRecord(value.error) && typeof value.error.message === "string") message = value.error.message;
62
+ else if (typeof value.message === "string") message = value.message;
92
63
  }
93
64
  } catch {
94
65
  // Plain-text error body.
@@ -96,70 +67,117 @@ function serverErrorMessage(body: string, token: string): string {
96
67
  return message.replaceAll(token, "[redacted]").slice(0, MAX_ERROR_MESSAGE_LENGTH).trim();
97
68
  }
98
69
 
70
+ export function detectImageMimeType(bytes: Buffer): GeneratedImage["mimeType"] | undefined {
71
+ if (
72
+ bytes.length >= 8 &&
73
+ bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
74
+ ) {
75
+ return "image/png";
76
+ }
77
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
78
+ if (
79
+ bytes.length >= 12 &&
80
+ bytes.subarray(0, 4).toString("ascii") === "RIFF" &&
81
+ bytes.subarray(8, 12).toString("ascii") === "WEBP"
82
+ ) {
83
+ return "image/webp";
84
+ }
85
+ return undefined;
86
+ }
87
+
99
88
  function decodeImageResponse(value: unknown): GeneratedImage {
100
- if (!isRecord(value) || !Array.isArray(value.data) || value.data.length === 0 || !isRecord(value.data[0])) {
101
- throw new Error("OpenAI Codex returned an invalid image response");
89
+ if (!isRecord(value) || !Array.isArray(value.data) || !isRecord(value.data[0])) {
90
+ throw new Error("xAI returned an invalid image response");
102
91
  }
103
92
  const encoded = value.data[0].b64_json;
104
- if (typeof encoded !== "string" || !encoded.trim()) {
105
- throw new Error("OpenAI Codex returned an invalid image response");
106
- }
93
+ if (typeof encoded !== "string" || !encoded.trim()) throw new Error("xAI returned an invalid image response");
107
94
  const base64 = encoded.trim();
108
95
  if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64) || base64.length % 4 === 1) {
109
- throw new Error("OpenAI Codex returned invalid base64 image data");
96
+ throw new Error("xAI returned invalid base64 image data");
110
97
  }
111
98
  const bytes = Buffer.from(base64, "base64");
112
99
  if (bytes.toString("base64").replace(/=+$/, "") !== base64.replace(/=+$/, "")) {
113
- throw new Error("OpenAI Codex returned invalid base64 image data");
114
- }
115
- if (bytes.length < PNG_SIGNATURE.length || !bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
116
- throw new Error("OpenAI Codex returned image data that is not PNG");
100
+ throw new Error("xAI returned invalid base64 image data");
117
101
  }
118
- return { bytes, base64: bytes.toString("base64"), mimeType: "image/png" };
102
+ const mimeType = detectImageMimeType(bytes);
103
+ if (!mimeType) throw new Error("xAI returned unsupported image data");
104
+ return { bytes, base64: bytes.toString("base64"), mimeType };
105
+ }
106
+
107
+ function retryable(status: number): boolean {
108
+ return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
109
+ }
110
+
111
+ async function delay(milliseconds: number, signal?: AbortSignal): Promise<void> {
112
+ await new Promise<void>((resolve, reject) => {
113
+ const timeout = setTimeout(() => {
114
+ signal?.removeEventListener("abort", abort);
115
+ resolve();
116
+ }, milliseconds);
117
+ const abort = () => {
118
+ clearTimeout(timeout);
119
+ signal?.removeEventListener("abort", abort);
120
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
121
+ };
122
+ if (signal?.aborted) abort();
123
+ else signal?.addEventListener("abort", abort, { once: true });
124
+ });
119
125
  }
120
126
 
121
127
  async function requestImage(
122
128
  operation: "generation" | "edit",
123
129
  body: Record<string, unknown>,
124
- auth: CodexAuth,
125
- signal: AbortSignal | undefined,
130
+ token: string,
131
+ signal?: AbortSignal,
126
132
  ): Promise<GeneratedImage> {
127
133
  const route = operation === "generation" ? "generations" : "edits";
128
- const response = (await fetch(`${CODEX_IMAGES_URL}/${route}`, {
129
- method: "POST",
130
- headers: {
131
- Authorization: `Bearer ${auth.token}`,
132
- "chatgpt-account-id": auth.accountId,
133
- originator: "pi",
134
- Accept: "application/json",
135
- "Content-Type": "application/json",
136
- },
137
- body: JSON.stringify(body),
138
- signal,
139
- })) as FetchResponse;
140
- if (!response.ok) {
141
- const message = serverErrorMessage(await readBoundedError(response), auth.token);
142
- throw new Error(`Image ${operation} failed with status ${response.status}${message ? `: ${message}` : ""}`);
143
- }
144
-
145
- let value: unknown;
146
- try {
147
- value = await response.json();
148
- } catch {
149
- throw new Error("OpenAI Codex returned a non-JSON image response");
134
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
135
+ const requestSignal = signal
136
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
137
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
138
+ let response: HttpResponse;
139
+ try {
140
+ response = (await fetch(`${XAI_API_BASE_URL}/images/${route}`, {
141
+ method: "POST",
142
+ headers: {
143
+ Accept: "application/json",
144
+ Authorization: `Bearer ${token}`,
145
+ "Content-Type": "application/json",
146
+ },
147
+ body: JSON.stringify(body),
148
+ signal: requestSignal,
149
+ })) as HttpResponse;
150
+ } catch (error) {
151
+ if (signal?.aborted) throw signal.reason;
152
+ if (attempt === MAX_ATTEMPTS) throw error;
153
+ await delay(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), signal);
154
+ continue;
155
+ }
156
+ if (response.ok) {
157
+ let value: unknown;
158
+ try {
159
+ value = await response.json();
160
+ } catch {
161
+ throw new Error("xAI returned a non-JSON image response");
162
+ }
163
+ return decodeImageResponse(value);
164
+ }
165
+ const message = serverErrorMessage(await boundedError(response), token);
166
+ if (!retryable(response.status) || attempt === MAX_ATTEMPTS) {
167
+ throw new Error(
168
+ `xAI image ${operation} failed with status ${response.status}${message ? `: ${message}` : ""}`,
169
+ );
170
+ }
171
+ await delay(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), signal);
150
172
  }
151
- return decodeImageResponse(value);
173
+ throw new Error(`xAI image ${operation} failed`);
152
174
  }
153
175
 
154
- export function generateImage(
155
- prompt: string,
156
- auth: CodexAuth,
157
- signal: AbortSignal | undefined,
158
- ): Promise<GeneratedImage> {
176
+ export function generateImage(prompt: string, token: string, signal?: AbortSignal): Promise<GeneratedImage> {
159
177
  return requestImage(
160
178
  "generation",
161
- { prompt, model: MODEL, background: "auto", quality: "auto", size: "auto" },
162
- auth,
179
+ { model: XAI_IMAGE_MODEL, prompt, n: 1, resolution: "1k", response_format: "b64_json" },
180
+ token,
163
181
  signal,
164
182
  );
165
183
  }
@@ -167,20 +185,21 @@ export function generateImage(
167
185
  export function editImage(
168
186
  prompt: string,
169
187
  images: readonly EditImage[],
170
- auth: CodexAuth,
171
- signal: AbortSignal | undefined,
188
+ token: string,
189
+ signal?: AbortSignal,
172
190
  ): Promise<GeneratedImage> {
191
+ const references = images.map((image) => ({ url: `data:${image.mimeType};base64,${image.data}` }));
173
192
  return requestImage(
174
193
  "edit",
175
194
  {
176
- images: images.map((image) => ({ image_url: `data:${image.mimeType};base64,${image.data}` })),
195
+ model: XAI_IMAGE_MODEL,
177
196
  prompt,
178
- model: MODEL,
179
- background: "auto",
180
- quality: "auto",
181
- size: "auto",
197
+ n: 1,
198
+ resolution: "1k",
199
+ response_format: "b64_json",
200
+ ...(references.length === 1 ? { image: references[0] } : { images: references }),
182
201
  },
183
- auth,
202
+ token,
184
203
  signal,
185
204
  );
186
205
  }