codex-grok-bridge 1.0.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/src/tools.mjs ADDED
@@ -0,0 +1,430 @@
1
+ import {
2
+ GROK_IMAGE_TOOL,
3
+ describeGeneratedImage,
4
+ isImageGenerationItem,
5
+ saveGeneratedImage,
6
+ } from "./imagegen.mjs";
7
+
8
+ const OBJECT_SCHEMA = { type: "object", properties: {} };
9
+
10
+ const CUSTOM_INPUT_SCHEMA = {
11
+ type: "object",
12
+ properties: {
13
+ input: {
14
+ type: "string",
15
+ description: "Raw freeform input for the Codex custom tool.",
16
+ },
17
+ },
18
+ required: ["input"],
19
+ additionalProperties: false,
20
+ };
21
+
22
+ const WEB_SEARCH_SCHEMA = {
23
+ type: "object",
24
+ properties: {
25
+ query: { type: "string", description: "Search query" },
26
+ },
27
+ required: ["query"],
28
+ };
29
+
30
+ const EFFORT = {
31
+ ultra: "xhigh",
32
+ max: "xhigh",
33
+ xhigh: "xhigh",
34
+ high: "high",
35
+ medium: "medium",
36
+ low: "low",
37
+ minimal: "low",
38
+ none: "low",
39
+ };
40
+
41
+ const DROP = Symbol("drop");
42
+ // Items whose payload only the originating provider can read. "reasoning" is
43
+ // deliberately absent: Codex reasoning items carry a plain-text summary that is
44
+ // useful to Grok across a multi-call turn, and only their encrypted_content is
45
+ // opaque - that field is stripped by the key filter below.
46
+ const PROVIDER_OPAQUE_TYPES = new Set([
47
+ "compaction",
48
+ "compaction_summary",
49
+ "context_compaction",
50
+ "encrypted_content",
51
+ ]);
52
+ const GROK_INPUT_ITEM_TYPES = new Set([
53
+ "message",
54
+ "reasoning",
55
+ "function_call",
56
+ "function_call_output",
57
+ "shell_call",
58
+ ]);
59
+
60
+ function usableParameters(parameters) {
61
+ if (!parameters || typeof parameters !== "object") return OBJECT_SCHEMA;
62
+ if (
63
+ parameters.type === "object" ||
64
+ Array.isArray(parameters.oneOf) ||
65
+ Array.isArray(parameters.anyOf)
66
+ )
67
+ return parameters;
68
+ return OBJECT_SCHEMA;
69
+ }
70
+
71
+ function sanitize(name) {
72
+ return String(name || "tool")
73
+ .replace(/[^A-Za-z0-9_-]/g, "_")
74
+ .slice(0, 40);
75
+ }
76
+
77
+ function findProxyName(map, name, namespace = null) {
78
+ for (const [proxyName, origin] of map) {
79
+ if (origin.name === name && origin.namespace === namespace) return proxyName;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ function customInputArguments(input) {
85
+ return JSON.stringify({ input: typeof input === "string" ? input : "" });
86
+ }
87
+
88
+ function decodeCustomInput(argumentsValue) {
89
+ if (typeof argumentsValue !== "string") return "";
90
+ try {
91
+ const parsed = JSON.parse(argumentsValue);
92
+ if (parsed && typeof parsed === "object" && typeof parsed.input === "string")
93
+ return parsed.input;
94
+ } catch {}
95
+ return argumentsValue;
96
+ }
97
+
98
+ function addFunction(flattened, map, spec) {
99
+ const proxyName = `codex_${flattened.length}_${sanitize(spec.name)}`;
100
+ map.set(proxyName, {
101
+ kind: spec.kind,
102
+ namespace: spec.namespace,
103
+ name: spec.name,
104
+ });
105
+ flattened.push({
106
+ type: "function",
107
+ name: proxyName,
108
+ description: spec.namespace
109
+ ? `[${spec.namespace}] ${spec.description || spec.name}`
110
+ : spec.description || spec.name,
111
+ parameters:
112
+ spec.kind === "custom"
113
+ ? structuredClone(CUSTOM_INPUT_SCHEMA)
114
+ : usableParameters(spec.parameters),
115
+ });
116
+ }
117
+
118
+ export function flattenCodexTools(tools = []) {
119
+ const flattened = [];
120
+ const map = new Map();
121
+ for (const tool of tools) {
122
+ if (!tool || typeof tool !== "object") continue;
123
+ if (tool.type === "function" || tool.type === "custom") {
124
+ addFunction(flattened, map, {
125
+ kind: tool.type === "custom" ? "custom" : "function",
126
+ namespace: null,
127
+ name: tool.name,
128
+ description: tool.description,
129
+ parameters: tool.parameters,
130
+ });
131
+ continue;
132
+ }
133
+ if (tool.type === "web_search") {
134
+ addFunction(flattened, map, {
135
+ kind: "web_search",
136
+ namespace: null,
137
+ name: "web_search",
138
+ description: tool.description || "Search the web",
139
+ parameters: tool.parameters || WEB_SEARCH_SCHEMA,
140
+ });
141
+ continue;
142
+ }
143
+ if (tool.type === "namespace" && Array.isArray(tool.tools)) {
144
+ for (const nested of tool.tools) {
145
+ if (!nested || typeof nested !== "object") continue;
146
+ addFunction(flattened, map, {
147
+ kind: nested.type === "custom" ? "custom" : "function",
148
+ namespace: tool.name,
149
+ name: nested.name,
150
+ description: nested.description,
151
+ parameters: nested.parameters,
152
+ });
153
+ }
154
+ continue;
155
+ }
156
+ flattened.push(structuredClone(tool));
157
+ }
158
+ return { tools: flattened, map };
159
+ }
160
+
161
+ export function proxyToolChoice(choice, map) {
162
+ if (choice === "auto" || choice === "none" || choice === "required")
163
+ return choice;
164
+ if (!choice || typeof choice !== "object" || !choice.name) return "auto";
165
+ const namespace = choice.namespace ?? null;
166
+ for (const [proxyName, origin] of map) {
167
+ if (origin.name === choice.name && origin.namespace === namespace)
168
+ return { type: "function", name: proxyName };
169
+ }
170
+ return "auto";
171
+ }
172
+
173
+ function toProxyInputNode(node, map, state) {
174
+ if (!node || typeof node !== "object") return node;
175
+ if (Array.isArray(node)) {
176
+ const items = [];
177
+ for (const item of node) {
178
+ const next = toProxyInputNode(item, map, state);
179
+ if (next !== DROP) items.push(next);
180
+ }
181
+ return items;
182
+ }
183
+
184
+ if (PROVIDER_OPAQUE_TYPES.has(node.type))
185
+ return DROP;
186
+
187
+ const next = {};
188
+ for (const [key, value] of Object.entries(node)) {
189
+ if (
190
+ key === "encrypted_content" ||
191
+ key === "encrypted_function_args" ||
192
+ key === "internal_chat_message_metadata_passthrough"
193
+ )
194
+ continue;
195
+ const converted = toProxyInputNode(value, map, state);
196
+ if (converted !== DROP) next[key] = converted;
197
+ }
198
+
199
+ if (next.type === "reasoning") {
200
+ // Forward the summary and nothing else. Codex's own item id and null
201
+ // content fields mean nothing to the upstream and only widen the surface
202
+ // for a schema rejection.
203
+ const summary = (Array.isArray(next.summary) ? next.summary : []).filter(
204
+ (part) => part && typeof part.text === "string" && part.text.trim(),
205
+ );
206
+ if (!summary.length) return DROP;
207
+ return { type: "reasoning", summary };
208
+ }
209
+
210
+ if (next.type === "agent_message") {
211
+ const content = Array.isArray(next.content)
212
+ ? next.content
213
+ : typeof next.text === "string" && next.text
214
+ ? [{ type: "input_text", text: next.text }]
215
+ : [];
216
+ if (!content.length) return DROP;
217
+ return { type: "message", role: "assistant", content };
218
+ }
219
+
220
+ if (
221
+ next.type === "function_call" &&
222
+ typeof next.name === "string"
223
+ ) {
224
+ const proxyName = findProxyName(map, next.name, next.namespace ?? null);
225
+ if (proxyName) {
226
+ next.name = proxyName;
227
+ delete next.namespace;
228
+ if (typeof next.call_id === "string") state.callIds.set(next.call_id, proxyName);
229
+ }
230
+ return next;
231
+ }
232
+
233
+ if (
234
+ next.type === "custom_tool_call" &&
235
+ typeof next.name === "string"
236
+ ) {
237
+ const proxyName = findProxyName(map, next.name, next.namespace ?? null);
238
+ const origin = proxyName ? map.get(proxyName) : null;
239
+ if (origin?.kind === "custom") {
240
+ next.type = "function_call";
241
+ next.name = proxyName;
242
+ next.arguments = customInputArguments(next.input);
243
+ delete next.namespace;
244
+ delete next.input;
245
+ if (typeof next.call_id === "string") state.callIds.set(next.call_id, proxyName);
246
+ }
247
+ return next;
248
+ }
249
+
250
+ if (
251
+ next.type === "function_call_output" ||
252
+ next.type === "custom_tool_call_output"
253
+ ) {
254
+ const proxyName =
255
+ typeof next.name === "string"
256
+ ? findProxyName(map, next.name, next.namespace ?? null)
257
+ : typeof next.call_id === "string"
258
+ ? state.callIds.get(next.call_id)
259
+ : null;
260
+ if (proxyName) {
261
+ next.type = "function_call_output";
262
+ next.name = proxyName;
263
+ delete next.namespace;
264
+ }
265
+ return next;
266
+ }
267
+
268
+ return next;
269
+ }
270
+
271
+ function toProxyInput(input, map) {
272
+ const items = toProxyInputNode(input, map, { callIds: new Map() });
273
+ if (!Array.isArray(items)) return items;
274
+ return items.filter(
275
+ (item) =>
276
+ item &&
277
+ typeof item === "object" &&
278
+ (item.type == null || GROK_INPUT_ITEM_TYPES.has(item.type)),
279
+ );
280
+ }
281
+
282
+ // Only the bridge knows a request is being served by the bridge. Codex does not
283
+ // put the provider or model into the prompt, so without this line the model has
284
+ // no way to answer "is Grok actually attached?" and either hedges or goes
285
+ // hunting through config files. Stated once, in the instructions, it costs a
286
+ // few tokens and removes the whole class of question.
287
+ export const TRANSPORT_PROVENANCE =
288
+ "Transport: this request is served by the local Codex-Grok bridge — model " +
289
+ "grok-4.6 via the grok_build_cli provider, with Codex owning tools, history " +
290
+ "and permissions. If asked whether Grok is attached to the Codex harness, " +
291
+ "this line is the authoritative answer and no tool call is needed to confirm it.";
292
+
293
+ export function toProxyRequest(body) {
294
+ const { tools, map } = flattenCodexTools(body.tools ?? []);
295
+ const effort = EFFORT[body.reasoning?.effort] ?? "high";
296
+ const request = {
297
+ model: "grok-4.6",
298
+ input: toProxyInput(body.input, map),
299
+ tools,
300
+ reasoning: { effort },
301
+ stream: true,
302
+ store: false,
303
+ };
304
+ // Grok generates images server-side when this tool is present. Codex never
305
+ // offers one to this provider, so without it the only path is a different
306
+ // vendor's API. GROK_BRIDGE_IMAGE_GEN=off restores that older behaviour.
307
+ const declaresImageTool = request.tools.some(
308
+ (tool) => tool?.type === GROK_IMAGE_TOOL.type,
309
+ );
310
+ if (process.env.GROK_BRIDGE_IMAGE_GEN !== "off" && !declaresImageTool)
311
+ request.tools = [...request.tools, GROK_IMAGE_TOOL];
312
+ if (tools.length) {
313
+ request.tool_choice = proxyToolChoice(body.tool_choice, map);
314
+ request.parallel_tool_calls = body.parallel_tool_calls !== false;
315
+ }
316
+ request.instructions =
317
+ typeof body.instructions === "string" && body.instructions
318
+ ? `${body.instructions}\n\n${TRANSPORT_PROVENANCE}`
319
+ : TRANSPORT_PROVENANCE;
320
+ if (typeof body.prompt_cache_key === "string" && body.prompt_cache_key)
321
+ request.prompt_cache_key = body.prompt_cache_key;
322
+ return { request, map };
323
+ }
324
+
325
+ function rememberProxyItem(item, origin, state) {
326
+ if (typeof item.call_id === "string") state.callIds.set(item.call_id, origin);
327
+ if (typeof item.id === "string") state.itemIds.set(item.id, origin);
328
+ }
329
+
330
+ function restoreOriginName(node, origin) {
331
+ node.name = origin.name;
332
+ if (origin.namespace) node.namespace = origin.namespace;
333
+ else delete node.namespace;
334
+ }
335
+
336
+ function rewriteResponseItem(node, map, state) {
337
+ if (!node || typeof node !== "object") return;
338
+ if (typeof node.name === "string" && map.has(node.name)) {
339
+ const origin = map.get(node.name);
340
+ rememberProxyItem(node, origin, state);
341
+ restoreOriginName(node, origin);
342
+ if (origin.kind === "custom") {
343
+ if (node.type === "function_call") node.type = "custom_tool_call";
344
+ if (node.type === "custom_tool_call") {
345
+ if (node.input == null) node.input = decodeCustomInput(node.arguments);
346
+ delete node.arguments;
347
+ delete node.encrypted_function_args;
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ // A generated image arrives as bytes on the stream. Codex has no tool for this
354
+ // and no place to put them, so the bridge writes the file and hands back an
355
+ // ordinary assistant message naming it.
356
+ function absorbGeneratedImage(item, state) {
357
+ const saved = saveGeneratedImage(item, state.imageOptions);
358
+ return {
359
+ type: "message",
360
+ id: typeof item.id === "string" ? item.id : undefined,
361
+ role: "assistant",
362
+ status: "completed",
363
+ content: [{ type: "output_text", text: describeGeneratedImage(item, saved) }],
364
+ };
365
+ }
366
+
367
+ function rewriteResponseEvent(value, map, state) {
368
+ if (!value || typeof value !== "object") return value;
369
+ if (
370
+ typeof value.item === "object" &&
371
+ value.item &&
372
+ (value.type === "response.output_item.added" ||
373
+ value.type === "response.output_item.done")
374
+ ) {
375
+ if (isImageGenerationItem(value.item))
376
+ value.item = absorbGeneratedImage(value.item, state);
377
+ else rewriteResponseItem(value.item, map, state);
378
+ }
379
+ return value;
380
+ }
381
+
382
+ // Progress events for a tool Codex does not know about carry nothing it can
383
+ // use, and an unfamiliar event type is a risk to its parser. The item that
384
+ // actually holds the image is kept and rewritten; the chatter around it is not.
385
+ const OPAQUE_EVENT_PREFIXES = ["response.image_generation_call."];
386
+
387
+ function isOpaqueEvent(lines) {
388
+ return lines.some(
389
+ (line) =>
390
+ line.startsWith("event:") &&
391
+ OPAQUE_EVENT_PREFIXES.some((prefix) =>
392
+ line.slice(6).trim().startsWith(prefix),
393
+ ),
394
+ );
395
+ }
396
+
397
+ export function rewriteSseBlock(block, map, state = { callIds: new Map(), itemIds: new Map() }) {
398
+ const lines = block.split("\n");
399
+ if (isOpaqueEvent(lines)) return null;
400
+ // An image_generation_call announced before its bytes exist has nothing to
401
+ // save yet; the matching .done block carries the result.
402
+ if (
403
+ lines.some((line) => line.startsWith("event: response.output_item.added")) &&
404
+ lines.some((line) => line.includes('"type":"image_generation_call"'))
405
+ )
406
+ return null;
407
+ return lines
408
+ .map((line) => {
409
+ if (!line.startsWith("data:")) return line;
410
+ const payload = line.slice(5).trim();
411
+ if (!payload || payload === "[DONE]") return line;
412
+ try {
413
+ const value = JSON.parse(payload);
414
+ rewriteResponseEvent(value, map, state);
415
+ return `data: ${JSON.stringify(value)}`;
416
+ } catch {
417
+ return line;
418
+ }
419
+ })
420
+ .join("\n");
421
+ }
422
+
423
+ export function createSseRewriter(map, options = {}) {
424
+ const state = {
425
+ callIds: new Map(),
426
+ itemIds: new Map(),
427
+ imageOptions: options.imageOptions,
428
+ };
429
+ return (block) => rewriteSseBlock(block, map, state);
430
+ }
@@ -0,0 +1,109 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import dns from "node:dns";
4
+ import { Readable } from "node:stream";
5
+
6
+ // Node's global fetch gives no control over DNS or connection reuse: its
7
+ // dispatcher lives in undici, which is neither a built-in module here nor a
8
+ // dependency this package carries. node:http(s) gives both through an Agent.
9
+ //
10
+ // Why it matters: between two inference calls Codex runs tools for 5-20s. If
11
+ // the pooled socket is gone by then the next call needs a fresh connection and
12
+ // therefore a fresh DNS lookup, and a stalled macOS resolver fails that lookup
13
+ // on a fixed schedule rather than quickly.
14
+
15
+ export const DNS_TTL_MS = 300_000;
16
+ export const KEEP_ALIVE_MS = 30_000;
17
+ export const MAX_SOCKETS = 4;
18
+ export const SOCKET_TIMEOUT_MS = 120_000;
19
+
20
+ const dnsCache = new Map();
21
+
22
+ export function clearDnsCache() {
23
+ dnsCache.clear();
24
+ }
25
+
26
+ /**
27
+ * dns.lookup with a short-lived cache, in the shape http.Agent expects.
28
+ * A failed lookup falls back to a still-known address rather than failing the
29
+ * turn, because a resolver hiccup is not a reason to lose a conversation.
30
+ */
31
+ export function cachedLookup(hostname, options, callback) {
32
+ const resolve = typeof options === "function" ? options : callback;
33
+ const settings = typeof options === "function" ? {} : (options ?? {});
34
+ const key = `${hostname}:${settings.family ?? 0}`;
35
+ const cached = dnsCache.get(key);
36
+ if (cached && cached.expiresAt > Date.now()) {
37
+ process.nextTick(() => resolve(null, cached.address, cached.family));
38
+ return;
39
+ }
40
+ dns.lookup(hostname, settings, (error, address, family) => {
41
+ if (!error) {
42
+ dnsCache.set(key, { address, family, expiresAt: Date.now() + DNS_TTL_MS });
43
+ resolve(null, address, family);
44
+ return;
45
+ }
46
+ if (cached) {
47
+ resolve(null, cached.address, cached.family);
48
+ return;
49
+ }
50
+ resolve(error);
51
+ });
52
+ }
53
+
54
+ const agents = new Map();
55
+
56
+ export function proxyAgent(protocol) {
57
+ const secure = protocol !== "http:";
58
+ const existing = agents.get(secure);
59
+ if (existing) return existing;
60
+ const Agent = secure ? https.Agent : http.Agent;
61
+ const agent = new Agent({
62
+ keepAlive: true,
63
+ keepAliveMsecs: KEEP_ALIVE_MS,
64
+ maxSockets: MAX_SOCKETS,
65
+ timeout: SOCKET_TIMEOUT_MS,
66
+ lookup: cachedLookup,
67
+ });
68
+ agents.set(secure, agent);
69
+ return agent;
70
+ }
71
+
72
+ export function destroyAgents() {
73
+ for (const agent of agents.values()) agent.destroy();
74
+ agents.clear();
75
+ }
76
+
77
+ /**
78
+ * A fetch-shaped request over node:http(s). Returns a real Response so callers
79
+ * keep `ok`, `status`, `text()` and a web ReadableStream body unchanged.
80
+ */
81
+ export function requestStream(url, init = {}) {
82
+ const target = new URL(url);
83
+ const client = target.protocol === "http:" ? http : https;
84
+ return new Promise((resolve, reject) => {
85
+ const request = client.request(
86
+ target,
87
+ {
88
+ method: init.method ?? "GET",
89
+ headers: init.headers,
90
+ agent: proxyAgent(target.protocol),
91
+ signal: init.signal,
92
+ },
93
+ (response) => {
94
+ const headers = {};
95
+ const contentType = response.headers["content-type"];
96
+ if (contentType) headers["content-type"] = contentType;
97
+ resolve(
98
+ new Response(Readable.toWeb(response), {
99
+ status: response.statusCode,
100
+ headers,
101
+ }),
102
+ );
103
+ },
104
+ );
105
+ request.once("error", reject);
106
+ if (init.body === undefined || init.body === null) request.end();
107
+ else request.end(init.body);
108
+ });
109
+ }