@juspay/neurolink 11.18.1 → 11.18.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.
@@ -895,6 +895,11 @@ function printProxyBanner(url, strategy) {
895
895
  logger.always(chalk.bold("Endpoints:"));
896
896
  logger.always(` ${chalk.blue("POST")} /v1/messages — Claude proxy (Anthropic format)`);
897
897
  logger.always(` ${chalk.blue("POST")} /v1/chat/completions — OpenAI-compatible proxy`);
898
+ // The banner listed two of the four inbound doors, so the Codex and Gemini
899
+ // CLIs looked unsupported to anyone reading start-up output rather than the
900
+ // docs. Every door the proxy actually answers on belongs here.
901
+ logger.always(` ${chalk.blue("POST")} /backend-api/codex/… — Codex proxy (Responses format)`);
902
+ logger.always(` ${chalk.blue("POST")} /v1beta/models/… — Gemini proxy (generateContent)`);
898
903
  logger.always(` ${chalk.green("GET")} /health — Health check`);
899
904
  logger.always(` ${chalk.green("GET")} /status — Detailed status`);
900
905
  logger.always("");
@@ -1149,9 +1154,17 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
1149
1154
  throw error;
1150
1155
  }
1151
1156
  };
1152
- // Cover both the Anthropic (/v1/*) and Codex (/backend-api/*) inbound paths so
1153
- // drain/reject, lifecycle logging, and concurrency accounting apply to both.
1157
+ // Cover every inbound door so drain/reject, lifecycle logging, and
1158
+ // concurrency accounting apply to all of them.
1159
+ //
1160
+ // `/v1beta/*` is listed separately on purpose: Hono matches wildcards a path
1161
+ // segment at a time, so `/v1/*` does NOT cover `/v1beta/models/...` — the
1162
+ // segment is `v1beta`, not `v1`. When the Gemini door landed it inherited
1163
+ // neither tracker, which meant its requests were absent from the request
1164
+ // log, from per-CLI attribution, and from the in-flight count the graceful
1165
+ // drain waits on. An update could therefore cut a live Gemini stream.
1154
1166
  app.use("/v1/*", trackingHandler);
1167
+ app.use("/v1beta/*", trackingHandler);
1155
1168
  app.use("/backend-api/*", trackingHandler);
1156
1169
  }
1157
1170
  export async function createProxyStartApp(params) {
@@ -169,6 +169,12 @@ export async function writeFileAtomic(filePath, contents, mode) {
169
169
  }
170
170
  }
171
171
  try {
172
+ // The temp file is a sibling of the destination, so a missing parent fails
173
+ // the write rather than the rename — the config is untouched, but the
174
+ // caller sees an ENOENT naming a path it never asked to write. Creating
175
+ // the directory first makes a first-run write behave like the plain
176
+ // writeFileSync it replaced.
177
+ fs.mkdirSync(dirname(filePath), { recursive: true });
172
178
  fs.writeFileSync(tempPath, contents, { mode: effectiveMode });
173
179
  stage = "chmod";
174
180
  fs.chmodSync(tempPath, effectiveMode);
@@ -22,17 +22,38 @@ import type { ParsedGeminiRequest, StreamSerializerAdapter } from "../types/inde
22
22
  /**
23
23
  * Parse a `generateContent` body into the shape the translation engine takes.
24
24
  *
25
- * The final user turn becomes `prompt`; everything before it becomes
26
- * `conversationMessages`, with Google's `model` role mapped to `assistant` so
27
- * downstream providers see a role they understand.
25
+ * The final user turn becomes `prompt`, with Google's `model` role mapped to
26
+ * `assistant` so downstream providers see a role they understand.
27
+ *
28
+ * `conversationMessages` carries EVERY turn, the final one included. That
29
+ * looks redundant next to `prompt`, and it is the contract the shared engine
30
+ * expects: `buildTranslationOptions` does `conversationMessages.slice(0, -1)`
31
+ * to derive history, because the final turn is already being sent as `prompt`.
32
+ * `claudeFormat` and `openaiFormat` both push unconditionally for that reason.
33
+ * Excluding the last turn here — the intuitive reading of "history" — made the
34
+ * engine's slice eat one real turn instead, so every multi-turn Gemini request
35
+ * silently lost its most recent message.
28
36
  */
29
37
  export declare function parseGeminiRequest(model: string, body: Record<string, unknown>, stream: boolean): ParsedGeminiRequest;
30
38
  /** Build a complete `generateContent` response body. */
39
+ /**
40
+ * Render a tool call as text.
41
+ *
42
+ * The proxy does not forward tool calls in Google's `functionCall` part shape:
43
+ * the CLI drives tools locally, so a `functionCall` it never asked for would be
44
+ * an unresolvable pending call. Text is what it can act on. Both the streaming
45
+ * serializer and the non-streaming builder go through here so the two paths
46
+ * cannot drift.
47
+ */
48
+ export declare function renderGeminiToolUse(name: string, input: unknown): string;
31
49
  export declare function buildGeminiResponse(text: string, finishReason: string, usage: {
32
50
  input: number;
33
51
  output: number;
34
52
  total: number;
35
- }, modelVersion: string): Record<string, unknown>;
53
+ }, modelVersion: string, toolCalls?: ReadonlyArray<{
54
+ toolName: string;
55
+ args: Record<string, unknown>;
56
+ }>): Record<string, unknown>;
36
57
  /** Google's error envelope, which the CLI parses to classify failures. */
37
58
  export declare function buildGeminiErrorResponse(status: number, message: string, statusText?: string): Response;
38
59
  /**
@@ -40,9 +40,17 @@ function partsToImages(parts) {
40
40
  /**
41
41
  * Parse a `generateContent` body into the shape the translation engine takes.
42
42
  *
43
- * The final user turn becomes `prompt`; everything before it becomes
44
- * `conversationMessages`, with Google's `model` role mapped to `assistant` so
45
- * downstream providers see a role they understand.
43
+ * The final user turn becomes `prompt`, with Google's `model` role mapped to
44
+ * `assistant` so downstream providers see a role they understand.
45
+ *
46
+ * `conversationMessages` carries EVERY turn, the final one included. That
47
+ * looks redundant next to `prompt`, and it is the contract the shared engine
48
+ * expects: `buildTranslationOptions` does `conversationMessages.slice(0, -1)`
49
+ * to derive history, because the final turn is already being sent as `prompt`.
50
+ * `claudeFormat` and `openaiFormat` both push unconditionally for that reason.
51
+ * Excluding the last turn here — the intuitive reading of "history" — made the
52
+ * engine's slice eat one real turn instead, so every multi-turn Gemini request
53
+ * silently lost its most recent message.
46
54
  */
47
55
  export function parseGeminiRequest(model, body, stream) {
48
56
  const contents = Array.isArray(body.contents)
@@ -58,9 +66,9 @@ export function parseGeminiRequest(model, body, stream) {
58
66
  content: partsToText(c?.parts),
59
67
  images: partsToImages(c?.parts),
60
68
  }));
61
- // The last user turn is the prompt; anything before it is history. A request
62
- // whose final turn is a model turn (the CLI does this when continuing) leaves
63
- // an empty prompt rather than replaying the assistant's own words as input.
69
+ // The last user turn is the prompt. A request whose final turn is a model
70
+ // turn (the CLI does this when continuing) leaves an empty prompt rather
71
+ // than replaying the assistant's own words as input.
64
72
  let prompt = "";
65
73
  let images = [];
66
74
  const conversationMessages = [];
@@ -70,12 +78,24 @@ export function parseGeminiRequest(model, body, stream) {
70
78
  prompt = turns[i].content;
71
79
  images = turns[i].images;
72
80
  }
73
- else {
74
- conversationMessages.push({
75
- role: turns[i].role,
76
- content: turns[i].content,
77
- });
78
- }
81
+ // Unconditional — see the slice-contract note on this function.
82
+ conversationMessages.push({
83
+ role: turns[i].role,
84
+ content: turns[i].content,
85
+ });
86
+ }
87
+ // The engine's `slice(0, -1)` drops the LAST entry on the assumption that it
88
+ // is the turn already being sent as `prompt`. That holds only when the
89
+ // request ends with a user turn. The Gemini CLI also continues from a model
90
+ // turn, and there the last entry is a real assistant reply — so the slice ate
91
+ // it, which is the same lost-turn bug one case further along.
92
+ //
93
+ // A terminal placeholder restores the invariant: the slice removes this
94
+ // instead of the model turn. It is never sent anywhere — `prompt` is
95
+ // independently "" in exactly this case, so the placeholder only exists to be
96
+ // consumed by the slice.
97
+ if (turns.length > 0 && turns[turns.length - 1].role !== "user") {
98
+ conversationMessages.push({ role: "user", content: "" });
79
99
  }
80
100
  const numeric = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
81
101
  const stops = generationConfig.stopSequences;
@@ -115,11 +135,31 @@ function usageMetadata(usage) {
115
135
  };
116
136
  }
117
137
  /** Build a complete `generateContent` response body. */
118
- export function buildGeminiResponse(text, finishReason, usage, modelVersion) {
138
+ /**
139
+ * Render a tool call as text.
140
+ *
141
+ * The proxy does not forward tool calls in Google's `functionCall` part shape:
142
+ * the CLI drives tools locally, so a `functionCall` it never asked for would be
143
+ * an unresolvable pending call. Text is what it can act on. Both the streaming
144
+ * serializer and the non-streaming builder go through here so the two paths
145
+ * cannot drift.
146
+ */
147
+ export function renderGeminiToolUse(name, input) {
148
+ return `\n[tool: ${name} ${JSON.stringify(input)}]\n`;
149
+ }
150
+ export function buildGeminiResponse(text, finishReason, usage, modelVersion, toolCalls) {
151
+ // A translated result can legitimately carry tool calls and no text — the
152
+ // engine's hasTranslatedOutput() accepts that. Rendering only `text` there
153
+ // handed the client parts[0].text === "" with finishReason STOP, which reads
154
+ // as "the model answered nothing" rather than "the model wants a tool".
155
+ const rendered = (toolCalls ?? [])
156
+ .map((call) => renderGeminiToolUse(call.toolName, call.args))
157
+ .join("");
158
+ const body = `${text}${rendered}`;
119
159
  return {
120
160
  candidates: [
121
161
  {
122
- content: { role: MODEL_ROLE, parts: [{ text }] },
162
+ content: { role: MODEL_ROLE, parts: [{ text: body }] },
123
163
  finishReason: toGeminiFinishReason(finishReason),
124
164
  index: 0,
125
165
  },
@@ -175,7 +215,7 @@ export class GeminiStreamSerializer {
175
215
  * that is never coming; rendering it as text keeps the turn terminating.
176
216
  */
177
217
  pushToolUse(_id, name, input) {
178
- return this.pushDelta(`\n[tool: ${name} ${JSON.stringify(input)}]\n`);
218
+ return this.pushDelta(renderGeminiToolUse(name, input));
179
219
  }
180
220
  finish(finishReason, usage) {
181
221
  return [
@@ -598,7 +598,7 @@ export async function handleTranslatedJsonRequest(args) {
598
598
  return serializeClaudeResponse(internal, requestModel);
599
599
  }
600
600
  if (format === "gemini") {
601
- return buildGeminiResponse(internal.content, internal.finishReason ?? defaultFinishReason(format), resolvedUsage, internal.model ?? requestModel);
601
+ return buildGeminiResponse(internal.content, internal.finishReason ?? defaultFinishReason(format), resolvedUsage, internal.model ?? requestModel, internal.toolCalls);
602
602
  }
603
603
  return serializeOpenAIResponse(internal, requestModel);
604
604
  }
@@ -184,7 +184,12 @@ export function createGeminiProxyRoutes(modelRouter, basePath = "", _loopbackPor
184
184
  // --- Dispatch via shared translation engine ---
185
185
  try {
186
186
  if (stream) {
187
- return handleTranslatedStreamRequest({
187
+ // Awaited, not returned bare: `handleTranslatedStreamRequest` is
188
+ // async, so a rejection raised before the Response exists would
189
+ // escape this try/catch and land in `app.onError`, which answers
190
+ // in Anthropic's error shape. A Gemini client parsing that finds
191
+ // no `error.message` and reports an empty failure.
192
+ return await handleTranslatedStreamRequest({
188
193
  ctx,
189
194
  format: "gemini",
190
195
  requestModel: modelId,
@@ -2773,15 +2773,6 @@ export type OpenAIErrorResponse = {
2773
2773
  };
2774
2774
  };
2775
2775
  /** Parsed OpenAI request — intermediate form for NeuroLink pipeline. */
2776
- /**
2777
- * A Gemini `generateContent` request, reduced to what translation needs.
2778
- *
2779
- * Google's shape differs from both others in three ways that matter here:
2780
- * roles are `user`/`model` rather than `user`/`assistant`, the system prompt
2781
- * lives in a sibling `systemInstruction` rather than in the turn list, and
2782
- * generation settings are nested under `generationConfig` instead of sitting
2783
- * at the top level.
2784
- */
2785
2776
  /** One part of a Gemini `contents[].parts[]` entry. */
2786
2777
  export type ProxyGeminiPart = {
2787
2778
  text?: string;
@@ -2794,6 +2785,15 @@ export type ProxyGeminiContent = {
2794
2785
  role?: string;
2795
2786
  parts?: ProxyGeminiPart[];
2796
2787
  };
2788
+ /**
2789
+ * A Gemini `generateContent` request, reduced to what translation needs.
2790
+ *
2791
+ * Google's shape differs from both others in three ways that matter here:
2792
+ * roles are `user`/`model` rather than `user`/`assistant`, the system prompt
2793
+ * lives in a sibling `systemInstruction` rather than in the turn list, and
2794
+ * generation settings are nested under `generationConfig` instead of sitting
2795
+ * at the top level.
2796
+ */
2797
2797
  export type ParsedGeminiRequest = {
2798
2798
  model: string;
2799
2799
  maxTokens?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.18.1",
3
+ "version": "11.18.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {