@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.
- package/CHANGELOG.md +4 -3
- package/dist/auth/codexOAuth.d.ts +1 -0
- package/dist/auth/codexOAuth.js +1 -0
- package/dist/browser/neurolink.min.js +386 -382
- package/dist/cli/commands/proxy.js +3 -0
- package/dist/providers/googleNativeGemini3/utils.d.ts +0 -49
- package/dist/providers/googleNativeGemini3/utils.js +4 -128
- package/dist/proxy/accountLedger.js +42 -1
- package/dist/proxy/clientAttribution.d.ts +30 -0
- package/dist/proxy/clientAttribution.js +64 -0
- package/dist/proxy/geminiFormat.d.ts +77 -0
- package/dist/proxy/geminiFormat.js +219 -0
- package/dist/proxy/proxyTranslationEngine.d.ts +10 -7
- package/dist/proxy/proxyTranslationEngine.js +43 -11
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +4 -3
- package/dist/server/routes/claudeProxyRoutes.js +2 -0
- package/dist/server/routes/codexProxyRoutes.js +129 -1
- package/dist/server/routes/geminiProxyRoutes.d.ts +89 -0
- package/dist/server/routes/geminiProxyRoutes.js +225 -0
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.js +6 -0
- package/dist/server/routes/openaiProxyRoutes.js +2 -0
- package/dist/types/proxy.d.ts +54 -1
- package/dist/types/proxyClient.d.ts +18 -0
- package/dist/types/server.d.ts +5 -3
- package/package.json +1 -1
|
@@ -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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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 =
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {};
|