codex-grok-bridge 1.7.0 → 1.7.2
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/CHANGELOG.md +13 -1
- package/README.md +129 -34
- package/package.json +1 -1
- package/src/bridge.mjs +135 -22
- package/src/errors.mjs +8 -1
- package/src/imagine.mjs +150 -0
- package/src/proxy.mjs +235 -36
- package/src/runtime.mjs +5 -8
- package/src/tools.mjs +256 -27
- package/src/videogen.mjs +196 -0
package/src/tools.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isImageGenerationItem,
|
|
5
5
|
saveGeneratedImage,
|
|
6
6
|
} from "./imagegen.mjs";
|
|
7
|
+
import { GROK_VIDEO_TOOL, GROK_VIDEO_TOOL_NAME } from "./videogen.mjs";
|
|
7
8
|
import { DEFAULT_GROK_MODEL, resolveGrokModel } from "./models.mjs";
|
|
8
9
|
import {
|
|
9
10
|
applyCacheUsage,
|
|
@@ -27,14 +28,6 @@ const CUSTOM_INPUT_SCHEMA = {
|
|
|
27
28
|
additionalProperties: false,
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
const WEB_SEARCH_SCHEMA = {
|
|
31
|
-
type: "object",
|
|
32
|
-
properties: {
|
|
33
|
-
query: { type: "string", description: "Search query" },
|
|
34
|
-
},
|
|
35
|
-
required: ["query"],
|
|
36
|
-
};
|
|
37
|
-
|
|
38
31
|
const EFFORT = {
|
|
39
32
|
ultra: "xhigh",
|
|
40
33
|
max: "xhigh",
|
|
@@ -50,12 +43,14 @@ const DROP = Symbol("drop");
|
|
|
50
43
|
// Items whose payload only the originating provider can read. "reasoning" is
|
|
51
44
|
// deliberately absent: Codex reasoning items carry a plain-text summary that is
|
|
52
45
|
// useful to Grok across a multi-call turn, and only their encrypted_content is
|
|
53
|
-
// opaque - that field is stripped by the key filter below.
|
|
54
|
-
|
|
46
|
+
// opaque - that field is stripped by the key filter below. Compaction items are
|
|
47
|
+
// likewise absent: a stored summary is rewritten into a user message, and an
|
|
48
|
+
// item with no readable text is dropped.
|
|
49
|
+
const PROVIDER_OPAQUE_TYPES = new Set(["encrypted_content"]);
|
|
50
|
+
const COMPACTION_ITEM_TYPES = new Set([
|
|
55
51
|
"compaction",
|
|
56
52
|
"compaction_summary",
|
|
57
53
|
"context_compaction",
|
|
58
|
-
"encrypted_content",
|
|
59
54
|
]);
|
|
60
55
|
const GROK_INPUT_ITEM_TYPES = new Set([
|
|
61
56
|
"message",
|
|
@@ -233,6 +228,21 @@ function decodeCustomInput(argumentsValue) {
|
|
|
233
228
|
return argumentsValue;
|
|
234
229
|
}
|
|
235
230
|
|
|
231
|
+
function plainObject(value) {
|
|
232
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// xAI runs `{type:"web_search"}` on the server. A hashed function is never
|
|
236
|
+
// called, so Codex's hosted tool stays one server tool. Keep filters and
|
|
237
|
+
// allowed_domains; every other field on that tool is dropped.
|
|
238
|
+
function keptWebSearchFields(tool) {
|
|
239
|
+
const next = {};
|
|
240
|
+
if (plainObject(tool.filters)) next.filters = tool.filters;
|
|
241
|
+
if (Array.isArray(tool.allowed_domains))
|
|
242
|
+
next.allowed_domains = tool.allowed_domains;
|
|
243
|
+
return next;
|
|
244
|
+
}
|
|
245
|
+
|
|
236
246
|
function addFunction(flattened, map, spec) {
|
|
237
247
|
// Index names change when Codex reorders tools, which rewrites every
|
|
238
248
|
// earlier function_call and breaks the prompt-cache prefix.
|
|
@@ -264,8 +274,25 @@ function addFunction(flattened, map, spec) {
|
|
|
264
274
|
export function flattenCodexTools(tools = []) {
|
|
265
275
|
const flattened = [];
|
|
266
276
|
const map = new Map();
|
|
277
|
+
let webSearch = null;
|
|
267
278
|
for (const tool of tools) {
|
|
268
279
|
if (!tool || typeof tool !== "object") continue;
|
|
280
|
+
if (tool.type === "web_search") {
|
|
281
|
+
const kept = keptWebSearchFields(tool);
|
|
282
|
+
if (!webSearch) {
|
|
283
|
+
webSearch = { type: "web_search", ...kept };
|
|
284
|
+
flattened.push(webSearch);
|
|
285
|
+
} else {
|
|
286
|
+
if (webSearch.filters === undefined && kept.filters !== undefined)
|
|
287
|
+
webSearch.filters = kept.filters;
|
|
288
|
+
if (
|
|
289
|
+
webSearch.allowed_domains === undefined &&
|
|
290
|
+
kept.allowed_domains !== undefined
|
|
291
|
+
)
|
|
292
|
+
webSearch.allowed_domains = kept.allowed_domains;
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
269
296
|
if (tool.type === "function" || tool.type === "custom") {
|
|
270
297
|
addFunction(flattened, map, {
|
|
271
298
|
kind: tool.type === "custom" ? "custom" : "function",
|
|
@@ -276,16 +303,6 @@ export function flattenCodexTools(tools = []) {
|
|
|
276
303
|
});
|
|
277
304
|
continue;
|
|
278
305
|
}
|
|
279
|
-
if (tool.type === "web_search") {
|
|
280
|
-
addFunction(flattened, map, {
|
|
281
|
-
kind: "web_search",
|
|
282
|
-
namespace: null,
|
|
283
|
-
name: "web_search",
|
|
284
|
-
description: tool.description || "Search the web",
|
|
285
|
-
parameters: tool.parameters || WEB_SEARCH_SCHEMA,
|
|
286
|
-
});
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
306
|
if (tool.type === "namespace" && Array.isArray(tool.tools)) {
|
|
290
307
|
for (const nested of tool.tools) {
|
|
291
308
|
if (!nested || typeof nested !== "object") continue;
|
|
@@ -301,6 +318,10 @@ export function flattenCodexTools(tools = []) {
|
|
|
301
318
|
}
|
|
302
319
|
flattened.push(stableJsonValue(structuredClone(tool)));
|
|
303
320
|
}
|
|
321
|
+
if (webSearch) {
|
|
322
|
+
const index = flattened.indexOf(webSearch);
|
|
323
|
+
flattened[index] = stableJsonValue(structuredClone(webSearch));
|
|
324
|
+
}
|
|
304
325
|
return { tools: flattened, map };
|
|
305
326
|
}
|
|
306
327
|
|
|
@@ -316,7 +337,178 @@ export function proxyToolChoice(choice, map) {
|
|
|
316
337
|
return "auto";
|
|
317
338
|
}
|
|
318
339
|
|
|
319
|
-
function
|
|
340
|
+
function readableString(value) {
|
|
341
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function textsFromValue(value) {
|
|
345
|
+
const direct = readableString(value);
|
|
346
|
+
if (direct) return [direct];
|
|
347
|
+
if (!Array.isArray(value)) return [];
|
|
348
|
+
const texts = [];
|
|
349
|
+
for (const part of value) {
|
|
350
|
+
if (!part || typeof part !== "object" || part.type === "encrypted_content")
|
|
351
|
+
continue;
|
|
352
|
+
const text = readableString(part.text);
|
|
353
|
+
if (text) texts.push(text);
|
|
354
|
+
}
|
|
355
|
+
return texts;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Codex replaces older history with a compaction item. The encrypted blob is
|
|
359
|
+
// provider-private, but the item may already carry the summary as plain text.
|
|
360
|
+
// That text is what Grok can read; everything else on the item is dropped.
|
|
361
|
+
function compactionInputTexts(node) {
|
|
362
|
+
for (const key of ["summary", "content", "text", "message"]) {
|
|
363
|
+
const texts = textsFromValue(node[key]);
|
|
364
|
+
if (texts.length) return texts;
|
|
365
|
+
}
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function rememberText(texts, seen, value) {
|
|
370
|
+
const text = readableString(value);
|
|
371
|
+
if (!text || seen.has(text)) return;
|
|
372
|
+
seen.add(text);
|
|
373
|
+
texts.push(text);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Content parts, stdout/stderr rows, and bare string lists. Encrypted parts
|
|
377
|
+
// are skipped. This does not walk arbitrary keys, so ids, status, env, and
|
|
378
|
+
// image bytes are not treated as text.
|
|
379
|
+
function rememberTextValue(texts, seen, value) {
|
|
380
|
+
if (typeof value === "string") {
|
|
381
|
+
rememberText(texts, seen, value);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (Array.isArray(value)) {
|
|
385
|
+
for (const part of value) {
|
|
386
|
+
if (typeof part === "string") {
|
|
387
|
+
rememberText(texts, seen, part);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (!part || typeof part !== "object" || part.type === "encrypted_content")
|
|
391
|
+
continue;
|
|
392
|
+
rememberText(texts, seen, part.text);
|
|
393
|
+
rememberText(texts, seen, part.stdout);
|
|
394
|
+
rememberText(texts, seen, part.stderr);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!value || typeof value !== "object") return;
|
|
399
|
+
rememberText(texts, seen, value.text);
|
|
400
|
+
rememberText(texts, seen, value.stdout);
|
|
401
|
+
rememberText(texts, seen, value.stderr);
|
|
402
|
+
if (typeof value.content === "string" || Array.isArray(value.content))
|
|
403
|
+
rememberTextValue(texts, seen, value.content);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function commandLines(action) {
|
|
407
|
+
const lines = [];
|
|
408
|
+
for (const list of [action.command, action.commands]) {
|
|
409
|
+
if (Array.isArray(list)) {
|
|
410
|
+
const line = list.filter((part) => typeof part === "string").join(" ").trim();
|
|
411
|
+
if (line) lines.push(line);
|
|
412
|
+
} else if (typeof list === "string" && list.trim()) {
|
|
413
|
+
lines.push(list.trim());
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return lines;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function rememberArguments(texts, seen, value) {
|
|
420
|
+
if (typeof value === "string") {
|
|
421
|
+
try {
|
|
422
|
+
const parsed = JSON.parse(value);
|
|
423
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
424
|
+
value = parsed;
|
|
425
|
+
else {
|
|
426
|
+
rememberText(texts, seen, value);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
rememberText(texts, seen, value);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
435
|
+
rememberText(texts, seen, value.query);
|
|
436
|
+
rememberTextValue(texts, seen, value.queries);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function stripOpaqueFields(value) {
|
|
440
|
+
if (Array.isArray(value)) return value.map(stripOpaqueFields);
|
|
441
|
+
if (!value || typeof value !== "object") return value;
|
|
442
|
+
const next = {};
|
|
443
|
+
for (const [key, child] of Object.entries(value)) {
|
|
444
|
+
if (
|
|
445
|
+
key === "encrypted_content" ||
|
|
446
|
+
key === "encrypted_function_args" ||
|
|
447
|
+
key.startsWith("internal_")
|
|
448
|
+
)
|
|
449
|
+
continue;
|
|
450
|
+
next[key] = stripOpaqueFields(child);
|
|
451
|
+
}
|
|
452
|
+
return next;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function containsReadableText(value) {
|
|
456
|
+
if (typeof value === "string") return Boolean(value.trim());
|
|
457
|
+
if (Array.isArray(value)) return value.some(containsReadableText);
|
|
458
|
+
if (value && typeof value === "object")
|
|
459
|
+
return Object.values(value).some(containsReadableText);
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function rememberTools(texts, seen, tools) {
|
|
464
|
+
if (!Array.isArray(tools) || !tools.length) return;
|
|
465
|
+
const stripped = stripOpaqueFields(tools);
|
|
466
|
+
if (!containsReadableText(stripped)) return;
|
|
467
|
+
rememberText(texts, seen, JSON.stringify(stripped));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Text Grok can read off an item type it will not accept. The caller turns
|
|
471
|
+
// these strings into a user message. Fields that 422 when forwarded
|
|
472
|
+
// (encrypted blobs, internal metadata, image bytes) are not read.
|
|
473
|
+
function droppedItemInputTexts(node) {
|
|
474
|
+
const texts = [];
|
|
475
|
+
const seen = new Set();
|
|
476
|
+
for (const key of [
|
|
477
|
+
"summary",
|
|
478
|
+
"content",
|
|
479
|
+
"text",
|
|
480
|
+
"message",
|
|
481
|
+
"input",
|
|
482
|
+
"output",
|
|
483
|
+
"query",
|
|
484
|
+
"revised_prompt",
|
|
485
|
+
]) {
|
|
486
|
+
rememberTextValue(texts, seen, node[key]);
|
|
487
|
+
}
|
|
488
|
+
const action = node.action;
|
|
489
|
+
if (action && typeof action === "object" && !Array.isArray(action)) {
|
|
490
|
+
const lines = commandLines(action);
|
|
491
|
+
for (const line of lines) rememberText(texts, seen, line);
|
|
492
|
+
if (lines.length) rememberText(texts, seen, action.working_directory);
|
|
493
|
+
rememberText(texts, seen, action.query);
|
|
494
|
+
rememberTextValue(texts, seen, action.queries);
|
|
495
|
+
rememberText(texts, seen, action.url);
|
|
496
|
+
rememberText(texts, seen, action.pattern);
|
|
497
|
+
}
|
|
498
|
+
rememberArguments(texts, seen, node.arguments);
|
|
499
|
+
rememberTools(texts, seen, node.tools);
|
|
500
|
+
return texts;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function userInputTextMessage(texts) {
|
|
504
|
+
return whitelistInputNode({
|
|
505
|
+
type: "message",
|
|
506
|
+
role: "user",
|
|
507
|
+
content: texts.map((text) => ({ type: "input_text", text })),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function toProxyInputNode(node, map, state, salvage = false) {
|
|
320
512
|
if (!node || typeof node !== "object") return node;
|
|
321
513
|
if (Array.isArray(node)) {
|
|
322
514
|
const items = [];
|
|
@@ -342,6 +534,16 @@ function toProxyInputNode(node, map, state) {
|
|
|
342
534
|
if (converted !== DROP) next[key] = converted;
|
|
343
535
|
}
|
|
344
536
|
|
|
537
|
+
if (COMPACTION_ITEM_TYPES.has(next.type)) {
|
|
538
|
+
const texts = compactionInputTexts(next);
|
|
539
|
+
if (!texts.length) return DROP;
|
|
540
|
+
return whitelistInputNode({
|
|
541
|
+
type: "message",
|
|
542
|
+
role: "user",
|
|
543
|
+
content: texts.map((text) => ({ type: "input_text", text })),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
345
547
|
if (next.type === "reasoning") {
|
|
346
548
|
// Forward the summary and nothing else. Codex's own item id and null
|
|
347
549
|
// content fields mean nothing to the upstream and only widen the surface
|
|
@@ -404,7 +606,14 @@ function toProxyInputNode(node, map, state) {
|
|
|
404
606
|
}
|
|
405
607
|
}
|
|
406
608
|
|
|
407
|
-
|
|
609
|
+
const whitelisted = whitelistInputNode(next);
|
|
610
|
+
if (!salvage || isForwardedItem(whitelisted)) return whitelisted;
|
|
611
|
+
// Grok rejects these Codex item types. Keep the readable text as a user
|
|
612
|
+
// message, the same shape as a compaction summary. An item with nothing
|
|
613
|
+
// left but an encrypted blob, image bytes, or ids is dropped.
|
|
614
|
+
const texts = droppedItemInputTexts(next);
|
|
615
|
+
if (!texts.length) return DROP;
|
|
616
|
+
return userInputTextMessage(texts);
|
|
408
617
|
}
|
|
409
618
|
|
|
410
619
|
function isForwardedItem(item) {
|
|
@@ -419,14 +628,14 @@ function isForwardedItem(item) {
|
|
|
419
628
|
|
|
420
629
|
function projectInput(input, map) {
|
|
421
630
|
if (!Array.isArray(input)) {
|
|
422
|
-
const items = toProxyInputNode(input, map, { callIds: new Map() });
|
|
631
|
+
const items = toProxyInputNode(input, map, { callIds: new Map() }, true);
|
|
423
632
|
return { items, pairs: [] };
|
|
424
633
|
}
|
|
425
634
|
const state = { callIds: new Map() };
|
|
426
635
|
const pairs = [];
|
|
427
636
|
for (const item of input) {
|
|
428
637
|
const fingerprint = logicalFingerprint(item);
|
|
429
|
-
const next = toProxyInputNode(item, map, state);
|
|
638
|
+
const next = toProxyInputNode(item, map, state, true);
|
|
430
639
|
if (!isForwardedItem(next)) continue;
|
|
431
640
|
pairs.push({ fingerprint, item: next });
|
|
432
641
|
}
|
|
@@ -463,6 +672,14 @@ export const IMAGE_GENERATION_PROVENANCE =
|
|
|
463
672
|
"and do not use OpenAI, image_gen, or a Python image CLI — those send the " +
|
|
464
673
|
"picture to another vendor.";
|
|
465
674
|
|
|
675
|
+
// Codex does not offer a video tool to this provider, and cli-chat-proxy has
|
|
676
|
+
// no video_generation tool type. The function below is the bridge's own.
|
|
677
|
+
export const VIDEO_GENERATION_PROVENANCE =
|
|
678
|
+
"Videos: clips on this transport are generated by the grok_bridge_generate_video " +
|
|
679
|
+
"function, which is already on the request. The bridge calls Grok's videos API " +
|
|
680
|
+
"with the grok login and returns the video URL. Do not ask for an API key and " +
|
|
681
|
+
"do not send the clip to another vendor.";
|
|
682
|
+
|
|
466
683
|
export function toProxyRequest(body) {
|
|
467
684
|
const { tools, map } = flattenCodexTools(body.tools ?? []);
|
|
468
685
|
const effort = EFFORT[body.reasoning?.effort] ?? "high";
|
|
@@ -484,13 +701,25 @@ export function toProxyRequest(body) {
|
|
|
484
701
|
const imageGenOn = process.env.GROK_BRIDGE_IMAGE_GEN !== "off";
|
|
485
702
|
if (imageGenOn && !declaresImageTool)
|
|
486
703
|
request.tools = [...request.tools, GROK_IMAGE_TOOL];
|
|
704
|
+
// Codex tool names are rewritten to codex_<hash>_…, so this literal name
|
|
705
|
+
// cannot collide with one. A second copy is not added if it is already there.
|
|
706
|
+
const videoGenOn = process.env.GROK_BRIDGE_VIDEO_GEN !== "off";
|
|
707
|
+
const declaresVideoTool = request.tools.some(
|
|
708
|
+
(tool) => tool?.name === GROK_VIDEO_TOOL_NAME,
|
|
709
|
+
);
|
|
710
|
+
if (videoGenOn && !declaresVideoTool)
|
|
711
|
+
request.tools = [...request.tools, GROK_VIDEO_TOOL];
|
|
487
712
|
if (tools.length) {
|
|
488
713
|
request.tool_choice = proxyToolChoice(body.tool_choice, map);
|
|
489
714
|
request.parallel_tool_calls = body.parallel_tool_calls !== false;
|
|
490
715
|
}
|
|
491
716
|
const provenanceLine = transportProvenance(request.model);
|
|
492
|
-
const
|
|
493
|
-
?
|
|
717
|
+
const notes = [
|
|
718
|
+
imageGenOn ? IMAGE_GENERATION_PROVENANCE : "",
|
|
719
|
+
videoGenOn ? VIDEO_GENERATION_PROVENANCE : "",
|
|
720
|
+
].filter(Boolean);
|
|
721
|
+
const provenance = notes.length
|
|
722
|
+
? `${provenanceLine}\n\n${notes.join("\n\n")}`
|
|
494
723
|
: provenanceLine;
|
|
495
724
|
request.instructions =
|
|
496
725
|
typeof body.instructions === "string" && body.instructions
|
package/src/videogen.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// cli-chat-proxy has no Responses `video_generation` tool. grok-build generates
|
|
2
|
+
// video by calling the videos API itself when the model invokes a function
|
|
3
|
+
// (`image_to_video` / `reference_to_video`): POST {xai_api_base}/videos/generations,
|
|
4
|
+
// then GET /videos/{request_id}. The bridge does the same with the grok login
|
|
5
|
+
// bearer it already sends to cli-chat-proxy — the `key` from that session, not
|
|
6
|
+
// an XAI_API_KEY.
|
|
7
|
+
|
|
8
|
+
export const GROK_VIDEO_TOOL_NAME = "grok_bridge_generate_video";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_VIDEO_API_BASE = "https://api.x.ai/v1";
|
|
11
|
+
|
|
12
|
+
const VIDEO_MODEL = "grok-imagine-video-1.5";
|
|
13
|
+
const POLL_LIMIT = 60;
|
|
14
|
+
|
|
15
|
+
export const GROK_VIDEO_TOOL = Object.freeze({
|
|
16
|
+
type: "function",
|
|
17
|
+
name: GROK_VIDEO_TOOL_NAME,
|
|
18
|
+
description:
|
|
19
|
+
"Generate a short video with Grok. The bridge runs this tool itself; Codex does not. " +
|
|
20
|
+
"Provide a prompt. Provide image_url only when the clip should start from that still image.",
|
|
21
|
+
parameters: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
prompt: { type: "string", description: "What the video should show." },
|
|
25
|
+
image_url: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Optional http(s) or data URL of a still image to animate.",
|
|
28
|
+
},
|
|
29
|
+
duration: {
|
|
30
|
+
type: "integer",
|
|
31
|
+
description: "Length in seconds, from 1 to 15.",
|
|
32
|
+
},
|
|
33
|
+
aspect_ratio: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "One of 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["prompt"],
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function scrub(text, token) {
|
|
44
|
+
let out = String(text ?? "");
|
|
45
|
+
out = out.replace(/Bearer\s+\S+/gi, "Bearer [redacted]");
|
|
46
|
+
if (token) out = out.split(token).join("[redacted]");
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseArgs(value) {
|
|
51
|
+
if (value && typeof value === "object") return value;
|
|
52
|
+
if (typeof value !== "string" || !value.trim()) return {};
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(value);
|
|
55
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
56
|
+
} catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function videoPayload(args) {
|
|
62
|
+
const payload = { model: VIDEO_MODEL, prompt: args.prompt };
|
|
63
|
+
if (typeof args.image_url === "string" && args.image_url.trim())
|
|
64
|
+
payload.image = { url: args.image_url.trim() };
|
|
65
|
+
if (Number.isInteger(args.duration)) payload.duration = args.duration;
|
|
66
|
+
else if (typeof args.duration === "string" && /^\d+$/.test(args.duration))
|
|
67
|
+
payload.duration = Number(args.duration);
|
|
68
|
+
if (typeof args.aspect_ratio === "string" && args.aspect_ratio.trim())
|
|
69
|
+
payload.aspect_ratio = args.aspect_ratio.trim();
|
|
70
|
+
return payload;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function videoHttp(fetchImpl, url, init) {
|
|
74
|
+
const response = await fetchImpl(url, { ...init, redirect: "manual" });
|
|
75
|
+
const text = await response.text().catch(() => "");
|
|
76
|
+
if (response.status >= 300 && response.status < 400) {
|
|
77
|
+
const error = new Error("Video generation failed (redirect).");
|
|
78
|
+
error.status = response.status;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
let body = null;
|
|
82
|
+
if (text) {
|
|
83
|
+
try {
|
|
84
|
+
body = JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
body = null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { status: response.status, body };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function httpFailure(status) {
|
|
93
|
+
const error = new Error(`Video generation failed (${status}).`);
|
|
94
|
+
error.status = status;
|
|
95
|
+
return error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function generateVideo(options) {
|
|
99
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
100
|
+
const base = (options.baseUrl ?? DEFAULT_VIDEO_API_BASE).replace(/\/$/, "");
|
|
101
|
+
const headers = {
|
|
102
|
+
authorization: `Bearer ${options.token}`,
|
|
103
|
+
"content-type": "application/json",
|
|
104
|
+
};
|
|
105
|
+
const started = await videoHttp(fetchImpl, `${base}/videos/generations`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers,
|
|
108
|
+
body: JSON.stringify(videoPayload(options)),
|
|
109
|
+
signal: options.signal,
|
|
110
|
+
});
|
|
111
|
+
if (started.status < 200 || started.status >= 300)
|
|
112
|
+
throw httpFailure(started.status);
|
|
113
|
+
const requestId = started.body?.request_id;
|
|
114
|
+
if (typeof requestId !== "string" || !requestId)
|
|
115
|
+
throw httpFailure(started.status);
|
|
116
|
+
const pause = options.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
117
|
+
for (let attempt = 0; attempt < POLL_LIMIT; attempt += 1) {
|
|
118
|
+
if (attempt > 0) await pause(5000);
|
|
119
|
+
const poll = await videoHttp(
|
|
120
|
+
fetchImpl,
|
|
121
|
+
`${base}/videos/${encodeURIComponent(requestId)}`,
|
|
122
|
+
{ method: "GET", headers, signal: options.signal },
|
|
123
|
+
);
|
|
124
|
+
if (poll.status === 202) continue;
|
|
125
|
+
if (poll.status < 200 || poll.status >= 300) throw httpFailure(poll.status);
|
|
126
|
+
const status = poll.body?.status;
|
|
127
|
+
if (status === "done") {
|
|
128
|
+
const url = poll.body?.video?.url;
|
|
129
|
+
if (typeof url !== "string" || !url)
|
|
130
|
+
throw new Error("Video generation finished without a URL.");
|
|
131
|
+
return url;
|
|
132
|
+
}
|
|
133
|
+
if (status === "failed" || status === "expired")
|
|
134
|
+
throw new Error(`Video generation ${status}.`);
|
|
135
|
+
}
|
|
136
|
+
throw new Error("Video generation timed out.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function videoToolOutput(call, options) {
|
|
140
|
+
const token = options?.token;
|
|
141
|
+
try {
|
|
142
|
+
const args = parseArgs(call?.arguments);
|
|
143
|
+
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
|
144
|
+
if (!prompt) return "Video generation failed: a prompt is required.";
|
|
145
|
+
const url = await generateVideo({
|
|
146
|
+
token: options.token,
|
|
147
|
+
fetchImpl: options.fetchImpl,
|
|
148
|
+
baseUrl: options.baseUrl,
|
|
149
|
+
pause: options.pause,
|
|
150
|
+
signal: options.signal,
|
|
151
|
+
prompt,
|
|
152
|
+
image_url: args.image_url,
|
|
153
|
+
duration: args.duration,
|
|
154
|
+
aspect_ratio: args.aspect_ratio,
|
|
155
|
+
});
|
|
156
|
+
return scrub(`Video ready at ${url}`, token);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const status = Number(error?.status) || 0;
|
|
159
|
+
if (status === 401 || status === 403)
|
|
160
|
+
return `Video generation failed (${status}).`;
|
|
161
|
+
return "Video generation failed.";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function videoCallsFromParts(parts) {
|
|
166
|
+
const calls = [];
|
|
167
|
+
const seen = new Set();
|
|
168
|
+
for (const part of parts ?? []) {
|
|
169
|
+
for (const line of String(part).split("\n")) {
|
|
170
|
+
if (!line.startsWith("data:")) continue;
|
|
171
|
+
const payload = line.slice(5).trim();
|
|
172
|
+
if (!payload || payload === "[DONE]") continue;
|
|
173
|
+
let value;
|
|
174
|
+
try {
|
|
175
|
+
value = JSON.parse(payload);
|
|
176
|
+
} catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const item = value?.item;
|
|
180
|
+
if (value?.type !== "response.output_item.done") continue;
|
|
181
|
+
if (item?.type !== "function_call" || item.name !== GROK_VIDEO_TOOL_NAME)
|
|
182
|
+
continue;
|
|
183
|
+
if (typeof item.call_id !== "string" || seen.has(item.call_id)) continue;
|
|
184
|
+
seen.add(item.call_id);
|
|
185
|
+
calls.push({
|
|
186
|
+
name: item.name,
|
|
187
|
+
call_id: item.call_id,
|
|
188
|
+
arguments:
|
|
189
|
+
typeof item.arguments === "string"
|
|
190
|
+
? item.arguments
|
|
191
|
+
: JSON.stringify(item.arguments ?? {}),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return calls;
|
|
196
|
+
}
|