@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.
- package/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +381 -377
- package/dist/cli/commands/proxy.js +3 -0
- package/dist/cli/proxy-clients/claudeCode.js +3 -3
- package/dist/cli/proxy-clients/codex.js +4 -3
- package/dist/cli/proxy-clients/copilot.js +6 -6
- package/dist/cli/proxy-clients/openCode.js +3 -3
- package/dist/cli/proxy-clients/qwenCode.js +3 -3
- package/dist/cli/proxy-clients/snapshot.d.ts +51 -0
- package/dist/cli/proxy-clients/snapshot.js +97 -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 +2 -0
- 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,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
|
|
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 } : {}),
|
|
@@ -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,
|