@juspay/neurolink 12.20.2 → 12.21.1

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.
@@ -13,6 +13,8 @@
13
13
  * here because nothing reshapes them.
14
14
  */
15
15
  import { GoogleAuth } from "google-auth-library";
16
+ import { logger } from "../utils/logger.js";
17
+ import { isTransientNetworkError } from "./proxyFetch.js";
16
18
  /** Vertex pins the Anthropic API version in the body, not a header. */
17
19
  const VERTEX_ANTHROPIC_VERSION = "vertex-2023-10-16";
18
20
  const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
@@ -68,7 +70,114 @@ export function mapVertexOutputConfig(value) {
68
70
  const { effort: _unsupported, ...rest } = value;
69
71
  return Object.keys(rest).length > 0 ? rest : undefined;
70
72
  }
71
- /** Strip what Vertex rejects and pin the API version it requires. */
73
+ /**
74
+ * Vertex's message schema is exactly `{ role, content }`. Claude Code 2.1.x
75
+ * also puts `output_config` on its system "directive" turn, which the
76
+ * first-party API accepts and Vertex refuses: "messages.1.output_config:
77
+ * Extra inputs are not permitted".
78
+ */
79
+ const VERTEX_MESSAGE_KEYS = new Set(["role", "content"]);
80
+ /**
81
+ * Claude Code stamps its version into a system block as
82
+ * `x-anthropic-billing-header: cc_version=…`. That is first-party billing
83
+ * plumbing; on Vertex it only triggers a client-version check against the
84
+ * fallback model, which the client never asked for ("Claude Code 2.1.278 does
85
+ * not support this model"). It also sits first in `system` and varies with the
86
+ * client build, entrypoint and subagent flag, so identical conversations from
87
+ * different builds could never share a Vertex prompt cache while it stayed.
88
+ *
89
+ * Matched as a prefix, not anywhere in the text: the generator
90
+ * (`buildStableClaudeCodeBillingHeader`) always emits it first, and a looser
91
+ * match would delete a caller's own block that merely mentions the header.
92
+ */
93
+ const BILLING_HEADER_MARKER = "x-anthropic-billing-header";
94
+ /**
95
+ * How each mid-conversation tool change reads once it is plain text: the prefix
96
+ * when the tool is named, and a sentence for a reference with no usable name.
97
+ */
98
+ const TOOL_CHANGE_TEXT = {
99
+ tool_addition: {
100
+ named: "Tool now available: ",
101
+ unnamed: "A tool became available.",
102
+ },
103
+ tool_removal: {
104
+ named: "Tool no longer available: ",
105
+ unnamed: "A tool was withdrawn.",
106
+ },
107
+ };
108
+ /**
109
+ * A change references a tool rather than defining one: `tool_reference` and
110
+ * `mcp_tool_reference` carry a `name`, and an MCP toolset reference carries
111
+ * only its `server_name`.
112
+ */
113
+ function toolChangeText(type, tool) {
114
+ const wording = TOOL_CHANGE_TEXT[type];
115
+ const label = !record(tool)
116
+ ? ""
117
+ : typeof tool.name === "string" && tool.name.trim()
118
+ ? tool.name.trim()
119
+ : typeof tool.server_name === "string"
120
+ ? tool.server_name.trim()
121
+ : "";
122
+ return label ? `${wording.named}${label}` : wording.unnamed;
123
+ }
124
+ /**
125
+ * Vertex rejects the `tool_addition` and `tool_removal` tags that offer or
126
+ * withdraw a tool mid-conversation. Each one becomes a text block in the same
127
+ * position, so the notice survives and the array never empties. Only the
128
+ * message's own content array is touched: nested `tool_result` content stays
129
+ * exactly as sent.
130
+ */
131
+ function rewriteToolChanges(content) {
132
+ if (!Array.isArray(content)) {
133
+ return content;
134
+ }
135
+ let changed = false;
136
+ const mapped = content.map((block) => {
137
+ if (!record(block) ||
138
+ typeof block.type !== "string" ||
139
+ !Object.hasOwn(TOOL_CHANGE_TEXT, block.type)) {
140
+ return block;
141
+ }
142
+ changed = true;
143
+ return {
144
+ type: "text",
145
+ text: toolChangeText(block.type, block.tool),
146
+ ...(block.cache_control !== undefined
147
+ ? { cache_control: block.cache_control }
148
+ : {}),
149
+ };
150
+ });
151
+ return changed ? mapped : content;
152
+ }
153
+ function isEmptyContent(content) {
154
+ return (content === undefined ||
155
+ content === "" ||
156
+ (Array.isArray(content) && content.length === 0));
157
+ }
158
+ /**
159
+ * Move a `cache_control` set on the message itself onto its last content
160
+ * block, which caches the same prefix, since Vertex accepts only `role` and
161
+ * `content` on a message. String content becomes one text block to carry it.
162
+ * A breakpoint with no block to land on, or whose block already has one, is
163
+ * reported rather than dropped silently.
164
+ */
165
+ function carryMessageBreakpoint(content, marker) {
166
+ if (marker === undefined) {
167
+ return content;
168
+ }
169
+ if (typeof content === "string" && content !== "") {
170
+ return [{ type: "text", text: content, cache_control: marker }];
171
+ }
172
+ const last = Array.isArray(content) ? content.at(-1) : undefined;
173
+ if (Array.isArray(content) &&
174
+ record(last) &&
175
+ last.cache_control === undefined) {
176
+ return [...content.slice(0, -1), { ...last, cache_control: marker }];
177
+ }
178
+ warnDroppedBreakpoint("a message-level cache_control");
179
+ return content;
180
+ }
72
181
  /**
73
182
  * Vertex refuses `role: "system"` messages outright — "role 'system' is not
74
183
  * supported on this model" — where the first-party Messages API accepts them
@@ -82,25 +191,156 @@ export function mapVertexOutputConfig(value) {
82
191
  * instead, which keeps both the text and the position. Consecutive same-role
83
192
  * turns are accepted, so this never produces an invalid sequence.
84
193
  */
85
- function normalizeVertexRoles(messages) {
194
+ function normalizeVertexMessage(message) {
195
+ if (!record(message)) {
196
+ return message;
197
+ }
198
+ const role = message.role === "system" ? "user" : message.role;
199
+ const content = carryMessageBreakpoint(rewriteToolChanges(message.content), message.cache_control);
200
+ const onlyKnownKeys = Object.keys(message).every((key) => VERTEX_MESSAGE_KEYS.has(key));
201
+ if (onlyKnownKeys && role === message.role && content === message.content) {
202
+ return message;
203
+ }
204
+ return { role, ...("content" in message ? { content } : {}) };
205
+ }
206
+ /** Index of the last `role: "user"` message, or -1 when there is none. */
207
+ function lastUserIndex(messages) {
208
+ return messages.findLastIndex((message) => record(message) && message.role === "user");
209
+ }
210
+ /**
211
+ * A turn-scoped system message (`clear_at: "next_user_message"`) renders only
212
+ * while no user message comes after it — one carrying only `tool_result`
213
+ * counts — and renders nothing once one does. Sent to Vertex as a user turn it
214
+ * would keep speaking after it should have gone quiet.
215
+ */
216
+ function isClearedTurnScopedMessage(message, index, lastUser) {
217
+ return (record(message) &&
218
+ message.role === "system" &&
219
+ message.clear_at === "next_user_message" &&
220
+ index < lastUser);
221
+ }
222
+ /**
223
+ * A system turn left with no content carries nothing once its extra keys are
224
+ * gone — the directive-only form is `content: []` plus `output_config`, whose
225
+ * effort is resolved at the top level — so it is dropped rather than sent as
226
+ * an empty user turn. So is a turn-scoped message that has already cleared.
227
+ */
228
+ function normalizeVertexMessages(messages) {
86
229
  if (!Array.isArray(messages)) {
87
230
  return messages;
88
231
  }
232
+ const lastUser = lastUserIndex(messages);
89
233
  let changed = false;
90
- const mapped = messages.map((message) => {
91
- if (!record(message) || message.role !== "system") {
92
- return message;
234
+ const normalized = messages.flatMap((message, index) => {
235
+ if (isClearedTurnScopedMessage(message, index, lastUser)) {
236
+ changed = true;
237
+ return [];
93
238
  }
94
- changed = true;
95
- return { ...message, role: "user" };
239
+ const next = normalizeVertexMessage(message);
240
+ if (next !== message) {
241
+ changed = true;
242
+ }
243
+ if (record(message) &&
244
+ message.role === "system" &&
245
+ record(next) &&
246
+ isEmptyContent(next.content)) {
247
+ return [];
248
+ }
249
+ return [next];
96
250
  });
97
- return changed ? mapped : messages;
251
+ return changed ? normalized : messages;
252
+ }
253
+ /**
254
+ * The effort in force for the turn being answered. A per-message directive
255
+ * "takes effect from the next user turn and holds until a later message changes
256
+ * it", so the governing one is the last directive before the final user
257
+ * message; one after it belongs to a turn not yet taken. It overrides only the
258
+ * settings it names. Vertex has no per-message effort, so this becomes the
259
+ * request's top-level `output_config`.
260
+ */
261
+ function resolveOutputConfig(topLevel, messages) {
262
+ if (!Array.isArray(messages)) {
263
+ return topLevel;
264
+ }
265
+ const lastUser = lastUserIndex(messages);
266
+ const directive = messages
267
+ .slice(0, Math.max(lastUser, 0))
268
+ .findLast((message) => record(message) &&
269
+ message.role === "system" &&
270
+ record(message.output_config));
271
+ if (!record(directive) || !record(directive.output_config)) {
272
+ return topLevel;
273
+ }
274
+ return {
275
+ ...(record(topLevel) ? topLevel : {}),
276
+ ...directive.output_config,
277
+ };
278
+ }
279
+ function isBillingBlock(block) {
280
+ return (record(block) &&
281
+ typeof block.text === "string" &&
282
+ block.text.trimStart().startsWith(BILLING_HEADER_MARKER));
283
+ }
284
+ function warnDroppedBreakpoint(source) {
285
+ logger.warn(`[proxy] Vertex fallback could not keep the cache_control breakpoint on ${source}`);
286
+ }
287
+ /**
288
+ * Remove the billing block without losing a cache breakpoint it carried: the
289
+ * marker moves onto the block that takes its place. When there is no such
290
+ * block, or that block already has its own marker, the loss is reported rather
291
+ * than passed over, because this path bills in real currency.
292
+ */
293
+ function stripBillingBlock(system) {
294
+ if (!Array.isArray(system) || !system.some(isBillingBlock)) {
295
+ return system;
296
+ }
297
+ const kept = [];
298
+ let carried;
299
+ for (const block of system) {
300
+ if (isBillingBlock(block)) {
301
+ if (record(block) && block.cache_control !== undefined) {
302
+ if (carried !== undefined) {
303
+ warnDroppedBreakpoint("the Claude Code billing block");
304
+ }
305
+ carried = block.cache_control;
306
+ }
307
+ continue;
308
+ }
309
+ if (carried !== undefined && record(block)) {
310
+ if (block.cache_control === undefined) {
311
+ kept.push({ ...block, cache_control: carried });
312
+ }
313
+ else {
314
+ warnDroppedBreakpoint("the Claude Code billing block");
315
+ kept.push(block);
316
+ }
317
+ carried = undefined;
318
+ continue;
319
+ }
320
+ kept.push(block);
321
+ }
322
+ if (carried !== undefined) {
323
+ warnDroppedBreakpoint("the Claude Code billing block");
324
+ }
325
+ return kept.length > 0 ? kept : undefined;
98
326
  }
327
+ /**
328
+ * Strip what Vertex rejects and pin the API version it requires.
329
+ *
330
+ * Prompt caching constrains every rewrite here. Anthropic caches only up to an
331
+ * explicit `cache_control` marker, at most four per request, over a prefix
332
+ * rendered `tools → system → messages`; anything removed or inserted before a
333
+ * marker invalidates it and everything after. So: never drop a marker, never
334
+ * reorder `system`, and keep each rewrite deterministic so consecutive turns
335
+ * share a prefix. Vertex holds its own cache, apart from the first-party one,
336
+ * so nothing here can disturb first-party hits.
337
+ */
99
338
  export function buildVertexAnthropicPayload(body) {
100
339
  const dropped = new Set(UNSUPPORTED_FIELDS);
101
340
  const payload = Object.fromEntries(Object.entries(body).filter(([key]) => !dropped.has(key)));
102
- if ("output_config" in payload) {
103
- const mapped = mapVertexOutputConfig(payload.output_config);
341
+ const outputConfig = resolveOutputConfig(payload.output_config, payload.messages);
342
+ if (outputConfig !== undefined) {
343
+ const mapped = mapVertexOutputConfig(outputConfig);
104
344
  if (mapped === undefined) {
105
345
  delete payload.output_config;
106
346
  }
@@ -109,7 +349,16 @@ export function buildVertexAnthropicPayload(body) {
109
349
  }
110
350
  }
111
351
  if ("messages" in payload) {
112
- payload.messages = normalizeVertexRoles(payload.messages);
352
+ payload.messages = normalizeVertexMessages(payload.messages);
353
+ }
354
+ if ("system" in payload) {
355
+ const system = stripBillingBlock(payload.system);
356
+ if (system === undefined) {
357
+ delete payload.system;
358
+ }
359
+ else {
360
+ payload.system = system;
361
+ }
113
362
  }
114
363
  return { ...payload, anthropic_version: VERTEX_ANTHROPIC_VERSION };
115
364
  }
@@ -159,12 +408,56 @@ async function vertexAccessToken() {
159
408
  export function setVertexAccessTokenProviderForTests(provider) {
160
409
  accessTokenProviderForTests = provider;
161
410
  }
411
+ const DNS_FAILURE_CODES = new Set([
412
+ "ENOTFOUND",
413
+ "EAI_AGAIN",
414
+ ]);
415
+ const DNS_FAILURE_MESSAGE = /\bgetaddrinfo (ENOTFOUND|EAI_AGAIN)\b/;
416
+ const TOKEN_RETRY_DELAY_MS = 250;
417
+ /**
418
+ * A resolver failure while reaching Google's token endpoint. The shared proxy
419
+ * classifier leaves DNS codes out, since elsewhere one can mean a mistyped
420
+ * host, but this endpoint is fixed and its failures on 2026-09-23 were blips
421
+ * reported only in the message text ("getaddrinfo ENOTFOUND
422
+ * oauth2.googleapis.com").
423
+ */
424
+ function isDnsFailure(error) {
425
+ let current = error;
426
+ for (let depth = 0; depth < 5 && record(current); depth++) {
427
+ if ((typeof current.code === "string" &&
428
+ DNS_FAILURE_CODES.has(current.code)) ||
429
+ (typeof current.message === "string" &&
430
+ DNS_FAILURE_MESSAGE.test(current.message))) {
431
+ return true;
432
+ }
433
+ current = current.cause;
434
+ }
435
+ return false;
436
+ }
437
+ /**
438
+ * Run once more after a short pause when the first attempt hit a transient
439
+ * network failure: anything the shared proxy classifier retries, plus a DNS
440
+ * blip. One retry absorbs a blip without hiding a real outage: a second
441
+ * failure, or any other error, surfaces unchanged.
442
+ */
443
+ export async function withTransientNetworkRetry(run, delayMs = TOKEN_RETRY_DELAY_MS) {
444
+ try {
445
+ return await run();
446
+ }
447
+ catch (error) {
448
+ if (!isTransientNetworkError(error) && !isDnsFailure(error)) {
449
+ throw error;
450
+ }
451
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
452
+ return run();
453
+ }
454
+ }
162
455
  /**
163
456
  * Send one Anthropic-shaped request to Vertex and return the upstream response
164
457
  * untouched, so the caller can stream its bytes straight to the client.
165
458
  */
166
459
  export async function dispatchVertexAnthropicPassthrough(request) {
167
- const token = await vertexAccessToken();
460
+ const token = await withTransientNetworkRetry(() => vertexAccessToken());
168
461
  if (!token) {
169
462
  throw new Error("Vertex passthrough could not obtain a Google access token; " +
170
463
  "check Application Default Credentials for this project");
@@ -295,6 +295,9 @@ export async function classifyJev(input, decide, timeoutMs, candidates, threshol
295
295
  // are already assembled by the caller. Sending them costs almost nothing
296
296
  // and they are exactly what distinguishes a long tool-using request from a
297
297
  // short conversational one.
298
+ // Continuity is sent as `session_bound` / `prior_messages`, never as the
299
+ // raw sessionId: the id is an opaque identifier that tells a third-party
300
+ // model nothing, so it stays in-process.
298
301
  const result = await decide({
299
302
  state: {
300
303
  request: (input.prompt ?? "").slice(0, 8000),
@@ -302,6 +305,10 @@ export async function classifyJev(input, decide, timeoutMs, candidates, threshol
302
305
  has_tools_available: input.hasTools ?? false,
303
306
  request_includes_images: input.requiresVision ?? false,
304
307
  caller_requested_thinking: input.thinkingLevel ?? "none",
308
+ session_bound: input.sessionBound ?? false,
309
+ ...(typeof input.priorMessageCount === "number"
310
+ ? { prior_messages: input.priorMessageCount }
311
+ : {}),
305
312
  },
306
313
  questions,
307
314
  timeoutMs,
@@ -143,6 +143,34 @@ function buildCodexErrorResponse(status, message, code) {
143
143
  },
144
144
  }), { status, headers: { "content-type": "application/json" } });
145
145
  }
146
+ /**
147
+ * Build a terminal Codex Responses SSE stream for a quota-exhaustion failure
148
+ * that never reached an upstream request (every pooled account is cooling).
149
+ * A bare non-2xx status with no body reads to the Codex CLI as a dropped
150
+ * connection, so it shows "Reconnecting... waiting for network" forever
151
+ * instead of a real error. Emitting a well-formed `response.failed` event —
152
+ * the same terminal shape the CLI already parses out of a live stream —
153
+ * lets it render the actual failure instead.
154
+ */
155
+ function buildCodexQuotaExhaustedResponse(message, retryAfterSec) {
156
+ const payload = {
157
+ type: "response.failed",
158
+ response: {
159
+ error: {
160
+ type: "insufficient_quota",
161
+ code: "insufficient_quota",
162
+ message,
163
+ },
164
+ },
165
+ };
166
+ const headers = {
167
+ "content-type": "text/event-stream",
168
+ };
169
+ if (retryAfterSec !== undefined) {
170
+ headers["retry-after"] = String(retryAfterSec);
171
+ }
172
+ return new Response(`event: response.failed\ndata: ${JSON.stringify(payload)}\n\n`, { status: 200, headers });
173
+ }
146
174
  /**
147
175
  * Load the Codex account pool from the token store, refreshing expired OAuth
148
176
  * tokens and hydrating cooldown + quota state from disk.
@@ -654,18 +682,10 @@ async function executeCodexResponsesRequest(ctx) {
654
682
  errorType: "all_accounts_cooling",
655
683
  errorMessage: "All Codex accounts are rate-limited",
656
684
  });
657
- return new Response(JSON.stringify({
658
- error: {
659
- type: "rate_limit_error",
660
- message: "All Codex accounts are currently rate-limited",
661
- },
662
- }), {
663
- status: 429,
664
- headers: {
665
- "content-type": "application/json",
666
- "retry-after": String(retryAfterSec),
667
- },
668
- });
685
+ const resetAt = soonest ? new Date(soonest).toISOString() : undefined;
686
+ return buildCodexQuotaExhaustedResponse(resetAt
687
+ ? `Codex quota exhausted: all accounts are rate-limited. Resets at ${resetAt}.`
688
+ : "Codex quota exhausted: all accounts are rate-limited.", retryAfterSec);
669
689
  }
670
690
  let attempt = 0;
671
691
  let lastErrorMessage = "All Codex accounts failed";
@@ -296,6 +296,10 @@ export type ClassifierRouterInput = {
296
296
  requiresVision?: boolean;
297
297
  thinkingLevel?: string;
298
298
  sessionId?: string;
299
+ /** Whether this request is tied to a session, independent of the (withheld) session id itself. */
300
+ sessionBound?: boolean;
301
+ /** Number of prior conversation messages the caller supplied, when known. */
302
+ priorMessageCount?: number;
299
303
  };
300
304
  /** Enriched per-model metadata used while ranking pool members. */
301
305
  export type ClassifierModelMeta = {
@@ -1296,6 +1296,18 @@ export type AutoresearchInitArgs = {
1296
1296
  provider?: string;
1297
1297
  model?: string;
1298
1298
  };
1299
+ /** Arguments for `neurolink decide` — the third inference type's CLI surface. */
1300
+ export type CliDecideArgs = {
1301
+ state?: string;
1302
+ stateFile?: string;
1303
+ questions?: string;
1304
+ questionsFile?: string;
1305
+ provider?: string;
1306
+ model?: string;
1307
+ timeout?: number;
1308
+ format?: "text" | "json";
1309
+ debug?: boolean;
1310
+ };
1299
1311
  /** Base options shared across all `neurolink evaluate` subcommands. */
1300
1312
  export type BaseEvaluateArgs = {
1301
1313
  json?: boolean;
@@ -119,6 +119,9 @@ export type CodexProxyStatusAccountIdentity = {
119
119
  } | {
120
120
  provider: "codex";
121
121
  key: string;
122
+ } | {
123
+ provider: "vertex";
124
+ key: string;
122
125
  } | {
123
126
  provider: "other";
124
127
  key: null;
@@ -3221,7 +3221,7 @@ export type StatusStats = {
3221
3221
  accounts?: {
3222
3222
  /** Provider-qualified key; null for explicitly unattributed legacy rows. */
3223
3223
  key?: string | null;
3224
- provider?: "anthropic" | "codex" | "other" | "unknown";
3224
+ provider?: "anthropic" | "codex" | "vertex" | "other" | "unknown";
3225
3225
  label: string;
3226
3226
  type: string;
3227
3227
  attempts?: number;
@@ -187,8 +187,11 @@ export type CliAccountsRow = {
187
187
  /**
188
188
  * Which pool engine owns this login. Absent on plumbing rows. Consumers
189
189
  * that key a list by row must key by `key`, not `label` — see above.
190
+ *
191
+ * `"vertex"` is not a login: native fallback legs are keyed by served
192
+ * model, with no OAuth account behind them, so they carry no token state.
190
193
  */
191
- provider?: "anthropic" | "codex";
194
+ provider?: "anthropic" | "codex" | "vertex";
192
195
  /**
193
196
  * What this row actually is. Only "account" rows are real logins; the proxy
194
197
  * also tracks internal and translation pseudo-accounts, which have no quota
@@ -19,6 +19,7 @@ declare class NeuroLinkLogger {
19
19
  private logs;
20
20
  private maxLogs;
21
21
  private isDebugMode;
22
+ private diagnosticsToStderr;
22
23
  private eventEmitter?;
23
24
  constructor();
24
25
  /**
@@ -49,6 +50,14 @@ declare class NeuroLinkLogger {
49
50
  * @param level - The minimum log level to process ("debug", "info", "warn", or "error")
50
51
  */
51
52
  setLogLevel(level: LogLevel): void;
53
+ /**
54
+ * Routes debug and info output to stderr instead of stdout. The CLI turns
55
+ * this on for `--format json`, where stdout carries the payload and any
56
+ * diagnostic line in it would make the output unparseable.
57
+ *
58
+ * @param enabled - True to send debug/info to stderr; false restores stdout
59
+ */
60
+ setDiagnosticsToStderr(enabled: boolean): void;
52
61
  /**
53
62
  * Determines whether a message with the given log level should be processed.
54
63
  * This method considers both the configured log level and the current debug mode.
@@ -222,6 +231,7 @@ export declare const logger: {
222
231
  table: (data: unknown) => void;
223
232
  shouldLog: (level: LogLevel) => boolean;
224
233
  setLogLevel: (level: LogLevel) => void;
234
+ setDiagnosticsToStderr: (enabled: boolean) => void;
225
235
  getLogs: (level?: LogLevel) => LogEntry[];
226
236
  clearLogs: () => void;
227
237
  setEventEmitter: (emitter: {
@@ -36,11 +36,24 @@ const UPPERCASE_LOG_LEVELS = {
36
36
  warn: "WARN",
37
37
  error: "ERROR",
38
38
  };
39
+ const FORMAT_FLAGS = ["--format", "-f", "--output-format"];
40
+ /**
41
+ * True when the process was started with JSON output requested. Read from
42
+ * argv on every write, like the `--debug` check in `shouldLog`: modules log
43
+ * while they are still being imported, before the CLI middleware that calls
44
+ * `setDiagnosticsToStderr` has run.
45
+ */
46
+ function argvRequestsJsonOutput() {
47
+ const argv = process.argv;
48
+ return argv.some((arg, index) => (FORMAT_FLAGS.includes(arg) && argv[index + 1] === "json") ||
49
+ FORMAT_FLAGS.some((flag) => arg === `${flag}=json`));
50
+ }
39
51
  class NeuroLinkLogger {
40
52
  logLevel = "info";
41
53
  logs = [];
42
54
  maxLogs = 1000;
43
55
  isDebugMode;
56
+ diagnosticsToStderr = false;
44
57
  eventEmitter;
45
58
  constructor() {
46
59
  // Cache debug mode check to avoid repeated array searches
@@ -86,6 +99,16 @@ class NeuroLinkLogger {
86
99
  setLogLevel(level) {
87
100
  this.logLevel = level;
88
101
  }
102
+ /**
103
+ * Routes debug and info output to stderr instead of stdout. The CLI turns
104
+ * this on for `--format json`, where stdout carries the payload and any
105
+ * diagnostic line in it would make the output unparseable.
106
+ *
107
+ * @param enabled - True to send debug/info to stderr; false restores stdout
108
+ */
109
+ setDiagnosticsToStderr(enabled) {
110
+ this.diagnosticsToStderr = enabled;
111
+ }
89
112
  /**
90
113
  * Determines whether a message with the given log level should be processed.
91
114
  * This method considers both the configured log level and the current debug mode.
@@ -209,9 +232,10 @@ class NeuroLinkLogger {
209
232
  * @param data - Optional additional data to log.
210
233
  */
211
234
  outputToConsole(level, prefix, message, data) {
235
+ const toStderr = this.diagnosticsToStderr || argvRequestsJsonOutput();
212
236
  const logMethod = {
213
- debug: console.debug,
214
- info: console.info,
237
+ debug: toStderr ? console.error : console.debug,
238
+ info: toStderr ? console.error : console.info,
215
239
  warn: console.warn,
216
240
  error: console.error,
217
241
  }[level];
@@ -467,6 +491,7 @@ export const logger = {
467
491
  shouldLog: (level) => neuroLinkLogger.shouldLog(level),
468
492
  // Expose structured logging methods
469
493
  setLogLevel: (level) => neuroLinkLogger.setLogLevel(level),
494
+ setDiagnosticsToStderr: (enabled) => neuroLinkLogger.setDiagnosticsToStderr(enabled),
470
495
  getLogs: (level) => neuroLinkLogger.getLogs(level),
471
496
  clearLogs: () => neuroLinkLogger.clearLogs(),
472
497
  setEventEmitter: (emitter) => neuroLinkLogger.setEventEmitter(emitter),