@ar-ai/brink-cli 0.0.0-stage → 1.0.6

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.
@@ -0,0 +1,480 @@
1
+ "use strict";
2
+
3
+ const http = require("http");
4
+ const { bridgeResponses } = require("./chat-bridge");
5
+ const { bridgeMessages } = require("./messages-bridge");
6
+ const { adaptModelCatalog } = require("./model-catalog");
7
+ const { createMonitor } = require("./monitor");
8
+ const { startTerminalUI } = require("./terminal-ui");
9
+ const { createUsageObserver } = require("./usage");
10
+ const {
11
+ cleanHeaders,
12
+ createUpstreamAgent,
13
+ requestWithRetries,
14
+ upstreamFailure,
15
+ } = require("./transport");
16
+
17
+ const LISTEN_HOST = "127.0.0.1";
18
+ const LISTEN_PORT = Number(process.env.AGENTROUTER_PROXY_PORT || 8787);
19
+ const UPSTREAM_ORIGIN = "https://agentrouter.org";
20
+ const UPSTREAM_API_PREFIX = "/v1";
21
+ const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
22
+ const MAX_CATALOG_BYTES = 8 * 1024 * 1024;
23
+ const agent = createUpstreamAgent();
24
+ const monitor = createMonitor({ port: LISTEN_PORT });
25
+ let terminalUI;
26
+
27
+ function sanitizeRequest(payload) {
28
+ let removedIds = 0;
29
+ let removedEncryptedContent = 0;
30
+ let removedReferences = 0;
31
+ let input;
32
+ if (Array.isArray(payload.input)) {
33
+ for (let index = 0; index < payload.input.length; index++) {
34
+ const item = payload.input[index];
35
+ if (!item || typeof item !== "object") {
36
+ if (input) input.push(item);
37
+ continue;
38
+ }
39
+ // References must be checked before removing their IDs. Tool call_id
40
+ // fields remain untouched: they link coding tool calls and results.
41
+ if (
42
+ item.type === "item_reference" &&
43
+ typeof item.id === "string" &&
44
+ item.id.startsWith("rs_")
45
+ ) {
46
+ if (!input) input = payload.input.slice(0, index);
47
+ removedReferences++;
48
+ continue;
49
+ }
50
+ const removeId = typeof item.id === "string" && item.id.length > 0;
51
+ const removeEncrypted =
52
+ item.type === "reasoning" &&
53
+ Object.prototype.hasOwnProperty.call(item, "encrypted_content");
54
+ if (!removeId && !removeEncrypted) {
55
+ if (input) input.push(item);
56
+ continue;
57
+ }
58
+ if (!input) input = payload.input.slice(0, index);
59
+ const cleaned = { ...item };
60
+ if (removeId) {
61
+ delete cleaned.id;
62
+ removedIds++;
63
+ }
64
+ if (removeEncrypted) {
65
+ delete cleaned.encrypted_content;
66
+ removedEncryptedContent++;
67
+ }
68
+ input.push(cleaned);
69
+ }
70
+ }
71
+ return {
72
+ payload: input ? { ...payload, input } : payload,
73
+ removedIds,
74
+ removedEncryptedContent,
75
+ removedReferences,
76
+ };
77
+ }
78
+
79
+ // AgentRouter's GLM channel rejects reasoning efforts outside low/high/max
80
+ // ("this model always thinks; disabling thinking is not supported"). T3 sends
81
+ // medium by default, so map disallowed efforts to the nearest allowed value
82
+ // instead of letting the request fail and trigger client-side paid retries.
83
+ const GLM_ALLOWED_EFFORTS = new Set(["low", "high", "max"]);
84
+ const GLM_EFFORT_FALLBACK = {
85
+ none: "low",
86
+ minimal: "low",
87
+ medium: "high",
88
+ xhigh: "max",
89
+ ultra: "max",
90
+ persistent: "max",
91
+ };
92
+
93
+ function normalizeReasoningEffort(payload) {
94
+ if (typeof payload?.model !== "string" || !payload.model.startsWith("glm-"))
95
+ return null;
96
+ const reasoning = payload.reasoning;
97
+ if (
98
+ !reasoning ||
99
+ typeof reasoning !== "object" ||
100
+ typeof reasoning.effort !== "string" ||
101
+ GLM_ALLOWED_EFFORTS.has(reasoning.effort)
102
+ )
103
+ return null;
104
+ return {
105
+ from: reasoning.effort,
106
+ to: GLM_EFFORT_FALLBACK[reasoning.effort] ?? "high",
107
+ };
108
+ }
109
+
110
+ function getUpstreamPath(clientPath) {
111
+ if (typeof clientPath !== "string" || clientPath.length === 0)
112
+ return `${UPSTREAM_API_PREFIX}/responses`;
113
+ if (clientPath === "/v1" || clientPath.startsWith("/v1/")) return clientPath;
114
+ if (clientPath === "/") return `${UPSTREAM_API_PREFIX}/`;
115
+ return `${UPSTREAM_API_PREFIX}${clientPath}`;
116
+ }
117
+
118
+ function sendError(res, status, message, close = false) {
119
+ if (res.destroyed || res.writableEnded) return;
120
+ if (res.headersSent) {
121
+ res.destroy();
122
+ return;
123
+ }
124
+ const headers = { "content-type": "application/json" };
125
+ if (close) {
126
+ headers.connection = "close";
127
+ res.shouldKeepAlive = false;
128
+ }
129
+ res.writeHead(status, headers);
130
+ res.end(JSON.stringify({ error: message }));
131
+ }
132
+
133
+ async function sendUpstream(req, res, target, body, tracker) {
134
+ const codexCatalog =
135
+ req.method === "GET" &&
136
+ target.pathname === "/v1/models" &&
137
+ target.searchParams.has("client_version");
138
+ const backgroundLabel =
139
+ target.pathname === "/v1/models"
140
+ ? "model discovery GET /v1/models"
141
+ : "background GET (other endpoint)";
142
+ const authPresent = Boolean(req.headers.authorization || req.headers["x-api-key"]);
143
+ const clientLabel = String(req.headers.originator || "unspecified")
144
+ .replace(/[^A-Za-z0-9_.-]/g, "")
145
+ .slice(0, 64);
146
+ const backgroundError = (message, status) =>
147
+ monitor.log(
148
+ "error",
149
+ `${backgroundLabel} · HTTP ${status} · ${message} · authorization ${authPresent ? "present" : "missing"} · client ${clientLabel}; generation status is tracked separately`,
150
+ );
151
+ const headers = cleanHeaders(req.headers);
152
+ if (!headers.authorization && typeof headers["x-api-key"] === "string")
153
+ headers.authorization = `Bearer ${headers["x-api-key"]}`;
154
+ delete headers["x-api-key"];
155
+ delete headers.host;
156
+ delete headers["content-length"];
157
+ headers.host = target.host;
158
+ if (req.method === "POST")
159
+ headers["content-length"] = Buffer.byteLength(body);
160
+ if (tracker) headers["accept-encoding"] = "identity";
161
+ if (codexCatalog) {
162
+ headers.accept = "application/json";
163
+ headers["accept-encoding"] = "identity";
164
+ }
165
+
166
+ let upstreamRequest;
167
+ let upstreamResponse;
168
+ let usageObserver;
169
+ let upstreamStream;
170
+ const controller = new AbortController();
171
+ let cancelled = false;
172
+ let failed = false;
173
+ const fail = (message, status = 502) => {
174
+ if (cancelled || failed || res.writableFinished) return;
175
+ failed = true;
176
+ if (tracker) tracker.fail(message);
177
+ else backgroundError(message, status);
178
+ usageObserver?.destroy();
179
+ controller.abort();
180
+ upstreamStream?.destroy();
181
+ upstreamResponse?.destroy();
182
+ upstreamRequest?.destroy();
183
+ sendError(res, status, message);
184
+ };
185
+ const cancel = () => {
186
+ if (res.writableFinished) return;
187
+ cancelled = true;
188
+ usageObserver?.destroy();
189
+ controller.abort();
190
+ upstreamStream?.destroy();
191
+ upstreamResponse?.destroy();
192
+ upstreamRequest?.destroy();
193
+ };
194
+ req.once("aborted", cancel);
195
+ res.once("close", cancel);
196
+ res.once("error", cancel);
197
+ if (req.aborted || res.destroyed) return;
198
+
199
+ try {
200
+ const result = await requestWithRetries(
201
+ target,
202
+ { method: req.method, headers, agent },
203
+ body,
204
+ {
205
+ signal: controller.signal,
206
+ tracker,
207
+ clientOwnsRetries: req.headers["x-agentrouter-retry-owner"] === "client",
208
+ },
209
+ );
210
+ upstreamRequest = result.request;
211
+ upstreamResponse = result.response;
212
+ upstreamStream = result.stream;
213
+ const upstream = upstreamResponse;
214
+ if (cancelled) {
215
+ upstreamStream.destroy();
216
+ upstream.destroy();
217
+ return;
218
+ }
219
+ const status = upstream.statusCode || 502;
220
+ tracker?.upstream(status);
221
+ if (result.retryReason && status < 400)
222
+ tracker?.fail(upstreamFailure(null, { code: result.retryReason }));
223
+ if (status >= 400) {
224
+ const message = upstreamFailure(status);
225
+ if (tracker) tracker.fail(message);
226
+ else backgroundError(message, status);
227
+ }
228
+ // Install failure handling before piping, so telemetry sees transport
229
+ // failures before the downstream close event finalizes the request.
230
+ upstreamStream.on("error", (error) =>
231
+ fail(upstreamFailure(null, error), error.status || 502),
232
+ );
233
+
234
+ if (codexCatalog && status === 200) {
235
+ const chunks = [];
236
+ let bytes = 0;
237
+ const declaredLength = Number(upstream.headers["content-length"]);
238
+ if (declaredLength > MAX_CATALOG_BYTES) {
239
+ fail("AgentRouter model catalog exceeds the size limit");
240
+ return;
241
+ }
242
+ upstreamStream.on("data", (chunk) => {
243
+ if (failed || cancelled) return;
244
+ bytes += chunk.length;
245
+ if (bytes > MAX_CATALOG_BYTES) {
246
+ chunks.length = 0;
247
+ fail("AgentRouter model catalog exceeds the size limit");
248
+ return;
249
+ }
250
+ chunks.push(chunk);
251
+ });
252
+ upstreamStream.once("end", () => {
253
+ if (failed || cancelled || res.destroyed) return;
254
+ let catalog;
255
+ try {
256
+ catalog = adaptModelCatalog(
257
+ JSON.parse(Buffer.concat(chunks, bytes).toString("utf8")),
258
+ );
259
+ } catch {
260
+ fail("AgentRouter returned an invalid model catalog");
261
+ return;
262
+ }
263
+ res.writeHead(200, {
264
+ "content-type": "application/json",
265
+ "cache-control": "no-store",
266
+ });
267
+ res.end(JSON.stringify(catalog));
268
+ });
269
+ return;
270
+ }
271
+ const responseHeaders = cleanHeaders(upstream.headers);
272
+ responseHeaders["cache-control"] = "no-cache";
273
+ responseHeaders["x-accel-buffering"] = "no";
274
+ res.writeHead(status, responseHeaders);
275
+ res.flushHeaders();
276
+ // The bounded observer reads usage metadata only; original bytes and
277
+ // pipe backpressure are preserved for native Responses and JSON.
278
+ usageObserver =
279
+ tracker && status >= 200 && status < 300
280
+ ? createUsageObserver(upstream.headers, tracker)
281
+ : null;
282
+ if (usageObserver) upstreamStream.pipe(usageObserver).pipe(res);
283
+ else upstreamStream.pipe(res);
284
+ } catch (error) {
285
+ fail(upstreamFailure(null, error), error.status || 502);
286
+ }
287
+ }
288
+
289
+ const server = http.createServer((req, res) => {
290
+ if (req.method === "GET" && (req.url === "/health" || req.url?.startsWith("/health?"))) {
291
+ res.writeHead(200, {
292
+ "content-type": "application/json",
293
+ "cache-control": "no-store",
294
+ });
295
+ res.end(JSON.stringify({
296
+ service: "brink",
297
+ status: "ready",
298
+ port: server.address()?.port || LISTEN_PORT,
299
+ }));
300
+ return;
301
+ }
302
+ let target;
303
+ try {
304
+ target = new URL(getUpstreamPath(req.url), UPSTREAM_ORIGIN);
305
+ } catch {
306
+ sendError(res, 400, "Invalid request path");
307
+ return;
308
+ }
309
+ if (req.method === "GET") {
310
+ sendUpstream(req, res, target, "");
311
+ return;
312
+ }
313
+ if (req.method !== "POST") {
314
+ sendError(res, 405, "Only GET and POST requests are supported");
315
+ return;
316
+ }
317
+
318
+ const chunks = [];
319
+ let bytes = 0;
320
+ let rejected = false;
321
+ const rejectBody = (status, message) => {
322
+ if (rejected) return;
323
+ rejected = true;
324
+ chunks.length = 0;
325
+ monitor.log("warn", message);
326
+ sendError(res, status, message, true);
327
+ req.resume();
328
+ };
329
+ req.once("error", () => rejectBody(400, "Could not read request body"));
330
+ if (Number(req.headers["content-length"]) > MAX_REQUEST_BYTES) {
331
+ rejectBody(413, "Request body exceeds the 32 MiB limit");
332
+ return;
333
+ }
334
+ req.on("data", (chunk) => {
335
+ if (rejected) return;
336
+ bytes += chunk.length;
337
+ if (bytes > MAX_REQUEST_BYTES) {
338
+ rejectBody(413, "Request body exceeds the 32 MiB limit");
339
+ return;
340
+ }
341
+ chunks.push(chunk);
342
+ });
343
+ req.once("end", () => {
344
+ if (rejected || req.aborted || res.destroyed) return;
345
+ const rawBody = Buffer.concat(chunks, bytes);
346
+ chunks.length = 0;
347
+ let parsed;
348
+ try {
349
+ parsed = JSON.parse(rawBody.toString("utf8"));
350
+ } catch {
351
+ rejectBody(400, "Request body is not valid JSON");
352
+ return;
353
+ }
354
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
355
+ rejectBody(400, "Request body must be a JSON object");
356
+ return;
357
+ }
358
+ const bridged =
359
+ target.pathname === "/v1/responses" &&
360
+ typeof parsed.model === "string" &&
361
+ parsed.model.startsWith("claude-");
362
+ const messagesBridged = target.pathname === "/v1/messages" &&
363
+ typeof parsed.model === "string" && /^(?:gpt-|o[134](?:-|$))/i.test(parsed.model);
364
+ const nativeMessages = target.pathname === "/v1/messages" ||
365
+ target.pathname === "/v1/messages/count_tokens";
366
+ const tracker = monitor.begin(req, res, {
367
+ model: typeof parsed.model === "string" ? parsed.model : null,
368
+ route: bridged ? "claude-bridge" : messagesBridged ? "messages-bridge" : nativeMessages ? "native-messages" : "native",
369
+ path: target.pathname,
370
+ });
371
+ if (messagesBridged) {
372
+ bridgeMessages(req, res, parsed, { agent, tracker }).catch(() => {
373
+ if (res.destroyed || res.writableEnded) return;
374
+ tracker.fail("Messages bridge could not complete the response");
375
+ sendError(res, 502, "Messages bridge could not complete the response");
376
+ });
377
+ return;
378
+ }
379
+ if (bridged) {
380
+ bridgeResponses(req, res, parsed, { agent, tracker }).catch(() => {
381
+ if (res.destroyed || res.writableEnded) return;
382
+ tracker.fail("Claude bridge could not complete the response");
383
+ sendError(res, 502, "Claude bridge could not complete the response");
384
+ });
385
+ return;
386
+ }
387
+ // Other Messages models and token counting retain their native endpoint.
388
+ if (target.pathname !== "/v1/responses") {
389
+ sendUpstream(req, res, target, rawBody, tracker);
390
+ return;
391
+ }
392
+ const effortFix = normalizeReasoningEffort(parsed);
393
+ if (effortFix) {
394
+ parsed = {
395
+ ...parsed,
396
+ reasoning: { ...parsed.reasoning, effort: effortFix.to },
397
+ };
398
+ tracker.note(
399
+ "warn",
400
+ `AgentRouter GLM accepts only low/high/max reasoning effort; mapped ${effortFix.from} to ${effortFix.to}`,
401
+ );
402
+ }
403
+ const cleaned = sanitizeRequest(parsed);
404
+ if (cleaned.payload !== parsed) {
405
+ tracker.note(
406
+ "info",
407
+ `Sanitized history: removed ${cleaned.removedIds} item IDs, ${cleaned.removedEncryptedContent} encrypted reasoning fields, ${cleaned.removedReferences} reasoning references`,
408
+ );
409
+ }
410
+ const changedBody = effortFix != null || cleaned.payload !== parsed;
411
+ const outgoingBody = changedBody
412
+ ? JSON.stringify(cleaned.payload)
413
+ : rawBody;
414
+ sendUpstream(req, res, target, outgoingBody, tracker);
415
+ });
416
+ });
417
+
418
+ // Reasoning has no total request deadline. Shared transport enforces connection,
419
+ // header and inactivity deadlines independently for every attempt.
420
+ server.requestTimeout = 0;
421
+ server.headersTimeout = 30_000;
422
+ server.on("clientError", (error, socket) => {
423
+ if (socket.writable && !socket.destroyed)
424
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
425
+ });
426
+
427
+ let shuttingDown = false;
428
+ function shutdown(exitCode = 0) {
429
+ if (shuttingDown) return;
430
+ shuttingDown = true;
431
+ const finish = () => {
432
+ clearTimeout(grace);
433
+ agent.destroy();
434
+ terminalUI?.close();
435
+ monitor.close();
436
+ process.exit(exitCode);
437
+ };
438
+ const grace = setTimeout(() => {
439
+ server.closeAllConnections();
440
+ finish();
441
+ }, 5_000);
442
+ monitor.log(
443
+ "info",
444
+ "Proxy shutting down; allowing up to 5 seconds for active requests",
445
+ );
446
+ server.close(finish);
447
+ server.closeIdleConnections();
448
+ }
449
+ process.once("SIGINT", () => shutdown());
450
+ process.once("SIGTERM", () => shutdown());
451
+ server.on("error", (error) => {
452
+ const message =
453
+ error.code === "EADDRINUSE"
454
+ ? `Brink cannot use port ${LISTEN_PORT}: it is already in use. Run brink to check for an existing Brink proxy or choose another port; do not kill an unrelated process.`
455
+ : "Brink could not start its local listener; run brink to try again";
456
+ monitor.log("error", message);
457
+ agent.destroy();
458
+ monitor.close();
459
+ process.exitCode = 1;
460
+ });
461
+ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
462
+ if (
463
+ process.stdin.isTTY &&
464
+ process.stdout.isTTY &&
465
+ process.env.TERM !== "dumb" &&
466
+ process.env.AGENTROUTER_PROXY_PLAIN !== "1"
467
+ ) {
468
+ terminalUI = startTerminalUI(monitor, {
469
+ clients: process.env.BRINK_CLIENTS?.split(","),
470
+ });
471
+ }
472
+ monitor.log(
473
+ "info",
474
+ `Brink listening on http://${LISTEN_HOST}:${LISTEN_PORT}; keep this terminal open`,
475
+ );
476
+ monitor.log(
477
+ "info",
478
+ "AgentRouter upstream: https://agentrouter.org/v1; transient-failure retry waits 1/2/3/4/5 seconds before output (client-owned retries bypassed); no model or credential substitution",
479
+ );
480
+ });