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/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
- const PROVIDER_OPAQUE_TYPES = new Set([
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 toProxyInputNode(node, map, state) {
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
- return whitelistInputNode(next);
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 provenance = imageGenOn
493
- ? `${provenanceLine}\n\n${IMAGE_GENERATION_PROVENANCE}`
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
@@ -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
+ }