@juspay/neurolink 11.17.2 → 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,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 } : {}),
@@ -18,11 +18,12 @@
18
18
  * the full transient-budget / admission machinery.
19
19
  */
20
20
  import { tokenStore } from "../../auth/tokenStore.js";
21
- import { CODEX_ORIGINATOR, CODEX_RESPONSES_URL, CODEX_USER_AGENT, codexTokenNeedsRefresh, isPermanentCodexRefreshFailure, refreshCodexToken, resolveCodexAccountId, } from "../../auth/codexOAuth.js";
21
+ import { CODEX_ORIGINATOR, CODEX_MODELS_URL, CODEX_RESPONSES_URL, CODEX_USER_AGENT, codexTokenNeedsRefresh, isPermanentCodexRefreshFailure, refreshCodexToken, resolveCodexAccountId, } from "../../auth/codexOAuth.js";
22
22
  import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
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,
@@ -452,6 +454,126 @@ async function handleCodexResponsesRequest(ctx) {
452
454
  });
453
455
  return buildCodexErrorResponse(lastErrorStatus, lastErrorMessage);
454
456
  }
457
+ /**
458
+ * Relay Codex model discovery upstream.
459
+ *
460
+ * The CLI refreshes its model list on every invocation. Only `/responses` was
461
+ * registered, so that GET 404'd and the CLI printed a refresh failure before
462
+ * falling back to a default model — quietly ignoring the model the user had
463
+ * configured.
464
+ *
465
+ * This relays rather than synthesises, unlike the Claude and OpenAI `/v1/models`
466
+ * routes, which build their lists locally from the model router. Codex model
467
+ * availability is a property of the upstream account (plan tier, rollout), not
468
+ * of anything this proxy knows, so a synthesised list would be a guess that
469
+ * looks authoritative.
470
+ *
471
+ * Read-only with respect to ROUTING: no cooldown is recorded and no quota is
472
+ * consumed, so a discovery call cannot influence which account real traffic
473
+ * lands on. It is not literally side-effect free — a token refreshed below is
474
+ * persisted, exactly as the proactive refresh in `loadCodexProxyAccounts`
475
+ * persists one. What discovery deliberately never does is *penalise* an
476
+ * account: it cannot cool one and cannot disable one. A read-only probe must
477
+ * not be able to cost the user a login.
478
+ */
479
+ async function handleCodexModelsRequest(ctx) {
480
+ const accounts = await loadCodexProxyAccounts();
481
+ if (accounts.length === 0) {
482
+ return buildCodexErrorResponse(401, "No Codex accounts configured. Run `neurolink auth login codex`.");
483
+ }
484
+ const now = Date.now();
485
+ const ordered = orderCodexAccounts(accounts, now);
486
+ // A cooling account is rate-limited for completions, not barred from
487
+ // answering what models exist. Healthy accounts go first, but a cooling one
488
+ // is still a candidate rather than a reason to fail discovery outright.
489
+ const isCooling = (a) => a.coolingUntil !== undefined && a.coolingUntil > now;
490
+ const candidates = [
491
+ ...ordered.filter((a) => !isCooling(a)),
492
+ ...ordered.filter(isCooling),
493
+ ];
494
+ // Forward the CLI's own query — it sends client_version, and upstream
495
+ // *requires* it: without it ChatGPT answers 400 with a pydantic
496
+ // "Field required" on ('query','client_version'). Rebuild from ctx.query,
497
+ // not ctx.path: path carries no query string, so reading it there silently
498
+ // dropped the parameter and produced exactly that 400.
499
+ const params = new URLSearchParams(ctx.query ?? {});
500
+ const query = params.toString();
501
+ const url = query ? `${CODEX_MODELS_URL}?${query}` : CODEX_MODELS_URL;
502
+ let lastErrorStatus = 502;
503
+ let lastErrorMessage = "Codex model discovery upstream failed";
504
+ for (const account of candidates) {
505
+ // One forced refresh per account, then move on. A token can be rejected
506
+ // upstream while still inside its local expiry window, so relaying that
507
+ // 401 straight back left discovery broken until the token expired locally
508
+ // — the CLI would fall back to a default model on every invocation in the
509
+ // meantime.
510
+ let authRetried = false;
511
+ for (;;) {
512
+ let upstream;
513
+ try {
514
+ upstream = await fetch(url, {
515
+ method: "GET",
516
+ headers: buildCodexUpstreamHeaders(ctx.headers ?? {}, account),
517
+ // Bound the upstream call, as the responses route does. Without a
518
+ // signal a stalled connection holds the proxy request open with no
519
+ // ceiling, and the CLI blocks on model discovery at startup.
520
+ signal: AbortSignal.timeout(CODEX_UPSTREAM_TIMEOUT_MS),
521
+ });
522
+ }
523
+ catch (error) {
524
+ lastErrorStatus = 502;
525
+ lastErrorMessage = `Codex model discovery upstream failed: ${error instanceof Error ? error.message : String(error)}`;
526
+ break; // rotate to the next account
527
+ }
528
+ if (upstream.status === 401 || upstream.status === 403) {
529
+ if (!authRetried && account.refreshToken) {
530
+ authRetried = true;
531
+ try {
532
+ const refreshed = await refreshCodexToken(account.refreshToken);
533
+ account.token = refreshed.accessToken;
534
+ account.refreshToken =
535
+ refreshed.refreshToken ?? account.refreshToken;
536
+ account.expiresAt = refreshed.expiresAt ?? account.expiresAt;
537
+ account.accountId = resolveCodexAccountId(refreshed.accessToken);
538
+ await tokenStore.saveTokens(account.key, {
539
+ accessToken: account.token,
540
+ refreshToken: account.refreshToken,
541
+ expiresAt: account.expiresAt ?? Date.now() + 3_600_000,
542
+ tokenType: "Bearer",
543
+ });
544
+ continue; // retry this account with the fresh token
545
+ }
546
+ catch {
547
+ // No cooldown and no disable, unlike the responses path: the
548
+ // verdict a completion draws from a failed refresh is earned by a
549
+ // request the user actually made. Discovery fires on every CLI
550
+ // invocation, so letting it disable an account would turn a
551
+ // background probe into a forced re-login.
552
+ lastErrorStatus = 401;
553
+ lastErrorMessage = "Codex token refresh failed; re-login required";
554
+ break; // rotate
555
+ }
556
+ }
557
+ lastErrorStatus = upstream.status;
558
+ lastErrorMessage = "Codex model discovery rejected upstream";
559
+ break; // rotate
560
+ }
561
+ // Every other status — including a 400 — is upstream's real answer to a
562
+ // well-formed request and is relayed unchanged. A 400 here means the
563
+ // query was not forwarded correctly, and hiding it behind a retry would
564
+ // bury the exact regression this route was added to fix.
565
+ const body = await upstream.text();
566
+ const contentType = upstream.headers.get("content-type") ?? "application/json";
567
+ return new Response(body, {
568
+ status: upstream.status,
569
+ headers: { "content-type": contentType },
570
+ });
571
+ }
572
+ }
573
+ // A discovery failure must not look like a missing route, or the next
574
+ // person debugging it re-opens this same issue.
575
+ return buildCodexErrorResponse(lastErrorStatus, lastErrorMessage);
576
+ }
455
577
  /**
456
578
  * Create Codex proxy routes.
457
579
  *
@@ -468,6 +590,12 @@ export function createCodexProxyRoutes(basePath = "") {
468
590
  description: "Codex ChatGPT-backend Responses API (account pool)",
469
591
  handler: (ctx) => handleCodexResponsesRequest(ctx),
470
592
  },
593
+ {
594
+ method: "GET",
595
+ path: `${basePath}/backend-api/codex/models`,
596
+ description: "Codex model discovery, relayed upstream (account pool)",
597
+ handler: (ctx) => handleCodexModelsRequest(ctx),
598
+ },
471
599
  ],
472
600
  };
473
601
  }
@@ -0,0 +1,89 @@
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 type { ModelRouterInterface, ParsedGeminiRequest, ProxyRuntimeConfigProvider, RouteGroup } from "../../types/index.js";
17
+ /**
18
+ * Google bakes the action into the final path segment as `<model>:<action>`
19
+ * (`models/gemini-2.5-pro:generateContent`). Hono's router has no way to
20
+ * express a literal `:` after a named param — verified directly against the
21
+ * `hono` version this repo pins: registering `:model\:generateContent`
22
+ * doesn't escape the colon, it becomes *part of the param name*, and the
23
+ * resulting param still greedily swallows the whole trailing segment
24
+ * (`streamGenerateContent` included) because nothing after the leading `:`
25
+ * stops the capture except the next `/`. A `:model{regex}` constraint
26
+ * followed by literal text fails outright — the route never matches (404 on
27
+ * every request). A bare `:model` segment is therefore the only shape that
28
+ * actually matches; it captures `<model>:<action>` whole, and this function
29
+ * splits it back apart in the handler instead of in the route table.
30
+ */
31
+ /**
32
+ * Pull "<model>:<action>" out of the request path.
33
+ *
34
+ * Read from `ctx.path`, not from a route param. Hono does capture the whole
35
+ * `gemini-2.5-pro:generateContent` segment — the colon is not a route
36
+ * separator, so one route covers both actions — but the proxy's mount loop
37
+ * builds its ServerContext from `path`, `headers`, `query`, `body`, `method`
38
+ * and `requestId` only. The captured param never reaches the handler, and
39
+ * reading `ctx.params` here would be `undefined` on every request.
40
+ */
41
+ declare function splitModelAction(requestPath: string): {
42
+ modelId: string;
43
+ action: string | undefined;
44
+ };
45
+ /**
46
+ * buildProxyTranslationPlan's classifier expects ParsedClaudeRequest, whose
47
+ * `maxTokens` is required. Gemini's is optional (Google omits
48
+ * `generationConfig.maxOutputTokens` on plenty of real requests), so the gap
49
+ * is bridged the same way the OpenAI door bridges it: fill in a safe default
50
+ * and let the (structurally near-identical) rest pass through untouched.
51
+ */
52
+ declare function adaptGeminiForTranslationPlan(parsed: ParsedGeminiRequest): {
53
+ model: string;
54
+ maxTokens: number;
55
+ temperature?: number;
56
+ topP?: number;
57
+ systemPrompt?: string;
58
+ stream: boolean;
59
+ prompt: string;
60
+ images: string[];
61
+ conversationMessages: Array<{
62
+ role: string;
63
+ content: string;
64
+ }>;
65
+ tools: Record<string, {
66
+ description?: string;
67
+ inputSchema: unknown;
68
+ execute?: (...args: unknown[]) => unknown;
69
+ }>;
70
+ stopSequences?: string[];
71
+ };
72
+ /**
73
+ * Create Gemini-compatible proxy routes.
74
+ *
75
+ * Every request flows through ctx.neurolink.stream() — no direct HTTP calls
76
+ * to any upstream provider.
77
+ *
78
+ * @param modelRouter - Optional model router for remapping model names.
79
+ * @param basePath - Base path prefix (default: "").
80
+ * @param loopbackPort - Unused by this door; kept for parameter-shape parity
81
+ * with createOpenAIProxyRoutes (see DEFAULT_LOOPBACK_PORT).
82
+ * @returns RouteGroup with Gemini-compatible endpoints.
83
+ */
84
+ export declare function createGeminiProxyRoutes(modelRouter?: ModelRouterInterface, basePath?: string, _loopbackPort?: number, runtimeConfigProvider?: ProxyRuntimeConfigProvider): RouteGroup;
85
+ export declare const __testHooks: {
86
+ splitModelAction: typeof splitModelAction;
87
+ adaptGeminiForTranslationPlan: typeof adaptGeminiForTranslationPlan;
88
+ };
89
+ export {};