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/bridge.mjs ADDED
@@ -0,0 +1,237 @@
1
+ import http from "node:http";
2
+ import { timingSafeEqual, randomUUID } from "node:crypto";
3
+ import { GrokAuthError, readGrokBearerToken } from "./auth.mjs";
4
+ import { sanitizeImages, toImageBlocks } from "./images.mjs";
5
+ import {
6
+ buildGrokInvocation,
7
+ decodeOutput,
8
+ parseGrokResult,
9
+ runGrok,
10
+ } from "./cli-inference.mjs";
11
+ import { openProxyStreamWithRetry, pipeProxySse } from "./proxy.mjs";
12
+ import { toProxyRequest } from "./tools.mjs";
13
+ import { classifyBridgeError, errorSignature, bridgeErrorMessage } from "./errors.mjs";
14
+ import { createDiagnostics } from "./diagnostics.mjs";
15
+ import { createSlots } from "./slots.mjs";
16
+
17
+ export const MODEL_INFO = {
18
+ slug: "grok-4.6",
19
+ display_name: "Grok 4.6 / xAI",
20
+ description: "Grok 4.6 via grok login; Codex executes tools",
21
+ default_reasoning_level: "high",
22
+ supported_reasoning_levels: ["low", "medium", "high", "xhigh"].map(
23
+ (effort) => ({ effort, description: effort }),
24
+ ),
25
+ shell_type: "unified_exec",
26
+ visibility: "list",
27
+ supported_in_api: true,
28
+ priority: 50,
29
+ availability_nux: null,
30
+ upgrade: null,
31
+ support_verbosity: false,
32
+ default_verbosity: null,
33
+ apply_patch_tool_type: "freeform",
34
+ truncation_policy: { mode: "tokens", limit: 10000 },
35
+ experimental_supported_tools: [],
36
+ context_window: 500000,
37
+ input_modalities: ["text", "image"],
38
+ tool_mode: "direct",
39
+ node_repl_disabled: true,
40
+ model_messages: {
41
+ instructions_template:
42
+ "You are Grok 4.6 by xAI, running as the Codex model. Use the tools provided by Codex and respect its permissions. Complete the user request accurately.",
43
+ },
44
+ };
45
+
46
+ export function publicBridgeError(error, context = {}) {
47
+ if (error instanceof GrokAuthError) return error.message;
48
+ const message = String(error?.message ?? "");
49
+ // Upstream status errors and CLI exit codes already carry an accurate,
50
+ // redacted message; passing them through keeps their status code visible.
51
+ if (/^Grok Build CLI exited with code \d+$/.test(message)) return message;
52
+ if (/^Grok (login|Responses proxy)/.test(message)) return message;
53
+ const kind = classifyBridgeError(error, context);
54
+ return bridgeErrorMessage(kind, context.detail);
55
+ }
56
+
57
+ export function createBridgeServer(options = {}) {
58
+ if (!options.token) throw new Error("A bridge token is required");
59
+ const diagnostics = options.diagnostics ?? createDiagnostics(options.diagnosticsOptions);
60
+ const token = Buffer.from(`Bearer ${options.token}`);
61
+ const slots = createSlots({
62
+ limit: options.maxConcurrentInference,
63
+ queueLimit: options.maxQueuedInference,
64
+ });
65
+ return http.createServer(async (req, res) => {
66
+ const json = (status, data) => {
67
+ res.writeHead(status, { "content-type": "application/json" });
68
+ res.end(JSON.stringify(data));
69
+ };
70
+ const route = new URL(req.url, "http://127.0.0.1").pathname;
71
+ if (req.method === "GET" && route === "/v1/models")
72
+ return json(200, { models: [MODEL_INFO] });
73
+ if (req.method !== "POST" || route !== "/v1/responses")
74
+ return json(404, { error: "Not found" });
75
+ const auth = Buffer.from(req.headers.authorization ?? "");
76
+ if (auth.length !== token.length || !timingSafeEqual(auth, token))
77
+ return json(401, { error: "Unauthorized" });
78
+ if (req.headers.origin)
79
+ return json(403, { error: "Browser requests are not accepted" });
80
+ let body;
81
+ let requestBytes = 0;
82
+ try {
83
+ // Collect buffers and decode once. Concatenating into a string as chunks
84
+ // arrive doubles the payload in memory as UTF-16 and reallocates on every
85
+ // chunk, which matters now that a request can carry 20 MiB of images.
86
+ const chunks = [];
87
+ let size = 0;
88
+ for await (const chunk of req) {
89
+ size += chunk.length;
90
+ if (size > (options.maxBodyBytes ?? 40 * 1024 * 1024)) {
91
+ json(413, { error: "Request too large" });
92
+ return;
93
+ }
94
+ chunks.push(chunk);
95
+ }
96
+ requestBytes = size;
97
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
98
+ if (body?.model !== "grok-4.6" || !Array.isArray(body.input))
99
+ throw new Error();
100
+ } catch {
101
+ return json(400, { error: "Invalid request" });
102
+ }
103
+ // Refuse only when even the queue is full; anything else waits for a slot.
104
+ // The check must sit after the body read, not before it: an `await` between
105
+ // the check and the claim lets concurrent requests all past it.
106
+ if (slots.isFull())
107
+ return json(429, { error: "Grok is busy; retry after this turn" });
108
+ const controller = new AbortController();
109
+ res.on("close", () => controller.abort());
110
+ res.writeHead(200, {
111
+ "content-type": "text/event-stream",
112
+ "cache-control": "no-cache",
113
+ });
114
+ // writeHead() alone leaves the status line buffered until the first body
115
+ // write, so a slow upstream leaves the client on a wholly silent socket.
116
+ res.flushHeaders();
117
+ const event = (type, data) =>
118
+ res.write(
119
+ `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`,
120
+ );
121
+ const id = "resp_" + randomUUID();
122
+ const startedAt = Date.now();
123
+ const keepalive = setInterval(() => {
124
+ if (!res.destroyed && !res.writableEnded) res.write(": keepalive\n\n");
125
+ }, 10000);
126
+ const useCli =
127
+ Boolean(options.runGrok) ||
128
+ options.inferenceMode === "cli" ||
129
+ process.env.GROK_BRIDGE_INFERENCE === "cli";
130
+ // Shape of the turn, for the diagnostic record. Structural counts only:
131
+ // never prompt text, tool output, or anything that could carry a secret.
132
+ const shape = {
133
+ mode: useCli ? "cli" : "proxy",
134
+ requestBytes,
135
+ items: Array.isArray(body.input) ? body.input.length : 0,
136
+ tools: Array.isArray(body.tools) ? body.tools.length : 0,
137
+ };
138
+ const queuedAt = Date.now();
139
+ let acquired = false;
140
+ let queuedMs = 0;
141
+ try {
142
+ await slots.acquire(controller.signal);
143
+ acquired = true;
144
+ queuedMs = Date.now() - queuedAt;
145
+ if (useCli) {
146
+ event("response.created", { response: { id } });
147
+ const invocation = buildGrokInvocation(body, options);
148
+ invocation.threadId = req.headers["thread-id"];
149
+ const result = await (options.runGrok ?? runGrok)(
150
+ invocation,
151
+ controller.signal,
152
+ );
153
+ if (result.exitCode !== 0)
154
+ throw new Error(
155
+ `Grok Build CLI exited with code ${Number(result.exitCode) || 1}`,
156
+ );
157
+ const parsed = parseGrokResult(result.stdout);
158
+ const items = decodeOutput(parsed, body.tools, body.tool_choice);
159
+ for (const item of items) event("response.output_item.done", { item });
160
+ const inputTokens =
161
+ parsed.usage?.input_tokens ?? parsed.usage?.inputTokens ?? 0,
162
+ outputTokens =
163
+ parsed.usage?.output_tokens ?? parsed.usage?.outputTokens ?? 0;
164
+ event("response.completed", {
165
+ response: {
166
+ id,
167
+ usage: {
168
+ input_tokens: inputTokens,
169
+ output_tokens: outputTokens,
170
+ total_tokens: inputTokens + outputTokens,
171
+ },
172
+ },
173
+ });
174
+ } else {
175
+ const session = readGrokBearerToken(options.grokHome);
176
+ // Grok takes input_image blocks natively; only unusable attachments
177
+ // are swapped for an explanation, so one bad image cannot make the
178
+ // upstream reject the whole conversation.
179
+ const { request, map } = toProxyRequest(sanitizeImages(body));
180
+ const proxy = await openProxyStreamWithRetry({
181
+ token: session.token,
182
+ userId: session.userId,
183
+ body: request,
184
+ signal: controller.signal,
185
+ fetchImpl: options.proxyFetch,
186
+ baseUrl: options.proxyBaseUrl,
187
+ convId: body.prompt_cache_key,
188
+ sessionId: req.headers["thread-id"],
189
+ onRetry: ({ attempt, kind }) =>
190
+ diagnostics.record({
191
+ event: "turn_retried",
192
+ kind,
193
+ attempt,
194
+ elapsedMs: Date.now() - startedAt,
195
+ }),
196
+ });
197
+ await pipeProxySse(proxy.body, res, map);
198
+ }
199
+ diagnostics.record({
200
+ event: "turn_ok",
201
+ ...shape,
202
+ queuedMs,
203
+ elapsedMs: Date.now() - startedAt,
204
+ });
205
+ } catch (error) {
206
+ const kind = classifyBridgeError(error);
207
+ diagnostics.record({
208
+ event: "turn_failed",
209
+ kind,
210
+ signature: errorSignature(error),
211
+ ...shape,
212
+ queuedMs,
213
+ elapsedMs: Date.now() - startedAt,
214
+ detail: error?.message,
215
+ });
216
+ if (!res.writableEnded && !res.destroyed) {
217
+ try {
218
+ event("response.failed", {
219
+ response: {
220
+ id,
221
+ error: {
222
+ code: `bridge_${kind}`,
223
+ message: publicBridgeError(error),
224
+ },
225
+ },
226
+ });
227
+ } catch {
228
+ // SSE already closed; Codex will see the disconnect without this event.
229
+ }
230
+ }
231
+ } finally {
232
+ clearInterval(keepalive);
233
+ if (acquired) slots.release();
234
+ res.end();
235
+ }
236
+ });
237
+ }
@@ -0,0 +1,265 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { statSync } from "node:fs";
4
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
5
+ import { tmpdir, homedir } from "node:os";
6
+ import path from "node:path";
7
+ import { toImageBlocks } from "./images.mjs";
8
+
9
+ // The GROK_BRIDGE_INFERENCE=cli fallback. It drives the Grok CLI once per turn
10
+ // with the whole Codex request as a prompt and parses a JSON envelope back out.
11
+ // The default path is streaming Responses over HTTP (proxy.mjs); this exists so
12
+ // a transport problem can be worked around without losing the harness.
13
+
14
+ const textOf = (item) =>
15
+ typeof item.content === "string"
16
+ ? item.content
17
+ : (item.content ?? [])
18
+ .filter((p) => p.type === "input_text" || p.type === "output_text")
19
+ .map((p) => p.text)
20
+ .join("\n\n");
21
+ export function extractLatestUserPrompt(input) {
22
+ return textOf(
23
+ [...(input ?? [])].reverse().find((i) => i.role === "user") ?? {},
24
+ );
25
+ }
26
+ const isDirectory = (p) => {
27
+ try {
28
+ return statSync(p).isDirectory();
29
+ } catch {
30
+ return false;
31
+ }
32
+ };
33
+ export function extractCodexCwd(input, fallback, check = isDirectory) {
34
+ const isAllowed = check ?? isDirectory;
35
+ for (const item of [...(input ?? [])].reverse()) {
36
+ if (item.role !== "developer") continue;
37
+ const match = textOf(item).match(
38
+ /<environment_context>[\s\S]*?<cwd>([^<]+)<\/cwd>[\s\S]*?<\/environment_context>/,
39
+ );
40
+ if (match && path.isAbsolute(match[1]) && isAllowed(match[1]))
41
+ return match[1];
42
+ }
43
+ return fallback;
44
+ }
45
+
46
+ export function buildGrokInvocation(body, options = {}) {
47
+ const effort =
48
+ {
49
+ ultra: "xhigh",
50
+ max: "xhigh",
51
+ xhigh: "xhigh",
52
+ high: "high",
53
+ medium: "medium",
54
+ low: "low",
55
+ minimal: "low",
56
+ none: "low",
57
+ }[body.reasoning?.effort] ?? "high";
58
+ const args = [
59
+ "--single",
60
+ extractLatestUserPrompt(body.input),
61
+ "--model",
62
+ "grok-4.6",
63
+ "--output-format",
64
+ "json",
65
+ "--json-schema",
66
+ JSON.stringify({
67
+ type: "object",
68
+ properties: {
69
+ text: { type: "string" },
70
+ calls: {
71
+ type: "array",
72
+ items: {
73
+ type: "object",
74
+ properties: {
75
+ name: { type: "string" },
76
+ namespace: { type: ["string", "null"] },
77
+ arguments: { type: "string" },
78
+ },
79
+ required: ["name", "namespace", "arguments"],
80
+ additionalProperties: false,
81
+ },
82
+ },
83
+ },
84
+ required: ["text", "calls"],
85
+ additionalProperties: false,
86
+ }),
87
+ "--reasoning-effort",
88
+ effort,
89
+ "--permission-mode",
90
+ options.permissionMode ?? "dontAsk",
91
+ "--tools",
92
+ // Unknown names fall back to ALL tools. Use a recognized allowlist,
93
+ // then remove it with the denylist (which takes precedence).
94
+ "read_file",
95
+ "--disallowed-tools",
96
+ "read_file,search_tool,search_tools,use_tool",
97
+ "--no-plan",
98
+ "--deny",
99
+ "MCPTool",
100
+ "--deny",
101
+ "Bash",
102
+ "--deny",
103
+ "Edit",
104
+ "--deny",
105
+ "Write",
106
+ "--deny",
107
+ "Read",
108
+ "--deny",
109
+ "Grep",
110
+ "--disable-web-search",
111
+ "--no-subagents",
112
+ "--max-turns",
113
+ "1",
114
+ "--no-auto-update",
115
+ "--verbatim",
116
+ "--system-prompt-override",
117
+ "You are a model backend for Codex. Return exactly the JSON envelope requested in the input. Do not execute your own tools. Tool calls in the envelope are executed by Codex.",
118
+ ];
119
+ return {
120
+ binary: options.grokBinary ?? path.join(homedir(), ".grok/bin/grok"),
121
+ args,
122
+ // Run the CLI where Codex is working, not where the bridge happens to live.
123
+ cwd:
124
+ options.fallbackCwd ??
125
+ extractCodexCwd(body.input, process.cwd(), options.isDirectory),
126
+ body,
127
+ };
128
+ }
129
+
130
+ export function bridgePrompt(body) {
131
+ return `You are the model backend inside Codex. Follow the instruction hierarchy in the request below. The input array is the complete conversation, including tool results. Continue from its last item. All tools listed in request.tools are external Codex tools; never try to execute them within Grok Build. Return ONLY a JSON object with shape {"text":"optional assistant text","calls":[{"name":"exact tool name","namespace":null,"arguments":"JSON string for function tools, raw input string for custom tools"}]}. Return an empty calls array when finished. For a namespace tool, use its namespace and nested tool name separately. Never invent tools. Do not repeat completed tool calls. Respect tool_choice.\n\nCODEX REQUEST:\n${JSON.stringify(body)}`;
132
+ }
133
+
134
+ export function parseGrokResult(stdout) {
135
+ for (const candidate of [
136
+ stdout.trim(),
137
+ ...stdout.trim().split("\n").reverse(),
138
+ ]) {
139
+ try {
140
+ const value = JSON.parse(candidate);
141
+ if (value && typeof value === "object" && typeof value.text === "string")
142
+ return value;
143
+ } catch {}
144
+ }
145
+ throw new Error("Grok did not return valid JSON");
146
+ }
147
+
148
+ export async function runGrok(invocation, signal) {
149
+ const dir = await mkdtemp(path.join(tmpdir(), "codex-grok-"));
150
+ try {
151
+ const { request, images } = toImageBlocks(invocation.body);
152
+ const promptFile = path.join(
153
+ dir,
154
+ images.length ? "prompt.json" : "prompt.txt",
155
+ );
156
+ const prompt = images.length
157
+ ? JSON.stringify([
158
+ { type: "text", text: bridgePrompt(request) },
159
+ ...images.flatMap((image, index) => [
160
+ { type: "text", text: `Attached visual image_${index + 1}:` },
161
+ image,
162
+ ]),
163
+ ])
164
+ : bridgePrompt(request);
165
+ await writeFile(promptFile, prompt, { mode: 0o600 });
166
+ const args = [...invocation.args];
167
+ args.splice(args.indexOf("--single"), 2, "--prompt-file", promptFile);
168
+ return await new Promise((resolve, reject) => {
169
+ const child = spawn(invocation.binary, args, {
170
+ cwd: invocation.cwd,
171
+ stdio: ["ignore", "pipe", "pipe"],
172
+ shell: false,
173
+ signal,
174
+ });
175
+ let stdout = "",
176
+ size = 0;
177
+ const timer = setTimeout(() => child.kill("SIGKILL"), 180000);
178
+ child.stdout.on("data", (chunk) => {
179
+ size += chunk.length;
180
+ if (size > 8 * 1024 * 1024) child.kill("SIGKILL");
181
+ else stdout += chunk;
182
+ });
183
+ child.stderr.resume();
184
+ child.once("error", () => {
185
+ clearTimeout(timer);
186
+ reject(new Error("Grok Build CLI could not start"));
187
+ });
188
+ child.once("close", (code) => {
189
+ clearTimeout(timer);
190
+ resolve({ exitCode: code ?? 1, stdout });
191
+ });
192
+ });
193
+ } finally {
194
+ await rm(dir, { recursive: true, force: true });
195
+ }
196
+ }
197
+
198
+ export function decodeOutput(result, tools = [], toolChoice = "auto") {
199
+ let envelope;
200
+ try {
201
+ envelope =
202
+ result.structured_output ??
203
+ JSON.parse(result.text.replace(/^```json\s*|\s*```$/g, ""));
204
+ } catch {
205
+ throw new Error("Invalid tool envelope");
206
+ }
207
+ if (
208
+ !envelope ||
209
+ typeof envelope.text !== "string" ||
210
+ !Array.isArray(envelope.calls) ||
211
+ envelope.calls.length > 32
212
+ )
213
+ throw new Error("Invalid tool envelope");
214
+ if (toolChoice === "none" && envelope.calls.length)
215
+ throw new Error("Tool calls forbidden");
216
+ if (toolChoice === "required" && !envelope.calls.length)
217
+ throw new Error("Tool call required");
218
+ if (
219
+ toolChoice &&
220
+ typeof toolChoice === "object" &&
221
+ toolChoice.name &&
222
+ (!envelope.calls.length ||
223
+ envelope.calls.some((c) => c.name !== toolChoice.name))
224
+ )
225
+ throw new Error("Required tool not selected");
226
+ const items = [];
227
+ if (envelope.text)
228
+ items.push({
229
+ type: "message",
230
+ id: "msg_" + randomUUID(),
231
+ role: "assistant",
232
+ content: [{ type: "output_text", text: envelope.text }],
233
+ });
234
+ for (const call of envelope.calls) {
235
+ const namespace = call.namespace ?? null;
236
+ const candidates = namespace
237
+ ? (tools.find((t) => t.type === "namespace" && t.name === namespace)
238
+ ?.tools ?? [])
239
+ : tools;
240
+ const tool = candidates.find(
241
+ (t) =>
242
+ t.name === call.name && (t.type === "function" || t.type === "custom"),
243
+ );
244
+ if (!tool || typeof call.arguments !== "string")
245
+ throw new Error("Unknown tool call");
246
+ if (tool.type === "function") {
247
+ try {
248
+ JSON.parse(call.arguments);
249
+ } catch {
250
+ throw new Error("Invalid tool arguments");
251
+ }
252
+ }
253
+ items.push({
254
+ type: tool.type === "custom" ? "custom_tool_call" : "function_call",
255
+ call_id: "call_" + randomUUID(),
256
+ name: call.name,
257
+ ...(namespace ? { namespace } : {}),
258
+ ...(tool.type === "custom"
259
+ ? { input: call.arguments }
260
+ : { arguments: call.arguments }),
261
+ });
262
+ }
263
+ if (!items.length) throw new Error("Empty model output");
264
+ return items;
265
+ }
@@ -0,0 +1,67 @@
1
+ // Prototype of src/diagnostics.mjs — a local, redacted, size-capped record of
2
+ // every bridge turn, so a failure is diagnosable after the fact instead of
3
+ // vanishing into an Electron process's inherited stderr.
4
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ export const DEFAULT_LOG_DIR = path.join(homedir(), ".local/share/codex-grok-bridge/logs");
9
+ export const MAX_LOG_BYTES = 4 * 1024 * 1024;
10
+
11
+ // Values that must never reach disk, matched structurally rather than by name,
12
+ // because the field a token arrives in is not stable.
13
+ const SECRET_PATTERNS = [
14
+ [/Bearer\s+[A-Za-z0-9._~+/-]+=*/g, "Bearer [redacted]"],
15
+ [/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g, "[jwt]"],
16
+ [/sk-[A-Za-z0-9_-]{16,}/g, "[key]"],
17
+ [/xai-[A-Za-z0-9_-]{16,}/g, "[key]"],
18
+ ];
19
+
20
+ export function redact(value, home = homedir()) {
21
+ let text = String(value ?? "");
22
+ for (const [pattern, replacement] of SECRET_PATTERNS) text = text.replace(pattern, replacement);
23
+ // Home paths identify the operator and can carry project names; keep the shape.
24
+ return text.split(home).join("~").slice(0, 400);
25
+ }
26
+
27
+ function rotate(file) {
28
+ try {
29
+ if (statSync(file).size > MAX_LOG_BYTES) renameSync(file, `${file}.1`);
30
+ } catch {
31
+ // Nothing to rotate.
32
+ }
33
+ }
34
+
35
+ export function createDiagnostics(options = {}) {
36
+ // Opt-in: only the runtime enables this. A directly constructed server
37
+ // (every test does that) must never touch the operator's real log.
38
+ if (!options.enabled) return { record: () => {}, file: null };
39
+ const dir = options.dir ?? DEFAULT_LOG_DIR;
40
+ const file = path.join(dir, "bridge.jsonl");
41
+ try {
42
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ } catch {
44
+ return { record: () => {}, file: null };
45
+ }
46
+ return {
47
+ file,
48
+ // Only structural facts: sizes, counts, durations, codes. Never prompt text,
49
+ // never tool output, never a token, never a raw upstream body.
50
+ record(entry) {
51
+ try {
52
+ rotate(file);
53
+ appendFileSync(
54
+ file,
55
+ JSON.stringify({
56
+ at: new Date().toISOString(),
57
+ ...entry,
58
+ ...(entry.detail === undefined ? {} : { detail: redact(entry.detail) }),
59
+ }) + "\n",
60
+ { mode: 0o600 },
61
+ );
62
+ } catch {
63
+ // Diagnostics must never break a turn.
64
+ }
65
+ },
66
+ };
67
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // Prototype of src/errors.mjs — the error taxonomy the bridge is missing today.
2
+ // Node's fetch reports every network fault as `TypeError: fetch failed` or
3
+ // `TypeError: terminated` with the real code hidden on `.cause`, so classification
4
+ // MUST walk the cause chain instead of matching a fixed set of top-level codes.
5
+
6
+ export const BRIDGE_ERROR = Object.freeze({
7
+ ABORTED: "aborted",
8
+ AUTH: "auth",
9
+ DNS: "dns",
10
+ CONNECT: "connect",
11
+ UPSTREAM_TIMEOUT: "upstream_timeout",
12
+ UPSTREAM_CLOSED: "upstream_closed",
13
+ UPSTREAM_STATUS: "upstream_status",
14
+ UPSTREAM_PROTOCOL: "upstream_protocol",
15
+ PAYLOAD: "payload",
16
+ INTERNAL: "internal",
17
+ });
18
+
19
+ const MESSAGES = Object.freeze({
20
+ [BRIDGE_ERROR.ABORTED]: "Grok request was aborted",
21
+ [BRIDGE_ERROR.AUTH]: "Grok login expired. Run grok login.",
22
+ [BRIDGE_ERROR.DNS]:
23
+ "Grok is unreachable: DNS lookup for the Grok proxy failed. Check network or DNS, then retry.",
24
+ [BRIDGE_ERROR.CONNECT]:
25
+ "Grok is unreachable: could not connect to the Grok proxy. Check network, then retry.",
26
+ [BRIDGE_ERROR.UPSTREAM_TIMEOUT]:
27
+ "Grok did not respond in time. Retry, or shorten the turn.",
28
+ [BRIDGE_ERROR.UPSTREAM_CLOSED]:
29
+ "Grok closed the connection before finishing. Retry.",
30
+ [BRIDGE_ERROR.UPSTREAM_PROTOCOL]:
31
+ "Grok sent a malformed response. Retry.",
32
+ [BRIDGE_ERROR.PAYLOAD]: "Grok rejected the request payload",
33
+ [BRIDGE_ERROR.INTERNAL]: "Grok bridge failed to process the turn",
34
+ });
35
+
36
+ const DNS_CODES = new Set(["EAI_AGAIN", "ENOTFOUND", "EAI_NODATA", "EAI_NONAME", "ETIMEOUT"]);
37
+ const CONNECT_CODES = new Set([
38
+ "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EACCES",
39
+ "UND_ERR_CONNECT_TIMEOUT", "CERT_HAS_EXPIRED", "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
40
+ "DEPTH_ZERO_SELF_SIGNED_CERT", "ERR_TLS_CERT_ALTNAME_INVALID",
41
+ ]);
42
+ const TIMEOUT_CODES = new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "ETIMEDOUT"]);
43
+ const CLOSED_CODES = new Set(["UND_ERR_SOCKET", "ECONNRESET", "EPIPE"]);
44
+ const ABORT_CODES = new Set([
45
+ "ABORT_ERR", "ERR_STREAM_DESTROYED", "ERR_STREAM_WRITE_AFTER_END", "ERR_STREAM_PREMATURE_CLOSE",
46
+ ]);
47
+
48
+ // Walk the whole cause chain once, newest first, and yield every (name, code, message).
49
+ function* chain(error, depth = 0) {
50
+ if (!error || typeof error !== "object" || depth > 8) return;
51
+ yield error;
52
+ yield* chain(error.cause, depth + 1);
53
+ }
54
+
55
+ export function classifyBridgeError(error, context = {}) {
56
+ for (const link of chain(error)) {
57
+ const code = typeof link.code === "string" ? link.code : "";
58
+ if (link.name === "AbortError" || ABORT_CODES.has(code)) {
59
+ // The bridge aborts the upstream only when the client hangs up, so an
60
+ // abort-shaped error means the client left. If the bridge ever aborts for
61
+ // its own reason it must pass that reason to controller.abort() and set
62
+ // context.reason here, instead of having classification guess from
63
+ // socket state (which races with the failing write).
64
+ return context.reason ?? BRIDGE_ERROR.ABORTED;
65
+ }
66
+ if (DNS_CODES.has(code)) return BRIDGE_ERROR.DNS;
67
+ if (CONNECT_CODES.has(code)) return BRIDGE_ERROR.CONNECT;
68
+ if (TIMEOUT_CODES.has(code)) return BRIDGE_ERROR.UPSTREAM_TIMEOUT;
69
+ if (CLOSED_CODES.has(code)) return BRIDGE_ERROR.UPSTREAM_CLOSED;
70
+ if (code.startsWith("HPE_") || link.name === "HTTPParserError")
71
+ return BRIDGE_ERROR.UPSTREAM_PROTOCOL;
72
+ }
73
+ if (/this operation was aborted/i.test(String(error?.message ?? "")))
74
+ return BRIDGE_ERROR.ABORTED;
75
+ return BRIDGE_ERROR.INTERNAL;
76
+ }
77
+
78
+ // A short, stable, secret-free trace of the cause chain: names and codes only,
79
+ // never messages (a message can carry a host, a path, or a token fragment).
80
+ export function errorSignature(error) {
81
+ const parts = [];
82
+ for (const link of chain(error)) {
83
+ const name = typeof link.name === "string" ? link.name : "Error";
84
+ const code = typeof link.code === "string" ? link.code : "";
85
+ parts.push(code ? `${name}[${code}]` : name);
86
+ }
87
+ return parts.join(" <- ").slice(0, 200);
88
+ }
89
+
90
+ export function bridgeErrorMessage(kind, detail) {
91
+ const base = MESSAGES[kind] ?? MESSAGES[BRIDGE_ERROR.INTERNAL];
92
+ return detail ? `${base} (${detail})` : base;
93
+ }