@juspay/neurolink 11.17.3 → 11.18.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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Gemini-Compatible Proxy Routes
3
+ *
4
+ * Exposes the Google `generateContent` / `streamGenerateContent` endpoints the
5
+ * Gemini CLI calls when pointed at this proxy via `GOOGLE_GEMINI_BASE_URL`.
6
+ * ALL requests are routed through ctx.neurolink.stream() via the shared proxy
7
+ * translation engine — no direct HTTP calls to any upstream provider.
8
+ *
9
+ * This is a thin wrapper that parses the Google wire format and delegates to
10
+ * the same translation engine openaiProxyRoutes.ts and claudeProxyRoutes.ts
11
+ * use, mirroring that file's validate → log → delegate shape.
12
+ *
13
+ * An optional ModelRouter can remap incoming model names to different
14
+ * provider/model pairs, exactly as it does for the OpenAI-compatible door.
15
+ */
16
+ import { buildGeminiErrorResponse, parseGeminiRequest, } from "../../proxy/geminiFormat.js";
17
+ import { ProxyTracer } from "../../proxy/proxyTracer.js";
18
+ import { handleTranslatedJsonRequest, handleTranslatedStreamRequest, } from "../../proxy/proxyTranslationEngine.js";
19
+ import { buildProxyTranslationPlan } from "../../proxy/routingPolicy.js";
20
+ import { sanitizeForLog } from "../../utils/logSanitize.js";
21
+ import { logger } from "../../utils/logger.js";
22
+ // Default loopback port — kept only for signature parity with
23
+ // createOpenAIProxyRoutes. Gemini has no Anthropic-loopback bridge (nothing
24
+ // in the Google wire format needs the OAuth-passthrough detour), so the value
25
+ // is threaded through but never read.
26
+ const DEFAULT_LOOPBACK_PORT = 55669;
27
+ const GENERATE = "generateContent";
28
+ const STREAM_GENERATE = "streamGenerateContent";
29
+ // ---------------------------------------------------------------------------
30
+ // Path parsing
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Google bakes the action into the final path segment as `<model>:<action>`
34
+ * (`models/gemini-2.5-pro:generateContent`). Hono's router has no way to
35
+ * express a literal `:` after a named param — verified directly against the
36
+ * `hono` version this repo pins: registering `:model\:generateContent`
37
+ * doesn't escape the colon, it becomes *part of the param name*, and the
38
+ * resulting param still greedily swallows the whole trailing segment
39
+ * (`streamGenerateContent` included) because nothing after the leading `:`
40
+ * stops the capture except the next `/`. A `:model{regex}` constraint
41
+ * followed by literal text fails outright — the route never matches (404 on
42
+ * every request). A bare `:model` segment is therefore the only shape that
43
+ * actually matches; it captures `<model>:<action>` whole, and this function
44
+ * splits it back apart in the handler instead of in the route table.
45
+ */
46
+ /**
47
+ * Pull "<model>:<action>" out of the request path.
48
+ *
49
+ * Read from `ctx.path`, not from a route param. Hono does capture the whole
50
+ * `gemini-2.5-pro:generateContent` segment — the colon is not a route
51
+ * separator, so one route covers both actions — but the proxy's mount loop
52
+ * builds its ServerContext from `path`, `headers`, `query`, `body`, `method`
53
+ * and `requestId` only. The captured param never reaches the handler, and
54
+ * reading `ctx.params` here would be `undefined` on every request.
55
+ */
56
+ function splitModelAction(requestPath) {
57
+ // Take the last path segment, ignoring any trailing slash or query.
58
+ const withoutQuery = requestPath.split("?")[0];
59
+ const rawSegment = withoutQuery.replace(/\/+$/, "").split("/").pop() ?? "";
60
+ const separatorIndex = rawSegment.lastIndexOf(":");
61
+ if (separatorIndex === -1) {
62
+ return { modelId: rawSegment, action: undefined };
63
+ }
64
+ return {
65
+ modelId: rawSegment.slice(0, separatorIndex),
66
+ action: rawSegment.slice(separatorIndex + 1),
67
+ };
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Adapt ParsedGeminiRequest to the shape buildProxyTranslationPlan expects
71
+ // ---------------------------------------------------------------------------
72
+ /**
73
+ * buildProxyTranslationPlan's classifier expects ParsedClaudeRequest, whose
74
+ * `maxTokens` is required. Gemini's is optional (Google omits
75
+ * `generationConfig.maxOutputTokens` on plenty of real requests), so the gap
76
+ * is bridged the same way the OpenAI door bridges it: fill in a safe default
77
+ * and let the (structurally near-identical) rest pass through untouched.
78
+ */
79
+ function adaptGeminiForTranslationPlan(parsed) {
80
+ return {
81
+ model: parsed.model,
82
+ maxTokens: parsed.maxTokens ?? 4096,
83
+ temperature: parsed.temperature,
84
+ topP: parsed.topP,
85
+ systemPrompt: parsed.systemPrompt,
86
+ stream: parsed.stream,
87
+ prompt: parsed.prompt,
88
+ images: parsed.images,
89
+ conversationMessages: parsed.conversationMessages,
90
+ tools: parsed.tools,
91
+ stopSequences: parsed.stopSequences,
92
+ };
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Route factory
96
+ // ---------------------------------------------------------------------------
97
+ /**
98
+ * Create Gemini-compatible proxy routes.
99
+ *
100
+ * Every request flows through ctx.neurolink.stream() — no direct HTTP calls
101
+ * to any upstream provider.
102
+ *
103
+ * @param modelRouter - Optional model router for remapping model names.
104
+ * @param basePath - Base path prefix (default: "").
105
+ * @param loopbackPort - Unused by this door; kept for parameter-shape parity
106
+ * with createOpenAIProxyRoutes (see DEFAULT_LOOPBACK_PORT).
107
+ * @returns RouteGroup with Gemini-compatible endpoints.
108
+ */
109
+ export function createGeminiProxyRoutes(modelRouter, basePath = "", _loopbackPort = DEFAULT_LOOPBACK_PORT, runtimeConfigProvider) {
110
+ return {
111
+ prefix: `${basePath}/v1beta`,
112
+ routes: [
113
+ // =================================================================
114
+ // POST /v1beta/models/:model — covers both `:generateContent` and
115
+ // `:streamGenerateContent`; the action lives inside the captured
116
+ // segment, not in a second route (see splitModelAction above).
117
+ // =================================================================
118
+ {
119
+ method: "POST",
120
+ path: `${basePath}/v1beta/models/:model`,
121
+ description: "Gemini-compatible generateContent / streamGenerateContent (translation mode)",
122
+ handler: async (ctx) => {
123
+ const requestModelRouter = runtimeConfigProvider
124
+ ? runtimeConfigProvider().modelRouter
125
+ : modelRouter;
126
+ const requestStartTime = Date.now();
127
+ // --- Split "<model>:<action>" out of the captured path segment ---
128
+ const { modelId, action } = splitModelAction(ctx.path);
129
+ if (!modelId || (action !== GENERATE && action !== STREAM_GENERATE)) {
130
+ // Mirrors Google's own 404 shape so the CLI's error handling
131
+ // (which parses `error.status`) sees a response it recognizes
132
+ // instead of a bare proxy 404.
133
+ return buildGeminiErrorResponse(404, `Unsupported Gemini endpoint: ${ctx.path}`, "NOT_FOUND");
134
+ }
135
+ const body = ctx.body;
136
+ // --- Validation ---
137
+ if (!body ||
138
+ !Array.isArray(body.contents) ||
139
+ body.contents.length === 0) {
140
+ return buildGeminiErrorResponse(400, "Request must include a non-empty 'contents' array");
141
+ }
142
+ const stream = action === STREAM_GENERATE;
143
+ // --- Resolve target provider/model ---
144
+ const route = requestModelRouter
145
+ ? requestModelRouter.resolve(modelId)
146
+ : { provider: null, model: modelId };
147
+ const targetProvider = route.provider ?? undefined;
148
+ const targetModel = route.model ?? modelId;
149
+ logger.debug(`[proxy:gemini] ${modelId} → ${targetProvider ?? "auto"}/${targetModel}`);
150
+ // --- Parse request ---
151
+ const parsed = parseGeminiRequest(targetModel, body, stream);
152
+ // --- Build translation plan ---
153
+ const adapted = adaptGeminiForTranslationPlan(parsed);
154
+ const plan = buildProxyTranslationPlan({
155
+ provider: targetProvider ?? "auto",
156
+ model: targetModel,
157
+ }, requestModelRouter?.getFallbackChain() ?? [], modelId,
158
+ // The classifier only reads fields present across all three formats.
159
+ adapted, requestModelRouter?.isAutoFallbackEnabled?.() ?? false);
160
+ const attempts = plan.attempts;
161
+ // --- Optional tracing ---
162
+ let tracer;
163
+ try {
164
+ tracer = ProxyTracer.startRequest({
165
+ requestId: ctx.requestId,
166
+ method: ctx.method,
167
+ path: ctx.path,
168
+ model: modelId,
169
+ stream,
170
+ toolCount: Object.keys(parsed.tools).length,
171
+ clientApp: "gemini-compat",
172
+ userAgent: ctx.headers["user-agent"] ?? "",
173
+ // Same reasoning as the OpenAI door: without an explicit
174
+ // provider the tracer defaults to "anthropic" and prices a
175
+ // Gemini model at Claude rates (or $0, off the anthropic
176
+ // table's _default-less lookup).
177
+ provider: targetProvider ?? "google-ai",
178
+ }, ctx.headers);
179
+ tracer.setMode("full");
180
+ }
181
+ catch {
182
+ // Tracing is best-effort; continue without it.
183
+ }
184
+ // --- Dispatch via shared translation engine ---
185
+ try {
186
+ if (stream) {
187
+ return handleTranslatedStreamRequest({
188
+ ctx,
189
+ format: "gemini",
190
+ requestModel: modelId,
191
+ parsed,
192
+ attempts,
193
+ tracer,
194
+ requestStartTime,
195
+ });
196
+ }
197
+ return await handleTranslatedJsonRequest({
198
+ ctx,
199
+ format: "gemini",
200
+ requestModel: modelId,
201
+ parsed,
202
+ attempts,
203
+ tracer,
204
+ requestStartTime,
205
+ });
206
+ }
207
+ catch (err) {
208
+ // Internal exception text (.message + any stack-trace remnants)
209
+ // is kept ONLY in server-side logs + tracer. The client receives
210
+ // a fixed generic message so internal paths/frames don't leak
211
+ // back through the response body. (CodeQL: information exposure
212
+ // through a stack trace.)
213
+ const rawMessage = err instanceof Error ? err.message : String(err);
214
+ const internalDetail = sanitizeForLog(rawMessage);
215
+ logger.always(`[proxy:gemini] request failed: ${internalDetail}`);
216
+ tracer?.setError("generation_error", internalDetail);
217
+ tracer?.end(500, Date.now() - requestStartTime);
218
+ return buildGeminiErrorResponse(500, "Internal proxy error", "INTERNAL");
219
+ }
220
+ },
221
+ },
222
+ ],
223
+ };
224
+ }
225
+ export const __testHooks = { splitModelAction, adaptGeminiForTranslationPlan };
@@ -8,6 +8,7 @@ export { createClaudeProxyRoutes } from "./claudeProxyRoutes.js";
8
8
  export { createHealthRoutes } from "./healthRoutes.js";
9
9
  export { createOpenAIProxyRoutes } from "./openaiProxyRoutes.js";
10
10
  export { createCodexProxyRoutes } from "./codexProxyRoutes.js";
11
+ export { createGeminiProxyRoutes } from "./geminiProxyRoutes.js";
11
12
  export { createMCPRoutes } from "./mcpRoutes.js";
12
13
  export { createMemoryRoutes } from "./memoryRoutes.js";
13
14
  export { createOpenApiRoutes } from "./openApiRoutes.js";
@@ -5,6 +5,7 @@
5
5
  import { createAgentRoutes } from "./agentRoutes.js";
6
6
  import { createClaudeProxyRoutes } from "./claudeProxyRoutes.js";
7
7
  // ClaudeProxyDeps removed
8
+ import { createGeminiProxyRoutes } from "./geminiProxyRoutes.js";
8
9
  import { createHealthRoutes } from "./healthRoutes.js";
9
10
  import { createOpenAIProxyRoutes } from "./openaiProxyRoutes.js";
10
11
  import { createCodexProxyRoutes } from "./codexProxyRoutes.js";
@@ -19,6 +20,7 @@ export { createClaudeProxyRoutes } from "./claudeProxyRoutes.js";
19
20
  export { createHealthRoutes } from "./healthRoutes.js";
20
21
  export { createOpenAIProxyRoutes } from "./openaiProxyRoutes.js";
21
22
  export { createCodexProxyRoutes } from "./codexProxyRoutes.js";
23
+ export { createGeminiProxyRoutes } from "./geminiProxyRoutes.js";
22
24
  export { createMCPRoutes } from "./mcpRoutes.js";
23
25
  export { createMemoryRoutes } from "./memoryRoutes.js";
24
26
  export { createOpenApiRoutes } from "./openApiRoutes.js";
@@ -46,6 +48,7 @@ export function createAllRoutes(basePath = "/api", options) {
46
48
  const enableClaudeProxy = options?.proxy || options?.claudeProxy;
47
49
  const enableOpenAIProxy = options?.proxy || options?.openaiProxy;
48
50
  const enableCodexProxy = options?.proxy || options?.codexProxy;
51
+ const enableGeminiProxy = options?.proxy || options?.geminiProxy;
49
52
  if (enableClaudeProxy) {
50
53
  routes.push(createClaudeProxyRoutes(undefined, basePath));
51
54
  }
@@ -55,6 +58,9 @@ export function createAllRoutes(basePath = "/api", options) {
55
58
  if (enableCodexProxy) {
56
59
  routes.push(createCodexProxyRoutes(basePath));
57
60
  }
61
+ if (enableGeminiProxy) {
62
+ routes.push(createGeminiProxyRoutes(undefined, basePath));
63
+ }
58
64
  return routes;
59
65
  }
60
66
  /**
@@ -14,6 +14,7 @@
14
14
  import { buildOpenAIError, convertClaudeToOpenAIResponse, convertOpenAIToClaudeRequest, createClaudeToOpenAIStreamTransform, parseOpenAIRequest, } from "../../proxy/openaiFormat.js";
15
15
  import { ProxyTracer } from "../../proxy/proxyTracer.js";
16
16
  import { buildModelsListResponse, handleTranslatedJsonRequest, handleTranslatedStreamRequest, } from "../../proxy/proxyTranslationEngine.js";
17
+ import { buildClientAttribution } from "../../proxy/clientAttribution.js";
17
18
  import { logRequest } from "../../proxy/requestLogger.js";
18
19
  import { buildProxyTranslationPlan } from "../../proxy/routingPolicy.js";
19
20
  import { withTimeout } from "../../utils/async/withTimeout.js";
@@ -108,6 +109,7 @@ async function handleOpenAIToAnthropicBridge(args) {
108
109
  toolCount,
109
110
  account: "",
110
111
  accountType: "openai-bridge",
112
+ ...buildClientAttribution(ctx.headers),
111
113
  responseStatus,
112
114
  responseTimeMs: Date.now() - requestStartTime,
113
115
  ...extra,
@@ -576,6 +576,18 @@ export type RequestLogEntry = {
576
576
  * cross-provider model lookup rather than assuming Anthropic.
577
577
  */
578
578
  provider?: string;
579
+ /**
580
+ * Which CLI made the request, derived from User-Agent, and the raw header it
581
+ * was derived from.
582
+ *
583
+ * Both are stored. The derived name is what a dashboard groups on, but the
584
+ * classifier only knows the clients it has seen — keeping the raw header
585
+ * means a client it does not recognise is still attributable rather than
586
+ * collapsing into "unknown" with everything else.
587
+ */
588
+ clientApp?: string;
589
+ /** Raw User-Agent, truncated. See clientApp. */
590
+ userAgent?: string;
579
591
  /** OTel trace ID for correlation with distributed traces */
580
592
  traceId?: string;
581
593
  /** OTel span ID for correlation with distributed traces */
@@ -2609,7 +2621,7 @@ export type StatusStats = {
2609
2621
  /** Sub-action of the `proxy telemetry` CLI command. */
2610
2622
  export type ProxyTelemetryAction = "setup" | "start" | "stop" | "status" | "logs" | "import-dashboard";
2611
2623
  /** Wire format a proxy request is using. */
2612
- export type ProxyFormat = "claude" | "openai";
2624
+ export type ProxyFormat = "claude" | "openai" | "gemini";
2613
2625
  /**
2614
2626
  * Common adapter interface that hides the differences between
2615
2627
  * Claude and OpenAI stream serializers from the unified translation engine.
@@ -2761,6 +2773,47 @@ export type OpenAIErrorResponse = {
2761
2773
  };
2762
2774
  };
2763
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
+ /** One part of a Gemini `contents[].parts[]` entry. */
2786
+ export type ProxyGeminiPart = {
2787
+ text?: string;
2788
+ inlineData?: {
2789
+ data?: string;
2790
+ };
2791
+ };
2792
+ /** One turn in a Gemini `contents[]` array. */
2793
+ export type ProxyGeminiContent = {
2794
+ role?: string;
2795
+ parts?: ProxyGeminiPart[];
2796
+ };
2797
+ export type ParsedGeminiRequest = {
2798
+ model: string;
2799
+ maxTokens?: number;
2800
+ temperature?: number;
2801
+ topP?: number;
2802
+ systemPrompt?: string;
2803
+ stream: boolean;
2804
+ prompt: string;
2805
+ images: string[];
2806
+ conversationMessages: Array<{
2807
+ role: string;
2808
+ content: string;
2809
+ }>;
2810
+ tools: Record<string, {
2811
+ description?: string;
2812
+ inputSchema: unknown;
2813
+ execute?: (...args: unknown[]) => unknown;
2814
+ }>;
2815
+ stopSequences?: string[];
2816
+ };
2764
2817
  export type ParsedOpenAIRequest = {
2765
2818
  model: string;
2766
2819
  maxTokens?: number;
@@ -72,6 +72,22 @@ export type CliAccountUsageTotals = {
72
72
  unpricedRequests: number;
73
73
  /** Distinct models with no pricing row, so an operator can chase them. */
74
74
  unpricedModels: string[];
75
+ /**
76
+ * Same totals split by calling CLI, keyed by the derived client name.
77
+ *
78
+ * Empty for traffic logged before attribution existed — those rows carry no
79
+ * User-Agent, and guessing one retroactively would invent history.
80
+ */
81
+ byClient: Record<string, CliClientUsageTotals>;
82
+ };
83
+ /** Per-CLI slice of an account's usage. See CliAccountUsageTotals.byClient. */
84
+ export type CliClientUsageTotals = {
85
+ requests: number;
86
+ inputTokens: number;
87
+ outputTokens: number;
88
+ cacheReadTokens: number;
89
+ cacheCreationTokens: number;
90
+ costUsd: number;
75
91
  };
76
92
  /** One row of GET /accounts. */
77
93
  export type CliAccountsRow = {
@@ -117,6 +133,8 @@ export type CliAccountsResponse = {
117
133
  /** One request as recorded in the proxy request log, reduced to what costing needs. */
118
134
  export type ProxyLedgerEntry = {
119
135
  account: string;
136
+ /** Derived calling CLI; see CliAccountUsageTotals.byClient. */
137
+ clientApp: string;
120
138
  accountType: string;
121
139
  model: string;
122
140
  provider?: string;
@@ -1058,7 +1058,7 @@ export type OpenAPISpec = {
1058
1058
  export type CreateRoutesOptions = {
1059
1059
  enableSwagger?: boolean;
1060
1060
  getRoutes?: () => RouteDefinition[];
1061
- /** Enable every proxy door: Claude, OpenAI and Codex. */
1061
+ /** Enable every proxy door: Claude, OpenAI, Codex and Gemini. */
1062
1062
  proxy?: boolean;
1063
1063
  claudeProxy?: boolean;
1064
1064
  openaiProxy?: boolean;
@@ -1066,10 +1066,12 @@ export type CreateRoutesOptions = {
1066
1066
  * Enable the Codex door on its own.
1067
1067
  *
1068
1068
  * Codex was reachable only from `neurolink proxy start` until this existed —
1069
- * `createAllRoutes` mounted two of the three doors, so an SDK consumer could
1070
- * not expose it even deliberately.
1069
+ * `createAllRoutes` mounted two of the doors, so an SDK consumer could not
1070
+ * expose it even deliberately.
1071
1071
  */
1072
1072
  codexProxy?: boolean;
1073
+ /** Enable the Gemini door on its own, for the same reason. */
1074
+ geminiProxy?: boolean;
1073
1075
  };
1074
1076
  /** Data stream finish event. */
1075
1077
  export type FinishEvent = DataStreamEvent & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.17.3",
3
+ "version": "11.18.0",
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": {