@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.
- package/docs/subagents.md +1 -2
- package/extensions/auto-name/index.ts +1 -1
- package/extensions/commit/commit-plan.ts +12 -5
- package/extensions/context/README.md +1 -1
- package/extensions/context/definitions.ts +2 -142
- package/extensions/context/index.ts +67 -387
- package/extensions/context/panel.ts +43 -108
- package/extensions/context/sync.ts +693 -0
- package/extensions/explore/README.md +2 -2
- package/extensions/explore/autoread.ts +20 -3
- package/extensions/explore/index.ts +23 -2
- package/extensions/explore/read-cache.ts +209 -0
- package/extensions/explore/read-snapshots.ts +44 -0
- package/extensions/explore/read-stats-panel.ts +154 -0
- package/extensions/explore/read-stats.ts +120 -0
- package/extensions/explore/read.ts +198 -42
- package/extensions/image-gen/README.md +4 -4
- package/extensions/image-gen/client.ts +126 -107
- package/extensions/image-gen/index.ts +44 -35
- package/extensions/subagent/README.md +0 -1
- package/extensions/subagent/agents.ts +0 -4
- package/extensions/tau-help/help.md +7 -3
- package/extensions/xai/README.md +7 -0
- package/extensions/xai/auth.ts +40 -0
- package/extensions/xai/constants.ts +11 -0
- package/extensions/xai/index.ts +38 -0
- package/extensions/xai/oauth.ts +342 -0
- package/extensions/xai/payload.ts +68 -0
- package/package.json +2 -2
- package/shared/git.ts +2 -2
- package/shared/model-fallback/index.ts +17 -12
- package/extensions/subagent/agents/context-maintenance.md +0 -23
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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: "
|
|
17
|
+
mimeType: EditImage["mimeType"];
|
|
28
18
|
}
|
|
29
19
|
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
interface HttpResponse {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
status: number;
|
|
23
|
+
body: ReadableStream<Uint8Array> | null;
|
|
24
|
+
json(): Promise<unknown>;
|
|
32
25
|
}
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
57
|
+
let message = body;
|
|
86
58
|
try {
|
|
87
|
-
const
|
|
88
|
-
if (isRecord(
|
|
89
|
-
|
|
90
|
-
if (
|
|
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) ||
|
|
101
|
-
throw new Error("
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
signal
|
|
130
|
+
token: string,
|
|
131
|
+
signal?: AbortSignal,
|
|
126
132
|
): Promise<GeneratedImage> {
|
|
127
133
|
const route = operation === "generation" ? "generations" : "edits";
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
162
|
-
|
|
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
|
-
|
|
171
|
-
signal
|
|
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
|
-
|
|
195
|
+
model: XAI_IMAGE_MODEL,
|
|
177
196
|
prompt,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
197
|
+
n: 1,
|
|
198
|
+
resolution: "1k",
|
|
199
|
+
response_format: "b64_json",
|
|
200
|
+
...(references.length === 1 ? { image: references[0] } : { images: references }),
|
|
182
201
|
},
|
|
183
|
-
|
|
202
|
+
token,
|
|
184
203
|
signal,
|
|
185
204
|
);
|
|
186
205
|
}
|