open-grok-build 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/package.json +79 -0
- package/src/auth/oauth.ts +529 -0
- package/src/index.ts +5 -0
- package/src/models/catalog.ts +149 -0
- package/src/opencode/billing.ts +57 -0
- package/src/opencode/collectGrokTools.ts +18 -0
- package/src/opencode/grokModels.ts +72 -0
- package/src/opencode/grokToolSchemas.ts +88 -0
- package/src/opencode/plugin.ts +227 -0
- package/src/opencode/tui.tsx +64 -0
- package/src/opencode/usage.ts +82 -0
- package/src/opencode/usageToast.ts +9 -0
- package/src/opencode/version.ts +2 -0
- package/src/payload/sanitize.ts +320 -0
- package/src/shared/errors.ts +44 -0
- package/src/tools/files.ts +538 -0
- package/src/tools/register.ts +31 -0
- package/src/tools/rendering.ts +260 -0
- package/src/tools/search.ts +195 -0
- package/src/tools/shell.ts +142 -0
- package/src/tools/types.ts +29 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload sanitization for xAI's Responses API via cli-chat-proxy.grok.com.
|
|
3
|
+
*
|
|
4
|
+
* xAI's endpoint has quirks compared to stock OpenAI:
|
|
5
|
+
* - Replayed `reasoning` items in input cause 400 errors.
|
|
6
|
+
* - `reasoning.effort` is only supported on a subset of models.
|
|
7
|
+
* - Empty-string content items cause validation failures.
|
|
8
|
+
* - `function_call_output.output` cannot contain image arrays.
|
|
9
|
+
* - `image_url` parts must be normalized to `input_image` with data URIs.
|
|
10
|
+
* - Local image paths must be resolved to base64 data URIs.
|
|
11
|
+
* - xAI rejects `role: "developer"` and `role: "system"` in the input
|
|
12
|
+
* array; these must be moved to top-level `instructions`.
|
|
13
|
+
* - xAI uses `text.format` instead of OpenAI's `response_format`.
|
|
14
|
+
* - xAI uses `prompt_cache_key` for conversation caching.
|
|
15
|
+
* - xAI doesn't support `prompt_cache_retention`.
|
|
16
|
+
*
|
|
17
|
+
* Additional Grok Build-specific behavior:
|
|
18
|
+
* - Adds x-grok-* headers for client identification
|
|
19
|
+
* - Uses prompt_cache_key for session affinity
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
23
|
+
import { extname, isAbsolute, resolve, sep } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { supportsReasoningEffort } from '../models/catalog.js';
|
|
26
|
+
|
|
27
|
+
// ─── Content text extraction ─────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function textFromContent(content: unknown): string {
|
|
30
|
+
if (typeof content === 'string') return content;
|
|
31
|
+
if (!Array.isArray(content)) return '';
|
|
32
|
+
return content
|
|
33
|
+
.map((part) => {
|
|
34
|
+
if (typeof part === 'string') return part;
|
|
35
|
+
if (!part || typeof part !== 'object') return '';
|
|
36
|
+
const item = part as Record<string, unknown>;
|
|
37
|
+
const type = typeof item.type === 'string' ? item.type : '';
|
|
38
|
+
return ['text', 'input_text', 'output_text'].includes(type) && typeof item.text === 'string'
|
|
39
|
+
? item.text
|
|
40
|
+
: '';
|
|
41
|
+
})
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Image helpers ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function stripShellQuotes(value: string): string {
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
if (
|
|
51
|
+
trimmed.length >= 2 &&
|
|
52
|
+
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
53
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")))
|
|
54
|
+
) {
|
|
55
|
+
return trimmed.slice(1, -1);
|
|
56
|
+
}
|
|
57
|
+
return trimmed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unescapeShellPath(value: string): string {
|
|
61
|
+
return stripShellQuotes(value).replace(/\\([\\\s'"()&;@])/g, '$1');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function imageMimeTypeForPath(path: string): string {
|
|
65
|
+
switch (extname(path).toLowerCase()) {
|
|
66
|
+
case '.jpg':
|
|
67
|
+
case '.jpeg':
|
|
68
|
+
return 'image/jpeg';
|
|
69
|
+
case '.png':
|
|
70
|
+
return 'image/png';
|
|
71
|
+
default:
|
|
72
|
+
throw new Error('xAI image understanding supports local .jpg, .jpeg, and .png files only');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ensurePathWithinWorkspace(cwd: string, filePath: string) {
|
|
77
|
+
const realCwd = realpathSync(cwd);
|
|
78
|
+
const realPath = realpathSync(filePath);
|
|
79
|
+
if (realPath !== realCwd && !realPath.startsWith(`${realCwd}${sep}`)) {
|
|
80
|
+
throw new Error('Image path is outside the workspace');
|
|
81
|
+
}
|
|
82
|
+
return realPath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveLocalImagePath(value: string, cwd: string): string | undefined {
|
|
86
|
+
const cleaned = unescapeShellPath(value);
|
|
87
|
+
if (!cleaned) return undefined;
|
|
88
|
+
|
|
89
|
+
if (cleaned.startsWith('file://')) {
|
|
90
|
+
try {
|
|
91
|
+
const filePath = fileURLToPath(cleaned);
|
|
92
|
+
return existsSync(filePath) ? ensurePathWithinWorkspace(cwd, filePath) : undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const candidate = isAbsolute(cleaned) ? cleaned : resolve(cwd, cleaned);
|
|
99
|
+
|
|
100
|
+
return existsSync(candidate) ? ensurePathWithinWorkspace(cwd, candidate) : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeImageInput(value: unknown, cwd: string): string | undefined {
|
|
104
|
+
if (typeof value !== 'string' || !value.trim()) return undefined;
|
|
105
|
+
const cleaned = stripShellQuotes(value);
|
|
106
|
+
|
|
107
|
+
if (/^https?:\/\//i.test(cleaned) || /^data:image\//i.test(cleaned)) {
|
|
108
|
+
return cleaned;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const localPath = resolveLocalImagePath(cleaned, cwd);
|
|
112
|
+
if (!localPath) {
|
|
113
|
+
throw new Error(`Image file does not exist or is not a valid URL: ${cleaned}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const mimeType = imageMimeTypeForPath(localPath);
|
|
117
|
+
const data = readFileSync(localPath).toString('base64');
|
|
118
|
+
return `data:${mimeType};base64,${data}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Content part normalization ───────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function isInputImagePart(value: unknown): value is Record<string, unknown> {
|
|
124
|
+
return (
|
|
125
|
+
!!value &&
|
|
126
|
+
typeof value === 'object' &&
|
|
127
|
+
(value as Record<string, unknown>).type === 'input_image'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getImageUrlAndDetail(obj: Record<string, unknown>): {
|
|
132
|
+
imageUrl: unknown;
|
|
133
|
+
detail: unknown;
|
|
134
|
+
} {
|
|
135
|
+
if (typeof obj.image_url === 'object' && obj.image_url) {
|
|
136
|
+
const imageUrl = obj.image_url as Record<string, unknown>;
|
|
137
|
+
return { imageUrl: imageUrl.url, detail: imageUrl.detail };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { imageUrl: obj.image_url, detail: obj.detail };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizeImageParts(value: unknown, cwd: string): unknown {
|
|
144
|
+
if (Array.isArray(value)) return value.map((item) => normalizeImageParts(item, cwd));
|
|
145
|
+
if (!value || typeof value !== 'object') return value;
|
|
146
|
+
|
|
147
|
+
const obj = { ...(value as Record<string, unknown>) };
|
|
148
|
+
|
|
149
|
+
if (obj.type === 'image' && typeof obj.data === 'string' && typeof obj.mimeType === 'string') {
|
|
150
|
+
return {
|
|
151
|
+
type: 'input_image',
|
|
152
|
+
image_url: `data:${obj.mimeType};base64,${obj.data}`,
|
|
153
|
+
detail: typeof obj.detail === 'string' && obj.detail ? obj.detail : 'auto',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (obj.type === 'image_url') {
|
|
158
|
+
const { imageUrl, detail } = getImageUrlAndDetail(obj);
|
|
159
|
+
obj.type = 'input_image';
|
|
160
|
+
obj.image_url = imageUrl;
|
|
161
|
+
if (typeof detail === 'string' && detail) obj.detail = detail;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (obj.type === 'input_image') {
|
|
165
|
+
const { imageUrl, detail } = getImageUrlAndDetail(obj);
|
|
166
|
+
const normalized = normalizeImageInput(imageUrl, cwd);
|
|
167
|
+
if (normalized) obj.image_url = normalized;
|
|
168
|
+
if (typeof detail === 'string' && detail) obj.detail = detail;
|
|
169
|
+
if (typeof obj.detail !== 'string' || !obj.detail) obj.detail = 'auto';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (Array.isArray(obj.content)) obj.content = normalizeImageParts(obj.content, cwd);
|
|
173
|
+
if (Array.isArray(obj.output)) obj.output = normalizeImageParts(obj.output, cwd);
|
|
174
|
+
return obj;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── function_call_output rewrite ─────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function rewriteFunctionCallOutput(input: Record<string, unknown>[]): Record<string, unknown>[] {
|
|
180
|
+
const rewritten: Record<string, unknown>[] = [];
|
|
181
|
+
|
|
182
|
+
for (const item of input) {
|
|
183
|
+
if (
|
|
184
|
+
!item ||
|
|
185
|
+
typeof item !== 'object' ||
|
|
186
|
+
item.type !== 'function_call_output' ||
|
|
187
|
+
!Array.isArray(item.output)
|
|
188
|
+
) {
|
|
189
|
+
rewritten.push(item);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const outputParts = item.output as unknown[];
|
|
194
|
+
const imageParts = outputParts.filter(isInputImagePart);
|
|
195
|
+
const textParts = outputParts.filter((p) => !isInputImagePart(p));
|
|
196
|
+
|
|
197
|
+
const textChunks: string[] = [];
|
|
198
|
+
for (const part of textParts) {
|
|
199
|
+
if (typeof part === 'string') {
|
|
200
|
+
textChunks.push(part);
|
|
201
|
+
} else if (part && typeof part === 'object') {
|
|
202
|
+
const p = part as Record<string, unknown>;
|
|
203
|
+
if (typeof p.text === 'string') textChunks.push(p.text);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let imageCount = 0;
|
|
207
|
+
for (const _ of imageParts) imageCount++;
|
|
208
|
+
|
|
209
|
+
const outputText = textChunks.join('\n') || '(tool returned no text output)';
|
|
210
|
+
rewritten.push({ ...item, output: outputText });
|
|
211
|
+
|
|
212
|
+
if (imageCount > 0) {
|
|
213
|
+
const callId = item.call_id ? ` (${String(item.call_id)})` : '';
|
|
214
|
+
const label = `The previous tool result${callId} included ${imageCount} image${imageCount === 1 ? '' : 's'}. Use the attached image${imageCount === 1 ? '' : 's'} as the visual output from that tool.`;
|
|
215
|
+
rewritten.push({
|
|
216
|
+
role: 'user',
|
|
217
|
+
content: [{ type: 'input_text', text: label }, ...imageParts],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return rewritten;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Main sanitization ────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Sanitize a provider request payload for xAI's Responses API via
|
|
229
|
+
* cli-chat-proxy.grok.com.
|
|
230
|
+
*
|
|
231
|
+
* Returns the modified payload. Mutates the input in place for efficiency.
|
|
232
|
+
*/
|
|
233
|
+
export function sanitizePayload(
|
|
234
|
+
params: Record<string, unknown>,
|
|
235
|
+
modelId: string,
|
|
236
|
+
sessionId: string | undefined,
|
|
237
|
+
cwd: string,
|
|
238
|
+
): Record<string, unknown> {
|
|
239
|
+
const next = params;
|
|
240
|
+
|
|
241
|
+
// ── Sanitize input array ──────────────────────────────────────────────
|
|
242
|
+
if (Array.isArray(next.input)) {
|
|
243
|
+
let input = (next.input as unknown[])
|
|
244
|
+
.map((item: unknown) => {
|
|
245
|
+
if (!item || typeof item !== 'object') return item;
|
|
246
|
+
const obj = item as Record<string, unknown>;
|
|
247
|
+
|
|
248
|
+
// Strip replayed reasoning items
|
|
249
|
+
if (obj.type === 'reasoning') return null;
|
|
250
|
+
|
|
251
|
+
// Drop empty string content
|
|
252
|
+
if (typeof obj.content === 'string' && obj.content.length === 0) return null;
|
|
253
|
+
|
|
254
|
+
return obj;
|
|
255
|
+
})
|
|
256
|
+
.filter(Boolean) as Record<string, unknown>[];
|
|
257
|
+
|
|
258
|
+
// Move system/developer messages to top-level instructions.
|
|
259
|
+
// xAI rejects role: "developer" and role: "system" in the input array.
|
|
260
|
+
const instructionParts: string[] = [];
|
|
261
|
+
input = input.filter((item) => {
|
|
262
|
+
const role = (item as Record<string, unknown>).role;
|
|
263
|
+
if (role !== 'developer' && role !== 'system') return true;
|
|
264
|
+
const text = textFromContent((item as Record<string, unknown>).content).trim();
|
|
265
|
+
if (text) instructionParts.push(text);
|
|
266
|
+
return false;
|
|
267
|
+
});
|
|
268
|
+
if (instructionParts.length > 0) {
|
|
269
|
+
const existing =
|
|
270
|
+
typeof next.instructions === 'string' && next.instructions ? next.instructions : '';
|
|
271
|
+
const merged = [existing, ...instructionParts].filter((part) => part.length > 0).join('\n\n');
|
|
272
|
+
next.instructions = merged;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Normalize image parts (resolve local paths, fix types)
|
|
276
|
+
input = normalizeImageParts(input, cwd) as Record<string, unknown>[];
|
|
277
|
+
|
|
278
|
+
// Rewrite function_call_output with images
|
|
279
|
+
input = rewriteFunctionCallOutput(input);
|
|
280
|
+
|
|
281
|
+
next.input = input;
|
|
282
|
+
} else if (typeof next.input === 'string') {
|
|
283
|
+
// String input is valid and should stay string-shaped.
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── response_format → text.format ────────────────────────────────────
|
|
287
|
+
if (next.response_format) {
|
|
288
|
+
if (!next.text) next.text = { format: next.response_format };
|
|
289
|
+
delete next.response_format;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Reasoning effort ──────────────────────────────────────────────────
|
|
293
|
+
if (supportsReasoningEffort(modelId)) {
|
|
294
|
+
const reasoning = next.reasoning as Record<string, unknown> | undefined;
|
|
295
|
+
if (reasoning) {
|
|
296
|
+
const effort = reasoning.effort === 'minimal' ? 'low' : reasoning.effort;
|
|
297
|
+
next.reasoning = reasoning.summary !== undefined ? { effort } : { ...reasoning, effort };
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
delete next.reasoning;
|
|
301
|
+
delete next.reasoningEffort;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Strip/filter unsupported fields ──────────────────────────────────
|
|
305
|
+
if (Array.isArray(next.include)) {
|
|
306
|
+
next.include = (next.include as unknown[]).filter(
|
|
307
|
+
(item) => item !== 'reasoning.encrypted_content',
|
|
308
|
+
);
|
|
309
|
+
if ((next.include as unknown[]).length === 0) delete next.include;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
delete next.prompt_cache_retention;
|
|
313
|
+
|
|
314
|
+
// Add prompt_cache_key for conversation caching (routes to same server).
|
|
315
|
+
if (sessionId && !next.prompt_cache_key) {
|
|
316
|
+
next.prompt_cache_key = sessionId;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return next;
|
|
320
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error for xAI OAuth failures.
|
|
3
|
+
*
|
|
4
|
+
* Codes allow the login flow and stream handlers to distinguish
|
|
5
|
+
* retryable failures (network) from fatal ones (revoked refresh token).
|
|
6
|
+
*/
|
|
7
|
+
export class XaiOAuthError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
message: string,
|
|
10
|
+
public readonly code: string,
|
|
11
|
+
public readonly reloginRequired = false,
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'XaiOAuthError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Well-known error codes. */
|
|
19
|
+
export const XaiErrorCode = {
|
|
20
|
+
/** OIDC discovery failed (network, invalid response). */
|
|
21
|
+
DISCOVERY_FAILED: 'discovery_failed',
|
|
22
|
+
/** Discovery endpoint returned a non-xAI origin. */
|
|
23
|
+
DISCOVERY_INVALID_ORIGIN: 'discovery_invalid_origin',
|
|
24
|
+
/** Authorization was denied or errored in the browser. */
|
|
25
|
+
AUTHORIZATION_FAILED: 'authorization_failed',
|
|
26
|
+
/** CSRF state mismatch between request and callback. */
|
|
27
|
+
STATE_MISMATCH: 'state_mismatch',
|
|
28
|
+
/** Callback did not include an authorization code. */
|
|
29
|
+
CODE_MISSING: 'code_missing',
|
|
30
|
+
/** Token exchange failed (network, invalid response). */
|
|
31
|
+
TOKEN_EXCHANGE_FAILED: 'token_exchange_failed',
|
|
32
|
+
/** Token exchange returned an invalid payload. */
|
|
33
|
+
TOKEN_EXCHANGE_INVALID: 'token_exchange_invalid',
|
|
34
|
+
/** Refresh token is missing or empty. */
|
|
35
|
+
REFRESH_MISSING: 'refresh_missing',
|
|
36
|
+
/** Token refresh failed (expired, revoked). */
|
|
37
|
+
REFRESH_FAILED: 'refresh_failed',
|
|
38
|
+
/** No credentials stored. */
|
|
39
|
+
AUTH_MISSING: 'auth_missing',
|
|
40
|
+
/** Loopback callback server could not bind. */
|
|
41
|
+
CALLBACK_BIND_FAILED: 'callback_bind_failed',
|
|
42
|
+
/** Loopback callback timed out. */
|
|
43
|
+
CALLBACK_TIMEOUT: 'callback_timeout',
|
|
44
|
+
} as const;
|