@juspay/neurolink 11.17.3 → 11.18.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/browser/neurolink.min.js +381 -377
  3. package/dist/cli/commands/proxy.js +3 -0
  4. package/dist/cli/proxy-clients/claudeCode.js +3 -3
  5. package/dist/cli/proxy-clients/codex.js +4 -3
  6. package/dist/cli/proxy-clients/copilot.js +6 -6
  7. package/dist/cli/proxy-clients/openCode.js +3 -3
  8. package/dist/cli/proxy-clients/qwenCode.js +3 -3
  9. package/dist/cli/proxy-clients/snapshot.d.ts +51 -0
  10. package/dist/cli/proxy-clients/snapshot.js +97 -0
  11. package/dist/providers/googleNativeGemini3/utils.d.ts +0 -49
  12. package/dist/providers/googleNativeGemini3/utils.js +4 -128
  13. package/dist/proxy/accountLedger.js +42 -1
  14. package/dist/proxy/clientAttribution.d.ts +30 -0
  15. package/dist/proxy/clientAttribution.js +64 -0
  16. package/dist/proxy/geminiFormat.d.ts +77 -0
  17. package/dist/proxy/geminiFormat.js +219 -0
  18. package/dist/proxy/proxyTranslationEngine.d.ts +10 -7
  19. package/dist/proxy/proxyTranslationEngine.js +43 -11
  20. package/dist/server/index.d.ts +1 -1
  21. package/dist/server/index.js +4 -3
  22. package/dist/server/routes/claudeProxyRoutes.js +2 -0
  23. package/dist/server/routes/codexProxyRoutes.js +2 -0
  24. package/dist/server/routes/geminiProxyRoutes.d.ts +89 -0
  25. package/dist/server/routes/geminiProxyRoutes.js +225 -0
  26. package/dist/server/routes/index.d.ts +1 -0
  27. package/dist/server/routes/index.js +6 -0
  28. package/dist/server/routes/openaiProxyRoutes.js +2 -0
  29. package/dist/types/proxy.d.ts +54 -1
  30. package/dist/types/proxyClient.d.ts +18 -0
  31. package/dist/types/server.d.ts +5 -3
  32. package/package.json +1 -1
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Which CLI is calling, from its User-Agent.
3
+ *
4
+ * The proxy already computed a client label for trace spans, but it only
5
+ * distinguished `claude-cli/` from `ai/` and dropped the result into an OTel
6
+ * attribute. Nothing persisted it, so usage could be attributed to an account
7
+ * but never to the tool that spent it — the question a pooled-subscription
8
+ * operator actually asks.
9
+ *
10
+ * Two rules keep this honest as the roster grows:
11
+ *
12
+ * - **Only prefixes observed in real traffic appear here.** A guessed mapping
13
+ * that never matches is indistinguishable from one that works, and silently
14
+ * files a client under the wrong name if the guess collides.
15
+ * - **The raw header is persisted alongside the derived name.** An unrecognised
16
+ * client is then still attributable by its own User-Agent rather than
17
+ * collapsing into one bucket with every other unknown.
18
+ */
19
+ /**
20
+ * Derive a stable client name, or "unknown" when the header is absent or
21
+ * unrecognised. Never throws: attribution must not be able to fail a request.
22
+ */
23
+ export declare function detectProxyClient(userAgent: string | undefined): string;
24
+ /** Read the User-Agent case-insensitively and bound it for storage. */
25
+ export declare function readUserAgent(headers: Record<string, string> | undefined): string | undefined;
26
+ /** The pair every proxy engine attaches to its request log entry. */
27
+ export declare function buildClientAttribution(headers: Record<string, string> | undefined): {
28
+ clientApp: string;
29
+ userAgent?: string;
30
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Which CLI is calling, from its User-Agent.
3
+ *
4
+ * The proxy already computed a client label for trace spans, but it only
5
+ * distinguished `claude-cli/` from `ai/` and dropped the result into an OTel
6
+ * attribute. Nothing persisted it, so usage could be attributed to an account
7
+ * but never to the tool that spent it — the question a pooled-subscription
8
+ * operator actually asks.
9
+ *
10
+ * Two rules keep this honest as the roster grows:
11
+ *
12
+ * - **Only prefixes observed in real traffic appear here.** A guessed mapping
13
+ * that never matches is indistinguishable from one that works, and silently
14
+ * files a client under the wrong name if the guess collides.
15
+ * - **The raw header is persisted alongside the derived name.** An unrecognised
16
+ * client is then still attributable by its own User-Agent rather than
17
+ * collapsing into one bucket with every other unknown.
18
+ */
19
+ /** Longest prefix wins, so a more specific match cannot be shadowed. */
20
+ const CLIENT_PREFIXES = [
21
+ // Verified against this machine's proxy logs.
22
+ ["claude-cli/", "claude-code"],
23
+ // Verified: the AI SDK's own UA, used by NeuroLink's SDK callers.
24
+ ["ai/", "sdk"],
25
+ ];
26
+ /** Cap stored User-Agents. They are attacker-influenced and unbounded. */
27
+ const MAX_USER_AGENT_CHARS = 200;
28
+ /**
29
+ * Derive a stable client name, or "unknown" when the header is absent or
30
+ * unrecognised. Never throws: attribution must not be able to fail a request.
31
+ */
32
+ export function detectProxyClient(userAgent) {
33
+ if (!userAgent) {
34
+ return "unknown";
35
+ }
36
+ const ua = userAgent.trim();
37
+ let best;
38
+ let bestLength = 0;
39
+ for (const [prefix, name] of CLIENT_PREFIXES) {
40
+ if (ua.startsWith(prefix) && prefix.length > bestLength) {
41
+ best = name;
42
+ bestLength = prefix.length;
43
+ }
44
+ }
45
+ return best ?? "unknown";
46
+ }
47
+ /** Read the User-Agent case-insensitively and bound it for storage. */
48
+ export function readUserAgent(headers) {
49
+ if (!headers) {
50
+ return undefined;
51
+ }
52
+ for (const [name, value] of Object.entries(headers)) {
53
+ if (name.toLowerCase() === "user-agent" && typeof value === "string") {
54
+ const trimmed = value.trim();
55
+ return trimmed ? trimmed.slice(0, MAX_USER_AGENT_CHARS) : undefined;
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+ /** The pair every proxy engine attaches to its request log entry. */
61
+ export function buildClientAttribution(headers) {
62
+ const userAgent = readUserAgent(headers);
63
+ return { clientApp: detectProxyClient(userAgent), userAgent };
64
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Google `generateContent` wire format.
3
+ *
4
+ * The Gemini CLI honours `GOOGLE_GEMINI_BASE_URL`, so it can be pointed at this
5
+ * proxy with no vendor cooperation — verified live: with the variable set the
6
+ * CLI reached the proxy and failed with `ModelNotFoundError: 404`, which is the
7
+ * right answer from a proxy that had no route to answer it. This module is the
8
+ * missing half.
9
+ *
10
+ * Three differences from the Claude and OpenAI shapes drive everything here:
11
+ *
12
+ * - Roles are `user` / `model`, not `user` / `assistant`.
13
+ * - The system prompt is a sibling `systemInstruction`, not a turn.
14
+ * - Generation settings nest under `generationConfig`.
15
+ *
16
+ * Token counts also have their own names — `promptTokenCount`,
17
+ * `candidatesTokenCount`, `totalTokenCount` — and the CLI reads them to render
18
+ * its own usage line, so getting them wrong is visible to the user rather than
19
+ * merely wrong in a log.
20
+ */
21
+ import type { ParsedGeminiRequest, StreamSerializerAdapter } from "../types/index.js";
22
+ /**
23
+ * Parse a `generateContent` body into the shape the translation engine takes.
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.
28
+ */
29
+ export declare function parseGeminiRequest(model: string, body: Record<string, unknown>, stream: boolean): ParsedGeminiRequest;
30
+ /** Build a complete `generateContent` response body. */
31
+ export declare function buildGeminiResponse(text: string, finishReason: string, usage: {
32
+ input: number;
33
+ output: number;
34
+ total: number;
35
+ }, modelVersion: string): Record<string, unknown>;
36
+ /** Google's error envelope, which the CLI parses to classify failures. */
37
+ export declare function buildGeminiErrorResponse(status: number, message: string, statusText?: string): Response;
38
+ /**
39
+ * SSE serializer for `streamGenerateContent?alt=sse`.
40
+ *
41
+ * Google streams whole `GenerateContentResponse` objects, one per `data:`
42
+ * frame, rather than the deltas Claude and OpenAI send. Each frame therefore
43
+ * carries a complete `candidates[0].content.parts[0].text` holding only that
44
+ * chunk's text — the CLI concatenates them — and the final frame is the one
45
+ * that carries `finishReason` and `usageMetadata`.
46
+ */
47
+ export declare class GeminiStreamSerializer {
48
+ private readonly model;
49
+ constructor(model: string);
50
+ private frame;
51
+ /** Google sends no preamble frame; the first delta is the first frame. */
52
+ start(): string[];
53
+ pushDelta(text: string): string[];
54
+ /**
55
+ * Tool calls are surfaced as text.
56
+ *
57
+ * The translation engine can emit a tool call, but the Gemini CLI drives its
58
+ * own tools locally and does not expect `functionCall` parts from a plain
59
+ * `generateContent`. Emitting one would make the CLI wait for a tool result
60
+ * that is never coming; rendering it as text keeps the turn terminating.
61
+ */
62
+ pushToolUse(_id: string, name: string, input: unknown): string[];
63
+ finish(finishReason: string, usage: {
64
+ input: number;
65
+ output: number;
66
+ total: number;
67
+ }): string[];
68
+ /**
69
+ * Errors ride the stream as a Google error object.
70
+ *
71
+ * Once headers are sent the status code is spent, so the CLI can only learn
72
+ * about a mid-stream failure from the body.
73
+ */
74
+ emitError(message: string): string[];
75
+ }
76
+ /** Adapter so the translation engine can drive this like the other two. */
77
+ export declare function createGeminiSerializerAdapter(model: string): StreamSerializerAdapter;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Google `generateContent` wire format.
3
+ *
4
+ * The Gemini CLI honours `GOOGLE_GEMINI_BASE_URL`, so it can be pointed at this
5
+ * proxy with no vendor cooperation — verified live: with the variable set the
6
+ * CLI reached the proxy and failed with `ModelNotFoundError: 404`, which is the
7
+ * right answer from a proxy that had no route to answer it. This module is the
8
+ * missing half.
9
+ *
10
+ * Three differences from the Claude and OpenAI shapes drive everything here:
11
+ *
12
+ * - Roles are `user` / `model`, not `user` / `assistant`.
13
+ * - The system prompt is a sibling `systemInstruction`, not a turn.
14
+ * - Generation settings nest under `generationConfig`.
15
+ *
16
+ * Token counts also have their own names — `promptTokenCount`,
17
+ * `candidatesTokenCount`, `totalTokenCount` — and the CLI reads them to render
18
+ * its own usage line, so getting them wrong is visible to the user rather than
19
+ * merely wrong in a log.
20
+ */
21
+ /** Google's role for assistant turns. */
22
+ const MODEL_ROLE = "model";
23
+ function partsToText(parts) {
24
+ if (!Array.isArray(parts)) {
25
+ return "";
26
+ }
27
+ return parts
28
+ .map((p) => (typeof p?.text === "string" ? p.text : ""))
29
+ .filter(Boolean)
30
+ .join("");
31
+ }
32
+ function partsToImages(parts) {
33
+ if (!Array.isArray(parts)) {
34
+ return [];
35
+ }
36
+ return parts
37
+ .map((p) => p?.inlineData?.data)
38
+ .filter((d) => typeof d === "string" && d.length > 0);
39
+ }
40
+ /**
41
+ * Parse a `generateContent` body into the shape the translation engine takes.
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.
46
+ */
47
+ export function parseGeminiRequest(model, body, stream) {
48
+ const contents = Array.isArray(body.contents)
49
+ ? body.contents
50
+ : [];
51
+ const generationConfig = (body.generationConfig ?? {});
52
+ const systemInstruction = body.systemInstruction;
53
+ const systemPrompt = systemInstruction
54
+ ? partsToText(systemInstruction.parts)
55
+ : undefined;
56
+ const turns = contents.map((c) => ({
57
+ role: c?.role === MODEL_ROLE ? "assistant" : "user",
58
+ content: partsToText(c?.parts),
59
+ images: partsToImages(c?.parts),
60
+ }));
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.
64
+ let prompt = "";
65
+ let images = [];
66
+ const conversationMessages = [];
67
+ for (let i = 0; i < turns.length; i += 1) {
68
+ const isLast = i === turns.length - 1;
69
+ if (isLast && turns[i].role === "user") {
70
+ prompt = turns[i].content;
71
+ images = turns[i].images;
72
+ }
73
+ else {
74
+ conversationMessages.push({
75
+ role: turns[i].role,
76
+ content: turns[i].content,
77
+ });
78
+ }
79
+ }
80
+ const numeric = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
81
+ const stops = generationConfig.stopSequences;
82
+ return {
83
+ model,
84
+ maxTokens: numeric(generationConfig.maxOutputTokens),
85
+ temperature: numeric(generationConfig.temperature),
86
+ topP: numeric(generationConfig.topP),
87
+ systemPrompt: systemPrompt || undefined,
88
+ stream,
89
+ prompt,
90
+ images,
91
+ conversationMessages,
92
+ tools: {},
93
+ stopSequences: Array.isArray(stops)
94
+ ? stops.filter((x) => typeof x === "string")
95
+ : undefined,
96
+ };
97
+ }
98
+ /** Google's finishReason vocabulary. */
99
+ function toGeminiFinishReason(reason) {
100
+ switch (reason) {
101
+ case "length":
102
+ case "max_tokens":
103
+ return "MAX_TOKENS";
104
+ case "content_filter":
105
+ return "SAFETY";
106
+ default:
107
+ return "STOP";
108
+ }
109
+ }
110
+ function usageMetadata(usage) {
111
+ return {
112
+ promptTokenCount: usage.input,
113
+ candidatesTokenCount: usage.output,
114
+ totalTokenCount: usage.total || usage.input + usage.output,
115
+ };
116
+ }
117
+ /** Build a complete `generateContent` response body. */
118
+ export function buildGeminiResponse(text, finishReason, usage, modelVersion) {
119
+ return {
120
+ candidates: [
121
+ {
122
+ content: { role: MODEL_ROLE, parts: [{ text }] },
123
+ finishReason: toGeminiFinishReason(finishReason),
124
+ index: 0,
125
+ },
126
+ ],
127
+ usageMetadata: usageMetadata(usage),
128
+ modelVersion,
129
+ };
130
+ }
131
+ /** Google's error envelope, which the CLI parses to classify failures. */
132
+ export function buildGeminiErrorResponse(status, message, statusText = "INVALID_ARGUMENT") {
133
+ return new Response(JSON.stringify({ error: { code: status, message, status: statusText } }), { status, headers: { "content-type": "application/json" } });
134
+ }
135
+ /**
136
+ * SSE serializer for `streamGenerateContent?alt=sse`.
137
+ *
138
+ * Google streams whole `GenerateContentResponse` objects, one per `data:`
139
+ * frame, rather than the deltas Claude and OpenAI send. Each frame therefore
140
+ * carries a complete `candidates[0].content.parts[0].text` holding only that
141
+ * chunk's text — the CLI concatenates them — and the final frame is the one
142
+ * that carries `finishReason` and `usageMetadata`.
143
+ */
144
+ export class GeminiStreamSerializer {
145
+ model;
146
+ constructor(model) {
147
+ this.model = model;
148
+ }
149
+ frame(payload) {
150
+ return `data: ${JSON.stringify(payload)}\n\n`;
151
+ }
152
+ /** Google sends no preamble frame; the first delta is the first frame. */
153
+ start() {
154
+ return [];
155
+ }
156
+ pushDelta(text) {
157
+ if (!text) {
158
+ return [];
159
+ }
160
+ return [
161
+ this.frame({
162
+ candidates: [
163
+ { content: { role: MODEL_ROLE, parts: [{ text }] }, index: 0 },
164
+ ],
165
+ modelVersion: this.model,
166
+ }),
167
+ ];
168
+ }
169
+ /**
170
+ * Tool calls are surfaced as text.
171
+ *
172
+ * The translation engine can emit a tool call, but the Gemini CLI drives its
173
+ * own tools locally and does not expect `functionCall` parts from a plain
174
+ * `generateContent`. Emitting one would make the CLI wait for a tool result
175
+ * that is never coming; rendering it as text keeps the turn terminating.
176
+ */
177
+ pushToolUse(_id, name, input) {
178
+ return this.pushDelta(`\n[tool: ${name} ${JSON.stringify(input)}]\n`);
179
+ }
180
+ finish(finishReason, usage) {
181
+ return [
182
+ this.frame({
183
+ candidates: [
184
+ {
185
+ content: { role: MODEL_ROLE, parts: [{ text: "" }] },
186
+ finishReason: toGeminiFinishReason(finishReason),
187
+ index: 0,
188
+ },
189
+ ],
190
+ usageMetadata: usageMetadata(usage),
191
+ modelVersion: this.model,
192
+ }),
193
+ ];
194
+ }
195
+ /**
196
+ * Errors ride the stream as a Google error object.
197
+ *
198
+ * Once headers are sent the status code is spent, so the CLI can only learn
199
+ * about a mid-stream failure from the body.
200
+ */
201
+ emitError(message) {
202
+ return [
203
+ this.frame({
204
+ error: { code: 500, message, status: "INTERNAL" },
205
+ }),
206
+ ];
207
+ }
208
+ }
209
+ /** Adapter so the translation engine can drive this like the other two. */
210
+ export function createGeminiSerializerAdapter(model) {
211
+ const s = new GeminiStreamSerializer(model);
212
+ return {
213
+ start: () => s.start(),
214
+ pushDelta: (text) => s.pushDelta(text),
215
+ pushToolUse: (id, name, input) => s.pushToolUse(id, name, input),
216
+ finish: (finishReason, usage) => s.finish(finishReason, usage),
217
+ emitError: (message) => s.emitError(message),
218
+ };
219
+ }
@@ -12,7 +12,7 @@
12
12
  * JSON handlers that accept a format discriminator.
13
13
  */
14
14
  import type { ProxyTracer } from "./proxyTracer.js";
15
- import type { ParsedClaudeRequest, ParsedOpenAIRequest, ProxyFormat, ProxyTranslationAttempt, ServerContext } from "../types/index.js";
15
+ import type { ParsedClaudeRequest, ParsedGeminiRequest, ParsedOpenAIRequest, ProxyFormat, ProxyTranslationAttempt, ServerContext } from "../types/index.js";
16
16
  /**
17
17
  * Extract text content from a stream chunk (handles various chunk formats).
18
18
  */
@@ -42,11 +42,14 @@ export declare function detectProxyFormat(path: string, headers: Record<string,
42
42
  * Build options for ctx.neurolink.stream() from a parsed request
43
43
  * and an optional provider/model override.
44
44
  *
45
- * Works for both ParsedClaudeRequest and ParsedOpenAIRequest — the
46
- * only differences are Claude-specific fields (topK, thinkingConfig)
47
- * which are safely absent on OpenAI parsed requests.
45
+ * Works for ParsedClaudeRequest, ParsedOpenAIRequest and
46
+ * ParsedGeminiRequest. The differences are Claude-specific fields (topK,
47
+ * thinkingConfig), safely absent on OpenAI/Gemini parsed requests, and
48
+ * toolChoice/toolChoiceName, absent on Gemini parsed requests — Gemini's
49
+ * parser always emits tools: {} (see geminiFormat.ts's parseGeminiRequest),
50
+ * so toolNames.length is always 0 below and disableTools is set instead.
48
51
  */
49
- export declare function buildTranslationOptions(parsed: ParsedClaudeRequest | ParsedOpenAIRequest, overrides?: {
52
+ export declare function buildTranslationOptions(parsed: ParsedClaudeRequest | ParsedOpenAIRequest | ParsedGeminiRequest, overrides?: {
50
53
  provider?: string;
51
54
  model?: string;
52
55
  }): Record<string, unknown>;
@@ -61,7 +64,7 @@ export declare function handleTranslatedStreamRequest(args: {
61
64
  ctx: ServerContext;
62
65
  format: ProxyFormat;
63
66
  requestModel: string;
64
- parsed: ParsedClaudeRequest | ParsedOpenAIRequest;
67
+ parsed: ParsedClaudeRequest | ParsedOpenAIRequest | ParsedGeminiRequest;
65
68
  attempts: ProxyTranslationAttempt[];
66
69
  tracer?: ProxyTracer;
67
70
  requestStartTime: number;
@@ -73,7 +76,7 @@ export declare function handleTranslatedJsonRequest(args: {
73
76
  ctx: ServerContext;
74
77
  format: ProxyFormat;
75
78
  requestModel: string;
76
- parsed: ParsedClaudeRequest | ParsedOpenAIRequest;
79
+ parsed: ParsedClaudeRequest | ParsedOpenAIRequest | ParsedGeminiRequest;
77
80
  attempts: ProxyTranslationAttempt[];
78
81
  tracer?: ProxyTracer;
79
82
  requestStartTime: number;
@@ -12,6 +12,7 @@
12
12
  * JSON handlers that accept a format discriminator.
13
13
  */
14
14
  import { ClaudeStreamSerializer, generateToolUseId, serializeClaudeResponse, } from "./claudeFormat.js";
15
+ import { buildGeminiResponse, createGeminiSerializerAdapter, } from "./geminiFormat.js";
15
16
  import { generateOpenAIToolCallId, OpenAIStreamSerializer, serializeOpenAIResponse, } from "./openaiFormat.js";
16
17
  import { logRequest } from "./requestLogger.js";
17
18
  import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "./usageStats.js";
@@ -96,6 +97,17 @@ export function detectProxyFormat(path, headers) {
96
97
  if (path.includes("/messages")) {
97
98
  return "claude";
98
99
  }
100
+ // Gemini CLI wire format: POST /v1beta/models/<model>:generateContent
101
+ // (non-streaming) or :streamGenerateContent (streaming, selected via
102
+ // ?alt=sse on ctx.query — not part of ctx.path, so it plays no role here).
103
+ // Checked as two explicit suffixes: "streamGenerateContent" capitalizes
104
+ // the "Generate" it shares with "generateContent", so
105
+ // "streamGenerateContent".includes("generateContent") is false and a
106
+ // single check would miss every streaming request.
107
+ if (path.includes(":generateContent") ||
108
+ path.includes(":streamGenerateContent")) {
109
+ return "gemini";
110
+ }
99
111
  // Header-based fallback
100
112
  if (headers["anthropic-version"]) {
101
113
  return "claude";
@@ -140,9 +152,12 @@ function shouldOmitThinkingConfigForTarget(provider, model) {
140
152
  * Build options for ctx.neurolink.stream() from a parsed request
141
153
  * and an optional provider/model override.
142
154
  *
143
- * Works for both ParsedClaudeRequest and ParsedOpenAIRequest — the
144
- * only differences are Claude-specific fields (topK, thinkingConfig)
145
- * which are safely absent on OpenAI parsed requests.
155
+ * Works for ParsedClaudeRequest, ParsedOpenAIRequest and
156
+ * ParsedGeminiRequest. The differences are Claude-specific fields (topK,
157
+ * thinkingConfig), safely absent on OpenAI/Gemini parsed requests, and
158
+ * toolChoice/toolChoiceName, absent on Gemini parsed requests — Gemini's
159
+ * parser always emits tools: {} (see geminiFormat.ts's parseGeminiRequest),
160
+ * so toolNames.length is always 0 below and disableTools is set instead.
146
161
  */
147
162
  export function buildTranslationOptions(parsed, overrides = {}) {
148
163
  const historyMessages = parsed.conversationMessages.slice(0, -1);
@@ -156,9 +171,14 @@ export function buildTranslationOptions(parsed, overrides = {}) {
156
171
  !shouldOmitThinkingConfigForTarget(overrides.provider, overrides.model)
157
172
  ? claudeParsed.thinkingConfig
158
173
  : undefined;
159
- const toolChoice = parsed.toolChoiceName
160
- ? { type: "tool", toolName: parsed.toolChoiceName }
161
- : parsed.toolChoice;
174
+ // toolChoice/toolChoiceName exist on ParsedClaudeRequest and
175
+ // ParsedOpenAIRequest but not on ParsedGeminiRequest — go through the same
176
+ // Partial-cast pattern as claudeParsed above so a Gemini request just sees
177
+ // both as undefined instead of failing to compile.
178
+ const toolChoiceParsed = parsed;
179
+ const toolChoice = toolChoiceParsed.toolChoiceName
180
+ ? { type: "tool", toolName: toolChoiceParsed.toolChoiceName }
181
+ : toolChoiceParsed.toolChoice;
162
182
  return {
163
183
  input: {
164
184
  text: parsed.prompt,
@@ -224,7 +244,13 @@ function defaultFinishReason(format) {
224
244
  return format === "claude" ? "end_turn" : "stop";
225
245
  }
226
246
  function logTag(format) {
227
- return format === "claude" ? "[proxy]" : "[proxy:openai]";
247
+ if (format === "claude") {
248
+ return "[proxy]";
249
+ }
250
+ if (format === "gemini") {
251
+ return "[proxy:gemini]";
252
+ }
253
+ return "[proxy:openai]";
228
254
  }
229
255
  // ---------------------------------------------------------------------------
230
256
  // Unified streaming handler
@@ -241,7 +267,9 @@ export async function handleTranslatedStreamRequest(args) {
241
267
  const tag = logTag(format);
242
268
  const serializer = format === "claude"
243
269
  ? createClaudeSerializerAdapter(requestModel)
244
- : createOpenAISerializerAdapter(requestModel);
270
+ : format === "gemini"
271
+ ? createGeminiSerializerAdapter(requestModel)
272
+ : createOpenAISerializerAdapter(requestModel);
245
273
  const KEEPALIVE_INTERVAL_MS = 15_000;
246
274
  const encoder = new TextEncoder();
247
275
  let keepAliveTimer;
@@ -566,9 +594,13 @@ export async function handleTranslatedJsonRequest(args) {
566
594
  ...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
567
595
  ...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
568
596
  });
569
- return format === "claude"
570
- ? serializeClaudeResponse(internal, requestModel)
571
- : serializeOpenAIResponse(internal, requestModel);
597
+ if (format === "claude") {
598
+ return serializeClaudeResponse(internal, requestModel);
599
+ }
600
+ if (format === "gemini") {
601
+ return buildGeminiResponse(internal.content, internal.finishReason ?? defaultFinishReason(format), resolvedUsage, internal.model ?? requestModel);
602
+ }
603
+ return serializeOpenAIResponse(internal, requestModel);
572
604
  }
573
605
  catch (attemptError) {
574
606
  lastAttemptError =
@@ -38,7 +38,7 @@ export { createMCPBodyAttachmentMiddleware, fastifyMCPBodyHook, } from "./middle
38
38
  export { createRateLimitMiddleware, createSlidingWindowRateLimitMiddleware, InMemoryRateLimitStore, } from "./middleware/rateLimit.js";
39
39
  export { CommonSchemas, createFieldValidator, createRequestValidationMiddleware, ValidationError, } from "./middleware/validation.js";
40
40
  export { AgentExecuteRequestSchema as OpenAPIAgentExecuteRequestSchema, AgentExecuteResponseSchema, AgentInputSchema, ApiKeySecurityScheme, BasicSecurityScheme, BearerSecurityScheme, CommonParameters, ConversationMessageSchema, createApiInfo, createDeleteOperation, createErrorResponse as createOpenAPIErrorResponse, createGetOperation, createHeaderParameter, createOpenAPIGenerator, createPathParameter, createPostOperation, createQueryParameter, createServer as createOpenAPIServer, createStreamingPostOperation, createStreamingResponse, createSuccessResponse, DefaultServers, ErrorResponseSchema, generateOpenAPIFromConfig, generateOpenAPISpec, HealthResponseSchema, MCPServerStatusSchema, MCPServersListResponseSchema, MCPServerToolSchema, MetricsResponseSchema, NeuroLinkApiInfo, OpenAPIGenerator, OpenAPISchemas, ProviderInfoSchema, ReadyResponseSchema, SessionSchema, SessionsListResponseSchema, StandardErrorResponses, StandardTags, TokenUsageSchema, ToolCallSchema, ToolDefinitionSchema, ToolExecuteRequestSchema as OpenAPIToolExecuteRequestSchema, ToolExecuteResponseSchema, ToolListResponseSchema, ToolParameterSchema, } from "./openapi/index.js";
41
- export { createAgentRoutes, createAllRoutes, createHealthRoutes, createMCPRoutes, createMemoryRoutes, createOpenApiRoutes, createToolRoutes, registerAllRoutes, createClaudeProxyRoutes, createOpenAIProxyRoutes, createCodexProxyRoutes, } from "./routes/index.js";
41
+ export { createAgentRoutes, createAllRoutes, createHealthRoutes, createMCPRoutes, createMemoryRoutes, createOpenApiRoutes, createToolRoutes, registerAllRoutes, createClaudeProxyRoutes, createOpenAIProxyRoutes, createCodexProxyRoutes, createGeminiProxyRoutes, } from "./routes/index.js";
42
42
  export { BaseDataStreamWriter, createDataStreamResponse, createDataStreamWriter, createNDJSONHeaders, createSSEHeaders, DataStreamResponse, formatSSEEvent, pipeAsyncIterableToDataStream, WebStreamWriter, } from "./streaming/index.js";
43
43
  export { ErrorCategory, ErrorSeverity, ServerAdapterErrorCode, } from "../types/index.js";
44
44
  export { createStreamRedactor, redactStreamChunk } from "./utils/redaction.js";
@@ -94,11 +94,12 @@ OpenAPIGenerator, OpenAPISchemas, ProviderInfoSchema, ReadyResponseSchema, Sessi
94
94
  // Routes
95
95
  // ============================================
96
96
  export { createAgentRoutes, createAllRoutes, createHealthRoutes, createMCPRoutes, createMemoryRoutes, createOpenApiRoutes, createToolRoutes, registerAllRoutes,
97
- // The proxy doors. All three were reachable only from the deep path
97
+ // The proxy doors. All were reachable only from the deep path
98
98
  // ./routes/index.js, which is not a package export — so a consumer of
99
99
  // "@juspay/neurolink/server" could reach them only indirectly through
100
- // createAllRoutes' flags, never to mount one on its own.
101
- createClaudeProxyRoutes, createOpenAIProxyRoutes, createCodexProxyRoutes, } from "./routes/index.js";
100
+ // createAllRoutes' flags, never to mount one on its own. Codex stayed
101
+ // CLI-only for long enough on exactly that gap to be worth naming.
102
+ createClaudeProxyRoutes, createOpenAIProxyRoutes, createCodexProxyRoutes, createGeminiProxyRoutes, } from "./routes/index.js";
102
103
  // ============================================
103
104
  // Streaming
104
105
  // ============================================
@@ -30,6 +30,7 @@ import { ProxyTracer, recordFallbackAttempt } from "../../proxy/proxyTracer.js";
30
30
  import { createRawStreamCapture } from "../../proxy/rawStreamCapture.js";
31
31
  import { relocateClientSystemIntoMessages } from "../../proxy/systemRelocation.js";
32
32
  import { logBodyCapture, logRequest, logRequestAttempt, } from "../../proxy/requestLogger.js";
33
+ import { buildClientAttribution } from "../../proxy/clientAttribution.js";
33
34
  import { createSSEInterceptor } from "../../proxy/sseInterceptor.js";
34
35
  import { createStreamTerminalOutcomeTracker, mergeStreamTerminalOutcome, preflightAnthropicStream, } from "../../proxy/streamOutcome.js";
35
36
  import { isPermanentRefreshFailure, needsRefresh, persistTokens, refreshToken, refreshTokenFromLatest, } from "../../proxy/tokenRefresh.js";
@@ -4747,6 +4748,7 @@ function createClaudeRequestRuntimeContext(args) {
4747
4748
  toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
4748
4749
  account: finalAccountLabel ?? "",
4749
4750
  accountType: finalAccountType ?? "",
4751
+ ...buildClientAttribution(ctx.headers),
4750
4752
  responseStatus: status,
4751
4753
  responseTimeMs: Date.now() - requestStartTime,
4752
4754
  ...(errorType ? { errorType } : {}),
@@ -23,6 +23,7 @@ import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from
23
23
  import { loadAccountQuotas, saveAccountQuota, } from "../../proxy/accountQuota.js";
24
24
  import { createCodexUsageTap } from "../../proxy/codexUsage.js";
25
25
  import { CODEX_ACCOUNT_PREFIX, parseCodexRateLimitHeaders, } from "../../proxy/codexAccountUsage.js";
26
+ import { buildClientAttribution } from "../../proxy/clientAttribution.js";
26
27
  import { logRequest } from "../../proxy/requestLogger.js";
27
28
  import { parseRetryAfterMs } from "../../proxy/routingPolicy.js";
28
29
  import { sanitizeForLog } from "../../utils/logSanitize.js";
@@ -252,6 +253,7 @@ async function handleCodexResponsesRequest(ctx) {
252
253
  : 0,
253
254
  account,
254
255
  accountType: "codex-oauth",
256
+ ...buildClientAttribution(ctx.headers),
255
257
  responseStatus,
256
258
  responseTimeMs: Date.now() - requestStartTime,
257
259
  ...extra,